2026-10-10 11:32:40 +00:00
import type { Sequelize , Transaction } from "sequelize" ;
import { QueryTypes } from "sequelize" ;
2026-10-24 23:19:17 +00:00
import { ResourceCatalogError } from "../../domain/resource-definition" ;
2026-10-10 11:32:40 +00:00
import type { ResourceInput , ResourceSpecificationData } from "../../domain/resource-values" ;
import { ResourceInputSchema , ResourceSpecificationSchema } from "../../domain/resource-values" ;
import type { ResourcePersistenceMapper } from "./resource-persistence.mapper" ;
2026-10-24 23:19:17 +00:00
import { readResourceOwnership } from "./resource-persistence.mapper" ;
2026-10-10 11:32:40 +00:00
import { readResourceRows , sqlIdentifier , writeResourceRow } from "./resource-relational-sql" ;
2026-10-19 09:45:09 +00:00
import {
readResourceRevisionSpecifications ,
writeResourceRevisionSpecification ,
} from "./resource-revision-specifications" ;
2026-10-10 11:32:40 +00:00
export type InventoryRow = {
id : string ;
2026-10-24 23:19:17 +00:00
companyId : string | null ;
scope? : unknown ;
2026-10-10 11:32:40 +00:00
templateId : string ;
values : unknown ;
specification : unknown ;
} ;
export type RevisionRow = {
id : string ;
resourceId : string ;
actorId : string ;
recordedAt : string ;
position : number ;
2026-10-19 09:45:09 +00:00
specification? : unknown ;
2026-10-10 11:32:40 +00:00
values : unknown ;
} ;
type IntegrationColumns = {
integrationKind : string | null ;
addressFieldId : string | null ;
contextFieldId : string | null ;
mailboxKind : string | null ;
accountId : string | null ;
referenceFieldId : string | null ;
resourceTypeId : string | null ;
systemId : string | null ;
environmentId : string | null ;
} ;
function integration ( row : IntegrationColumns ) {
switch ( row . integrationKind ) {
case null :
return { } ;
case "mailbox" :
return {
integration : {
kind : "mailbox" ,
addressFieldId : row.addressFieldId ,
contextFieldId : row.contextFieldId ,
mailboxKind : row.mailboxKind ,
accountId : row.accountId ,
} ,
} ;
case "access" :
return {
integration : {
kind : "access" ,
referenceFieldId : row.referenceFieldId ,
resourceTypeId : row.resourceTypeId ,
systemId : row.systemId ,
environmentId : row.environmentId ,
} ,
} ;
default :
throw new Error ( "Integración de recurso persistida inconsistente." ) ;
}
}
function integrationColumns ( values : ResourceInput ) : IntegrationColumns {
const item = values . integration ;
return {
integrationKind : item?.kind ? ? null ,
addressFieldId : item?.kind === "mailbox" ? item.addressFieldId : null ,
contextFieldId : item?.kind === "mailbox" ? item.contextFieldId : null ,
mailboxKind : item?.kind === "mailbox" ? item.mailboxKind : null ,
accountId : item?.kind === "mailbox" ? item.accountId : null ,
referenceFieldId : item?.kind === "access" ? item.referenceFieldId : null ,
resourceTypeId : item?.kind === "access" ? item.resourceTypeId : null ,
systemId : item?.kind === "access" ? item.systemId : null ,
environmentId : item?.kind === "access" ? item.environmentId : null ,
} ;
}
type ResourceRow = Omit < InventoryRow , " values " | " specification " > &
IntegrationColumns & {
name : string ;
active : boolean ;
notes : string ;
templateName : string ;
sectionsDefined : boolean ;
} ;
2026-10-19 09:45:09 +00:00
type RevisionColumns = Omit < RevisionRow , " values " | " specification " > &
2026-10-10 11:32:40 +00:00
IntegrationColumns & {
name : string ;
active : boolean ;
notes : string ;
templateId : string ;
} ;
type SpecificationField = Omit < ResourceSpecificationData [ " fields " ] [ number ] , " type " | " choices " > & {
resourceId : string ;
fieldType : string ;
position : number ;
} ;
type SpecificationSection = {
resourceId : string ;
id : string ;
name : string ;
description : string ;
position : number ;
} ;
type SpecificationChoice = { resourceId : string ; fieldId : string ; position : number ; value : string } ;
type CharacteristicRow = {
resourceId : string ;
revisionId? : string ;
fieldId : string ;
valueType : string ;
textValue : string | null ;
numberValue : number | null ;
booleanValue : boolean | null ;
dateValue : string | null ;
} ;
export function decodeCharacteristic (
row : Omit < CharacteristicRow , " resourceId " | " fieldId " | " revisionId " >
) {
const populated = [ row . textValue , row . numberValue , row . booleanValue , row . dateValue ] . filter (
( value ) = > value !== null
) ;
if ( populated . length !== 1 ) {
throw new Error ( "Valor tipado de recurso persistido inconsistente." ) ;
}
switch ( row . valueType ) {
case "text" :
case "choice" :
if ( row . textValue !== null ) {
return row . textValue ;
}
break ;
case "number" :
if ( row . numberValue !== null ) {
return Number ( row . numberValue ) ;
}
break ;
case "boolean" :
if ( row . booleanValue !== null ) {
return Boolean ( row . booleanValue ) ;
}
break ;
case "date" :
if ( row . dateValue !== null ) {
return row . dateValue ;
}
break ;
}
throw new Error ( "Tipo de característica persistida inconsistente." ) ;
}
function characteristicRows (
resourceId : string ,
values : ResourceInput ,
specification : ResourceSpecificationData ,
revisionId? : string
) {
return Object . entries ( values . characteristics ) . map ( ( [ fieldId , value ] ) = > {
const type = specification . fields . find ( ( field ) = > field . fieldId === fieldId ) ? . type ;
if ( ! type ) {
throw new Error ( "Campo de recurso sin especificación." ) ;
}
return {
resourceId ,
. . . ( revisionId ? { revisionId } : { } ) ,
fieldId ,
valueType : type ,
textValue : type === "text" || type === "choice" ? String ( value ) : null ,
numberValue : type === "number" && typeof value === "number" ? value : null ,
booleanValue : type === "boolean" && typeof value === "boolean" ? value : null ,
dateValue : type === "date" ? String ( value ) : null ,
} ;
} ) ;
}
export async function readInventory (
database : Sequelize ,
companyId : string ,
transaction : Transaction
) {
const resources = await readResourceRows < ResourceRow > (
database ,
"resources" ,
"companyId" ,
[ companyId ] ,
transaction
) ;
2026-10-24 23:19:17 +00:00
return readInventoryContents ( database , resources , transaction ) ;
}
/** Lectura interna por identidades; no expuesta como servicio ni endpoint. */
export async function readInventoryByIds (
database : Sequelize ,
ids : readonly string [ ] ,
transaction : Transaction ,
revisionIds? : readonly string [ ]
) {
const resources = await readResourceRows < ResourceRow > (
database ,
"resources" ,
"id" ,
ids ,
transaction
) ;
return readInventoryContents ( database , resources , transaction , revisionIds ) ;
}
async function readInventoryContents (
database : Sequelize ,
resources : readonly ResourceRow [ ] ,
transaction : Transaction ,
revisionIds? : readonly string [ ]
) {
2026-10-10 11:32:40 +00:00
const ids = resources . map ( ( row ) = > row . id ) ;
const fields = await readResourceRows < SpecificationField > (
database ,
"resource_specification_fields" ,
"resourceId" ,
ids ,
transaction
) ;
const sections = await readResourceRows < SpecificationSection > (
database ,
"resource_specification_sections" ,
"resourceId" ,
ids ,
transaction
) ;
const choices = await readResourceRows < SpecificationChoice > (
database ,
"resource_specification_field_choices" ,
"resourceId" ,
ids ,
transaction
) ;
const current = await readCharacteristics ( database , "resource_characteristics" , ids , transaction ) ;
const histories = await readResourceRows < RevisionColumns > (
database ,
"resource_revisions" ,
2026-10-24 23:19:17 +00:00
revisionIds ? "id" : "resourceId" ,
revisionIds ? ? ids ,
2026-10-10 11:32:40 +00:00
transaction
) ;
const historical = await readCharacteristics (
database ,
"resource_revision_characteristics" ,
ids ,
2026-10-24 23:19:17 +00:00
transaction ,
revisionIds
2026-10-10 11:32:40 +00:00
) ;
const values = ( row : ResourceRow | RevisionColumns , entries : CharacteristicRow [ ] ) = >
ResourceInputSchema . parse ( {
name : row.name ,
templateId : row.templateId ,
active : Boolean ( row . active ) ,
. . . integration ( row ) ,
notes : row.notes ,
characteristics : Object.fromEntries (
entries . map ( ( entry ) = > [ entry . fieldId , decodeCharacteristic ( entry ) ] )
) ,
} ) ;
const rows : InventoryRow [ ] = resources . map ( ( row ) = > ( {
. . . row ,
values : values (
row ,
current . filter ( ( entry ) = > entry . resourceId === row . id )
) ,
specification : ResourceSpecificationSchema.parse ( {
templateId : row.templateId ,
templateName : row.templateName ,
. . . ( row . sectionsDefined
? {
sections : sections
. filter ( ( section ) = > section . resourceId === row . id )
. sort ( ( a , b ) = > a . position - b . position )
. map ( ( { id , name , description } ) = > ( { id , name , description } ) ) ,
}
: { } ) ,
fields : fields
. filter ( ( field ) = > field . resourceId === row . id )
. sort ( ( a , b ) = > a . position - b . position )
. map ( ( field ) = > ( {
fieldId : field.fieldId ,
name : field.name ,
type : field . fieldType ,
unit : field.unit ,
required : Boolean ( field . required ) ,
. . . ( field . sectionId ? { sectionId : field.sectionId } : { } ) ,
choices : choices
. filter ( ( choice ) = > choice . resourceId === row . id && choice . fieldId === field . fieldId )
. sort ( ( a , b ) = > a . position - b . position )
. map ( ( choice ) = > choice . value ) ,
} ) ) ,
} ) ,
} ) ) ;
2026-10-19 09:45:09 +00:00
const revisionSpecifications = await readResourceRevisionSpecifications (
database ,
ids ,
2026-10-24 23:19:17 +00:00
transaction ,
revisionIds
2026-10-19 09:45:09 +00:00
) ;
const revisions : RevisionRow [ ] = histories . map ( ( row ) = > {
const specification = revisionSpecifications . get ( row . id ) ;
if ( ! specification ) {
throw new Error ( "Revisión sin especificación histórica persistida." ) ;
}
return {
. . . row ,
specification ,
values : values (
row ,
historical . filter ( ( entry ) = > entry . revisionId === row . id )
) ,
} ;
} ) ;
2026-10-10 11:32:40 +00:00
return { rows , revisions } ;
}
export async function writeInventory (
database : Sequelize ,
2026-10-24 23:19:17 +00:00
rows :
| ReturnType < ResourcePersistenceMapper [ " toPersistence " ] >
| ReturnType < ResourcePersistenceMapper [ " toScopedPersistence " ] > ,
existing : Set < string > ,
transaction : Transaction
) {
await assertLocalResourceUpdate ( database , rows . resource , transaction ) ;
await persistInventory ( database , rows , existing , transaction ) ;
}
/** Escritor exclusivo de raíz global; nunca proyecta Access, SMB ni directorio. */
export async function writeGlobalInventory (
database : Sequelize ,
rows : ReturnType < ResourcePersistenceMapper [ " toGlobalPersistence " ] > ,
existing : Set < string > ,
transaction : Transaction
) {
if (
rows . resource . companyId !== null ||
rows . resource . scope !== "global" ||
rows . resource . values . integration
) {
throw new ResourceCatalogError ( "RESOURCE_SCOPE_UNSUPPORTED" ) ;
}
const previous = await database . query < { companyId : unknown ; scope : unknown ; templateId : string } > (
"SELECT companyId,scope,templateId FROM resources WHERE id=:id FOR UPDATE" ,
{ replacements : { id : rows.resource.id } , transaction , type : QueryTypes . SELECT }
) ;
const root = previous [ 0 ] ;
if (
root &&
( readResourceOwnership ( root , "scoped" ) . scope !== "global" ||
root . templateId !== rows . resource . templateId )
) {
throw new ResourceCatalogError ( "RESOURCE_OWNERSHIP_FIXED" ) ;
}
await persistInventory ( database , rows , existing , transaction ) ;
}
async function persistInventory (
database : Sequelize ,
rows :
| ReturnType < ResourcePersistenceMapper [ " toPersistence " ] >
| ReturnType < ResourcePersistenceMapper [ " toScopedPersistence " ] >
| ReturnType < ResourcePersistenceMapper [ " toGlobalPersistence " ] > ,
2026-10-10 11:32:40 +00:00
existing : Set < string > ,
transaction : Transaction
) {
const { resource , revisions } = rows ;
2026-10-24 23:19:17 +00:00
const scope : unknown = Reflect . get ( resource , "scope" ) ;
2026-10-10 11:32:40 +00:00
const v = resource . values ;
const specification = resource . specification ;
await writeResourceRow (
database ,
"resources" ,
{
id : resource.id ,
companyId : resource.companyId ,
2026-10-24 23:19:17 +00:00
. . . ( scope === "company" || scope === "global" ? { scope } : { } ) ,
2026-10-10 11:32:40 +00:00
templateId : v.templateId ,
. . . integrationColumns ( v ) ,
name : v.name ,
active : v.active ,
notes : v.notes ,
templateName : specification.templateName ,
sectionsDefined : specification.sections !== undefined ,
} ,
transaction
) ;
2026-10-19 09:45:09 +00:00
for ( const table of [
"resource_characteristics" ,
"resource_specification_field_choices" ,
"resource_specification_fields" ,
"resource_specification_sections" ,
] ) {
await database . query ( ` DELETE FROM ${ sqlIdentifier ( table ) } WHERE resourceId=:id ` , {
replacements : { id : resource.id } ,
transaction ,
} ) ;
}
2026-10-10 11:32:40 +00:00
for ( const [ position , section ] of ( specification . sections ? ? [ ] ) . entries ( ) ) {
await writeResourceRow (
database ,
"resource_specification_sections" ,
{ . . . section , resourceId : resource.id , position } ,
transaction
) ;
}
for ( const [ position , field ] of specification . fields . entries ( ) ) {
await writeResourceRow (
database ,
"resource_specification_fields" ,
{
resourceId : resource.id ,
fieldId : field.fieldId ,
name : field.name ,
fieldType : field.type ,
unit : field.unit ,
required : field.required ,
sectionId : field.sectionId ? ? null ,
position ,
} ,
transaction
) ;
for ( const [ choicePosition , value ] of field . choices . entries ( ) ) {
await writeResourceRow (
database ,
"resource_specification_field_choices" ,
{
resourceId : resource.id ,
fieldId : field.fieldId ,
position : choicePosition ,
value ,
} ,
transaction
) ;
}
}
2026-10-19 09:45:09 +00:00
2026-10-10 11:32:40 +00:00
for ( const row of characteristicRows ( resource . id , v , specification ) ) {
await writeResourceRow ( database , "resource_characteristics" , row , transaction ) ;
}
for ( const revision of revisions ) {
if ( existing . has ( revision . id ) ) {
continue ;
}
const previous = revision . values ;
await writeResourceRow (
database ,
"resource_revisions" ,
{
id : revision.id ,
resourceId : resource.id ,
actorId : revision.actorId ,
recordedAt : revision.recordedAt ,
position : revision.position ,
templateId : previous.templateId ,
. . . integrationColumns ( previous ) ,
name : previous.name ,
active : previous.active ,
notes : previous.notes ,
} ,
transaction
) ;
2026-10-19 09:45:09 +00:00
await writeResourceRevisionSpecification (
database ,
resource . id ,
revision . id ,
revision . specification ,
transaction
) ;
for ( const row of characteristicRows (
resource . id ,
previous ,
revision . specification ,
revision . id
) ) {
2026-10-10 11:32:40 +00:00
await writeResourceRow ( database , "resource_revision_characteristics" , row , transaction ) ;
}
}
}
async function readCharacteristics (
database : Sequelize ,
table : "resource_characteristics" | "resource_revision_characteristics" ,
ids : string [ ] ,
2026-10-24 23:19:17 +00:00
transaction : Transaction ,
revisionIds? : readonly string [ ]
2026-10-10 11:32:40 +00:00
) {
2026-10-24 23:19:17 +00:00
if ( ! ids . length || revisionIds ? . length === 0 ) {
2026-10-10 11:32:40 +00:00
return [ ] ;
}
// El protocolo de MariaDB puede devolver DATE como Date: fijar ISO evita depender
// de la zona horaria del proceso y mantiene el contrato de fecha sin hora.
const revision = table === "resource_revision_characteristics" ? "revisionId," : "" ;
return database . query < CharacteristicRow > (
2026-10-24 23:19:17 +00:00
` SELECT ${ revision } resourceId,fieldId,valueType,textValue,numberValue,booleanValue,DATE_FORMAT(dateValue,'%Y-%m-%d') AS dateValue FROM ${ sqlIdentifier ( table ) } WHERE resourceId IN (:ids) ${ revisionIds ? " AND revisionId IN (:revisionIds)" : "" } ` ,
{
replacements : { ids , revisionIds : revisionIds ? [ . . . revisionIds ] : [ ] } ,
type : QueryTypes . SELECT ,
transaction ,
}
2026-10-10 11:32:40 +00:00
) ;
}
2026-10-24 23:19:17 +00:00
async function assertLocalResourceUpdate (
database : Sequelize ,
resource : ReturnType < ResourcePersistenceMapper [ " toPersistence " ] > [ "resource" ] ,
transaction : Transaction
) {
const scope : unknown = Reflect . get ( resource , "scope" ) ;
if ( Object . hasOwn ( resource , "scope" ) && scope !== "company" ) {
throw new ResourceCatalogError ( "RESOURCE_SCOPE_UNSUPPORTED" ) ;
}
const previous = await database . query < {
companyId : string | null ;
templateId : string ;
scope? : unknown ;
} > ( "SELECT * FROM resources WHERE id=:id FOR UPDATE" , {
replacements : { id : resource.id } ,
transaction ,
type : QueryTypes . SELECT ,
} ) ;
const stored = previous [ 0 ] ;
if ( stored ) {
const ownership = readResourceOwnership (
stored ,
Object . hasOwn ( resource , "scope" ) ? "scoped" : "legacy"
) ;
if (
ownership . scope !== "company" ||
ownership . companyId . toLowerCase ( ) !== resource . companyId . toLowerCase ( ) ||
stored . templateId !== resource . templateId
) {
throw new ResourceCatalogError ( "RESOURCE_OWNERSHIP_FIXED" ) ;
}
}
}