PRD — Dominio 09: notifications
Canal único por el que la plataforma habla con los usuarios (push, email, SMS/WhatsApp). Consume eventos de todos los dominios, los traduce a templates según preferencias y contexto, y garantiza entrega idempotente con respeto de quiet hours y opt-outs.
1. Propósito
Centralizar el envío de cualquier mensaje saliente — confirmaciones de booking, invitaciones de membresía, pedidos de reseña, alertas de seguridad — para:
- Unificar la gestión de preferencias (el usuario decide qué recibir y por dónde en un solo lugar).
- Evitar duplicados (el mismo evento no se envía dos veces ante reintentos, failovers o re-consumo).
- Respetar quiet hours, idioma, canal preferido, y categorías suscritas.
- Aislar a cada dominio de negocio de los detalles de proveedores (SendGrid, Resend, Expo Push, Twilio, WhatsApp Business, …).
notifications no decide negocio — solo traduce eventos en mensajes. Si un dominio no emite el evento, nada se envía.
2. Entidades
| Entidad | Responsabilidad |
|---|---|
NotificationTemplate | Plantilla por (event_key, channel, locale). Contiene asunto/cuerpo con variables. |
Notification | Una intención de envío concreta. Única por (event_id, recipient_account_id, channel). |
NotificationDelivery | Registro de cada intento físico de entrega (con proveedor, status, timestamp, error). |
NotificationPreference | Preferencias del Account (por categoría + canal). |
DeviceToken | Token de push por device (Expo push token) asociado a un Account. |
NotificationSuppression | Opt-outs duros (email bouncing, desuscripción, bloqueo). |
NotificationScheduled | Recordatorio programado para envío futuro. Campos: notification_id, send_at TIMESTAMPTZ, status ∈ {pending, sent, cancelled_before_send}, booking_id? (para poder cancelar todos los recordatorios de un booking en un solo UPDATE). |
Categorías de notificación
Agrupación semántica que determina defaults y elegibilidad de opt-out:
| Categoría | Ejemplos | Default | Se puede desactivar |
|---|---|---|---|
transactional | Confirmación/cancelación de booking, OTP, invitación aceptada, cobro exitoso | Siempre ON | No (salvo cierre de cuenta) |
reminders | Recordatorio pre-cita (T-24h, T-2h), pedido de reseña | ON | Sí, por canal |
alerts | Cambios en la sede que afectan bookings, ausencia del profesional | ON | Sí, por canal |
marketing | Promociones, nuevos servicios en sedes favoritas | OFF | Sí (requiere opt-in explícito) |
Canales
push— Expo Push (React Native / móvil). RequiereDeviceTokenactivo.email— provider: Resend (MVP). Fallback/alternativa: SendGrid.sms— Twilio. Solo paratransactional(OTP, confirmaciones críticas) por coste.whatsapp— WhatsApp Business API. Fase 2+.
Relaciones
Account ──< DeviceToken
Account ──< NotificationPreference (por categoría + canal)
Account ──< NotificationSuppression
event (de cualquier dominio) ──► Notification ──< NotificationDelivery (1..N intentos)
NotificationTemplate (lookup por event_key + channel + locale)3. Historias de usuario
- U-1. Como Sofía, al confirmar una reserva recibo una notificación push instantánea y un email con los detalles.
- U-2. Como Sofía, recibo un recordatorio 24h antes y 2h antes de mi cita.
- U-3. Como Sofía, después de mi cita (T+1h) recibo un pedido para reseñar.
- U-4. Como Sofía, puedo desactivar los recordatorios por email pero dejar los push activos.
- U-5. Como Sofía, los recordatorios no llegan entre 22:00 y 08:00 hora local (quiet hours).
- U-6. Como Sofía, si cancelo una cita no recibo el recordatorio pre-cita (se cancela el delivery programado).
- U-7. Como Carlos, cuando un cliente cancela o confirma una reserva en mi sede, recibo push.
- U-8. Como María, cuando Carlos me invita como profesional a su sede, recibo email + push con el deep link para aceptar.
- U-9. Como Sofía, si rebota mi email (bounce), el sistema lo marca y deja de intentar por ese canal hasta que lo actualice.
- U-10. Como Diego (platform admin), puedo revisar entregas fallidas y reenviar si fue un error transitorio del proveedor.
- U-11. Como Sofía, no recibo dos veces la misma confirmación aunque el sistema reintente el evento.
4. Requerimientos funcionales
Consumo de eventos → creación de Notification
- RF-1 Para cada evento suscrito (tabla en §7), hay una regla declarativa:
event_key(ej.booking.confirmed)destinatarios(ej.[client, professional, branch_owner])canales_por_categoría(derivan del template + preferencias)categoría(transactional | reminders | alerts | marketing)schedule(immediate | at(T-24h) | at(T-2h) | at(completed+1h))
- RF-2 Al consumir el evento, el dominio:
- Expande destinatarios en
Accounts concretos. - Por cada
(account, channel)válido (ver §5 preferencias):- Calcula
idempotency_key = hash(event_id, account_id, channel). - Upsert
Notificationcon ese key → si ya existe, no crea otra. - Programa un
NotificationDeliveryen la cola (inmediato o diferido segúnschedule).
- Calcula
- Expande destinatarios en
- RF-3 Eventos con
schedule != immediate(recordatorios) se agendan en una tablanotifications_scheduledconsend_at. Un worker los dispara cuando llegan al tiempo. Si el booking se cancela antes delsend_at→ el eventobooking.cancelledmarca los pendientes comocancelled_before_send.
Preferencias
- RF-4
GET /accounts/me/notification-preferencesdevuelve el set actual (categoría × canal → enabled). - RF-5
PUT /accounts/me/notification-preferencescon{ [category]: { [channel]: bool } }. Reglas:transactionalno se puede desactivar (request retorna422si se intenta).- Categorías desconocidas →
422.
- RF-6 Link de desuscripción (
marketing,reminders) en todos los emails no-transaccionales, firmado con HMAC → endpoint públicoGET /unsubscribe?token=...que marca preferencias.
Device tokens (push)
- RF-7
POST /accounts/me/devicescon{ platform, expo_push_token, device_label? }. Upsert (un token único por registro). - RF-8
DELETE /accounts/me/devices/{id}— usuario revoca un device. - RF-9 Al fallar un envío push con "InvalidToken" / "DeviceNotRegistered", el token se marca
inactive. No se reintenta por ese device.
Envío y entrega
- RF-10 Worker
sendertoma jobs de la cola, por cada uno:- Re-chequea preferencias + suppressions (podrían haber cambiado desde que se agendó).
- Renderiza template con variables del evento + locale del Account.
- Llama al provider (Resend/Expo/Twilio).
- Escribe
NotificationDeliverycon status (sent,failed,suppressed).
- RF-11 Retries con exponential backoff (1m, 5m, 15m, 1h, 6h) solo en errores transitorios (5xx, timeouts, rate limits). Errores permanentes (4xx semánticos, token inválido, email malformado) →
faileddefinitivo. - RF-12
NotificationDelivery.status∈{queued, sent, delivered, bounced, failed, suppressed, cancelled_before_send}.deliveredsolo si el provider lo confirma (webhook).bounced→ agregaNotificationSuppressionpara ese canal + account.
Webhooks de providers
- RF-13
POST /webhooks/resendy/webhooks/expo— endpoints internos que actualizanNotificationDelivery.statusy registran bounces/unsubscribes. - RF-14 Firmar todos los webhooks con secret del provider; rechazar si no verifica.
Administración
- RF-15
GET /admin/notifications?event_key=&account_id=&status=— cola de moderación/debug. Soloplatform_admin. - RF-16
POST /admin/notifications/{id}/resend— reintento manual de un delivery fallido (resetea contador). - RF-17
GET /admin/notifications/stats?from=&to=— métricas agregadas (enviadas, entregadas, bouncerate, tiempo de entrega p95) por canal y por categoría.
Templates
- RF-18 Templates viven en código (archivos
templates/*.md.tmpl+templates/*.html.tmpl), versionados en git. Hotswap no requerido en MVP. - RF-19 Variables disponibles en el template se documentan por evento (ej.
booking.confirmedexpone{ client, professional, branch, start_at_local, service_name, total_amount, cancellation_policy_hours }). - RF-20 Locale por defecto:
es-CO. Fallback aessi falta, luego aen(MVP: soloes, pero el pipeline está listo).
5. Reglas de negocio
RN-1 Idempotencia estricta:
UNIQUE(event_id, recipient_account_id, channel)enNotification. El mismo evento nunca produce dos notificaciones al mismo destinatario por el mismo canal, aunque se re-consuma.RN-2
transactionalsiempre sale — ignora quiet hours y no respeta opt-out dereminders/marketing. Opt-out total solo al cerrar cuenta.RN-3 Quiet hours (22:00–08:00 hora local del Account) aplican a
remindersymarketing. Si elsend_atcae dentro, se posterga hasta el siguiente 08:00. No se envían 2 si la postergación cruza con otro.RN-4
alertsrespetan quiet hours salvo si el evento es crítico — esos se envían inmediatamente ignorando la ventana. El dominio origen marca el evento con"override_quiet": trueen el payload dedomain_events.notificationslee este campo al consumir el evento.Eventos que llevan
override_quiet: true(lista cerrada en MVP):Evento Razón booking.cancelledconreason_code ∈ {system_branch_blocked, system_time_off, system_membership_ended}El cliente pierde una cita confirmada — no puede esperar a las 8am payment.reauthorization_failed(T-48h)El cliente necesita actuar para no perder su cita payment.authorization_expiredLa cita se canceló por fallo de pago — informar inmediatamente Cualquier evento no listado aquí nunca tiene
override_quiet: true, aunque el dominio lo incluya —notificationslo ignora si el event_key no está en la lista autorizada.RN-5
marketingrequiere opt-in explícito. Default OFF. Un Account recién creado no recibe nada de marketing hasta marcarlo.RN-6 Si un evento de negocio cancela un estado que tenía recordatorios programados (ej.
booking.cancelledantes de T-24h), losnotifications_scheduledpendientes se marcancancelled_before_send. Enforced con un listener explícito.RN-7
NotificationSuppressionbloquea el canal para ese account. Para reactivar: update explícito del contacto (nuevo email) o acción de admin.RN-8 Un Account con 3 bounces consecutivos en email → suppression automática. 5 push InvalidToken consecutivos → device token a inactive.
RN-9 Envíos a Accounts con
status = suspendedse bloquean (salvo que el evento seaaccount.unsuspended).RN-10 Locale del Account se resuelve:
Account.locale || Branch.locale_default || 'es-CO'.RN-11 Para recordatorios pre-cita, la hora de envío se calcula con la tz del booking (
Booking.timezone_snapshot), no la del Account (el cliente puede estar viajando).RN-12 Tamaño máximo de push: 2 KB (restricción de Expo); el template validador rechaza builds que excedan.
RN-13 SMS solo se usa para
transactionalcríticos (OTP, cancelación sistémica). Nunca parareminders/marketingen MVP (coste y potencial spam).
6. Flujos críticos
Confirmación de booking (multi-canal, multi-destinatario):
(booking emite: booking.confirmed { event_id, booking_id, client, prof_membership, branch })
↓ notifications consume
Destinatarios resueltos: client (Sofía), professional (María), branch_owner (Carlos)
Para cada:
Canales elegibles según preferencias y DeviceTokens activos
UPSERT Notification (event_id, account, channel) — idempotente
Agenda delivery inmediato
Worker sender:
Sofía → push (preferencia ON, device activo) → Expo
Sofía → email (preferencia ON) → Resend
María → push
Carlos → push
Todos escriben NotificationDelivery status=sent; webhooks de provider luego los pasan a delivered o bounced.Recordatorio pre-cita y cancelación antes del envío:
booking.confirmed (start_at=martes 15:00 America/Bogota) →
Agenda notifications_scheduled:
{ event_id, account=sofia, send_at=martes 13:00, template=booking.reminder_2h }
{ event_id, account=sofia, send_at=lunes 15:00, template=booking.reminder_24h }
(lunes 10am) booking.cancelled →
UPDATE notifications_scheduled
SET status='cancelled_before_send'
WHERE booking_id = X AND status='pending'
→ no se envían recordatorios
Si NO hubiera cancelación:
(lunes 15:00) worker dispara reminder_24h
Chequea: account.preferences, booking still confirmed, NOT quiet hours
→ envíaQuiet hours + override:
scheduling emite branch_block.created (block empieza en 3h, afecta booking de Sofía)
marca evento como critical=true, override_quiet=true
↓
booking.cancelled (reason=system_branch_blocked) emitido
notifications traduce → Sofía recibe aviso INMEDIATO aunque sean las 23:45
(porque override_quiet=true en evento de cancelación sistémica)Bounce automático:
Sofía cambia de email y deja de leer el anterior
3 envíos consecutivos bouncean
Webhook de Resend reporta bounced 3 veces
↓
INSERT NotificationSuppression (account=sofia, channel=email, reason=hard_bounce_3x)
Futuros envíos por email → status=suppressed
Sofía sigue recibiendo push/sms normalmente
Cuando Sofía actualiza email en /accounts/me → email suppression se limpia automáticamente para ese email (el nuevo no tiene historial)Desuscripción vía link:
GET /unsubscribe?token=HMAC(account, category, channel, exp)
Valida HMAC + exp
UPDATE NotificationPreference SET enabled=false WHERE account AND category AND channel
→ HTML simple "listo, desuscrito"7. Dependencias
Dominios productores de eventos (consumidos por notifications)
| Dominio | Eventos (no exhaustivo) |
|---|---|
auth | auth.otp_issued, auth.password_reset_requested, auth.email_verification_required |
accounts | invitation.sent, invitation.accepted, invitation.rejected, membership.ended |
catalog | branch_service.proposed, .approved, .rejected, .re_proposed |
scheduling | branch_block.created (critical), professional_time_off.created |
booking | booking.confirmed, .cancelled, .completed, .no_show; recordatorios pre-cita derivados |
billing | payment.captured, payment.failed, refund.issued |
reviews | review.invited, review.created, review.response_created |
Integraciones externas
- Resend (email MVP) o SendGrid.
- Expo Push (mobile MVP — alineado con stack Expo de Consumer/Business Apps).
- Twilio (SMS).
- WhatsApp Business API (fase 2+).
Qué no hace notifications
- No decide si un evento amerita comunicación — eso lo decide cada dominio emisor (si no emite, no hay mensaje).
- No mantiene estado de negocio (solo su propia cola + historial de envíos).
- No expone lectura de mensajes a los clientes (Sofía lee su push/email, no un inbox dentro de la app — MVP).
8. Fuera de alcance (MVP)
- In-app inbox (centro de notificaciones persistente dentro de la app) — fase 2.
- WhatsApp Business — fase 2+.
- Templates editables desde admin UI — fase 3. MVP: templates en código.
- Segmentación de marketing (envío masivo a segmentos) — fase 4+.
- A/B testing de templates — fase 4.
- Personalización dinámica con ML (mejor hora, mejor canal por usuario) — fase 5.
- Notificaciones por voz (robo-calls) — nunca en roadmap.
- Traducción automática — fase 3. MVP: templates solo en
es-CO. - Preview del email para el usuario — fase 3.
- Throttling global por account (máx N mensajes/hora) — fase 2 si se vuelve necesario.
- Rich push (imágenes, acciones inline) — fase 2. MVP: título + body.
- Calendario iCal adjunto a la confirmación — fase 2.
9. Métricas
- p95 delivery latency de
transactional< 30s desde el evento (push + email). - ≥ 98% delivery rate de push en devices activos.
- ≥ 95% delivery rate de email (sin contar bounces).
- < 1% hard bounce rate sostenido — alerta si sube por 24h.
- 0 notificaciones duplicadas por el mismo
(event_id, account, channel). - 0 envíos marketing a Accounts sin opt-in explícito.
- ≥ 99% de recordatorios pre-cita que se cancelan cuando el booking se cancela antes del send_at.
- < 0.5% de desuscripciones post-confirmación de booking (salud de la relevancia).
- < 1% de envíos en quiet hours a categorías no override (control de quiet hours correcto).
- < 3s p95 de
POST /webhooks/*(webhooks de provider deben ser rápidos para no retry).