Catálogos¶
Datos que administración gobierna sin desplegar código: catálogos de negocio, parámetros del motor, plantillas de mensaje, archivos y consentimiento legal.
Las tablas de estado siguen el mismo principio pero tienen su propia página:
Estados.
Requerimientos cubiertos¶
Alérgenos
El sistema no administra un catálogo de alérgenos. La declaración es responsabilidad del comercio dentro del texto de su publicación, no un dato estructurado que la plataforma administre y por tanto parezca garantizar.
parametro_sistema¶
Los valores que gobiernan el motor de reservas, en una sola columna valor
sin tipar en vez de una columna física por tipo: la forma de la fila sería
fea (tres NULL siempre presentes) para ganar una tipificación que un
trigger da igual de estricta.
- Régimen: Mutable protegida
- Volumen estimado: 23 filas activas, ampliable sin migración
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
clave |
varchar(60) |
no | Única, con espacio de nombres: reserva.max_unidades |
grupo |
varchar(20) |
no | usuarioincumplimientocomercioprecioverificacionimpactoalertascomunicacion |
tipo_dato |
varchar(16) |
no | enterodecimalduracion_minporcentajebooleanolista_entero |
valor |
jsonb |
no | Un Decimal se guarda como string JSON, nunca como number |
descripcion |
text |
no | Vacía por defecto |
actualizado_by |
uuid |
sí | Llave foránea RESTRICT a usuario |
Constraints¶
Solo dos constraints declarativas; la tercera regla, la que importa de verdad, es un trigger:
CONSTRAINT unq_parametrosistema_clave UNIQUE (clave)
CONSTRAINT chk_parametrosistema_valor_tipo
CHECK (
(tipo_dato IN ('entero', 'duracion_min', 'decimal', 'porcentaje')
AND jsonb_typeof(valor) IN ('number', 'string'))
OR (tipo_dato = 'booleano' AND jsonb_typeof(valor) = 'boolean')
OR (tipo_dato = 'lista_entero' AND jsonb_typeof(valor) = 'array')
)
chk_parametrosistema_valor_tipo es la barrera barata de primera línea:
descarta la forma gruesa equivocada (un objeto donde se espera un número)
sin riesgo de reventar, porque jsonb_typeof nunca lanza error.
trg_parametrosistema_especificacion¶
BEFORE INSERT OR UPDATE, ROW. Reemplaza lo que sería un segundo CHECK
de veintitrés ramas: Postgres no garantiza evaluar OR de izquierda a
derecha, así que una expresión pura arriesga castear a numeric el valor
de una fila que en realidad es un array o un booleano de otra clave, y
revienta con un error de casteo en vez de simplemente rechazar la fila. Un
trigger con CASE NEW.clave WHEN ... no tiene ese problema: cada rama
numérica solo se evalúa cuando su propia clave coincidió.
Por cada clave conocida, la rama generada verifica que grupo/tipo_dato
correspondan, extrae valor como número ((NEW.valor #>> '{}')::numeric,
dentro de un bloque que convierte cualquier fallo de casteo en un mensaje
limpio) y lo compara contra el rango declarado. Una clave que no coincide
con ningún WHEN cae en el ELSE y se rechaza. La única rama que no sigue
este patrón escalar es exploracion.distancias_km, que recorre
jsonb_array_elements(valor) y exige que cada elemento sea numérico y
positivo.
Cualquier violación se levanta como RAISE check_violation, la misma
condición real de Postgres que dispara un CHECK, así Django la sigue
clasificando como IntegrityError sin que el llamador necesite conocer el
código 23514 a mano.
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_parametrosistema_especificacion |
INSERTUPDATE |
BEFORE |
ROW |
grupo, tipo_dato y rango de valor por clave |
RF-A-37 |
trg_parametrosistema_coherencia |
INSERTUPDATE |
BEFORE |
ROW |
Coherencia entre parámetros de un mismo par | RF-A-40 |
trg_parametrosistema_autor |
UPDATE OF valor |
BEFORE |
ROW |
actualizado_by obligatorio al cambiar un valor |
RF-A-37 |
trg_parametrosistema_readonly_clave |
UPDATE OF clave |
BEFORE |
ROW |
clave es inmutable una vez creada |
RF-A-37 |
trg_parametrosistema_coherencia cubre las dos parejas de parámetros que no
tienen sentido por separado: ventana.duracion_min_minutos no puede superar a
ventana.duracion_max_minutos, y precio.descuento_minimo_pct no puede
superar a precio.descuento_revision_pct. Son reglas entre filas distintas
de la misma tabla, así que ningún CHECK las alcanza y el CASE del
trigger de especificación tampoco: cada rama de ese CASE solo ve la fila que
se está escribiendo.
trg_parametrosistema_readonly_clave es lo que hace confiable al resto.
Cambiar la clave de una fila la haría caer en otra rama del CASE con un
valor validado contra el rango equivocado, y todo trigger que lea
hz_parametro('reserva.max_activas') empezaría a leer otra cosa.
RF-A-37 exige historial y reversión del valor, no de la
identidad del parámetro.
Los 23 parámetros y su rango válido¶
clave |
grupo |
tipo_dato |
Rango |
|---|---|---|---|
reserva.max_unidades |
usuario | entero | 1 – 50 |
reserva.max_activas |
usuario | entero | 1 – 20 |
reserva.max_activas_por_comercio |
usuario | entero | 1 – 20 |
reserva.retencion_min |
usuario | duracion_min | 1 – 60 |
reserva.plazo_cancelacion_min |
usuario | duracion_min | 0 – 1440 |
valoracion.plazo_horas |
usuario | entero | 1 – 720 |
exploracion.distancias_km |
usuario | lista_entero | cada elemento > 0 |
sancion.expiradas_para_bloqueo |
incumplimiento | entero | 1 – 20 |
sancion.periodo_conteo_dias |
incumplimiento | entero | 1 – 365 |
sancion.duracion_bloqueo_dias |
incumplimiento | entero | 1 – 365 |
ventana.duracion_min_minutos |
comercio | duracion_min | 5 – 1440 |
ventana.duracion_max_minutos |
comercio | duracion_min | 5 – 1440 |
ventana.max_por_dia |
comercio | entero | 1 – 20 |
publicacion.horizonte_horas |
comercio | entero | 1 – 720 |
publicacion.corte_reservas_min |
comercio | duracion_min | 0 – 1440 |
validacion.tolerancia_min |
comercio | duracion_min | 0 – 1440 |
plantilla.max_activas |
comercio | entero | 1 – 50 |
comercio.max_operadores |
comercio | entero | 1 – 50 |
precio.minimo |
precio | decimal | ≥ 0.01 |
precio.descuento_minimo_pct |
precio | porcentaje | 0 – 100 |
precio.descuento_revision_pct |
precio | porcentaje | 0 – 100 |
publicacion.max_unidades |
precio | entero | 1 – 1000 |
campana.max_por_semana |
alertas | entero | 0 – 50 |
Los grupos verificacion, impacto y comunicacion existen en el
TextChoices de grupo sin ningún parámetro activo todavía. Quedan
reservados para cuando ese vocabulario tenga una clave concreta que
necesite validarse, sin requerir una migración para agregar el grupo en sí.
Notas de diseño¶
valor es jsonb y no columnas tipadas porque los parámetros son
heterogéneos: enteros, minutos, porcentajes con decimal y una lista de
distancias. El costo de esa flexibilidad, que nada fuera de la aplicación
impida un valor con la forma equivocada, lo cierra el trigger, no una
convención de servicio: un UPDATE hecho desde el admin de Django, una
consola o una migración de datos futura pasa por la misma barrera.
No existe una tabla parametro_cambio para historial o reversión: la
tabla de eventos gemela que genera el régimen mutable protegida ya guarda el
estado completo de la fila en cada UPDATE. Reconstruir un valor anterior o
revertir un cambio es una consulta sobre esa tabla de eventos, no un modelo
aparte.
motivo¶
Catálogo único de motivos de lista cerrada, discriminado por contexto.
- Régimen: Mutable rastreada
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
contexto |
varchar(30) |
no | Dónde se ofrece este motivo |
codigo |
varchar(50) |
no | Estable |
etiqueta |
varchar(80) |
no | Visible |
require_nota |
boolean |
no | Implementa "otro con nota obligatoria" |
visible_para |
varchar(20) |
no | usuariocomercioadministraciontodos |
activo |
boolean |
no |
Constraints¶
CONSTRAINT chk_motivo_codigo_format
CHECK (codigo ~ '^[a-z][a-z0-9_]*$')
CONSTRAINT chk_motivo_contexto
CHECK (
contexto IN (
'cancelacion_usuario', 'cancelacion_comercio', 'cancelacion_administrativa',
'rechazo_afiliacion', 'suspension_comercio', 'suspension_usuario',
'despublicacion', 'incidencia_categoria', 'entrega_parcial',
'pausa_comercio', 'moderacion_valoracion', 'rechazo_cambio_comercio'
)
)
CONSTRAINT chk_motivo_visible_para
CHECK (visible_para IN ('usuario', 'comercio', 'administracion', 'todos'))
Unicidad¶
| Nombre | Definición |
|---|---|
unq_motivo_contexto_codigo |
(contexto, codigo) |
Índices¶
| Nombre | Definición |
|---|---|
idx_motivo_contexto_isactive |
(contexto) WHERE is_active |
Notas de diseño¶
Una sola tabla en lugar de doce evita doce pantallas de administración con la misma lógica, y los requerimientos describen la gestión de motivos como un módulo único.
require_nota lo consume la familia de triggers trg_<tabla>_motivo_<papel>, una instancia por cada llave foránea a esta tabla. Cada una
lo lee desde aquí en vez de copiarlo, porque ninguna de esas reglas alimenta un
índice parcial.
Esa familia es además la única barrera contra el riesgo estructural de tener
un catálogo discriminado: hoy nada impide que
reserva.motivo_id apunte a un motivo de rechazo_afiliacion. La llave
foránea garantiza que el motivo existe, no que sea del contexto correcto, y
un CHECK no puede consultar la columna contexto de la otra tabla.
categoria_comida¶
- Régimen: Mutable rastreada
- Origen:
Columnas¶
codigo, etiqueta, icono (vacío por defecto), activa.
Constraints¶
CONSTRAINT chk_categoriacomida_codigo_format
CHECK (codigo ~ '^[a-z][a-z0-9_]*$')
Unicidad¶
| Nombre | Definición |
|---|---|
unq_categoriacomida_codigo |
(codigo) |
Índices¶
| Nombre | Definición |
|---|---|
idx_categoriacomida_codigo |
(codigo) WHERE is_active |
Notas de diseño¶
Referenciada con ON DELETE RESTRICT en comercio, plantilla_oferta,
publicacion y factor_impacto. Como una categoría con ofertas asociadas
solo se desactiva, la llave foránea convierte el borrado en imposible en
lugar de improbable.
tipo_negocio¶
Catálogo simple.
- Régimen: Mutable rastreada
- Filas semilla:
restaurantepanaderiacafeteriaotro
Columnas¶
codigo, etiqueta, activo.
Constraints¶
CONSTRAINT chk_tiponegocio_codigo_format
CHECK (codigo ~ '^[a-z][a-z0-9_]*$')
Unicidad¶
| Nombre | Definición |
|---|---|
unq_tiponegocio_codigo |
(codigo) |
factor_impacto¶
Factores de conversión por categoría, versionados por vigencia.
- Régimen: Mutable protegida, no
append-onlypuro: cerrar la vigencia de un factor anterior al insertar uno nuevo requiere unUPDATEacotado sobrevigencia, que ese régimen no admite. - Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
categoria_comida_id |
uuid |
no | Llave foránea RESTRICT |
kg_por_unidad |
numeric(6,3) |
no | Kilogramos estimados por unidad rescatada |
co2e_por_kg |
numeric(6,3) |
no | Kilogramos de CO₂e evitados por kilogramo |
vigencia |
daterange |
no | [), con extremo superior abierto si sigue vigente |
creado_by |
uuid |
no | Llave foránea RESTRICT a usuario |
Constraints¶
CONSTRAINT chk_factorimpacto_kg_por_unidad CHECK (kg_por_unidad > 0)
CONSTRAINT chk_factorimpacto_co2e_por_kg CHECK (co2e_por_kg > 0)
CONSTRAINT chk_factorimpacto_vigencia_no_vacia CHECK (NOT isempty(vigencia))
CONSTRAINT excl_factorimpacto_vigencia
EXCLUDE USING gist (categoria_comida_id WITH =, vigencia WITH &&)
Un factor en cero o negativo contamina de forma permanente cualquier
cálculo de impacto que lo use, y el régimen de esta tabla no permite
corregirlo después salvo cerrando su vigencia, de ahí que ambos límites
sean CHECK, no una validación de trigger.
Índices¶
| Nombre | Definición |
|---|---|
idx_factorimpacto_vigente |
(categoria_comida_id) WHERE upper_inf(vigencia) |
Notas de diseño¶
Los factores son tabla versionada y no columnas de categoria_comida porque
un reporte de impacto ya generado debe seguir siendo defendible después de
que el factor cambie. Se usa daterange en lugar de dos columnas porque el
operador de solapamiento && es lo que hace posible la restricción de
exclusión, que garantiza que una categoría no tenga dos factores vigentes el
mismo día.
zona¶
Barrios y zonas seleccionables como ubicación manual.
- Régimen: Mutable rastreada
- Origen:
Columnas¶
nombre, municipio_id (llave foránea RESTRICT a municipio),
punto_referencia (geography(Point,4326), obligatorio), poligono
(geography(Polygon,4326), opcional).
Unicidad¶
| Nombre | Definición | Estado |
|---|---|---|
unq_zona_ubicacion |
(punto_referencia, poligono) |
Vigente |
unq_zona_nombre_municipio |
(municipio_id, upper(immutable_unaccent(nombre))) |
Vigente |
Índices¶
| Nombre | Definición | Propósito |
|---|---|---|
gist_zona_poligono |
GIST (poligono) WHERE poligono IS NOT NULL |
Sostiene la derivación automática de comercio.zona_id |
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_zona_punto_en_poligono |
INSERTUPDATE |
BEFORE |
ROW |
Con polígono, punto_referencia debe caer dentro de él |
RF-A-47 |
trg_zona_poligono_valido |
INSERTUPDATE |
BEFORE |
ROW |
El polígono debe ser una geometría válida y cerrada | RF-A-47 |
Ninguno puede ser CHECK; ST_Covers y ST_IsValid sobre geography son
STABLE, no IMMUTABLE, y una restricción de verificación solo admite
expresiones inmutables.
Un polígono autointersecante no lanza error al insertarse; lanza resultados silenciosamente incoherentes cada vez que alguien pregunta si un comercio cae dentro, que es justo lo que este catálogo existe para responder.
Notas de diseño¶
El polígono es opcional: una zona sirve para que el usuario elija ubicación
manual con solo el punto de referencia. Cuando existe, además permite
asignar comercios a zona sin intervención manual, mediante
trg_comercio_zona.
departamento, municipio¶
División política administrativa, dos niveles. zona cuelga de municipio y
municipio de departamento.
- Régimen: [
append-only]]auditoria - Volumen estimado: 17 departamentos, 153 municipios
Columnas¶
departamento:nombre(varchar(30)).municipio:departamento_id(llave foráneaRESTRICT) ynombre(varchar(30)).
Unicidad¶
| Nombre | Definición |
|---|---|
unq_departamento_nombre |
(upper(immutable_unaccent(nombre))) |
unq_municipio_nombre_departamento |
(departamento_id, upper(immutable_unaccent(nombre))) |
Ambas normalizan con upper(immutable_unaccent(...)) por la misma razón que
los índices de búsqueda de comercio: "León" y "Leon" son el mismo municipio,
y admitir los dos rompería la asignación de zona del comercio que los cita.
zona_cobertura¶
Área geográfica habilitada del piloto. Versionada por vigencia: nunca se sobrescribe, para no invalidar el motivo histórico de un rechazo de afiliación fuera de cobertura.
- Régimen: Mutable rastreada
- Origen:
RF-A-31
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
tipo |
varchar(10) |
no | radiopoligono |
centro |
geography(Point,4326) |
sí | Solo si tipo = 'radio' |
radio |
integer |
sí | Metros; solo si tipo = 'radio' |
poligono |
geography(Polygon,4326) |
sí | Solo si tipo = 'poligono' |
vigente_desde |
timestamptz |
no | |
vigente_hasta |
timestamptz |
sí | Marca de cierre |
Constraints¶
CONSTRAINT chk_zonacobertura_variante
CHECK (
(tipo = 'radio' AND centro IS NOT NULL AND radio IS NOT NULL AND poligono IS NULL)
OR
(tipo = 'poligono' AND poligono IS NOT NULL AND centro IS NULL AND radio IS NULL)
)
CONSTRAINT chk_zonacobertura_radio
CHECK (radio IS NULL OR radio BETWEEN 100 AND 100000)
CONSTRAINT chk_zonacobertura_vigencia_orden
CHECK (vigente_hasta IS NULL OR vigente_hasta > vigente_desde)
Sin ella, una vigencia invertida deja el área sin ningún instante en que esté activa, y las solicitudes se marcan fuera de cobertura sin que nada lo explique.
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_zonacobertura_poligono_valido |
INSERTUPDATE |
BEFORE |
ROW |
El polígono debe ser una geometría válida | RF-A-31 |
Igual que su gemelo en zona: un área de cobertura mal formada rechaza
afiliaciones legítimas y las acepta al revés, sin producir nunca un error.
Notas de diseño¶
Se versiona por vigencia y no se sobrescribe porque ampliar la cobertura no debe invalidar el motivo por el que se rechazó una solicitud el mes pasado.
plantilla_mensaje¶
Textos de las notificaciones automáticas, versionados.
- Régimen: Mutable rastreada
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
codigo |
varchar(50) |
no | Nombre del evento que la dispara |
canal |
varchar(10) |
no | pushsmsemail |
version |
integer |
no | Incremental por (codigo, canal) |
asunto |
varchar(150) |
no | Vacío salvo en email |
cuerpo |
text |
no | Con marcadores de variable |
variables |
text[] |
no | Nombres de los marcadores que cuerpo espera |
estado_id |
uuid |
no | Llave foránea a estado_plantilla_mensaje |
vigente_desde |
timestamptz |
sí | |
vigente_hasta |
timestamptz |
sí | Marca de cierre |
creada_by |
uuid |
no | Llave foránea RESTRICT a usuario |
variables es un arreglo nativo de texto, no un segundo jsonb describiendo
la forma esperada por convención, mismo criterio que
alerta_especificacion.claves_requeridas más abajo: Postgres exige por tipo
de columna que sea una lista de texto, sin necesitar un CHECK de
jsonb_typeof.
Constraints¶
CONSTRAINT chk_plantillamensaje_version CHECK (version >= 1)
CONSTRAINT chk_plantillamensaje_canal CHECK (canal IN ('push', 'sms', 'email'))
CONSTRAINT chk_plantillamensaje_asunto_email
CHECK (canal <> 'email' OR length(asunto) > 0)
CONSTRAINT chk_plantillamensaje_vigencia_coherente
CHECK (vigente_hasta IS NULL OR vigente_desde IS NOT NULL)
Unicidad¶
| Nombre | Definición |
|---|---|
unq_plantillamensaje_codigo_canal_version |
(codigo, canal, version) |
No hay una constraint de base de datos para "una sola plantilla vigente por
código y canal": esa unicidad depende de una columna del catálogo de estado
relacionado (estado_plantilla_mensaje.vigente), no de una columna propia,
así que Django no puede expresarla como índice único parcial. La impone
trg_plantillamensaje_vigente_unica.
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_plantillamensaje_vigente_unica |
INSERTUPDATE |
BEFORE |
ROW |
Una sola fila con estado vigente por (codigo, canal) |
RF-A-50 |
trg_plantillamensaje_version |
INSERT |
BEFORE |
ROW |
version es el consecutivo siguiente de su (codigo, canal) |
RF-A-50 |
trg_plantillamensaje_variables |
INSERTUPDATE |
BEFORE |
ROW |
variables y los marcadores de cuerpo deben coincidir exactamente |
RF-A-50 |
trg_plantillamensaje_allow_edit |
UPDATE |
BEFORE |
ROW |
asunto, cuerpo y variables solo cambian si el estado tiene allow_edit |
RF-A-50 |
trg_plantillamensaje_variables recorre cuerpo con una expresión regular y
compara el conjunto de marcadores encontrados contra el arreglo variables,
en las dos direcciones: un marcador sin declarar se enviaría literal al
usuario, y una variable declarada de más haría fallar a
trg_campana_variables_especificacion, que exige que la campaña traiga cada
una de ellas.
trg_plantillamensaje_allow_edit es lo que hace real al versionado. Sin él,
allow_edit del catálogo de estado es una etiqueta que nadie lee, y editar el
cuerpo de la versión vigente reescribiría el texto que
RF-A-50 promete poder reconstruir desde cada notificacion
enviada.
Notas de diseño¶
Cada notificacion enviada guarda plantilla_id, de modo que el texto
exacto que recibió el usuario es reconstruible aunque la plantilla se haya
editado después: el snapshot ocurre dentro del registro que consume el
dato volátil, mismo criterio que D-15.
media¶
Tabla única de archivos.
- Régimen: Mutable protegida
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
tipo |
varchar(20) |
no | logofoto_localfoto_ofertafachadalicenciaavatarexportacion |
mime |
varchar(100) |
no | |
bytes |
integer |
no | |
width |
integer |
sí | Nulo en documentos |
height |
integer |
sí | |
storage_key |
varchar(255) |
no | Ruta en el almacenamiento de objetos, única |
variantes |
jsonb |
no | Un objeto por variante generada, ver el trigger de especificación abajo |
checksum |
varchar(64) |
no | |
subida_by |
uuid |
no | Llave foránea RESTRICT a usuario |
Constraints¶
CONSTRAINT chk_media_bytes CHECK (bytes > 0 AND bytes <= 5242880)
CONSTRAINT chk_media_dimensiones_coherentes
CHECK ((width IS NULL) = (height IS NULL))
CONSTRAINT chk_media_logo_dimensiones
CHECK (tipo <> 'logo' OR (width >= 400 AND height >= 400))
CONSTRAINT chk_media_tipo
CHECK (tipo IN ('logo', 'foto_local', 'foto_oferta', 'fachada', 'licencia', 'avatar', 'exportacion'))
CONSTRAINT chk_media_variantes_tipo
CHECK (jsonb_typeof(variantes) = 'object')
CONSTRAINT chk_media_checksum_formato CHECK (checksum ~ '^[0-9a-f]{64}$')
CONSTRAINT chk_media_mime_formato CHECK (mime ~ '^[a-z]+/[a-zA-Z0-9.+-]+$')
CONSTRAINT chk_media_imagen_dimensiones
CHECK (mime NOT LIKE 'image/%' OR width IS NOT NULL)
checksum se declara como SHA-256 en la ficha y nada lo obliga a serlo; una
cadena vacía pasa hoy sin objeción, y el checksum existe precisamente para
detectar que el archivo cambió.
Unicidad¶
| Nombre | Definición |
|---|---|
unq_media_storagekey |
(storage_key) |
trg_media_variantes_especificacion¶
BEFORE INSERT OR UPDATE, ROW. variantes guarda una clave por variante
generada (por ejemplo, un tamaño comprimido para el modo bajo consumo de
datos); el trigger recorre jsonb_object_keys(variantes) y exige que cada
entrada sea un objeto, y que sus campos storage_key/width/height,
cuando están presentes, tengan el tipo JSON correcto (string o number). Es
el mismo problema que resuelve chk_parametrosistema_valor_tipo aplicado a
un jsonb con forma libre: sin esta barrera, nada impide una variante con
width: "<número enorme>" insertada por fuera del servicio que arma la respuesta.
Notas de diseño¶
El límite de 5 MB y el mínimo de dimensiones del logo viven en la base, no en
el formulario: una validación de formulario protege contra el usuario
distraído, un CHECK protege además contra el script de carga masiva, la
migración de datos y el segundo cliente que alguien escriba dentro de un
año.
documento_legal¶
Versiones publicadas de términos de uso, privacidad y términos comerciales.
- Régimen: Mutable protegida, no
append-onlypuro, misma desviación quefactor_impacto: cerrar la vigencia de la versión anterior al publicar la siguiente exige unUPDATEacotado. - Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
tipo |
varchar(20) |
no | terminos_usuarioprivacidadterminos_comercio |
version |
smallint |
no | |
hash |
varchar(64) |
no | SHA-256 del texto publicado; prueba de que lo aceptado sigue siendo lo vigente |
url |
varchar |
no | |
vigencia |
daterange |
no | [), media abierta si sigue vigente |
creado_by |
uuid |
no | Llave foránea RESTRICT a usuario |
Constraints¶
CONSTRAINT chk_documentolegal_tipo
CHECK (tipo IN ('terminos_usuario', 'privacidad', 'terminos_comercio'))
CONSTRAINT chk_documentolegal_vigencia_no_vacia CHECK (NOT isempty(vigencia))
CONSTRAINT excl_documentolegal_vigencia
EXCLUDE USING gist (tipo WITH =, vigencia WITH &&)
CONSTRAINT chk_documentolegal_hash_formato CHECK (hash ~ '^[0-9a-f]{64}$')
Unicidad¶
| Nombre | Definición |
|---|---|
unq_documentolegal_tipo_version |
(tipo, version) |
Índices¶
| Nombre | Definición |
|---|---|
idx_documentolegal_vigente |
(tipo) WHERE upper_inf(vigencia) |
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_readonly_snapshot |
UPDATE OF tipoUPDATE OF versionUPDATE OF urlUPDATE OF hash |
BEFORE |
ROW |
Esas cuatro columnas son inmutables | D-07 |
trg_documentolegal_version |
INSERT |
BEFORE |
ROW |
version es el consecutivo siguiente de su tipo, y su vigencia empieza donde terminó la anterior |
RF-A-47 |
trg_documentolegal_version cubre lo que excl_documentolegal_vigencia no
alcanza: la exclusión impide que dos versiones se solapen, pero no impide que
la versión 5 vigente desde enero conviva con la versión 3 vigente desde
marzo si alguien las crea fuera de orden. El trigger deriva version como el
consecutivo del tipo y encadena vigencia a partir de donde terminó la
anterior, de modo que reconstruir "qué estaba vigente cuando este usuario
aceptó" siempre es una lectura directa del número de versión.
trg_readonly_snapshot cubre tipo, version, url y hash: el texto
publicado nunca se edita ni se re-etiqueta. Lo único mutable de una fila ya
creada es el cierre de su propia vigencia al publicar la siguiente versión,
que es exactamente el UPDATE acotado que motiva el régimen mutable protegida
en vez de append-only.
aceptacion_terminos¶
Registro de que una cuenta aceptó una versión concreta de un documento legal.
- Régimen:
append-only: una aceptación no se edita ni se retira nunca; retirar el consentimiento es aceptar (o no) la versión siguiente, no la desaparición de la anterior.
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
usuario_id |
uuid |
no | Llave foránea RESTRICT |
documento_id |
uuid |
no | Llave foránea RESTRICT a documento_legal |
aceptado_at |
timestamptz |
no | Instante declarado de la aceptación |
Constraints¶
CONSTRAINT chk_aceptacionterminos_aceptadoat
CHECK (aceptado_at > created_at)
Que el instante declarado sea posterior a created_at (el instante en que
la fila realmente se guardó) descarta una aceptación cuyo reloj declarado
adelanta al del servidor.
Unicidad¶
| Nombre | Definición |
|---|---|
unq_aceptacionterminos_ambito |
(usuario_id, documento_id) |
Índices¶
| Nombre | Definición |
|---|---|
idx_aceptacionterminos_aceptadoat |
(aceptado_at) |
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_aceptacionterminos_vigencia |
INSERT |
BEFORE |
ROW |
El documento aceptado debe estar vigente en aceptado_at |
RF-U-05 |
Sin él, nada impediría registrar la aceptación de una versión retirada hace
un año, y la tabla existe para poder demostrar lo contrario:
RF-U-05 y RF-C-08 piden constancia de qué versión
aceptó cada quien, y una constancia que admite versiones imposibles no
prueba nada. El trigger compara aceptado_at::date contra el daterange
del documento.
Notas de diseño¶
Cualquier ApiUser puede aceptar, no solo quienes tienen perfil de
consumidor: el personal de comercio también acepta los términos
comerciales. Registrar una aceptación es idempotente por construcción: la
unicidad (usuario_id, documento_id) hace que un segundo intento sobre el
mismo par no duplique fila.
alerta_especificacion¶
Especificación de cada disparador válido de alerta: mismo problema que
resolvía parametro_sistema antes de su diseño actual: alerta.datos es un
jsonb de forma libre, y sin esta tabla nada fuera de la aplicación impide
un datos incompleto o con las claves equivocadas.
- Régimen: Mutable rastreada
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
codigo |
varchar(40) |
no | Código del disparador concreto, único |
tipo |
varchar(16) |
no | moderacioncumplimientoseguridad |
entidad_tipo |
varchar(20) |
no | publicacioncomerciousuarioreservaparametroexportacion |
claves_requeridas |
text[] |
no | Claves que alerta.datos debe traer para este codigo |
Constraints¶
CONSTRAINT chk_alertaespecificacion_codigo_format
CHECK (codigo ~ '^[a-z][a-z0-9_]*$')
CONSTRAINT chk_alertaespecificacion_tipo
CHECK (tipo IN ('moderacion', 'cumplimiento', 'seguridad'))
CONSTRAINT chk_alertaespecificacion_entidad_tipo
CHECK (entidad_tipo IN ('publicacion', 'comercio', 'usuario', 'reserva', 'parametro', 'exportacion'))
Unicidad¶
| Nombre | Definición |
|---|---|
unq_alertaespecificacion_codigo |
(codigo) |
Notas de diseño¶
claves_requeridas es text[] nativo, no un segundo jsonb describiendo la
forma de otro: el mismo error de diseño que tenía parametro_sistema
antes de fijar una sola columna valor. El trigger que consume esta tabla
vive en alerta, documentado en la ficha de esa tabla.
Retirar un codigo es marcarlo inactivo, no un DELETE: alerta.codigo no
lleva llave foránea (es una referencia polimórfica), así que las alertas
históricas con ese código siguen existiendo sin problema.