Appearance
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ón | Fecha | Descripción |
|---|---|---|
20260410120000_create_portal_users | 2026-04-10 | Scaffold Phase 1 |
20260422000000_add_ordcon_id_to_portal_users | 2026-04-22 | Phase 3: columna ordcon_id + índice |
20260427100000_add_telefono_to_portal_users | 2026-04-27 | Fase 7 (Perfil): columna telefono |
20260428100000_portal_users_sucursal_id_nullable | 2026-04-28 | sucursal_id nullable + partial unique indexes |
20260511000000_widen_portal_users_reset_code | 2026-05-11 | reset_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
| Campo | Tipo | Restricciones | Descripción |
|---|---|---|---|
id | serial | PK | Identificador secuencial del usuario del portal |
tenant_id | integer | NOT NULL | ID del tenant |
sucursal_id | integer | nullable | ID de la sucursal; NULL para portales multi-sucursal |
ordcon_id | integer | nullable | FK lógica → ordcon.cnro (cliente del ERP vinculado) |
dni | varchar(20) | nullable | DNI del usuario; al menos dni o cuit debe estar presente |
cuit | varchar(20) | nullable | CUIT del usuario; al menos dni o cuit debe estar presente |
email | varchar(255) | nullable | Email de contacto (usado para reset de contraseña) |
telefono | varchar(30) | nullable | Teléfono de contacto (actualizable desde Perfil) |
password_hash | varchar(255) | NOT NULL | Hash bcrypt del password |
refresh_token | varchar(500) | nullable | Token de renovación de sesión activo |
refresh_token_expires_at | timestamp | nullable | Expiración del refresh token |
reset_code | varchar(64) | nullable | Código de reset de contraseña (bin2hex(random_bytes(16)) = 32 chars hex) |
reset_code_expires_at | timestamp | nullable | Expiración del código de reset |
failed_login_attempts | integer | NOT NULL, default 0 | Contador de intentos de login fallidos consecutivos |
locked_until | timestamp | nullable | Si está en el futuro: cuenta bloqueada hasta esa fecha |
created_at | timestamp | NOT NULL, default now() | Fecha de creación del registro |
updated_at | timestamp | nullable | Última modificación |
deleted_at | timestamp | nullable | Soft delete — si no es null, el usuario está eliminado |
Restricciones
- CHECK documento: Al menos
dniocuitdebe 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 ensucursal_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_attemptsse incrementa en cada login fallido y se resetea a 0 en login exitosolocked_untilse establece aNOW() + 15mincuandofailed_login_attemptsalcanza 5- La aplicación verifica
locked_until > NOW()antes de validar el password reset_codeusabin2hex(random_bytes(16))= 32 caracteres hexadecimales aleatoriosreset_codevive enVARCHAR(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;