Skip to content

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

EntidadResponsabilidad
NotificationTemplatePlantilla por (event_key, channel, locale). Contiene asunto/cuerpo con variables.
NotificationUna intención de envío concreta. Única por (event_id, recipient_account_id, channel).
NotificationDeliveryRegistro de cada intento físico de entrega (con proveedor, status, timestamp, error).
NotificationPreferencePreferencias del Account (por categoría + canal).
DeviceTokenToken de push por device (Expo push token) asociado a un Account.
NotificationSuppressionOpt-outs duros (email bouncing, desuscripción, bloqueo).
NotificationScheduledRecordatorio 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íaEjemplosDefaultSe puede desactivar
transactionalConfirmación/cancelación de booking, OTP, invitación aceptada, cobro exitosoSiempre ONNo (salvo cierre de cuenta)
remindersRecordatorio pre-cita (T-24h, T-2h), pedido de reseñaONSí, por canal
alertsCambios en la sede que afectan bookings, ausencia del profesionalONSí, por canal
marketingPromociones, nuevos servicios en sedes favoritasOFFSí (requiere opt-in explícito)

Canales

  • push — Expo Push (React Native / móvil). Requiere DeviceToken activo.
  • email — provider: Resend (MVP). Fallback/alternativa: SendGrid.
  • sms — Twilio. Solo para transactional (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:
    1. Expande destinatarios en Accounts concretos.
    2. Por cada (account, channel) válido (ver §5 preferencias):
      • Calcula idempotency_key = hash(event_id, account_id, channel).
      • Upsert Notification con ese key → si ya existe, no crea otra.
      • Programa un NotificationDelivery en la cola (inmediato o diferido según schedule).
  • RF-3 Eventos con schedule != immediate (recordatorios) se agendan en una tabla notifications_scheduled con send_at. Un worker los dispara cuando llegan al tiempo. Si el booking se cancela antes del send_at → el evento booking.cancelled marca los pendientes como cancelled_before_send.

Preferencias

  • RF-4 GET /accounts/me/notification-preferences devuelve el set actual (categoría × canal → enabled).
  • RF-5 PUT /accounts/me/notification-preferences con { [category]: { [channel]: bool } }. Reglas:
    • transactional no se puede desactivar (request retorna 422 si 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úblico GET /unsubscribe?token=... que marca preferencias.

Device tokens (push)

  • RF-7 POST /accounts/me/devices con { 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 sender toma jobs de la cola, por cada uno:
    1. Re-chequea preferencias + suppressions (podrían haber cambiado desde que se agendó).
    2. Renderiza template con variables del evento + locale del Account.
    3. Llama al provider (Resend/Expo/Twilio).
    4. Escribe NotificationDelivery con 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) → failed definitivo.
  • RF-12 NotificationDelivery.status{queued, sent, delivered, bounced, failed, suppressed, cancelled_before_send}. delivered solo si el provider lo confirma (webhook). bounced → agrega NotificationSuppression para ese canal + account.

Webhooks de providers

  • RF-13 POST /webhooks/resend y /webhooks/expo — endpoints internos que actualizan NotificationDelivery.status y 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. Solo platform_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.confirmed expone { client, professional, branch, start_at_local, service_name, total_amount, cancellation_policy_hours }).
  • RF-20 Locale por defecto: es-CO. Fallback a es si falta, luego a en (MVP: solo es, pero el pipeline está listo).

5. Reglas de negocio

  • RN-1 Idempotencia estricta: UNIQUE(event_id, recipient_account_id, channel) en Notification. El mismo evento nunca produce dos notificaciones al mismo destinatario por el mismo canal, aunque se re-consuma.

  • RN-2 transactional siempre sale — ignora quiet hours y no respeta opt-out de reminders/marketing. Opt-out total solo al cerrar cuenta.

  • RN-3 Quiet hours (22:00–08:00 hora local del Account) aplican a reminders y marketing. Si el send_at cae dentro, se posterga hasta el siguiente 08:00. No se envían 2 si la postergación cruza con otro.

  • RN-4 alerts respetan 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": true en el payload de domain_events. notifications lee este campo al consumir el evento.

    Eventos que llevan override_quiet: true (lista cerrada en MVP):

    EventoRazón
    booking.cancelled con reason_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 — notifications lo ignora si el event_key no está en la lista autorizada.

  • RN-5 marketing requiere 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.cancelled antes de T-24h), los notifications_scheduled pendientes se marcan cancelled_before_send. Enforced con un listener explícito.

  • RN-7 NotificationSuppression bloquea 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 = suspended se bloquean (salvo que el evento sea account.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 transactional críticos (OTP, cancelación sistémica). Nunca para reminders/marketing en 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ía

Quiet 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)

DominioEventos (no exhaustivo)
authauth.otp_issued, auth.password_reset_requested, auth.email_verification_required
accountsinvitation.sent, invitation.accepted, invitation.rejected, membership.ended
catalogbranch_service.proposed, .approved, .rejected, .re_proposed
schedulingbranch_block.created (critical), professional_time_off.created
bookingbooking.confirmed, .cancelled, .completed, .no_show; recordatorios pre-cita derivados
billingpayment.captured, payment.failed, refund.issued
reviewsreview.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).

Documentación interna — BeautyHub