From ee03f0b36d0154674624eca7122a67ca331ddf8c Mon Sep 17 00:00:00 2001 From: Kilian Date: Wed, 17 Jun 2026 11:12:58 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20configuraci=C3=B3n=20de=20destinatarios?= =?UTF-8?q?=20de=20notificaciones=20desde=20la=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gestiona grupos de email y asignación por escenario en BD (natur_reservas.notification_groups / notification_scenario_recipients) en lugar de variables de entorno hardcodeadas. Incluye pantalla de ajustes, endpoint admin protegido, envío de prueba y niveles reducida/completa. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + apps/api/.env.example | 7 +- apps/api/src/config/env.ts | 10 +- apps/api/src/events/handler.ts | 71 ++-- apps/api/src/events/recipients.ts | 54 +++ apps/api/src/events/types.ts | 7 - apps/api/src/index.ts | 2 + apps/api/src/jobs/runner.ts | 18 +- apps/api/src/middleware/requireAdmin.ts | 41 +++ apps/api/src/routes/notificationConfig.ts | 184 +++++++++++ apps/api/src/routes/users.ts | 42 +-- apps/web/src/App.tsx | 2 + apps/web/src/components/MobileNavigation.tsx | 3 +- .../src/components/NotificationSettings.tsx | 308 ++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 3 +- ...ion-destinatarios-notificaciones-design.md | 211 ++++++++++++ packages/shared/src/index.ts | 3 +- packages/shared/src/types/notification.ts | 40 ++- .../migrations/012_notification_config.sql | 87 +++++ 19 files changed, 991 insertions(+), 103 deletions(-) create mode 100644 apps/api/src/events/recipients.ts create mode 100644 apps/api/src/middleware/requireAdmin.ts create mode 100644 apps/api/src/routes/notificationConfig.ts create mode 100644 apps/web/src/components/NotificationSettings.tsx create mode 100644 docs/superpowers/specs/2026-06-15-configuracion-destinatarios-notificaciones-design.md create mode 100644 supabase/migrations/012_notification_config.sql diff --git a/.gitignore b/.gitignore index 9fb823d..a5c55ed 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ apps/api/preview-out/ *.njsproj *.sln *.sw? +.superpowers/ diff --git a/apps/api/.env.example b/apps/api/.env.example index 1f50f85..ba0e920 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -17,9 +17,10 @@ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... N8N_EMAIL_WEBHOOK_URL=https://n8n.tudominio.com/webhook/naturcalabacera-email EMAIL_FROM=Naturcalabacera -# Destinatarios de notificaciones (parametrizables, sin hardcodear) -NOTIFICATION_EMAIL_TENERIFFA=contacto-teneriffa@ejemplo.com -NOTIFICATION_EMAIL_NATUR=admin@naturcalabacera.com +# Destinatarios de notificaciones (parametrizables) +# Teneriffa admite VARIOS correos separados por coma (correo reducido). +NOTIFICATION_EMAIL_TENERIFFA=uwe.dotschkis@teneriffa2000.de, j.reichstein@teneriffa2000.de +NOTIFICATION_EMAIL_NATUR=administracion@naturcalabacera.com # correo completo NOTIFICATION_EMAIL_POOL_HEATING=jonathan@ejemplo.com # "Jonathan o las chicas" # URL del frontend para CORS diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 213374a..066d185 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -26,10 +26,12 @@ export const env = { N8N_EMAIL_WEBHOOK_URL: required('N8N_EMAIL_WEBHOOK_URL'), EMAIL_FROM: optional('EMAIL_FROM', 'Naturcalabacera '), - // Destinatarios de notificaciones (configurables, sin hardcodear) - NOTIFICATION_EMAIL_TENERIFFA: required('NOTIFICATION_EMAIL_TENERIFFA'), - NOTIFICATION_EMAIL_NATUR: required('NOTIFICATION_EMAIL_NATUR'), - NOTIFICATION_EMAIL_POOL_HEATING: required('NOTIFICATION_EMAIL_POOL_HEATING'), + // DEPRECADO: los destinatarios ahora se gestionan en la BD (tablas notification_groups / + // notification_scenario_recipients) desde la pantalla de la app. Se mantienen como opcionales + // por compatibilidad, pero el motor de envío ya NO los usa. + NOTIFICATION_EMAIL_TENERIFFA: optional('NOTIFICATION_EMAIL_TENERIFFA', ''), + NOTIFICATION_EMAIL_NATUR: optional('NOTIFICATION_EMAIL_NATUR', ''), + NOTIFICATION_EMAIL_POOL_HEATING: optional('NOTIFICATION_EMAIL_POOL_HEATING', ''), // Jobs JOB_RUNNER_INTERVAL_MS: parseInt(optional('JOB_RUNNER_INTERVAL_MS', '300000'), 10), // 5 min diff --git a/apps/api/src/events/handler.ts b/apps/api/src/events/handler.ts index 8e438d5..5a17bf7 100644 --- a/apps/api/src/events/handler.ts +++ b/apps/api/src/events/handler.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { sendEmail } from '../services/email.js'; -import { env } from '../config/env.js'; +import { resolveRecipients, type ResolvedRecipients } from './recipients.js'; import type { Reservation, NotificationEventType } from '@naturcalabacera/shared'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -176,6 +176,7 @@ function renderTeneriffaMinimal( dateRange: string, property: string, cancelled = false, + changesBlock = '', ): string { const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b'; return ` @@ -190,12 +191,15 @@ function renderTeneriffaMinimal(

${dateRange}

Propiedad

${property}

+ ${changesBlock} `; } async function sendCrudPair(opts: { + reducidaEmails: string[]; + completaEmails: string[]; actionLabel: string; subjectTag: string; dateRange: string; @@ -205,28 +209,29 @@ async function sendCrudPair(opts: { originLabel: string; clientName: string; cancelled?: boolean; + /** Tabla de cambios (solo modificaciones). Se muestra también en la versión reducida. */ + changesBlock?: string; }): Promise { - // 1) Correo reducido a Teneriffa (solo fechas + propiedad) - const minHtml = renderTeneriffaMinimal(opts.actionLabel, opts.dateRange, opts.property, opts.cancelled); - const minSubject = `[${opts.subjectTag}] ${opts.dateRange} · ${opts.property}`; - const minRes = await sendEmail({ - to: env.NOTIFICATION_EMAIL_TENERIFFA, - subject: minSubject, - html: minHtml, - }); + let lastFail: SendResult | null = null; - // 2) Correo completo a Natur (HTML con todos los detalles) - const fullHtml = renderTemplate(opts.template, opts.vars); - const fullSubject = `[${opts.subjectTag}] ${opts.originLabel} — ${opts.clientName} | ${opts.property} | ${opts.dateRange}`; - const fullRes = await sendEmail({ - to: env.NOTIFICATION_EMAIL_NATUR, - subject: fullSubject, - html: fullHtml, - }); + // 1) Versión REDUCIDA (fechas + propiedad, y la tabla de cambios si la hay) → grupos nivel 'reducida'. + // Por diseño nunca incluye datos del cliente: imposible filtrar a Teneriffa. + if (opts.reducidaEmails.length > 0) { + const minHtml = renderTeneriffaMinimal(opts.actionLabel, opts.dateRange, opts.property, opts.cancelled, opts.changesBlock ?? ''); + const minSubject = `[${opts.subjectTag}] ${opts.dateRange} · ${opts.property}`; + const r = await sendEmail({ to: opts.reducidaEmails, subject: minSubject, html: minHtml }); + if (!r.success) lastFail = r; + } - if (!minRes.success) return minRes; - if (!fullRes.success) return fullRes; - return { success: true }; + // 2) Versión COMPLETA (HTML con todos los detalles) → grupos nivel 'completa'. + if (opts.completaEmails.length > 0) { + const fullHtml = renderTemplate(opts.template, opts.vars); + const fullSubject = `[${opts.subjectTag}] ${opts.originLabel} — ${opts.clientName} | ${opts.property} | ${opts.dateRange}`; + const r = await sendEmail({ to: opts.completaEmails, subject: fullSubject, html: fullHtml }); + if (!r.success) lastFail = r; + } + + return lastFail ?? { success: true }; } /** @@ -236,7 +241,8 @@ async function sendCrudPair(opts: { export async function handleNotificationEvent( eventType: NotificationEventType, reservation: Reservation, - previousReservation?: Reservation + previousReservation?: Reservation, + overrideRecipients?: ResolvedRecipients ): Promise { const nights = nightsCount(reservation.start_date, reservation.end_date); const dateRange = `${formatDateShort(reservation.start_date)} – ${formatDateShort(reservation.end_date)}`; @@ -261,6 +267,10 @@ export async function handleNotificationEvent( CANCEL_ALERT: '', }; + // Destinatarios configurados para este escenario (desde la BD, no env vars). + // overrideRecipients permite el envío de prueba a una dirección concreta. + const recipients = overrideRecipients ?? await resolveRecipients(eventType); + switch (eventType) { case 'reservation.created': { const isTeenriffa = reservation.origin === 'Teneriffa2000'; @@ -273,6 +283,8 @@ export async function handleNotificationEvent( CHANGES_BLOCK: '', }; return sendCrudPair({ + reducidaEmails: recipients.reducida, + completaEmails: recipients.completa, actionLabel: 'Nueva Reserva', subjectTag: 'NUEVA RESERVA', dateRange, @@ -303,6 +315,8 @@ export async function handleNotificationEvent( CHANGES_BLOCK: changesBlock, }; return sendCrudPair({ + reducidaEmails: recipients.reducida, + completaEmails: recipients.completa, actionLabel: 'Reserva Modificada', subjectTag: 'MODIFICADA', dateRange, @@ -311,6 +325,7 @@ export async function handleNotificationEvent( vars, originLabel, clientName: reservation.client_name, + changesBlock, }); } @@ -326,6 +341,8 @@ export async function handleNotificationEvent( CANCEL_ALERT: `

Esta reserva ha sido cancelada y eliminada del sistema.

`, }; return sendCrudPair({ + reducidaEmails: recipients.reducida, + completaEmails: recipients.completa, actionLabel: 'Reserva Cancelada', subjectTag: 'CANCELADA', dateRange, @@ -338,22 +355,18 @@ export async function handleNotificationEvent( }); } - case 'reservation.reminder_24h': { - const html = renderTemplate('reminder-24h', baseVars); - const subject = `Recordatorio: Check-in mañana — ${reservation.client_name} (Los Dragos)`; - return sendEmail({ to: env.NOTIFICATION_EMAIL_TENERIFFA, subject, html }); - } - case 'reservation.invoice_second_notice': { + if (recipients.all.length === 0) return { success: true }; const html = renderTemplate('invoice-10d', baseVars); const subject = `Segunda factura en 10 días — ${reservation.client_name} (${propertyLabel(reservation.property)})`; - return sendEmail({ to: env.NOTIFICATION_EMAIL_NATUR, subject, html }); + return sendEmail({ to: recipients.all, subject, html }); } case 'reservation.pool_heating_notice': { + if (recipients.all.length === 0) return { success: true }; const html = renderTemplate('pool-heating-48h', baseVars); const subject = `Calefacción de piscina en 48h — ${reservation.client_name} (${propertyLabel(reservation.property)})`; - return sendEmail({ to: env.NOTIFICATION_EMAIL_POOL_HEATING, subject, html }); + return sendEmail({ to: recipients.all, subject, html }); } default: diff --git a/apps/api/src/events/recipients.ts b/apps/api/src/events/recipients.ts new file mode 100644 index 0000000..5e548f1 --- /dev/null +++ b/apps/api/src/events/recipients.ts @@ -0,0 +1,54 @@ +import { supabaseAdmin } from '../lib/supabase.js'; +import type { NotificationEventType } from '@naturcalabacera/shared'; + +export interface ResolvedRecipients { + /** Emails de grupos nivel 'reducida' (solo reciben la versión reducida). */ + reducida: string[]; + /** Emails de grupos nivel 'completa'. */ + completa: string[]; + /** Unión de ambos, para escenarios de plantilla única (factura, piscina). */ + all: string[]; +} + +interface JoinedRow { + notification_groups: { + level: 'reducida' | 'completa'; + emails: string[] | null; + enabled: boolean; + } | null; +} + +/** + * Resuelve los destinatarios de un escenario leyendo la configuración de la BD + * (notification_scenario_recipients + notification_groups). Agrupa por nivel. + * + * Garantía de privacidad: los emails de grupos 'reducida' nunca aparecen en la + * lista 'completa', por lo que es imposible enviarles la versión completa. + */ +export async function resolveRecipients(scenario: NotificationEventType): Promise { + const { data, error } = await supabaseAdmin + .from('notification_scenario_recipients') + .select('notification_groups(level, emails, enabled)') + .eq('scenario', scenario); + + if (error) { + console.error(`[recipients] Error al resolver destinatarios de ${scenario}:`, error.message); + return { reducida: [], completa: [], all: [] }; + } + + const reducida = new Set(); + const completa = new Set(); + + for (const row of (data ?? []) as unknown as JoinedRow[]) { + const g = row.notification_groups; + if (!g || !g.enabled) continue; + const bucket = g.level === 'reducida' ? reducida : completa; + for (const email of g.emails ?? []) { + const clean = email.trim(); + if (clean) bucket.add(clean); + } + } + + const all = new Set([...reducida, ...completa]); + return { reducida: [...reducida], completa: [...completa], all: [...all] }; +} diff --git a/apps/api/src/events/types.ts b/apps/api/src/events/types.ts index ec479eb..dcefad8 100644 --- a/apps/api/src/events/types.ts +++ b/apps/api/src/events/types.ts @@ -33,13 +33,6 @@ export interface EventDescriptor { * Añadir un nuevo tipo de notificación = añadir una entrada aquí. */ export const EVENT_DESCRIPTORS: EventDescriptor[] = [ - { - eventType: 'reservation.reminder_24h', - conditions: { origin: 'Teneriffa2000', property: 'los_dragos' }, - recipientEnvKey: 'NOTIFICATION_EMAIL_TENERIFFA', - templateName: 'reminder-24h', - offsetDays: 1, - }, { eventType: 'reservation.invoice_second_notice', conditions: { origin: 'Naturcalabacera' }, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1793f25..ec2e155 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,6 +3,7 @@ import cors from 'cors'; import { env } from './config/env.js'; import { healthRouter } from './routes/health.js'; import { notificationsRouter } from './routes/notifications.js'; +import { notificationConfigRouter } from './routes/notificationConfig.js'; import { usersRouter } from './routes/users.js'; import { runPendingJobs } from './jobs/runner.js'; @@ -18,6 +19,7 @@ app.use(express.json()); // Rutas app.use('/health', healthRouter); app.use('/api/notifications', notificationsRouter); +app.use('/api/notification-config', notificationConfigRouter); app.use('/api/users', usersRouter); // Arrancar servidor diff --git a/apps/api/src/jobs/runner.ts b/apps/api/src/jobs/runner.ts index 5cd3821..59534ff 100644 --- a/apps/api/src/jobs/runner.ts +++ b/apps/api/src/jobs/runner.ts @@ -113,21 +113,7 @@ export async function scheduleNotificationsForReservation( // El email CRUD se envía directamente en la ruta, no se encola aquí. - // 2. Recordatorio 24h antes — solo Teneriffa + Los Dragos - if (reservation.origin === 'Teneriffa2000' && reservation.property === 'los_dragos') { - const scheduled = new Date(startDate); - scheduled.setUTCHours(scheduled.getUTCHours() - 24); - if (scheduled > new Date()) { - eventsToInsert.push({ - reservation_id: reservation.id, - event_type: 'reservation.reminder_24h', - scheduled_for: scheduled.toISOString(), - status: 'pending', - }); - } - } - - // 3. Segunda factura 10 días antes — solo Natur + // 1. Segunda factura 10 días antes — solo Natur if (reservation.origin === 'Naturcalabacera') { const scheduled = new Date(startDate); scheduled.setUTCDate(scheduled.getUTCDate() - 10); @@ -141,7 +127,7 @@ export async function scheduleNotificationsForReservation( } } - // 4. Aviso calefacción piscina 48h antes + // 2. Aviso calefacción piscina 48h antes if (reservation.has_pool_heating) { const scheduled = new Date(startDate); scheduled.setUTCHours(scheduled.getUTCHours() - 48); diff --git a/apps/api/src/middleware/requireAdmin.ts b/apps/api/src/middleware/requireAdmin.ts new file mode 100644 index 0000000..8f153af --- /dev/null +++ b/apps/api/src/middleware/requireAdmin.ts @@ -0,0 +1,41 @@ +import type { Request, Response, NextFunction } from 'express'; +import { supabaseAdmin } from '../lib/supabase.js'; + +/** + * Middleware: extrae el JWT del header Authorization, valida la sesión con + * Supabase y comprueba que el usuario es admin. Si pasa, deja el id del + * caller en res.locals.callerId. + */ +export async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Bearer ')) { + res.status(401).json({ error: 'Falta token de autenticación' }); + return; + } + const token = auth.slice('Bearer '.length); + + const { data: userData, error: userError } = await supabaseAdmin.auth.getUser(token); + if (userError || !userData.user) { + res.status(401).json({ error: 'Token inválido' }); + return; + } + + const { data: profile, error: profileError } = await supabaseAdmin + .from('user_profiles') + .select('role') + .eq('id', userData.user.id) + .single(); + + if (profileError || !profile) { + res.status(403).json({ error: 'Perfil no encontrado' }); + return; + } + + if (profile.role !== 'admin') { + res.status(403).json({ error: 'Solo admins pueden realizar esta acción' }); + return; + } + + res.locals.callerId = userData.user.id; + next(); +} diff --git a/apps/api/src/routes/notificationConfig.ts b/apps/api/src/routes/notificationConfig.ts new file mode 100644 index 0000000..feb1c71 --- /dev/null +++ b/apps/api/src/routes/notificationConfig.ts @@ -0,0 +1,184 @@ +import { Router, type Router as ExpressRouter } from 'express'; +import { supabaseAdmin } from '../lib/supabase.js'; +import { requireAdmin } from '../middleware/requireAdmin.js'; +import { handleNotificationEvent } from '../events/handler.js'; +import { CONFIGURABLE_SCENARIOS } from '@naturcalabacera/shared'; +import type { NotificationGroup, NotificationEventType, Reservation } from '@naturcalabacera/shared'; + +const router: ExpressRouter = Router(); + +const SCENARIOS = CONFIGURABLE_SCENARIOS.map(s => s.scenario); +const VALID_LEVELS = ['reducida', 'completa']; + +/** + * GET /api/notification-config + * Devuelve los grupos y la asignación de escenarios. + */ +router.get('/', requireAdmin, async (_req, res) => { + const { data: groups, error: gErr } = await supabaseAdmin + .from('notification_groups') + .select('*') + .order('created_at', { ascending: true }); + if (gErr) return res.status(500).json({ error: gErr.message }); + + const { data: rows, error: rErr } = await supabaseAdmin + .from('notification_scenario_recipients') + .select('scenario, group_id'); + if (rErr) return res.status(500).json({ error: rErr.message }); + + const assignments: Record = {}; + for (const r of rows ?? []) { + (assignments[r.scenario] ??= []).push(r.group_id); + } + + return res.json({ groups: groups ?? [], assignments }); +}); + +/** + * PUT /api/notification-config + * Reemplaza el estado completo: grupos + asignaciones. + * Los grupos nuevos llegan con un id temporal "tmp_..."; se mapean a su id real. + */ +router.put('/', requireAdmin, async (req, res) => { + const { groups, assignments } = req.body as { + groups: NotificationGroup[]; + assignments: Record; + }; + + if (!Array.isArray(groups) || typeof assignments !== 'object' || assignments === null) { + return res.status(400).json({ error: 'Formato inválido: se esperan groups[] y assignments{}' }); + } + + // Validación de grupos + for (const g of groups) { + if (!g.name || !g.name.trim()) return res.status(400).json({ error: 'Todos los grupos necesitan nombre' }); + if (!VALID_LEVELS.includes(g.level)) return res.status(400).json({ error: `Nivel inválido en "${g.name}"` }); + if (!Array.isArray(g.emails)) return res.status(400).json({ error: `Emails inválidos en "${g.name}"` }); + } + + const existing = groups.filter(g => g.id && !g.id.startsWith('tmp_')); + const nuevos = groups.filter(g => !g.id || g.id.startsWith('tmp_')); + const now = new Date().toISOString(); + + try { + // 1) Borrar grupos que ya no están (cascade elimina sus asignaciones) + const { data: current, error: curErr } = await supabaseAdmin.from('notification_groups').select('id'); + if (curErr) throw new Error(curErr.message); + const keepIds = existing.map(g => g.id); + const toDelete = (current ?? []).map(c => c.id).filter(id => !keepIds.includes(id)); + if (toDelete.length > 0) { + const { error } = await supabaseAdmin.from('notification_groups').delete().in('id', toDelete); + if (error) throw new Error(error.message); + } + + // 2) Actualizar grupos existentes + for (const g of existing) { + const { error } = await supabaseAdmin + .from('notification_groups') + .update({ + name: g.name.trim(), + level: g.level, + emails: g.emails.map(e => e.trim()).filter(Boolean), + enabled: g.enabled !== false, + updated_at: now, + }) + .eq('id', g.id); + if (error) throw new Error(error.message); + } + + // 3) Insertar grupos nuevos y mapear tmp_id → id real + const idMap: Record = {}; + for (const g of nuevos) { + const { data, error } = await supabaseAdmin + .from('notification_groups') + .insert({ + name: g.name.trim(), + level: g.level, + emails: g.emails.map(e => e.trim()).filter(Boolean), + enabled: g.enabled !== false, + }) + .select('id') + .single(); + if (error) throw new Error(error.message); + if (g.id) idMap[g.id] = data.id; + } + + // 4) Reemplazar asignaciones de los escenarios conocidos + const { error: delErr } = await supabaseAdmin + .from('notification_scenario_recipients') + .delete() + .in('scenario', SCENARIOS); + if (delErr) throw new Error(delErr.message); + + const rows: { scenario: string; group_id: string }[] = []; + for (const [scenario, groupIds] of Object.entries(assignments)) { + if (!SCENARIOS.includes(scenario as NotificationEventType)) continue; + for (const gid of groupIds) { + const realId = idMap[gid] ?? gid; + rows.push({ scenario, group_id: realId }); + } + } + if (rows.length > 0) { + const { error } = await supabaseAdmin.from('notification_scenario_recipients').insert(rows); + if (error) throw new Error(error.message); + } + + return res.json({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Error al guardar'; + console.error('[notification-config] Error al guardar:', message); + return res.status(500).json({ error: message }); + } +}); + +/** + * POST /api/notification-config/test + * Envía un correo de prueba de un escenario con datos ficticios. + * Si se indica testEmail, se envía solo a esa dirección (incluye versión reducida + * y completa para los escenarios duales). Si no, va a los destinatarios reales. + */ +router.post('/test', requireAdmin, async (req, res) => { + const { scenario, testEmail } = req.body as { scenario?: NotificationEventType; testEmail?: string }; + + if (!scenario || !SCENARIOS.includes(scenario)) { + return res.status(400).json({ error: 'Escenario inválido' }); + } + + const fake = buildFakeReservation(); + const previous = scenario === 'reservation.updated' + ? { ...fake, start_date: '2026-10-08', end_date: '2026-10-12' } + : undefined; + + const override = testEmail && testEmail.trim() + ? { reducida: [testEmail.trim()], completa: [testEmail.trim()], all: [testEmail.trim()] } + : undefined; + + const result = await handleNotificationEvent(scenario, fake, previous, override); + if (!result.success) { + return res.status(502).json({ error: result.error ?? 'Fallo al enviar la prueba' }); + } + return res.json({ ok: true }); +}); + +/** Reserva ficticia coherente para los correos de prueba. */ +function buildFakeReservation(): Reservation { + return { + id: 'test-0000', + client_name: 'Cliente de Prueba', + start_date: '2026-10-15', + end_date: '2026-10-20', + property: 'los_dragos', + origin: 'Naturcalabacera', + adults_count: 2, + children_count: 2, + has_cleaning: true, + has_pool_heating: true, + has_flies_products: false, + government_registration: null, + observations: 'Reserva de prueba generada desde la pantalla de configuración.', + invoice_number: null, + is_event: false, + } as unknown as Reservation; +} + +export { router as notificationConfigRouter }; diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 82a909d..5fc09e7 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -1,50 +1,12 @@ -import { Router, type Request, type Response, type NextFunction, type Router as ExpressRouter } from 'express'; +import { Router, type Router as ExpressRouter } from 'express'; import { supabaseAdmin } from '../lib/supabase.js'; +import { requireAdmin } from '../middleware/requireAdmin.js'; import type { UserRole } from '@naturcalabacera/shared'; const router: ExpressRouter = Router(); const VALID_ROLES: UserRole[] = ['admin', 'internal_staff', 'external_availability_viewer']; -/** - * Middleware: extrae el JWT del header Authorization, valida la sesión con - * Supabase y comprueba que el usuario es admin. Si pasa, deja el id del - * caller en res.locals.callerId. - */ -async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise { - const auth = req.headers.authorization; - if (!auth || !auth.startsWith('Bearer ')) { - res.status(401).json({ error: 'Falta token de autenticación' }); - return; - } - const token = auth.slice('Bearer '.length); - - const { data: userData, error: userError } = await supabaseAdmin.auth.getUser(token); - if (userError || !userData.user) { - res.status(401).json({ error: 'Token inválido' }); - return; - } - - const { data: profile, error: profileError } = await supabaseAdmin - .from('user_profiles') - .select('role') - .eq('id', userData.user.id) - .single(); - - if (profileError || !profile) { - res.status(403).json({ error: 'Perfil no encontrado' }); - return; - } - - if (profile.role !== 'admin') { - res.status(403).json({ error: 'Solo admins pueden gestionar usuarios' }); - return; - } - - res.locals.callerId = userData.user.id; - next(); -} - // GET /api/users — listar todos los perfiles router.get('/', requireAdmin, async (_req, res) => { const { data, error } = await supabaseAdmin diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 32cf611..9e04734 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,7 @@ import { SearchBar } from './components/SearchBar'; import { SettingsPage } from './components/SettingsPage'; import { YearlyCalendar } from './components/YearlyCalendar'; import { UserManagement } from './components/UserManagement'; +import { NotificationSettings } from './components/NotificationSettings'; import { LoginPage } from './components/LoginPage'; import { ChatbotContainer } from './components/ChatbotContainer'; import { useAuth } from './hooks/useAuth'; @@ -196,6 +197,7 @@ function AppContent() { /> )} {currentView === 'users' && isAdmin && } + {currentView === 'notifications' && isAdmin && } diff --git a/apps/web/src/components/MobileNavigation.tsx b/apps/web/src/components/MobileNavigation.tsx index 8c1acc8..c8f076e 100644 --- a/apps/web/src/components/MobileNavigation.tsx +++ b/apps/web/src/components/MobileNavigation.tsx @@ -1,4 +1,4 @@ -import { Calendar, Settings, CalendarDays, Home, Users } from 'lucide-react'; +import { Calendar, Settings, CalendarDays, Home, Users, Bell } from 'lucide-react'; import { useProperty } from '../contexts/PropertyContext'; import { PROPERTY_CONFIG, PROPERTIES } from '@naturcalabacera/shared'; import type { Property } from '@naturcalabacera/shared'; @@ -16,6 +16,7 @@ export function MobileNavigation({ currentView, onNavigate, isViewer = false, is { id: 'calendar', label: 'Mensual', icon: Calendar, requires: 'all' as const }, { id: 'yearly', label: 'Anual', icon: CalendarDays, requires: 'all' as const }, { id: 'users', label: 'Usuarios', icon: Users, requires: 'admin' as const }, + { id: 'notifications', label: 'Avisos', icon: Bell, requires: 'admin' as const }, { id: 'settings', label: 'Ajustes', icon: Settings, requires: 'staff' as const }, ]; const menuItems = allMenuItems.filter(item => { diff --git a/apps/web/src/components/NotificationSettings.tsx b/apps/web/src/components/NotificationSettings.tsx new file mode 100644 index 0000000..789c9ad --- /dev/null +++ b/apps/web/src/components/NotificationSettings.tsx @@ -0,0 +1,308 @@ +import { useEffect, useState } from 'react'; +import { Loader2, Save, Plus, Trash2, X, Send, Lock, Bell, AlertCircle } from 'lucide-react'; +import { toast } from 'sonner'; +import { supabase } from '../lib/supabase'; +import { CONFIGURABLE_SCENARIOS } from '@naturcalabacera/shared'; +import type { NotificationGroup, NotificationLevel } from '@naturcalabacera/shared'; + +const apiUrl = import.meta.env.VITE_API_URL; + +async function authFetch(path: string, init: RequestInit = {}): Promise { + const { data } = await supabase.auth.getSession(); + const token = data.session?.access_token; + const headers = new Headers(init.headers); + headers.set('Content-Type', 'application/json'); + if (token) headers.set('Authorization', `Bearer ${token}`); + return fetch(`${apiUrl}${path}`, { ...init, headers }); +} + +const newTmpId = () => `tmp_${crypto.randomUUID()}`; + +export function NotificationSettings() { + const [groups, setGroups] = useState([]); + const [assignments, setAssignments] = useState>({}); + const [emailDraft, setEmailDraft] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [testing, setTesting] = useState(null); + + useEffect(() => { void load(); }, []); + + async function load() { + setLoading(true); + setError(null); + try { + const res = await authFetch('/api/notification-config'); + if (!res.ok) { + const body = await res.json().catch(() => ({} as { error?: string })); + throw new Error(body?.error ?? `Error ${res.status}`); + } + const body = await res.json() as { groups: NotificationGroup[]; assignments: Record }; + setGroups(body.groups); + setAssignments(body.assignments ?? {}); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar la configuración'); + } finally { + setLoading(false); + } + } + + // ── Grupos ── + const patchGroup = (id: string, patch: Partial) => + setGroups(gs => gs.map(g => (g.id === id ? { ...g, ...patch } : g))); + + const addGroup = () => + setGroups(gs => [...gs, { id: newTmpId(), name: 'Nuevo grupo', level: 'completa', emails: [], enabled: true }]); + + const removeGroup = (id: string) => { + setGroups(gs => gs.filter(g => g.id !== id)); + setAssignments(a => { + const next: Record = {}; + for (const [sc, ids] of Object.entries(a)) next[sc] = ids.filter(x => x !== id); + return next; + }); + }; + + const addEmail = (groupId: string) => { + const raw = (emailDraft[groupId] ?? '').trim(); + if (!raw) return; + patchGroup(groupId, { emails: [...(groups.find(g => g.id === groupId)?.emails ?? []), raw] }); + setEmailDraft(d => ({ ...d, [groupId]: '' })); + }; + + const removeEmail = (groupId: string, email: string) => { + const g = groups.find(x => x.id === groupId); + if (g) patchGroup(groupId, { emails: g.emails.filter(e => e !== email) }); + }; + + // ── Asignaciones ── + const isAssigned = (scenario: string, groupId: string) => (assignments[scenario] ?? []).includes(groupId); + + const toggleAssignment = (scenario: string, groupId: string) => + setAssignments(a => { + const current = a[scenario] ?? []; + const next = current.includes(groupId) ? current.filter(x => x !== groupId) : [...current, groupId]; + return { ...a, [scenario]: next }; + }); + + // ── Guardar ── + async function save() { + setSaving(true); + // Incorpora los emails escritos en el casillero pero aún no confirmados con Enter/+, + // para que no se pierdan si el usuario pulsa Guardar directamente. + const groupsToSave = groups.map(g => { + const draft = (emailDraft[g.id] ?? '').trim(); + if (!draft || g.emails.includes(draft)) return g; + return { ...g, emails: [...g.emails, draft] }; + }); + try { + const res = await authFetch('/api/notification-config', { + method: 'PUT', + body: JSON.stringify({ groups: groupsToSave, assignments }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({} as { error?: string })); + throw new Error(body?.error ?? `Error ${res.status}`); + } + setEmailDraft({}); + toast.success('Configuración guardada'); + await load(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Error al guardar'); + } finally { + setSaving(false); + } + } + + // ── Enviar prueba ── + async function sendTest(scenario: string) { + const testEmail = window.prompt( + 'Email de prueba (déjalo vacío para enviar a los destinatarios reales configurados):', + '', + ); + if (testEmail === null) return; // cancelado + setTesting(scenario); + try { + const res = await authFetch('/api/notification-config/test', { + method: 'POST', + body: JSON.stringify({ scenario, testEmail: testEmail.trim() || undefined }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({} as { error?: string })); + throw new Error(body?.error ?? `Error ${res.status}`); + } + toast.success(testEmail.trim() ? `Prueba enviada a ${testEmail.trim()}` : 'Prueba enviada a los destinatarios configurados'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Error al enviar la prueba'); + } finally { + setTesting(null); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ +
+
+

Notificaciones

+

Quién recibe cada correo y con qué nivel de información.

+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* ── Grupos ── */} +
+

Grupos de destinatarios

+ {groups.map(g => ( +
+
+ patchGroup(g.id, { name: e.target.value })} + className="flex-1 min-w-[160px] bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2 text-white font-bold focus:outline-none focus:ring-2 focus:ring-emerald-500/40" + /> + + + +
+ + {g.level === 'reducida' && ( +

+ Solo recibe la versión reducida (sin datos del cliente). +

+ )} + +
+ {g.emails.map(email => ( + + {email} + + + ))} +
+ setEmailDraft(d => ({ ...d, [g.id]: e.target.value }))} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addEmail(g.id); } }} + placeholder="añadir email…" + className="bg-slate-800/60 border border-slate-700 rounded-full px-3 py-1 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/40" + /> + +
+
+
+ ))} + +
+ + {/* ── Asignación ── */} +
+

Qué grupo recibe cada escenario

+
+ {CONFIGURABLE_SCENARIOS.map(sc => ( +
+
+

{sc.label}

+ {sc.dualVersion && ( +

Versión según el nivel del grupo (reducida/completa)

+ )} +
+
+ {groups.map(g => ( + + ))} + +
+
+ ))} +
+
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 501b319..7c86790 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { Calendar, Settings, CalendarDays, Users } from 'lucide-react'; +import { Calendar, Settings, CalendarDays, Users, Bell } from 'lucide-react'; import { PropertySelector } from './PropertySelector'; import { usePropertyTheme } from '../hooks/usePropertyTheme'; @@ -16,6 +16,7 @@ export function Sidebar({ currentView, onNavigate, isViewer = false, isAdmin = f { id: 'calendar', label: 'Mensual', icon: Calendar, requires: 'all' as const }, { id: 'yearly', label: 'Anual', icon: CalendarDays, requires: 'all' as const }, { id: 'users', label: 'Usuarios', icon: Users, requires: 'admin' as const }, + { id: 'notifications', label: 'Notificaciones', icon: Bell, requires: 'admin' as const }, { id: 'settings', label: 'Ajustes', icon: Settings, requires: 'staff' as const }, ]; const menuItems = allMenuItems.filter(item => { diff --git a/docs/superpowers/specs/2026-06-15-configuracion-destinatarios-notificaciones-design.md b/docs/superpowers/specs/2026-06-15-configuracion-destinatarios-notificaciones-design.md new file mode 100644 index 0000000..48fc2d2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-configuracion-destinatarios-notificaciones-design.md @@ -0,0 +1,211 @@ +# Configuración de destinatarios de notificaciones — Diseño + +**Fecha:** 2026-06-15 +**Estado:** Aprobado para implementación (pendiente revisión final del spec) + +--- + +## 1. Problema + +Hoy, "quién recibe cada correo" se define con variables de entorno en Dokploy +(`NOTIFICATION_EMAIL_TENERIFFA`, `NOTIFICATION_EMAIL_NATUR`, +`NOTIFICATION_EMAIL_POOL_HEATING`). Esto es: + +- **Rígido**: cualquier cambio exige editar Dokploy y redeplegar. +- **Invisible**: el cliente no ve ni controla a quién llega cada cosa. +- **Frágil/inseguro**: nada impide mandar información completa (datos del cliente) a + un destinatario que solo debería recibir información reducida (Teneriffa, por + acuerdo de privacidad). + +## 2. Objetivo + +Mover la configuración de destinatarios a la base de datos y gestionarla desde una +**pantalla nueva dentro de la app** (solo admin). El motor de envío deja de leer las +env vars y lee esta configuración. + +**Regla dura de privacidad:** a un destinatario marcado como "reducida" es +**imposible** enviarle la versión completa, por diseño del modelo. + +### No-objetivos + +- No se rediseña el formato visual de los emails: **las plantillas HTML actuales se + conservan tal cual** (es justo lo que al cliente le gusta). +- No se añade gestión de plantillas desde la UI (las plantillas siguen en disco). + +## 3. Modelo de datos (Supabase, schema `natur_reservas`) + +> **CRÍTICO:** todo el SQL se cualifica con `natur_reservas.` (instancia Supabase +> compartida; `public` es otro proyecto). Ver CLAUDE.md §1. + +### Tabla `natur_reservas.notification_groups` + +Un grupo de destinatarios con un nivel de información fijo. + +| Columna | Tipo | Notas | +|---|---|---| +| `id` | uuid PK | `gen_random_uuid()` | +| `name` | text NOT NULL | Ej. "Teneriffa", "Naturcalabacera (Admin)", "Limpieza" | +| `level` | text NOT NULL | `CHECK (level IN ('reducida','completa'))`, default `'completa'` | +| `emails` | text[] NOT NULL | default `'{}'`; lista de correos del grupo | +| `enabled` | boolean NOT NULL | default `true` | +| `created_at` | timestamptz | default `now()` | +| `updated_at` | timestamptz | default `now()` | + +### Tabla `natur_reservas.notification_scenario_recipients` + +Qué grupos recibe cada escenario (relación N:M escenario ↔ grupo). + +| Columna | Tipo | Notas | +|---|---|---| +| `id` | uuid PK | `gen_random_uuid()` | +| `scenario` | text NOT NULL | Igual que `event_type` (ver §5) | +| `group_id` | uuid NOT NULL | `REFERENCES natur_reservas.notification_groups(id) ON DELETE CASCADE` | +| `created_at` | timestamptz | default `now()` | +| — | — | `UNIQUE (scenario, group_id)` | + +### RLS + +Solo `admin` gestiona ambas tablas (mismo patrón que `notification_events` en la +migración 009: política `FOR ALL` con `get_user_role() = 'admin'`). El backend usa la +`service_role` key, que bypasea RLS. + +### Migración + +Archivo nuevo `supabase/migrations/012_notification_config.sql` que crea las dos +tablas, RLS y la **semilla** del §6. + +## 4. Motor de envío (backend) + +`apps/api/src/events/handler.ts` deja de leer `env.NOTIFICATION_EMAIL_*`. En su lugar, +una función nueva resuelve destinatarios desde la BD por escenario: + +``` +resolveRecipients(scenario) -> + SELECT g.level, g.emails + FROM natur_reservas.notification_scenario_recipients r + JOIN natur_reservas.notification_groups g ON g.id = r.group_id + WHERE r.scenario = $scenario AND g.enabled = true +``` + +Reglas de uso: + +- **Escenarios de reserva con doble versión** (`created`, `updated`, `cancelled`): + - Emails de grupos `level = 'reducida'` → se envía la **plantilla reducida** + (`renderTeneriffaMinimal`). Un envío con todos esos emails en `to`. + - Emails de grupos `level = 'completa'` → se envía la **plantilla completa** + (`natur-crud.html`). Un envío con todos esos emails en `to`. + - Si una de las dos listas está vacía, ese envío simplemente no ocurre. +- **Escenarios de plantilla única** (`invoice_second_notice`, `pool_heating_notice`): + - Se juntan todos los emails de todos los grupos asignados (sin importar nivel, + porque la plantilla es fija) → un envío con la plantilla del escenario. +- Si un escenario no tiene grupos asignados, **no se envía nada** y se registra en log + (`console.warn`) para diagnóstico. + +El resto del flujo (render de plantilla, `sendEmail` → webhook n8n → Gmail) **no +cambia**. El webhook y el formato siguen igual. + +### Compatibilidad con env vars + +Tras esta migración, `NOTIFICATION_EMAIL_TENERIFFA/_NATUR/_POOL_HEATING` dejan de +usarse. Se eliminan del `required()` en `apps/api/src/config/env.ts` para que la app no +dependa de ellas. (Se pueden retirar también de Dokploy, pero no es necesario.) + +## 5. Escenarios (mapa de `event_type` → plantilla) + +| Escenario (`event_type`) | Disparo | Plantilla(s) | Doble versión | +|---|---|---|---| +| `reservation.created` | Inmediato (al guardar) | reducida + completa | Sí | +| `reservation.updated` | Inmediato (solo si cambian fechas/horas) | reducida + completa | Sí | +| `reservation.cancelled` | Inmediato | reducida + completa | Sí | +| `reservation.invoice_second_notice` | Programado 10 días antes | `invoice-10d` | No | +| `reservation.pool_heating_notice` | Programado 48h antes | `pool-heating-48h` | No | + +> El correo de check-in 24h (`reservation.reminder_24h`) ya fue eliminado del sistema +> en una iteración previa. + +## 6. Semilla inicial + +**Grupos:** + +| name | level | emails | +|---|---|---| +| Teneriffa | `reducida` | `uwe.dotschkis@teneriffa2000.de`, `j.reichstein@teneriffa2000.de` | +| Naturcalabacera (Admin) | `completa` | `administracion@naturcalabacera.com` | +| Limpieza | `completa` | `limpieza.naturcalabacera@gmail.com` *(placeholder editable)* | + +**Asignación escenario → grupos:** + +| Escenario | Teneriffa | Admin | Limpieza | +|---|---|---|---| +| `reservation.created` | ✅ | ✅ | — | +| `reservation.updated` | ✅ | ✅ | — | +| `reservation.cancelled` | ✅ | ✅ | — | +| `reservation.invoice_second_notice` | — | ✅ | — | +| `reservation.pool_heating_notice` | — | — | ✅ | + +(El email de Limpieza y la posible copia de piscina para Admin se ajustan desde la +pantalla una vez montada.) + +## 7. Pantalla (frontend) + +Nueva vista **"Notificaciones"**, accesible **solo para rol `admin`**. + +- **Navegación:** añadir entrada en `Sidebar.tsx` y `MobileNavigation.tsx` + (`requires: 'admin'`), y el render condicional en `App.tsx` + (`currentView === 'notifications' && isAdmin`). +- **Componente** `apps/web/src/components/NotificationSettings.tsx` con hook + `useNotificationConfig` que lee/escribe las dos tablas vía el cliente Supabase + (schema `natur_reservas`), siguiendo el patrón de `useReservations` / + `UserManagement`. +- **Bloque 1 — Grupos:** lista editable de grupos. Por grupo: nombre, selector de + nivel (`reducida`/`completa`), chips de emails (añadir/quitar), activar/desactivar, + borrar. Botón "+ Nuevo grupo". +- **Bloque 2 — Asignación:** por cada escenario, chips de los grupos; click para + activar/desactivar qué grupos lo reciben. +- **Guardar:** persiste cambios (upsert de grupos y de asignaciones). +- **Estética:** Tailwind, modo oscuro, paleta de la app (emerald/amber/slate), + iconos `lucide-react`, toasts `sonner`. Coherente con `SettingsPage` / + `UserManagement`. + +## 8. Botón "Enviar prueba" + +Por cada escenario, un botón que dispara ese correo **con datos ficticios** sin crear +reservas reales. + +- **Endpoint** nuevo `POST /api/notifications/test-scenario` (protegido, admin): + `{ scenario, testEmail? }`. +- Construye una `Reservation` ficticia coherente con el escenario y llama al mismo + `handleNotificationEvent`, de modo que la prueba usa **exactamente** la misma lógica + y plantillas que el envío real. +- Destinatario: si `testEmail` viene, se envía solo a ese; si no, a los destinatarios + configurados del escenario (prueba realista de entrega). +- El asunto/encabezado de prueba se marca visualmente como tal (p. ej. prefijo + `[PRUEBA]`) para no confundir con un aviso real. + +## 9. Riesgo conocido a verificar en implementación + +Esta pantalla resuelve **a quién** y **con qué formato** llega cada correo, pero **no** +garantiza por sí sola que los correos de reserva (CRUD) se disparen en producción. + +Patrón observado durante el diagnóstico: los correos de **jobs** (segunda factura; +prueba manual de piscina) llegan, mientras que los **CRUD disparados desde el +navegador** (`App.tsx` → `POST /api/notifications/reservation-event`) estaban en duda. +El `notifyApi` del frontend es "fire-and-forget" con errores silenciados +(`App.tsx:236-239`) y depende de `VITE_API_URL` (build) y de `WEB_ORIGIN`/CORS +(`index.ts:11-14`). + +**Tarea explícita del plan:** verificar que el frontend de producción alcanza el +backend (valores reales de `VITE_API_URL` y `WEB_ORIGIN`, ver una llamada a +`reservation-event` en DevTools/Network). Si esa vía está rota, ninguna configuración +de destinatarios hará que salgan los correos de reserva. Considerar, si procede, dejar +de silenciar el error en `notifyApi` (al menos un `console.error` / toast) para que el +fallo sea visible. + +## 10. Pruebas + +- **Backend:** test unitario de `resolveRecipients` (separación por nivel; escenario + sin grupos → sin envío; plantilla correcta por nivel). +- **Regla de privacidad:** test que verifica que un grupo `reducida` nunca recibe la + plantilla completa, en ninguno de los escenarios de doble versión. +- **Manual:** botón "Enviar prueba" de cada escenario contra el webhook real, + comprobando entrega y formato (continuidad del método que veníamos usando). diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4605b96..aedbcd7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,9 +2,10 @@ export type { Reservation, NewReservation, ReservationOrigin, Property, PricingSnapshot, WebhookPayload } from './types/reservation.js'; export type { UserRole, UserProfile } from './types/user.js'; export type { PricingInput, PricingResult } from './types/pricing.js'; -export type { NotificationEventType, NotificationStatus, NotificationEvent } from './types/notification.js'; +export type { NotificationEventType, NotificationStatus, NotificationEvent, NotificationLevel, NotificationGroup, NotificationConfig, NotificationScenarioMeta } from './types/notification.js'; // Constants +export { CONFIGURABLE_SCENARIOS } from './types/notification.js'; export { PROPERTY_CONFIG, PROPERTIES, DEFAULT_IGIC_RATE, getExtraPersonRate } from './constants/properties.js'; export { ORIGIN_CONFIG } from './constants/origins.js'; diff --git a/packages/shared/src/types/notification.ts b/packages/shared/src/types/notification.ts index e71edbd..072a500 100644 --- a/packages/shared/src/types/notification.ts +++ b/packages/shared/src/types/notification.ts @@ -3,12 +3,50 @@ export type NotificationEventType = | 'reservation.created' | 'reservation.updated' | 'reservation.cancelled' - | 'reservation.reminder_24h' | 'reservation.invoice_second_notice' | 'reservation.pool_heating_notice'; export type NotificationStatus = 'pending' | 'sent' | 'failed'; +// ── Configuración de destinatarios (gestionada desde la app) ── + +// Nivel de información que un grupo puede recibir. +// 'reducida' nunca recibe datos completos del cliente (regla de privacidad). +export type NotificationLevel = 'reducida' | 'completa'; + +export interface NotificationGroup { + id: string; + name: string; + level: NotificationLevel; + emails: string[]; + enabled: boolean; + created_at?: string; + updated_at?: string; +} + +// Estado completo de la configuración: grupos + qué grupos recibe cada escenario. +// assignments: { [event_type]: groupId[] } +export interface NotificationConfig { + groups: NotificationGroup[]; + assignments: Record; +} + +// Escenarios configurables desde la pantalla, con etiqueta y si tienen doble versión. +export interface NotificationScenarioMeta { + scenario: NotificationEventType; + label: string; + /** true = tiene versión reducida y completa (escenarios CRUD de reserva) */ + dualVersion: boolean; +} + +export const CONFIGURABLE_SCENARIOS: NotificationScenarioMeta[] = [ + { scenario: 'reservation.created', label: 'Nueva reserva', dualVersion: true }, + { scenario: 'reservation.updated', label: 'Reserva modificada (fechas)', dualVersion: true }, + { scenario: 'reservation.cancelled', label: 'Reserva cancelada', dualVersion: true }, + { scenario: 'reservation.invoice_second_notice', label: 'Segunda factura (10 días antes)', dualVersion: false }, + { scenario: 'reservation.pool_heating_notice', label: 'Calefacción de piscina (48h antes)', dualVersion: false }, +]; + export interface NotificationEvent { id: string; reservation_id: string; diff --git a/supabase/migrations/012_notification_config.sql b/supabase/migrations/012_notification_config.sql new file mode 100644 index 0000000..807975e --- /dev/null +++ b/supabase/migrations/012_notification_config.sql @@ -0,0 +1,87 @@ +-- Migración 012: Configuración de destinatarios de notificaciones +-- +-- Mueve "quién recibe cada correo" desde variables de entorno (Dokploy) a la BD, +-- gestionable desde la app. Dos tablas: +-- notification_groups — grupos de destinatarios con un nivel de info fijo +-- notification_scenario_recipients — qué grupos recibe cada escenario (event_type) +-- +-- IMPORTANTE: schema natur_reservas (la instancia Supabase es compartida; public es +-- otro proyecto). Ver CLAUDE.md §1. + +-- ──────────────────────────────────────────────────────────── +-- Grupos de destinatarios +-- ──────────────────────────────────────────────────────────── +CREATE TABLE natur_reservas.notification_groups ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + name TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'completa' + CHECK (level IN ('reducida', 'completa')), + emails TEXT[] NOT NULL DEFAULT '{}', + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE natur_reservas.notification_groups IS + 'Grupos de destinatarios de notificaciones. level=reducida solo puede recibir la versión reducida de los correos (regla de privacidad).'; + +-- ──────────────────────────────────────────────────────────── +-- Asignación escenario → grupos (N:M) +-- ──────────────────────────────────────────────────────────── +CREATE TABLE natur_reservas.notification_scenario_recipients ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + scenario TEXT NOT NULL, -- igual que notification_events.event_type + group_id UUID NOT NULL + REFERENCES natur_reservas.notification_groups(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_scenario_group UNIQUE (scenario, group_id) +); + +CREATE INDEX idx_scenario_recipients_scenario + ON natur_reservas.notification_scenario_recipients(scenario); + +COMMENT ON TABLE natur_reservas.notification_scenario_recipients IS + 'Qué grupos reciben cada escenario (event_type). El motor de envío en apps/api lo consulta con service_role.'; + +-- ──────────────────────────────────────────────────────────── +-- RLS: el acceso del frontend va por el API (service_role bypasea RLS). +-- Bloqueamos acceso directo de anon/authenticated; el API valida admin. +-- ──────────────────────────────────────────────────────────── +ALTER TABLE natur_reservas.notification_groups ENABLE ROW LEVEL SECURITY; +ALTER TABLE natur_reservas.notification_scenario_recipients ENABLE ROW LEVEL SECURITY; + +-- El API accede con service_role: bypasea RLS, pero necesita el GRANT de tabla +-- (en schemas custom no se concede automáticamente, a diferencia de public). +GRANT USAGE ON SCHEMA natur_reservas TO service_role; +GRANT ALL ON natur_reservas.notification_groups TO service_role; +GRANT ALL ON natur_reservas.notification_scenario_recipients TO service_role; + +REVOKE ALL ON natur_reservas.notification_groups FROM anon; +REVOKE ALL ON natur_reservas.notification_scenario_recipients FROM anon; + +-- ──────────────────────────────────────────────────────────── +-- Semilla inicial (editable después desde la pantalla) +-- ──────────────────────────────────────────────────────────── +WITH nuevos AS ( + INSERT INTO natur_reservas.notification_groups (name, level, emails) VALUES + ('Teneriffa', 'reducida', + ARRAY['uwe.dotschkis@teneriffa2000.de', 'j.reichstein@teneriffa2000.de']), + ('Naturcalabacera (Admin)', 'completa', + ARRAY['administracion@naturcalabacera.com']), + ('Limpieza', 'completa', + ARRAY['limpieza.naturcalabacera@gmail.com']) -- placeholder editable + RETURNING id, name +) +INSERT INTO natur_reservas.notification_scenario_recipients (scenario, group_id) +SELECT s.scenario, n.id +FROM nuevos n +JOIN (VALUES + ('Teneriffa', 'reservation.created'), + ('Teneriffa', 'reservation.updated'), + ('Teneriffa', 'reservation.cancelled'), + ('Naturcalabacera (Admin)', 'reservation.created'), + ('Naturcalabacera (Admin)', 'reservation.updated'), + ('Naturcalabacera (Admin)', 'reservation.cancelled'), + ('Naturcalabacera (Admin)', 'reservation.invoice_second_notice'), + ('Limpieza', 'reservation.pool_heating_notice') +) AS s(group_name, scenario) ON s.group_name = n.name;