Normalización de la BD

This commit is contained in:
David Arranz 2026-10-10 13:32:40 +02:00
parent ae9c490be5
commit 933972ae8e
56 changed files with 2432 additions and 368 deletions

View File

@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { configurationSnapshots } from "../../../modules/companies/src/api/infrastructure/persistence/configuration-snapshots";
import {
normalizeScalarSnapshots,
snapshotValues,
} from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-normalization";
export async function verifyConfigurationNormalization(database: Sequelize) {
assert.equal(database.getDatabaseName(), "metis_desk_test");
for (const [table, definition] of configurationSnapshots) {
const rows = await database.query<Record<string, unknown>>(
`SELECT * FROM ${table} ORDER BY id`,
{ type: QueryTypes.SELECT }
);
const expected = rows.map((row) => ({ id: row.id, values: snapshotValues(definition, row) }));
await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`);
const store = (id: unknown, values: unknown) =>
database.query(`UPDATE ${table} SET \`values\`=:values WHERE id=:id`, {
replacements: { id, values: JSON.stringify(values) },
});
for (const row of expected) {
await store(row.id, row.values);
}
if (table === "work_profiles" && expected[0]) {
const input = definition.schema.parse(expected[0].values);
const foreign = { ...Object(input), personId: randomUUID() };
await store(expected[0].id, foreign);
await assert.rejects(normalizeScalarSnapshots(database, [table], definition));
assert.ok((await database.getQueryInterface().describeTable(table)).values);
await store(expected[0].id, input);
}
await normalizeScalarSnapshots(database, [table], definition);
await normalizeScalarSnapshots(database, [table], definition);
const actual = await database.query<Record<string, unknown>>(
`SELECT * FROM ${table} ORDER BY id`,
{ type: QueryTypes.SELECT }
);
assert.deepEqual(
actual.map((row) => ({ id: row.id, values: snapshotValues(definition, row) })),
expected
);
assert.equal((await database.getQueryInterface().describeTable(table)).values, undefined);
}
}

View File

@ -31,6 +31,7 @@ import { verifyBrands } from "./brands.integration";
import { verifyCertificates } from "./certificates.integration";
import { verifyCompanyLogos } from "./company-logos.integration";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
import { verifyConfigurationNormalization } from "./configuration-normalization.integration";
import {
verifyDashboard,
verifyDashboardBoundaries,
@ -47,6 +48,7 @@ import { verifyRecordDeletion } from "./record-deletion.integration";
import { verifyRelationships } from "./relationships.integration";
import { verifyRemoteConnections } from "./remote.integration";
import { verifyResourceMigration } from "./resource-migration.integration";
import { verifyResourceNormalization } from "./resource-normalization.integration";
import { verifyResources } from "./resources.integration";
import { verifySupport } from "./support.integration";
import { verifyTelework } from "./telework.integration";
@ -115,6 +117,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await database.query("DELETE FROM resources");
await database.query("DELETE FROM resource_availability");
await database.query("DELETE FROM resource_template_fields");
await database.query("DELETE FROM resource_template_sections");
await database.query("DELETE FROM resource_field_choices");
await database.query("DELETE FROM resource_definitions");
await database.query("DELETE FROM asset_relationships");
for (const table of [
@ -979,11 +983,13 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
migratedServer.close((error) => (error ? reject(error) : resolve()))
);
}
await verifyResourceNormalization(database);
await verifyAssetNormalization(
database,
(path, body) => operator.request(path, body),
customer.id
);
await verifyConfigurationNormalization(database);
await verifyRecordDeletion((path, body) => operator.request(path, body), database);
const countAfterAccess = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM users",

View File

@ -42,33 +42,33 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
assert.ok(subject, "Debe existir un perfil y una necesidad de recurso para la prueba de carpeta");
const mappingId = randomUUID();
const values = JSON.stringify({
const values = {
profileId: subject.profileId,
needId: subject.needId,
path: "\\\\SERVER\\Test",
driveLetter: null,
state: "pending",
notes: null,
});
};
await database.query(
"INSERT INTO folder_mappings (id,companyId,profileId,needId,`values`,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:values,:currentKey,NULL)",
"INSERT INTO folder_mappings (id,companyId,profileId,needId,path,driveLetter,state,notes,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:path,:driveLetter,:state,:notes,:currentKey,NULL)",
{
replacements: {
...subject,
id: mappingId,
values,
...values,
currentKey: `${subject.profileId}:${subject.needId}`,
},
}
);
await database.query(
"INSERT INTO folder_mapping_revisions (id,mappingId,position,`values`,actorId,recordedAt) VALUES (:id,:mappingId,0,:values,:actorId,:recordedAt)",
"INSERT INTO folder_mapping_revisions (id,mappingId,position,profileId,needId,path,driveLetter,state,notes,actorId,recordedAt) VALUES (:id,:mappingId,0,:profileId,:needId,:path,:driveLetter,:state,:notes,:actorId,:recordedAt)",
{
replacements: {
id: randomUUID(),
mappingId,
values,
...values,
actorId: subject.actorId,
recordedAt: new Date().toISOString(),
},

View File

@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { readDefinitions } from "../../../modules/resources/src/api/infrastructure/persistence/resource-catalog-relational";
import { readInventory } from "../../../modules/resources/src/api/infrastructure/persistence/resource-inventory-relational";
import {
enforceResourceConstraints,
normalizeResourceCatalog,
normalizeResourceInventory,
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-normalization-migration";
import { ResourcePersistenceMapper } from "../../../modules/resources/src/api/infrastructure/persistence/resource-persistence.mapper";
export async function verifyResourceNormalization(database: Sequelize) {
const catalogRoots = await database.query<{ id: string }>("SELECT id FROM resource_catalogs", {
type: QueryTypes.SELECT,
});
const companies = await database.query<{ companyId: string }>(
"SELECT DISTINCT companyId FROM resources",
{ type: QueryTypes.SELECT }
);
const before = await database.transaction(async (transaction) => ({
definitions: (
await Promise.all(catalogRoots.map((root) => readDefinitions(database, root.id, transaction)))
).flat(),
inventories: await Promise.all(
companies.map(async (company) => ({
companyId: company.companyId,
...(await readInventory(database, company.companyId, transaction)),
}))
),
}));
// Recrear el origen anterior solo en la base dedicada de pruebas, conservando sus UUID.
assert.equal(database.getDatabaseName(), "metis_desk_test");
const [accountForeignKey] = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_revisions' AND COLUMN_NAME='accountId' AND REFERENCED_TABLE_NAME='access_items'",
{ type: QueryTypes.SELECT }
);
assert.ok(accountForeignKey);
assert.match(accountForeignKey.name, /^[a-zA-Z0-9_]+$/);
await database.query(
`ALTER TABLE resource_revisions DROP FOREIGN KEY \`${accountForeignKey.name}\`, ADD CONSTRAINT resource_revision_account_legacy FOREIGN KEY (accountId) REFERENCES access_items(id) ON DELETE CASCADE`
);
await enforceResourceConstraints(database);
await enforceResourceConstraints(database);
const rules = await database.query<{ rule: string }>(
"SELECT c.DELETE_RULE AS rule FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=k.CONSTRAINT_NAME AND c.TABLE_NAME=k.TABLE_NAME WHERE k.TABLE_SCHEMA=DATABASE() AND k.TABLE_NAME='resource_revisions' AND k.COLUMN_NAME='accountId' AND k.REFERENCED_TABLE_NAME='access_items'",
{ type: QueryTypes.SELECT }
);
assert.deepEqual(
rules.map((row) => row.rule),
["RESTRICT"]
);
for (const table of ["resource_definitions", "resources", "resource_revisions"]) {
await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`);
}
await database.query("ALTER TABLE resources ADD COLUMN specification JSON NULL");
const store = async (table: string, id: string, values: unknown) =>
database.query(`UPDATE ${table} SET \`values\`=:values WHERE id=:id`, {
replacements: { id, values: JSON.stringify(values) },
});
for (const definition of before.definitions) {
await store("resource_definitions", definition.id, definition.values);
}
for (const inventory of before.inventories) {
for (const row of inventory.rows) {
await store("resources", row.id, row.values);
await database.query("UPDATE resources SET specification=:value WHERE id=:id", {
replacements: { id: row.id, value: JSON.stringify(row.specification) },
});
}
for (const row of inventory.revisions) {
await store("resource_revisions", row.id, row.values);
}
}
const first = before.definitions[0];
assert.ok(first);
await store("resource_definitions", first.id, { kind: "invalid" });
await assert.rejects(normalizeResourceCatalog(database));
assert.ok((await database.getQueryInterface().describeTable("resource_definitions")).values);
await store("resource_definitions", first.id, first.values);
await normalizeResourceCatalog(database);
await database.query("DELETE FROM resource_characteristics");
await database.query("DELETE FROM resource_revision_characteristics");
await database.query("UPDATE resources SET name='Origen pendiente'");
await database.query("UPDATE resource_revisions SET name='Origen pendiente'");
await normalizeResourceInventory(database);
// Un segundo arranque no modifica ni duplica el catálogo, los valores ni el historial.
await normalizeResourceCatalog(database);
await normalizeResourceInventory(database);
await database.transaction(async (transaction) => {
for (const root of catalogRoots) {
const actual = await readDefinitions(database, root.id, transaction);
assert.deepEqual(
actual.sort((a, b) => a.id.localeCompare(b.id)),
before.definitions
.filter((item) => item.catalogId === root.id)
.sort((a, b) => a.id.localeCompare(b.id))
);
}
const mapper = new ResourcePersistenceMapper();
for (const expected of before.inventories) {
const actual = await readInventory(database, expected.companyId, transaction);
const project = (inventory: typeof actual) =>
inventory.rows
.map((row) =>
mapper.toPersistence(
mapper.toDomain(
row,
inventory.revisions.filter((item) => item.resourceId === row.id)
)
)
)
.sort((a, b) => a.resource.id.localeCompare(b.resource.id));
assert.deepEqual(project(actual), project(expected));
}
});
// Recuperación del corte entre DDL: las revisiones ya se normalizaron pero
// aún quedan las columnas de origen del activo.
await database.query(
"ALTER TABLE resources ADD COLUMN `values` JSON NULL, ADD COLUMN specification JSON NULL"
);
for (const inventory of before.inventories) {
for (const row of inventory.rows) {
await store("resources", row.id, row.values);
await database.query("UPDATE resources SET specification=:value WHERE id=:id", {
replacements: { id: row.id, value: JSON.stringify(row.specification) },
});
}
}
await normalizeResourceInventory(database);
await database.transaction(async (transaction) => {
for (const expected of before.inventories) {
const actual = await readInventory(database, expected.companyId, transaction);
assert.deepEqual(
actual.rows.sort((a, b) => a.id.localeCompare(b.id)),
expected.rows.sort((a, b) => a.id.localeCompare(b.id))
);
assert.deepEqual(
actual.revisions.sort((a, b) => a.id.localeCompare(b.id)),
expected.revisions.sort((a, b) => a.id.localeCompare(b.id))
);
}
});
for (const table of ["resource_definitions", "resources", "resource_revisions"]) {
const columns = await database.getQueryInterface().describeTable(table);
assert.equal(columns.values, undefined);
assert.equal(columns.specification, undefined);
}
}

View File

@ -155,10 +155,10 @@ export async function verifyTelework(
assert.equal(reactivated.effective, true);
assert.equal(reactivated.history.length, 7);
assert.ok(reactivated.history.every((revision) => revision.actorId !== person.id));
await database.query("UPDATE telework_authorizations SET `values`=:values WHERE id=:id", {
await database.query("UPDATE telework_authorizations SET active=:active WHERE id=:id", {
replacements: {
id: authorization.id,
values: JSON.stringify({ ...reactivated.values, personId: randomUUID() }),
active: !reactivated.values.active,
},
});
try {
@ -170,8 +170,8 @@ export async function verifyTelework(
assert.ok(!body.includes("SELECT"));
assert.ok(!body.includes("inconsistente"));
} finally {
await database.query("UPDATE telework_authorizations SET `values`=:values WHERE id=:id", {
replacements: { id: authorization.id, values: JSON.stringify(reactivated.values) },
await database.query("UPDATE telework_authorizations SET active=:active WHERE id=:id", {
replacements: { id: authorization.id, active: reactivated.values.active },
});
}
}

View File

@ -3,8 +3,11 @@ import assert from "node:assert/strict";
import { InventoryResponseSchema, WorkstationResponseSchema } from "@metis/assets/common";
import { CompanyResponseSchema } from "@metis/companies/common";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { normalizeWorkstations } from "../../../modules/assets/src/api/infrastructure/persistence/workstation-relational";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyWorkstations(
@ -138,17 +141,18 @@ export async function verifyWorkstations(
replacements: { asset: foreign.id, id: created.id },
})
);
const corruption = { ...restored.values, assetIds: [foreign.id] };
await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
replacements: { values: JSON.stringify(corruption), id: restored.id },
await database.query("UPDATE workstation_members SET kind='invalid' WHERE workstationId=:id", {
replacements: { id: restored.id },
});
try {
assert.equal((await request(`/assets/${companyId}`)).status, 500);
} finally {
await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
replacements: { values: JSON.stringify(restored.values), id: restored.id },
});
await database.query(
"UPDATE workstation_members SET kind='exclusive' WHERE workstationId=:id",
{
replacements: { id: restored.id },
}
);
}
const current = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
@ -163,48 +167,40 @@ export async function verifyWorkstations(
.length,
0
);
// Las instantáneas anteriores se leen sin reescribir identidades ni historia.
const legacyRoot = { ...restored.values };
const { sharedAssetIds: _legacyShared, ...legacyValues } = legacyRoot;
// Migrar instantáneas antiguas sin activos compartidos conserva identidades e historial.
const beforeMigration = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
);
for (const table of ["workstations", "workstation_revisions"]) {
await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`);
}
for (const station of beforeMigration.workstations) {
const { sharedAssetIds: _shared, ...legacyValues } = station.values;
await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
replacements: { id: station.id, values: JSON.stringify(legacyValues) },
});
for (const revision of station.history) {
const { sharedAssetIds: _revisionShared, ...legacyRevision } = revision.values;
await database.query(
"UPDATE workstations SET " +
String.fromCharCode(96) +
"values" +
String.fromCharCode(96) +
"=:values WHERE id=:id",
{
replacements: { values: JSON.stringify(legacyValues), id: restored.id },
}
);
await database.query(
"UPDATE workstation_revisions SET `values`=JSON_REMOVE(`values`, '$.sharedAssetIds') WHERE workstationId=:id",
{ replacements: { id: restored.id } }
);
try {
assert.deepEqual(
InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
).workstations.find((item) => item.id === restored.id),
restored
);
} finally {
for (const revision of restored.history) {
await database.query("UPDATE workstation_revisions SET `values`=:values WHERE id=:id", {
replacements: { id: revision.id, values: JSON.stringify(revision.values) },
replacements: { id: revision.id, values: JSON.stringify(legacyRevision) },
});
}
await database.query(
"UPDATE workstations SET " +
String.fromCharCode(96) +
"values" +
String.fromCharCode(96) +
"=:values WHERE id=:id",
{
replacements: { values: JSON.stringify(restored.values), id: restored.id },
}
);
}
await normalizeWorkstations(database);
await normalizeWorkstations(database);
assert.deepEqual(
InventoryResponseSchema.parse(await (await request(`/assets/${companyId}`)).json()),
beforeMigration
);
const jsonColumns = await database.query<{ name: string }>(
"SELECT COLUMN_NAME AS name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('workstations','workstation_revisions') AND COLUMN_NAME='values'",
{ type: QueryTypes.SELECT }
);
assert.equal(jsonColumns.length, 0);
await error(
{ ...input, personId: null, assetIds: [], sharedAssetIds: [first.id] },
"ASSET_IN_WORKSTATION"

View File

@ -50,3 +50,8 @@ La ficha de cada activo, incluida su vista lateral, incorpora [Asistencia técni
## Marca del activo
El campo de serie Marca se selecciona desde el [catálogo global de marcas](catalogo-global-de-marcas.md), con alta rápida. Continúa siendo opcional por defecto. AssetValues guarda una referencia UUID opcional y la característica conserva el nombre como instantánea en cada revisión. Las marcas inactivas existentes y los textos anteriores se mantienen; la importación no reescribe fichas ni historial.
## Persistencia relacional
Los puestos de trabajo y la asistencia técnica se almacenan también de forma relacional, conservando sus revisiones. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md).

View File

@ -0,0 +1,74 @@
# Normalización de la persistencia
Se mantiene Sequelize 6 y MariaDB. El criterio del proyecto es almacenar datos de negocio en columnas y relaciones, sin documentos JSON. Los objetos `values` del dominio y el JSON del contrato HTTP no implican columnas JSON.
## Estado de la normalización
La normalización completa todavía está en curso. Esta revisión extiende el modelo relacional anterior de activos a recursos, puestos, asistencia y configuraciones de perfiles. No representa el cierre de la auditoría de toda la base.
| Ámbito | Persistencia actual |
| --- | --- |
| Compañías, personas, departamentos, sedes, usuarios y marcas | Columnas y relaciones existentes |
| Catálogo e inventario de activos | Definiciones, campos tipados, especificaciones y revisiones normalizados previamente |
| Catálogo e inventario de recursos | Definiciones, opciones, secciones, especificaciones congeladas, valores tipados e integración en columnas/tablas |
| Puestos de trabajo | Nombre, estado y referencias en columnas; activos exclusivos/compartidos por puesto y revisión en tablas |
| Asistencia técnica de activos | Destino, VPN, preferencia, estado y notas en columnas actuales e históricas |
| Perfiles de usuario | Nombre, estado, notas y referencias en columnas actuales e históricas |
| Teletrabajo | Estado, periodo, notas y persona en columnas actuales e históricas |
| Aplicaciones, certificados, impresoras y carpetas del perfil | Valores escalares y referencias en columnas actuales e históricas |
| Cuentas y accesos | Pendiente: catálogo discriminado, asignaciones, permisos, grupos y necesidades |
| Correo | Pendiente: proyecciones de buzones, usos y configuraciones, incluido su formato histórico anterior |
| Accesos remotos | Pendiente |
| Procesos y plantillas de procesos | Pendiente: pasos y revisiones |
| Tickets e intervenciones | Pendiente: afectados, pasos, procedimiento y plantillas |
| Sesiones HTTP | Pendiente de evaluar el contrato del almacén de sesiones existente; no se introduce una excepción nueva para JSON |
Las columnas `data` de fotos y logotipos son datos binarios; no son documentos JSON de negocio.
## Recursos
Las tablas existentes `resource_definitions`, `resource_template_fields`, `resources` y `resource_revisions` mantienen sus identidades. Se añaden:
- `resource_field_choices`: opciones ordenadas de cada campo.
- `resource_template_sections`: secciones ordenadas, identificadas por `(templateId, id)`; copiar una plantilla puede conservar los identificadores internos de sus secciones.
- `resource_specification_sections`, `resource_specification_fields` y `resource_specification_field_choices`: definición congelada al crear el recurso.
- `resource_characteristics` y `resource_revision_characteristics`: valores actuales e históricos con columnas independientes de texto, número, booleano y fecha.
La ausencia de una característica se representa mediante ausencia de fila. Una cadena vacía, `0` y `false` siguen siendo valores y se conservan. Las fechas se leen como fechas ISO. La especificación congelada no se reemplaza por el estado actual del catálogo.
Los vínculos de integración con buzones y accesos se conservan en columnas con claves foráneas. Eliminar una referencia externa no debe eliminar en cascada el recurso ni sus revisiones. Las proyecciones de compatibilidad de correo/accesos siguen existiendo hasta normalizar sus consumidores.
## Puestos, perfiles y configuraciones
`workstation_members` conserva la composición actual del puesto, con tipo `exclusive` o `shared` y posición. `workstation_revision_members` conserva la composición de cada revisión. `workstation_assets` sigue siendo la proyección técnica que protege referencias históricas y reserva activos exclusivos de puestos vigentes; no es otra asignación de negocio.
Los perfiles y configuraciones mantienen sus reglas de unicidad, transiciones y cierre. El cambio de persistencia no modifica rutas, formularios, DTO, contadores, permisos, identidad ni comportamiento funcional. Las referencias de las revisiones se conservan mediante claves foráneas.
## Migraciones
Las migraciones se ejecutan explícitamente al inicializar los módulos; no se utiliza `sync({ alter: true })`.
1. Se añaden columnas y tablas de destino sin borrar el origen.
2. Se valida el documento anterior mediante el esquema de dominio, incluidas las referencias ya persistidas.
3. La transferencia de valores se realiza en una transacción.
4. Se reconstruye el destino y se compara con el origen antes de retirar las columnas antiguas.
5. Se eliminan las columnas JSON transferidas y se refuerzan las restricciones.
El DDL de MariaDB no es transaccional. Una interrupción puede dejar columnas de preparación, pero el origen se conserva hasta verificar la transferencia. La migración de recursos admite también el corte en el que las revisiones ya se transfirieron y el recurso aún conserva sus columnas de origen. Repetir las migraciones no añade revisiones ni duplica datos.
Las pruebas de integración utilizan exclusivamente `metis_desk_test`. Comprueban origen inválido, conservación de UUID/historial/orden/valores, referencias incompatibles, idempotencia, arranque vacío y mantenimiento del comportamiento HTTP. Las fixtures SQL que antes escribían JSON se adaptan a las tablas nuevas.
## Continuación pendiente
Antes de dar por terminada la normalización hay que implementar y verificar las filas pendientes del inventario, adaptar sus lectores de dashboard/procesos y proyecciones de compatibilidad, y repetir una auditoría del esquema real y del código de persistencia. No basta con cambiar una columna JSON a LONGTEXT ni con renombrar los documentos serializados.
## Verificación de esta revisión
- Suite completa: 277 pruebas superadas; espaciado: 3 pruebas superadas.
- Typecheck de servidor, activos, compañías y recursos: correcto.
- Build: correcto; mantiene el aviso de tamaño de bundles del frontend.
- Biome y ESLint sobre los ficheros modificados en esta revisión: correctos.
- Arranque y migraciones sobre una base de pruebas vacía: correctos.
- Inicialización de desarrollo: comprobada después de guardar una copia SQL externa al repositorio en `/home/david/backups/metis-desk/`.
El typecheck global sigue encontrando el problema anterior de `locale` en `packages/shadcn-ui/src/components/calendar.tsx`. El lint/check global conserva errores de otros ficheros (principalmente código de fotos y formato pendiente). No se modifica código vendor ni cambios de interfaz ajenos a esta normalización.

View File

@ -92,3 +92,8 @@ La ficha captura su especificación y conserva revisiones como cualquier recurso
## Organización de campos
Las plantillas permiten organizar campos en secciones ordenadas, globales o propias de compañía. Consultar [Secciones de campos en plantillas](secciones-de-campos-en-plantillas.md) para las reglas, presentación y conservación de datos existentes.
## Persistencia relacional
El catálogo, las especificaciones congeladas y los valores actuales e históricos de recursos se almacenan en columnas y tablas relacionadas. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md).

View File

@ -41,7 +41,7 @@ export class AssetDashboardReader {
inactivePeople(companyId?: string) {
return this.database.query<{ id: string }>(
"SELECT DISTINCT p.id FROM workstations w JOIN company_contacts p ON p.id=w.personId AND p.companyId=w.companyId WHERE p.active=0 AND JSON_EXTRACT(w.`values`,'$.active')=true AND (:companyId IS NULL OR w.companyId=:companyId)",
"SELECT DISTINCT p.id FROM workstations w JOIN company_contacts p ON p.id=w.personId AND p.companyId=w.companyId WHERE p.active=0 AND w.active=true AND (:companyId IS NULL OR w.companyId=:companyId)",
{ type: QueryTypes.SELECT, replacements: { companyId: companyId ?? null } }
);
}

View File

@ -3,6 +3,7 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { inventoryColumns, specificationColumns } from "./asset-normalization-migration";
import { writeWorkstation } from "./workstation-relational";
const id = { type: DataTypes.UUID, primaryKey: true };
@ -139,33 +140,32 @@ export async function migrateLegacyAssetAssignments(database: Sequelize) {
};
const recordedAt = new Date().toISOString();
await database.query(
"INSERT INTO workstations (id,companyId,siteId,personId,`values`) VALUES (:id,:companyId,:siteId,:personId,:values)",
await writeWorkstation(
database,
{
replacements: {
workstation: {
id: workstationId,
companyId: first.companyId,
siteId: first.siteId,
personId: first.personId,
values: JSON.stringify(values),
values,
},
transaction,
}
);
await database.query(
"INSERT INTO workstation_revisions (id,workstationId,actorId,siteId,personId,`values`,recordedAt,position) VALUES (:id,:workstationId,:actorId,:siteId,:personId,:values,:recordedAt,0)",
{
replacements: {
id: revisionId,
workstationId,
actorId: first.actorId,
siteId: first.siteId,
personId: first.personId,
values: JSON.stringify(values),
recordedAt,
},
transaction,
}
revisions: [
{
id: revisionId,
workstationId,
actorId: first.actorId,
siteId: first.siteId,
personId: first.personId,
values,
recordedAt,
position: 0,
},
],
references: [],
},
new Set(),
transaction
);
for (const entry of entries) {
await database.query(

View File

@ -3,12 +3,9 @@ import type { Transaction } from "sequelize";
import type { Asset } from "../../domain/asset.aggregate";
import {
WorkstationAssetModel,
WorkstationModel,
WorkstationRevisionModel,
} from "./workstation.model";
import { WorkstationAssetModel } from "./workstation.model";
import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
import { readWorkstations } from "./workstation-relational";
export async function loadWorkstations(
companyId: UniqueID,
@ -17,16 +14,16 @@ export async function loadWorkstations(
sites: readonly { id: string }[],
people: readonly { id: string }[]
) {
const rows = await WorkstationModel.findAll({
where: { companyId: companyId.toPrimitive() },
transaction,
});
const revisions = rows.length
? await WorkstationRevisionModel.findAll({
where: { workstationId: rows.map((item) => item.id) },
transaction,
})
: [];
const database = WorkstationAssetModel.sequelize;
if (!database) {
throw new Error("Persistencia de puestos sin inicializar.");
}
const { rows, revisions } = await readWorkstations(
database,
companyId.toPrimitive(),
transaction
);
const references = await WorkstationAssetModel.findAll({
where: { companyId: companyId.toPrimitive() },
transaction,

View File

@ -16,12 +16,9 @@ import { RelationshipModel } from "./asset-relationship.model";
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
import { loadWorkstations } from "./load-workstations";
import { readBrands } from "./sequelize-brand.repository";
import {
WorkstationAssetModel,
WorkstationModel,
WorkstationRevisionModel,
} from "./workstation.model";
import { WorkstationAssetModel } from "./workstation.model";
import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
import { writeWorkstation } from "./workstation-relational";
export class SequelizeAssetRepository implements IAssetRepository {
private readonly mapper = new AssetPersistenceMapper();
@ -181,14 +178,9 @@ export class SequelizeAssetRepository implements IAssetRepository {
) {
const rows = new WorkstationPersistenceMapper().toPersistence(item);
await WorkstationModel.upsert(rows.workstation, { transaction });
const existing = new Set(previous?.history.map((item) => item.id.toPrimitive()));
for (const revision of rows.revisions) {
if (!existing.has(revision.id)) {
await WorkstationRevisionModel.create(revision, { transaction });
}
}
await writeWorkstation(this.database, rows, existing, transaction);
for (const reference of rows.references) {
await WorkstationAssetModel.upsert(reference, { transaction });
}

View File

@ -49,11 +49,11 @@ export class SequelizeSupportRepository implements ISupportRepository {
}
const assetStatus = AssetStatusSchema.parse(assets[0].status);
const roots = await this.database.query<Record<string, unknown>>(
"SELECT id,companyId,assetId,toolId,`values`,preferredKey FROM asset_support WHERE companyId=:companyId AND assetId=:assetId",
"SELECT id,companyId,assetId,toolId,destination,requiresVpn,preferred,state,notes,preferredKey FROM asset_support WHERE companyId=:companyId AND assetId=:assetId",
{ replacements, transaction, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<Record<string, unknown>>(
"SELECT r.id,r.connectionId,r.actorId,r.toolId,r.`values`,r.recordedAt,r.position FROM asset_support_revisions r JOIN asset_support c ON c.id=r.connectionId WHERE c.companyId=:companyId AND c.assetId=:assetId",
"SELECT r.id,r.connectionId,r.actorId,r.toolId,r.destination,r.requiresVpn,r.preferred,r.state,r.notes,r.recordedAt,r.position FROM asset_support_revisions r JOIN asset_support c ON c.id=r.connectionId WHERE c.companyId=:companyId AND c.assetId=:assetId",
{ replacements, transaction, type: QueryTypes.SELECT }
);
const users = await this.database.query<{ id: string; name: string }>(
@ -133,8 +133,8 @@ export class SequelizeSupportRepository implements ISupportRepository {
await this.database.query(
context.data.connections.some((item) => item.id.equals(result.data.id))
? "UPDATE asset_support SET toolId=:toolId,`values`=:values,preferredKey=:preferredKey WHERE id=:id"
: "INSERT INTO asset_support (id,companyId,assetId,toolId,`values`,preferredKey) VALUES (:id,:companyId,:assetId,:toolId,:values,:preferredKey)",
? "UPDATE asset_support SET toolId=:toolId,destination=:destination,requiresVpn=:requiresVpn,preferred=:preferred,state=:state,notes=:notes,preferredKey=:preferredKey WHERE id=:id"
: "INSERT INTO asset_support (id,companyId,assetId,toolId,destination,requiresVpn,preferred,state,notes,preferredKey) VALUES (:id,:companyId,:assetId,:toolId,:destination,:requiresVpn,:preferred,:state,:notes,:preferredKey)",
{ replacements: data.root, transaction }
);
const existing = new Set(
@ -146,7 +146,7 @@ export class SequelizeSupportRepository implements ISupportRepository {
for (const row of data.revisions) {
if (!existing.has(row.id)) {
await this.database.query(
"INSERT INTO asset_support_revisions (id,connectionId,actorId,toolId,`values`,recordedAt,position) VALUES (:id,:connectionId,:actorId,:toolId,:values,:recordedAt,:position)",
"INSERT INTO asset_support_revisions (id,connectionId,actorId,toolId,destination,requiresVpn,preferred,state,notes,recordedAt,position) VALUES (:id,:connectionId,:actorId,:toolId,:destination,:requiresVpn,:preferred,:state,:notes,:recordedAt,:position)",
{ replacements: row, transaction }
);
}

View File

@ -2,6 +2,8 @@ import { UniqueID } from "@repo/rdx-ddd";
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { normalizeSupport, supportColumns } from "./support-normalization-migration";
export async function migrateSupport(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -34,7 +36,7 @@ export async function migrateSupport(database: Sequelize) {
companyId: fk("companies"),
assetId: fk("assets"),
toolId: fk("support_tools"),
values: { type: DataTypes.JSON, allowNull: false },
...supportColumns,
preferredKey: { type: DataTypes.UUID, allowNull: true, unique: true },
},
],
@ -45,7 +47,7 @@ export async function migrateSupport(database: Sequelize) {
connectionId: fk("asset_support"),
actorId: fk("users"),
toolId: fk("support_tools"),
values: { type: DataTypes.JSON, allowNull: false },
...supportColumns,
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
},
@ -57,6 +59,7 @@ export async function migrateSupport(database: Sequelize) {
await query.createTable(table, columns);
}
}
await normalizeSupport(database);
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='asset_support'",
{ type: QueryTypes.SELECT }

View File

@ -0,0 +1,90 @@
import { isDeepStrictEqual } from "node:util";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { SupportValuesSchema } from "../../domain/support-values";
import { removeAssetColumn } from "./asset-relational-sql";
export const supportColumns = {
destination: { type: DataTypes.STRING(300), allowNull: true },
requiresVpn: { type: DataTypes.BOOLEAN, allowNull: true },
preferred: { type: DataTypes.BOOLEAN, allowNull: true },
state: { type: DataTypes.STRING(12), allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
};
export async function normalizeSupport(database: Sequelize) {
for (const table of ["asset_support", "asset_support_revisions"]) {
const columns = await database.getQueryInterface().describeTable(table);
for (const [name, definition] of Object.entries(supportColumns)) {
if (!(name in columns)) {
await database.getQueryInterface().addColumn(table, name, definition);
}
}
if (columns.values) {
await database.transaction(async (transaction) => {
const rows = await database.query<{ id: string; toolId: string; values: unknown }>(
`SELECT id,toolId,\`values\` FROM ${table}`,
{ type: QueryTypes.SELECT, transaction }
);
for (const row of rows) {
await transferSupportRow(database, table, row, transaction);
}
});
await removeAssetColumn(database, table, "values");
}
const finalColumns = await database.getQueryInterface().describeTable(table);
for (const [name, definition] of Object.entries(supportColumns)) {
if (!finalColumns[name]?.allowNull) {
continue;
}
await database
.getQueryInterface()
.changeColumn(table, name, { ...definition, allowNull: false });
}
}
}
async function transferSupportRow(
database: Sequelize,
table: string,
row: { id: string; toolId: string; values: unknown },
transaction: Transaction
) {
const values = SupportValuesSchema.parse(
typeof row.values === "string" ? JSON.parse(row.values) : row.values
);
if (values.toolId !== row.toolId) {
throw new Error("Referencia de asistencia inconsistente.");
}
await database.query(
`UPDATE ${table} SET destination=:destination,requiresVpn=:requiresVpn,preferred=:preferred,state=:state,notes=:notes WHERE id=:id`,
{ replacements: { id: row.id, ...values }, transaction }
);
const [actual] = await database.query<Record<string, unknown>>(
`SELECT toolId,destination,requiresVpn,preferred,state,notes FROM ${table} WHERE id=:id`,
{ replacements: { id: row.id }, transaction, type: QueryTypes.SELECT }
);
if (
!(
actual &&
isDeepStrictEqual(
values,
SupportValuesSchema.parse({
...actual,
requiresVpn: Boolean(actual.requiresVpn),
preferred: Boolean(actual.preferred),
})
)
)
) {
throw new Error("La asistencia normalizada no coincide con el origen.");
}
}

View File

@ -1,3 +1,25 @@
function connectionRow(row: Record<string, unknown>) {
const { destination, requiresVpn, preferred, state, notes, ...identity } = row;
return {
...identity,
values: {
toolId: row.toolId,
destination,
requiresVpn: z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(requiresVpn),
preferred: z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(preferred),
state,
notes,
},
};
}
import { Name, UniqueID } from "@repo/rdx-ddd";
import { z } from "zod/v4";
@ -35,12 +57,8 @@ export function supportIdentity(value: string) {
return id.data;
}
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
function values(value: unknown) {
const parsed = SupportValuesSchema.parse(json(value));
const parsed = SupportValuesSchema.parse(value);
const result = SupportValues.create(parsed);
if (!result.isSuccess) {
@ -52,9 +70,9 @@ function values(value: unknown) {
export class SupportPersistenceMapper {
toDomain(root: Record<string, unknown>, rows: readonly Record<string, unknown>[]) {
const current = RootSchema.parse({ ...root, values: json(root.values) });
const current = RootSchema.parse(connectionRow(root));
const history = rows
.map((row) => RevisionSchema.parse({ ...row, values: json(row.values) }))
.map((row) => RevisionSchema.parse(connectionRow(row)))
.sort((a, b) => a.position - b.position);
if (
@ -109,16 +127,14 @@ export class SupportPersistenceMapper {
id: connection.id.toPrimitive(),
companyId: connection.companyId.toPrimitive(),
assetId: connection.assetId.toPrimitive(),
toolId: input.toolId,
values: JSON.stringify(input),
...input,
preferredKey: input.preferred ? connection.assetId.toPrimitive() : null,
},
revisions: connection.history.map((item, position) => ({
id: item.id.toPrimitive(),
connectionId: connection.id.toPrimitive(),
actorId: item.actorId.toPrimitive(),
toolId: item.values.toolId.toPrimitive(),
values: JSON.stringify(item.values.toPrimitive()),
...item.values.toPrimitive(),
recordedAt: item.recordedAt,
position,
})),

View File

@ -6,11 +6,8 @@ import { WorkstationRevision } from "../../domain/workstation-revision.entity";
import { WorkstationInputSchema } from "../../domain/workstation-values";
import { inventoryIdentity } from "./asset-persistence.mapper";
import type {
WorkstationAssetModel,
WorkstationModel,
WorkstationRevisionModel,
} from "./workstation.model";
import type { WorkstationAssetModel } from "./workstation.model";
import type { WorkstationRevisionRow, WorkstationRow } from "./workstation-relational";
function valuesOf(raw: unknown, siteId: string | null, personId: string | null) {
// Las instantáneas anteriores no tenían recursos compartidos. Se interpretan como una lista vacía sin reescribir el historial.
@ -35,8 +32,8 @@ function valuesOf(raw: unknown, siteId: string | null, personId: string | null)
export class WorkstationPersistenceMapper {
toDomain(
row: WorkstationModel,
revisions: readonly WorkstationRevisionModel[],
row: WorkstationRow,
revisions: readonly WorkstationRevisionRow[],
references: readonly WorkstationAssetModel[]
) {
const values = valuesOf(row.values, row.siteId, row.personId);

View File

@ -0,0 +1,292 @@
import { isDeepStrictEqual } from "node:util";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { WorkstationInputSchema } from "../../domain/workstation-values";
import { readAssetRows, removeAssetColumn, writeAssetRow } from "./asset-relational-sql";
import type { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
type Values = ReturnType<typeof WorkstationInputSchema.parse>;
export type WorkstationRow = {
id: string;
companyId: string;
siteId: string | null;
personId: string | null;
values: Values;
};
export type WorkstationRevisionRow = {
id: string;
workstationId: string;
actorId: string;
recordedAt: string;
position: number;
siteId: string | null;
personId: string | null;
values: Values;
};
type StoredRow = Record<string, unknown> & {
id: string;
name: string;
active: boolean;
siteId: string | null;
personId: string | null;
};
type Member = { ownerId: string; assetId: string; kind: string; position: number };
const tables = [
{ root: "workstations", members: "workstation_members", owner: "workstationId" },
{ root: "workstation_revisions", members: "workstation_revision_members", owner: "revisionId" },
] as const;
function reconstruct(row: StoredRow, members: readonly Member[]): Values {
const ordered = members
.filter((member) => member.ownerId === row.id)
.sort((a, b) => a.position - b.position);
if (ordered.some((member) => !["exclusive", "shared"].includes(member.kind))) {
throw new Error("Tipo de asignación de activo inconsistente.");
}
return WorkstationInputSchema.parse({
name: row.name,
active: Boolean(row.active),
siteId: row.siteId,
personId: row.personId,
assetIds: ordered
.filter((member) => member.kind === "exclusive")
.map((member) => member.assetId),
sharedAssetIds: ordered
.filter((member) => member.kind === "shared")
.map((member) => member.assetId),
});
}
export async function readWorkstations(
database: Sequelize,
companyId: string,
transaction: Transaction
) {
const roots = await readAssetRows<StoredRow & Omit<WorkstationRow, "values">>(
database,
"workstations",
"companyId",
[companyId],
transaction
);
const revisions = await readAssetRows<StoredRow & Omit<WorkstationRevisionRow, "values">>(
database,
"workstation_revisions",
"workstationId",
roots.map((row) => row.id),
transaction
);
const rootMembers = await database.query<Member>(
"SELECT workstationId AS ownerId,assetId,kind,position FROM workstation_members WHERE workstationId IN (:ids)",
{
replacements: {
ids: roots.map((row) => row.id).concat(["00000000-0000-0000-0000-000000000000"]),
},
type: QueryTypes.SELECT,
transaction,
}
);
const revisionMembers = await database.query<Member>(
"SELECT revisionId AS ownerId,assetId,kind,position FROM workstation_revision_members WHERE revisionId IN (:ids)",
{
replacements: {
ids: revisions.map((row) => row.id).concat(["00000000-0000-0000-0000-000000000000"]),
},
type: QueryTypes.SELECT,
transaction,
}
);
return {
rows: roots.map((row) => ({
id: row.id,
companyId: row.companyId,
siteId: row.siteId,
personId: row.personId,
values: reconstruct(row, rootMembers),
})),
revisions: revisions.map((row) => ({
id: row.id,
workstationId: row.workstationId,
actorId: row.actorId,
recordedAt: row.recordedAt,
position: row.position,
siteId: row.siteId,
personId: row.personId,
values: reconstruct(row, revisionMembers),
})),
};
}
async function writeMembers(
database: Sequelize,
table: string,
owner: string,
id: string,
values: Values,
transaction: Transaction
) {
await database.query(`DELETE FROM ${table} WHERE ${owner}=:id`, {
replacements: { id },
transaction,
});
for (const [kind, ids] of [
["exclusive", values.assetIds],
["shared", values.sharedAssetIds],
] as const) {
for (const [position, assetId] of ids.entries()) {
await writeAssetRow(database, table, { [owner]: id, assetId, kind, position }, transaction);
}
}
}
async function writeSnapshot(
database: Sequelize,
definition: (typeof tables)[number],
row: Record<string, string | number | boolean | null>,
values: Values,
transaction: Transaction
) {
await writeAssetRow(
database,
definition.root,
{ ...row, name: values.name, active: values.active },
transaction
);
await writeMembers(
database,
definition.members,
definition.owner,
String(row.id),
values,
transaction
);
}
export async function writeWorkstation(
database: Sequelize,
data: ReturnType<WorkstationPersistenceMapper["toPersistence"]>,
existing: ReadonlySet<string>,
transaction: Transaction
) {
const { values, ...root } = data.workstation;
await writeSnapshot(database, tables[0], root, values, transaction);
for (const { values, ...revision } of data.revisions) {
if (!existing.has(revision.id)) {
await writeSnapshot(database, tables[1], revision, values, transaction);
}
}
}
export async function normalizeWorkstations(database: Sequelize) {
const query = database.getQueryInterface();
const existing = await query.showAllTables();
for (const definition of tables) {
const columns = await query.describeTable(definition.root);
for (const [name, column] of Object.entries({
name: { type: DataTypes.STRING(100), allowNull: true },
active: { type: DataTypes.BOOLEAN, allowNull: true },
})) {
if (!(name in columns)) {
await query.addColumn(definition.root, name, column);
}
}
if (!existing.includes(definition.members)) {
await query.createTable(definition.members, {
[definition.owner]: {
type: DataTypes.UUID,
primaryKey: true,
references: { model: definition.root, key: "id" },
onDelete: "CASCADE",
},
assetId: {
type: DataTypes.UUID,
primaryKey: true,
references: { model: "assets", key: "id" },
onDelete: "RESTRICT",
},
kind: { type: DataTypes.STRING(10), allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
});
}
if (columns.values) {
await transferWorkstations(database, definition);
await removeAssetColumn(database, definition.root, "values");
}
const finalColumns = await query.describeTable(definition.root);
if (finalColumns.name?.allowNull || finalColumns.active?.allowNull) {
await query.changeColumn(definition.root, "name", {
type: DataTypes.STRING(100),
allowNull: false,
});
await query.changeColumn(definition.root, "active", {
type: DataTypes.BOOLEAN,
allowNull: false,
});
}
}
}
async function transferWorkstations(database: Sequelize, definition: (typeof tables)[number]) {
await database.query(`ALTER TABLE ${definition.root} MODIFY COLUMN \`values\` LONGTEXT NULL`);
await database.transaction(async (transaction) => {
const rows = await database.query<StoredRow>(`SELECT * FROM ${definition.root}`, {
type: QueryTypes.SELECT,
transaction,
});
for (const row of rows) {
const raw: unknown = typeof row.values === "string" ? JSON.parse(row.values) : row.values;
const legacy = WorkstationInputSchema.extend({
sharedAssetIds: z.array(z.uuid()).max(200).optional(),
}).parse(raw);
const values = WorkstationInputSchema.parse({
...legacy,
sharedAssetIds: legacy.sharedAssetIds ?? [],
});
if (values.siteId !== row.siteId || values.personId !== row.personId) {
throw new Error("Referencias de puesto inconsistentes.");
}
await database.query(`UPDATE ${definition.root} SET name=:name,active=:active WHERE id=:id`, {
replacements: { id: row.id, name: values.name, active: values.active },
transaction,
});
await writeMembers(
database,
definition.members,
definition.owner,
row.id,
values,
transaction
);
const members = await database.query<Member>(
`SELECT ${definition.owner} AS ownerId,assetId,kind,position FROM ${definition.members} WHERE ${definition.owner}=:id`,
{ replacements: { id: row.id }, transaction, type: QueryTypes.SELECT }
);
const [actual] = await database.query<StoredRow>(
`SELECT * FROM ${definition.root} WHERE id=:id`,
{ replacements: { id: row.id }, transaction, type: QueryTypes.SELECT }
);
if (!(actual && isDeepStrictEqual(values, reconstruct(actual, members)))) {
throw new Error("La normalización del puesto no coincide con el origen.");
}
}
});
}

View File

@ -1,30 +1,7 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
export class WorkstationModel extends Model<
InferAttributes<WorkstationModel>,
InferCreationAttributes<WorkstationModel>
> {
declare id: string;
declare companyId: string;
declare siteId: string | null;
declare personId: string | null;
declare values: unknown;
}
export class WorkstationRevisionModel extends Model<
InferAttributes<WorkstationRevisionModel>,
InferCreationAttributes<WorkstationRevisionModel>
> {
declare id: string;
declare workstationId: string;
declare actorId: string;
declare recordedAt: string;
declare values: unknown;
declare position: number;
declare siteId: string | null;
declare personId: string | null;
}
import { normalizeWorkstations } from "./workstation-relational";
/** Proyección técnica de referencias históricas y reserva vigente; no representa otra entidad de negocio. */
export class WorkstationAssetModel extends Model<
@ -56,7 +33,8 @@ export async function migrateWorkstations(database: Sequelize) {
companyId: fk("companies"),
siteId: fk("company_sites", true),
personId: fk("company_contacts", true),
values: { type: DataTypes.JSON, allowNull: false },
name: { type: DataTypes.STRING(100), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
};
const revision = {
id: { type: DataTypes.UUID, primaryKey: true },
@ -64,7 +42,8 @@ export async function migrateWorkstations(database: Sequelize) {
actorId: fk("users"),
siteId: fk("company_sites", true),
personId: fk("company_contacts", true),
values: { type: DataTypes.JSON, allowNull: false },
name: { type: DataTypes.STRING(100), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
};
@ -110,19 +89,10 @@ export async function migrateWorkstations(database: Sequelize) {
"ALTER TABLE workstation_assets ADD CONSTRAINT workstation_owner_company FOREIGN KEY (companyId, workstationId) REFERENCES workstations(companyId,id)"
);
}
WorkstationModel.init(root, {
sequelize: database,
tableName: "workstations",
timestamps: false,
});
WorkstationRevisionModel.init(revision, {
sequelize: database,
tableName: "workstation_revisions",
timestamps: false,
});
WorkstationAssetModel.init(reference, {
sequelize: database,
tableName: "workstation_assets",
timestamps: false,
});
await normalizeWorkstations(database);
}

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { applicationSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateApplicationConfigurations(database: Sequelize) {
const q = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -19,9 +22,7 @@ export async function migrateApplicationConfigurations(database: Sequelize) {
await q.createTable("application_configurations", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
profileId: fk("work_profiles"),
resourceId: fk("resources"),
values: { type: DataTypes.JSON, allowNull: false },
...applicationSnapshot.columns,
currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true },
});
}
@ -30,7 +31,7 @@ export async function migrateApplicationConfigurations(database: Sequelize) {
id: { type: DataTypes.UUID, primaryKey: true },
configurationId: fk("application_configurations"),
position: { type: DataTypes.INTEGER, allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...applicationSnapshot.columns,
actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
});
@ -46,4 +47,9 @@ export async function migrateApplicationConfigurations(database: Sequelize) {
name: "application_configuration_position",
});
}
await normalizeScalarSnapshots(
database,
["application_configurations", "application_configuration_revisions"],
applicationSnapshot
);
}

View File

@ -8,6 +8,8 @@ import {
} from "../../domain/application-configuration-values";
import { ApplicationConfigurationValues } from "../../domain/value-objects/application-configuration-values.vo";
import { applicationSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
export type ApplicationRow = {
@ -15,7 +17,7 @@ export type ApplicationRow = {
companyId: string;
profileId: string;
resourceId: string;
values: unknown;
values?: unknown;
currentKey: string | null;
};
@ -23,7 +25,7 @@ export type ApplicationRevisionRow = {
id: string;
configurationId: string;
position: number;
values: unknown;
values?: unknown;
actorId: string;
recordedAt: string;
};
@ -42,10 +44,10 @@ function mapped(raw: unknown) {
export class ApplicationConfigurationPersistenceMapper {
toDomain(row: ApplicationRow, revisions: readonly ApplicationRevisionRow[]) {
const values = mapped(row.values);
const values = mapped(snapshotValues(applicationSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, index) => {
const value = mapped(revision.values);
const value = mapped(snapshotValues(applicationSnapshot, revision));
const previous = ordered[index - 1];
if (
@ -108,9 +110,7 @@ export class ApplicationConfigurationPersistenceMapper {
root: {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
profileId: values.profileId,
resourceId: values.resourceId,
values: JSON.stringify(values),
...values,
...applicationKeys(item.values),
},
revisions: item.history.map((revision, position) => ({
@ -119,7 +119,7 @@ export class ApplicationConfigurationPersistenceMapper {
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()),
...revision.values.toPrimitive(),
})),
};
}

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { certificateSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateCertificateConfigurations(database: Sequelize) {
const q = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -19,9 +22,7 @@ export async function migrateCertificateConfigurations(database: Sequelize) {
await q.createTable("certificate_configurations", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
profileId: fk("work_profiles"),
resourceId: fk("resources"),
values: { type: DataTypes.JSON, allowNull: false },
...certificateSnapshot.columns,
currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true },
});
}
@ -30,7 +31,7 @@ export async function migrateCertificateConfigurations(database: Sequelize) {
id: { type: DataTypes.UUID, primaryKey: true },
configurationId: fk("certificate_configurations"),
position: { type: DataTypes.INTEGER, allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...certificateSnapshot.columns,
actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
});
@ -46,4 +47,9 @@ export async function migrateCertificateConfigurations(database: Sequelize) {
name: "certificate_configuration_position",
});
}
await normalizeScalarSnapshots(
database,
["certificate_configurations", "certificate_configuration_revisions"],
certificateSnapshot
);
}

View File

@ -8,6 +8,8 @@ import {
} from "../../domain/certificate-configuration-values";
import { CertificateConfigurationValues } from "../../domain/value-objects/certificate-configuration-values.vo";
import { certificateSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
export type CertificateRow = {
@ -15,7 +17,7 @@ export type CertificateRow = {
companyId: string;
profileId: string;
resourceId: string;
values: unknown;
values?: unknown;
currentKey: string | null;
};
@ -23,7 +25,7 @@ export type CertificateRevisionRow = {
id: string;
configurationId: string;
position: number;
values: unknown;
values?: unknown;
actorId: string;
recordedAt: string;
};
@ -42,10 +44,10 @@ function mapped(raw: unknown) {
export class CertificateConfigurationPersistenceMapper {
toDomain(row: CertificateRow, revisions: readonly CertificateRevisionRow[]) {
const values = mapped(row.values);
const values = mapped(snapshotValues(certificateSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, index) => {
const value = mapped(revision.values);
const value = mapped(snapshotValues(certificateSnapshot, revision));
const previous = ordered[index - 1];
if (
@ -108,9 +110,7 @@ export class CertificateConfigurationPersistenceMapper {
root: {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
profileId: values.profileId,
resourceId: values.resourceId,
values: JSON.stringify(values),
...values,
...certificateKeys(item.values),
},
revisions: item.history.map((revision, position) => ({
@ -119,7 +119,7 @@ export class CertificateConfigurationPersistenceMapper {
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()),
...revision.values.toPrimitive(),
})),
};
}

View File

@ -0,0 +1,161 @@
import { DataTypes } from "sequelize";
import { ApplicationConfigurationInputSchema } from "../../domain/application-configuration-values";
import { CertificateConfigurationInputSchema } from "../../domain/certificate-configuration-values";
import { FolderMappingInputSchema } from "../../domain/folder-mapping-values";
import { PrinterConfigurationInputSchema } from "../../domain/printer-configuration-values";
import { TeleworkAuthorizationInputSchema } from "../../domain/telework-authorization-values";
import { WorkProfileInputSchema } from "../../domain/work-profile-values";
import type { SnapshotDefinition } from "./scalar-snapshot-normalization";
export const profileSnapshot = {
schema: WorkProfileInputSchema,
columns: {
name: { type: DataTypes.STRING(100), allowNull: false },
personId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "company_contacts", key: "id" },
onDelete: "RESTRICT",
},
assetId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "assets", key: "id" },
onDelete: "RESTRICT",
},
accountId: {
type: DataTypes.UUID,
allowNull: true,
references: { model: "access_items", key: "id" },
onDelete: "RESTRICT",
},
active: { type: DataTypes.BOOLEAN, allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: true },
},
booleans: ["active"],
} satisfies SnapshotDefinition<ReturnType<typeof WorkProfileInputSchema.parse>>;
export const teleworkSnapshot = {
schema: TeleworkAuthorizationInputSchema,
columns: {
personId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "company_contacts", key: "id" },
onDelete: "RESTRICT",
},
active: { type: DataTypes.BOOLEAN, allowNull: false },
startsOn: { type: DataTypes.STRING(10), allowNull: true },
expiresOn: { type: DataTypes.STRING(10), allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
},
booleans: ["active"],
} satisfies SnapshotDefinition<ReturnType<typeof TeleworkAuthorizationInputSchema.parse>>;
export const applicationSnapshot = {
schema: ApplicationConfigurationInputSchema,
columns: {
profileId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "work_profiles", key: "id" },
onDelete: "RESTRICT",
},
resourceId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "resources", key: "id" },
onDelete: "RESTRICT",
},
delivery: { type: DataTypes.STRING(16), allowNull: false },
version: { type: DataTypes.STRING(100), allowNull: true },
state: { type: DataTypes.STRING(20), allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: true },
},
booleans: [],
} satisfies SnapshotDefinition<ReturnType<typeof ApplicationConfigurationInputSchema.parse>>;
export const certificateSnapshot = {
schema: CertificateConfigurationInputSchema,
columns: {
profileId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "work_profiles", key: "id" },
onDelete: "RESTRICT",
},
resourceId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "resources", key: "id" },
onDelete: "RESTRICT",
},
location: { type: DataTypes.STRING(255), allowNull: true },
state: { type: DataTypes.STRING(20), allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: true },
},
booleans: [],
} satisfies SnapshotDefinition<ReturnType<typeof CertificateConfigurationInputSchema.parse>>;
export const printerSnapshot = {
schema: PrinterConfigurationInputSchema,
columns: {
profileId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "work_profiles", key: "id" },
onDelete: "RESTRICT",
},
printerId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "assets", key: "id" },
onDelete: "RESTRICT",
},
connection: { type: DataTypes.STRING(16), allowNull: false },
isDefault: { type: DataTypes.BOOLEAN, allowNull: false },
state: { type: DataTypes.STRING(20), allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: true },
},
booleans: ["isDefault"],
} satisfies SnapshotDefinition<ReturnType<typeof PrinterConfigurationInputSchema.parse>>;
export const folderSnapshot = {
schema: FolderMappingInputSchema,
columns: {
profileId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "work_profiles", key: "id" },
onDelete: "RESTRICT",
},
needId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "access_items", key: "id" },
onDelete: "RESTRICT",
},
path: { type: DataTypes.STRING(255), allowNull: false },
driveLetter: { type: DataTypes.STRING(2), allowNull: true },
state: { type: DataTypes.STRING(20), allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: true },
},
booleans: [],
} satisfies SnapshotDefinition<ReturnType<typeof FolderMappingInputSchema.parse>>;
export const configurationSnapshots = new Map<string, SnapshotDefinition<unknown>>([
["work_profiles", profileSnapshot],
["work_profile_revisions", profileSnapshot],
["telework_authorizations", teleworkSnapshot],
["telework_authorization_revisions", teleworkSnapshot],
["application_configurations", applicationSnapshot],
["application_configuration_revisions", applicationSnapshot],
["certificate_configurations", certificateSnapshot],
["certificate_configuration_revisions", certificateSnapshot],
["printer_configurations", printerSnapshot],
["printer_configuration_revisions", printerSnapshot],
["folder_mappings", folderSnapshot],
["folder_mapping_revisions", folderSnapshot],
]);

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { folderSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateFolderMappings(database: Sequelize) {
const q = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -19,9 +22,7 @@ export async function migrateFolderMappings(database: Sequelize) {
await q.createTable("folder_mappings", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
profileId: fk("work_profiles"),
needId: fk("access_items"),
values: { type: DataTypes.JSON, allowNull: false },
...folderSnapshot.columns,
currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true },
driveKey: { type: DataTypes.STRING(40), allowNull: true, unique: true },
});
@ -31,7 +32,7 @@ export async function migrateFolderMappings(database: Sequelize) {
id: { type: DataTypes.UUID, primaryKey: true },
mappingId: fk("folder_mappings"),
position: { type: DataTypes.INTEGER, allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...folderSnapshot.columns,
actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
});
@ -47,4 +48,9 @@ export async function migrateFolderMappings(database: Sequelize) {
name: "folder_mapping_position",
});
}
await normalizeScalarSnapshots(
database,
["folder_mappings", "folder_mapping_revisions"],
folderSnapshot
);
}

View File

@ -8,6 +8,8 @@ import {
} from "../../domain/folder-mapping-values";
import { FolderMappingValues } from "../../domain/value-objects/folder-mapping-values.vo";
import { folderSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
export type FolderRow = {
@ -15,7 +17,7 @@ export type FolderRow = {
companyId: string;
profileId: string;
needId: string;
values: unknown;
values?: unknown;
currentKey: string | null;
driveKey: string | null;
};
@ -24,7 +26,7 @@ export type FolderRevisionRow = {
id: string;
mappingId: string;
position: number;
values: unknown;
values?: unknown;
actorId: string;
recordedAt: string;
};
@ -41,10 +43,10 @@ function mapped(raw: unknown) {
export class FolderMappingPersistenceMapper {
toDomain(row: FolderRow, revisions: readonly FolderRevisionRow[]) {
const values = mapped(row.values);
const values = mapped(snapshotValues(folderSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, index) => {
const value = mapped(revision.values);
const value = mapped(snapshotValues(folderSnapshot, revision));
const previous = ordered[index - 1];
if (
@ -104,9 +106,7 @@ export class FolderMappingPersistenceMapper {
root: {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
profileId: values.profileId,
needId: values.needId,
values: JSON.stringify(values),
...values,
...folderKeys(item.values),
},
revisions: item.history.map((revision, position) => ({
@ -115,7 +115,7 @@ export class FolderMappingPersistenceMapper {
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()),
...revision.values.toPrimitive(),
})),
};
}

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { printerSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migratePrinterConfigurations(database: Sequelize) {
const q = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -19,9 +22,7 @@ export async function migratePrinterConfigurations(database: Sequelize) {
await q.createTable("printer_configurations", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
profileId: fk("work_profiles"),
printerId: fk("assets"),
values: { type: DataTypes.JSON, allowNull: false },
...printerSnapshot.columns,
currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true },
defaultKey: { type: DataTypes.STRING(40), allowNull: true, unique: true },
});
@ -31,7 +32,7 @@ export async function migratePrinterConfigurations(database: Sequelize) {
id: { type: DataTypes.UUID, primaryKey: true },
configurationId: fk("printer_configurations"),
position: { type: DataTypes.INTEGER, allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...printerSnapshot.columns,
actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
});
@ -47,4 +48,9 @@ export async function migratePrinterConfigurations(database: Sequelize) {
name: "printer_configuration_position",
});
}
await normalizeScalarSnapshots(
database,
["printer_configurations", "printer_configuration_revisions"],
printerSnapshot
);
}

View File

@ -8,6 +8,8 @@ import {
} from "../../domain/printer-configuration-values";
import { PrinterConfigurationValues } from "../../domain/value-objects/printer-configuration-values.vo";
import { printerSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
export type PrinterRow = {
@ -15,7 +17,7 @@ export type PrinterRow = {
companyId: string;
profileId: string;
printerId: string;
values: unknown;
values?: unknown;
currentKey: string | null;
defaultKey: string | null;
};
@ -24,7 +26,7 @@ export type PrinterRevisionRow = {
id: string;
configurationId: string;
position: number;
values: unknown;
values?: unknown;
actorId: string;
recordedAt: string;
};
@ -43,10 +45,10 @@ function mapped(raw: unknown) {
export class PrinterConfigurationPersistenceMapper {
toDomain(row: PrinterRow, revisions: readonly PrinterRevisionRow[]) {
const values = mapped(row.values);
const values = mapped(snapshotValues(printerSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, index) => {
const value = mapped(revision.values);
const value = mapped(snapshotValues(printerSnapshot, revision));
const previous = ordered[index - 1];
if (
@ -110,9 +112,7 @@ export class PrinterConfigurationPersistenceMapper {
root: {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
profileId: values.profileId,
printerId: values.printerId,
values: JSON.stringify(values),
...values,
...printerKeys(item.values),
},
revisions: item.history.map((revision, position) => ({
@ -121,7 +121,7 @@ export class PrinterConfigurationPersistenceMapper {
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()),
...revision.values.toPrimitive(),
})),
};
}

View File

@ -18,6 +18,8 @@ import { TeleworkAuthorizationInputSchema } from "../../domain/telework-authoriz
import type { WorkProfileInput } from "../../domain/work-profile-values";
import { WorkProfileInputSchema } from "../../domain/work-profile-values";
import { configurationSnapshots } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
function configurationDetail(
@ -70,11 +72,17 @@ function profileOf(profiles: readonly Profile[], id: string) {
export class ProcessReviewReader {
constructor(private readonly database: Sequelize) {}
private rows(table: Table, companyId: string, transaction: Transaction) {
return this.database.query<Row>(
`SELECT id,\`values\` FROM ${table} WHERE companyId=:companyId`,
private async rows(table: Table, companyId: string, transaction: Transaction) {
const rows = await this.database.query<Row>(
`SELECT * FROM ${table} WHERE companyId=:companyId`,
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const definition = configurationSnapshots.get(table);
return rows.map((row) => ({
...row,
values: definition ? snapshotValues(definition, row) : row.values,
}));
}
private mail(data: readonly Row[], profiles: readonly Profile[]) {

View File

@ -0,0 +1,131 @@
import { isDeepStrictEqual } from "node:util";
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
type Columns = Record<string, ModelAttributeColumnOptions>;
export type SnapshotDefinition<T> = {
schema: z.ZodType<T>;
columns: Columns;
booleans?: readonly string[];
};
function identifier(value: string) {
return `\`${value.replaceAll("`", "``")}\``;
}
export function snapshotValues<T>(definition: SnapshotDefinition<T>, row: object): T {
const source = z.record(z.string(), z.unknown()).parse(row);
// Compatible con instantáneas de pruebas y la transferencia del formato anterior.
if (source.values !== undefined) {
return definition.schema.parse(
typeof source.values === "string" ? JSON.parse(source.values) : source.values
);
}
const input = Object.fromEntries(
Object.keys(definition.columns).map((name) => {
const value = source[name];
return [
name,
definition.booleans?.includes(name)
? z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(value)
: value,
];
})
);
return definition.schema.parse(input);
}
export async function normalizeScalarSnapshots<T>(
database: Sequelize,
tables: readonly string[],
definition: SnapshotDefinition<T>
) {
const query = database.getQueryInterface();
for (const table of tables) {
const original = await query.describeTable(table);
for (const [name, column] of Object.entries(definition.columns)) {
if (!(name in original)) {
await query.addColumn(table, name, { ...column, allowNull: true });
}
}
if (original.values) {
await database.transaction(async (transaction) => {
const rows = await database.query<Record<string, unknown>>(
`SELECT * FROM ${identifier(table)}`,
{ type: QueryTypes.SELECT, transaction }
);
for (const row of rows) {
await transferSnapshotRow(
database,
table,
definition,
row,
new Set(Object.keys(original)),
transaction
);
}
});
await database.query(`ALTER TABLE ${identifier(table)} DROP COLUMN \`values\``);
}
const final = await query.describeTable(table);
for (const [name, column] of Object.entries(definition.columns)) {
if (column.allowNull === false && final[name]?.allowNull) {
await query.changeColumn(table, name, column);
}
}
}
}
async function transferSnapshotRow<T>(
database: Sequelize,
table: string,
definition: SnapshotDefinition<T>,
row: Record<string, unknown>,
original: ReadonlySet<string>,
transaction: Transaction
) {
const expected = snapshotValues(definition, row);
const values = z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.parse(expected);
for (const [name, value] of Object.entries(values)) {
if (name.endsWith("Id") && original.has(name) && !isDeepStrictEqual(row[name], value)) {
throw new Error("Referencias de la instantánea inconsistentes.");
}
}
const assignments = Object.keys(values)
.map((name) => `${identifier(name)}=:${name}`)
.join(",");
await database.query(`UPDATE ${identifier(table)} SET ${assignments} WHERE id=:id`, {
replacements: { ...values, id: row.id },
transaction,
});
const [actual] = await database.query<Record<string, unknown>>(
`SELECT * FROM ${identifier(table)} WHERE id=:id`,
{ replacements: { id: row.id }, transaction, type: QueryTypes.SELECT }
);
if (!actual) {
throw new Error("Instantánea desaparecida durante la normalización.");
}
const { values: _legacy, ...normalized } = actual;
if (!isDeepStrictEqual(expected, snapshotValues(definition, normalized))) {
throw new Error("La instantánea normalizada no coincide con el origen.");
}
}

View File

@ -162,14 +162,14 @@ export class SequelizeApplicationConfigurationRepository
const record = this.mapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO application_configurations (id,companyId,profileId,resourceId,`values`,currentKey) VALUES (:id,:companyId,:profileId,:resourceId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)",
"INSERT INTO application_configurations (id,companyId,profileId,resourceId,delivery,version,state,notes,currentKey) VALUES (:id,:companyId,:profileId,:resourceId,:delivery,:version,:state,:notes,:currentKey) ON DUPLICATE KEY UPDATE delivery=VALUES(delivery),version=VALUES(version),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey)",
{ replacements: record.root, transaction }
);
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
await this.database.query(
"INSERT INTO application_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)",
"INSERT INTO application_configuration_revisions (id,configurationId,position,actorId,recordedAt,profileId,resourceId,delivery,version,state,notes) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:profileId,:resourceId,:delivery,:version,:state,:notes)",
{ replacements: revision, transaction }
);
}

View File

@ -163,14 +163,14 @@ export class SequelizeCertificateConfigurationRepository
const record = this.mapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO certificate_configurations (id,companyId,profileId,resourceId,`values`,currentKey) VALUES (:id,:companyId,:profileId,:resourceId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)",
"INSERT INTO certificate_configurations (id,companyId,profileId,resourceId,location,state,notes,currentKey) VALUES (:id,:companyId,:profileId,:resourceId,:location,:state,:notes,:currentKey) ON DUPLICATE KEY UPDATE location=VALUES(location),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey)",
{ replacements: record.root, transaction }
);
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
await this.database.query(
"INSERT INTO certificate_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)",
"INSERT INTO certificate_configuration_revisions (id,configurationId,position,actorId,recordedAt,profileId,resourceId,location,state,notes) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:profileId,:resourceId,:location,:state,:notes)",
{ replacements: revision, transaction }
);
}

View File

@ -14,7 +14,7 @@ const OPEN_TICKET =
"JSON_UNQUOTE(JSON_EXTRACT(t.`values`,'$.state')) IN ('open','in_progress','waiting')";
const PENDING_INTERVENTION =
"JSON_UNQUOTE(JSON_EXTRACT(i.`values`,'$.state')) IN ('pending','in_progress')";
const PROFILE_REVIEW = `JSON_EXTRACT(p.\`values\`,'$.active')=true AND (person.active=0 OR a.status='retired' OR (p.accountId IS NOT NULL AND (
const PROFILE_REVIEW = `p.active=true AND (person.active=0 OR a.status='retired' OR (p.accountId IS NOT NULL AND (
account.id IS NULL OR JSON_EXTRACT(account.\`values\`,'$.active')<>true OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.usage'))='technical'
OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.expiresOn')) < :today
OR NOT EXISTS (SELECT 1 FROM access_items s WHERE s.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.systemId')) AND s.kind='system' AND JSON_EXTRACT(s.\`values\`,'$.active')=true)
@ -111,7 +111,7 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
query
);
const items = await this.select<InventoryEntry>(
`SELECT p.id,p.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(p.\`values\`,'$.name')) AS name,NULL AS recordedAt,NULL AS actorName ${from} ${where} ORDER BY name,p.id LIMIT 10 OFFSET :offset`,
`SELECT p.id,p.companyId,c.name AS companyName,p.name AS name,NULL AS recordedAt,NULL AS actorName ${from} ${where} ORDER BY name,p.id LIMIT 10 OFFSET :offset`,
query
);
@ -137,14 +137,14 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
]
.map(
(table) =>
`EXISTS (SELECT 1 FROM ${table} cfg JOIN work_profiles w ON w.id=cfg.profileId WHERE cfg.companyId=p.companyId AND w.personId=p.id AND JSON_UNQUOTE(JSON_EXTRACT(cfg.\`values\`,'$.state')) NOT IN ('removed','never_configured'))`
`EXISTS (SELECT 1 FROM ${table} cfg JOIN work_profiles w ON w.id=cfg.profileId WHERE cfg.companyId=p.companyId AND w.personId=p.id AND cfg.state NOT IN ('removed','never_configured'))`
)
.join(" OR ");
const where = `WHERE p.active=0 AND (:companyId IS NULL OR p.companyId=:companyId) AND (p.id IN (:ids) OR EXISTS (SELECT 1 FROM work_profiles w WHERE w.personId=p.id AND JSON_EXTRACT(w.\`values\`,'$.active')=true) OR EXISTS (SELECT 1 FROM access_items link JOIN access_workspaces ws ON ws.id=link.workspaceId WHERE ws.companyId=p.companyId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.personId'))=p.id AND ((link.kind='need' AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate'))>:today)) OR (link.kind='account_person' AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.configuration')) IN ('pending','configured','removal_pending'))))
const where = `WHERE p.active=0 AND (:companyId IS NULL OR p.companyId=:companyId) AND (p.id IN (:ids) OR EXISTS (SELECT 1 FROM work_profiles w WHERE w.personId=p.id AND w.active=true) OR EXISTS (SELECT 1 FROM access_items link JOIN access_workspaces ws ON ws.id=link.workspaceId WHERE ws.companyId=p.companyId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.personId'))=p.id AND ((link.kind='need' AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate'))>:today)) OR (link.kind='account_person' AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.configuration')) IN ('pending','configured','removal_pending'))))
OR EXISTS (SELECT 1 FROM remote_connections r WHERE r.companyId=p.companyId AND r.personId=p.id AND JSON_UNQUOTE(JSON_EXTRACT(r.\`values\`,'$.state')) NOT IN ('removed','never_configured'))
OR EXISTS (SELECT 1 FROM mail_configurations m LEFT JOIN work_profiles w ON w.id=m.profileId WHERE m.companyId=p.companyId AND (m.personId=p.id OR w.personId=p.id) AND JSON_UNQUOTE(JSON_EXTRACT(m.\`values\`,'$.state')) NOT IN ('removed','never_configured'))
OR ${profileConfigurations}
OR EXISTS (SELECT 1 FROM telework_authorizations ta WHERE ta.companyId=p.companyId AND ta.personId=p.id AND JSON_EXTRACT(ta.\`values\`,'$.active')=true AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(ta.\`values\`,'$.expiresOn')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(ta.\`values\`,'$.expiresOn'))>=:today)))`;
OR EXISTS (SELECT 1 FROM telework_authorizations ta WHERE ta.companyId=p.companyId AND ta.personId=p.id AND ta.active=true AND (ta.expiresOn IS NULL OR ta.expiresOn>=:today)))`;
const extra = { ids: ids.length ? ids : [""] };
const count = await this.select<{ total: number }>(
`SELECT COUNT(*) AS total FROM company_contacts p ${where}`,

View File

@ -174,14 +174,14 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
const record = this.mapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO folder_mappings (id,companyId,profileId,needId,`values`,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:values,:currentKey,:driveKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey),driveKey=VALUES(driveKey)",
"INSERT INTO folder_mappings (id,companyId,profileId,needId,path,driveLetter,state,notes,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:path,:driveLetter,:state,:notes,:currentKey,:driveKey) ON DUPLICATE KEY UPDATE path=VALUES(path),driveLetter=VALUES(driveLetter),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey),driveKey=VALUES(driveKey)",
{ replacements: record.root, transaction }
);
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
await this.database.query(
"INSERT INTO folder_mapping_revisions (id,mappingId,position,actorId,recordedAt,`values`) VALUES (:id,:mappingId,:position,:actorId,:recordedAt,:values)",
"INSERT INTO folder_mapping_revisions (id,mappingId,position,actorId,recordedAt,profileId,needId,path,driveLetter,state,notes) VALUES (:id,:mappingId,:position,:actorId,:recordedAt,:profileId,:needId,:path,:driveLetter,:state,:notes)",
{ replacements: revision, transaction }
);
}

View File

@ -152,14 +152,14 @@ export class SequelizePrinterConfigurationRepository implements IPrinterConfigur
const record = this.mapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO printer_configurations (id,companyId,profileId,printerId,`values`,currentKey,defaultKey) VALUES (:id,:companyId,:profileId,:printerId,:values,:currentKey,:defaultKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey),defaultKey=VALUES(defaultKey)",
"INSERT INTO printer_configurations (id,companyId,profileId,printerId,connection,isDefault,state,notes,currentKey,defaultKey) VALUES (:id,:companyId,:profileId,:printerId,:connection,:isDefault,:state,:notes,:currentKey,:defaultKey) ON DUPLICATE KEY UPDATE connection=VALUES(connection),isDefault=VALUES(isDefault),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey),defaultKey=VALUES(defaultKey)",
{ replacements: record.root, transaction }
);
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
await this.database.query(
"INSERT INTO printer_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)",
"INSERT INTO printer_configuration_revisions (id,configurationId,position,actorId,recordedAt,profileId,printerId,connection,isDefault,state,notes) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:profileId,:printerId,:connection,:isDefault,:state,:notes)",
{ replacements: revision, transaction }
);
}

View File

@ -101,7 +101,7 @@ export class SequelizeTeleworkAuthorizationRepository implements ITeleworkAuthor
const record = this.mapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO telework_authorizations (id,companyId,personId,`values`) VALUES (:id,:companyId,:personId,:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)",
"INSERT INTO telework_authorizations (id,companyId,personId,active,startsOn,expiresOn,notes) VALUES (:id,:companyId,:personId,:active,:startsOn,:expiresOn,:notes) ON DUPLICATE KEY UPDATE active=VALUES(active),startsOn=VALUES(startsOn),expiresOn=VALUES(expiresOn),notes=VALUES(notes)",
{ replacements: record.root, transaction }
);
const stored = new Set(
@ -110,7 +110,7 @@ export class SequelizeTeleworkAuthorizationRepository implements ITeleworkAuthor
for (const revision of record.revisions.filter((item) => !stored.has(item.id))) {
await this.database.query(
"INSERT INTO telework_authorization_revisions (id,authorizationId,position,actorId,recordedAt,`values`) VALUES (:id,:authorizationId,:position,:actorId,:recordedAt,:values)",
"INSERT INTO telework_authorization_revisions (id,authorizationId,position,actorId,recordedAt,personId,active,startsOn,expiresOn,notes) VALUES (:id,:authorizationId,:position,:actorId,:recordedAt,:personId,:active,:startsOn,:expiresOn,:notes)",
{ replacements: revision, transaction }
);
}

View File

@ -127,7 +127,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
const record = this.mapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO work_profiles (id,companyId,personId,accountId,assetId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),accountId=VALUES(accountId),currentKey=VALUES(currentKey)",
"INSERT INTO work_profiles (id,companyId,personId,accountId,assetId,name,active,notes,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,:name,:active,:notes,:currentKey) ON DUPLICATE KEY UPDATE name=VALUES(name),active=VALUES(active),notes=VALUES(notes),accountId=VALUES(accountId),currentKey=VALUES(currentKey)",
{ replacements: record.root, transaction }
);
const stored = new Set(
@ -136,7 +136,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
for (const revision of record.revisions.filter((item) => !stored.has(item.id))) {
await this.database.query(
"INSERT INTO work_profile_revisions (id,profileId,accountId,position,actorId,recordedAt,`values`) VALUES (:id,:profileId,:accountId,:position,:actorId,:recordedAt,:values)",
"INSERT INTO work_profile_revisions (id,profileId,accountId,position,actorId,recordedAt,name,personId,assetId,active,notes) VALUES (:id,:profileId,:accountId,:position,:actorId,:recordedAt,:name,:personId,:assetId,:active,:notes)",
{ replacements: revision, transaction }
);
}

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { teleworkSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateTeleworkAuthorizations(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -19,8 +22,7 @@ export async function migrateTeleworkAuthorizations(database: Sequelize) {
await query.createTable("telework_authorizations", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: reference("companies"),
personId: { ...reference("company_contacts"), unique: true },
values: { type: DataTypes.JSON, allowNull: false },
...teleworkSnapshot.columns,
});
}
if (!tables.some((row) => row.name === "telework_authorization_revisions")) {
@ -30,7 +32,7 @@ export async function migrateTeleworkAuthorizations(database: Sequelize) {
position: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...teleworkSnapshot.columns,
});
}
const indexes = await database.query<{ name: string }>(
@ -44,4 +46,9 @@ export async function migrateTeleworkAuthorizations(database: Sequelize) {
name: "telework_revision_position",
});
}
await normalizeScalarSnapshots(
database,
["telework_authorizations", "telework_authorization_revisions"],
teleworkSnapshot
);
}

View File

@ -6,6 +6,9 @@ import { TeleworkAuthorizationRevision } from "../../domain/telework-authorizati
import { TeleworkAuthorizationInputSchema } from "../../domain/telework-authorization-values";
import { TeleworkAuthorizationValues } from "../../domain/value-objects/telework-authorization-values.vo";
import { teleworkSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
export function teleworkIdentity(value: string) {
const id = UniqueID.create(value);
@ -16,7 +19,7 @@ export function teleworkIdentity(value: string) {
return id.data;
}
export type TeleworkRow = { id: string; companyId: string; personId: string; values: unknown };
export type TeleworkRow = { id: string; companyId: string; personId: string; values?: unknown };
export type TeleworkRevisionRow = {
id: string;
@ -24,7 +27,7 @@ export type TeleworkRevisionRow = {
position: number;
actorId: string;
recordedAt: string;
values: unknown;
values?: unknown;
};
function valuesOf(raw: unknown) {
@ -40,10 +43,10 @@ function valuesOf(raw: unknown) {
export class TeleworkAuthorizationPersistenceMapper {
toDomain(row: TeleworkRow, revisions: readonly TeleworkRevisionRow[]) {
const values = valuesOf(row.values);
const values = valuesOf(snapshotValues(teleworkSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, position) => {
const revisionValues = valuesOf(revision.values);
const revisionValues = valuesOf(snapshotValues(teleworkSnapshot, revision));
if (
revision.authorizationId !== row.id ||
@ -80,8 +83,7 @@ export class TeleworkAuthorizationPersistenceMapper {
root: {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
personId: item.values.personId.toPrimitive(),
values: JSON.stringify(item.values.toPrimitive()),
...item.values.toPrimitive(),
},
revisions: item.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
@ -89,7 +91,7 @@ export class TeleworkAuthorizationPersistenceMapper {
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()),
...revision.values.toPrimitive(),
})),
};
}

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { profileSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateWorkProfiles(database: Sequelize) {
const query = database.getQueryInterface();
const rows = await database.query<{ name: string }>(
@ -19,10 +22,7 @@ export async function migrateWorkProfiles(database: Sequelize) {
await query.createTable("work_profiles", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: reference("companies"),
personId: reference("company_contacts"),
accountId: { ...reference("access_items"), allowNull: true },
assetId: reference("assets"),
values: { type: DataTypes.JSON, allowNull: false },
...profileSnapshot.columns,
currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true },
});
}
@ -30,11 +30,10 @@ export async function migrateWorkProfiles(database: Sequelize) {
await query.createTable("work_profile_revisions", {
id: { type: DataTypes.UUID, primaryKey: true },
profileId: reference("work_profiles"),
accountId: { ...reference("access_items"), allowNull: true },
position: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...profileSnapshot.columns,
});
}
const indexes = await database.query<{ name: string }>(
@ -48,4 +47,9 @@ export async function migrateWorkProfiles(database: Sequelize) {
name: "profile_revision_position",
});
}
await normalizeScalarSnapshots(
database,
["work_profiles", "work_profile_revisions"],
profileSnapshot
);
}

View File

@ -6,6 +6,9 @@ import { WorkProfile } from "../../domain/work-profile.aggregate";
import { WorkProfileRevision } from "../../domain/work-profile-revision.entity";
import { WorkProfileInputSchema } from "../../domain/work-profile-values";
import { profileSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
export function profileIdentity(value: string) {
const id = UniqueID.create(value);
@ -26,7 +29,7 @@ export type ProfileRow = {
personId: string;
accountId: string | null;
assetId: string;
values: unknown;
values?: unknown;
currentKey: string | null;
};
@ -37,7 +40,7 @@ export type ProfileRevisionRow = {
position: number;
actorId: string;
recordedAt: string;
values: unknown;
values?: unknown;
};
function valuesOf(raw: unknown) {
@ -52,10 +55,10 @@ function valuesOf(raw: unknown) {
export class WorkProfilePersistenceMapper {
toDomain(row: ProfileRow, revisions: readonly ProfileRevisionRow[]) {
const values = valuesOf(row.values);
const values = valuesOf(snapshotValues(profileSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, position) => {
const revisionValues = valuesOf(revision.values);
const revisionValues = valuesOf(snapshotValues(profileSnapshot, revision));
if (
revision.position !== position ||
@ -100,20 +103,16 @@ export class WorkProfilePersistenceMapper {
root: {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
personId: values.personId,
accountId: values.accountId,
assetId: values.assetId,
values: JSON.stringify(values),
...values,
currentKey: item.values.active ? [values.personId, values.assetId].join(":") : null,
},
revisions: item.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
profileId: item.id.toPrimitive(),
accountId: revision.values.toPrimitive().accountId,
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()),
...revision.values.toPrimitive(),
})),
};
}

View File

@ -13,14 +13,14 @@ import {
ResourceValues,
} from "../../domain/value-objects/resource-values.vo";
import { ResourceModel, ResourceRevisionModel } from "./resource.model";
import {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
ResourceDefinitionModel,
} from "./resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
import { readDefinitions } from "./resource-catalog-relational";
import { readInventory, writeInventory } from "./resource-inventory-relational";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
const mailboxSchema = z.strictObject({
@ -165,7 +165,9 @@ async function importResource(
row: SourceRow,
transaction: Transaction
) {
const existing = await ResourceModel.findByPk(row.id, { transaction });
const existing = (await readInventory(database, row.companyId, transaction)).rows.find(
(item) => item.id === row.id
);
if (existing) {
const parsed = ResourceInputSchema.parse(json(existing.values));
@ -219,10 +221,7 @@ async function importResource(
);
const saved = new ResourcePersistenceMapper().toPersistence(resource);
await ResourceModel.create(saved.resource, { transaction });
for (const revision of saved.revisions) {
await ResourceRevisionModel.create(revision, { transaction });
}
await writeInventory(database, saved, new Set(), transaction);
}
/** Traspaso idempotente por compañía: UUID y revisiones originales, sin inventar autoría. */
@ -237,10 +236,7 @@ export async function migrateLegacyResources(database: Sequelize) {
if (!root) {
throw new Error("Catálogo de recursos ausente.");
}
const definitions = await ResourceDefinitionModel.findAll({
where: { catalogId: root.id },
transaction,
});
const definitions = await readDefinitions(database, root.id, transaction);
const bindings = await ResourceBindingModel.findAll({
where: { templateId: definitions.map((item) => item.id) },
transaction,

View File

@ -10,12 +10,8 @@ import {
} from "../../domain/resource-definition.entity";
import { ResourceDefinitionValues } from "../../domain/value-objects/resource-definition-values.vo";
import type {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
ResourceDefinitionModel,
} from "./resource-catalog.model";
import type { ResourceAvailabilityModel, ResourceCatalogModel } from "./resource-catalog.model";
import type { BindingRow, DefinitionRow } from "./resource-catalog-relational";
function identity(value: string) {
const parsed = UniqueID.create(value);
@ -30,8 +26,8 @@ function identity(value: string) {
export class ResourceCatalogPersistenceMapper {
toDomain(
root: ResourceCatalogModel,
rows: readonly ResourceDefinitionModel[],
fields: readonly ResourceBindingModel[],
rows: readonly DefinitionRow[],
fields: readonly BindingRow[],
overrides: readonly ResourceAvailabilityModel[]
) {
const definitions = rows.map((row) => {
@ -107,6 +103,10 @@ export class ResourceCatalogPersistenceMapper {
fieldId: field.fieldId.toPrimitive(),
required: field.required,
position,
sectionId:
item.values.data.kind === "template"
? (item.values.data.fields[position]?.sectionId ?? null)
: null,
}))
),
availability: catalog.availability.map((item) => ({

View File

@ -0,0 +1,214 @@
import type { Sequelize, Transaction } from "sequelize";
import { z } from "zod/v4";
import type { ResourceDefinitionSpec } from "../../domain/resource-definition";
import { ResourceDefinitionSchema } from "../../domain/resource-definition";
import type { TemplateSectionData } from "../../domain/template-sections";
import { readResourceRows, writeResourceRow } from "./resource-relational-sql";
export type DefinitionRow = {
id: string;
catalogId: string;
companyId: string | null;
stockKey: string | null;
active: boolean;
values: unknown;
};
type RelationalDefinition = Omit<DefinitionRow, "values"> & {
kind: string;
name: string;
description: string;
fieldType: string | null;
unit: string | null;
usage: string | null;
sectionsDefined: boolean;
};
type ChoiceRow = { fieldId: string; position: number; value: string };
type SectionRow = TemplateSectionData & { templateId: string; position: number };
export type BindingRow = {
id: string;
templateId: string;
fieldId: string;
required: boolean;
position: number;
sectionId?: string | null;
};
export async function readDefinitions(
database: Sequelize,
catalogId: string,
transaction: Transaction
): Promise<DefinitionRow[]> {
const rows = await readResourceRows<RelationalDefinition>(
database,
"resource_definitions",
"catalogId",
[catalogId],
transaction
);
const ids = rows.map((row) => row.id);
const choices = await readResourceRows<ChoiceRow>(
database,
"resource_field_choices",
"fieldId",
ids,
transaction
);
const sections = await readResourceRows<SectionRow>(
database,
"resource_template_sections",
"templateId",
ids,
transaction
);
const bindings = await readResourceRows<BindingRow>(
database,
"resource_template_fields",
"templateId",
ids,
transaction
);
return rows.map((row) => {
const common = { name: row.name, description: row.description };
let values: unknown;
switch (row.kind) {
case "field":
values = {
...common,
kind: row.kind,
type: row.fieldType,
unit: row.unit,
choices: choices
.filter((choice) => choice.fieldId === row.id)
.sort((a, b) => a.position - b.position)
.map((choice) => choice.value),
};
break;
case "template":
values = {
...common,
kind: row.kind,
...(row.usage ? { usage: row.usage } : {}),
fields: bindings
.filter((binding) => binding.templateId === row.id)
.sort((a, b) => a.position - b.position)
.map((binding) => ({
fieldId: binding.fieldId,
required: Boolean(binding.required),
...(binding.sectionId ? { sectionId: binding.sectionId } : {}),
})),
...(row.sectionsDefined
? {
sections: sections
.filter((section) => section.templateId === row.id)
.sort((a, b) => a.position - b.position)
.map(({ id, name, description }) => ({ id, name, description })),
}
: {}),
};
break;
default:
throw new Error("Tipo de definición persistida inconsistente.");
}
return {
id: row.id,
catalogId: row.catalogId,
companyId: row.companyId,
stockKey: row.stockKey,
active: Boolean(row.active),
values: ResourceDefinitionSchema.parse(values),
};
});
}
export async function writeDefinition(
database: Sequelize,
row: Omit<DefinitionRow, "values"> & { values: ResourceDefinitionSpec },
transaction: Transaction
) {
const values = row.values;
await writeResourceRow(
database,
"resource_definitions",
{
id: row.id,
catalogId: row.catalogId,
companyId: row.companyId,
stockKey: row.stockKey,
active: row.active,
kind: values.kind,
name: values.name,
description: values.description,
fieldType: values.kind === "field" ? values.type : null,
unit: values.kind === "field" ? values.unit : null,
usage: values.kind === "template" ? (values.usage ?? null) : null,
sectionsDefined: values.kind === "template" && values.sections !== undefined,
},
transaction
);
if (values.kind === "field") {
await database.query("DELETE FROM resource_field_choices WHERE fieldId=:id", {
replacements: { id: row.id },
transaction,
});
for (const [position, value] of values.choices.entries()) {
await writeResourceRow(
database,
"resource_field_choices",
{ fieldId: row.id, position, value },
transaction
);
}
}
if (values.kind === "template") {
// Desvincular antes de retirar secciones permite mover campos a «Sin sección».
await database.query(
"UPDATE resource_template_fields SET sectionId=NULL WHERE templateId=:id",
{
replacements: { id: row.id },
transaction,
}
);
await database.query("DELETE FROM resource_template_sections WHERE templateId=:id", {
replacements: { id: row.id },
transaction,
});
for (const [position, section] of (values.sections ?? []).entries()) {
await writeResourceRow(
database,
"resource_template_sections",
{ ...section, templateId: row.id, position },
transaction
);
}
}
}
export async function readBindings(
database: Sequelize,
ids: readonly string[],
transaction: Transaction
) {
const rows = await readResourceRows<BindingRow>(
database,
"resource_template_fields",
"templateId",
ids,
transaction
);
return rows.map((row) => ({ ...row, required: Boolean(row.required) }));
}
export const LegacyJsonSchema = z
.unknown()
.transform((value) => (typeof value === "string" ? JSON.parse(value) : value));

View File

@ -1,6 +1,8 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
import { definitionColumns, normalizeResourceCatalog } from "./resource-normalization-migration";
export class ResourceCatalogModel extends Model<
InferAttributes<ResourceCatalogModel>,
InferCreationAttributes<ResourceCatalogModel>
@ -9,18 +11,6 @@ export class ResourceCatalogModel extends Model<
declare catalogKey: string;
}
export class ResourceDefinitionModel extends Model<
InferAttributes<ResourceDefinitionModel>,
InferCreationAttributes<ResourceDefinitionModel>
> {
declare id: string;
declare catalogId: string;
declare companyId: string | null;
declare stockKey: string | null;
declare values: unknown;
declare active: boolean;
}
export class ResourceBindingModel extends Model<
InferAttributes<ResourceBindingModel>,
InferCreationAttributes<ResourceBindingModel>
@ -30,6 +20,7 @@ export class ResourceBindingModel extends Model<
declare fieldId: string;
declare required: boolean;
declare position: number;
declare sectionId: string | null;
}
export class ResourceAvailabilityModel extends Model<
@ -74,7 +65,7 @@ export async function migrateResourceCatalog(database: Sequelize) {
catalogId: fk("resource_catalogs"),
companyId: fk("companies", true),
stockKey: { type: DataTypes.STRING(100), allowNull: true, unique: true },
values: { type: DataTypes.JSON, allowNull: false },
...definitionColumns,
active: { type: DataTypes.BOOLEAN, allowNull: false },
};
const binding = {
@ -83,6 +74,7 @@ export async function migrateResourceCatalog(database: Sequelize) {
fieldId: fk("resource_definitions"),
required: { type: DataTypes.BOOLEAN, allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
sectionId: { type: DataTypes.UUID, allowNull: true },
};
const availability = {
id,
@ -113,16 +105,12 @@ export async function migrateResourceCatalog(database: Sequelize) {
await query.addIndex(table, [...fields], { unique: true, name });
}
}
await normalizeResourceCatalog(database);
ResourceCatalogModel.init(catalog, {
sequelize: database,
tableName: "resource_catalogs",
timestamps: false,
});
ResourceDefinitionModel.init(definition, {
sequelize: database,
tableName: "resource_definitions",
timestamps: false,
});
ResourceBindingModel.init(binding, {
sequelize: database,
tableName: "resource_template_fields",

View File

@ -25,12 +25,12 @@ export class ResourceDashboardReader {
async entries(companyId?: string, issue?: string, page = 1) {
const expiry = issue === "certificate" || issue === "license";
const join = expiry
? " JOIN resource_definitions d ON d.id=r.templateId JOIN resource_definitions f ON f.stockKey='field:expires'"
? " JOIN resource_definitions d ON d.id=r.templateId JOIN resource_definitions f ON f.stockKey='field:expires' JOIN resource_characteristics expiry ON expiry.resourceId=r.id AND expiry.fieldId=f.id"
: "";
const where =
"WHERE (:companyId IS NULL OR r.companyId=:companyId)" +
(expiry
? " AND d.stockKey=:stockKey AND JSON_EXTRACT(r.`values`,'$.active')=true AND JSON_UNQUOTE(JSON_EXTRACT(r.`values`,CONCAT('$.characteristics.\"',f.id,'\"'))) BETWEEN :today AND :until"
? " AND d.stockKey=:stockKey AND r.active=1 AND expiry.dateValue BETWEEN :today AND :until"
: "");
const today = new Date().toISOString().slice(0, 10);
const until = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
@ -46,7 +46,7 @@ export class ResourceDashboardReader {
{ type: QueryTypes.SELECT, replacements }
);
const items = await this.database.query<Entry>(
`SELECT r.id,r.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(r.\`values\`,'$.name')) AS name,NULL AS recordedAt,NULL AS actorName FROM resources r JOIN companies c ON c.id=r.companyId ${join} ${where} ORDER BY name,r.id LIMIT 10 OFFSET :offset`,
`SELECT r.id,r.companyId,c.name AS companyName,r.name AS name,NULL AS recordedAt,NULL AS actorName FROM resources r JOIN companies c ON c.id=r.companyId ${join} ${where} ORDER BY name,r.id LIMIT 10 OFFSET :offset`,
{ type: QueryTypes.SELECT, replacements }
);
@ -55,7 +55,7 @@ export class ResourceDashboardReader {
activity(companyId?: string) {
return this.database.query<Entry>(
"SELECT r.id,h.id AS eventId,r.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(r.`values`,'$.name')) AS name,h.recordedAt,u.name AS actorName FROM resource_revisions h JOIN resources r ON r.id=h.resourceId JOIN companies c ON c.id=r.companyId LEFT JOIN users u ON u.id=h.actorId WHERE (:companyId IS NULL OR r.companyId=:companyId) ORDER BY h.recordedAt DESC,h.id DESC LIMIT 6",
"SELECT r.id,h.id AS eventId,r.companyId,c.name AS companyName,r.name AS name,h.recordedAt,u.name AS actorName FROM resource_revisions h JOIN resources r ON r.id=h.resourceId JOIN companies c ON c.id=r.companyId LEFT JOIN users u ON u.id=h.actorId WHERE (:companyId IS NULL OR r.companyId=:companyId) ORDER BY h.recordedAt DESC,h.id DESC LIMIT 6",
{ type: QueryTypes.SELECT, replacements: { companyId: companyId ?? null } }
);
}

View File

@ -0,0 +1,414 @@
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 }
);
}

View File

@ -0,0 +1,428 @@
import { isDeepStrictEqual } from "node:util";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { ResourceDefinitionSchema } from "../../domain/resource-definition";
import { ResourceInputSchema, ResourceSpecificationSchema } from "../../domain/resource-values";
import type { BindingRow, DefinitionRow } from "./resource-catalog-relational";
import { LegacyJsonSchema, readDefinitions, writeDefinition } from "./resource-catalog-relational";
import type { InventoryRow, RevisionRow } from "./resource-inventory-relational";
import { readInventory, writeInventory } from "./resource-inventory-relational";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
import { removeResourceColumn, sqlIdentifier } from "./resource-relational-sql";
const fk = (model: string, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: ["resource_definitions", "access_items"].includes(model) ? "RESTRICT" : "CASCADE",
});
const title = { type: DataTypes.STRING(100), allowNull: false };
const position = { type: DataTypes.INTEGER, allowNull: false };
export const definitionColumns = {
kind: { type: DataTypes.STRING(12), allowNull: true },
name: { ...title, allowNull: true },
description: { type: DataTypes.TEXT, allowNull: true },
fieldType: { type: DataTypes.STRING(12), allowNull: true },
unit: { type: DataTypes.STRING(30), allowNull: true },
usage: { type: DataTypes.STRING(15), allowNull: true },
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
export const inventoryColumns = {
name: { ...title, allowNull: true },
active: { type: DataTypes.BOOLEAN, allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
integrationKind: { type: DataTypes.STRING(10), allowNull: true },
addressFieldId: fk("resource_definitions", true),
contextFieldId: fk("resource_definitions", true),
mailboxKind: { type: DataTypes.STRING(10), allowNull: true },
accountId: fk("access_items", true),
referenceFieldId: fk("resource_definitions", true),
resourceTypeId: fk("access_items", true),
systemId: fk("access_items", true),
environmentId: fk("access_items", true),
};
export const specificationColumns = {
templateName: { ...title, allowNull: true },
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
type Columns = Parameters<ReturnType<Sequelize["getQueryInterface"]>["createTable"]>[1];
async function create(database: Sequelize, name: string, columns: Columns) {
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:name",
{ replacements: { name }, type: QueryTypes.SELECT }
);
if (!tables.length) {
await database.getQueryInterface().createTable(name, columns);
}
}
async function add(database: Sequelize, table: string, columns: Columns) {
const previous = await database.getQueryInterface().describeTable(table);
for (const [column, definition] of Object.entries(columns)) {
if (!(column in previous)) {
await database.getQueryInterface().addColumn(table, column, definition);
}
}
}
function legacy(value: unknown) {
return LegacyJsonSchema.parse(value);
}
export async function normalizeResourceCatalog(database: Sequelize) {
await add(database, "resource_definitions", definitionColumns);
await create(database, "resource_field_choices", {
fieldId: { ...fk("resource_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
await create(database, "resource_template_sections", {
id: { type: DataTypes.UUID, primaryKey: true },
templateId: { ...fk("resource_definitions"), primaryKey: true },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
await add(database, "resource_template_fields", {
sectionId: { type: DataTypes.UUID, allowNull: true },
});
await ensureSectionIdentity(database);
const columns = await database.getQueryInterface().describeTable("resource_definitions");
if (!columns.values) {
await constrainSections(database);
return;
}
// Las columnas originales se conservan hasta terminar la transferencia validada.
await database.query("ALTER TABLE resource_definitions MODIFY COLUMN `values` LONGTEXT NULL");
await database.transaction(async (transaction) => {
const rows = await database.query<DefinitionRow>("SELECT * FROM resource_definitions", {
type: QueryTypes.SELECT,
transaction,
});
const bindings = await database.query<BindingRow>("SELECT * FROM resource_template_fields", {
type: QueryTypes.SELECT,
transaction,
});
for (const row of rows) {
const values = ResourceDefinitionSchema.parse(legacy(row.values));
validateLegacyBindings(row.id, values, bindings);
await writeDefinition(database, { ...row, values }, transaction);
if (values.kind === "template") {
for (const field of values.fields) {
await database.query(
"UPDATE resource_template_fields SET sectionId=:sectionId WHERE templateId=:id AND fieldId=:fieldId",
{
replacements: {
sectionId: field.sectionId ?? null,
id: row.id,
fieldId: field.fieldId,
},
transaction,
}
);
}
}
}
await verifyCatalogTransfer(database, rows, transaction);
});
await removeResourceColumn(database, "resource_definitions", "values");
await constrainSections(database);
}
export async function normalizeResourceInventory(database: Sequelize) {
await add(database, "resources", { ...inventoryColumns, ...specificationColumns });
await add(database, "resource_revisions", {
...inventoryColumns,
templateId: { ...fk("resource_definitions"), allowNull: true },
});
await create(database, "resource_specification_sections", {
resourceId: { ...fk("resources"), primaryKey: true },
id: { type: DataTypes.UUID, primaryKey: true },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
await create(database, "resource_specification_fields", {
resourceId: { ...fk("resources"), primaryKey: true },
fieldId: { ...fk("resource_definitions"), primaryKey: true },
name: title,
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,
});
await create(database, "resource_specification_field_choices", {
resourceId: { ...fk("resources"), primaryKey: true },
fieldId: { ...fk("resource_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
const typedValues = {
fieldId: { ...fk("resource_definitions"), primaryKey: true },
valueType: { type: DataTypes.STRING(12), allowNull: false },
textValue: { type: DataTypes.TEXT, allowNull: true },
numberValue: { type: DataTypes.DOUBLE, allowNull: true },
booleanValue: { type: DataTypes.BOOLEAN, allowNull: true },
dateValue: { type: DataTypes.DATEONLY, allowNull: true },
};
await create(database, "resource_characteristics", {
resourceId: { ...fk("resources"), primaryKey: true },
...typedValues,
});
await create(database, "resource_revision_characteristics", {
revisionId: { ...fk("resource_revisions"), primaryKey: true },
resourceId: fk("resources"),
...typedValues,
});
const columns = await database.getQueryInterface().describeTable("resources");
if (!columns.values) {
await removeResourceColumn(database, "resources", "specification");
return;
}
await database.query(
"ALTER TABLE resources MODIFY COLUMN `values` LONGTEXT NULL, MODIFY COLUMN specification LONGTEXT NULL"
);
const revisionColumns = await database.getQueryInterface().describeTable("resource_revisions");
if (revisionColumns.values) {
await database.query("ALTER TABLE resource_revisions MODIFY COLUMN `values` LONGTEXT NULL");
}
await database.transaction(async (transaction) => {
const resources = await database.query<InventoryRow>("SELECT * FROM resources", {
type: QueryTypes.SELECT,
transaction,
});
const revisions = await database.query<RevisionRow>("SELECT * FROM resource_revisions", {
type: QueryTypes.SELECT,
transaction,
});
const mapper = new ResourcePersistenceMapper();
if (!revisionColumns.values) {
await restoreTransferredRevisions(database, resources, revisions, transaction);
}
const expectedResources = [];
for (const row of resources) {
const resource = mapper.toDomain(
{
...row,
values: ResourceInputSchema.parse(legacy(row.values)),
specification: ResourceSpecificationSchema.parse(legacy(row.specification)),
},
revisions
.filter((revision) => revision.resourceId === row.id)
.map((revision) => ({
...revision,
values: ResourceInputSchema.parse(legacy(revision.values)),
}))
);
const expected = mapper.toPersistence(resource);
await writeInventory(database, expected, new Set(), transaction);
expectedResources.push(expected);
}
await verifyInventoryTransfer(database, expectedResources, transaction);
});
await removeResourceColumn(database, "resource_revisions", "values");
await removeResourceColumn(database, "resources", "values");
await removeResourceColumn(database, "resources", "specification");
}
function validateLegacyBindings(
id: string,
values: ReturnType<typeof ResourceDefinitionSchema.parse>,
bindings: BindingRow[]
) {
if (values.kind !== "template") {
return;
}
const stored = bindings
.filter((binding) => binding.templateId === id)
.sort((a, b) => a.position - b.position);
if (
stored.length !== values.fields.length ||
stored.some(
(binding, index) =>
binding.fieldId !== values.fields[index]?.fieldId ||
Boolean(binding.required) !== values.fields[index]?.required
)
) {
throw new Error("Referencias de plantilla incompatibles con la normalización.");
}
}
async function verifyCatalogTransfer(
database: Sequelize,
rows: DefinitionRow[],
transaction: Transaction
) {
for (const catalogId of new Set(rows.map((row) => row.catalogId))) {
const actual = await readDefinitions(database, catalogId, transaction);
for (const row of rows.filter((item) => item.catalogId === catalogId)) {
const transferred = actual.find((item) => item.id === row.id);
if (
!(
transferred &&
isDeepStrictEqual(ResourceDefinitionSchema.parse(legacy(row.values)), transferred.values)
)
) {
throw new Error("La verificación del catálogo normalizado no coincide con el origen.");
}
}
}
}
async function restoreTransferredRevisions(
database: Sequelize,
resources: InventoryRow[],
revisions: RevisionRow[],
transaction: Transaction
) {
for (const companyId of new Set(resources.map((row) => row.companyId))) {
const previous = await readInventory(database, companyId, transaction);
for (const revision of revisions) {
const transferred = previous.revisions.find((item) => item.id === revision.id);
if (transferred) {
revision.values = transferred.values;
}
}
}
}
async function verifyInventoryTransfer(
database: Sequelize,
expectedResources: ReturnType<ResourcePersistenceMapper["toPersistence"]>[],
transaction: Transaction
) {
const mapper = new ResourcePersistenceMapper();
for (const companyId of new Set(expectedResources.map((item) => item.resource.companyId))) {
const transferred = await readInventory(database, companyId, transaction);
for (const expected of expectedResources.filter(
(item) => item.resource.companyId === companyId
)) {
const row = transferred.rows.find((item) => item.id === expected.resource.id);
if (
!(
row &&
isDeepStrictEqual(
expected,
mapper.toPersistence(
mapper.toDomain(
row,
transferred.revisions.filter((item) => item.resourceId === row.id)
)
)
)
)
) {
throw new Error("La verificación del inventario normalizado no coincide con el origen.");
}
}
}
}
async function ensureSectionIdentity(database: Sequelize) {
const primary = await database.query<{ columnName: string }>(
"SELECT COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_template_sections' AND CONSTRAINT_NAME='PRIMARY' ORDER BY ORDINAL_POSITION",
{ type: QueryTypes.SELECT }
);
if (primary.length === 2 && primary[0]?.columnName === "templateId") {
return;
}
// Las copias antiguas comparten IDs de sección; la identidad incluye su plantilla.
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_template_fields' AND COLUMN_NAME='sectionId' AND REFERENCED_TABLE_NAME IS NOT NULL",
{ type: QueryTypes.SELECT }
);
for (const { name } of constraints) {
await database.getQueryInterface().removeConstraint("resource_template_fields", name);
}
await database.query(
"ALTER TABLE resource_template_sections DROP PRIMARY KEY, ADD PRIMARY KEY (templateId,id)"
);
}
async function constrainSections(database: Sequelize) {
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_template_fields' AND CONSTRAINT_NAME='resource_template_field_section'",
{ type: QueryTypes.SELECT }
);
if (!constraints.length) {
await database.query(
"ALTER TABLE resource_template_fields ADD CONSTRAINT resource_template_field_section FOREIGN KEY (templateId,sectionId) REFERENCES resource_template_sections(templateId,id)"
);
}
}
export async function enforceResourceConstraints(database: Sequelize) {
const required = [
["resource_definitions", ["kind", "name", "description"]],
["resources", ["name", "active", "notes", "templateName"]],
["resource_revisions", ["name", "active", "notes", "templateId"]],
] as const;
const definitions = {
...definitionColumns,
...inventoryColumns,
...specificationColumns,
templateId: fk("resource_definitions"),
};
for (const [table, names] of required) {
const columns = await database.getQueryInterface().describeTable(table);
for (const name of names) {
if (columns[name]?.allowNull) {
await database
.getQueryInterface()
.changeColumn(table, name, { ...definitions[name], allowNull: false });
}
}
}
// Las referencias externas nunca eliminan en cascada recursos ni sus revisiones.
const external = await database.query<{
tableName: string;
constraintName: string;
columnName: string;
targetTable: string;
}>(
"SELECT k.TABLE_NAME AS tableName,k.CONSTRAINT_NAME AS constraintName,k.COLUMN_NAME AS columnName,k.REFERENCED_TABLE_NAME AS targetTable FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND c.TABLE_NAME=k.TABLE_NAME AND c.CONSTRAINT_NAME=k.CONSTRAINT_NAME WHERE k.TABLE_SCHEMA=DATABASE() AND k.TABLE_NAME IN ('resources','resource_revisions') AND k.REFERENCED_TABLE_NAME IN ('access_items','resource_definitions') AND c.DELETE_RULE='CASCADE'",
{ type: QueryTypes.SELECT }
);
for (const row of external) {
await database.query(
`ALTER TABLE ${sqlIdentifier(row.tableName)} DROP FOREIGN KEY ${sqlIdentifier(row.constraintName)}, ADD CONSTRAINT ${sqlIdentifier(`${row.tableName}_${row.columnName}_restrict_fk`)} FOREIGN KEY (${sqlIdentifier(row.columnName)}) REFERENCES ${sqlIdentifier(row.targetTable)} (id) ON DELETE RESTRICT`
);
}
}

View File

@ -9,7 +9,7 @@ import {
ResourceValues,
} from "../../domain/value-objects/resource-values.vo";
import type { ResourceModel, ResourceRevisionModel } from "./resource.model";
import type { InventoryRow, RevisionRow } from "./resource-inventory-relational";
export function inventoryIdentity(value: string) {
const parsed = UniqueID.create(value);
@ -22,7 +22,7 @@ export function inventoryIdentity(value: string) {
}
export class ResourcePersistenceMapper {
toDomain(row: ResourceModel, revisions: readonly ResourceRevisionModel[]) {
toDomain(row: InventoryRow, revisions: readonly RevisionRow[]) {
const specification = ResourceSpecification.create(
ResourceSpecificationSchema.parse(row.specification)
);

View File

@ -0,0 +1,58 @@
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
/** Identificadores internos; los valores externos siempre se parametrizan. */
export function sqlIdentifier(value: string) {
return `\`${value.replaceAll("`", "``")}\``;
}
export async function writeResourceRow(
database: Sequelize,
table: string,
row: Record<string, string | number | boolean | null>,
transaction: Transaction
) {
const columns = Object.keys(row);
await database.query(
`INSERT INTO ${sqlIdentifier(table)} (${columns.map(sqlIdentifier).join(",")}) VALUES (${columns.map((key) => `:${key}`).join(",")}) ON DUPLICATE KEY UPDATE ${columns.map((key) => `${sqlIdentifier(key)}=VALUES(${sqlIdentifier(key)})`).join(",")}`,
{ replacements: row, transaction }
);
}
export async function readResourceRows<T extends object>(
database: Sequelize,
table: string,
column: string,
ids: readonly string[],
transaction: Transaction
) {
if (!ids.length) {
return [];
}
return database.query<T>(
`SELECT * FROM ${sqlIdentifier(table)} WHERE ${sqlIdentifier(column)} IN (:ids)`,
{ replacements: { ids }, type: QueryTypes.SELECT, transaction }
);
}
export async function removeResourceColumn(database: Sequelize, table: string, column: string) {
const columns = await database.getQueryInterface().describeTable(table);
if (!(column in columns)) {
return;
}
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME=:column AND REFERENCED_TABLE_NAME IS NOT NULL",
{ replacements: { table, column }, type: QueryTypes.SELECT }
);
for (const { name } of constraints) {
await database.query(
`ALTER TABLE ${sqlIdentifier(table)} DROP FOREIGN KEY ${sqlIdentifier(name)}`
);
}
// Evita el fallo de QueryInterface.removeColumn con Sequelize 6 y mariadb 3.
await database.query(`ALTER TABLE ${sqlIdentifier(table)} DROP COLUMN ${sqlIdentifier(column)}`);
}

View File

@ -1,28 +1,11 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
export class ResourceModel extends Model<
InferAttributes<ResourceModel>,
InferCreationAttributes<ResourceModel>
> {
declare id: string;
declare companyId: string;
declare templateId: string;
declare specification: unknown;
declare values: unknown;
}
export class ResourceRevisionModel extends Model<
InferAttributes<ResourceRevisionModel>,
InferCreationAttributes<ResourceRevisionModel>
> {
declare id: string;
declare resourceId: string;
declare actorId: string;
declare recordedAt: string;
declare values: unknown;
declare position: number;
}
import {
inventoryColumns,
normalizeResourceInventory,
specificationColumns,
} from "./resource-normalization-migration";
const id = { type: DataTypes.UUID, primaryKey: true };
@ -43,15 +26,16 @@ export async function migrateInventory(database: Sequelize) {
id,
companyId: fk("companies"),
templateId: fk("resource_definitions"),
specification: { type: DataTypes.JSON, allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...inventoryColumns,
...specificationColumns,
};
const revision = {
id,
resourceId: fk("resources"),
actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...inventoryColumns,
templateId: fk("resource_definitions"),
position: { type: DataTypes.INTEGER, allowNull: false },
};
@ -72,10 +56,5 @@ export async function migrateInventory(database: Sequelize) {
name: "resource_revision_position_unique",
});
}
ResourceModel.init(resource, { sequelize: database, tableName: "resources", timestamps: false });
ResourceRevisionModel.init(revision, {
sequelize: database,
tableName: "resource_revisions",
timestamps: false,
});
await normalizeResourceInventory(database);
}

View File

@ -11,9 +11,9 @@ import {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
ResourceDefinitionModel,
} from "./resource-catalog.model";
import type { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
import { readDefinitions, writeDefinition } from "./resource-catalog-relational";
export class SequelizeResourceCatalogRepository implements IResourceCatalogRepository {
constructor(
@ -44,10 +44,7 @@ export class SequelizeResourceCatalogRepository implements IResourceCatalogRepos
return Result.fail(new ResourceCatalogError("COMPANY_NOT_FOUND"));
}
}
const definitions = await ResourceDefinitionModel.findAll({
where: { catalogId: root.id },
transaction,
});
const definitions = await readDefinitions(this.database, root.id, transaction);
const bindings = definitions.length
? await ResourceBindingModel.findAll({
where: { templateId: definitions.map((item) => item.id) },
@ -82,7 +79,7 @@ export class SequelizeResourceCatalogRepository implements IResourceCatalogRepos
// Primero las definiciones, después sus relaciones: las FK no admiten huérfanos.
for (const row of rows.definitions) {
await ResourceDefinitionModel.upsert(row, { transaction });
await writeDefinition(this.database, row, transaction);
}
const previousBindings = loaded.data.definitions.flatMap((item) => item.bindings);
const retained = new Set(rows.bindings.map((row) => row.id));

View File

@ -8,15 +8,15 @@ import { ResourceReferenceSchema } from "../../../common/inventory";
import type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
import { ResourceCatalogError } from "../../domain/resource-definition";
import { ResourceModel, ResourceRevisionModel } from "./resource.model";
import {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
ResourceDefinitionModel,
} from "./resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
import { readDefinitions } from "./resource-catalog-relational";
import { projectResource } from "./resource-compatibility";
import { readInventory, writeInventory } from "./resource-inventory-relational";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
export class SequelizeResourceRepository implements IResourceRepository {
@ -47,10 +47,7 @@ export class SequelizeResourceRepository implements IResourceRepository {
if (!companies.length) {
return Result.fail(new ResourceCatalogError("COMPANY_NOT_FOUND"));
}
const definitions = await ResourceDefinitionModel.findAll({
where: { catalogId: root.id },
transaction,
});
const definitions = await readDefinitions(this.database, root.id, transaction);
const bindings = definitions.length
? await ResourceBindingModel.findAll({
where: { templateId: definitions.map((item) => item.id) },
@ -67,16 +64,11 @@ export class SequelizeResourceRepository implements IResourceRepository {
bindings,
overrides
);
const rows = await ResourceModel.findAll({
where: { companyId: companyId.toPrimitive() },
transaction,
});
const revisions = rows.length
? await ResourceRevisionModel.findAll({
where: { resourceId: rows.map((item) => item.id) },
transaction,
})
: [];
const { rows, revisions } = await readInventory(
this.database,
companyId.toPrimitive(),
transaction
);
const resources = rows.map((row) => {
const resource = this.mapper.toDomain(
row,
@ -160,17 +152,16 @@ export class SequelizeResourceRepository implements IResourceRepository {
const persistence = this.mapper.toPersistence(changed.data);
await projectResource(this.database, changed.data, transaction);
await ResourceModel.upsert(persistence.resource, { transaction });
const existing = await ResourceRevisionModel.findAll({
where: { resourceId: persistence.resource.id },
transaction,
});
for (const revision of persistence.revisions) {
if (!existing.some((item) => item.id === revision.id)) {
await ResourceRevisionModel.create(revision, { transaction });
}
}
await writeInventory(
this.database,
persistence,
new Set(
loaded.data.resources
.find((item) => item.id.equals(changed.data.id))
?.history.map((revision) => revision.id.toPrimitive()) ?? []
),
transaction
);
return Result.ok({ resource: changed.data, actorNames: loaded.data.actorNames });
});

View File

@ -17,6 +17,7 @@ import {
} from "./infrastructure/persistence/resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./infrastructure/persistence/resource-catalog-persistence.mapper";
import { ResourceDashboardReader } from "./infrastructure/persistence/resource-dashboard-reader";
import { enforceResourceConstraints } from "./infrastructure/persistence/resource-normalization-migration";
import { SequelizeResourceRepository } from "./infrastructure/persistence/sequelize-resource.repository";
import { SequelizeResourceCatalogRepository } from "./infrastructure/persistence/sequelize-resource-catalog.repository";
@ -38,6 +39,7 @@ export async function createResourceModule(database: Sequelize, authenticate: Re
throw new Error("No se pudo inicializar el catálogo de recursos.");
}
await migrateInventory(database);
await enforceResourceConstraints(database);
const catalog = new ResourceCatalogUseCases(repository);
const inventoryRepository = new SequelizeResourceRepository(database);
const inventory = new ResourceUseCases(inventoryRepository);

View File

@ -119,10 +119,10 @@ function entityColumnClass(pinned: boolean, header: boolean) {
if (pinned) {
return header
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"
: "sticky right-0 z-10 h-14 w-20 min-w-20 bg-card px-3 py-3 pr-4 text-right whitespace-nowrap group-hover/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] group-has-aria-expanded/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))]";
: "sticky right-0 z-10 h-10 w-20 min-w-20 bg-card px-3 py-3 pr-4 text-right whitespace-nowrap group-hover/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] group-has-aria-expanded/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))]";
}
return header
? "first:min-w-40 bg-card font-medium"
? "first:min-w-40 bg-background font-medium"
: "h-14 first:min-w-40 max-w-lg whitespace-normal break-words px-3 py-3";
}