Saltar a contenido

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.

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 INSERT
UPDATE
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.

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 INSERT
UPDATE
BEFORE ROW El grupo debe ser un rol de comercio, no un rol interno RF-A-01
trg_usuariocomercio_maximo INSERT
UPDATE
BEFORE ROW Operadores activos dentro de comercio.max_operadores RF-C-13
trg_usuariocomercio_admin_minimo UPDATE
DELETE
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.

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.

Columnas

Campo Tipo Nulo Descripción
perfil_id uuid no Llave foránea RESTRICT a perfil_consumidor
tipo varchar(20) no bloqueo_reservas
suspension_cuenta
origen varchar(12) no automatico
manual
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 INSERT
UPDATE
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_consideradas y duracion_dias_aplicada, snapshot de sancion.expiradas_para_bloqueo y sancion.duracion_bloqueo_dias al aplicarse, por D-16.
  • La tabla sancion_usuario_reserva (sancion_id, reserva_id, append-only), que trg_reserva_sancion_automatica llena 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.