Notificaciones¶
Cinco tablas: el token de dispositivo, la preferencia de canal por categoría, el estado operativo de los proveedores, la notificación individual (que es también la cola de envío y el registro de entrega), y la campaña masiva que las genera en lote.
El principio del módulo es que una notificación enviada es un hecho, no un mensaje que se recalcula. Cada fila guarda el texto ya resuelto y la plantilla de la que salió, de modo que responder ¿qué le llegó exactamente a este usuario? no dependa de que la plantilla no se haya editado desde entonces.
Requerimientos cubiertos¶
dispositivo_push¶
Token de notificación push por dispositivo.
- Régimen: Mutable rastreada
Columnas¶
| Campo | Tipo | Nulo | Predeterminado | Descripción |
|---|---|---|---|---|
usuario_id |
uuid |
no | Llave foránea RESTRICT |
|
plataforma |
varchar(10) |
no | iosandroidweb |
|
token |
varchar(128) |
no | Único: el token es propiedad del dispositivo, no del usuario | |
activo |
boolean |
no | true |
Constraints¶
CONSTRAINT chk_dispositivopush_plataforma CHECK (plataforma IN ('ios', 'android', 'web'))
CONSTRAINT chk_dispositivopush_token CHECK (length(token) > 0)
unq_dispositivopush_token impide repetirlo, pero admite exactamente un
registro con token vacío, que es un dispositivo al que nunca se le podrá
enviar nada y que ocupa el lugar del real.
Unicidad¶
| Nombre | Definición |
|---|---|
unq_dispositivopush_token |
(token) |
preferencia_notificacion¶
Preferencia de un usuario (o del personal de un comercio) por canal y categoría.
- Régimen: Mutable rastreada
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
usuario_id |
uuid |
no | Llave foránea RESTRICT |
comercio_id |
uuid |
sí | Nulo en preferencias de consumidor; con valor en las del personal |
canal |
varchar(10) |
no | pushsmsemail |
categoria |
varchar(20) |
no | transaccionaloperativapromocional |
habilitado |
boolean |
no |
Constraints¶
CONSTRAINT chk_preferencianotificacion_transaccional
CHECK (categoria <> 'transaccional' OR habilitado)
CONSTRAINT chk_preferencianotificacion_canal CHECK (canal IN ('push', 'sms', 'email'))
CONSTRAINT chk_preferencianotificacion_categoria
CHECK (categoria IN ('transaccional', 'operativa', 'promocional'))
chk_preferencianotificacion_transaccional vive en la base porque es una
obligación frente al comercio y al usuario, no una preferencia que la
aplicación pueda decidir respetar: las notificaciones transaccionales no
son desactivables.
Unicidad¶
| Nombre | Definición |
|---|---|
unq_preferencianotificacion_clave |
(usuario_id, comercio_id, canal, categoria), NULLS NOT DISTINCT |
NULLS NOT DISTINCT es necesario para que dos filas de consumidor con
comercio_id = NULL colisionen como se espera.
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_preferencianotificacion_membresia |
INSERTUPDATE |
BEFORE |
ROW |
Con comercio_id, el usuario debe tener membresía activa en ese comercio |
RF-C-58 |
RF-C-58 da estas preferencias a "cada usuario del comercio";
sin este trigger, cualquier cuenta podría crear una preferencia apuntando a
cualquier comercio. No produciría una fuga de datos, pero sí filas que
nadie limpiaría y un conteo de destinatarios que no cuadraría con la
membresía real.
notificacion¶
Una fila por mensaje y destinatario. Es a la vez cola de envío y registro de entrega.
- Régimen: Mutable protegida
- Alto volumen: sí, retención por
created_at - Volumen estimado: 3.000-15.000 filas diarias
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
usuario_id |
uuid |
no | Llave foránea RESTRICT |
comercio_id |
uuid |
sí | Con valor si el destinatario es personal de comercio |
canal |
varchar(10) |
no | pushsmsemailin_app |
categoria |
varchar(16) |
no | transaccionaloperativapromocional |
evento |
varchar(40) |
no | Código estable del disparador, 14 valores en uso |
plantilla_id |
uuid |
sí | Nulo si el texto fue compuesto sin plantilla |
titulo |
varchar(150) |
no | Resuelto |
cuerpo |
text |
no | Resuelto: el texto exacto que se envió |
datos |
jsonb |
no | Carga útil para la navegación profunda |
estado_id |
uuid |
no | Llave foránea a estado_notificacion |
campana_id |
uuid |
sí | Llave foránea RESTRICT a campana |
reserva_id |
uuid |
sí | Contexto transaccional |
programada_para |
timestamptz |
no | Base de la cola |
enviada_at |
timestamptz |
sí | |
entregada_at |
timestamptz |
sí | Acuse del proveedor |
fallida_at |
timestamptz |
sí | |
descartada_at |
timestamptz |
sí | Suprimida por preferencia o falta de dispositivo |
leida_at |
timestamptz |
sí | Solo in_app |
intentos |
smallint |
no | default 0 |
proveedor_id |
varchar(120) |
no | Identificador del proveedor, para conciliar |
error_detalle |
text |
no | '' |
Constraints¶
CONSTRAINT chk_notificacion_canal CHECK (canal IN ('push', 'sms', 'email', 'in_app'))
CONSTRAINT chk_notificacion_categoria
CHECK (categoria IN ('transaccional', 'operativa', 'promocional'))
CONSTRAINT chk_notificacion_evento CHECK (evento IN (/* 14 valores */))
CONSTRAINT chk_notificacion_titulo_email CHECK (canal <> 'email' OR length(titulo) > 0)
CONSTRAINT chk_notificacion_leida_solo_inapp CHECK (leida_at IS NULL OR canal = 'in_app')
CONSTRAINT chk_notificacion_desenlace_unico
CHECK (num_nonnulls(entregada_at, fallida_at, descartada_at) <= 1)
CONSTRAINT chk_notificacion_orden_temporal
CHECK (entregada_at IS NULL OR (enviada_at IS NOT NULL AND entregada_at >= enviada_at))
CONSTRAINT chk_notificacion_fallo_detalle
CHECK (fallida_at IS NULL OR length(error_detalle) > 0)
CONSTRAINT chk_notificacion_promocional_con_campana
CHECK (categoria <> 'promocional' OR campana_id IS NOT NULL)
CONSTRAINT chk_notificacion_intentos CHECK (intentos >= 0)
CONSTRAINT chk_notificacion_datos_tipo CHECK (jsonb_typeof(datos) = 'object')
CONSTRAINT chk_notificacion_orden_marcas
CHECK (
(fallida_at IS NULL OR enviada_at IS NULL OR fallida_at >= enviada_at)
AND (leida_at IS NULL OR (entregada_at IS NOT NULL AND leida_at >= entregada_at))
)
CONSTRAINT chk_notificacion_categoria_evento
CHECK (
(categoria = 'transaccional' AND evento IN (
'reserva_confirmada', 'reserva_expirada', 'reserva_cancelada_comercio',
'publicacion_despublicada', 'nueva_reserva', 'cancelacion_usuario',
'bloqueo_aplicado', 'solicitud_resuelta', 'incidencia_descargo'
))
OR (categoria = 'operativa' AND evento IN (
'recordatorio_ventana', 'reserva_por_expirar', 'favorito_publico',
'sin_publicacion', 'valoracion_baja'
))
OR (categoria = 'promocional')
)
chk_notificacion_promocional_con_campana es la garantía que hace
verificable el límite de envíos masivos: si toda notificación promocional
debe pertenecer a una campaña, no hay forma de enviar promoción por fuera
del mecanismo que cuenta y limita.
El mapa fijo de evento a categoria sigue el criterio que la nota de
diseño del final de esta ficha explica: transaccional para lo que afecta una
reserva viva, operativa para recordatorios y gestión. Es CHECK y no
trigger porque relaciona dos columnas de la propia fila, y se
genera desde la misma lista de Python que alimenta
trg_notificacion_datos_especificacion, para que las dos no puedan
divergir. Sin él, "la categoría se deriva del evento" es una convención de
services.py, y una notificación transaccional mal categorizada como
promocional es una que el usuario puede desactivar, contra lo que dicen
RF-U-46 y RF-C-58.
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_notificacion_datos_especificacion |
INSERTUPDATE |
BEFORE |
ROW |
datos trae las claves que exige su evento |
RF-U-44 |
trg_notificacion_preferencia |
INSERT |
BEFORE |
ROW |
Una categoría desactivada por el usuario nace descartada, no encolada | RF-U-46 |
trg_notificacion_plantilla |
INSERT |
BEFORE |
ROW |
La plantilla debe ser del mismo canal y estar vigente | RF-A-50 |
trg_notificacion_datos_especificacion exige, por cada evento, que datos
traiga las claves que ese evento necesita para la navegación profunda: por
ejemplo, reserva_confirmada exige reserva_id, favorito_publico exige
comercio_id y publicacion_id, bloqueo_aplicado exige sancion_id.
Mismo patrón que trg_parametrosistema_especificacion: un
CASE NEW.evento WHEN ... generado desde una lista fija en código, para que
Postgres nunca intente evaluar la rama equivocada. Un evento que no coincide
con ningún WHEN se rechaza.
trg_notificacion_preferencia¶
La preferencia del usuario se consulta al encolar y no al enviar, y el
trigger no rechaza: reescribe. Si existe una preferencia_notificacion con
habilitado = false para ese (usuario, comercio, canal, categoria), fija
descartada_at = now() y el estado descartada en vez de dejar la fila en
la cola.
Rechazar sería el diseño equivocado: RF-U-45 exige que "un aviso
suprimido quede registrado como descartado, con su motivo", así que la fila
tiene que existir. Y hacerlo en el momento del envío dejaría la decisión en
el proceso que barre la cola, donde una notificación promocional podría
colarse si alguien la encola por otro camino.
chk_preferencianotificacion_transaccional es lo que hace seguro el atajo:
como una categoría transaccional no puede tener habilitado = false, el
trigger nunca va a descartar un aviso que afecta a una reserva viva.
Índices¶
| Nombre | Definición | Propósito |
|---|---|---|
idx_notificacion_cola |
(estado_id, programada_para) WHERE enviada_at IS NULL |
Cola de envío |
brin_notificacion_createdat |
BRIN (created_at) |
Purga por retención |
Catálogo de eventos¶
evento |
Destinatario |
|---|---|
reserva_confirmada |
Usuario |
recordatorio_ventana |
Usuario |
reserva_por_expirar |
Usuario |
reserva_expirada |
Usuario |
reserva_cancelada_comercio |
Usuario |
publicacion_despublicada |
Usuario |
favorito_publico |
Usuario |
bloqueo_aplicado |
Usuario |
nueva_reserva |
Comercio |
cancelacion_usuario |
Comercio |
sin_publicacion |
Comercio |
valoracion_baja |
Comercio |
solicitud_resuelta |
Comercio |
incidencia_descargo |
Comercio |
La categoria se deriva del evento en un mapa fijo: transaccional para lo
que afecta una reserva viva, operativa para recordatorios y gestión,
promocional solo vía campana. Ese mapa lo impone
chk_notificacion_categoria_evento, generado desde la misma lista de Python
que alimenta el trigger de especificación de datos, para que la columna no
pueda desincronizarse del evento que la determina.
campana¶
Envío masivo segmentado, con vista previa y confirmación en dos pasos.
- Régimen: Mutable protegida
- Volumen estimado: unidades por semana
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
nombre |
varchar(120) |
no | Identificación interna |
canal |
varchar(10) |
no | pushemail |
plantilla_id |
uuid |
no | Llave foránea RESTRICT |
variables |
jsonb |
no | Valores fijos para la plantilla |
segmento |
jsonb |
no | Criterios de selección |
destinatarios_estimados |
integer |
sí | Resultado de la vista previa |
destinatarios_reales |
integer |
sí | Filas efectivamente generadas |
estado_id |
uuid |
no | Llave foránea a estado_campana |
programada_para |
timestamptz |
no | |
confirmada_at |
timestamptz |
sí | |
cancelada_at |
timestamptz |
sí | |
iniciada_at |
timestamptz |
sí | |
finalizada_at |
timestamptz |
sí | |
creada_by |
uuid |
no | Llave foránea RESTRICT a usuario |
confirmada_by |
uuid |
sí | Llave foránea RESTRICT a usuario |
segmento admite las claves: zona_ids, categoria_comida_ids,
comercio_ids (arreglos), con_reserva_desde, sin_reserva_desde,
solo_favoritos_de (texto), plataformas (arreglo).
Constraints¶
CONSTRAINT chk_campana_canal CHECK (canal IN ('push', 'email'))
CONSTRAINT chk_campana_confirmacion
CHECK ((confirmada_at IS NULL) = (confirmada_by IS NULL))
CONSTRAINT chk_campana_previsualizada
CHECK (confirmada_at IS NULL OR destinatarios_estimados IS NOT NULL)
CONSTRAINT chk_campana_desenlace_unico
CHECK (num_nonnulls(finalizada_at, cancelada_at) <= 1)
CONSTRAINT chk_campana_orden_temporal
CHECK (finalizada_at IS NULL OR (iniciada_at IS NOT NULL AND finalizada_at >= iniciada_at))
CONSTRAINT chk_campana_autor_distinto
CHECK (confirmada_by IS NULL OR confirmada_by <> creada_by)
CONSTRAINT chk_campana_nombre_longitud CHECK (length(nombre) >= 1)
CONSTRAINT chk_campana_variables_tipo CHECK (jsonb_typeof(variables) = 'object')
CONSTRAINT chk_campana_segmento_tipo CHECK (jsonb_typeof(segmento) = 'object')
CONSTRAINT chk_campana_destinatarios
CHECK (
(destinatarios_estimados IS NULL OR destinatarios_estimados >= 0)
AND (destinatarios_reales IS NULL OR destinatarios_reales >= 0)
)
CONSTRAINT chk_campana_finalizada_reales
CHECK (finalizada_at IS NULL OR destinatarios_reales IS NOT NULL)
La segunda es el espejo de chk_campana_previsualizada: si confirmar exige
haber estimado, terminar exige haber contado. RF-A-49 pide
registrar "el número real de destinatarios, que puede diferir del estimado",
y una campaña finalizada sin ese número no registra nada.
chk_campana_autor_distinto es una invariante de negocio explícita:
confirmación en dos pasos exige que quien crea no sea quien confirma. Si
el piloto opera con un solo administrador activo, esta constraint bloquea
toda campaña. Hay que validar con negocio el número real de
administradores antes de activar este módulo en producción; no se relaja
por decisión unilateral de implementación.
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_campana_segmento_especificacion |
INSERTUPDATE |
BEFORE |
ROW |
Cada clave de segmento es una de las siete, con su tipo JSON |
RF-A-48 |
trg_campana_variables_especificacion |
INSERTUPDATE |
BEFORE |
ROW |
variables trae cada marcador que la plantilla declara |
RF-A-50 |
trg_campana_limite_semanal |
UPDATE OF confirmada_at |
BEFORE |
ROW |
Campañas confirmadas de la semana dentro de campana.max_por_semana |
RF-A-48 |
trg_campana_plantilla |
INSERT |
BEFORE |
ROW |
La plantilla debe ser del mismo canal y estar vigente | RF-A-49 |
trg_campana_segmento_especificacion recorre jsonb_object_keys(segmento) y
exige que cada clave sea una de las siete reconocidas y que su tipo JSON
corresponda (arreglo o texto, según la clave). Mismo patrón que la
especificación de notificacion.datos.
trg_campana_variables_especificacion lee plantilla_mensaje.variables (los
marcadores que la plantilla declara) y exige que variables traiga cada uno.
trg_campana_limite_semanal corre al confirmar y no al crear, porque
RF-A-48 cuenta el límite "sobre las campañas confirmadas": un
borrador no consume cupo. Necesita algo que contar e indexar, semana_iso,
pero esa columna no puede ser generada: to_char sobre un timestamptz
depende del TimeZone de la sesión, así que PostgreSQL la clasifica
STABLE, no IMMUTABLE, y rechaza toda columna GENERATED ALWAYS AS que la
use. La deriva en su lugar trg_campana_semana_iso sobre una columna normal indexada
por idx_campana_semana. Ver Convenciones.
Notas de diseño¶
El segmento se guarda como jsonb y las notificaciones se materializan al
enviar, en lugar de resolver el segmento en cada consulta: es lo que
permite responder meses después a quién se le envió qué. Si el segmento se
reevaluara, un usuario que se dio de baja después desaparecería del
resultado y el registro de la campaña sería incompleto.
destinatarios_estimados y destinatarios_reales son dos columnas y no
una: la diferencia entre ambas es el número de personas que quedaron fuera
entre la vista previa y el envío, por preferencias, bajas o falta de
dispositivo.