# 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).