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"; import { ResourceSpecification, ResourceValues, } from "../../domain/value-objects/resource-values.vo"; import { ResourceAvailabilityModel, ResourceBindingModel, ResourceCatalogModel, } from "./resource-catalog.model"; import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper"; import { readDefinitions } from "./resource-catalog-relational"; import { readInventory, writeInventory } from "./resource-inventory-relational"; import { ResourcePersistenceMapper } from "./resource-persistence.mapper"; import { readResourceProjectionHistory, readResourceProjections, } from "./resource-projection-reader"; 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 }; 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, ...(binding.sectionId ? { sectionId: binding.sectionId } : {}), }; }); const result = ResourceSpecification.create({ templateId: template.id.toPrimitive(), templateName: template.values.data.name, ...(template.values.data.sections ? { sections: [...template.values.data.sections] } : {}), 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(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(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 readInventory(database, row.companyId, transaction)).rows.find( (item) => item.id === row.id ); if (existing) { const parsed = ResourceInputSchema.parse(existing.values); if (!parsed || parsed.integration?.kind !== kind || existing.companyId !== row.companyId) { throw new Error("Colisión de identidad al migrar recursos."); } return; } const rows = await readResourceProjectionHistory(database, kind, row.id, 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 writeInventory(database, saved, new Set(), 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 readDefinitions(database, 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 readResourceProjections( database, "mailbox", company.id, transaction ); const accessRows = await readResourceProjections(database, "access", company.id, 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); } } }); }