575 lines
16 KiB
TypeScript
575 lines
16 KiB
TypeScript
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<InventoryRow, "values" | "specification"> &
|
|
IntegrationColumns & {
|
|
name: string;
|
|
active: boolean;
|
|
notes: string;
|
|
templateName: string;
|
|
sectionsDefined: boolean;
|
|
};
|
|
|
|
type RevisionColumns = Omit<RevisionRow, "values" | "specification"> &
|
|
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
|
|
);
|
|
|
|
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<ResourceRow>(
|
|
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<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",
|
|
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<ResourcePersistenceMapper["toPersistence"]>
|
|
| ReturnType<ResourcePersistenceMapper["toScopedPersistence"]>,
|
|
existing: Set<string>,
|
|
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<ResourcePersistenceMapper["toGlobalPersistence"]>,
|
|
existing: Set<string>,
|
|
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<ResourcePersistenceMapper["toPersistence"]>
|
|
| ReturnType<ResourcePersistenceMapper["toScopedPersistence"]>
|
|
| ReturnType<ResourcePersistenceMapper["toGlobalPersistence"]>,
|
|
existing: Set<string>,
|
|
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<CharacteristicRow>(
|
|
`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<ResourcePersistenceMapper["toPersistence"]>["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");
|
|
}
|
|
}
|
|
}
|