diff --git a/README.md b/README.md index a653664..9ed4356 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,9 @@ docker compose down --remove-orphans ### Notificaciones en tiempo real (SSE) El backend expone un endpoint `/events` que utiliza **Server-Sent Events**. La -base de datos PostgreSQL emite mensajes mediante triggers y el servidor los -reenvía a los navegadores conectados. +base de datos PostgreSQL emite mensajes mediante triggers en las tablas +`Planilla`, `Cliente`, `TareaRealizada` y `Asistencia`. Cada cambio se +reenvía automáticamente a los navegadores conectados. ```javascript const source = new EventSource('http://localhost:4000/events'); diff --git a/api/prisma/migrations/20250609211800_notify_others_sse/migration.sql b/api/prisma/migrations/20250609211800_notify_others_sse/migration.sql new file mode 100644 index 0000000..af103a9 --- /dev/null +++ b/api/prisma/migrations/20250609211800_notify_others_sse/migration.sql @@ -0,0 +1,42 @@ +-- Triggers for other tables to notify SSE channel + +CREATE OR REPLACE FUNCTION notify_generic_change() +RETURNS trigger AS $$ +DECLARE + payload TEXT; +BEGIN + payload := json_build_object( + 'table', TG_TABLE_NAME, + 'operation', TG_OP, + 'old', row_to_json(OLD), + 'new', row_to_json(NEW) + )::text; + PERFORM pg_notify('sse_events', payload); + IF TG_OP = 'DELETE' THEN + RETURN OLD; + ELSE + RETURN NEW; + END IF; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS cliente_notify_trigger ON "Cliente"; +CREATE TRIGGER cliente_notify_trigger + AFTER INSERT OR UPDATE OR DELETE ON "Cliente" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE PROCEDURE notify_generic_change(); + +DROP TRIGGER IF EXISTS tarea_notify_trigger ON "TareaRealizada"; +CREATE TRIGGER tarea_notify_trigger + AFTER INSERT OR UPDATE OR DELETE ON "TareaRealizada" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE PROCEDURE notify_generic_change(); + +DROP TRIGGER IF EXISTS asistencia_notify_trigger ON "Asistencia"; +CREATE TRIGGER asistencia_notify_trigger + AFTER INSERT OR UPDATE OR DELETE ON "Asistencia" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE PROCEDURE notify_generic_change(); diff --git a/ui/src/main.js b/ui/src/main.js index 101a662..7977e04 100644 --- a/ui/src/main.js +++ b/ui/src/main.js @@ -1,7 +1,7 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import router from './router' -import { usePlanillasStore } from './stores/usePlanillas' +import { useRealtimeStore } from './stores/useRealtime' import App from './App.vue' import './style.css' // Tailwind o tus estilos globales @@ -16,7 +16,7 @@ app.use(pinia) app.use(router) // iniciar suscripción SSE cuando la app monte -const store = usePlanillasStore(pinia) -store.subscribeToEvents() +const realtime = useRealtimeStore(pinia) +realtime.init() app.mount('#app') diff --git a/ui/src/stores/usePlanillas.js b/ui/src/stores/usePlanillas.js index 06d6bea..c56fc0c 100644 --- a/ui/src/stores/usePlanillas.js +++ b/ui/src/stores/usePlanillas.js @@ -17,7 +17,6 @@ export const usePlanillasStore = defineStore('planillas', { state: () => ({ planillas: [], currentPlanilla: getDefaultCurrentPlanilla(), - _sse: null, }), actions: { @@ -83,23 +82,5 @@ export const usePlanillasStore = defineStore('planillas', { clearCurrentPlanilla() { this.currentPlanilla = getDefaultCurrentPlanilla() }, - - subscribeToEvents() { - if (this._sse) return - this._sse = new EventSource('/events') - this._sse.onmessage = (e) => { - try { - const payload = JSON.parse(e.data) - if (payload.table === 'Planilla') { - this.fetchPlanillas() - } - } catch (err) { - console.error('Error procesando SSE', err) - } - } - this._sse.onerror = () => { - console.warn('SSE connection lost, reloading...') - } - }, }, }) diff --git a/ui/src/stores/useRealtime.js b/ui/src/stores/useRealtime.js new file mode 100644 index 0000000..5602b80 --- /dev/null +++ b/ui/src/stores/useRealtime.js @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia' +import { usePlanillasStore } from './usePlanillas' +import { useEmpleadosStore } from './useEmpleados' +import { useTareasStore } from './useTareas' +import { useAsistenciasStore } from './useAsistencias' + +export const useRealtimeStore = defineStore('realtime', { + state: () => ({ + _sse: null, + }), + actions: { + init() { + if (this._sse) return + this._sse = new EventSource('/events') + this._sse.onmessage = (e) => { + try { + const payload = JSON.parse(e.data) + switch (payload.table) { + case 'Planilla': + usePlanillasStore().fetchPlanillas() + break + case 'Cliente': + useEmpleadosStore().fetchEmpleados() + break + case 'TareaRealizada': + useTareasStore().fetchTareas() + break + case 'Asistencia': + useAsistenciasStore().fetchAsistencias() + break + } + } catch (err) { + console.error('Error procesando SSE', err) + } + } + this._sse.onerror = () => { + console.warn('SSE connection lost, reloading...') + } + }, + }, +})