264 lines
8.3 KiB
TypeScript
264 lines
8.3 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
});
|
|
}
|