metis-desk/modules/resources/src/api/infrastructure/persistence/migrate-support-resources.ts

223 lines
7.7 KiB
TypeScript

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<Record<string, unknown>>(
"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<typeof ToolSchema>,
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 }
);
}