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"; 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; 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 & IntegrationColumns & { name: string; active: boolean; notes: string; templateName: string; sectionsDefined: boolean; }; type RevisionColumns = Omit & IntegrationColumns & { name: string; active: boolean; notes: string; templateId: string; }; type SpecificationField = Omit & { 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 ) { 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( database, "resources", "companyId", [companyId], transaction ); const ids = resources.map((row) => row.id); const fields = await readResourceRows( database, "resource_specification_fields", "resourceId", ids, transaction ); const sections = await readResourceRows( database, "resource_specification_sections", "resourceId", ids, transaction ); const choices = await readResourceRows( database, "resource_specification_field_choices", "resourceId", ids, transaction ); const current = await readCharacteristics(database, "resource_characteristics", ids, transaction); const histories = await readResourceRows( 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) ), }; }); return { rows, revisions }; } export async function writeInventory( database: Sequelize, rows: ReturnType, existing: Set, 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, }); } 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 ); } } 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 )) { 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( `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 } ); }