Perfiles¶
Quién es cada cuenta dentro de hartazone: el perfil de
consumidor, la membresía de una persona en un comercio con su rol, la
invitación que da de alta a un operador, y la sanción que bloquea o
suspende a un consumidor incumplidor.
La definición de cada rol y de sus permisos no vive aquí: la aportan las tablas de grupos y permisos del subsistema de autenticación, que este módulo solo referencia por llave foránea.
Requerimientos cubiertos¶
perfil_consumidor¶
Datos de negocio de una cuenta que reserva. Relación 1:1 con la cuenta: teléfono, estado del ciclo de vida de consumidor y verificación, ninguno de los cuales vive en la tabla de cuentas del subsistema de autenticación.
- Régimen: Mutable rastreada
Columnas¶
| Campo | Tipo | Nulo | Predeterminado | Descripción |
|---|---|---|---|---|
cuenta_id |
uuid |
no | Llave foránea única a la cuenta | |
estado_id |
uuid |
no | Llave foránea a estado_usuario |
|
telefono_pais |
varchar(5) |
no | +505 |
|
telefono_numero |
varchar(8) |
no | '' |
8 dígitos, o vacío si aún no se declara |
telefono_verificado_at |
timestamptz |
sí | Sin mecanismo de envío automatizado todavía |
Constraints¶
CONSTRAINT chk_perfilconsumidor_telefono_numero
CHECK (telefono_numero = '' OR telefono_numero ~ '^\d{8}$')
CONSTRAINT chk_perfilconsumidor_telefono_pais
CHECK (telefono_pais ~ '^\+\d+')
CONSTRAINT chk_perfilconsumidor_verificacion
CHECK (telefono_verificado_at IS NULL OR length(telefono_numero) > 0)
Unicidad¶
| Nombre | Definición | Estado |
|---|---|---|
unq_perfilconsumidor_cuenta |
(cuenta_id) |
Vigente, por construcción (OneToOneField) |
unq_perfilconsumidor_telefono |
(telefono_pais, telefono_numero) WHERE telefono_numero <> '' |
Vigente |
Es un índice único parcial, no total: el valor de ausencia de
telefono_numero es '' y no NULL, así que sin el predicado todas
las cuentas sin teléfono declarado colisionarían entre sí. Es el mismo
criterio de predicados de índice parcial
aplicado a texto, con length(col) > 0 en vez de IS NULL. Es la garantía
que hace cierto lo que D-11 promete al justificar la tabla única
de cuentas: "con tablas separadas esa unicidad cruzada no es garantizable
en la base."
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_perfilconsumidor_canal |
INSERTUPDATE |
BEFORE |
ROW |
Un estado con require_canalverificado exige al menos un canal verificado |
RF-U-03 |
estado_usuario.require_canalverificado es el atributo semántico que este
trigger hace cumplir: sin él, nada impediría que un perfil llegara a
activo sin teléfono ni correo verificados, que es exactamente lo que
RF-U-03 prohíbe ("el sistema no admite una cuenta sin ningún
canal de contacto"). El trigger cruza telefono_verificado_at de esta tabla
con la verificación de correo de la cuenta, en el subsistema de
autenticación.
Notas de diseño¶
telefono_verificado_at se documenta porque el negocio lo necesita, pero
hoy solo se puede fijar a mano (por ejemplo, por un administrador): el
canal de verificación por SMS que un diseño anterior asumía no existe en el
subsistema de autenticación, que hoy solo verifica correo.
usuario_comercio¶
Membresía de una persona en un comercio. Una persona puede pertenecer a varios comercios, pero tiene un solo rol en cada uno.
- Régimen: Mutable rastreada
- Volumen estimado: 60-400 filas
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Predeterminado | Descripción |
|---|---|---|---|---|
usuario_id |
uuid |
no | Llave foránea | |
comercio_id |
uuid |
no | Llave foránea | |
grupo_id |
integer |
no | Llave foránea al grupo que define el rol | |
invitacion_id |
uuid |
sí | Nulo en el administrador inicial creado al aprobar el comercio | |
activado_at |
timestamptz |
sí | Instante en que aceptó la invitación | |
desactivado_at |
timestamptz |
sí | Marca de cierre | |
ultimo_ingreso_at |
timestamptz |
sí | Se muestra en el panel de usuarios del portal |
La membresía activa se identifica con is_active, la columna del régimen
mutable rastreada, no con una columna de negocio propia. Es la única tabla
del modelo donde el soft-delete heredado sí es el ciclo de vida de
negocio: desactivar a un operador y borrar su membresía son la misma
operación, y desactivado_at guarda cuándo ocurrió.
chk_usuariocomercio_desactivacion amarra las dos columnas para que no
puedan contradecirse; sin él, is_active y desactivado_at serían dos
fuentes de verdad sobre lo mismo.
Llaves foráneas¶
| Columna | Referencia | ON DELETE |
Notas |
|---|---|---|---|
usuario_id |
usuario |
RESTRICT |
|
comercio_id |
comercio |
RESTRICT |
|
grupo_id |
grupo |
RESTRICT |
Un grupo en uso no se elimina |
invitacion_id |
invitacion |
RESTRICT |
Constraints¶
CONSTRAINT chk_usuariocomercio_desactivacion
CHECK (is_active = (desactivado_at IS NULL))
CONSTRAINT chk_usuariocomercio_activacion
CHECK (desactivado_at IS NULL OR activado_at IS NOT NULL)
Unicidad¶
| Nombre | Definición | Propósito |
|---|---|---|
unq_usuariocomercio_par |
(usuario_id, comercio_id) |
Un solo rol por persona y comercio, para siempre |
unq_usuariocomercio_invitacion |
(invitacion_id) WHERE invitacion_id IS NOT NULL |
Una invitación produce a lo sumo una membresía |
Índices¶
El primero es además el índice de apoyo del trigger de tope de operadores.
| Nombre | Definición | Propósito |
|---|---|---|
idx_usuariocomercio_comercio |
(comercio_id, grupo_id) WHERE is_active |
Panel de usuarios del comercio y conteo de operadores |
idx_usuariocomercio_usuario |
(usuario_id) WHERE is_active |
Resolver a qué comercios puede entrar la sesión |
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_usuariocomercio_grupo |
INSERTUPDATE |
BEFORE |
ROW |
El grupo debe ser un rol de comercio, no un rol interno | RF-A-01 |
trg_usuariocomercio_maximo |
INSERTUPDATE |
BEFORE |
ROW |
Operadores activos dentro de comercio.max_operadores |
RF-C-13 |
trg_usuariocomercio_admin_minimo |
UPDATEDELETE |
BEFORE |
ROW |
Un comercio que puede publicar no se queda sin administrador activo | RF-C-12 |
trg_usuariocomercio_invitacion |
INSERT |
BEFORE |
ROW |
La invitación canjeada debe ser del mismo comercio y del mismo grupo | RF-C-13 |
trg_usuariocomercio_admin_minimo es la única regla del módulo que se
comprueba al retirar y no al agregar, y por eso tiene que mirar el estado
del comercio: la prohibición de RF-C-12 aplica a un comercio
"habilitado para publicar", de modo que el trigger lee
estado_comercio.allow_publicar antes de contar. Un comercio cancelado sí
puede quedarse sin administradores; uno afiliado, no.
trg_usuariocomercio_grupo cierra la otra mitad de RF-A-01,
cuya primera mitad vive en trg_invitacion_ambito: la llave foránea a
grupo admite cualquier grupo del sistema, incluidos los internos del panel
de administración, y una membresía de comercio que apunte a uno de ellos
otorgaría permisos internos por la puerta de atrás.
Notas de diseño¶
Ninguna fila de esta tabla se borra nunca (el borrado se reescribe como
is_active = false), lo que permite que validacion_reserva.validada_by
siga nombrando al operador que validó una entrega años después, aunque su
membresía ya esté desactivada.
invitacion¶
Alta de operadores de comercio. Es la única puerta de entrada a una cuenta de este tipo: no hay registro público para este rol.
- Régimen: Mutable protegida
- Volumen estimado: decenas de filas
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
email |
varchar(254) |
no | Destinatario |
comercio_id |
uuid |
sí | Con valor en invitaciones de comercio |
grupo_id |
integer |
no | Grupo que se asignará al aceptar |
estado_id |
uuid |
no | Llave foránea a estado_invitacion |
token |
varchar(128) |
no | Guarda el hash del token, nunca el token en claro |
expira_at |
timestamptz |
no | 72 horas desde la creación |
consumida_at |
timestamptz |
sí | Generada almacenada, marca de cierre |
aceptada_at |
timestamptz |
sí | |
revocada_at |
timestamptz |
sí | |
creada_by |
uuid |
no | Llave foránea RESTRICT a usuario |
usuario_creado_id |
uuid |
sí | Cuenta resultante del canje |
El token en claro se devuelve una sola vez al crear la invitación, para que la capa que la envía por correo lo tenga; nunca se vuelve a poder leer después de esa llamada.
consumida_at¶
consumida_at timestamptz
GENERATED ALWAYS AS (COALESCE(aceptada_at, revocada_at)) STORED
Almacenada porque sostiene los índices parciales de la tabla. Al derivarse de las dos marcas de desenlace no puede desincronizarse de ellas.
Llaves foráneas¶
| Columna | Referencia | ON DELETE |
Notas |
|---|---|---|---|
comercio_id |
comercio |
RESTRICT |
Nulo en invitaciones de ámbito interno |
grupo_id |
grupo |
RESTRICT |
|
estado_id |
estado_invitacion |
RESTRICT |
|
creada_by |
usuario |
RESTRICT |
|
usuario_creado_id |
usuario |
RESTRICT |
Constraints¶
CONSTRAINT chk_invitacion_vigencia CHECK (expira_at > created_at)
CONSTRAINT chk_invitacion_desenlace_unico
CHECK (num_nonnulls(aceptada_at, revocada_at) <= 1)
CONSTRAINT chk_invitacion_resultado
CHECK ((aceptada_at IS NULL) = (usuario_creado_id IS NULL))
CONSTRAINT chk_invitacion_aceptada_vigente
CHECK (aceptada_at IS NULL OR aceptada_at <= expira_at)
Sin ella, una invitación vencida se puede canjear igual escribiendo
aceptada_at después de expira_at, y las setenta y dos horas de
RF-C-07 dejan de ser un límite para volverse una sugerencia.
Unicidad¶
| Nombre | Definición | Propósito |
|---|---|---|
unq_invitacion_token |
(token) |
Un token identifica una invitación |
unq_invitacion_pendiente |
(lower(email), comercio_id) WHERE consumida_at IS NULL |
No se acumulan invitaciones vivas al mismo correo |
Índices¶
| Nombre | Definición | Propósito |
|---|---|---|
idx_invitacion_comercio |
(comercio_id) WHERE consumida_at IS NULL |
Panel de invitaciones pendientes |
idx_invitacion_expiracion |
(expira_at) WHERE consumida_at IS NULL |
Barrido de vencimiento |
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_invitacion_ambito |
INSERT |
BEFORE |
ROW |
Rol interno o comercio asociado, nunca ambos ni ninguno | RF-A-01 |
RF-A-01 es explícito: "una invitación no puede llevar a la vez
un rol interno y un comercio asociado".
La regla enlaza dos columnas de esta tabla con una columna de grupo, así
que no cabe en un CHECK: comercio_id IS NULL tiene que ser exactamente
lo mismo que "el grupo es interno", y solo la tabla de grupos sabe cuál es
cuál.
La consecuencia de no imponerla no es cosmética: una invitación con grupo interno y comercio produce, al canjearse, una cuenta con permisos de administración que entra por la puerta del portal de comercios.
Notas de diseño¶
Guardar solo el hash del token significa que, si la base se filtra, un atacante con la tabla completa no puede canjear ninguna invitación pendiente.
sancion_usuario¶
Bloqueo o suspensión de una cuenta de consumidor, automático o manual. Unifica el bloqueo por incumplimiento y la suspensión administrativa en un solo esquema.
- Régimen: Mutable protegida
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
perfil_id |
uuid |
no | Llave foránea RESTRICT a perfil_consumidor |
tipo |
varchar(20) |
no | bloqueo_reservassuspension_cuenta |
origen |
varchar(12) |
no | automaticomanual |
motivo_id |
uuid |
sí | Llave foránea RESTRICT a motivo, obligatoria si origen = 'manual' |
nota |
text |
no | '' |
inicio_at |
timestamptz |
no | |
fin_at |
timestamptz |
sí | Nulo, indefinida |
levantada_at |
timestamptz |
sí | |
levantada_by |
uuid |
sí | Llave foránea RESTRICT a usuario |
motivo_levantamiento |
text |
no | '' |
creada_by |
uuid |
sí | Llave foránea RESTRICT a usuario, nula si origen = 'automatico' |
expiradas_consideradas |
smallint |
sí | Snapshot de sancion.expiradas_para_bloqueo al aplicarse, por D-16 |
duracion_dias_aplicada |
smallint |
sí | Snapshot de sancion.duracion_bloqueo_dias al aplicarse, por D-16 |
Constraints¶
CONSTRAINT chk_sancionusuario_vigencia
CHECK (fin_at IS NULL OR fin_at > inicio_at)
CONSTRAINT chk_sancionusuario_autor
CHECK (
(origen = 'manual' AND creada_by IS NOT NULL)
OR (origen = 'automatico' AND creada_by IS NULL)
)
CONSTRAINT chk_sancionusuario_motivo_manual
CHECK (origen <> 'manual' OR motivo_id IS NOT NULL)
CONSTRAINT chk_sancionusuario_levantamiento
CHECK ((levantada_at IS NULL) = (levantada_by IS NULL))
CONSTRAINT chk_sancionusuario_levantamiento_orden
CHECK (levantada_at IS NULL OR levantada_at >= inicio_at)
CONSTRAINT chk_sancionusuario_levantamiento_motivo
CHECK (levantada_at IS NULL OR length(motivo_levantamiento) > 0)
RF-A-19 exige que levantar una sanción anticipadamente sea "indicando
motivo", y hoy motivo_levantamiento puede quedar vacío.
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_sancionusuario_vigente_unica |
INSERTUPDATE |
BEFORE |
ROW |
Una sola sanción vigente por (perfil_id, tipo) |
RF-A-20 |
trg_sancionusuario_aplica |
INSERT |
AFTER |
ROW |
Una suspension_cuenta mueve el estado del perfil a suspendido |
RF-A-20 |
trg_sancionusuario_vigente_unica es la regla que esta ficha ya declaraba
como necesariamente de trigger: "vigente" significa
levantada_at IS NULL AND (fin_at IS NULL OR fin_at > now()), y now() no es
inmutable, así que no hay índice único parcial que la exprese. El trigger
cuenta las filas vigentes del mismo (perfil_id, tipo) y rechaza si ya hay
una, que es exactamente lo que pide RF-A-20.
El detalle congelado de la sanción automática¶
RF-A-19 exige mostrar "el detalle exacto de las reservas
expiradas que la originaron, congelado al momento de aplicarse, no
recalculado con los parámetros actuales", y RF-A-39 que "el
valor vigente al aplicarse queda congelado en la sanción". Dos piezas lo
resuelven:
- Las columnas
expiradas_consideradasyduracion_dias_aplicada, snapshot desancion.expiradas_para_bloqueoysancion.duracion_bloqueo_diasal aplicarse, porD-16. - La tabla
sancion_usuario_reserva(sancion_id,reserva_id,append-only), quetrg_reserva_sancion_automaticallena con las reservas expiradas concretas que motivaron el bloqueo.
Sin ellas, la pantalla que RF-U-37 promete al usuario ("las
reservas concretas que originaron el bloqueo") tendría que recalcularse con
los parámetros de hoy, y podría dejar de cuadrar con la sanción que está
explicando.