metis-desk/modules/resources/src/api/infrastructure/persistence/resource-inventory-relational.ts

453 lines
13 KiB
TypeScript
Raw Normal View History

2026-10-10 11:32:40 +00:00
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import type { ResourceInput, ResourceSpecificationData } from "../../domain/resource-values";
import { ResourceInputSchema, ResourceSpecificationSchema } from "../../domain/resource-values";
import type { ResourcePersistenceMapper } from "./resource-persistence.mapper";
import { readResourceRows, sqlIdentifier, writeResourceRow } from "./resource-relational-sql";
import {
readResourceRevisionSpecifications,
writeResourceRevisionSpecification,
} from "./resource-revision-specifications";
2026-10-10 11:32:40 +00:00
export type InventoryRow = {
id: string;
companyId: string;
templateId: string;
values: unknown;
specification: unknown;
};
export type RevisionRow = {
id: string;
resourceId: string;
actorId: string;
recordedAt: string;
position: number;
specification?: unknown;
2026-10-10 11:32:40 +00:00
values: unknown;
};
type IntegrationColumns = {
integrationKind: string | null;
addressFieldId: string | null;
contextFieldId: string | null;
mailboxKind: string | null;
accountId: string | null;
referenceFieldId: string | null;
resourceTypeId: string | null;
systemId: string | null;
environmentId: string | null;
};
function integration(row: IntegrationColumns) {
switch (row.integrationKind) {
case null:
return {};
case "mailbox":
return {
integration: {
kind: "mailbox",
addressFieldId: row.addressFieldId,
contextFieldId: row.contextFieldId,
mailboxKind: row.mailboxKind,
accountId: row.accountId,
},
};
case "access":
return {
integration: {
kind: "access",
referenceFieldId: row.referenceFieldId,
resourceTypeId: row.resourceTypeId,
systemId: row.systemId,
environmentId: row.environmentId,
},
};
default:
throw new Error("Integración de recurso persistida inconsistente.");
}
}
function integrationColumns(values: ResourceInput): IntegrationColumns {
const item = values.integration;
return {
integrationKind: item?.kind ?? null,
addressFieldId: item?.kind === "mailbox" ? item.addressFieldId : null,
contextFieldId: item?.kind === "mailbox" ? item.contextFieldId : null,
mailboxKind: item?.kind === "mailbox" ? item.mailboxKind : null,
accountId: item?.kind === "mailbox" ? item.accountId : null,
referenceFieldId: item?.kind === "access" ? item.referenceFieldId : null,
resourceTypeId: item?.kind === "access" ? item.resourceTypeId : null,
systemId: item?.kind === "access" ? item.systemId : null,
environmentId: item?.kind === "access" ? item.environmentId : null,
};
}
type ResourceRow = Omit<InventoryRow, "values" | "specification"> &
IntegrationColumns & {
name: string;
active: boolean;
notes: string;
templateName: string;
sectionsDefined: boolean;
};
type RevisionColumns = Omit<RevisionRow, "values" | "specification"> &
2026-10-10 11:32:40 +00:00
IntegrationColumns & {
name: string;
active: boolean;
notes: string;
templateId: string;
};
type SpecificationField = Omit<ResourceSpecificationData["fields"][number], "type" | "choices"> & {
resourceId: string;
fieldType: string;
position: number;
};
type SpecificationSection = {
resourceId: string;
id: string;
name: string;
description: string;
position: number;
};
type SpecificationChoice = { resourceId: string; fieldId: string; position: number; value: string };
type CharacteristicRow = {
resourceId: string;
revisionId?: string;
fieldId: string;
valueType: string;
textValue: string | null;
numberValue: number | null;
booleanValue: boolean | null;
dateValue: string | null;
};
export function decodeCharacteristic(
row: Omit<CharacteristicRow, "resourceId" | "fieldId" | "revisionId">
) {
const populated = [row.textValue, row.numberValue, row.booleanValue, row.dateValue].filter(
(value) => value !== null
);
if (populated.length !== 1) {
throw new Error("Valor tipado de recurso persistido inconsistente.");
}
switch (row.valueType) {
case "text":
case "choice":
if (row.textValue !== null) {
return row.textValue;
}
break;
case "number":
if (row.numberValue !== null) {
return Number(row.numberValue);
}
break;
case "boolean":
if (row.booleanValue !== null) {
return Boolean(row.booleanValue);
}
break;
case "date":
if (row.dateValue !== null) {
return row.dateValue;
}
break;
}
throw new Error("Tipo de característica persistida inconsistente.");
}
function characteristicRows(
resourceId: string,
values: ResourceInput,
specification: ResourceSpecificationData,
revisionId?: string
) {
return Object.entries(values.characteristics).map(([fieldId, value]) => {
const type = specification.fields.find((field) => field.fieldId === fieldId)?.type;
if (!type) {
throw new Error("Campo de recurso sin especificación.");
}
return {
resourceId,
...(revisionId ? { revisionId } : {}),
fieldId,
valueType: type,
textValue: type === "text" || type === "choice" ? String(value) : null,
numberValue: type === "number" && typeof value === "number" ? value : null,
booleanValue: type === "boolean" && typeof value === "boolean" ? value : null,
dateValue: type === "date" ? String(value) : null,
};
});
}
export async function readInventory(
database: Sequelize,
companyId: string,
transaction: Transaction
) {
const resources = await readResourceRows<ResourceRow>(
database,
"resources",
"companyId",
[companyId],
transaction
);
const ids = resources.map((row) => row.id);
const fields = await readResourceRows<SpecificationField>(
database,
"resource_specification_fields",
"resourceId",
ids,
transaction
);
const sections = await readResourceRows<SpecificationSection>(
database,
"resource_specification_sections",
"resourceId",
ids,
transaction
);
const choices = await readResourceRows<SpecificationChoice>(
database,
"resource_specification_field_choices",
"resourceId",
ids,
transaction
);
const current = await readCharacteristics(database, "resource_characteristics", ids, transaction);
const histories = await readResourceRows<RevisionColumns>(
database,
"resource_revisions",
"resourceId",
ids,
transaction
);
const historical = await readCharacteristics(
database,
"resource_revision_characteristics",
ids,
transaction
);
const values = (row: ResourceRow | RevisionColumns, entries: CharacteristicRow[]) =>
ResourceInputSchema.parse({
name: row.name,
templateId: row.templateId,
active: Boolean(row.active),
...integration(row),
notes: row.notes,
characteristics: Object.fromEntries(
entries.map((entry) => [entry.fieldId, decodeCharacteristic(entry)])
),
});
const rows: InventoryRow[] = resources.map((row) => ({
...row,
values: values(
row,
current.filter((entry) => entry.resourceId === row.id)
),
specification: ResourceSpecificationSchema.parse({
templateId: row.templateId,
templateName: row.templateName,
...(row.sectionsDefined
? {
sections: sections
.filter((section) => section.resourceId === row.id)
.sort((a, b) => a.position - b.position)
.map(({ id, name, description }) => ({ id, name, description })),
}
: {}),
fields: fields
.filter((field) => field.resourceId === row.id)
.sort((a, b) => a.position - b.position)
.map((field) => ({
fieldId: field.fieldId,
name: field.name,
type: field.fieldType,
unit: field.unit,
required: Boolean(field.required),
...(field.sectionId ? { sectionId: field.sectionId } : {}),
choices: choices
.filter((choice) => choice.resourceId === row.id && choice.fieldId === field.fieldId)
.sort((a, b) => a.position - b.position)
.map((choice) => choice.value),
})),
}),
}));
const revisionSpecifications = await readResourceRevisionSpecifications(
database,
ids,
transaction
);
const revisions: RevisionRow[] = histories.map((row) => {
const specification = revisionSpecifications.get(row.id);
if (!specification) {
throw new Error("Revisión sin especificación histórica persistida.");
}
return {
...row,
specification,
values: values(
row,
historical.filter((entry) => entry.revisionId === row.id)
),
};
});
2026-10-10 11:32:40 +00:00
return { rows, revisions };
}
export async function writeInventory(
database: Sequelize,
rows: ReturnType<ResourcePersistenceMapper["toPersistence"]>,
existing: Set<string>,
transaction: Transaction
) {
const { resource, revisions } = rows;
const v = resource.values;
const specification = resource.specification;
await writeResourceRow(
database,
"resources",
{
id: resource.id,
companyId: resource.companyId,
templateId: v.templateId,
...integrationColumns(v),
name: v.name,
active: v.active,
notes: v.notes,
templateName: specification.templateName,
sectionsDefined: specification.sections !== undefined,
},
transaction
);
for (const table of [
"resource_characteristics",
"resource_specification_field_choices",
"resource_specification_fields",
"resource_specification_sections",
]) {
await database.query(`DELETE FROM ${sqlIdentifier(table)} WHERE resourceId=:id`, {
replacements: { id: resource.id },
transaction,
});
}
2026-10-10 11:32:40 +00:00
for (const [position, section] of (specification.sections ?? []).entries()) {
await writeResourceRow(
database,
"resource_specification_sections",
{ ...section, resourceId: resource.id, position },
transaction
);
}
for (const [position, field] of specification.fields.entries()) {
await writeResourceRow(
database,
"resource_specification_fields",
{
resourceId: resource.id,
fieldId: field.fieldId,
name: field.name,
fieldType: field.type,
unit: field.unit,
required: field.required,
sectionId: field.sectionId ?? null,
position,
},
transaction
);
for (const [choicePosition, value] of field.choices.entries()) {
await writeResourceRow(
database,
"resource_specification_field_choices",
{
resourceId: resource.id,
fieldId: field.fieldId,
position: choicePosition,
value,
},
transaction
);
}
}
2026-10-10 11:32:40 +00:00
for (const row of characteristicRows(resource.id, v, specification)) {
await writeResourceRow(database, "resource_characteristics", row, transaction);
}
for (const revision of revisions) {
if (existing.has(revision.id)) {
continue;
}
const previous = revision.values;
await writeResourceRow(
database,
"resource_revisions",
{
id: revision.id,
resourceId: resource.id,
actorId: revision.actorId,
recordedAt: revision.recordedAt,
position: revision.position,
templateId: previous.templateId,
...integrationColumns(previous),
name: previous.name,
active: previous.active,
notes: previous.notes,
},
transaction
);
await writeResourceRevisionSpecification(
database,
resource.id,
revision.id,
revision.specification,
transaction
);
for (const row of characteristicRows(
resource.id,
previous,
revision.specification,
revision.id
)) {
2026-10-10 11:32:40 +00:00
await writeResourceRow(database, "resource_revision_characteristics", row, transaction);
}
}
}
async function readCharacteristics(
database: Sequelize,
table: "resource_characteristics" | "resource_revision_characteristics",
ids: string[],
transaction: Transaction
) {
if (!ids.length) {
return [];
}
// El protocolo de MariaDB puede devolver DATE como Date: fijar ISO evita depender
// de la zona horaria del proceso y mantiene el contrato de fecha sin hora.
const revision = table === "resource_revision_characteristics" ? "revisionId," : "";
return database.query<CharacteristicRow>(
`SELECT ${revision}resourceId,fieldId,valueType,textValue,numberValue,booleanValue,DATE_FORMAT(dateValue,'%Y-%m-%d') AS dateValue FROM ${sqlIdentifier(table)} WHERE resourceId IN (:ids)`,
{ replacements: { ids }, type: QueryTypes.SELECT, transaction }
);
}