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>
212 lines
9.3 KiB
Markdown
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).
|