BD normalizada

This commit is contained in:
David Arranz 2026-10-10 16:40:51 +02:00
parent ee21c0a80e
commit bf4831a34f
41 changed files with 2112 additions and 360 deletions

View File

@ -11,23 +11,22 @@
"start": "node --env-file=../../.env dist/index.js" "start": "node --env-file=../../.env dist/index.js"
}, },
"dependencies": { "dependencies": {
"@metis/core": "workspace:*",
"express": "4.22.1",
"helmet": "8.1.0",
"zod": "4.3.6",
"@metis/identity": "workspace:*",
"sequelize": "6.37.8",
"mariadb": "3.5.4",
"express-session": "1.19.0",
"connect-session-sequelize": "8.0.6",
"@metis/companies": "workspace:*",
"@metis/assets": "workspace:*", "@metis/assets": "workspace:*",
"@metis/resources": "workspace:*" "@metis/companies": "workspace:*",
"@metis/core": "workspace:*",
"@metis/identity": "workspace:*",
"@metis/resources": "workspace:*",
"express": "4.22.1",
"express-session": "1.19.0",
"helmet": "8.1.0",
"mariadb": "3.5.4",
"sequelize": "6.37.8",
"zod": "4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/express": "4.17.21", "@types/express": "4.17.21",
"tsup": "8.5.1", "@types/express-session": "^1.18.2",
"@types/express-session": "^1.18.2" "tsup": "8.5.1"
} }
} }

View File

@ -20,6 +20,8 @@ import { createResourceModule } from "@metis/resources/module";
import { QueryTypes } from "sequelize"; import { QueryTypes } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { AccessKindSchema } from "../../../modules/companies/src/api/domain/access/access-definition";
import { accessValueTable } from "../../../modules/companies/src/api/infrastructure/persistence/access-relational";
import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration"; import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration";
import { verifyAccess } from "./access.integration"; import { verifyAccess } from "./access.integration";
@ -46,6 +48,7 @@ import { verifyPrinters } from "./printers.integration";
import { verifyProcesses } from "./processes.integration"; import { verifyProcesses } from "./processes.integration";
import { verifyRecordDeletion } from "./record-deletion.integration"; import { verifyRecordDeletion } from "./record-deletion.integration";
import { verifyRelationships } from "./relationships.integration"; import { verifyRelationships } from "./relationships.integration";
import { verifyRemainingNormalization } from "./remaining-normalization.integration";
import { verifyRemoteConnections } from "./remote.integration"; import { verifyRemoteConnections } from "./remote.integration";
import { verifyResourceMigration } from "./resource-migration.integration"; import { verifyResourceMigration } from "./resource-migration.integration";
import { verifyResourceNormalization } from "./resource-normalization.integration"; import { verifyResourceNormalization } from "./resource-normalization.integration";
@ -142,6 +145,9 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await database.query("DELETE FROM access_person_references"); await database.query("DELETE FROM access_person_references");
await database.query("DELETE FROM access_references"); await database.query("DELETE FROM access_references");
await database.query("DELETE FROM access_revisions"); await database.query("DELETE FROM access_revisions");
for (const kind of AccessKindSchema.options) {
await database.query(`DELETE FROM ${accessValueTable(kind)}`);
}
await database.query("DELETE FROM access_items"); await database.query("DELETE FROM access_items");
await database.query("DELETE FROM access_workspaces WHERE companyId IS NOT NULL"); await database.query("DELETE FROM access_workspaces WHERE companyId IS NOT NULL");
await database.query("DELETE FROM users"); await database.query("DELETE FROM users");
@ -992,6 +998,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
customer.id customer.id
); );
await verifyConfigurationNormalization(database); await verifyConfigurationNormalization(database);
await verifyRemainingNormalization(database);
await verifyRecordDeletion((path, body) => operator.request(path, body), database); await verifyRecordDeletion((path, body) => operator.request(path, body), database);
const countAfterAccess = await database.query<{ total: number }>( const countAfterAccess = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM users", "SELECT COUNT(*) AS total FROM users",

View File

@ -1,8 +1,11 @@
import { createAssetModule } from "@metis/assets/module"; import { createAssetModule } from "@metis/assets/module";
import { createCompanyModule } from "@metis/companies/module"; import { createCompanyModule } from "@metis/companies/module";
import { createIdentityModule, migrateIdentity } from "@metis/identity/module"; import {
SequelizeSessionStore,
createIdentityModule,
migrateIdentity,
} from "@metis/identity/module";
import { createResourceModule } from "@metis/resources/module"; import { createResourceModule } from "@metis/resources/module";
import connectSequelize from "connect-session-sequelize";
import session from "express-session"; import session from "express-session";
import type { Sequelize } from "sequelize"; import type { Sequelize } from "sequelize";
@ -34,12 +37,7 @@ export async function createIdentityRuntime(
assets.services.dashboard, assets.services.dashboard,
resources.services.dashboard resources.services.dashboard
); );
const SequelizeStore = connectSequelize(session.Store); const store = new SequelizeSessionStore(database);
const store = new SequelizeStore({
db: database,
tableName: "http_sessions",
expiration: 8 * 60 * 60 * 1000,
});
const middleware = session({ const middleware = session({
name: "metis.sid", name: "metis.sid",
secret: environment.SESSION_SECRET, secret: environment.SESSION_SECRET,
@ -65,6 +63,7 @@ export async function createIdentityRuntime(
dashboard.register(app); dashboard.register(app);
}, },
middleware, middleware,
stop: () => store.stopExpiringSessions(), // El almacén relacional no utiliza un temporizador de fondo.
stop: () => undefined,
}; };
} }

View File

@ -19,6 +19,10 @@ import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize"; import { QueryTypes } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { mailSnapshot } from "../../../modules/companies/src/api/infrastructure/persistence/mail-remote-snapshots";
import { snapshotValues } from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-normalization";
import { writeSnapshotRow } from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-writer";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyMail( export async function verifyMail(
@ -304,32 +308,37 @@ export async function verifyMail(
state: "configured", state: "configured",
notes: "Conservar", notes: "Conservar",
}; };
const raw = JSON.stringify(legacyValues);
await database.query( await database.transaction(async (transaction) => {
"INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,NULL,NULL,:values,:key)", await writeSnapshotRow(
{ database,
replacements: { "mail_configurations",
mailSnapshot,
{
id: legacyId, id: legacyId,
companyId, companyId,
...legacyValues, values: legacyValues,
values: raw, currentKey: [person.id, account.id, first.id].join(":"),
key: [person.id, account.id, first.id].join(":"),
}, },
} transaction,
); "insert"
await database.query( );
"INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,0,:actorId,:recordedAt,:values)", await writeSnapshotRow(
{ database,
replacements: { "mail_configuration_revisions",
mailSnapshot,
{
id: revisionId, id: revisionId,
configurationId: legacyId, configurationId: legacyId,
position: 0,
actorId, actorId,
recordedAt: new Date().toISOString(), recordedAt: new Date().toISOString(),
values: raw, values: legacyValues,
}, },
} transaction,
); "insert"
);
});
const legacy = MailConfigurationsResponseSchema.parse( const legacy = MailConfigurationsResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).configurations.find((item) => item.id === legacyId); ).configurations.find((item) => item.id === legacyId);
@ -351,14 +360,14 @@ export async function verifyMail(
assert.equal(adopted.history[0]?.id, revisionId); assert.equal(adopted.history[0]?.id, revisionId);
assert.equal(adopted.history.length, 2); assert.equal(adopted.history.length, 2);
assert.deepEqual(adopted.history[0]?.values, legacyValues); assert.deepEqual(adopted.history[0]?.values, legacyValues);
const persisted = await database.query<{ values: string }>( const persisted = await database.query<Record<string, unknown>>(
"SELECT `values` FROM mail_configuration_revisions WHERE id=:id", "SELECT * FROM mail_configuration_revisions WHERE id=:id",
{ replacements: { id: revisionId }, type: QueryTypes.SELECT } { replacements: { id: revisionId }, type: QueryTypes.SELECT }
); );
const original = persisted[0]?.values; const original = persisted[0];
assert.ok(original); assert.ok(original);
assert.deepEqual(typeof original === "string" ? JSON.parse(original) : original, legacyValues); assert.deepEqual(snapshotValues(mailSnapshot, original), legacyValues);
await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", { await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", {
replacements: { id: mobile.id }, replacements: { id: mobile.id },
}); });

View File

@ -35,7 +35,7 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
needId: string; needId: string;
actorId: string; actorId: string;
}>( }>(
"SELECT p.id AS profileId,p.companyId,i.id AS needId,u.id AS actorId FROM work_profiles p JOIN access_workspaces w ON w.companyId=p.companyId JOIN access_items i ON i.workspaceId=w.id CROSS JOIN users u WHERE JSON_UNQUOTE(JSON_EXTRACT(i.`values`, '$.kind'))='need' LIMIT 1", "SELECT p.id AS profileId,p.companyId,i.id AS needId,u.id AS actorId FROM work_profiles p JOIN access_workspaces w ON w.companyId=p.companyId JOIN access_items i ON i.workspaceId=w.id CROSS JOIN users u WHERE i.kind='need' LIMIT 1",
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
); );
const subject = folderSubjects[0]; const subject = folderSubjects[0];

View File

@ -0,0 +1,284 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import session from "express-session";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { AccessKindSchema } from "../../../modules/companies/src/api/domain/access/access-definition";
import {
accessValueTable,
normalizeAccess,
readAccessItems,
readAccessRevisions,
} from "../../../modules/companies/src/api/infrastructure/persistence/access-relational";
import {
type ProcessTable,
normalizeProcesses,
readProcessRows,
} from "../../../modules/companies/src/api/infrastructure/persistence/process-relational";
import { supportIdentifier } from "../../../modules/companies/src/api/infrastructure/persistence/support-record-relational";
import { SequelizeSessionStore } from "../../../modules/identity/src/api/infrastructure/persistence/sequelize-session.store";
import {
normalizeSessions,
sessionFromRow,
sessionRow,
} from "../../../modules/identity/src/api/infrastructure/persistence/session-normalization";
const processTables: readonly ProcessTable[] = [
"process_templates",
"process_template_revisions",
"person_processes",
"person_process_revisions",
];
const processChildren = [
"process_template_instructions",
"process_template_revision_instructions",
"person_process_tasks",
"person_process_revision_tasks",
];
async function accessSnapshot(database: Sequelize) {
return database.transaction(async (transaction) => ({
items: (await readAccessItems(database, transaction)).sort((a, b) => a.id.localeCompare(b.id)),
revisions: (await readAccessRevisions(database, transaction)).sort((a, b) =>
a.id.localeCompare(b.id)
),
}));
}
async function processSnapshot(database: Sequelize) {
return database.transaction(async (transaction) => {
const snapshots = new Map<ProcessTable, Record<string, unknown>[]>();
for (const table of processTables) {
snapshots.set(table, await readProcessRows(database, table, transaction));
}
return snapshots;
});
}
async function legacyRows(
database: Sequelize,
table: string,
rows: readonly { id: unknown; values: unknown }[],
transaction?: Transaction
) {
for (const row of rows) {
await database.query(`UPDATE ${supportIdentifier(table)} SET \`values\`=:values WHERE id=:id`, {
replacements: { id: row.id, values: JSON.stringify(row.values) },
...(transaction ? { transaction } : {}),
});
}
}
async function verifyAccessMigration(database: Sequelize) {
const expected = await accessSnapshot(database);
assert.ok(expected.items.some((item) => item.kind === "need"));
assert.ok(expected.revisions.length > expected.items.length);
const query = database.getQueryInterface();
for (const table of ["access_items", "access_revisions"]) {
await query.addColumn(table, "values", { type: DataTypes.JSON, allowNull: true });
}
await database.transaction(async (transaction) => {
await legacyRows(database, "access_items", expected.items, transaction);
await legacyRows(database, "access_revisions", expected.revisions, transaction);
for (const history of [false, true]) {
for (const kind of AccessKindSchema.options) {
await database.query(`DELETE FROM ${supportIdentifier(accessValueTable(kind, history))}`, {
transaction,
});
}
}
});
const first = expected.items[0];
assert.ok(first);
await legacyRows(database, "access_items", [{ id: first.id, values: { kind: first.kind } }]);
await assert.rejects(() => normalizeAccess(database));
assert.ok((await query.describeTable("access_items")).values);
await legacyRows(database, "access_items", [first]);
await normalizeAccess(database);
assert.deepEqual(await accessSnapshot(database), expected);
for (const table of ["access_items", "access_revisions"]) {
assert.equal((await query.describeTable(table)).values, undefined);
}
// Corte entre la transferencia de la raíz y el historial.
await query.addColumn("access_items", "values", { type: DataTypes.JSON, allowNull: true });
await legacyRows(database, "access_items", expected.items);
await normalizeAccess(database);
await normalizeAccess(database);
assert.deepEqual(await accessSnapshot(database), expected);
await assert.rejects(() =>
database.query(
"INSERT INTO access_need_permissions (ownerId,position,permissionId) VALUES (:ownerId,999,:permissionId)",
{ replacements: { ownerId: randomUUID(), permissionId: randomUUID() } }
)
);
}
async function verifyProcessMigration(database: Sequelize) {
const expected = await processSnapshot(database);
assert.ok((expected.get("person_processes")?.length ?? 0) > 0);
const query = database.getQueryInterface();
for (const table of processTables) {
await query.addColumn(table, "values", { type: DataTypes.JSON, allowNull: true });
}
await database.transaction(async (transaction) => {
for (const [table, rows] of expected) {
for (const row of rows) {
await legacyRows(database, table, [{ id: row.id, values: row.values }], transaction);
}
}
for (const table of processChildren) {
await database.query(`DELETE FROM ${supportIdentifier(table)}`, { transaction });
}
});
const first = expected.get("person_processes")?.[0];
assert.ok(first);
await legacyRows(database, "person_processes", [{ id: first.id, values: {} }]);
await assert.rejects(() => normalizeProcesses(database));
assert.ok((await query.describeTable("person_processes")).values);
await legacyRows(database, "person_processes", [{ id: first.id, values: first.values }]);
await normalizeProcesses(database);
assert.deepEqual(await processSnapshot(database), expected);
for (const table of processTables) {
assert.equal((await query.describeTable(table)).values, undefined);
}
await query.addColumn("person_processes", "values", { type: DataTypes.JSON, allowNull: true });
for (const row of expected.get("person_processes") ?? []) {
await legacyRows(database, "person_processes", [{ id: row.id, values: row.values }]);
}
await normalizeProcesses(database);
await normalizeProcesses(database);
assert.deepEqual(await processSnapshot(database), expected);
}
async function sessionSnapshot(database: Sequelize) {
const rows = await database.query<Record<string, unknown>>(
"SELECT * FROM http_sessions ORDER BY sid",
{ type: QueryTypes.SELECT }
);
return rows.map((row) => ({
sid: row.sid,
expires: row.expires,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
values: sessionRow(sessionFromRow(row)),
}));
}
async function verifySessionMigration(database: Sequelize) {
const expected = await sessionSnapshot(database);
assert.ok(expected.length);
const query = database.getQueryInterface();
await query.addColumn("http_sessions", "data", { type: DataTypes.TEXT, allowNull: true });
const original = await database.query<Record<string, unknown>>(
"SELECT * FROM http_sessions ORDER BY sid",
{ type: QueryTypes.SELECT }
);
for (const row of original) {
await database.query("UPDATE http_sessions SET data=:data WHERE sid=:sid", {
replacements: { sid: row.sid, data: JSON.stringify(sessionFromRow(row)) },
});
}
const first = original[0];
assert.ok(first);
await database.query("UPDATE http_sessions SET data=:data WHERE sid=:sid", {
replacements: {
sid: first.sid,
data: JSON.stringify({ unknown: "No omitir silenciosamente" }),
},
});
await assert.rejects(() => normalizeSessions(database));
assert.ok((await query.describeTable("http_sessions")).data);
await database.query("UPDATE http_sessions SET data=:data WHERE sid=:sid", {
replacements: { sid: first.sid, data: JSON.stringify(sessionFromRow(first)) },
});
await normalizeSessions(database);
await normalizeSessions(database);
assert.deepEqual(await sessionSnapshot(database), expected);
assert.equal((await query.describeTable("http_sessions")).data, undefined);
const store = new SequelizeSessionStore(database);
const sid = randomUUID();
const cookie = Object.assign(new session.Cookie(), {
maxAge: 3600000,
httpOnly: true,
secure: false,
sameSite: false,
});
const data = { cookie, userId: undefined, authVersion: undefined, csrfToken: "test-csrf-token" };
await store.set(sid, data);
const restored = await new Promise<session.SessionData>((resolve, reject) => {
store
.get(sid, (error, result) => {
if (error) {
reject(error);
} else if (result) {
resolve(result);
} else {
reject(new Error("Sesión no recuperada."));
}
})
.catch(reject);
});
assert.equal(restored.cookie.originalMaxAge, 3600000);
assert.equal(restored.cookie.secure, false);
assert.equal(restored.cookie.sameSite, false);
assert.equal(restored.csrfToken, data.csrfToken);
await store.touch(sid, { ...data, csrfToken: "No sobrescribir al tocar la cookie" });
const stored = await database.query<{ csrfToken: string }>(
"SELECT csrfToken FROM http_sessions WHERE sid=:sid",
{ replacements: { sid }, type: QueryTypes.SELECT }
);
assert.equal(stored[0]?.csrfToken, data.csrfToken);
await store.destroy(sid);
assert.deepEqual(
await database.query("SELECT sid FROM http_sessions WHERE sid=:sid", {
replacements: { sid },
type: QueryTypes.SELECT,
}),
[]
);
await assert.rejects(() =>
store.set(sid, { ...data, cookie: { ...cookie, originalMaxAge: -1 } })
);
}
/** Recrea el formato legado únicamente en la base de pruebas y verifica recuperación e idempotencia. */
export async function verifyRemainingNormalization(database: Sequelize) {
assert.equal(database.getDatabaseName(), "metis_desk_test");
await verifyAccessMigration(database);
await verifyProcessMigration(database);
await verifySessionMigration(database);
// Las variantes polimórficas también se protegen frente a escritura SQL directa.
await assert.rejects(() =>
database.query(
"UPDATE remote_connections SET targetKind='asset',targetAssetId=NULL,targetResourceId=NULL"
)
);
await assert.rejects(() =>
database.query("UPDATE mail_configurations SET snapshotKind='profile',mailboxId=NULL")
);
const jsonChecks = await database.query(
"SELECT CONSTRAINT_NAME FROM information_schema.CHECK_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND LOWER(CHECK_CLAUSE) LIKE '%json_valid%'",
{ type: QueryTypes.SELECT }
);
assert.deepEqual(jsonChecks, []);
}

View File

@ -4,7 +4,7 @@ Se mantiene Sequelize 6 y MariaDB. El criterio del proyecto es almacenar datos d
## Estado de la normalización ## Estado de la normalización
La normalización completa todavía está en curso. La revisión anterior extendió el modelo relacional de activos a recursos, puestos, asistencia y configuraciones de perfiles. La continuación incorpora tickets, intervenciones y plantillas de intervención. No representa el cierre de la auditoría de toda la base. La auditoría y normalización de la persistencia actual están completadas. Activos, recursos, configuraciones, tickets e intervenciones se completan con cuentas y accesos, correo, accesos remotos, procesos y sesiones HTTP. No se mantienen documentos JSON en la base de desarrollo; los contratos HTTP continúan usando JSON como transporte.
| Ámbito | Persistencia actual | | Ámbito | Persistencia actual |
| --- | --- | | --- | --- |
@ -16,12 +16,12 @@ La normalización completa todavía está en curso. La revisión anterior extend
| Perfiles de usuario | Nombre, estado, notas y referencias 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 | | 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 | | 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 | | Cuentas y accesos | Subtipos en tablas actuales e históricas; permisos de necesidades en asociaciones ordenadas |
| Correo | Pendiente: proyecciones de buzones, usos y configuraciones, incluido su formato histórico anterior | | Correo | Buzones, usos y configuraciones en columnas actuales e históricas; variantes legadas conservadas |
| Accesos remotos | Pendiente | | Accesos remotos | Origen, destinos con claves foráneas independientes, VPN y estado en columnas actuales e históricas |
| Procesos y plantillas de procesos | Pendiente: pasos y revisiones | | Procesos y plantillas de procesos | Columnas escalares y tablas de instrucciones/tareas actuales e históricas |
| Tickets e intervenciones | Columnas actuales e históricas; afectados, pasos, procedimientos congelados y plantillas en tablas relacionadas | | Tickets e intervenciones | Columnas actuales e históricas; afectados, pasos, procedimientos congelados y plantillas en tablas relacionadas |
| Sesiones HTTP | Pendiente de evaluar el contrato del almacén de sesiones existente; no se introduce una excepción nueva para JSON | | Sesiones HTTP | Almacén Sequelize propio: autenticación, CSRF y opciones de cookie en columnas; sin documento serializado |
Las columnas `data` de fotos y logotipos son datos binarios; no son documentos JSON de negocio. Las columnas `data` de fotos y logotipos son datos binarios; no son documentos JSON de negocio.
@ -36,7 +36,7 @@ Las tablas existentes `resource_definitions`, `resource_template_fields`, `resou
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. 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. 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 conservan las integraciones y sus identidades; sus lectores y escritores también utilizan columnas y tablas normalizadas.
## Puestos, perfiles y configuraciones ## Puestos, perfiles y configuraciones
@ -79,9 +79,57 @@ El DDL de MariaDB no es transaccional. Una interrupción puede dejar columnas de
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. 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 ## Cuentas y accesos
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. Se conservan las raíces access_workspaces, access_items y access_revisions, y las proyecciones access_references y access_person_references. La raíz identifica compañía/ámbito y tipo; las revisiones mantienen UUID, secuencia, autor e instante. Cada tipo tiene una tabla de valores y otra de valores históricos:
| Tipo | Actual | Histórica |
| --- | --- | --- |
| Sistema | access_system_values | access_system_revision_values |
| Tipo de recurso | access_resource_type_values | access_resource_type_revision_values |
| Permiso | access_permission_values | access_permission_revision_values |
| Plantilla de grupo | access_template_values | access_template_revision_values |
| Entorno | access_environment_values | access_environment_revision_values |
| Cuenta | access_account_values | access_account_revision_values |
| Titular/usuario de cuenta | access_account_person_values | access_account_person_revision_values |
| Grupo | access_group_values | access_group_revision_values |
| Pertenencia a grupo | access_membership_values | access_membership_revision_values |
| Proyección de recurso | access_resource_values | access_resource_revision_values |
| Permiso concedido | access_grant_values | access_grant_revision_values |
| Necesidad | access_need_values | access_need_revision_values |
| Cobertura | access_coverage_values | access_coverage_revision_values |
ownerId es clave primaria y foránea a la raíz o revisión correspondiente. Las referencias entre elementos y personas tienen claves foráneas. access_need_permissions y access_need_revision_permissions conservan permisos y orden; no permiten duplicar un permiso dentro de la misma necesidad. Las vistas access_current_values y access_historical_values unen columnas de los subtipos para lecturas comunes, sin generar JSON SQL.
El catálogo global y los ámbitos de compañía mantienen sus reglas. Se conservan titularidad histórica, cuentas compartidas, caducidad opcional, grupos, permisos, coberturas, bloqueos de concurrencia y proyecciones de referencias.
## Correo y accesos remotos
mailboxes, mailbox_uses, mail_configurations y sus revisiones almacenan dirección, tipo/contexto, cuenta vinculada, personas autorizadas, periodo, perfil, protocolo, estado y notas en columnas. snapshotKind diferencia configuraciones históricas anteriores (persona/cuenta/activo) de las actuales (perfil/buzón). No se inventa un perfil para transformar el historial anterior. Las restricciones SQL exigen las referencias apropiadas a cada variante.
remote_connections y remote_connection_revisions mantienen persona, tipo de origen, activo corporativo cuando corresponde, sede/red, método, VPN, estado y notas. El destino se separa en targetAssetId y targetResourceId, con claves foráneas reales. Las restricciones SQL impiden destinos incompatibles, un origen personal con activo corporativo y acceso al propio equipo como destino.
Los repositorios, lectores de dashboard/procesos y proyecciones de recursos reconstruyen los mismos contratos anteriores. Se mantienen las configuraciones múltiples por persona, la adopción de configuraciones legadas y todo el historial.
## Procesos
process_templates y process_template_revisions tienen columnas de nombre, tipo, estado y notas. Sus pasos ordenados se guardan en process_template_instructions y process_template_revision_instructions.
person_processes y person_process_revisions tienen persona, plantilla, nombre congelado de plantilla, nombre del proceso, tipo, estado y notas. person_process_tasks y person_process_revision_tasks conservan UUID de tarea, orden, título, estado, notas y referencia de revisión (tipo, UUID, etiqueta y procedencia).
La referencia de una tarea es información congelada para revisar una situación, no una asignación viva. No lleva FK al elemento revisado: debe conservarse aunque ese elemento se retire o elimine. Sí existen claves foráneas entre procesos, personas, plantillas, revisiones y sus tablas dependientes. Las instrucciones originales y tareas históricas no se reemplazan por una plantilla modificada.
## Sesiones
http_sessions conserva sid, expiración y fechas de creación/actualización; añade columnas de usuario, versión de autenticación, token CSRF y opciones de cookie. La columna data desaparece. El almacén propio SequelizeSessionStore sustituye connect-session-sequelize, que se retira de las dependencias.
Se conservan duración original y expiración de la cookie, opciones booleanas, autenticación y protección CSRF. touch renueva la cookie sin sobrescribir autenticación o CSRF de otra petición. Las sesiones vencidas no se recuperan y se purgan al guardar sesiones; la expiración tiene índice. No se necesita temporizador de fondo. La migración rechaza atributos de sesión desconocidos en lugar de descartarlos.
## Auditoría final
La auditoría de information_schema en desarrollo no encuentra tipos JSON, restricciones JSON_VALID ni columnas values, specification, characteristics, procedure u otros documentos serializados. Las columnas instructions de intervenciones son texto de instrucciones de un paso; data en fotos/logotipos es binario.
El código de producción no declara DataTypes.JSON. Los lectores JSON restantes corresponden a migraciones del origen anterior; las escrituras JSON de pruebas reconstruyen ese origen exclusivamente en metis_desk_test. El uso de JSON en HTTP, traducciones, logs y fixtures no es persistencia de documentos de negocio.
## Verificación de la revisión anterior ## Verificación de la revisión anterior
@ -101,3 +149,12 @@ El typecheck global sigue encontrando el problema anterior de `locale` en `packa
- La prueba de integración reconstruye el origen JSON exclusivamente en `metis_desk_test`, rechaza documentos y referencias incoherentes, conserva las instantáneas y contadores, comprueba restricciones de destinos y repite la migración después de simular un corte entre raíz y revisiones. También se ha ejercitado el arranque desde una base de pruebas vacía. - La prueba de integración reconstruye el origen JSON exclusivamente en `metis_desk_test`, rechaza documentos y referencias incoherentes, conserva las instantáneas y contadores, comprueba restricciones de destinos y repite la migración después de simular un corte entre raíz y revisiones. También se ha ejercitado el arranque desde una base de pruebas vacía.
- Base de desarrollo migrada e inicialización verificada. Auditoría del esquema: desaparecen las columnas `values`/`procedure` de los seis registros principales y existen las diez tablas relacionadas. Copia previa: `/home/david/backups/metis-desk/20261010-133639-tickets-intervenciones.sql`. - Base de desarrollo migrada e inicialización verificada. Auditoría del esquema: desaparecen las columnas `values`/`procedure` de los seis registros principales y existen las diez tablas relacionadas. Copia previa: `/home/david/backups/metis-desk/20261010-133639-tickets-intervenciones.sql`.
- Los chequeos globales mantienen el error previo de `locale` en el calendario de shadcn/ui y errores de lint/formato en otros ficheros. No se modifica el código vendor ni cambios de interfaz realizados simultáneamente. - Los chequeos globales mantienen el error previo de `locale` en el calendario de shadcn/ui y errores de lint/formato en otros ficheros. No se modifica el código vendor ni cambios de interfaz realizados simultáneamente.
## Verificación de cuentas, correo, accesos remotos, procesos y sesiones
- Suite completa: 277 pruebas superadas; 3 pruebas de espaciado superadas.
- Typecheck de servidor, identidad, compañías y recursos: correcto. Build: correcto.
- Biome y ESLint sobre todos los ficheros modificados: correctos, sin avisos.
- Integración HTTP/MariaDB desde una base de pruebas vacía: correcta. Se verifica transferencia del origen legado, rechazo de datos inválidos, conservación de valores actuales e históricos, UUID, orden, duración de cookies, CSRF, restricciones SQL e idempotencia, incluido un corte entre raíz e historial.
- Base de desarrollo migrada, inicialización verificada y auditoría de esquema sin columnas JSON ni documentos serializados. Copia previa: /home/david/backups/metis-desk/20261010-140951-normalizacion-restante.sql.
- El typecheck global mantiene el error previo de locale en el calendario vendor; lint/check global mantienen problemas de código y formato ajenos a estos cambios. No se modifica código vendor.

View File

@ -18,6 +18,15 @@ import { AccessValues } from "../../domain/access/access-values.vo";
import { AccessWorkspace } from "../../domain/access/access-workspace.aggregate"; import { AccessWorkspace } from "../../domain/access/access-workspace.aggregate";
import type { Person } from "../../domain/person.entity"; import type { Person } from "../../domain/person.entity";
import {
type AccessItemRow,
type AccessRevisionRow,
normalizeAccess,
readAccessItems,
readAccessRevisions,
writeAccessRow,
} from "./access-relational";
class WorkspaceModel extends Model< class WorkspaceModel extends Model<
InferAttributes<WorkspaceModel>, InferAttributes<WorkspaceModel>,
InferCreationAttributes<WorkspaceModel> InferCreationAttributes<WorkspaceModel>
@ -27,25 +36,6 @@ class WorkspaceModel extends Model<
declare scopeKey: string; declare scopeKey: string;
} }
class ItemModel extends Model<InferAttributes<ItemModel>, InferCreationAttributes<ItemModel>> {
declare id: string;
declare workspaceId: string;
declare kind: string;
declare values: unknown;
}
class RevisionModel extends Model<
InferAttributes<RevisionModel>,
InferCreationAttributes<RevisionModel>
> {
declare id: string;
declare itemId: string;
declare sequence: number;
declare actorId: string;
declare recordedAt: string;
declare values: unknown;
}
class ReferenceModel extends Model< class ReferenceModel extends Model<
InferAttributes<ReferenceModel>, InferAttributes<ReferenceModel>,
InferCreationAttributes<ReferenceModel> InferCreationAttributes<ReferenceModel>
@ -93,7 +83,6 @@ export async function migrateAccess(database: Sequelize) {
id: idColumn, id: idColumn,
workspaceId: reference("access_workspaces"), workspaceId: reference("access_workspaces"),
kind: { type: DataTypes.STRING(30), allowNull: false }, kind: { type: DataTypes.STRING(30), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
}); });
} }
if (!exists("access_revisions")) { if (!exists("access_revisions")) {
@ -103,7 +92,6 @@ export async function migrateAccess(database: Sequelize) {
sequence: { type: DataTypes.INTEGER, allowNull: false }, sequence: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"), actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false }, recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
}); });
} }
if (!exists("access_references")) { if (!exists("access_references")) {
@ -141,26 +129,7 @@ export async function migrateAccess(database: Sequelize) {
}, },
{ sequelize: database, tableName: "access_workspaces", timestamps: false } { sequelize: database, tableName: "access_workspaces", timestamps: false }
); );
ItemModel.init( await normalizeAccess(database);
{
id: idColumn,
workspaceId: { type: DataTypes.UUID, allowNull: false },
kind: { type: DataTypes.STRING(30), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
},
{ sequelize: database, tableName: "access_items", timestamps: false }
);
RevisionModel.init(
{
id: idColumn,
itemId: { type: DataTypes.UUID, allowNull: false },
sequence: { type: DataTypes.INTEGER, allowNull: false },
actorId: { type: DataTypes.UUID, allowNull: false },
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
},
{ sequelize: database, tableName: "access_revisions", timestamps: false }
);
ReferenceModel.init( ReferenceModel.init(
{ {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
@ -177,7 +146,9 @@ export async function migrateAccess(database: Sequelize) {
{ sequelize: database, tableName: "access_person_references", timestamps: false } { sequelize: database, tableName: "access_person_references", timestamps: false }
); );
// Completar referencias de registros ya existentes sin reescribir sus valores ni su historial. // Completar referencias de registros ya existentes sin reescribir sus valores ni su historial.
for (const item of await ItemModel.findAll()) { for (const item of await database.transaction((transaction) =>
readAccessItems(database, transaction)
)) {
const value = domainValues(item.values).data; const value = domainValues(item.values).data;
if ("personId" in value) { if ("personId" in value) {
@ -219,7 +190,11 @@ function domainValues(value: unknown) {
} }
export class AccessPersistenceMapper { export class AccessPersistenceMapper {
toDomain(root: WorkspaceModel, rows: readonly ItemModel[], revisions: readonly RevisionModel[]) { toDomain(
root: WorkspaceModel,
rows: readonly AccessItemRow[],
revisions: readonly AccessRevisionRow[]
) {
const items = rows.map((row) => { const items = rows.map((row) => {
const values = domainValues(row.values); const values = domainValues(row.values);
const history = revisions const history = revisions
@ -343,13 +318,12 @@ export class SequelizeAccessRepository implements IAccessRepository {
root = created; root = created;
} }
const roots = company ? [global, root] : await WorkspaceModel.findAll({ transaction }); const roots = company ? [global, root] : await WorkspaceModel.findAll({ transaction });
const rows = await ItemModel.findAll({ const rows = (await readAccessItems(this.database, transaction)).filter((row) =>
where: { workspaceId: roots.map((item) => item.id) }, roots.some((root) => root.id === row.workspaceId)
transaction, );
}); const revisions = (await readAccessRevisions(this.database, transaction)).filter((revision) =>
const revisions = rows.length rows.some((row) => row.id === revision.itemId)
? await RevisionModel.findAll({ where: { itemId: rows.map((item) => item.id) }, transaction }) );
: [];
const workspace = this.mapper.toDomain( const workspace = this.mapper.toDomain(
root, root,
rows.filter((row) => row.workspaceId === root.id), rows.filter((row) => row.workspaceId === root.id),
@ -419,11 +393,20 @@ export class SequelizeAccessRepository implements IAccessRepository {
); );
for (const record of records) { for (const record of records) {
await ItemModel.upsert(record.item, { transaction }); await writeAccessRow(
this.database,
record.item,
transaction,
previousWorkspace.items.some((item) => item.id.toPrimitive() === record.item.id)
? "update"
: "insert"
);
const revisions = record.revisions.filter((revision) => !previous.has(revision.id)); const revisions = record.revisions.filter((revision) => !previous.has(revision.id));
if (revisions.length) { if (revisions.length) {
await RevisionModel.bulkCreate(revisions, { transaction }); for (const revision of revisions) {
await writeAccessRow(this.database, revision, transaction, "insert");
}
} }
const personId = Object.entries(record.item.values).find(([key]) => key === "personId")?.[1]; const personId = Object.entries(record.item.values).find(([key]) => key === "personId")?.[1];

View File

@ -0,0 +1,409 @@
import { isDeepStrictEqual } from "node:util";
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import {
AccessDefinitionSchema,
type AccessKind,
AccessKindSchema,
} from "../../domain/access/access-definition";
import { supportIdentifier, writeSupportScalars } from "./support-record-relational";
const ref = (model = "access_items", allowNull = false): ModelAttributeColumnOptions => ({
type: DataTypes.UUID,
allowNull,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
const named = {
name: { type: DataTypes.STRING(100), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
};
const note = { type: DataTypes.TEXT, allowNull: true };
const scope = { systemId: ref(), environmentId: ref("access_items", true) };
const period = {
startDate: { type: DataTypes.STRING(10), allowNull: false },
endDate: { type: DataTypes.STRING(10), allowNull: true },
};
const configuration = { type: DataTypes.STRING(20), allowNull: false };
export const accessColumns: Record<AccessKind, Record<string, ModelAttributeColumnOptions>> = {
system: named,
resource_type: named,
permission: { ...named, resourceTypeId: ref(), description: note },
template: { ...named, description: note, systemId: ref("access_items", true) },
environment: { ...named, systemId: ref() },
account: {
...scope,
login: { type: DataTypes.STRING(255), allowNull: false },
usage: { type: DataTypes.STRING(16), allowNull: false },
active: named.active,
expiresOn: { type: DataTypes.STRING(10), allowNull: true },
notes: note,
},
account_person: {
accountId: ref(),
personId: ref("company_contacts"),
...period,
configuration: { ...configuration, allowNull: true },
},
group: {
...scope,
...named,
identifier: { type: DataTypes.STRING(255), allowNull: false },
description: note,
templateId: ref("access_items", true),
},
membership: { accountId: ref(), groupId: ref(), ...period, configuration },
resource: {
...named,
resourceTypeId: ref(),
systemId: ref("access_items", true),
environmentId: ref("access_items", true),
reference: { type: DataTypes.STRING(255), allowNull: true },
notes: note,
},
grant: {
resourceId: ref(),
permissionId: ref(),
accountId: ref("access_items", true),
groupId: ref("access_items", true),
...period,
configuration,
},
need: { personId: ref("company_contacts"), resourceId: ref(), ...period, notes: note },
coverage: {
needId: ref(),
grantId: ref(),
accountPersonId: ref(),
membershipId: ref("access_items", true),
...period,
},
};
export function accessValueTable(kind: AccessKind, history = false) {
return `access_${kind}_${history ? "revision_" : ""}values`;
}
const itemSchema = z.object({
id: z.uuid(),
workspaceId: z.uuid(),
kind: AccessKindSchema,
values: AccessDefinitionSchema,
});
const revisionSchema = z.object({
id: z.uuid(),
itemId: z.uuid(),
sequence: z.number().int().nonnegative(),
actorId: z.uuid(),
recordedAt: z.iso.datetime(),
values: AccessDefinitionSchema,
});
export type AccessItemRow = z.infer<typeof itemSchema>;
export type AccessRevisionRow = z.infer<typeof revisionSchema>;
function decoded(row: Record<string, unknown>, permissionIds: string[]) {
if (row.values !== undefined) {
return AccessDefinitionSchema.parse(
typeof row.values === "string" ? JSON.parse(row.values) : row.values
);
}
const kind = AccessKindSchema.parse(row.kind);
const values = Object.fromEntries(
Object.keys(accessColumns[kind]).map((key) => [
key,
key === "active"
? z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(row[key])
: row[key],
])
);
return AccessDefinitionSchema.parse({
kind,
...values,
...(kind === "need" ? { permissionIds } : {}),
});
}
export async function readAccessItems(
database: Sequelize,
transaction: Transaction,
companyId?: string
) {
const rows = await database.query<Record<string, unknown>>(
`SELECT i.id,i.workspaceId,v.* FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId LEFT JOIN access_current_values v ON v.ownerId=i.id AND v.kind=i.kind${companyId ? " WHERE w.companyId=:companyId OR w.companyId IS NULL" : ""}`,
{ replacements: { companyId: companyId ?? null }, transaction, type: QueryTypes.SELECT }
);
const permissions = await database.query<{
ownerId: string;
permissionId: string;
position: number;
}>("SELECT * FROM access_need_permissions ORDER BY ownerId,position", {
transaction,
type: QueryTypes.SELECT,
});
return rows.map((row) =>
itemSchema.parse({ ...row, values: decoded(row, permissionIds(permissions, row.id)) })
);
}
export async function readAccessRevisions(database: Sequelize, transaction: Transaction) {
const rows = await database.query<Record<string, unknown>>(
"SELECT r.id,r.itemId,r.sequence,r.actorId,r.recordedAt,v.* FROM access_revisions r JOIN access_items i ON i.id=r.itemId LEFT JOIN access_historical_values v ON v.ownerId=r.id AND v.kind=i.kind",
{ transaction, type: QueryTypes.SELECT }
);
const permissions = await database.query<{
ownerId: string;
permissionId: string;
position: number;
}>("SELECT * FROM access_need_revision_permissions ORDER BY ownerId,position", {
transaction,
type: QueryTypes.SELECT,
});
return rows.map((row) =>
revisionSchema.parse({ ...row, values: decoded(row, permissionIds(permissions, row.id)) })
);
}
function permissionIds(
rows: readonly { ownerId: string; permissionId: string; position: number }[],
id: unknown
) {
const selected = rows.filter((row) => row.ownerId === id);
if (selected.some((row, position) => row.position !== position)) {
throw new Error("Orden de permisos de necesidad inconsistente.");
}
return selected.map((row) => row.permissionId);
}
export async function writeAccessRow(
database: Sequelize,
row: AccessItemRow | AccessRevisionRow,
transaction: Transaction,
operation: "insert" | "update"
) {
const { values, ...metadata } = row;
const input = AccessDefinitionSchema.parse(values);
const history = "itemId" in row;
await writeSupportScalars(
database,
history ? "access_revisions" : "access_items",
metadata,
transaction,
operation
);
await writeAccessValues(database, row.id, input, history, transaction);
}
async function writeAccessValues(
database: Sequelize,
id: string,
input: z.infer<typeof AccessDefinitionSchema>,
history: boolean,
transaction: Transaction
) {
const { kind, ...values } = input;
const { permissionIds: _permissions, ...columns } = z
.record(z.string(), z.unknown())
.parse(values);
const table = accessValueTable(kind, history);
await database.query(`DELETE FROM ${supportIdentifier(table)} WHERE ownerId=:id`, {
replacements: { id },
transaction,
});
await writeSupportScalars(database, table, { ownerId: id, ...columns }, transaction, "insert");
if (kind === "need") {
const links = history ? "access_need_revision_permissions" : "access_need_permissions";
await database.query(`DELETE FROM ${supportIdentifier(links)} WHERE ownerId=:id`, {
replacements: { id },
transaction,
});
for (const [position, permissionId] of input.permissionIds.entries()) {
await writeSupportScalars(
database,
links,
{ ownerId: id, permissionId, position },
transaction,
"insert"
);
}
}
}
async function prepareAccessValues(database: Sequelize) {
const query = database.getQueryInterface();
const existing = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
);
for (const kind of AccessKindSchema.options) {
for (const history of [false, true]) {
const table = accessValueTable(kind, history);
if (!existing.some((row) => row.name === table)) {
await query.createTable(table, {
ownerId: {
...ref(history ? "access_revisions" : "access_items"),
primaryKey: true,
onDelete: "CASCADE",
},
...accessColumns[kind],
});
}
}
}
await prepareNeedPermissions(database, existing);
await createAccessViews(database);
}
async function prepareNeedPermissions(database: Sequelize, existing: readonly { name: string }[]) {
const query = database.getQueryInterface();
for (const history of [false, true]) {
const table = history ? "access_need_revision_permissions" : "access_need_permissions";
if (!existing.some((row) => row.name === table)) {
await query.createTable(table, {
ownerId: {
...ref(accessValueTable("need", history)),
references: { model: accessValueTable("need", history), key: "ownerId" },
primaryKey: true,
onDelete: "CASCADE",
},
position: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true },
permissionId: ref(),
});
}
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
{ replacements: { table }, type: QueryTypes.SELECT }
);
if (!indexes.some((index) => index.name === `${table}_unique`)) {
await query.addIndex(table, ["ownerId", "permissionId"], {
unique: true,
name: `${table}_unique`,
});
}
}
}
async function createAccessViews(database: Sequelize) {
// Vistas de lectura: columnas SQL, sin reconstruir documentos JSON en la base.
const allColumns = [...new Set(Object.values(accessColumns).flatMap(Object.keys))];
for (const history of [false, true]) {
const selections = AccessKindSchema.options.map(
(kind) =>
`SELECT ownerId,'${kind}' AS kind,${allColumns.map((name) => (name in accessColumns[kind] ? supportIdentifier(name) : `NULL AS ${supportIdentifier(name)}`)).join(",")} FROM ${supportIdentifier(accessValueTable(kind, history))}`
);
await database.query(
`CREATE OR REPLACE VIEW ${history ? "access_historical_values" : "access_current_values"} AS ${selections.join(" UNION ALL ")}`
);
}
}
export async function normalizeAccess(database: Sequelize) {
await prepareAccessValues(database);
const query = database.getQueryInterface();
const original = await query.describeTable("access_items");
const history = await query.describeTable("access_revisions");
if (!(original.values || history.values)) {
return;
}
await database.transaction(async (transaction) => {
const items = original.values
? (
await database.query<Record<string, unknown>>("SELECT * FROM access_items", {
type: QueryTypes.SELECT,
transaction,
})
).map((row) => itemSchema.parse({ ...row, values: decoded(row, []) }))
: await readAccessItems(database, transaction);
const revisions = history.values
? (
await database.query<Record<string, unknown>>("SELECT * FROM access_revisions", {
type: QueryTypes.SELECT,
transaction,
})
).map((row) => revisionSchema.parse({ ...row, values: decoded(row, []) }))
: await readAccessRevisions(database, transaction);
for (const item of items) {
const ordered = revisions
.filter((row) => row.itemId === item.id)
.sort((a, b) => a.sequence - b.sequence);
if (
item.kind !== item.values.kind ||
!ordered.length ||
ordered.some(
(row, index) =>
row.sequence !== index ||
row.values.kind !== item.kind ||
row.recordedAt < (ordered[index - 1]?.recordedAt ?? "")
) ||
!isDeepStrictEqual(ordered.at(-1)?.values, item.values)
) {
throw new Error("Historial de acceso inconsistente durante la normalización.");
}
}
// Insertar valores de todos los propietarios antes de validar referencias entre subtipos.
for (const row of [...items, ...revisions]) {
await writeAccessValues(database, row.id, row.values, "itemId" in row, transaction);
}
if (
!(
isDeepStrictEqual(
(await readAccessItems(database, transaction)).sort(byId),
items.sort(byId)
) &&
isDeepStrictEqual(
(await readAccessRevisions(database, transaction)).sort(byId),
revisions.sort(byId)
)
)
) {
throw new Error("Los accesos normalizados no coinciden con el origen.");
}
});
for (const [table, columns] of [
["access_items", original],
["access_revisions", history],
] as const) {
if (columns.values) {
await database.query(`ALTER TABLE ${supportIdentifier(table)} DROP COLUMN \`values\``);
}
}
}
function byId(left: { id: string }, right: { id: string }) {
return left.id.localeCompare(right.id);
}
export function readCompanyAccessItems(
database: Sequelize,
companyId: string,
transaction: Transaction
) {
return readAccessItems(database, transaction, companyId);
}

View File

@ -145,7 +145,22 @@ export const folderSnapshot = {
booleans: [], booleans: [],
} satisfies SnapshotDefinition<ReturnType<typeof FolderMappingInputSchema.parse>>; } satisfies SnapshotDefinition<ReturnType<typeof FolderMappingInputSchema.parse>>;
import {
mailSnapshot,
mailboxSnapshot,
mailboxUseSnapshot,
remoteSnapshot,
} from "./mail-remote-snapshots";
export const configurationSnapshots = new Map<string, SnapshotDefinition<unknown>>([ export const configurationSnapshots = new Map<string, SnapshotDefinition<unknown>>([
["remote_connection_revisions", remoteSnapshot],
["mailbox_use_revisions", mailboxUseSnapshot],
["mailbox_revisions", mailboxSnapshot],
["mail_configuration_revisions", mailSnapshot],
["mail_configurations", mailSnapshot],
["mailboxes", mailboxSnapshot],
["mailbox_uses", mailboxUseSnapshot],
["remote_connections", remoteSnapshot],
["work_profiles", profileSnapshot], ["work_profiles", profileSnapshot],
["work_profile_revisions", profileSnapshot], ["work_profile_revisions", profileSnapshot],
["telework_authorizations", teleworkSnapshot], ["telework_authorizations", teleworkSnapshot],

View File

@ -1,6 +1,10 @@
import type { Sequelize } from "sequelize"; import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize"; import { DataTypes, QueryTypes } from "sequelize";
import { ensureSnapshotConstraint } from "./mail-remote-constraints";
import { mailSnapshot } from "./mail-remote-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateMailConfigurations(database: Sequelize) { export async function migrateMailConfigurations(database: Sequelize) {
const query = database.getQueryInterface(); const query = database.getQueryInterface();
const rows = await database.query<{ name: string }>( const rows = await database.query<{ name: string }>(
@ -19,12 +23,7 @@ export async function migrateMailConfigurations(database: Sequelize) {
await query.createTable("mail_configurations", { await query.createTable("mail_configurations", {
id: { type: DataTypes.UUID, primaryKey: true }, id: { type: DataTypes.UUID, primaryKey: true },
companyId: reference("companies"), companyId: reference("companies"),
personId: { ...reference("company_contacts"), allowNull: true }, ...mailSnapshot.columns,
accountId: { ...reference("access_items"), allowNull: true },
assetId: { ...reference("assets"), allowNull: true },
profileId: { ...reference("work_profiles"), allowNull: true },
mailboxId: { ...reference("mailboxes"), allowNull: true },
values: { type: DataTypes.JSON, allowNull: false },
currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true }, currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true },
}); });
} }
@ -35,7 +34,7 @@ export async function migrateMailConfigurations(database: Sequelize) {
position: { type: DataTypes.INTEGER, allowNull: false }, position: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"), actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false }, recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false }, ...mailSnapshot.columns,
}); });
} }
@ -71,4 +70,17 @@ export async function migrateMailConfigurations(database: Sequelize) {
name: "mail_revision_position", name: "mail_revision_position",
}); });
} }
await normalizeScalarSnapshots(
database,
["mail_configurations", "mail_configuration_revisions"],
mailSnapshot
);
for (const table of ["mail_configurations", "mail_configuration_revisions"]) {
await ensureSnapshotConstraint(
database,
table,
`${table}_snapshot_shape`,
"(snapshotKind='legacy' AND personId IS NOT NULL AND accountId IS NOT NULL AND assetId IS NOT NULL AND profileId IS NULL AND mailboxId IS NULL) OR (snapshotKind='profile' AND profileId IS NOT NULL AND mailboxId IS NOT NULL AND personId IS NULL AND accountId IS NULL AND assetId IS NULL)"
);
}
} }

View File

@ -20,6 +20,9 @@ export function persistedJson(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value; return typeof value === "string" ? JSON.parse(value) : value;
} }
import { mailSnapshot } from "./mail-remote-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
export type MailRow = { export type MailRow = {
id: string; id: string;
companyId: string; companyId: string;
@ -71,10 +74,10 @@ function mailReferences(values: MailConfigurationValues) {
export class MailConfigurationPersistenceMapper { export class MailConfigurationPersistenceMapper {
toDomain(row: MailRow, revisions: readonly MailRevisionRow[]) { toDomain(row: MailRow, revisions: readonly MailRevisionRow[]) {
const values = valuesOf(row.values); const values = valuesOf(snapshotValues(mailSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position); const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, position) => { const history = ordered.map((revision, position) => {
const revisionValues = valuesOf(revision.values); const revisionValues = valuesOf(snapshotValues(mailSnapshot, revision));
if ( if (
revision.position !== position || revision.position !== position ||
@ -134,7 +137,7 @@ export class MailConfigurationPersistenceMapper {
id: item.id.toPrimitive(), id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(), companyId: item.companyId.toPrimitive(),
...mailReferences(item.values), ...mailReferences(item.values),
values: JSON.stringify(values), values: values,
}, },
revisions: item.history.map((revision, position) => ({ revisions: item.history.map((revision, position) => ({
id: revision.id.toPrimitive(), id: revision.id.toPrimitive(),
@ -142,7 +145,7 @@ export class MailConfigurationPersistenceMapper {
position, position,
actorId: revision.actorId.toPrimitive(), actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt, recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()), values: revision.values.toPrimitive(),
})), })),
}; };
} }

View File

@ -0,0 +1,23 @@
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { supportIdentifier } from "./support-record-relational";
/** Valida las alternativas de las relaciones polimórficas también en MariaDB. */
export async function ensureSnapshotConstraint(
database: Sequelize,
table: string,
name: string,
expression: string
) {
const existing = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=:table AND CONSTRAINT_NAME=:name",
{ replacements: { table, name }, type: QueryTypes.SELECT }
);
if (!existing.length) {
await database.query(
`ALTER TABLE ${supportIdentifier(table)} ADD CONSTRAINT ${supportIdentifier(name)} CHECK (${expression})`
);
}
}

View File

@ -0,0 +1,122 @@
import { DataTypes } from "sequelize";
import { MailSnapshotSchema } from "../../domain/mail-configuration-values";
import { MailboxInputSchema, MailboxUseInputSchema } from "../../domain/mailbox-values";
import { RemoteConnectionInputSchema } from "../../domain/remote-connection-values";
import type { SnapshotDefinition } from "./scalar-snapshot-normalization";
const reference = (model: string, allowNull = true) => ({
type: DataTypes.UUID,
allowNull,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
const note = { type: DataTypes.TEXT, allowNull: true };
const state = { type: DataTypes.STRING(20), allowNull: false };
export const mailboxSnapshot = {
schema: MailboxInputSchema,
columns: {
address: { type: DataTypes.STRING(254), allowNull: false },
kind: { type: DataTypes.STRING(20), allowNull: false },
context: { type: DataTypes.STRING(255), allowNull: true },
accountId: reference("access_items"),
active: { type: DataTypes.BOOLEAN, allowNull: false },
notes: note,
},
booleans: ["active"],
} satisfies SnapshotDefinition<ReturnType<typeof MailboxInputSchema.parse>>;
export const mailboxUseSnapshot = {
schema: MailboxUseInputSchema,
columns: {
mailboxId: reference("mailboxes", false),
personId: reference("company_contacts", false),
startDate: { type: DataTypes.STRING(10), allowNull: false },
endDate: { type: DataTypes.STRING(10), allowNull: true },
},
} satisfies SnapshotDefinition<ReturnType<typeof MailboxUseInputSchema.parse>>;
export const mailSnapshot = {
schema: MailSnapshotSchema,
columns: {
snapshotKind: { type: DataTypes.STRING(10), allowNull: false },
personId: reference("company_contacts"),
accountId: reference("access_items"),
assetId: reference("assets"),
profileId: reference("work_profiles"),
mailboxId: reference("mailboxes"),
address: { type: DataTypes.STRING(254), allowNull: false },
method: { type: DataTypes.STRING(16), allowNull: false },
state,
notes: note,
},
fromColumns(row: Record<string, unknown>) {
const { snapshotKind, personId, accountId, assetId, profileId, mailboxId, ...common } = row;
if (snapshotKind === "legacy") {
return { ...common, personId, accountId, assetId };
}
if (snapshotKind !== "profile") {
throw new Error("Tipo de instantánea de correo inconsistente.");
}
return { ...common, profileId, mailboxId };
},
toColumns(source: unknown) {
const input = MailSnapshotSchema.parse(source);
return {
personId: null,
accountId: null,
assetId: null,
profileId: null,
mailboxId: null,
snapshotKind: "profileId" in input ? "profile" : "legacy",
...input,
};
},
} satisfies SnapshotDefinition<ReturnType<typeof MailSnapshotSchema.parse>>;
export const remoteSnapshot = {
schema: RemoteConnectionInputSchema,
columns: {
personId: reference("company_contacts", false),
sourceKind: { type: DataTypes.STRING(20), allowNull: false },
sourceAssetId: reference("assets"),
targetKind: { type: DataTypes.STRING(16), allowNull: false },
targetAssetId: reference("assets"),
targetResourceId: reference("resources"),
siteId: reference("company_sites"),
method: { type: DataTypes.STRING(80), allowNull: false },
requiresVpn: { type: DataTypes.BOOLEAN, allowNull: false },
state,
notes: note,
},
booleans: ["requiresVpn"],
fromColumns(row: Record<string, unknown>) {
const { targetAssetId, targetResourceId, ...values } = row;
return {
...values,
targetId:
row.targetKind === "asset"
? targetAssetId
: row.targetKind === "resource"
? targetResourceId
: null,
};
},
toColumns(source: unknown) {
const input = RemoteConnectionInputSchema.parse(source);
const { targetId, ...values } = input;
return {
...values,
targetAssetId: input.targetKind === "asset" ? targetId : null,
targetResourceId: input.targetKind === "resource" ? targetId : null,
};
},
} satisfies SnapshotDefinition<ReturnType<typeof RemoteConnectionInputSchema.parse>>;

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize"; import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize"; import { DataTypes, QueryTypes } from "sequelize";
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateMailboxes(database: Sequelize) { export async function migrateMailboxes(database: Sequelize) {
const query = database.getQueryInterface(); const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>( const tables = await database.query<{ name: string }>(
@ -18,7 +21,6 @@ export async function migrateMailboxes(database: Sequelize) {
const root = { const root = {
id: { type: DataTypes.UUID, primaryKey: true }, id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"), companyId: fk("companies"),
values: { type: DataTypes.JSON, allowNull: false },
}; };
for (const [table, columns] of [ for (const [table, columns] of [
@ -26,18 +28,27 @@ export async function migrateMailboxes(database: Sequelize) {
"mailboxes", "mailboxes",
{ {
...root, ...root,
...mailboxSnapshot.columns,
accountId: fk("access_items", true), accountId: fk("access_items", true),
addressKey: { type: DataTypes.STRING(300), allowNull: false, unique: true }, addressKey: { type: DataTypes.STRING(300), allowNull: false, unique: true },
}, },
], ],
["mailbox_uses", { ...root, mailboxId: fk("mailboxes"), personId: fk("company_contacts") }], [
"mailbox_uses",
{
...root,
...mailboxUseSnapshot.columns,
mailboxId: fk("mailboxes"),
personId: fk("company_contacts"),
},
],
[ [
"mailbox_revisions", "mailbox_revisions",
{ {
id: root.id, id: root.id,
itemId: fk("mailboxes"), itemId: fk("mailboxes"),
position: { type: DataTypes.INTEGER, allowNull: false }, position: { type: DataTypes.INTEGER, allowNull: false },
values: root.values, ...mailboxSnapshot.columns,
actorId: fk("users"), actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false }, recordedAt: { type: DataTypes.STRING(24), allowNull: false },
}, },
@ -48,7 +59,7 @@ export async function migrateMailboxes(database: Sequelize) {
id: root.id, id: root.id,
itemId: fk("mailbox_uses"), itemId: fk("mailbox_uses"),
position: { type: DataTypes.INTEGER, allowNull: false }, position: { type: DataTypes.INTEGER, allowNull: false },
values: root.values, ...mailboxUseSnapshot.columns,
actorId: fk("users"), actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false }, recordedAt: { type: DataTypes.STRING(24), allowNull: false },
}, },
@ -71,4 +82,10 @@ export async function migrateMailboxes(database: Sequelize) {
}); });
} }
} }
await normalizeScalarSnapshots(database, ["mailboxes", "mailbox_revisions"], mailboxSnapshot);
await normalizeScalarSnapshots(
database,
["mailbox_uses", "mailbox_use_revisions"],
mailboxUseSnapshot
);
} }

View File

@ -9,6 +9,9 @@ import { MailboxInputSchema, MailboxUseInputSchema } from "../../domain/mailbox-
import { MailboxUseValues } from "../../domain/value-objects/mailbox-use-values.vo"; import { MailboxUseValues } from "../../domain/value-objects/mailbox-use-values.vo";
import { MailboxValues } from "../../domain/value-objects/mailbox-values.vo"; import { MailboxValues } from "../../domain/value-objects/mailbox-values.vo";
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
export function mailboxIdentity(raw: string) { export function mailboxIdentity(raw: string) {
const value = UniqueID.create(raw); const value = UniqueID.create(raw);
@ -86,11 +89,11 @@ function mailboxUseValues(raw: unknown) {
export class MailboxPersistenceMapper { export class MailboxPersistenceMapper {
toDomain(row: MailboxRow, revisions: readonly MailboxRevisionRow[]) { toDomain(row: MailboxRow, revisions: readonly MailboxRevisionRow[]) {
const values = mailboxValues(row.values); const values = mailboxValues(snapshotValues(mailboxSnapshot, row));
const history = ordered(revisions).map((revision) => const history = ordered(revisions).map((revision) =>
MailboxRevision.rehydrate( MailboxRevision.rehydrate(
{ {
values: mailboxValues(revision.values), values: mailboxValues(snapshotValues(mailboxSnapshot, revision)),
actorId: mailboxIdentity(revision.actorId), actorId: mailboxIdentity(revision.actorId),
recordedAt: revision.recordedAt, recordedAt: revision.recordedAt,
}, },
@ -114,11 +117,11 @@ export class MailboxPersistenceMapper {
} }
toUseDomain(row: MailboxUseRow, revisions: readonly MailboxRevisionRow[]) { toUseDomain(row: MailboxUseRow, revisions: readonly MailboxRevisionRow[]) {
const values = mailboxUseValues(row.values); const values = mailboxUseValues(snapshotValues(mailboxUseSnapshot, row));
const history = ordered(revisions).map((revision) => const history = ordered(revisions).map((revision) =>
MailboxUseRevision.rehydrate( MailboxUseRevision.rehydrate(
{ {
values: mailboxUseValues(revision.values), values: mailboxUseValues(snapshotValues(mailboxUseSnapshot, revision)),
actorId: mailboxIdentity(revision.actorId), actorId: mailboxIdentity(revision.actorId),
recordedAt: revision.recordedAt, recordedAt: revision.recordedAt,
}, },
@ -152,12 +155,12 @@ export class MailboxPersistenceMapper {
return { return {
id: item.id.toPrimitive(), id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(), companyId: item.companyId.toPrimitive(),
values: JSON.stringify(item.values.toPrimitive()), values: item.values.toPrimitive(),
revisions: item.history.map((revision, position) => ({ revisions: item.history.map((revision, position) => ({
id: revision.id.toPrimitive(), id: revision.id.toPrimitive(),
itemId: item.id.toPrimitive(), itemId: item.id.toPrimitive(),
position, position,
values: JSON.stringify(revision.values.toPrimitive()), values: revision.values.toPrimitive(),
actorId: revision.actorId.toPrimitive(), actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt, recordedAt: revision.recordedAt,
})), })),

View File

@ -5,6 +5,12 @@ import { DataTypes, QueryTypes } from "sequelize";
import { ProcessTemplate } from "../../domain/process-template.aggregate"; import { ProcessTemplate } from "../../domain/process-template.aggregate";
import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values.vo"; import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values.vo";
import {
normalizeProcesses,
processColumns,
processTemplateColumns,
writeProcessRow,
} from "./process-relational";
import { import {
ProcessTemplatePersistenceMapper, ProcessTemplatePersistenceMapper,
processTemplateNameKey, processTemplateNameKey,
@ -26,12 +32,10 @@ export async function migrateProcesses(database: Sequelize) {
const root = { const root = {
id: { type: DataTypes.UUID, primaryKey: true }, id: { type: DataTypes.UUID, primaryKey: true },
values: { type: DataTypes.JSON, allowNull: false },
}; };
const revision = { const revision = {
id: root.id, id: root.id,
position: { type: DataTypes.INTEGER, allowNull: false }, position: { type: DataTypes.INTEGER, allowNull: false },
values: root.values,
actorId: fk("users", true), actorId: fk("users", true),
recordedAt: { type: DataTypes.STRING(24), allowNull: false }, recordedAt: { type: DataTypes.STRING(24), allowNull: false },
}; };
@ -42,22 +46,30 @@ export async function migrateProcesses(database: Sequelize) {
"process_templates", "process_templates",
{ {
...root, ...root,
...processTemplateColumns,
nameKey: { type: DataTypes.STRING(160), allowNull: false, unique: true }, nameKey: { type: DataTypes.STRING(160), allowNull: false, unique: true },
builtinKey: { type: DataTypes.STRING(30), allowNull: true, unique: true }, builtinKey: { type: DataTypes.STRING(30), allowNull: true, unique: true },
}, },
], ],
["process_template_revisions", { ...revision, itemId: fk("process_templates") }], [
"process_template_revisions",
{ ...revision, ...processTemplateColumns, itemId: fk("process_templates") },
],
[ [
"person_processes", "person_processes",
{ {
...root, ...root,
...processColumns,
companyId: fk("companies"), companyId: fk("companies"),
personId: fk("company_contacts"), personId: fk("company_contacts"),
templateId: fk("process_templates"), templateId: fk("process_templates"),
currentKey: { type: DataTypes.STRING(100), allowNull: true, unique: true }, currentKey: { type: DataTypes.STRING(100), allowNull: true, unique: true },
}, },
], ],
["person_process_revisions", { ...revision, itemId: fk("person_processes") }], [
"person_process_revisions",
{ ...revision, ...processColumns, itemId: fk("person_processes") },
],
] as const) { ] as const) {
if (!tables.some((t) => t.name === table)) { if (!tables.some((t) => t.name === table)) {
await query.createTable(table, columns); await query.createTable(table, columns);
@ -77,6 +89,7 @@ export async function migrateProcesses(database: Sequelize) {
} }
} }
await database.query("INSERT IGNORE INTO process_catalog_lock (id) VALUES (1)"); await database.query("INSERT IGNORE INTO process_catalog_lock (id) VALUES (1)");
await normalizeProcesses(database);
const defaults = [ const defaults = [
{ {
key: "onboarding", key: "onboarding",
@ -153,14 +166,20 @@ export async function migrateProcesses(database: Sequelize) {
} }
const record = new ProcessTemplatePersistenceMapper().toPersistence(template.data); const record = new ProcessTemplatePersistenceMapper().toPersistence(template.data);
await database.query( await writeProcessRow(
"INSERT INTO process_templates (id,nameKey,builtinKey,`values`) VALUES (:id,:nameKey,:builtinKey,:values)", database,
{ replacements: { ...record.root, builtinKey: item.key }, transaction } "process_templates",
{ ...record.root, builtinKey: item.key },
transaction,
"insert"
); );
for (const revision of record.revisions) { for (const revision of record.revisions) {
await database.query( await writeProcessRow(
"INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)", database,
{ replacements: revision, transaction } "process_template_revisions",
revision,
transaction,
"insert"
); );
} }
} }
@ -169,7 +188,7 @@ export async function migrateProcesses(database: Sequelize) {
async function normalizeTemplateNameKeys(database: Sequelize, transaction: Transaction) { async function normalizeTemplateNameKeys(database: Sequelize, transaction: Transaction) {
const existingNames = await database.query<{ id: string; nameKey: string; name: string }>( const existingNames = await database.query<{ id: string; nameKey: string; name: string }>(
"SELECT id,nameKey,JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.name')) AS name FROM process_templates", "SELECT id,nameKey,name FROM process_templates",
{ transaction, type: QueryTypes.SELECT } { transaction, type: QueryTypes.SELECT }
); );

View File

@ -27,6 +27,23 @@ export type PersonProcessRevisionRow = {
values: unknown; values: unknown;
}; };
const rowSchema = z.object({
id: z.uuid(),
companyId: z.uuid(),
personId: z.uuid(),
templateId: z.uuid(),
currentKey: z.string().nullable(),
values: z.unknown(),
});
const revisionSchema = z.object({
id: z.uuid(),
itemId: z.uuid(),
position: z.number().int().nonnegative(),
actorId: z.uuid().nullable(),
recordedAt: z.iso.datetime(),
values: z.unknown(),
});
function identity(value: string) { function identity(value: string) {
const parsed = UniqueID.create(value); const parsed = UniqueID.create(value);
@ -85,7 +102,9 @@ function validateTransitions(history: readonly PersonProcessRevision[]) {
} }
export class PersonProcessPersistenceMapper { export class PersonProcessPersistenceMapper {
toDomain(row: PersonProcessRow, revisions: readonly PersonProcessRevisionRow[]) { toDomain(source: unknown, revisionRows: readonly unknown[]) {
const row = rowSchema.parse(source);
const revisions = revisionRows.map((item) => revisionSchema.parse(item));
const values = mapped(row.values); const values = mapped(row.values);
const ordered = [...revisions].sort((a, b) => a.position - b.position); const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((r, index) => { const history = ordered.map((r, index) => {
@ -137,7 +156,7 @@ export class PersonProcessPersistenceMapper {
currentKey: item.values.closed currentKey: item.values.closed
? null ? null
: `${item.companyId.toPrimitive()}:${values.personId}:${values.kind}`, : `${item.companyId.toPrimitive()}:${values.personId}:${values.kind}`,
values: JSON.stringify(values), values: values,
}, },
revisions: item.history.map((r, position) => ({ revisions: item.history.map((r, position) => ({
id: r.id.toPrimitive(), id: r.id.toPrimitive(),
@ -145,7 +164,7 @@ export class PersonProcessPersistenceMapper {
position, position,
actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null, actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null,
recordedAt: r.recordedAt, recordedAt: r.recordedAt,
values: JSON.stringify(r.values.toPrimitive()), values: r.values.toPrimitive(),
})), })),
}; };
} }

View File

@ -0,0 +1,318 @@
import { isDeepStrictEqual } from "node:util";
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { ProcessTemplateInputSchema, ProcessValuesSchema } from "../../domain/process-values";
import { PersonProcessPersistenceMapper } from "./process-persistence.mapper";
import { ProcessTemplatePersistenceMapper } from "./process-template-persistence.mapper";
import { supportIdentifier, writeSupportScalars } from "./support-record-relational";
const name = { type: DataTypes.STRING(160), allowNull: false };
const note = { type: DataTypes.TEXT, allowNull: true };
const kind = { type: DataTypes.STRING(20), allowNull: false };
const reference = (model: string): ModelAttributeColumnOptions => ({
type: DataTypes.UUID,
allowNull: false,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
export const processTemplateColumns = {
name,
kind,
active: { type: DataTypes.BOOLEAN, allowNull: false },
notes: note,
};
export const processColumns = {
personId: reference("company_contacts"),
templateId: reference("process_templates"),
templateName: name,
kind,
name,
state: { type: DataTypes.STRING(20), allowNull: false },
notes: note,
};
export type ProcessTable =
| "process_templates"
| "process_template_revisions"
| "person_processes"
| "person_process_revisions";
function templateTable(table: ProcessTable) {
return table === "process_templates" || table === "process_template_revisions";
}
function columns(table: ProcessTable): Record<string, ModelAttributeColumnOptions> {
return templateTable(table) ? processTemplateColumns : processColumns;
}
function children(table: ProcessTable) {
switch (table) {
case "process_templates":
return "process_template_instructions";
case "process_template_revisions":
return "process_template_revision_instructions";
case "person_processes":
return "person_process_tasks";
case "person_process_revisions":
return "person_process_revision_tasks";
}
}
function input(table: ProcessTable, value: unknown) {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
return templateTable(table)
? ProcessTemplateInputSchema.parse(parsed)
: ProcessValuesSchema.parse(parsed);
}
function task(row: Record<string, unknown>) {
return {
id: row.taskId,
title: row.title,
status: row.status,
notes: row.notes,
reference:
row.referenceKind === null
? null
: {
kind: row.referenceKind,
id: row.referenceId,
label: row.referenceLabel,
...(row.sourceKind === null ? {} : { sourceKind: row.sourceKind }),
},
};
}
export async function readProcessRows(
database: Sequelize,
table: ProcessTable,
transaction: Transaction,
mode: "compatible" | "normalized" = "compatible"
): Promise<Record<string, unknown>[]> {
const rows = await database.query<Record<string, unknown>>(
`SELECT * FROM ${supportIdentifier(table)} ORDER BY id`,
{ type: QueryTypes.SELECT, transaction }
);
const childRows = await database.query<Record<string, unknown>>(
`SELECT * FROM ${supportIdentifier(children(table))} ORDER BY ownerId,position`,
{ type: QueryTypes.SELECT, transaction }
);
return rows.map((row) => {
const metadata = Object.fromEntries(
Object.entries(row).filter(
([key]) =>
!(key in columns(table)) ||
(table === "person_processes" && (key === "personId" || key === "templateId"))
)
);
if (mode === "compatible" && row.values !== undefined) {
return { ...metadata, values: input(table, row.values) };
}
const values = Object.fromEntries(
Object.keys(columns(table)).map((name) => [
name,
name === "active"
? z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(row[name])
: row[name],
])
);
const selected = childRows.filter((child) => child.ownerId === row.id);
if (selected.some((child, position) => child.position !== position)) {
throw new Error("Orden de tareas de proceso inconsistente.");
}
return {
...metadata,
values: input(table, {
...values,
...(templateTable(table)
? { instructions: selected.map((child) => child.title) }
: { tasks: selected.map(task) }),
}),
};
});
}
export async function writeProcessRow(
database: Sequelize,
table: ProcessTable,
row: Record<string, unknown>,
transaction: Transaction,
operation: "insert" | "update"
) {
const values = input(table, row.values);
const { values: _values, ...metadata } = row;
const {
instructions: _instructions,
tasks: _tasks,
...scalars
} = z.record(z.string(), z.unknown()).parse(values);
await writeSupportScalars(database, table, { ...metadata, ...scalars }, transaction, operation);
const ownerId = z.uuid().parse(row.id);
const childTable = children(table);
await database.query(`DELETE FROM ${supportIdentifier(childTable)} WHERE ownerId=:ownerId`, {
replacements: { ownerId },
transaction,
});
if ("instructions" in values) {
for (const [position, title] of values.instructions.entries()) {
await writeSupportScalars(
database,
childTable,
{ ownerId, position, title },
transaction,
"insert"
);
}
} else {
for (const [position, item] of values.tasks.entries()) {
await writeSupportScalars(
database,
childTable,
{
ownerId,
position,
taskId: item.id,
title: item.title,
status: item.status,
notes: item.notes,
referenceKind: item.reference?.kind ?? null,
referenceId: item.reference?.id ?? null,
referenceLabel: item.reference?.label ?? null,
sourceKind: item.reference?.sourceKind ?? null,
},
transaction,
"insert"
);
}
}
}
async function prepare(database: Sequelize, table: ProcessTable) {
const query = database.getQueryInterface();
const original = await query.describeTable(table);
for (const [name, column] of Object.entries(columns(table))) {
if (!(name in original)) {
await query.addColumn(table, name, { ...column, allowNull: true });
}
}
const childTable = children(table);
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
{ replacements: { table: childTable }, type: QueryTypes.SELECT }
);
if (!tables.length) {
await query.createTable(childTable, {
ownerId: { ...reference(table), onDelete: "CASCADE", primaryKey: true },
position: { type: DataTypes.INTEGER, allowNull: false, primaryKey: templateTable(table) },
title: name,
...(templateTable(table)
? {}
: {
taskId: { type: DataTypes.UUID, allowNull: false, primaryKey: true },
status: { type: DataTypes.STRING(20), allowNull: false },
notes: note,
referenceKind: { type: DataTypes.STRING(20), allowNull: true },
referenceId: { type: DataTypes.UUID, allowNull: true },
referenceLabel: { type: DataTypes.STRING(500), allowNull: true },
sourceKind: { type: DataTypes.STRING(20), allowNull: true },
}),
});
}
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
{ replacements: { table: childTable }, type: QueryTypes.SELECT }
);
if (!indexes.some((row) => row.name === `${childTable}_position`)) {
await query.addIndex(childTable, ["ownerId", "position"], {
unique: true,
name: `${childTable}_position`,
});
}
return original.values !== undefined;
}
async function transfer(database: Sequelize, rootTable: ProcessTable, revisionTable: ProcessTable) {
await database.transaction(async (transaction) => {
const roots = await readProcessRows(database, rootTable, transaction);
const revisions = await readProcessRows(database, revisionTable, transaction);
for (const root of roots) {
const history = revisions.filter((row) => row.itemId === root.id);
if (templateTable(rootTable)) {
new ProcessTemplatePersistenceMapper().toDomain(root, history);
} else {
new PersonProcessPersistenceMapper().toDomain(root, history);
}
}
for (const [table, rows] of [
[rootTable, roots],
[revisionTable, revisions],
] as const) {
for (const row of rows) {
await writeProcessRow(database, table, row, transaction, "update");
}
if (
!isDeepStrictEqual(rows, await readProcessRows(database, table, transaction, "normalized"))
) {
throw new Error("Los procesos normalizados no coinciden con el origen.");
}
}
});
}
export async function normalizeProcesses(database: Sequelize) {
for (const [root, revision] of [
["process_templates", "process_template_revisions"],
["person_processes", "person_process_revisions"],
] as const) {
const rootLegacy = await prepare(database, root);
const revisionLegacy = await prepare(database, revision);
if (rootLegacy || revisionLegacy) {
await transfer(database, root, revision);
for (const [table, legacy] of [
[root, rootLegacy],
[revision, revisionLegacy],
] as const) {
if (legacy) {
await database.query(`ALTER TABLE ${supportIdentifier(table)} DROP COLUMN \`values\``);
}
}
}
await tightenProcessColumns(database, root);
await tightenProcessColumns(database, revision);
}
}
async function tightenProcessColumns(database: Sequelize, table: ProcessTable) {
const query = database.getQueryInterface();
const final = await query.describeTable(table);
for (const [name, column] of Object.entries(columns(table))) {
if (column.allowNull === false && final[name]?.allowNull) {
await query.changeColumn(table, name, column);
}
}
}

View File

@ -45,6 +45,8 @@ function configurationDetail(
type Profile = { id: string; values: WorkProfileInput }; type Profile = { id: string; values: WorkProfileInput };
import { readAccessItems } from "./access-relational";
type Row = { id: string; values: unknown }; type Row = { id: string; values: unknown };
type Table = type Table =
@ -200,10 +202,13 @@ export class ProcessReviewReader {
...this.remote(await rows("remote_connections"), assetNames, resourceNames), ...this.remote(await rows("remote_connections"), assetNames, resourceNames),
]; ];
const relationships: ProcessReviewSources["relationships"][number][] = []; const relationships: ProcessReviewSources["relationships"][number][] = [];
const rawAccess = await this.database.query<{ id: string; values: unknown }>( const workspaces = await this.database.query<{ id: string }>(
"SELECT i.id,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId", "SELECT id FROM access_workspaces WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT } { replacements: { companyId }, transaction, type: QueryTypes.SELECT }
); );
const rawAccess = (await readAccessItems(this.database, transaction)).filter((row) =>
workspaces.some((workspace) => workspace.id === row.workspaceId)
);
const access = rawAccess.map((r) => ({ const access = rawAccess.map((r) => ({
id: profileIdentity(r.id).toPrimitive(), id: profileIdentity(r.id).toPrimitive(),
values: AccessDefinitionSchema.parse(persistedJson(r.values)), values: AccessDefinitionSchema.parse(persistedJson(r.values)),
@ -211,7 +216,7 @@ export class ProcessReviewReader {
relationships.push(...accessReviewRelationships(access)); relationships.push(...accessReviewRelationships(access));
const mailboxes = await this.database.query<{ id: string; address: string }>( const mailboxes = await this.database.query<{ id: string; address: string }>(
"SELECT id,JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.address')) AS address FROM mailboxes WHERE companyId=:companyId", "SELECT id,address FROM mailboxes WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT } { replacements: { companyId }, transaction, type: QueryTypes.SELECT }
); );

View File

@ -22,6 +22,16 @@ export type ProcessTemplateRevisionRow = {
values: unknown; values: unknown;
}; };
const rowSchema = z.object({ id: z.uuid(), nameKey: z.string(), values: z.unknown() });
const revisionSchema = z.object({
id: z.uuid(),
itemId: z.uuid(),
position: z.number().int().nonnegative(),
actorId: z.uuid().nullable(),
recordedAt: z.iso.datetime(),
values: z.unknown(),
});
function identity(value: string) { function identity(value: string) {
const parsed = UniqueID.create(value); const parsed = UniqueID.create(value);
@ -43,7 +53,9 @@ function mapped(raw: unknown) {
} }
export class ProcessTemplatePersistenceMapper { export class ProcessTemplatePersistenceMapper {
toDomain(row: ProcessTemplateRow, revisions: readonly ProcessTemplateRevisionRow[]) { toDomain(source: unknown, revisionRows: readonly unknown[]) {
const row = rowSchema.parse(source);
const revisions = revisionRows.map((item) => revisionSchema.parse(item));
const values = mapped(row.values); const values = mapped(row.values);
const ordered = [...revisions].sort((a, b) => a.position - b.position); const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((r, index) => { const history = ordered.map((r, index) => {
@ -84,7 +96,7 @@ export class ProcessTemplatePersistenceMapper {
root: { root: {
id: item.id.toPrimitive(), id: item.id.toPrimitive(),
nameKey: processTemplateNameKey(values.name), nameKey: processTemplateNameKey(values.name),
values: JSON.stringify(values), values: values,
}, },
revisions: item.history.map((r, position) => ({ revisions: item.history.map((r, position) => ({
id: r.id.toPrimitive(), id: r.id.toPrimitive(),
@ -92,7 +104,7 @@ export class ProcessTemplatePersistenceMapper {
position, position,
actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null, actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null,
recordedAt: r.recordedAt, recordedAt: r.recordedAt,
values: JSON.stringify(r.values.toPrimitive()), values: r.values.toPrimitive(),
})), })),
}; };
} }

View File

@ -1,6 +1,10 @@
import type { Sequelize } from "sequelize"; import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize"; import { DataTypes, QueryTypes } from "sequelize";
import { ensureSnapshotConstraint } from "./mail-remote-constraints";
import { remoteSnapshot } from "./mail-remote-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateRemoteConnections(database: Sequelize) { export async function migrateRemoteConnections(database: Sequelize) {
const query = database.getQueryInterface(); const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>( const tables = await database.query<{ name: string }>(
@ -19,12 +23,7 @@ export async function migrateRemoteConnections(database: Sequelize) {
await query.createTable("remote_connections", { await query.createTable("remote_connections", {
id: { type: DataTypes.UUID, primaryKey: true }, id: { type: DataTypes.UUID, primaryKey: true },
companyId: reference("companies"), companyId: reference("companies"),
personId: reference("company_contacts"), ...remoteSnapshot.columns,
sourceAssetId: reference("assets", true),
targetAssetId: reference("assets", true),
targetResourceId: reference("resources", true),
siteId: reference("company_sites", true),
values: { type: DataTypes.JSON, allowNull: false },
currentKey: { type: DataTypes.STRING(64), allowNull: true, unique: true }, currentKey: { type: DataTypes.STRING(64), allowNull: true, unique: true },
}); });
} }
@ -35,7 +34,7 @@ export async function migrateRemoteConnections(database: Sequelize) {
position: { type: DataTypes.INTEGER, allowNull: false }, position: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"), actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false }, recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false }, ...remoteSnapshot.columns,
}); });
} }
const indexes = await database.query<{ name: string }>( const indexes = await database.query<{ name: string }>(
@ -49,4 +48,17 @@ export async function migrateRemoteConnections(database: Sequelize) {
name: "remote_revision_position", name: "remote_revision_position",
}); });
} }
await normalizeScalarSnapshots(
database,
["remote_connections", "remote_connection_revisions"],
remoteSnapshot
);
for (const table of ["remote_connections", "remote_connection_revisions"]) {
await ensureSnapshotConstraint(
database,
table,
`${table}_snapshot_shape`,
"((sourceKind='company_asset' AND sourceAssetId IS NOT NULL) OR (sourceKind IN ('personal_computer','personal_mobile') AND sourceAssetId IS NULL)) AND ((targetKind='network' AND targetAssetId IS NULL AND targetResourceId IS NULL) OR (targetKind='asset' AND targetAssetId IS NOT NULL AND targetResourceId IS NULL AND siteId IS NULL AND (sourceAssetId IS NULL OR sourceAssetId<>targetAssetId)) OR (targetKind='resource' AND targetResourceId IS NOT NULL AND targetAssetId IS NULL AND siteId IS NULL))"
);
}
} }

View File

@ -10,6 +10,8 @@ import {
} from "../../domain/remote-connection-values"; } from "../../domain/remote-connection-values";
import { RemoteConnectionValues } from "../../domain/value-objects/remote-connection-values.vo"; import { RemoteConnectionValues } from "../../domain/value-objects/remote-connection-values.vo";
import { remoteSnapshot } from "./mail-remote-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper"; import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
export type RemoteRow = { export type RemoteRow = {
@ -47,10 +49,10 @@ function mapped(raw: unknown) {
export class RemoteConnectionPersistenceMapper { export class RemoteConnectionPersistenceMapper {
toDomain(row: RemoteRow, revisions: readonly RemoteRevisionRow[]) { toDomain(row: RemoteRow, revisions: readonly RemoteRevisionRow[]) {
const values = mapped(row.values); const values = mapped(snapshotValues(remoteSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position); const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, index) => { const history = ordered.map((revision, index) => {
const value = mapped(revision.values); const value = mapped(snapshotValues(remoteSnapshot, revision));
const previous = ordered[index - 1]; const previous = ordered[index - 1];
if ( if (
@ -123,7 +125,7 @@ export class RemoteConnectionPersistenceMapper {
targetAssetId: values.targetKind === "asset" ? values.targetId : null, targetAssetId: values.targetKind === "asset" ? values.targetId : null,
targetResourceId: values.targetKind === "resource" ? values.targetId : null, targetResourceId: values.targetKind === "resource" ? values.targetId : null,
siteId: values.siteId, siteId: values.siteId,
values: JSON.stringify(values), values: values,
...remoteKeys(item.values), ...remoteKeys(item.values),
}, },
revisions: item.history.map((revision, position) => ({ revisions: item.history.map((revision, position) => ({
@ -132,7 +134,7 @@ export class RemoteConnectionPersistenceMapper {
position, position,
actorId: revision.actorId.toPrimitive(), actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt, recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()), values: revision.values.toPrimitive(),
})), })),
}; };
} }

View File

@ -10,6 +10,8 @@ export type SnapshotDefinition<T> = {
schema: z.ZodType<T>; schema: z.ZodType<T>;
columns: Columns; columns: Columns;
booleans?: readonly string[]; booleans?: readonly string[];
fromColumns?: (row: Record<string, unknown>) => unknown;
toColumns?: (input: T) => object;
}; };
function identifier(value: string) { function identifier(value: string) {
@ -41,7 +43,7 @@ export function snapshotValues<T>(definition: SnapshotDefinition<T>, row: object
}) })
); );
return definition.schema.parse(input); return definition.schema.parse(definition.fromColumns ? definition.fromColumns(input) : input);
} }
export async function normalizeScalarSnapshots<T>( export async function normalizeScalarSnapshots<T>(
@ -100,7 +102,7 @@ async function transferSnapshotRow<T>(
const expected = snapshotValues(definition, row); const expected = snapshotValues(definition, row);
const values = z const values = z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])) .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.parse(expected); .parse(definition.toColumns ? definition.toColumns(expected) : expected);
for (const [name, value] of Object.entries(values)) { for (const [name, value] of Object.entries(values)) {
if (name.endsWith("Id") && original.has(name) && !isDeepStrictEqual(row[name], value)) { if (name.endsWith("Id") && original.has(name) && !isDeepStrictEqual(row[name], value)) {

View File

@ -0,0 +1,26 @@
import type { Sequelize, Transaction } from "sequelize";
import { z } from "zod/v4";
import { type SnapshotDefinition, snapshotValues } from "./scalar-snapshot-normalization";
import { writeSupportScalars } from "./support-record-relational";
export async function writeSnapshotRow<T>(
database: Sequelize,
table: string,
definition: SnapshotDefinition<T>,
row: object,
transaction: Transaction,
operation: "insert" | "update"
) {
const { values: _values, ...metadata } = z.record(z.string(), z.unknown()).parse(row);
const input = snapshotValues(definition, row);
const columns = definition.toColumns ? definition.toColumns(input) : input;
await writeSupportScalars(
database,
table,
{ ...metadata, ...z.record(z.string(), z.unknown()).parse(columns) },
transaction,
operation
);
}

View File

@ -13,11 +13,11 @@ import type {
const OPEN_TICKET = "t.state IN ('open','in_progress','waiting')"; const OPEN_TICKET = "t.state IN ('open','in_progress','waiting')";
const PENDING_INTERVENTION = "i.state IN ('pending','in_progress')"; const PENDING_INTERVENTION = "i.state IN ('pending','in_progress')";
const PROFILE_REVIEW = `p.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' account.ownerId IS NULL OR account.active<>true OR account.usage='technical'
OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.expiresOn')) < :today OR account.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) OR NOT EXISTS (SELECT 1 FROM access_current_values s WHERE s.ownerId=account.systemId AND s.kind='system' AND s.active=true)
OR (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.environmentId')),'null') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM access_items e WHERE e.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.environmentId')) AND e.kind='environment' AND JSON_EXTRACT(e.\`values\`,'$.active')=true)) OR (NULLIF(account.environmentId,'null') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM access_current_values e WHERE e.ownerId=account.environmentId AND e.kind='environment' AND e.active=true))
OR NOT EXISTS (SELECT 1 FROM access_items link WHERE link.kind='account_person' AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.personId'))=p.personId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.accountId'))=p.accountId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.startDate'))<=:today AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate'))>:today) AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.configuration')) IN ('pending','configured')) OR NOT EXISTS (SELECT 1 FROM access_current_values link WHERE link.kind='account_person' AND link.personId=p.personId AND link.accountId=p.accountId AND link.startDate<=:today AND (NULLIF(link.endDate,'null') IS NULL OR link.endDate>:today) AND link.configuration IN ('pending','configured'))
)))`; )))`;
export class SequelizeDashboardRepository implements IDashboardRepository { export class SequelizeDashboardRepository implements IDashboardRepository {
@ -102,7 +102,7 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
private async issues(query: DashboardDetailsQuery) { private async issues(query: DashboardDetailsQuery) {
if (query.issue === "profile") { if (query.issue === "profile") {
const from = const from =
"FROM work_profiles p JOIN companies c ON c.id=p.companyId JOIN company_contacts person ON person.id=p.personId JOIN assets a ON a.id=p.assetId LEFT JOIN access_items account ON account.id=p.accountId"; "FROM work_profiles p JOIN companies c ON c.id=p.companyId JOIN company_contacts person ON person.id=p.personId JOIN assets a ON a.id=p.assetId LEFT JOIN access_current_values account ON account.ownerId=p.accountId";
const where = `WHERE (:companyId IS NULL OR p.companyId=:companyId) AND ${PROFILE_REVIEW}`; const where = `WHERE (:companyId IS NULL OR p.companyId=:companyId) AND ${PROFILE_REVIEW}`;
const count = await this.select<{ total: number }>( const count = await this.select<{ total: number }>(
`SELECT COUNT(*) AS total ${from} ${where}`, `SELECT COUNT(*) AS total ${from} ${where}`,
@ -138,9 +138,9 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
`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'))` `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 "); .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 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')))) 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_current_values link JOIN access_items li ON li.id=link.ownerId JOIN access_workspaces ws ON ws.id=li.workspaceId WHERE ws.companyId=p.companyId AND link.personId=p.id AND ((link.kind='need' AND (NULLIF(link.endDate,'null') IS NULL OR link.endDate>:today)) OR (link.kind='account_person' AND link.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 remote_connections r WHERE r.companyId=p.companyId AND r.personId=p.id AND r.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 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 m.state NOT IN ('removed','never_configured'))
OR ${profileConfigurations} OR ${profileConfigurations}
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)))`; 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 extra = { ids: ids.length ? ids : [""] };

View File

@ -9,18 +9,14 @@ import type {
} from "../../application/folder-mapping.repository"; } from "../../application/folder-mapping.repository";
import { folderReferences } from "../../application/folder-mapping-policy"; import { folderReferences } from "../../application/folder-mapping-policy";
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository"; import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
import { AccessValues } from "../../domain/access/access-values.vo"; import { AccessValues } from "../../domain/access/access-values.vo";
import { FolderMappingError } from "../../domain/folder-mapping-values"; import { FolderMappingError } from "../../domain/folder-mapping-values";
import { readCompanyAccessItems } from "./access-relational";
import type { FolderRevisionRow, FolderRow } from "./folder-mapping-persistence.mapper"; import type { FolderRevisionRow, FolderRow } from "./folder-mapping-persistence.mapper";
import { FolderMappingPersistenceMapper } from "./folder-mapping-persistence.mapper"; import { FolderMappingPersistenceMapper } from "./folder-mapping-persistence.mapper";
import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper"; import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper";
import { import { WorkProfilePersistenceMapper, profileIdentity } from "./work-profile-persistence.mapper";
WorkProfilePersistenceMapper,
persistedJson,
profileIdentity,
} from "./work-profile-persistence.mapper";
export class SequelizeFolderMappingRepository implements IFolderMappingRepository { export class SequelizeFolderMappingRepository implements IFolderMappingRepository {
private readonly mapper = new FolderMappingPersistenceMapper(); private readonly mapper = new FolderMappingPersistenceMapper();
@ -61,10 +57,7 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
"SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId", "SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId",
{ ...options, type: QueryTypes.SELECT } { ...options, type: QueryTypes.SELECT }
); );
const items = await this.database.query<{ id: string; values: unknown }>( const items = await readCompanyAccessItems(this.database, company.toPrimitive(), transaction);
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
{ ...options, type: QueryTypes.SELECT }
);
const people = await this.database.query<{ id: string; active: boolean }>( const people = await this.database.query<{ id: string; active: boolean }>(
"SELECT id,active FROM company_contacts WHERE companyId=:companyId", "SELECT id,active FROM company_contacts WHERE companyId=:companyId",
{ ...options, type: QueryTypes.SELECT } { ...options, type: QueryTypes.SELECT }
@ -87,9 +80,7 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
) )
), ),
items: items.map((item) => { items: items.map((item) => {
const values = AccessValues.create( const values = AccessValues.create(item.values);
AccessDefinitionSchema.parse(persistedJson(item.values))
);
if (!values.isSuccess) { if (!values.isSuccess) {
throw new Error("Recurso de carpeta persistido inconsistente."); throw new Error("Recurso de carpeta persistido inconsistente.");

View File

@ -92,6 +92,9 @@ function validateReferences(context: MailContext, accounts: MailboxContext["item
} }
} }
import { mailSnapshot } from "./mail-remote-snapshots";
import { writeSnapshotRow } from "./scalar-snapshot-writer";
export class SequelizeMailConfigurationRepository implements IMailConfigurationRepository { export class SequelizeMailConfigurationRepository implements IMailConfigurationRepository {
private readonly mapper = new MailConfigurationPersistenceMapper(); private readonly mapper = new MailConfigurationPersistenceMapper();
@ -228,18 +231,26 @@ export class SequelizeMailConfigurationRepository implements IMailConfigurationR
if (previous !== candidate.data) { if (previous !== candidate.data) {
const record = this.mapper.toPersistence(candidate.data); const record = this.mapper.toPersistence(candidate.data);
await this.database.query( await writeSnapshotRow(
"INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,:profileId,:mailboxId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey),personId=VALUES(personId),accountId=VALUES(accountId),assetId=VALUES(assetId),profileId=VALUES(profileId),mailboxId=VALUES(mailboxId)", this.database,
{ replacements: record.root, transaction } "mail_configurations",
mailSnapshot,
record.root,
transaction,
previous ? "update" : "insert"
); );
const stored = new Set( const stored = new Set(
previous?.history.map((revision) => revision.id.toPrimitive()) ?? [] previous?.history.map((revision) => revision.id.toPrimitive()) ?? []
); );
for (const revision of record.revisions.filter((item) => !stored.has(item.id))) { for (const revision of record.revisions.filter((item) => !stored.has(item.id))) {
await this.database.query( await writeSnapshotRow(
"INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)", this.database,
{ replacements: revision, transaction } "mail_configuration_revisions",
mailSnapshot,
revision,
transaction,
"insert"
); );
} }
} }

View File

@ -9,8 +9,11 @@ import { Mailbox } from "../../domain/mailbox.aggregate";
import type { MailboxUse } from "../../domain/mailbox-use.aggregate"; import type { MailboxUse } from "../../domain/mailbox-use.aggregate";
import { MailboxError } from "../../domain/mailbox-values"; import { MailboxError } from "../../domain/mailbox-values";
import { readCompanyAccessItems } from "./access-relational";
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
import type { MailboxRevisionRow, MailboxRow, MailboxUseRow } from "./mailbox-persistence.mapper"; import type { MailboxRevisionRow, MailboxRow, MailboxUseRow } from "./mailbox-persistence.mapper";
import { MailboxPersistenceMapper, mailboxIdentity } from "./mailbox-persistence.mapper"; import { MailboxPersistenceMapper, mailboxIdentity } from "./mailbox-persistence.mapper";
import { writeSnapshotRow } from "./scalar-snapshot-writer";
export class SequelizeMailboxRepository implements IMailboxRepository { export class SequelizeMailboxRepository implements IMailboxRepository {
private readonly mapper = new MailboxPersistenceMapper(); private readonly mapper = new MailboxPersistenceMapper();
@ -51,10 +54,7 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
"SELECT id,active FROM company_contacts WHERE companyId=:companyId", "SELECT id,active FROM company_contacts WHERE companyId=:companyId",
{ replacements, transaction, type: QueryTypes.SELECT } { replacements, transaction, type: QueryTypes.SELECT }
); );
const items = await this.database.query<{ id: string; values: unknown }>( const items = await readCompanyAccessItems(this.database, companyId.toPrimitive(), transaction);
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
{ replacements, transaction, type: QueryTypes.SELECT }
);
const actors = await this.database.query<{ id: string; name: string }>( const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users", "SELECT id,name FROM users",
{ transaction, type: QueryTypes.SELECT } { transaction, type: QueryTypes.SELECT }
@ -122,10 +122,25 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
for (const revision of row.revisions.filter((revision) => !existing.has(revision.id))) { for (const revision of row.revisions.filter((revision) => !existing.has(revision.id))) {
const table = item instanceof Mailbox ? "mailbox_revisions" : "mailbox_use_revisions"; const table = item instanceof Mailbox ? "mailbox_revisions" : "mailbox_use_revisions";
await this.database.query( if (item instanceof Mailbox) {
`INSERT INTO ${table} (id,itemId,position,actorId,recordedAt,\`values\`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)`, await writeSnapshotRow(
{ replacements: revision, transaction } this.database,
); table,
mailboxSnapshot,
revision,
transaction,
"insert"
);
} else {
await writeSnapshotRow(
this.database,
table,
mailboxUseSnapshot,
revision,
transaction,
"insert"
);
}
} }
} }
@ -133,6 +148,43 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
return this.database.transaction((transaction) => this.load(companyId, transaction, false)); return this.database.transaction((transaction) => this.load(companyId, transaction, false));
} }
private async persistItem(
item: Mailbox | MailboxUse,
previous: Mailbox | MailboxUse | undefined,
transaction: Transaction
) {
const operation = previous ? "update" : "insert";
const row = this.mapper.toPersistence(item);
if (item instanceof Mailbox) {
const values = item.values.toPrimitive();
await writeSnapshotRow(
this.database,
"mailboxes",
mailboxSnapshot,
{
id: row.id,
companyId: row.companyId,
values,
addressKey: `${row.companyId}:${values.address}`,
},
transaction,
operation
);
} else {
await writeSnapshotRow(
this.database,
"mailbox_uses",
mailboxUseSnapshot,
{ id: row.id, companyId: row.companyId, values: item.values.toPrimitive() },
transaction,
operation
);
}
await this.saveRevisions(item, previous, transaction);
}
save(companyId: UniqueID, action: Parameters<IMailboxRepository["save"]>[1]) { save(companyId: UniqueID, action: Parameters<IMailboxRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => { return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true); const loaded = await this.load(companyId, transaction, true);
@ -152,42 +204,7 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
: loaded.data.uses.find((old) => old.id.equals(item.id)); : loaded.data.uses.find((old) => old.id.equals(item.id));
if (previous !== item) { if (previous !== item) {
const row = this.mapper.toPersistence(item); await this.persistItem(item, previous, transaction);
if (item instanceof Mailbox) {
const values = item.values.toPrimitive();
await this.database.query(
"INSERT INTO mailboxes (id,companyId,accountId,addressKey,`values`) VALUES (:id,:companyId,:accountId,:addressKey,:values) ON DUPLICATE KEY UPDATE accountId=VALUES(accountId),addressKey=VALUES(addressKey),`values`=VALUES(`values`)",
{
replacements: {
id: row.id,
companyId: row.companyId,
values: row.values,
accountId: values.accountId,
addressKey: `${row.companyId}:${values.address}`,
},
transaction,
}
);
} else {
const values = item.values.toPrimitive();
await this.database.query(
"INSERT INTO mailbox_uses (id,companyId,mailboxId,personId,`values`) VALUES (:id,:companyId,:mailboxId,:personId,:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)",
{
replacements: {
id: row.id,
companyId: row.companyId,
values: row.values,
mailboxId: values.mailboxId,
personId: values.personId,
},
transaction,
}
);
}
await this.saveRevisions(item, previous, transaction);
} }
return Result.ok({ return Result.ok({

View File

@ -12,13 +12,9 @@ import type {
} from "../../application/process.repository"; } from "../../application/process.repository";
import { ProcessError } from "../../domain/process-values"; import { ProcessError } from "../../domain/process-values";
import type { PersonProcessRevisionRow, PersonProcessRow } from "./process-persistence.mapper";
import { PersonProcessPersistenceMapper } from "./process-persistence.mapper"; import { PersonProcessPersistenceMapper } from "./process-persistence.mapper";
import { readProcessRows, writeProcessRow } from "./process-relational";
import { ProcessReviewReader } from "./process-review-reader"; import { ProcessReviewReader } from "./process-review-reader";
import type {
ProcessTemplateRevisionRow,
ProcessTemplateRow,
} from "./process-template-persistence.mapper";
import { ProcessTemplatePersistenceMapper } from "./process-template-persistence.mapper"; import { ProcessTemplatePersistenceMapper } from "./process-template-persistence.mapper";
export class SequelizeProcessRepository implements IProcessRepository { export class SequelizeProcessRepository implements IProcessRepository {
@ -32,13 +28,11 @@ export class SequelizeProcessRepository implements IProcessRepository {
) {} ) {}
private async loadCatalog(transaction: Transaction): Promise<ProcessCatalogContext> { private async loadCatalog(transaction: Transaction): Promise<ProcessCatalogContext> {
const templates = await this.database.query<ProcessTemplateRow>( const templates = await readProcessRows(this.database, "process_templates", transaction);
"SELECT * FROM process_templates", const revisions = await readProcessRows(
{ transaction, type: QueryTypes.SELECT } this.database,
); "process_template_revisions",
const revisions = await this.database.query<ProcessTemplateRevisionRow>( transaction
"SELECT * FROM process_template_revisions",
{ transaction, type: QueryTypes.SELECT }
); );
const actors = await this.database.query<{ id: string; name: string }>( const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users", "SELECT id,name FROM users",
@ -71,14 +65,10 @@ export class SequelizeProcessRepository implements IProcessRepository {
return Result.fail(new ProcessError("COMPANY_NOT_FOUND")); return Result.fail(new ProcessError("COMPANY_NOT_FOUND"));
} }
const catalog = await this.loadCatalog(transaction); const catalog = await this.loadCatalog(transaction);
const rows = await this.database.query<PersonProcessRow>( const rows = (await readProcessRows(this.database, "person_processes", transaction)).filter(
"SELECT * FROM person_processes WHERE companyId=:companyId", (row) => row.companyId === companyId
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<PersonProcessRevisionRow>(
"SELECT r.* FROM person_process_revisions r JOIN person_processes p ON p.id=r.itemId WHERE p.companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
); );
const revisions = await readProcessRows(this.database, "person_process_revisions", transaction);
const persons = await this.database.query<{ id: string; name: string }>( const persons = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM company_contacts WHERE companyId=:companyId", "SELECT id,name FROM company_contacts WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT } { replacements: { companyId }, transaction, type: QueryTypes.SELECT }
@ -147,16 +137,22 @@ export class SequelizeProcessRepository implements IProcessRepository {
if (previous !== candidate.data) { if (previous !== candidate.data) {
const record = this.templateMapper.toPersistence(candidate.data); const record = this.templateMapper.toPersistence(candidate.data);
await this.database.query( await writeProcessRow(
"INSERT INTO process_templates (id,nameKey,`values`) VALUES (:id,:nameKey,:values) ON DUPLICATE KEY UPDATE nameKey=VALUES(nameKey),`values`=VALUES(`values`)", this.database,
{ replacements: record.root, transaction } "process_templates",
record.root,
transaction,
previous ? "update" : "insert"
); );
const known = new Set(previous?.history.map((r) => r.id.toPrimitive())); const known = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const r of record.revisions.filter((r) => !known.has(r.id))) { for (const r of record.revisions.filter((r) => !known.has(r.id))) {
await this.database.query( await writeProcessRow(
"INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)", this.database,
{ replacements: r, transaction } "process_template_revisions",
r,
transaction,
"insert"
); );
} }
} }
@ -176,7 +172,9 @@ export class SequelizeProcessRepository implements IProcessRepository {
} }
); );
if (!rows.length) return Result.fail(new ProcessError("PROCESS_NOT_FOUND")); if (!rows.length) {
return Result.fail(new ProcessError("PROCESS_NOT_FOUND"));
}
await this.database.query("DELETE FROM person_process_revisions WHERE itemId=:id", { await this.database.query("DELETE FROM person_process_revisions WHERE itemId=:id", {
replacements: { id: process.toPrimitive() }, replacements: { id: process.toPrimitive() },
transaction, transaction,
@ -210,16 +208,22 @@ export class SequelizeProcessRepository implements IProcessRepository {
if (previous !== candidate.data) { if (previous !== candidate.data) {
const record = this.processMapper.toPersistence(candidate.data); const record = this.processMapper.toPersistence(candidate.data);
await this.database.query( await writeProcessRow(
"INSERT INTO person_processes (id,companyId,personId,templateId,currentKey,`values`) VALUES (:id,:companyId,:personId,:templateId,:currentKey,:values) ON DUPLICATE KEY UPDATE currentKey=VALUES(currentKey),`values`=VALUES(`values`)", this.database,
{ replacements: record.root, transaction } "person_processes",
record.root,
transaction,
previous ? "update" : "insert"
); );
const known = new Set(previous?.history.map((r) => r.id.toPrimitive())); const known = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const r of record.revisions.filter((r) => !known.has(r.id))) { for (const r of record.revisions.filter((r) => !known.has(r.id))) {
await this.database.query( await writeProcessRow(
"INSERT INTO person_process_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)", this.database,
{ replacements: r, transaction } "person_process_revisions",
r,
transaction,
"insert"
); );
} }
} }

View File

@ -12,8 +12,10 @@ import { remoteReferences } from "../../application/remote-connection-policy";
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository"; import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
import { RemoteConnectionError } from "../../domain/remote-connection-values"; import { RemoteConnectionError } from "../../domain/remote-connection-values";
import { remoteSnapshot } from "./mail-remote-snapshots";
import type { RemoteRevisionRow, RemoteRow } from "./remote-connection-persistence.mapper"; import type { RemoteRevisionRow, RemoteRow } from "./remote-connection-persistence.mapper";
import { RemoteConnectionPersistenceMapper } from "./remote-connection-persistence.mapper"; import { RemoteConnectionPersistenceMapper } from "./remote-connection-persistence.mapper";
import { writeSnapshotRow } from "./scalar-snapshot-writer";
import type { TeleworkRevisionRow, TeleworkRow } from "./telework-authorization-persistence.mapper"; import type { TeleworkRevisionRow, TeleworkRow } from "./telework-authorization-persistence.mapper";
import { import {
TeleworkAuthorizationPersistenceMapper, TeleworkAuthorizationPersistenceMapper,
@ -125,16 +127,24 @@ export class SequelizeRemoteConnectionRepository implements IRemoteConnectionRep
if (previous !== candidate.data) { if (previous !== candidate.data) {
const record = this.mapper.toPersistence(candidate.data); const record = this.mapper.toPersistence(candidate.data);
await this.database.query( await writeSnapshotRow(
"INSERT INTO remote_connections (id,companyId,personId,sourceAssetId,targetAssetId,targetResourceId,siteId,`values`,currentKey) VALUES (:id,:companyId,:personId,:sourceAssetId,:targetAssetId,:targetResourceId,:siteId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)", this.database,
{ replacements: record.root, transaction } "remote_connections",
remoteSnapshot,
record.root,
transaction,
previous ? "update" : "insert"
); );
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive())); const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) { for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
await this.database.query( await writeSnapshotRow(
"INSERT INTO remote_connection_revisions (id,connectionId,position,actorId,recordedAt,`values`) VALUES (:id,:connectionId,:position,:actorId,:recordedAt,:values)", this.database,
{ replacements: revision, transaction } "remote_connection_revisions",
remoteSnapshot,
revision,
transaction,
"insert"
); );
} }
} }

View File

@ -8,15 +8,11 @@ import type {
IWorkProfileRepository, IWorkProfileRepository,
WorkProfileContext, WorkProfileContext,
} from "../../application/work-profile.repository"; } from "../../application/work-profile.repository";
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
import { WorkProfileError } from "../../domain/work-profile-values"; import { WorkProfileError } from "../../domain/work-profile-values";
import { readCompanyAccessItems } from "./access-relational";
import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper"; import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper";
import { import { WorkProfilePersistenceMapper, profileIdentity } from "./work-profile-persistence.mapper";
WorkProfilePersistenceMapper,
persistedJson,
profileIdentity,
} from "./work-profile-persistence.mapper";
export class SequelizeWorkProfileRepository implements IWorkProfileRepository { export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
private readonly mapper = new WorkProfilePersistenceMapper(); private readonly mapper = new WorkProfilePersistenceMapper();
@ -53,10 +49,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
"SELECT id,active FROM company_contacts WHERE companyId=:companyId", "SELECT id,active FROM company_contacts WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT } { replacements: { companyId }, transaction, type: QueryTypes.SELECT }
); );
const items = await this.database.query<{ id: string; values: unknown }>( const items = await readCompanyAccessItems(this.database, companyId, transaction);
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const actors = await this.database.query<{ id: string; name: string }>( const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users", "SELECT id,name FROM users",
{ transaction, type: QueryTypes.SELECT } { transaction, type: QueryTypes.SELECT }
@ -74,7 +67,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
})), })),
items: items.map((item) => ({ items: items.map((item) => ({
id: profileIdentity(item.id), id: profileIdentity(item.id),
values: AccessDefinitionSchema.parse(persistedJson(item.values)), values: item.values,
})), })),
assets: await this.inventory.list(company), assets: await this.inventory.list(company),
actors: new Map(actors.map((actor) => [actor.id, actor.name])), actors: new Map(actors.map((actor) => [actor.id, actor.name])),

View File

@ -1,7 +1,9 @@
import type { Sequelize } from "sequelize"; import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize"; import { DataTypes, QueryTypes } from "sequelize";
/** Migración aditiva; nunca altera ni borra tablas existentes. */ import { normalizeSessions, sessionColumns } from "./session-normalization";
/** Migraciones explícitas de identidad y normalización de sesiones. */
export async function migrateIdentity(database: Sequelize): Promise<void> { export async function migrateIdentity(database: Sequelize): Promise<void> {
const query = database.getQueryInterface(); const query = database.getQueryInterface();
const tableRows = await database.query<{ name: string }>( const tableRows = await database.query<{ name: string }>(
@ -50,9 +52,10 @@ export async function migrateIdentity(database: Sequelize): Promise<void> {
await query.createTable("http_sessions", { await query.createTable("http_sessions", {
sid: { type: DataTypes.STRING(36), primaryKey: true }, sid: { type: DataTypes.STRING(36), primaryKey: true },
expires: { type: DataTypes.DATE, allowNull: true }, expires: { type: DataTypes.DATE, allowNull: true },
data: { type: DataTypes.TEXT, allowNull: true }, ...sessionColumns,
createdAt: { type: DataTypes.DATE, allowNull: false }, createdAt: { type: DataTypes.DATE, allowNull: false },
updatedAt: { type: DataTypes.DATE, allowNull: false }, updatedAt: { type: DataTypes.DATE, allowNull: false },
}); });
} }
await normalizeSessions(database);
} }

View File

@ -0,0 +1,82 @@
import session from "express-session";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { restoreSession, sessionRow, writeSession } from "./session-normalization";
type Callback = (error?: Error | null) => void;
function failure(error: unknown) {
return error instanceof Error ? error : new Error("Fallo del almacén de sesiones.");
}
/** Almacén propio: persistencia relacional del contrato de sesión usado por Metis Desk. */
export class SequelizeSessionStore extends session.Store {
constructor(private readonly database: Sequelize) {
super();
}
async get(
sid: string,
callback: (error: Error | null, data?: session.SessionData | null) => void
) {
let result: session.SessionData | null = null;
let error: Error | null = null;
try {
const rows = await this.database.query<Record<string, unknown>>(
"SELECT * FROM http_sessions WHERE sid=:sid AND expires>NOW()",
{ replacements: { sid }, type: QueryTypes.SELECT }
);
result = rows[0] ? restoreSession(rows[0]) : null;
} catch (caught) {
error = failure(caught);
}
callback(error, result);
}
set(sid: string, data: session.SessionData, callback?: Callback) {
return this.complete(async () => {
await writeSession(this.database, sid, data);
await this.database.query("DELETE FROM http_sessions WHERE expires<=NOW()");
}, callback);
}
destroy(sid: string, callback?: Callback) {
return this.complete(async () => {
await this.database.query("DELETE FROM http_sessions WHERE sid=:sid", {
replacements: { sid },
});
}, callback);
}
touch(sid: string, data: session.SessionData, callback?: Callback) {
return this.complete(async () => {
const cookie = sessionRow(data);
const expires = cookie.cookieExpires
? new Date(cookie.cookieExpires)
: new Date(Date.now() + 8 * 60 * 60 * 1000);
// Tocar la sesión renueva la cookie; no sobrescribe la autenticación de otra petición.
await this.database.query(
"UPDATE http_sessions SET expires=:expires,cookieExpires=:cookieExpires,updatedAt=NOW() WHERE sid=:sid",
{ replacements: { sid, expires, cookieExpires: cookie.cookieExpires } }
);
}, callback);
}
private async complete(operation: () => Promise<void>, callback?: Callback) {
let error: Error | null = null;
try {
await operation();
} catch (caught) {
if (!callback) {
throw caught;
}
error = failure(caught);
}
callback?.(error);
}
}

View File

@ -0,0 +1,190 @@
import { isDeepStrictEqual } from "node:util";
import session from "express-session";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
const optionalBoolean = { type: DataTypes.BOOLEAN, allowNull: true };
export const sessionColumns = {
userId: {
type: DataTypes.UUID,
allowNull: true,
references: { model: "users", key: "id" },
onDelete: "SET NULL",
},
authVersion: { type: DataTypes.INTEGER, allowNull: true },
csrfToken: { type: DataTypes.STRING(128), allowNull: true },
cookieExpires: { type: DataTypes.STRING(30), allowNull: true },
originalMaxAge: { type: DataTypes.BIGINT, allowNull: true },
cookiePath: { type: DataTypes.STRING(255), allowNull: true },
cookieDomain: { type: DataTypes.STRING(255), allowNull: true },
cookieSecure: { type: DataTypes.STRING(10), allowNull: true },
cookieHttpOnly: optionalBoolean,
cookieSameSite: { type: DataTypes.STRING(10), allowNull: true },
cookiePartitioned: optionalBoolean,
cookiePriority: { type: DataTypes.STRING(10), allowNull: true },
};
const cookieSchema = z.object({
originalMaxAge: z.number().int().nonnegative().nullable(),
expires: z.preprocess(
(value) => (value instanceof Date ? value.toISOString() : value),
z.iso.datetime().nullish()
),
path: z.string().max(255).optional(),
domain: z.string().max(255).optional(),
secure: z.union([z.boolean(), z.literal("auto")]).optional(),
httpOnly: z.boolean().optional(),
sameSite: z.union([z.boolean(), z.enum(["lax", "strict", "none"])]).optional(),
partitioned: z.boolean().optional(),
priority: z.enum(["low", "medium", "high"]).optional(),
});
const sessionSchema = z.strictObject({
cookie: cookieSchema,
userId: z.uuid().optional(),
authVersion: z.number().int().nonnegative().optional(),
csrfToken: z.string().max(128).optional(),
});
export function sessionValues(source: unknown) {
return sessionSchema.parse(source);
}
export function sessionRow(source: unknown) {
const data = sessionValues(source);
const cookie = data.cookie;
return {
userId: data.userId ?? null,
authVersion: data.authVersion ?? null,
csrfToken: data.csrfToken ?? null,
cookieExpires: cookie.expires ?? null,
originalMaxAge: cookie.originalMaxAge,
cookiePath: cookie.path ?? null,
cookieDomain: cookie.domain ?? null,
cookieSecure: cookie.secure === undefined ? null : String(cookie.secure),
cookieHttpOnly: cookie.httpOnly ?? null,
cookieSameSite: cookie.sameSite === undefined ? null : String(cookie.sameSite),
cookiePartitioned: cookie.partitioned ?? null,
cookiePriority: cookie.priority ?? null,
};
}
function flag(value: unknown) {
return z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(value);
}
function literal(value: unknown) {
return value === "true" ? true : value === "false" ? false : value;
}
export function sessionFromRow(row: Record<string, unknown>) {
return sessionValues({
cookie: {
originalMaxAge:
row.originalMaxAge === null
? null
: z.coerce.number().int().nonnegative().parse(row.originalMaxAge),
expires: row.cookieExpires,
...(row.cookiePath === null ? {} : { path: row.cookiePath }),
...(row.cookieDomain === null ? {} : { domain: row.cookieDomain }),
...(row.cookieSecure === null ? {} : { secure: literal(row.cookieSecure) }),
...(row.cookieHttpOnly === null ? {} : { httpOnly: flag(row.cookieHttpOnly) }),
...(row.cookieSameSite === null ? {} : { sameSite: literal(row.cookieSameSite) }),
...(row.cookiePartitioned === null ? {} : { partitioned: flag(row.cookiePartitioned) }),
...(row.cookiePriority === null ? {} : { priority: row.cookiePriority }),
},
...(row.userId === null ? {} : { userId: row.userId }),
...(row.authVersion === null ? {} : { authVersion: row.authVersion }),
...(row.csrfToken === null ? {} : { csrfToken: row.csrfToken }),
});
}
export function restoreSession(row: Record<string, unknown>): session.SessionData {
const data = sessionFromRow(row);
const cookie = Object.assign(new session.Cookie(), {
...data.cookie,
expires: data.cookie.expires ? new Date(data.cookie.expires) : null,
});
cookie.originalMaxAge = data.cookie.originalMaxAge;
return {
cookie,
userId: data.userId,
authVersion: data.authVersion,
csrfToken: data.csrfToken ?? "",
};
}
export async function writeSession(
database: Sequelize,
sid: string,
source: unknown,
transaction?: Transaction
) {
z.string().min(1).max(36).parse(sid);
const row = sessionRow(source);
const expires = row.cookieExpires
? new Date(row.cookieExpires)
: new Date(Date.now() + 8 * 60 * 60 * 1000);
const names = Object.keys(row);
await database.query(
`INSERT INTO http_sessions (sid,expires,createdAt,updatedAt,${names.map((name) => `\`${name}\``).join(",")}) VALUES (:sid,:expires,NOW(),NOW(),${names.map((name) => `:${name}`).join(",")}) ON DUPLICATE KEY UPDATE expires=VALUES(expires),updatedAt=NOW(),${names.map((name) => `\`${name}\`=VALUES(\`${name}\`)`).join(",")}`,
{ replacements: { sid, expires, ...row }, ...(transaction ? { transaction } : {}) }
);
}
export async function normalizeSessions(database: Sequelize) {
const query = database.getQueryInterface();
const original = await query.describeTable("http_sessions");
for (const [name, column] of Object.entries(sessionColumns)) {
if (!(name in original)) {
await query.addColumn("http_sessions", name, column);
}
}
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='http_sessions'",
{ type: QueryTypes.SELECT }
);
if (!indexes.some((index) => index.name === "http_sessions_expires")) {
await query.addIndex("http_sessions", ["expires"], { name: "http_sessions_expires" });
}
if (!original.data) {
return;
}
await database.transaction(async (transaction) => {
const rows = await database.query<Record<string, unknown>>("SELECT * FROM http_sessions", {
transaction,
type: QueryTypes.SELECT,
});
for (const row of rows) {
const source = typeof row.data === "string" ? JSON.parse(row.data) : row.data;
const expected = sessionRow(source);
const names = Object.keys(expected);
await database.query(
`UPDATE http_sessions SET ${names.map((name) => `\`${name}\`=:${name}`).join(",")} WHERE sid=:sid`,
{ replacements: { sid: row.sid, ...expected }, transaction }
);
const actual = await database.query<Record<string, unknown>>(
"SELECT * FROM http_sessions WHERE sid=:sid",
{ replacements: { sid: row.sid }, transaction, type: QueryTypes.SELECT }
);
if (!(actual[0] && isDeepStrictEqual(expected, sessionRow(sessionFromRow(actual[0]))))) {
throw new Error("La sesión normalizada no coincide con el origen.");
}
}
});
await database.query("ALTER TABLE http_sessions DROP COLUMN data");
}

View File

@ -7,12 +7,13 @@ import { UserInputMapper } from "./application/user-input.mapper";
import { registerIdentityRoutes } from "./infrastructure/http/identity.routes"; import { registerIdentityRoutes } from "./infrastructure/http/identity.routes";
import { requireAuthenticated } from "./infrastructure/http/require-authenticated"; import { requireAuthenticated } from "./infrastructure/http/require-authenticated";
import { migrateIdentity } from "./infrastructure/persistence/migrate-identity"; import { migrateIdentity } from "./infrastructure/persistence/migrate-identity";
import { SequelizeSessionStore } from "./infrastructure/persistence/sequelize-session.store";
import { SequelizeUserRepository } from "./infrastructure/persistence/sequelize-user.repository"; import { SequelizeUserRepository } from "./infrastructure/persistence/sequelize-user.repository";
import { initializeIdentityModels } from "./infrastructure/persistence/user.model"; import { initializeIdentityModels } from "./infrastructure/persistence/user.model";
import { UserPersistenceMapper } from "./infrastructure/persistence/user-persistence.mapper"; import { UserPersistenceMapper } from "./infrastructure/persistence/user-persistence.mapper";
import { ScryptPasswordHasher } from "./infrastructure/security/scrypt-password-hasher"; import { ScryptPasswordHasher } from "./infrastructure/security/scrypt-password-hasher";
export { migrateIdentity }; export { SequelizeSessionStore, migrateIdentity };
export async function createIdentityModule(database: Sequelize, origin: string) { export async function createIdentityModule(database: Sequelize, origin: string) {
initializeIdentityModels(database); initializeIdentityModels(database);

View File

@ -22,6 +22,10 @@ import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence
import { readDefinitions } from "./resource-catalog-relational"; import { readDefinitions } from "./resource-catalog-relational";
import { readInventory, writeInventory } from "./resource-inventory-relational"; import { readInventory, writeInventory } from "./resource-inventory-relational";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper"; import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
import {
readResourceProjectionHistory,
readResourceProjections,
} from "./resource-projection-reader";
const mailboxSchema = z.strictObject({ const mailboxSchema = z.strictObject({
address: z.email().max(254), address: z.email().max(254),
@ -44,14 +48,6 @@ const accessSchema = z.strictObject({
type SourceRow = { id: string; companyId: string; values: unknown }; type SourceRow = { id: string; companyId: string; values: unknown };
type RevisionRow = {
id: string;
actorId: string;
recordedAt: string;
values: unknown;
position: number;
};
function json(value: unknown): unknown { function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value; return typeof value === "string" ? JSON.parse(value) : value;
} }
@ -178,12 +174,7 @@ async function importResource(
return; return;
} }
const table = kind === "mailbox" ? "mailbox_revisions" : "access_revisions"; const rows = await readResourceProjectionHistory(database, kind, row.id, transaction);
const column = kind === "mailbox" ? "position" : "sequence";
const rows = await database.query<RevisionRow>(
`SELECT id,actorId,recordedAt,\`values\`,${column} AS position FROM ${table} WHERE itemId=:id ORDER BY ${column}`,
{ replacements: { id: row.id }, type: QueryTypes.SELECT, transaction }
);
const specification = spec(catalog, kind); const specification = spec(catalog, kind);
const values = ResourceValues.create(map(catalog, kind, row.values), specification); const values = ResourceValues.create(map(catalog, kind, row.values), specification);
@ -257,14 +248,13 @@ export async function migrateLegacyResources(database: Sequelize) {
); );
for (const company of companies) { for (const company of companies) {
const mailboxRows = await database.query<SourceRow>( const mailboxRows = await readResourceProjections(
"SELECT id,companyId,`values` FROM mailboxes WHERE companyId=:companyId", database,
{ replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction } "mailbox",
); company.id,
const accessRows = await database.query<SourceRow>( transaction
"SELECT i.id,w.companyId,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE i.kind='resource' AND w.companyId=:companyId",
{ replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction }
); );
const accessRows = await readResourceProjections(database, "access", company.id, transaction);
for (const row of mailboxRows) { for (const row of mailboxRows) {
await importResource(database, catalog, "mailbox", row, transaction); await importResource(database, catalog, "mailbox", row, transaction);

View File

@ -1,10 +1,13 @@
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import type { Sequelize, Transaction } from "sequelize"; import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize"; import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import type { Resource } from "../../domain/resource.aggregate"; import type { Resource } from "../../domain/resource.aggregate";
import type { ResourceInput } from "../../domain/resource-values"; import type { ResourceInput } from "../../domain/resource-values";
import { writeResourceRow } from "./resource-relational-sql";
/** Proyecciones para los contratos de accesos y perfiles: la ficha editable pertenece a resources. */ /** Proyecciones para los contratos de accesos y perfiles: la ficha editable pertenece a resources. */
export async function projectResource( export async function projectResource(
database: Sequelize, database: Sequelize,
@ -23,34 +26,32 @@ export async function projectResource(
if (integration.kind === "mailbox") { if (integration.kind === "mailbox") {
const values = mailboxProjection(current); const values = mailboxProjection(current);
await database.query( const columns = scalarProjection(values);
"INSERT INTO mailboxes (id,companyId,accountId,addressKey,`values`) VALUES (:id,:companyId,:accountId,:addressKey,:values) ON DUPLICATE KEY UPDATE accountId=VALUES(accountId),addressKey=VALUES(addressKey),`values`=VALUES(`values`)",
{ await writeResourceRow(
replacements: { database,
id, "mailboxes",
companyId, { id, companyId, addressKey: `${companyId}:${values.address}`, ...columns },
accountId: values.accountId, transaction
addressKey: `${companyId}:${values.address}`,
values: JSON.stringify(values),
},
transaction,
}
); );
for (const [position, revision] of resource.history.entries()) { for (const [position, revision] of resource.history.entries()) {
await database.query( const revisionId = revision.id.toPrimitive();
"INSERT INTO mailbox_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id",
{ if (!(await projectionExists(database, "mailbox_revisions", revisionId, transaction))) {
replacements: { await writeResourceRow(
id: revision.id.toPrimitive(), database,
"mailbox_revisions",
{
id: revisionId,
itemId: id, itemId: id,
position, position,
actorId: revision.actorId.toPrimitive(), actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt, recordedAt: revision.recordedAt,
values: JSON.stringify(mailboxProjection(revision.values.toPrimitive())), ...scalarProjection(mailboxProjection(revision.values.toPrimitive())),
}, },
transaction, transaction
} );
); }
} }
return; return;
@ -70,25 +71,47 @@ export async function projectResource(
} }
const values = accessProjection(current); const values = accessProjection(current);
await database.query( await writeResourceRow(
"INSERT INTO access_items (id,workspaceId,kind,`values`) VALUES (:id,:workspaceId,'resource',:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)", database,
{ replacements: { id, workspaceId, values: JSON.stringify(values) }, transaction } "access_items",
{ id, workspaceId, kind: "resource" },
transaction
);
const { kind: _kind, ...columns } = scalarProjection(values);
await writeResourceRow(
database,
"access_resource_values",
{ ownerId: id, ...columns },
transaction
); );
for (const [sequence, revision] of resource.history.entries()) { for (const [sequence, revision] of resource.history.entries()) {
await database.query( const revisionId = revision.id.toPrimitive();
"INSERT INTO access_revisions (id,itemId,sequence,actorId,recordedAt,`values`) VALUES (:id,:itemId,:sequence,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id",
{ if (!(await projectionExists(database, "access_revisions", revisionId, transaction))) {
replacements: { await writeResourceRow(
id: revision.id.toPrimitive(), database,
"access_revisions",
{
id: revisionId,
itemId: id, itemId: id,
sequence, sequence,
actorId: revision.actorId.toPrimitive(), actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt, recordedAt: revision.recordedAt,
values: JSON.stringify(accessProjection(revision.values.toPrimitive())),
}, },
transaction, transaction
} );
); const { kind: _revisionKind, ...frozen } = scalarProjection(
accessProjection(revision.values.toPrimitive())
);
await writeResourceRow(
database,
"access_resource_revision_values",
{ ownerId: revisionId, ...frozen },
transaction
);
}
} }
// El contexto es fijo; estas referencias preservan las FK utilizadas por permisos y necesidades. // El contexto es fijo; estas referencias preservan las FK utilizadas por permisos y necesidades.
for (const targetId of [ for (const targetId of [
@ -145,3 +168,24 @@ function accessProjection(input: ResourceInput) {
notes: input.notes || null, notes: input.notes || null,
}; };
} }
function scalarProjection(values: unknown) {
return z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.parse(values);
}
async function projectionExists(
database: Sequelize,
table: "mailbox_revisions" | "access_revisions",
id: string,
transaction: Transaction
) {
const rows = await database.query(`SELECT id FROM ${table} WHERE id=:id`, {
replacements: { id },
transaction,
type: QueryTypes.SELECT,
});
return rows.length > 0;
}

View File

@ -0,0 +1,75 @@
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
const bool = z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean);
const mailbox = z.object({
address: z.email(),
kind: z.enum(["corporate", "external"]),
context: z.string().nullable(),
accountId: z.uuid().nullable(),
active: bool,
notes: z.string().nullable(),
});
const access = z
.object({
name: z.string(),
active: bool,
resourceTypeId: z.uuid(),
systemId: z.uuid().nullable(),
environmentId: z.uuid().nullable(),
reference: z.string().nullable(),
notes: z.string().nullable(),
})
.transform((values) => ({ kind: "resource", ...values }));
const root = z.object({ id: z.uuid(), companyId: z.uuid() });
const revision = z.object({
id: z.uuid(),
actorId: z.uuid(),
recordedAt: z.iso.datetime(),
position: z.number().int().nonnegative(),
});
export async function readResourceProjections(
database: Sequelize,
kind: "mailbox" | "access",
companyId: string,
transaction: Transaction
) {
const sql =
kind === "mailbox"
? "SELECT * FROM mailboxes WHERE companyId=:companyId"
: "SELECT i.id,w.companyId,v.* FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId JOIN access_resource_values v ON v.ownerId=i.id WHERE w.companyId=:companyId";
const rows = await database.query<Record<string, unknown>>(sql, {
replacements: { companyId },
transaction,
type: QueryTypes.SELECT,
});
return rows.map((row) => ({
...root.parse(row),
values: kind === "mailbox" ? mailbox.parse(row) : access.parse(row),
}));
}
export async function readResourceProjectionHistory(
database: Sequelize,
kind: "mailbox" | "access",
id: string,
transaction: Transaction
) {
const sql =
kind === "mailbox"
? "SELECT * FROM mailbox_revisions WHERE itemId=:id ORDER BY position"
: "SELECT r.id,r.actorId,r.recordedAt,r.sequence AS position,v.* FROM access_revisions r JOIN access_resource_revision_values v ON v.ownerId=r.id WHERE r.itemId=:id ORDER BY r.sequence";
const rows = await database.query<Record<string, unknown>>(sql, {
replacements: { id },
transaction,
type: QueryTypes.SELECT,
});
return rows.map((row) => ({
...revision.parse(row),
values: kind === "mailbox" ? mailbox.parse(row) : access.parse(row),
}));
}

View File

@ -101,20 +101,20 @@ export class SequelizeResourceRepository implements IResourceRepository {
"SELECT id, name FROM users", "SELECT id, name FROM users",
{ type: QueryTypes.SELECT, transaction } { type: QueryTypes.SELECT, transaction }
); );
const rawReferences = await this.database.query<{ id: string; values: unknown }>( const rawReferences = await this.database.query<Record<string, unknown>>(
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE (w.companyId=:companyId OR w.companyId IS NULL) AND i.kind IN ('account','system','environment','resource_type')", "SELECT i.id,v.kind,v.name,v.identifier,v.active,v.systemId FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId JOIN access_current_values v ON v.ownerId=i.id AND v.kind=i.kind WHERE (w.companyId=:companyId OR w.companyId IS NULL) AND i.kind IN ('account','system','environment','resource_type')",
{ replacements: { companyId: companyId.toPrimitive() }, transaction, type: QueryTypes.SELECT } { replacements: { companyId: companyId.toPrimitive() }, transaction, type: QueryTypes.SELECT }
); );
const references = rawReferences.map((row) => { const references = rawReferences.map((row) => {
const data = z const data = z
.object({ .object({
kind: z.enum(["account", "system", "environment", "resource_type"]), kind: z.enum(["account", "system", "environment", "resource_type"]),
name: z.string().optional(), name: z.string().nullish(),
identifier: z.string().optional(), identifier: z.string().nullish(),
active: z.boolean(), active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean),
systemId: z.uuid().optional(), systemId: z.uuid().nullish(),
}) })
.parse(typeof row.values === "string" ? JSON.parse(row.values) : row.values); .parse(row);
return ResourceReferenceSchema.parse({ return ResourceReferenceSchema.parse({
id: row.id, id: row.id,

View File

@ -50,9 +50,6 @@ importers:
'@metis/resources': '@metis/resources':
specifier: workspace:* specifier: workspace:*
version: link:../../modules/resources version: link:../../modules/resources
connect-session-sequelize:
specifier: 8.0.6
version: 8.0.6(sequelize@6.37.8(mariadb@3.5.4))
express: express:
specifier: 4.22.1 specifier: 4.22.1
version: 4.22.1 version: 4.22.1
@ -2224,12 +2221,6 @@ packages:
confbox@0.1.8: confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
connect-session-sequelize@8.0.6:
resolution: {integrity: sha512-SAcXvRHKA3T1z5udDYK1dy783gh5CX7ok5dZNvuJEBd52FOi8ZfZjTMA8lwS357NNB2TmWDaV3bIBbypYco/CA==}
engines: {node: '>= 22'}
peerDependencies:
sequelize: '>= 6.37.7'
consola@3.4.2: consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0} engines: {node: ^14.18.0 || >=16.10.0}
@ -5591,13 +5582,6 @@ snapshots:
confbox@0.1.8: {} confbox@0.1.8: {}
connect-session-sequelize@8.0.6(sequelize@6.37.8(mariadb@3.5.4)):
dependencies:
debug: 4.4.3
sequelize: 6.37.8(mariadb@3.5.4)
transitivePeerDependencies:
- supports-color
consola@3.4.2: {} consola@3.4.2: {}
content-disposition@0.5.4: content-disposition@0.5.4: