Files
Gesti-n-Reservas-Naturcalab…/docs/superpowers/specs/2026-06-15-configuracion-destinatarios-notificaciones-design.md
Kilian ee03f0b36d feat: configuración de destinatarios de notificaciones desde la app
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>
2026-06-17 11:12:58 +01:00

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; 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
reservation.updated Inmediato (solo si cambian fechas/horas) reducida + completa
reservation.cancelled Inmediato reducida + completa
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.tsxPOST /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).