import type { Sequelize, Transaction } from "sequelize"; import { QueryTypes } from "sequelize"; import { ResourceCatalogError } from "../../domain/resource-definition"; import type { ResourceInput, ResourceSpecificationData } from "../../domain/resource-values"; import { ResourceInputSchema, ResourceSpecificationSchema } from "../../domain/resource-values"; import type { ResourcePersistenceMapper } from "./resource-persistence.mapper"; import { readResourceOwnership } 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 | null; scope?: unknown; 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 ); return readInventoryContents(database, resources, transaction); } /** Lectura interna por identidades; no expuesta como servicio ni endpoint. */ export async function readInventoryByIds( database: Sequelize, ids: readonly string[], transaction: Transaction, revisionIds?: readonly string[] ) { const resources = await readResourceRows( database, "resources", "id", ids, transaction ); return readInventoryContents(database, resources, transaction, revisionIds); } async function readInventoryContents( database: Sequelize, resources: readonly ResourceRow[], transaction: Transaction, revisionIds?: readonly string[] ) { 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", revisionIds ? "id" : "resourceId", revisionIds ?? ids, transaction ); const historical = await readCharacteristics( database, "resource_revision_characteristics", ids, transaction, revisionIds ); 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, revisionIds ); 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 | ReturnType, existing: Set, transaction: Transaction ) { await assertLocalResourceUpdate(database, rows.resource, transaction); await persistInventory(database, rows, existing, transaction); } /** Escritor exclusivo de raíz global; nunca proyecta Access, SMB ni directorio. */ export async function writeGlobalInventory( database: Sequelize, rows: ReturnType, existing: Set, transaction: Transaction ) { if ( rows.resource.companyId !== null || rows.resource.scope !== "global" || rows.resource.values.integration ) { throw new ResourceCatalogError("RESOURCE_SCOPE_UNSUPPORTED"); } const previous = await database.query<{ companyId: unknown; scope: unknown; templateId: string }>( "SELECT companyId,scope,templateId FROM resources WHERE id=:id FOR UPDATE", { replacements: { id: rows.resource.id }, transaction, type: QueryTypes.SELECT } ); const root = previous[0]; if ( root && (readResourceOwnership(root, "scoped").scope !== "global" || root.templateId !== rows.resource.templateId) ) { throw new ResourceCatalogError("RESOURCE_OWNERSHIP_FIXED"); } await persistInventory(database, rows, existing, transaction); } async function persistInventory( database: Sequelize, rows: | ReturnType | ReturnType | ReturnType, existing: Set, transaction: Transaction ) { const { resource, revisions } = rows; const scope: unknown = Reflect.get(resource, "scope"); const v = resource.values; const specification = resource.specification; await writeResourceRow( database, "resources", { id: resource.id, companyId: resource.companyId, ...(scope === "company" || scope === "global" ? { scope } : {}), 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, revisionIds?: readonly string[] ) { if (!ids.length || revisionIds?.length === 0) { 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)${revisionIds ? " AND revisionId IN (:revisionIds)" : ""}`, { replacements: { ids, revisionIds: revisionIds ? [...revisionIds] : [] }, type: QueryTypes.SELECT, transaction, } ); } async function assertLocalResourceUpdate( database: Sequelize, resource: ReturnType["resource"], transaction: Transaction ) { const scope: unknown = Reflect.get(resource, "scope"); if (Object.hasOwn(resource, "scope") && scope !== "company") { throw new ResourceCatalogError("RESOURCE_SCOPE_UNSUPPORTED"); } const previous = await database.query<{ companyId: string | null; templateId: string; scope?: unknown; }>("SELECT * FROM resources WHERE id=:id FOR UPDATE", { replacements: { id: resource.id }, transaction, type: QueryTypes.SELECT, }); const stored = previous[0]; if (stored) { const ownership = readResourceOwnership( stored, Object.hasOwn(resource, "scope") ? "scoped" : "legacy" ); if ( ownership.scope !== "company" || ownership.companyId.toLowerCase() !== resource.companyId.toLowerCase() || stored.templateId !== resource.templateId ) { throw new ResourceCatalogError("RESOURCE_OWNERSHIP_FIXED"); } } }