> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Campañas y disparadores

> Cuando esto se entregue, un tenant configura una vez "escríbele a todos en su cumpleaños" y corre correctamente por años, en la zona horaria de cada member.

> Traducción. Autoritativo: [`fs-msg-0007-campaigns-and-triggers.md`](/modules/messaging/features/fs-msg-0007-campaigns-and-triggers).

## Contexto

Dos formas de campaña, y fallan distinto.

Un **blast** es una vez a un segmento: los riesgos son volumen, exactitud de la lista y el momento sin
retorno. Una **automatización** es un disparador permanente: los riesgos son duplicados, zonas
horarias y detenerse en silencio. Tratarlas como una sola cosa produce un diseño que no maneja bien
ninguna.

El disparador por propiedad de fecha es el que hay que diseñar con cuidado. "Feliz cumpleaños" es la
automatización más común de la categoría y la más fácil de equivocar de forma vergonzosa — enviada un
día antes en una zona, dos veces en año bisiesto, o a todos a las 03:00 porque el cron corre en UTC.

## Alcance *(normativo)*

* `messaging.campaigns`: `blast` o `automation`, con categoría.
* `messaging.campaign_triggers`: evento, propiedad de fecha, entrada o salida de segmento.
* Programación por propiedad de fecha con zona horaria del destinatario.
* `messaging.campaign_runs` para contabilidad e idempotencia por corrida.
* Programación de blasts, vista previa del conteo de destinatarios y cancelación antes del despacho.
* Overrides de quiet hours y frequency caps por campaña.

## Fuera de alcance *(normativo)*

* Journeys multi-paso con ramas. Deliberadamente fuera: un constructor de journeys es su propio
  producto y uno malo es peor que ninguno.
* Testing A/B — F2.
* El motor de reglas, que es de `loyalty`.
* La definición de segmentos, que es de `core`.

## Comportamiento *(normativo)*

1. Una campaña es `blast` o `automation`. Un blast corre una vez contra un snapshot del segmento
   **tomado al despachar**, no al programar — la lista es la que hay cuando sale.
2. Los disparadores por propiedad de fecha corren por tenant con fan-out de cron, en una **ventana de
   envío en la zona horaria del destinatario**, cayendo a la del tenant. Un correo de cumpleaños a las
   03:00 local es peor que ninguno.
3. Los duplicados son imposibles por construcción: una **clave única de (campaña, contacto, fecha de
   ocurrencia)**. Reejecutar el job, un reintento o un segundo worker no pueden producir un segundo
   mensaje.
4. Los cumpleaños del 29 de febrero se disparan el 28 en años no bisiestos. Se enuncia porque la
   alternativa es un member que nunca recibe uno.
5. Una campaña cuya plantilla, segmento o disparador se vuelve inválido se **pausa y se notifica a su
   dueño**, nunca se salta en silencio. Una campaña que dejó de correr sin avisar se descubre meses
   después.
6. Los blasts son **cancelables hasta que empieza el despacho** y reportan un conteo antes. Una vez
   iniciado corren hasta el final — un blast a medio enviar no se puede des-enviar.
7. Toda campaña declara una **categoría**, y las de marketing obedecen quiet hours y frequency caps
   con override opcional por campaña (DEC-E5).
8. Cada mensaje que una campaña produce pasa la cascada individualmente. Una campaña no es un atajo;
   es una razón para enviar.
9. `campaign_runs` registra qué corrió, cuándo, cuántos resolvieron, cuántos se excluyeron y por qué.

## Datos *(normativo)*

| Tabla                         | Invariantes clave                                                                                                                                                               |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messaging.campaigns`         | `tenant_id`; `kind` blast\|automation; `category`; `template_id`; `segment_id` nullable; `status` draft\|scheduled\|running\|paused\|completed; overrides de quiet hours y caps |
| `messaging.campaign_triggers` | FK campaña; `type` event\|date\_property\|segment\_entered\|segment\_exited; configuración JSONB; `send_window_local`                                                           |
| `messaging.campaign_runs`     | FK campaña; `occurrence_key`; único (`campaign_id`, `contact_id`, `occurrence_key`); conteos de resueltos y excluidos; append-only                                              |

## API *(normativo)*

| Endpoint                                     | Clase      | Permiso                                      | Presupuesto            |
| -------------------------------------------- | ---------- | -------------------------------------------- | ---------------------- |
| `GET/POST/PATCH /v1/messaging/campaigns`     | Management | `messaging.campaigns.{read\|create\|update}` | p95 \<1 s              |
| `POST /v1/messaging/campaigns/{id}/schedule` | Management | `messaging.campaigns.schedule`               | p95 \<1 s              |
| `POST /v1/messaging/campaigns/{id}/cancel`   | Management | `messaging.campaigns.cancel`                 | p95 \<1 s              |
| `GET /v1/messaging/campaigns/{id}/preview`   | Management | `messaging.campaigns.read`                   | p95 \<5 s, solo conteo |

## Eventos *(normativo)*

`messaging.campaign.triggered`, disponible como webhook saliente, con campaña y ocurrencia.

## Criterios de aceptación *(normativo)*

1. Una campaña de cumpleaños envía una vez por member por año, en la ventana de su zona horaria,
   verificado cruzando la línea de cambio de fecha.
2. Reejecutar el job el mismo día produce cero mensajes adicionales.
3. Un cumpleaños del 29 de febrero se dispara el 28 en año no bisiesto.
4. Un blast toma snapshot de su segmento al despachar, no al programar.
5. Un blast se puede cancelar hasta que empieza el despacho, y no después.
6. Una campaña con plantilla inválida se pausa y se notifica a su dueño.
7. Cada mensaje pasa la cascada individualmente — un member suprimido dentro del segmento no recibe
   nada y la exclusión se cuenta.
8. Una campaña de marketing obedece quiet hours salvo que declare override.
9. **Negativo:** ningún camino de campaña alcanza un adaptador sin pasar la cascada.

## Ejecución

Pipeline asíncrono. Evaluación de disparadores y fan-out en `backend/scheduler`, despacho por el
carril de marketing en `backend/workers`.

## Preguntas abiertas

| # | Pregunta                                                                   | Decide | Para             |
| - | -------------------------------------------------------------------------- | ------ | ---------------- |
| 1 | Ventana de envío local por defecto para campañas por fecha — ¿09:00–11:00? | Daniel | antes de aprobar |
| 2 | ¿Tamaño máximo de blast antes de exigir una segunda confirmación?          | Daniel | antes de aprobar |

## Changelog

| Versión | Fecha      | Cambio           | Por qué | Autor                  |
| ------- | ---------- | ---------------- | ------- | ---------------------- |
| 0.1.0   | 2026-08-17 | Borrador inicial | —       | daniel + claude-opus-5 |

## Registro de entrega

*Aún no implementado.*
