import type { Sequelize, Transaction } from "sequelize"; import { DataTypes, QueryTypes } from "sequelize"; import { z } from "zod/v4"; import { Resource } from "../../domain/resource.aggregate"; import { ResourceSpecification, ResourceValues, } from "../../domain/value-objects/resource-values.vo"; import { writeInventory } from "./resource-inventory-relational"; import { ResourcePersistenceMapper, inventoryIdentity } from "./resource-persistence.mapper"; const mappingTable = "support_tool_resource_migrations"; async function specification(database: Sequelize, transaction: Transaction) { const templates = await database.query<{ id: string; name: string }>( "SELECT id,name FROM resource_definitions WHERE stockKey='template:support'", { type: QueryTypes.SELECT, transaction } ); const template = templates[0]; if (!template) { throw new Error("Plantilla de asistencia ausente."); } const fields = await database.query<{ fieldId: string; name: string; unit: string }>( "SELECT f.id AS fieldId,f.name,f.unit FROM resource_template_fields b JOIN resource_definitions f ON f.id=b.fieldId WHERE b.templateId=:id ORDER BY b.position", { replacements: { id: template.id }, type: QueryTypes.SELECT, transaction } ); const result = ResourceSpecification.create({ templateId: template.id, templateName: template.name, fields: fields.map((field) => ({ ...field, type: "text", choices: [], required: false })), }); if (!result.isSuccess) { throw new Error("Especificación de asistencia inválida."); } return result.data; } /** Traslada el catálogo a recursos por compañía y conserva las conexiones y sus revisiones. */ export async function migrateSupportResources(database: Sequelize) { const query = database.getQueryInterface(); const tables = await database.query<{ name: string }>( "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", { type: QueryTypes.SELECT } ); if (!tables.some((table) => table.name === "support_tools")) { if (tables.some((table) => table.name === "support_catalog_lock")) { await query.dropTable("support_catalog_lock"); } return; } if (!tables.some((table) => table.name === mappingTable)) { await query.createTable(mappingTable, { toolId: { type: DataTypes.UUID, primaryKey: true }, companyId: { type: DataTypes.UUID, primaryKey: true }, resourceId: { type: DataTypes.UUID, allowNull: false, unique: true, references: { model: "resources", key: "id" }, onDelete: "RESTRICT", }, }); } await database.transaction((transaction) => migrateTools(database, transaction)); for (const table of ["asset_support", "asset_support_revisions"] as const) { if (!tables.some((item) => item.name === table)) { continue; } await migrateConnectionReferences(database, table); } await query.dropTable(mappingTable); await query.dropTable("support_tools"); if (tables.some((table) => table.name === "support_catalog_lock")) { await query.dropTable("support_catalog_lock"); } } async function migrateConnectionReferences( database: Sequelize, table: "asset_support" | "asset_support_revisions" ) { const query = database.getQueryInterface(); const columns = await query.describeTable(table); if (!columns.toolId) { return; } if (!columns.resourceId) { await query.addColumn(table, "resourceId", { type: DataTypes.UUID, allowNull: true }); } await database.query( table === "asset_support" ? "UPDATE asset_support s JOIN support_tool_resource_migrations m ON m.companyId=s.companyId AND m.toolId=s.toolId SET s.resourceId=m.resourceId" : "UPDATE asset_support_revisions h JOIN asset_support s ON s.id=h.connectionId JOIN support_tool_resource_migrations m ON m.companyId=s.companyId AND m.toolId=h.toolId SET h.resourceId=m.resourceId" ); const missing = await database.query( `SELECT id FROM \`${table}\` WHERE resourceId IS NULL LIMIT 1`, { type: QueryTypes.SELECT } ); if (missing.length) { throw new Error("Conexión de asistencia sin recurso migrado."); } await query.changeColumn(table, "resourceId", { type: DataTypes.UUID, allowNull: false }); const constraints = await database.query<{ name: string }>( "SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME='toolId' AND REFERENCED_TABLE_NAME IS NOT NULL", { replacements: { table }, type: QueryTypes.SELECT } ); for (const constraint of constraints) { await query.removeConstraint(table, constraint.name); } await database.query(`ALTER TABLE ${table} DROP COLUMN toolId`); } const ToolSchema = z.object({ id: z.uuid(), name: z.string(), active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean), }); async function migrateTools(database: Sequelize, transaction: Transaction) { const companies = await database.query<{ id: string }>( "SELECT id FROM companies ORDER BY id FOR UPDATE", { type: QueryTypes.SELECT, transaction } ); if (!companies.length) { return; } const actors = await database.query<{ id: string }>( "SELECT id FROM users ORDER BY isInitialAdmin DESC,id LIMIT 1", { type: QueryTypes.SELECT, transaction } ); const actor = actors[0]; if (!actor) { throw new Error("La migración necesita un usuario para la auditoría."); } const rows = await database.query>( "SELECT id,name,active FROM support_tools ORDER BY id FOR UPDATE", { type: QueryTypes.SELECT, transaction } ); const tools = rows.map((row) => ToolSchema.parse(row)); const spec = await specification(database, transaction); for (const company of companies) { for (const tool of tools) { await migrateTool(database, company.id, tool, spec, actor.id, transaction); } } } async function migrateTool( database: Sequelize, companyId: string, tool: z.infer, spec: ResourceSpecification, actorId: string, transaction: Transaction ) { const replacements = { toolId: tool.id, companyId }; const existing = await database.query( "SELECT resourceId FROM support_tool_resource_migrations WHERE toolId=:toolId AND companyId=:companyId", { replacements, type: QueryTypes.SELECT, transaction } ); if (existing.length) { return; } const values = ResourceValues.create( { templateId: spec.templateId.toPrimitive(), name: tool.name, active: tool.active, notes: "", characteristics: {}, }, spec ); if (!values.isSuccess) { throw new Error("Herramienta de asistencia inválida."); } const resource = Resource.create( inventoryIdentity(companyId), spec, values.data, inventoryIdentity(actorId), new Date().toISOString() ); if (!resource.isSuccess) { throw new Error("No se pudo crear el recurso de asistencia."); } const rows = new ResourcePersistenceMapper().toPersistence(resource.data); // Conserva el UUID de la herramienta en la primera compañía que pueda utilizarlo. const occupied = await database.query("SELECT id FROM resources WHERE id=:id", { replacements: { id: tool.id }, type: QueryTypes.SELECT, transaction, }); if (!occupied.length) { rows.resource.id = tool.id; for (const revision of rows.revisions) { revision.resourceId = tool.id; } } await writeInventory(database, rows, new Set(), transaction); await database.query( "INSERT INTO support_tool_resource_migrations (toolId,companyId,resourceId) VALUES (:toolId,:companyId,:resourceId)", { replacements: { ...replacements, resourceId: rows.resource.id }, transaction } ); }