refactor: isolate SSE setup
This commit is contained in:
14
README.md
14
README.md
@@ -132,6 +132,20 @@ docker compose down --remove-orphans
|
||||
* Arranca en puerto **80** internamente.
|
||||
* 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 y el servidor los
|
||||
reenvía 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
|
||||
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,26 @@
|
||||
-- Create function and trigger to notify SSE on planilla changes
|
||||
CREATE OR REPLACE FUNCTION notify_planilla_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 planilla_notify_trigger ON "Planilla";
|
||||
CREATE TRIGGER planilla_notify_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "Planilla"
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE notify_planilla_change();
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import { PrismaClient } from './prisma/generated/client/index.js';
|
||||
import { Decimal } from '@prisma/client/runtime/library.js';
|
||||
import cors from 'cors';
|
||||
import { registerSse } from './sse/index.js';
|
||||
|
||||
// Import new routers
|
||||
import empleadosRouter from './routes/empleados/empleados.js';
|
||||
@@ -48,6 +49,9 @@ app.use(cors({
|
||||
origin: ['http://localhost:5173', 'https://planilla.interno.com'],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// --------- Server Sent Events setup ---------
|
||||
registerSse(app);
|
||||
// Mount new routers
|
||||
app.use('/api/empleados', empleadosRouter);
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import { usePlanillasStore } from './stores/usePlanillas'
|
||||
|
||||
import App from './App.vue'
|
||||
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}`);
|
||||
|
||||
const app =
|
||||
createApp(App)
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// iniciar suscripción SSE cuando la app monte
|
||||
const store = usePlanillasStore(pinia)
|
||||
store.subscribeToEvents()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -17,6 +17,7 @@ export const usePlanillasStore = defineStore('planillas', {
|
||||
state: () => ({
|
||||
planillas: [],
|
||||
currentPlanilla: getDefaultCurrentPlanilla(),
|
||||
_sse: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
@@ -82,5 +83,23 @@ 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...')
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user