2026-10-05 15:01:32 +00:00
import { UniqueID } from "@repo/rdx-ddd" ;
import type { Sequelize , Transaction } from "sequelize" ;
import { QueryTypes } from "sequelize" ;
import { z } from "zod/v4" ;
import { Resource } from "../../domain/resource.aggregate" ;
import type { ResourceCatalog } from "../../domain/resource-catalog.aggregate" ;
import { ResourceRevision } from "../../domain/resource-revision.entity" ;
import type { ResourceInput } from "../../domain/resource-values" ;
import { ResourceInputSchema } from "../../domain/resource-values" ;
2026-10-09 08:38:19 +00:00
import {
ResourceSpecification ,
ResourceValues ,
} from "../../domain/value-objects/resource-values.vo" ;
2026-10-05 15:01:32 +00:00
import { ResourceModel , ResourceRevisionModel } from "./resource.model" ;
import {
ResourceAvailabilityModel ,
ResourceBindingModel ,
ResourceCatalogModel ,
ResourceDefinitionModel ,
} from "./resource-catalog.model" ;
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper" ;
import { ResourcePersistenceMapper } from "./resource-persistence.mapper" ;
const mailboxSchema = z . strictObject ( {
address : z.email ( ) . max ( 254 ) ,
kind : z.enum ( [ "corporate" , "external" ] ) ,
context : z.string ( ) . max ( 255 ) . nullable ( ) ,
accountId : z.uuid ( ) . nullable ( ) ,
active : z.boolean ( ) ,
notes : z.string ( ) . max ( 4000 ) . nullable ( ) ,
} ) ;
const accessSchema = z . strictObject ( {
kind : z.literal ( "resource" ) ,
name : z.string ( ) . min ( 1 ) . max ( 100 ) ,
active : z.boolean ( ) ,
resourceTypeId : z.uuid ( ) ,
systemId : z.uuid ( ) . nullable ( ) ,
environmentId : z.uuid ( ) . nullable ( ) ,
reference : z.string ( ) . max ( 255 ) . nullable ( ) ,
notes : z.string ( ) . max ( 4000 ) . nullable ( ) ,
} ) ;
type SourceRow = { id : string ; companyId : string ; values : unknown } ;
type RevisionRow = {
id : string ;
actorId : string ;
recordedAt : string ;
values : unknown ;
position : number ;
} ;
function json ( value : unknown ) : unknown {
return typeof value === "string" ? JSON . parse ( value ) : value ;
}
function identity ( value : string ) {
const id = UniqueID . create ( value ) ;
if ( ! id . isSuccess ) {
throw new Error ( "Identidad de migración inválida." ) ;
}
return id . data ;
}
function definition ( catalog : ResourceCatalog , key : string ) {
const item = catalog . definitions . find ( ( entry ) = > entry . stockKey . getOrUndefined ( ) === key ) ;
if ( ! item ) {
throw new Error ( "Definición de migración ausente." ) ;
}
return item ;
}
function spec ( catalog : ResourceCatalog , kind : "mailbox" | "access" ) {
const template = definition ( catalog , ` template: ${ kind } ` ) ;
if ( template . values . data . kind !== "template" ) {
throw new Error ( "Plantilla de migración inválida." ) ;
}
const fields = template . values . data . fields . map ( ( binding ) = > {
const field = catalog . definitions . find ( ( item ) = > item . id . toPrimitive ( ) === binding . fieldId ) ;
if ( field ? . values . data . kind !== "field" ) {
throw new Error ( "Campo de migración inválido." ) ;
}
return {
fieldId : binding.fieldId ,
name : field.values.data.name ,
type : field . values . data . type ,
unit : field.values.data.unit ,
choices : [ . . . field . values . data . choices ] ,
required : binding.required ,
2026-10-09 18:03:56 +00:00
. . . ( binding . sectionId ? { sectionId : binding.sectionId } : { } ) ,
2026-10-05 15:01:32 +00:00
} ;
} ) ;
const result = ResourceSpecification . create ( {
templateId : template.id.toPrimitive ( ) ,
templateName : template.values.data.name ,
2026-10-09 18:03:56 +00:00
. . . ( template . values . data . sections ? { sections : [ . . . template . values . data . sections ] } : { } ) ,
2026-10-05 15:01:32 +00:00
fields ,
} ) ;
if ( ! result . isSuccess ) {
throw new Error ( "Especificación de migración inválida." ) ;
}
return result . data ;
}
function map ( catalog : ResourceCatalog , kind : "mailbox" | "access" , value : unknown ) : ResourceInput {
const templateId = definition ( catalog , ` template: ${ kind } ` ) . id . toPrimitive ( ) ;
if ( kind === "mailbox" ) {
const old = mailboxSchema . parse ( json ( value ) ) ;
const addressFieldId = definition ( catalog , "field:email" ) . id . toPrimitive ( ) ;
const contextFieldId = definition ( catalog , "field:context" ) . id . toPrimitive ( ) ;
return {
name : old.address.slice ( 0 , 100 ) ,
templateId ,
active : old.active ,
notes : old.notes ? ? "" ,
characteristics : {
[ addressFieldId ] : old . address ,
. . . ( old . context ? { [ contextFieldId ] : old . context } : { } ) ,
} ,
integration : {
kind : "mailbox" ,
mailboxKind : old.kind ,
accountId : old.accountId ,
addressFieldId ,
contextFieldId ,
} ,
} ;
}
const old = accessSchema . parse ( json ( value ) ) ;
const referenceFieldId = definition ( catalog , "field:reference" ) . id . toPrimitive ( ) ;
return {
name : old.name ,
templateId ,
active : old.active ,
notes : old.notes ? ? "" ,
characteristics : old.reference ? { [ referenceFieldId ] : old . reference } : { } ,
integration : {
kind : "access" ,
resourceTypeId : old.resourceTypeId ,
systemId : old.systemId ,
environmentId : old.environmentId ,
referenceFieldId ,
} ,
} ;
}
async function importResource (
database : Sequelize ,
catalog : ResourceCatalog ,
kind : "mailbox" | "access" ,
row : SourceRow ,
transaction : Transaction
) {
const existing = await ResourceModel . findByPk ( row . id , { transaction } ) ;
if ( existing ) {
const parsed = ResourceInputSchema . parse ( json ( existing . values ) ) ;
if ( ! parsed || parsed . integration ? . kind !== kind || existing . companyId !== row . companyId ) {
throw new Error ( "Colisión de identidad al migrar recursos." ) ;
}
return ;
}
const table = kind === "mailbox" ? "mailbox_revisions" : "access_revisions" ;
const column = kind === "mailbox" ? "position" : "sequence" ;
const rows = await database . query < RevisionRow > (
` SELECT id,actorId,recordedAt, \` values \` , ${ column } AS position FROM ${ table } WHERE itemId=:id ORDER BY ${ column } ` ,
{ replacements : { id : row.id } , type : QueryTypes . SELECT , transaction }
) ;
const specification = spec ( catalog , kind ) ;
const values = ResourceValues . create ( map ( catalog , kind , row . values ) , specification ) ;
if ( ! ( values . isSuccess && rows . length ) ) {
throw new Error ( "Recurso anterior inconsistente." ) ;
}
const history = rows . map ( ( revision , index ) = > {
const previous = ResourceValues . create ( map ( catalog , kind , revision . values ) , specification ) ;
if (
! previous . isSuccess ||
revision . position !== index ||
! z . iso . datetime ( ) . safeParse ( revision . recordedAt ) . success ||
( index > 0 && revision . recordedAt < ( rows [ index - 1 ] ? . recordedAt ? ? "" ) )
) {
throw new Error ( "Historial anterior inconsistente." ) ;
}
return ResourceRevision . rehydrate (
{
values : previous.data ,
actorId : identity ( revision . actorId ) ,
recordedAt : revision.recordedAt ,
} ,
identity ( revision . id )
) ;
} ) ;
if ( ! history . at ( - 1 ) ? . values . equals ( values . data ) ) {
throw new Error ( "Última revisión anterior inconsistente." ) ;
}
const resource = Resource . rehydrate (
{ companyId : identity ( row . companyId ) , specification , values : values.data , history } ,
identity ( row . id )
) ;
const saved = new ResourcePersistenceMapper ( ) . toPersistence ( resource ) ;
await ResourceModel . create ( saved . resource , { transaction } ) ;
for ( const revision of saved . revisions ) {
await ResourceRevisionModel . create ( revision , { transaction } ) ;
}
}
/** Traspaso idempotente por compañía: UUID y revisiones originales, sin inventar autoría. */
export async function migrateLegacyResources ( database : Sequelize ) {
await database . transaction ( async ( transaction ) = > {
const root = await ResourceCatalogModel . findOne ( {
where : { catalogKey : "global" } ,
transaction ,
lock : transaction.LOCK.UPDATE ,
} ) ;
if ( ! root ) {
throw new Error ( "Catálogo de recursos ausente." ) ;
}
const definitions = await ResourceDefinitionModel . findAll ( {
where : { catalogId : root.id } ,
transaction ,
} ) ;
const bindings = await ResourceBindingModel . findAll ( {
where : { templateId : definitions.map ( ( item ) = > item . id ) } ,
transaction ,
} ) ;
const availability = await ResourceAvailabilityModel . findAll ( {
where : { catalogId : root.id } ,
transaction ,
} ) ;
const catalog = new ResourceCatalogPersistenceMapper ( ) . toDomain (
root ,
definitions ,
bindings ,
availability
) ;
const companies = await database . query < { id : string } > (
"SELECT id FROM companies ORDER BY id FOR UPDATE" ,
{ type : QueryTypes . SELECT , transaction }
) ;
for ( const company of companies ) {
const mailboxRows = await database . query < SourceRow > (
"SELECT id,companyId,`values` FROM mailboxes WHERE companyId=:companyId" ,
{ replacements : { companyId : company.id } , type : QueryTypes . SELECT , transaction }
) ;
const accessRows = await database . query < SourceRow > (
"SELECT i.id,w.companyId,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE i.kind='resource' AND w.companyId=:companyId" ,
{ replacements : { companyId : company.id } , type : QueryTypes . SELECT , transaction }
) ;
for ( const row of mailboxRows ) {
await importResource ( database , catalog , "mailbox" , row , transaction ) ;
}
for ( const row of accessRows ) {
await importResource ( database , catalog , "access" , row , transaction ) ;
}
}
} ) ;
}