Saltar a contenido

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.

Columnas

Campo Tipo Nulo Predeterminado Descripción
usuario_id uuid no Llave foránea RESTRICT
plataforma varchar(10) no ios
android
web
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.

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 push
sms
email
categoria varchar(20) no transaccional
operativa
promocional
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 INSERT
UPDATE
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 push
sms
email
in_app
categoria varchar(16) no transaccional
operativa
promocional
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 INSERT
UPDATE
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.

Columnas

Campo Tipo Nulo Descripción
nombre varchar(120) no Identificación interna
canal varchar(10) no push
email
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 INSERT
UPDATE
BEFORE ROW Cada clave de segmento es una de las siete, con su tipo JSON RF-A-48
trg_campana_variables_especificacion INSERT
UPDATE
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.