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) <noreply@anthropic.com>
9.3 KiB
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;publices 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 ento. - Emails de grupos
level = 'completa'→ se envía la plantilla completa (natur-crud.html). Un envío con todos esos emails ento. - Si una de las dos listas está vacía, ese envío simplemente no ocurre.
- Emails de grupos
- 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.tsxyMobileNavigation.tsx(requires: 'admin'), y el render condicional enApp.tsx(currentView === 'notifications' && isAdmin). - Componente
apps/web/src/components/NotificationSettings.tsxcon hookuseNotificationConfigque lee/escribe las dos tablas vía el cliente Supabase (schemanatur_reservas), siguiendo el patrón deuseReservations/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, toastssonner. Coherente conSettingsPage/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
Reservationficticia coherente con el escenario y llama al mismohandleNotificationEvent, de modo que la prueba usa exactamente la misma lógica y plantillas que el envío real. - Destinatario: si
testEmailviene, 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
reducidanunca 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).