415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
|
|
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";
|
||
|
|
|
||
|
|
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;
|
||
|
|
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"> &
|
||
|
|
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 revisions: RevisionRow[] = histories.map((row) => ({
|
||
|
|
...row,
|
||
|
|
values: values(
|
||
|
|
row,
|
||
|
|
historical.filter((entry) => entry.revisionId === row.id)
|
||
|
|
),
|
||
|
|
}));
|
||
|
|
|
||
|
|
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
|
||
|
|
);
|
||
|
|
// La especificación permanece congelada durante toda la vida del recurso.
|
||
|
|
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
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
await database.query("DELETE FROM resource_characteristics WHERE resourceId=:id", {
|
||
|
|
replacements: { id: resource.id },
|
||
|
|
transaction,
|
||
|
|
});
|
||
|
|
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
|
||
|
|
);
|
||
|
|
for (const row of characteristicRows(resource.id, previous, specification, revision.id)) {
|
||
|
|
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 }
|
||
|
|
);
|
||
|
|
}
|