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

212 lines
9.3 KiB
Markdown

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