2026-10-03 13:19:20 +00:00
import { UniqueID } from "@repo/rdx-ddd" ;
import { Maybe , Result } from "@repo/rdx-utils" ;
import type {
CreationOptional ,
InferAttributes ,
InferCreationAttributes ,
Sequelize ,
Transaction ,
} from "sequelize" ;
import { DataTypes , Model , QueryTypes } from "sequelize" ;
2026-10-21 10:37:59 +00:00
import { z } from "zod/v4" ;
2026-10-03 13:19:20 +00:00
2026-10-21 10:37:59 +00:00
import { LocalIdentityOwnerSchema } from "../../../common/dto/shared/access.dto" ;
2026-10-03 13:19:20 +00:00
import type { AccessSnapshot , IAccessRepository } from "../../application/access.repository" ;
import type { ICompanyRepository } from "../../application/company.repository" ;
2026-10-20 11:20:22 +00:00
import type { DirectoryContextSnapshot } from "../../application/directory-context-plan" ;
2026-10-03 13:19:20 +00:00
import { AccessDefinitionSchema } from "../../domain/access/access-definition" ;
import { AccessError } from "../../domain/access/access-error" ;
import { AccessItem , AccessRevision } from "../../domain/access/access-item.entity" ;
2026-10-09 08:38:19 +00:00
import { AccessValues } from "../../domain/access/access-values.vo" ;
2026-10-03 13:19:20 +00:00
import { AccessWorkspace } from "../../domain/access/access-workspace.aggregate" ;
import type { Person } from "../../domain/person.entity" ;
2026-10-10 14:40:51 +00:00
import {
type AccessItemRow ,
type AccessRevisionRow ,
2026-10-10 15:57:05 +00:00
initializeAccessValues ,
2026-10-10 14:40:51 +00:00
readAccessItems ,
readAccessRevisions ,
writeAccessRow ,
} from "./access-relational" ;
2026-10-20 11:20:22 +00:00
import { migrateDirectoryContexts } from "./directory-context-migration" ;
2026-10-21 09:25:23 +00:00
import { migrateLocalIdentityEnvironments } from "./local-identity-migration" ;
2026-10-10 14:40:51 +00:00
2026-10-03 13:19:20 +00:00
class WorkspaceModel extends Model <
InferAttributes < WorkspaceModel > ,
InferCreationAttributes < WorkspaceModel >
> {
declare id : string ;
declare companyId : string | null ;
declare scopeKey : string ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
class ReferenceModel extends Model <
InferAttributes < ReferenceModel > ,
InferCreationAttributes < ReferenceModel >
> {
declare id : CreationOptional < number > ;
declare itemId : string ;
declare targetId : string ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
class PersonReferenceModel extends Model <
InferAttributes < PersonReferenceModel > ,
InferCreationAttributes < PersonReferenceModel >
> {
declare itemId : string ;
declare personId : string ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
const idColumn = { type : DataTypes . UUID , primaryKey : true } ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
const reference = ( table : string ) = > ( {
type : DataTypes . UUID ,
allowNull : false ,
references : { model : table , key : "id" } ,
onDelete : "RESTRICT" ,
} ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
export async function migrateAccess ( database : Sequelize ) {
const query = database . getQueryInterface ( ) ;
const rows = await database . query < { name : string } > (
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()" ,
{ type : QueryTypes . SELECT }
) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
const exists = ( name : string ) = > rows . some ( ( row ) = > row . name === name ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( ! exists ( "access_workspaces" ) ) {
await query . createTable ( "access_workspaces" , {
id : idColumn ,
companyId : { . . . reference ( "companies" ) , allowNull : true } ,
scopeKey : { type : DataTypes . STRING ( 40 ) , allowNull : false , unique : true } ,
} ) ;
}
if ( ! exists ( "access_items" ) ) {
await query . createTable ( "access_items" , {
id : idColumn ,
workspaceId : reference ( "access_workspaces" ) ,
kind : { type : DataTypes . STRING ( 30 ) , allowNull : false } ,
} ) ;
}
if ( ! exists ( "access_revisions" ) ) {
await query . createTable ( "access_revisions" , {
id : idColumn ,
itemId : reference ( "access_items" ) ,
sequence : { type : DataTypes . INTEGER , allowNull : false } ,
actorId : reference ( "users" ) ,
recordedAt : { type : DataTypes . STRING ( 24 ) , allowNull : false } ,
} ) ;
}
if ( ! exists ( "access_references" ) ) {
await query . createTable ( "access_references" , {
id : { type : DataTypes . INTEGER , primaryKey : true , autoIncrement : true } ,
itemId : reference ( "access_items" ) ,
targetId : reference ( "access_items" ) ,
} ) ;
}
if ( ! exists ( "access_person_references" ) ) {
await query . createTable ( "access_person_references" , {
itemId : { . . . reference ( "access_items" ) , primaryKey : true } ,
personId : { . . . reference ( "company_contacts" ) , primaryKey : true } ,
} ) ;
}
// Índices fuera de las ramas de creación: permiten completar un arranque interrumpido.
for ( const [ table , fields , name ] of [
[ "access_revisions" , [ "itemId" , "sequence" ] , "access_revision_sequence_unique" ] ,
[ "access_references" , [ "itemId" , "targetId" ] , "access_reference_unique" ] ,
] as const ) {
const indexes = await database . query < { name : string } > (
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table" ,
{ replacements : { table } , type : QueryTypes . SELECT }
) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( ! indexes . some ( ( item ) = > item . name === name ) ) {
await query . addIndex ( table , [ . . . fields ] , { unique : true , name } ) ;
}
}
WorkspaceModel . init (
{
id : idColumn ,
companyId : { type : DataTypes . UUID , allowNull : true } ,
scopeKey : { type : DataTypes . STRING ( 40 ) , allowNull : false } ,
} ,
{ sequelize : database , tableName : "access_workspaces" , timestamps : false }
) ;
2026-10-10 15:57:05 +00:00
await initializeAccessValues ( database ) ;
2026-10-20 11:20:22 +00:00
await migrateDirectoryContexts ( database ) ;
2026-10-21 09:25:23 +00:00
await migrateLocalIdentityEnvironments ( database ) ;
2026-10-03 13:19:20 +00:00
ReferenceModel . init (
{
id : { type : DataTypes . INTEGER , primaryKey : true , autoIncrement : true } ,
itemId : { type : DataTypes . UUID , allowNull : false } ,
targetId : { type : DataTypes . UUID , allowNull : false } ,
} ,
{ sequelize : database , tableName : "access_references" , timestamps : false }
) ;
PersonReferenceModel . init (
{
itemId : { type : DataTypes . UUID , primaryKey : true } ,
personId : { type : DataTypes . UUID , primaryKey : true } ,
} ,
{ sequelize : database , tableName : "access_person_references" , timestamps : false }
) ;
// Completar referencias de registros ya existentes sin reescribir sus valores ni su historial.
2026-10-10 14:40:51 +00:00
for ( const item of await database . transaction ( ( transaction ) = >
readAccessItems ( database , transaction )
) ) {
2026-10-03 13:19:20 +00:00
const value = domainValues ( item . values ) . data ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( "personId" in value ) {
await PersonReferenceModel . findOrCreate ( {
where : { itemId : item.id , personId : value.personId } ,
defaults : { itemId : item.id , personId : value.personId } ,
} ) ;
}
}
await WorkspaceModel . findOrCreate ( {
where : { scopeKey : "global" } ,
defaults : { id : UniqueID.generateNewID ( ) . toPrimitive ( ) , companyId : null , scopeKey : "global" } ,
} ) ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
function validId ( value : string ) {
const id = UniqueID . create ( value ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( ! id . isSuccess ) {
throw new Error ( "Identidad de acceso persistida inconsistente." ) ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
return id . data ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
function domainValues ( value : unknown ) {
const parsed = AccessDefinitionSchema . safeParse ( value ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( ! parsed . success ) {
throw new Error ( "Valores de acceso persistidos inconsistentes." ) ;
}
const values = AccessValues . create ( parsed . data ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( ! values . isSuccess ) {
throw new Error ( "Valores de acceso persistidos inconsistentes." ) ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
return values . data ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
export class AccessPersistenceMapper {
2026-10-10 14:40:51 +00:00
toDomain (
root : WorkspaceModel ,
rows : readonly AccessItemRow [ ] ,
revisions : readonly AccessRevisionRow [ ]
) {
2026-10-03 13:19:20 +00:00
const items = rows . map ( ( row ) = > {
const values = domainValues ( row . values ) ;
const history = revisions
. filter ( ( revision ) = > revision . itemId === row . id )
. sort ( ( a , b ) = > a . sequence - b . sequence )
. map ( ( revision , index ) = > {
if (
revision . sequence !== index ||
! /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ . test ( revision . recordedAt )
) {
throw new Error ( "Historial de acceso persistido inconsistente." ) ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
return AccessRevision . rehydrate (
domainValues ( revision . values ) ,
{ actorId : validId ( revision . actorId ) , recordedAt : revision.recordedAt } ,
validId ( revision . id )
) ;
} ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( ! history . at ( - 1 ) ? . values . equals ( values ) || row . kind !== values . data . kind ) {
throw new Error ( "Historial de acceso persistido inconsistente." ) ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
return AccessItem . rehydrate (
{
companyId : root.companyId ? Maybe . some ( validId ( root . companyId ) ) : Maybe . none ( ) ,
values ,
history ,
} ,
validId ( row . id )
) ;
} ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
return AccessWorkspace . rehydrate (
root . companyId ? Maybe . some ( validId ( root . companyId ) ) : Maybe . none ( ) ,
items ,
validId ( root . id )
) ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
toPersistence ( workspace : AccessWorkspace ) {
return workspace . items . map ( ( item ) = > ( {
item : {
id : item.id.toPrimitive ( ) ,
workspaceId : workspace.id.toPrimitive ( ) ,
kind : item.values.data.kind ,
values : item.values.data ,
} ,
revisions : item.history.map ( ( revision , sequence ) = > ( {
id : revision.id.toPrimitive ( ) ,
itemId : item.id.toPrimitive ( ) ,
sequence ,
actorId : revision.actorId.toPrimitive ( ) ,
recordedAt : revision.recordedAt ,
values : revision.values.data ,
} ) ) ,
references : item.values
. references ( )
. filter ( ( id ) = > {
const person = item . values . reference ( "personId" ) . getOrUndefined ( ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
return ! person ? . equals ( id ) ;
} )
. map ( ( id ) = > ( { itemId : item.id.toPrimitive ( ) , targetId : id.toPrimitive ( ) } ) ) ,
} ) ) ;
}
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
export class SequelizeAccessRepository implements IAccessRepository {
constructor (
private readonly database : Sequelize ,
private readonly companies : ICompanyRepository ,
private readonly mapper : AccessPersistenceMapper
) { }
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
private async load (
companyId : Maybe < UniqueID > ,
transaction : Transaction ,
write : boolean
) : Promise < Result < AccessSnapshot , AccessError > > {
// Orden de bloqueo único: catálogo global, compañía y espacio de accesos. Serializa altas y cambios de catálogos.
const global = await WorkspaceModel . findOne ( {
where : { scopeKey : "global" } ,
transaction ,
. . . ( write ? { lock : transaction.LOCK.UPDATE } : { } ) ,
} ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( ! global ) {
throw new Error ( "Catálogo no inicializado." ) ;
}
let root = global ;
let people : readonly Person [ ] = [ ] ;
const company = companyId . getOrUndefined ( ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( company ) {
const exists = await this . database . query < { id : string } > (
` SELECT id FROM companies WHERE id=:id ${ write ? " FOR UPDATE" : "" } ` ,
{ replacements : { id : company.toPrimitive ( ) } , transaction , type : QueryTypes . SELECT }
) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( ! exists . length ) {
return Result . fail ( new AccessError ( "COMPANY_NOT_FOUND" ) ) ;
}
const aggregate = ( await this . companies . findById ( company ) ) . getOrUndefined ( ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( ! aggregate ) {
return Result . fail ( new AccessError ( "COMPANY_NOT_FOUND" ) ) ;
}
people = aggregate . people ;
const [ created ] = await WorkspaceModel . findOrCreate ( {
where : { scopeKey : company.toPrimitive ( ) } ,
defaults : {
id : UniqueID.generateNewID ( ) . toPrimitive ( ) ,
scopeKey : company.toPrimitive ( ) ,
companyId : company.toPrimitive ( ) ,
} ,
transaction ,
} ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
root = created ;
}
const roots = company ? [ global , root ] : await WorkspaceModel . findAll ( { transaction } ) ;
2026-10-10 14:40:51 +00:00
const rows = ( await readAccessItems ( this . database , transaction ) ) . filter ( ( row ) = >
roots . some ( ( root ) = > root . id === row . workspaceId )
) ;
const revisions = ( await readAccessRevisions ( this . database , transaction ) ) . filter ( ( revision ) = >
rows . some ( ( row ) = > row . id === revision . itemId )
) ;
2026-10-03 13:19:20 +00:00
const workspace = this . mapper . toDomain (
root ,
rows . filter ( ( row ) = > row . workspaceId === root . id ) ,
revisions
) ;
const catalog = roots
. filter ( ( item ) = > item . id !== root . id )
. flatMap (
( item ) = >
this . mapper . toDomain (
item ,
rows . filter ( ( row ) = > row . workspaceId === item . id ) ,
revisions
) . items
) ;
const actorRows = await this . database . query < { id : string ; name : string } > (
"SELECT id,name FROM users" ,
{ transaction , type : QueryTypes . SELECT }
) ;
const actors = new Map ( actorRows . map ( ( row ) = > [ row . id , row . name ] ) ) ;
2026-10-10 10:01:58 +00:00
2026-10-21 10:37:59 +00:00
const localEnvironments = company
? z . array ( LocalIdentityOwnerSchema ) . parse (
await this . database . query (
"SELECT l.environmentId,l.assetId,a.name AS assetName,l.endedAt FROM access_local_identity_environments l JOIN assets a ON a.id=l.assetId AND a.companyId=l.companyId WHERE l.companyId=:companyId" ,
{
replacements : { companyId : company.toPrimitive ( ) } ,
transaction ,
type : QueryTypes . SELECT ,
}
)
)
: [ ] ;
const directories = company
? await this . database . query < { environmentId : string } > (
"SELECT environmentId FROM access_directory_environments WHERE workspaceId=:workspaceId" ,
{ replacements : { workspaceId : root.id } , transaction , type : QueryTypes . SELECT }
)
: [ ] ;
2026-10-03 13:19:20 +00:00
return Result . ok ( {
2026-10-21 10:37:59 +00:00
localEnvironments ,
directoryEnvironmentIds : directories.map ( ( row ) = > row . environmentId ) ,
2026-10-03 13:19:20 +00:00
actors ,
workspace ,
context : { catalog , people , today : new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } ,
} ) ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
get ( companyId : Maybe < UniqueID > ) {
return this . database . transaction ( ( transaction ) = > this . load ( companyId , transaction , false ) ) ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
update ( companyId : Maybe < UniqueID > , action : Parameters < IAccessRepository [ " update " ] > [ 1 ] ) {
2026-10-20 11:20:22 +00:00
return this . database . transaction ( ( transaction ) = >
this . updateInTransaction ( companyId , action , transaction )
) ;
}
2026-10-10 10:01:58 +00:00
2026-10-20 11:20:22 +00:00
async updateInTransaction (
companyId : Maybe < UniqueID > ,
action : (
snapshot : DirectoryContextSnapshot
) = > ReturnType < Parameters < IAccessRepository [ " update " ] > [ 1 ] > ,
transaction : Transaction
) {
const loaded = await this . load ( companyId , transaction , true ) ;
2026-10-10 10:01:58 +00:00
2026-10-20 11:20:22 +00:00
if ( ! loaded . isSuccess ) {
return loaded ;
}
const replacements = { workspaceId : loaded.data.workspace.id.toPrimitive ( ) } ;
const systems = await this . database . query < { systemId : string } > (
"SELECT systemId FROM access_directory_systems WHERE workspaceId=:workspaceId" ,
{ replacements , transaction , type : QueryTypes . SELECT }
) ;
const environments = await this . database . query < { environmentId : string } > (
"SELECT environmentId FROM access_directory_environments WHERE workspaceId=:workspaceId" ,
{ replacements , transaction , type : QueryTypes . SELECT }
) ;
const changed = action ( {
. . . loaded . data ,
directorySystems : systems.map ( ( row ) = > row . systemId ) ,
directoryEnvironments : environments.map ( ( row ) = > row . environmentId ) ,
} ) ;
2026-10-10 10:01:58 +00:00
2026-10-20 11:20:22 +00:00
if ( ! changed . isSuccess ) {
return changed ;
}
// Un contexto documental no se desactiva por una edición lateral del sistema o del entorno.
if (
changed . data . items . some ( ( item ) = > {
const linked =
systems . some ( ( row ) = > row . systemId === item . id . toPrimitive ( ) ) ||
environments . some ( ( row ) = > row . environmentId === item . id . toPrimitive ( ) ) ;
return linked && "active" in item . values . data && ! item . values . data . active ;
} )
) {
return Result . fail ( new AccessError ( "IMMUTABLE_FIELDS" ) ) ;
}
if ( changed . data !== loaded . data . workspace ) {
await this . persistChanges ( changed . data , loaded . data . workspace , transaction ) ;
}
return Result . ok ( {
2026-10-21 10:37:59 +00:00
localEnvironments : loaded.data.localEnvironments ,
directoryEnvironmentIds : loaded.data.directoryEnvironmentIds ? ? [ ] ,
2026-10-20 11:20:22 +00:00
workspace : changed.data ,
context : loaded.data.context ,
actors : loaded.data.actors ,
2026-10-03 13:19:20 +00:00
} ) ;
}
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
private async persistChanges (
workspace : AccessWorkspace ,
previousWorkspace : AccessWorkspace ,
transaction : Transaction
) {
const records = this . mapper . toPersistence ( workspace ) ;
const previous = new Set (
previousWorkspace . items . flatMap ( ( item ) = >
item . history . map ( ( revision ) = > revision . id . toPrimitive ( ) )
)
) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
for ( const record of records ) {
2026-10-10 14:40:51 +00:00
await writeAccessRow (
this . database ,
record . item ,
transaction ,
previousWorkspace . items . some ( ( item ) = > item . id . toPrimitive ( ) === record . item . id )
? "update"
: "insert"
) ;
2026-10-03 13:19:20 +00:00
const revisions = record . revisions . filter ( ( revision ) = > ! previous . has ( revision . id ) ) ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( revisions . length ) {
2026-10-10 14:40:51 +00:00
for ( const revision of revisions ) {
await writeAccessRow ( this . database , revision , transaction , "insert" ) ;
}
2026-10-03 13:19:20 +00:00
}
const personId = Object . entries ( record . item . values ) . find ( ( [ key ] ) = > key === "personId" ) ? . [ 1 ] ;
2026-10-10 10:01:58 +00:00
2026-10-03 13:19:20 +00:00
if ( typeof personId === "string" ) {
await PersonReferenceModel . findOrCreate ( {
where : { itemId : record.item.id , personId } ,
defaults : { itemId : record.item.id , personId } ,
transaction ,
} ) ;
}
for ( const reference of record . references ) {
await ReferenceModel . findOrCreate ( {
where : reference ,
defaults : reference ,
transaction ,
} ) ;
}
}
}
}