metis-desk/modules/resources/src/api/infrastructure/persistence/resource-revision-specifications.ts

298 lines
9.5 KiB
TypeScript
Raw Normal View History

import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import type { ResourceSpecificationData } from "../../domain/resource-values";
import { ResourceSpecificationSchema } from "../../domain/resource-values";
import { readResourceRows, writeResourceRow } from "./resource-relational-sql";
const bool = z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean);
const identity = z.object({ revisionId: z.uuid(), resourceId: z.uuid() });
const headerSchema = identity.extend({
templateId: z.uuid(),
templateName: z.string(),
sectionsDefined: bool,
});
const fieldSchema = identity.extend({
fieldId: z.uuid(),
name: z.string(),
fieldType: z.enum(["text", "multiline", "number", "date", "boolean", "choice"]),
unit: z.string(),
required: bool,
sectionId: z.uuid().nullable(),
position: z.number().int(),
});
const sectionSchema = identity.extend({
id: z.uuid(),
name: z.string(),
description: z.string(),
position: z.number().int(),
});
const choiceSchema = identity.extend({
fieldId: z.uuid(),
position: z.number().int(),
value: z.string(),
});
/** Se ejecuta antes de que una estructura viva pueda cambiar; las revisiones anteriores reciben su instantánea original. */
export async function migrateResourceRevisionSpecifications(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
);
const revision = {
type: DataTypes.UUID,
primaryKey: true,
references: { model: "resource_revisions", key: "id" },
onDelete: "CASCADE",
};
const parent = {
type: DataTypes.UUID,
allowNull: false,
references: { model: "resources", key: "id" },
onDelete: "RESTRICT",
};
const definitions = [
[
"resource_revision_specifications",
{
revisionId: revision,
resourceId: parent,
templateId: { type: DataTypes.UUID, allowNull: false },
templateName: { type: DataTypes.STRING(100), allowNull: false },
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false },
},
],
[
"resource_revision_specification_sections",
{
revisionId: revision,
resourceId: parent,
id: { type: DataTypes.UUID, primaryKey: true },
name: { type: DataTypes.STRING(100), allowNull: false },
description: { type: DataTypes.TEXT, allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
},
],
[
"resource_revision_specification_fields",
{
revisionId: revision,
resourceId: parent,
fieldId: {
type: DataTypes.UUID,
primaryKey: true,
references: { model: "resource_definitions", key: "id" },
onDelete: "RESTRICT",
},
name: { type: DataTypes.STRING(100), allowNull: false },
fieldType: { type: DataTypes.STRING(12), allowNull: false },
unit: { type: DataTypes.STRING(30), allowNull: false },
required: { type: DataTypes.BOOLEAN, allowNull: false },
sectionId: { type: DataTypes.UUID, allowNull: true },
position: { type: DataTypes.INTEGER, allowNull: false },
},
],
[
"resource_revision_specification_choices",
{
revisionId: revision,
resourceId: parent,
fieldId: { type: DataTypes.UUID, primaryKey: true },
position: { type: DataTypes.INTEGER, primaryKey: true },
value: { type: DataTypes.STRING(100), allowNull: false },
},
],
] as const;
for (const [table, columns] of definitions) {
if (!tables.some((item) => item.name === table)) {
await query.createTable(table, columns);
}
}
await database.transaction(async (transaction) => {
const missing = await database.query<{ id: string }>(
"SELECT r.id FROM resource_revisions r LEFT JOIN resource_revision_specifications s ON s.revisionId=r.id WHERE s.revisionId IS NULL",
{ type: QueryTypes.SELECT, transaction }
);
for (let offset = 0; offset < missing.length; offset += 500) {
const ids = missing.slice(offset, offset + 500).map((item) => item.id);
const options = { replacements: { ids }, transaction };
await database.query(
"INSERT INTO resource_revision_specifications (revisionId,resourceId,templateId,templateName,sectionsDefined) SELECT r.id,a.id,a.templateId,a.templateName,a.sectionsDefined FROM resource_revisions r JOIN resources a ON a.id=r.resourceId WHERE r.id IN (:ids)",
options
);
for (const [suffix, columns] of [
["sections", "id,name,description,position"],
["fields", "fieldId,name,fieldType,unit,required,sectionId,position"],
["choices", "fieldId,position,value"],
] as const) {
const source =
suffix === "choices"
? "resource_specification_field_choices"
: `resource_specification_${suffix}`;
const selected = columns
.split(",")
.map((column) => `s.\`${column}\``)
.join(",");
await database.query(
`INSERT INTO resource_revision_specification_${suffix} (revisionId,resourceId,${columns
.split(",")
.map((column) => `\`${column}\``)
.join(
","
)}) SELECT r.id,r.resourceId,${selected} FROM resource_revisions r JOIN ${source} s ON s.resourceId=r.resourceId WHERE r.id IN (:ids)`,
options
);
}
}
});
}
export async function readResourceRevisionSpecifications(
database: Sequelize,
ids: readonly string[],
2026-10-24 23:19:17 +00:00
transaction: Transaction,
revisionIds?: readonly string[]
) {
const headers = z
.array(headerSchema)
.parse(
await readResourceRows(
database,
"resource_revision_specifications",
2026-10-24 23:19:17 +00:00
revisionIds ? "revisionId" : "resourceId",
revisionIds ?? ids,
transaction
)
);
const fields = z
.array(fieldSchema)
.parse(
await readResourceRows(
database,
"resource_revision_specification_fields",
2026-10-24 23:19:17 +00:00
revisionIds ? "revisionId" : "resourceId",
revisionIds ?? ids,
transaction
)
);
const sections = z
.array(sectionSchema)
.parse(
await readResourceRows(
database,
"resource_revision_specification_sections",
2026-10-24 23:19:17 +00:00
revisionIds ? "revisionId" : "resourceId",
revisionIds ?? ids,
transaction
)
);
const choices = z
.array(choiceSchema)
.parse(
await readResourceRows(
database,
"resource_revision_specification_choices",
2026-10-24 23:19:17 +00:00
revisionIds ? "revisionId" : "resourceId",
revisionIds ?? ids,
transaction
)
);
return new Map(
headers.map((header) => [
header.revisionId,
ResourceSpecificationSchema.parse({
templateId: header.templateId,
templateName: header.templateName,
...(header.sectionsDefined
? {
sections: sections
.filter((item) => item.revisionId === header.revisionId)
.sort((a, b) => a.position - b.position)
.map(({ id, name, description }) => ({ id, name, description })),
}
: {}),
fields: fields
.filter((item) => item.revisionId === header.revisionId)
.sort((a, b) => a.position - b.position)
.map((field) => ({
fieldId: field.fieldId,
name: field.name,
type: field.fieldType,
unit: field.unit,
required: field.required,
...(field.sectionId ? { sectionId: field.sectionId } : {}),
choices: choices
.filter(
(item) => item.revisionId === header.revisionId && item.fieldId === field.fieldId
)
.sort((a, b) => a.position - b.position)
.map((item) => item.value),
})),
}),
])
);
}
export async function writeResourceRevisionSpecification(
database: Sequelize,
resourceId: string,
revisionId: string,
specification: ResourceSpecificationData,
transaction: Transaction
) {
const identity = { resourceId, revisionId };
await writeResourceRow(
database,
"resource_revision_specifications",
{
...identity,
templateId: specification.templateId,
templateName: specification.templateName,
sectionsDefined: specification.sections !== undefined,
},
transaction
);
for (const [position, section] of (specification.sections ?? []).entries()) {
await writeResourceRow(
database,
"resource_revision_specification_sections",
{ ...identity, ...section, position },
transaction
);
}
for (const [position, field] of specification.fields.entries()) {
await writeResourceRow(
database,
"resource_revision_specification_fields",
{
...identity,
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_revision_specification_choices",
{ ...identity, fieldId: field.fieldId, position: choicePosition, value },
transaction
);
}
}
}