Skip to content

Migración: portal_users

Información

  • Database: {tenant} (cada DB de tenant)
  • Schema: public (LEVEL_EMPRESA)
  • Level: LEVEL_EMPRESA — tabla compartida para todos los schemas del tenant

Propósito

Tabla de credenciales y seguridad para usuarios del portal de clientes. Un usuario se identifica por DNI o CUIT (al menos uno requerido). Incluye tokens de sesión, reset de contraseña y control de bloqueo por intentos fallidos.

Historia de Migraciones

MigraciónFechaDescripción
20260410120000_create_portal_users2026-04-10Scaffold Phase 1
20260422000000_add_ordcon_id_to_portal_users2026-04-22Phase 3: columna ordcon_id + índice
20260427100000_add_telefono_to_portal_users2026-04-27Fase 7 (Perfil): columna telefono
20260428100000_portal_users_sucursal_id_nullable2026-04-28sucursal_id nullable + partial unique indexes
20260511000000_widen_portal_users_reset_code2026-05-11reset_code VARCHAR(10) → VARCHAR(64)

DDL Actual (post todas las migraciones)

sql
-- Tabla creada en LEVEL_EMPRESA (schema public)
-- Custodiada por la feature flag: isPortalClientesEnabled()
CREATE TABLE portal_users (
    id                          SERIAL PRIMARY KEY,
    tenant_id                   INTEGER NOT NULL,
    sucursal_id                 INTEGER NULL,
    ordcon_id                   INTEGER NULL,
    dni                         VARCHAR(20) NULL,
    cuit                        VARCHAR(20) NULL,
    email                       VARCHAR(255) NULL,
    telefono                    VARCHAR(30) NULL,
    password_hash               VARCHAR(255) NOT NULL,
    refresh_token               VARCHAR(500) NULL,
    refresh_token_expires_at    TIMESTAMP NULL,
    reset_code                  VARCHAR(64) NULL,
    reset_code_expires_at       TIMESTAMP NULL,
    failed_login_attempts       INTEGER NOT NULL DEFAULT 0,
    locked_until                TIMESTAMP NULL,
    created_at                  TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at                  TIMESTAMP NULL,
    deleted_at                  TIMESTAMP NULL,

    CONSTRAINT chk_portal_users_document
        CHECK (dni IS NOT NULL OR cuit IS NOT NULL)
);

Índices

sql
-- Unicidad DNI por sucursal (con sucursal fija)
CREATE UNIQUE INDEX uq_portal_users_dni_sucursal
    ON portal_users (tenant_id, sucursal_id, dni)
    WHERE sucursal_id IS NOT NULL AND dni IS NOT NULL;

-- Unicidad CUIT por sucursal (con sucursal fija)
CREATE UNIQUE INDEX uq_portal_users_cuit_sucursal
    ON portal_users (tenant_id, sucursal_id, cuit)
    WHERE sucursal_id IS NOT NULL AND cuit IS NOT NULL;

-- Unicidad DNI por tenant (portal multi-sucursal: sucursal_id IS NULL)
CREATE UNIQUE INDEX uq_portal_users_dni_null_sucursal
    ON portal_users (tenant_id, dni)
    WHERE sucursal_id IS NULL AND dni IS NOT NULL;

-- Unicidad CUIT por tenant (portal multi-sucursal: sucursal_id IS NULL)
CREATE UNIQUE INDEX uq_portal_users_cuit_null_sucursal
    ON portal_users (tenant_id, cuit)
    WHERE sucursal_id IS NULL AND cuit IS NOT NULL;

-- Búsqueda por ordcon vinculado
CREATE INDEX idx_portal_users_ordcon_id
    ON portal_users (ordcon_id);

Campos

CampoTipoRestriccionesDescripción
idserialPKIdentificador secuencial del usuario del portal
tenant_idintegerNOT NULLID del tenant
sucursal_idintegernullableID de la sucursal; NULL para portales multi-sucursal
ordcon_idintegernullableFK lógica → ordcon.cnro (cliente del ERP vinculado)
dnivarchar(20)nullableDNI del usuario; al menos dni o cuit debe estar presente
cuitvarchar(20)nullableCUIT del usuario; al menos dni o cuit debe estar presente
emailvarchar(255)nullableEmail de contacto (usado para reset de contraseña)
telefonovarchar(30)nullableTeléfono de contacto (actualizable desde Perfil)
password_hashvarchar(255)NOT NULLHash bcrypt del password
refresh_tokenvarchar(500)nullableToken de renovación de sesión activo
refresh_token_expires_attimestampnullableExpiración del refresh token
reset_codevarchar(64)nullableCódigo de reset de contraseña (bin2hex(random_bytes(16)) = 32 chars hex)
reset_code_expires_attimestampnullableExpiración del código de reset
failed_login_attemptsintegerNOT NULL, default 0Contador de intentos de login fallidos consecutivos
locked_untiltimestampnullableSi está en el futuro: cuenta bloqueada hasta esa fecha
created_attimestampNOT NULL, default now()Fecha de creación del registro
updated_attimestampnullableÚltima modificación
deleted_attimestampnullableSoft delete — si no es null, el usuario está eliminado

Restricciones

  • CHECK documento: Al menos dni o cuit debe estar presente — un usuario no puede existir sin identificación.
  • Unicidad por sucursal: Un DNI/CUIT solo puede tener un usuario por combinación (tenant_id, sucursal_id). Con partial indexes se soportan correctamente los NULL en sucursal_id.
  • sucursal_id nullable: Permite portales que atienden múltiples sucursales del mismo tenant con un único registro de usuario. Cuando sucursal_id IS NULL, la unicidad se aplica por (tenant_id, dni/cuit).

Seguridad

  • failed_login_attempts se incrementa en cada login fallido y se resetea a 0 en login exitoso
  • locked_until se establece a NOW() + 15min cuando failed_login_attempts alcanza 5
  • La aplicación verifica locked_until > NOW() antes de validar el password
  • reset_code usa bin2hex(random_bytes(16)) = 32 caracteres hexadecimales aleatorios
  • reset_code vive en VARCHAR(64) con margen para tokens futuros

Relación con ordcon

ordcon_id es una FK lógica (sin constraint de integridad referencial en DB) hacia ordcon.cnro. La relación es N:1 — múltiples portal_users pueden apuntar al mismo ordcon.

El nombre del cliente se lee de ordcon.cnom en runtime vía OrdconLookupInterface::findNombreByOrdconId(). El email y telefono son de portal_users y editables desde Perfil. El nombre es read-only desde el portal.

Cuando ordcon_id IS NULL (registro sin ordcon vinculado), nombre retorna null.

Rollback

sql
DROP TABLE IF EXISTS portal_users CASCADE;