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

250 lines
9.0 KiB
TypeScript
Raw Normal View History

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,
...(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,
...(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);
}
}
});
}