Merge pull request #41 from josedario87/codex/implementar-notificaciones-en-tiempo-real-con-postgresql-y-v
Improve SSE notifications for Planilla
This commit is contained in:
9
Makefile
9
Makefile
@@ -49,4 +49,11 @@ mcp:
|
|||||||
cd mcp && npm install && npm run dev
|
cd mcp && npm install && npm run dev
|
||||||
|
|
||||||
api:
|
api:
|
||||||
cd api && npm install && npm run dev
|
cd api && npm install && npm run dev
|
||||||
|
|
||||||
|
# Creates a new Prisma migration in development mode.
|
||||||
|
# Pass the migration name as an argument, e.g.:
|
||||||
|
# make prisma-migrate-dev name=my-migration-name
|
||||||
|
# If no name is provided, it defaults to "new_migration".
|
||||||
|
prisma-migrate-dev:
|
||||||
|
cd api && npx prisma migrate deploy
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -132,6 +132,21 @@ docker compose down --remove-orphans
|
|||||||
* Arranca en puerto **80** internamente.
|
* Arranca en puerto **80** internamente.
|
||||||
* Código fuente en `ui/src/`, configuración en `vite.config.js`.
|
* Código fuente en `ui/src/`, configuración en `vite.config.js`.
|
||||||
|
|
||||||
|
### 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 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');
|
||||||
|
source.onmessage = (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
console.log('Evento recibido', data);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
#### Card Animation
|
#### Card Animation
|
||||||
The data cards implemented in `ui/src/components/ui/NucleoDataCard.vue` now feature a subtle growing animation when hovered over. This animation is implemented purely with CSS using keyframes and transitions defined within the component's `<style scoped>` section, ensuring the styles are encapsulated and don't affect other elements.
|
The data cards implemented in `ui/src/components/ui/NucleoDataCard.vue` now feature a subtle growing animation when hovered over. This animation is implemented purely with CSS using keyframes and transitions defined within the component's `<style scoped>` section, ensuring the styles are encapsulated and don't affect other elements.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- prisma/migrations/20250609211800_notify_others_sse/migration.sql
|
||||||
|
/* 0️⃣ Limpieza */
|
||||||
|
DROP FUNCTION IF EXISTS notify_planilla_change() CASCADE;
|
||||||
|
DROP FUNCTION IF EXISTS notify_generic_change() CASCADE;
|
||||||
|
|
||||||
|
/* 1️⃣ Función genérica */
|
||||||
|
CREATE OR REPLACE FUNCTION notify_sse_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;
|
||||||
|
|
||||||
|
/* 2️⃣ Drop de triggers viejos (normal o constraint) */
|
||||||
|
DROP TRIGGER IF EXISTS planilla_notify_trigger ON "Planilla";
|
||||||
|
DROP TRIGGER IF EXISTS cliente_notify_trigger ON "Cliente";
|
||||||
|
DROP TRIGGER IF EXISTS tarea_notify_trigger ON "TareaRealizada";
|
||||||
|
DROP TRIGGER IF EXISTS asistencia_notify_trigger ON "Asistencia";
|
||||||
|
|
||||||
|
/* 3️⃣ Constraint triggers DEFERRABLE */
|
||||||
|
CREATE CONSTRAINT TRIGGER planilla_notify_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "Planilla"
|
||||||
|
DEFERRABLE INITIALLY DEFERRED
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE notify_sse_change();
|
||||||
|
|
||||||
|
CREATE CONSTRAINT TRIGGER cliente_notify_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "Cliente"
|
||||||
|
DEFERRABLE INITIALLY DEFERRED
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE notify_sse_change();
|
||||||
|
|
||||||
|
CREATE CONSTRAINT TRIGGER tarea_notify_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "TareaRealizada"
|
||||||
|
DEFERRABLE INITIALLY DEFERRED
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE notify_sse_change();
|
||||||
|
|
||||||
|
CREATE CONSTRAINT TRIGGER asistencia_notify_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "Asistencia"
|
||||||
|
DEFERRABLE INITIALLY DEFERRED
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE notify_sse_change();
|
||||||
@@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import { PrismaClient } from './prisma/generated/client/index.js';
|
import { PrismaClient } from './prisma/generated/client/index.js';
|
||||||
import { Decimal } from '@prisma/client/runtime/library.js';
|
import { Decimal } from '@prisma/client/runtime/library.js';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
import { registerSse } from './sse/index.js';
|
||||||
|
|
||||||
// Import new routers
|
// Import new routers
|
||||||
import empleadosRouter from './routes/empleados/empleados.js';
|
import empleadosRouter from './routes/empleados/empleados.js';
|
||||||
@@ -48,6 +49,9 @@ app.use(cors({
|
|||||||
origin: ['http://localhost:5173', 'https://planilla.interno.com'],
|
origin: ['http://localhost:5173', 'https://planilla.interno.com'],
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --------- Server Sent Events setup ---------
|
||||||
|
registerSse(app);
|
||||||
// Mount new routers
|
// Mount new routers
|
||||||
app.use('/api/empleados', empleadosRouter);
|
app.use('/api/empleados', empleadosRouter);
|
||||||
app.use('/api/asistencias', asistenciasRouter);
|
app.use('/api/asistencias', asistenciasRouter);
|
||||||
|
|||||||
39
api/sse/index.js
Normal file
39
api/sse/index.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
export function registerSse(app) {
|
||||||
|
const sseClients = [];
|
||||||
|
|
||||||
|
app.get('/events', (req, res) => {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive'
|
||||||
|
});
|
||||||
|
res.flushHeaders();
|
||||||
|
res.write(':\n\n');
|
||||||
|
sseClients.push(res);
|
||||||
|
|
||||||
|
res.on('close', () => {
|
||||||
|
const idx = sseClients.indexOf(res);
|
||||||
|
if (idx !== -1) sseClients.splice(idx, 1);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const broadcast = (data) => {
|
||||||
|
const payload = `data: ${data}\n\n`;
|
||||||
|
sseClients.forEach((client) => client.write(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Client: PgClient } = pg;
|
||||||
|
const pgClient = new PgClient({ connectionString: process.env.DATABASE_URL });
|
||||||
|
pgClient.connect()
|
||||||
|
.then(() => pgClient.query('LISTEN sse_events'))
|
||||||
|
.then(() => console.log('🛎️ Listening to Postgres channel sse_events'))
|
||||||
|
.catch((err) => console.error('PG listen error', err));
|
||||||
|
|
||||||
|
pgClient.on('notification', (msg) => {
|
||||||
|
console.log('Notificación Postgres:', msg.payload);
|
||||||
|
broadcast(msg.payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -31,7 +31,8 @@ services:
|
|||||||
container_name: planilla-ui
|
container_name: planilla-ui
|
||||||
image: gitea.interno.com/nucleo000/planilla-ui:latest
|
image: gitea.interno.com/nucleo000/planilla-ui:latest
|
||||||
build: ./ui
|
build: ./ui
|
||||||
restart: unless-stopped
|
environment:
|
||||||
|
VITE_API_EVENTS_URL: http://planilla-api:4000/events
|
||||||
ports:
|
ports:
|
||||||
- "3008:80"
|
- "3008:80"
|
||||||
networks: [planilla, principal]
|
networks: [planilla, principal]
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
# planilla/ui/Dockerfile
|
# Etapa de build de Vite
|
||||||
FROM node:18-alpine AS build
|
FROM node:18-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Etapa final con Nginx
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copia la app compilada
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copia script que genera config.js en base a variables de entorno
|
||||||
|
COPY runtime-env.sh /docker-entrypoint.d/
|
||||||
|
|
||||||
|
# Copia configuración de Nginx para asegurar que sirva correctamente la app
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
4
ui/runtime-env.sh
Normal file
4
ui/runtime-env.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
echo "window.RUNTIME_CONFIG = {" > /usr/share/nginx/html/config.js
|
||||||
|
echo " VITE_API_EVENTS_URL: '${VITE_API_EVENTS_URL}'" >> /usr/share/nginx/html/config.js
|
||||||
|
echo "};" >> /usr/share/nginx/html/config.js
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
// forzar subida
|
// forzar subida
|
||||||
|
|
||||||
const baseURL = 'https://planilla.interno.com'
|
const baseURL = 'http://localhost:4000';
|
||||||
// const baseURL = import.meta.env.API_BASE_URL || 'https://planilla.interno.com'
|
// const baseURL = import.meta.env.API_BASE_URL || 'https://planilla.interno.com'
|
||||||
console.log(baseURL);
|
console.log(baseURL);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { useRealtimeStore } from './stores/useRealtime'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './style.css' // Tailwind o tus estilos globales
|
import './style.css' // Tailwind o tus estilos globales
|
||||||
@@ -8,12 +9,14 @@ import './style.css' // Tailwind o tus estilos globales
|
|||||||
|
|
||||||
// console.log(`API_BASE_URL: ${API_BASE_URL}`);
|
// console.log(`API_BASE_URL: ${API_BASE_URL}`);
|
||||||
|
|
||||||
const app =
|
const app = createApp(App)
|
||||||
createApp(App)
|
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
app.use(pinia)
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
|
// iniciar suscripción SSE cuando la app monte
|
||||||
|
const realtime = useRealtimeStore(pinia)
|
||||||
|
realtime.init()
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -70,16 +70,35 @@ export const useEmpleadosStore = defineStore('empleados', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteEmpleado(id) {
|
async deleteEmpleado(id) {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/api/empleados/${id}`);
|
await apiClient.delete(`/api/empleados/${id}`);
|
||||||
await this.fetchEmpleados();
|
await this.fetchEmpleados();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error eliminando empleado ${id}:`, error);
|
console.error(`Error eliminando empleado ${id}:`, error);
|
||||||
const errorMsg = error.response?.data?.message || error.message || 'Ocurrió un error.';
|
|
||||||
throw errorMsg;
|
const prismaCode = error?.response?.data?.code;
|
||||||
}
|
const constraint = error?.response?.data?.meta?.constraint;
|
||||||
},
|
|
||||||
|
if (prismaCode === 'P2003') {
|
||||||
|
if (constraint === 'Planilla_empleado_id_fkey') {
|
||||||
|
throw 'No se puede eliminar: el empleado tiene planillas asociadas.';
|
||||||
|
}
|
||||||
|
if (constraint === 'Asistencia_empleado_id_fkey') {
|
||||||
|
throw 'No se puede eliminar: el empleado tiene asistencias registradas.';
|
||||||
|
}
|
||||||
|
if (constraint === 'TareaRealizada_empleado_id_fkey') {
|
||||||
|
throw 'No se puede eliminar: el empleado tiene tareas registradas.';
|
||||||
|
}
|
||||||
|
// Si no sabemos qué constraint es
|
||||||
|
throw 'No se puede eliminar: el empleado está referenciado en otros datos.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || 'Ocurrió un error.';
|
||||||
|
throw errorMsg;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
clearCurrentEmpleado() {
|
clearCurrentEmpleado() {
|
||||||
this.currentEmpleado = getDefaultEmpleado();
|
this.currentEmpleado = getDefaultEmpleado();
|
||||||
|
|||||||
49
ui/src/stores/useRealtime.js
Normal file
49
ui/src/stores/useRealtime.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
const eventosURL = import.meta.env.VITE_API_EVENTS_URL || '/events';
|
||||||
|
this._sse = new EventSource(eventosURL);
|
||||||
|
|
||||||
|
this._sse.onopen = () => {
|
||||||
|
console.log('🟢 Conexión SSE establecida correctamente');
|
||||||
|
};
|
||||||
|
|
||||||
|
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...');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user