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

268 lines
8.4 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 {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
} from "./resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
2026-10-10 11:32:40 +00:00
import { readDefinitions } from "./resource-catalog-relational";
import { readInventory, writeInventory } from "./resource-inventory-relational";
2026-10-05 15:01:32 +00:00
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
2026-10-10 14:40:51 +00:00
import {
readResourceProjectionHistory,
readResourceProjections,
} from "./resource-projection-reader";
2026-10-05 15:01:32 +00:00
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(),
});
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
type SourceRow = { id: string; companyId: string; values: unknown };
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
function identity(value: string) {
const id = UniqueID.create(value);
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
if (!id.isSuccess) {
throw new Error("Identidad de migración inválida.");
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
return id.data;
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
function definition(catalog: ResourceCatalog, key: string) {
const item = catalog.definitions.find((entry) => entry.stockKey.getOrUndefined() === key);
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
if (!item) {
throw new Error("Definición de migración ausente.");
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
return item;
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
function spec(catalog: ResourceCatalog, kind: "mailbox" | "access") {
const template = definition(catalog, `template:${kind}`);
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
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);
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
if (field?.values.data.kind !== "field") {
throw new Error("Campo de migración inválido.");
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
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,
});
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
if (!result.isSuccess) {
throw new Error("Especificación de migración inválida.");
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
return result.data;
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
function map(catalog: ResourceCatalog, kind: "mailbox" | "access", value: unknown): ResourceInput {
const templateId = definition(catalog, `template:${kind}`).id.toPrimitive();
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
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();
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
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();
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
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,
},
};
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
async function importResource(
database: Sequelize,
catalog: ResourceCatalog,
kind: "mailbox" | "access",
row: SourceRow,
transaction: Transaction
) {
2026-10-10 11:32:40 +00:00
const existing = (await readInventory(database, row.companyId, transaction)).rows.find(
(item) => item.id === row.id
);
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
if (existing) {
const parsed = ResourceInputSchema.parse(json(existing.values));
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
if (!parsed || parsed.integration?.kind !== kind || existing.companyId !== row.companyId) {
throw new Error("Colisión de identidad al migrar recursos.");
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
return;
}
2026-10-10 14:40:51 +00:00
const rows = await readResourceProjectionHistory(database, kind, row.id, transaction);
2026-10-05 15:01:32 +00:00
const specification = spec(catalog, kind);
const values = ResourceValues.create(map(catalog, kind, row.values), specification);
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
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);
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
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.");
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
return ResourceRevision.rehydrate(
{
values: previous.data,
actorId: identity(revision.actorId),
recordedAt: revision.recordedAt,
},
identity(revision.id)
);
});
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
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);
2026-10-10 10:01:58 +00:00
2026-10-10 11:32:40 +00:00
await writeInventory(database, saved, new Set(), transaction);
2026-10-05 15:01:32 +00:00
}
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
/** 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,
});
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
if (!root) {
throw new Error("Catálogo de recursos ausente.");
}
2026-10-10 11:32:40 +00:00
const definitions = await readDefinitions(database, root.id, transaction);
2026-10-05 15:01:32 +00:00
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 }
);
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
for (const company of companies) {
2026-10-10 14:40:51 +00:00
const mailboxRows = await readResourceProjections(
database,
"mailbox",
company.id,
transaction
2026-10-05 15:01:32 +00:00
);
2026-10-10 14:40:51 +00:00
const accessRows = await readResourceProjections(database, "access", company.id, transaction);
2026-10-10 10:01:58 +00:00
2026-10-05 15:01:32 +00:00
for (const row of mailboxRows) {
await importResource(database, catalog, "mailbox", row, transaction);
}
for (const row of accessRows) {
await importResource(database, catalog, "access", row, transaction);
}
}
});
}