diff --git a/apps/server/package.json b/apps/server/package.json index 9b191c5..651c807 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -11,23 +11,22 @@ "start": "node --env-file=../../.env dist/index.js" }, "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/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": { "@repo/typescript-config": "workspace:*", "@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" } } diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 79d7c4a..022f126 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -20,6 +20,8 @@ import { createResourceModule } from "@metis/resources/module"; import { QueryTypes } from "sequelize"; 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 { verifyAccess } from "./access.integration"; @@ -46,6 +48,7 @@ import { verifyPrinters } from "./printers.integration"; import { verifyProcesses } from "./processes.integration"; import { verifyRecordDeletion } from "./record-deletion.integration"; import { verifyRelationships } from "./relationships.integration"; +import { verifyRemainingNormalization } from "./remaining-normalization.integration"; import { verifyRemoteConnections } from "./remote.integration"; import { verifyResourceMigration } from "./resource-migration.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_references"); 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_workspaces WHERE companyId IS NOT NULL"); 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 ); await verifyConfigurationNormalization(database); + await verifyRemainingNormalization(database); await verifyRecordDeletion((path, body) => operator.request(path, body), database); const countAfterAccess = await database.query<{ total: number }>( "SELECT COUNT(*) AS total FROM users", diff --git a/apps/server/src/infrastructure/http/identity-runtime.ts b/apps/server/src/infrastructure/http/identity-runtime.ts index 5a35334..44050e8 100644 --- a/apps/server/src/infrastructure/http/identity-runtime.ts +++ b/apps/server/src/infrastructure/http/identity-runtime.ts @@ -1,8 +1,11 @@ import { createAssetModule } from "@metis/assets/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 connectSequelize from "connect-session-sequelize"; import session from "express-session"; import type { Sequelize } from "sequelize"; @@ -34,12 +37,7 @@ export async function createIdentityRuntime( assets.services.dashboard, resources.services.dashboard ); - const SequelizeStore = connectSequelize(session.Store); - const store = new SequelizeStore({ - db: database, - tableName: "http_sessions", - expiration: 8 * 60 * 60 * 1000, - }); + const store = new SequelizeSessionStore(database); const middleware = session({ name: "metis.sid", secret: environment.SESSION_SECRET, @@ -65,6 +63,7 @@ export async function createIdentityRuntime( dashboard.register(app); }, middleware, - stop: () => store.stopExpiringSessions(), + // El almacén relacional no utiliza un temporizador de fondo. + stop: () => undefined, }; } diff --git a/apps/server/src/mail.integration.ts b/apps/server/src/mail.integration.ts index 72f2299..b1cbcc1 100644 --- a/apps/server/src/mail.integration.ts +++ b/apps/server/src/mail.integration.ts @@ -19,6 +19,10 @@ import type { Sequelize } from "sequelize"; import { QueryTypes } from "sequelize"; 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; export async function verifyMail( @@ -304,32 +308,37 @@ export async function verifyMail( state: "configured", notes: "Conservar", }; - const raw = JSON.stringify(legacyValues); - await database.query( - "INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,NULL,NULL,:values,:key)", - { - replacements: { + await database.transaction(async (transaction) => { + await writeSnapshotRow( + database, + "mail_configurations", + mailSnapshot, + { id: legacyId, companyId, - ...legacyValues, - values: raw, - key: [person.id, account.id, first.id].join(":"), + values: legacyValues, + currentKey: [person.id, account.id, first.id].join(":"), }, - } - ); - await database.query( - "INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,0,:actorId,:recordedAt,:values)", - { - replacements: { + transaction, + "insert" + ); + await writeSnapshotRow( + database, + "mail_configuration_revisions", + mailSnapshot, + { id: revisionId, configurationId: legacyId, + position: 0, actorId, recordedAt: new Date().toISOString(), - values: raw, + values: legacyValues, }, - } - ); + transaction, + "insert" + ); + }); const legacy = MailConfigurationsResponseSchema.parse( await (await request(path)).json() ).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.length, 2); assert.deepEqual(adopted.history[0]?.values, legacyValues); - const persisted = await database.query<{ values: string }>( - "SELECT `values` FROM mail_configuration_revisions WHERE id=:id", + const persisted = await database.query>( + "SELECT * FROM mail_configuration_revisions WHERE id=:id", { replacements: { id: revisionId }, type: QueryTypes.SELECT } ); - const original = persisted[0]?.values; + const original = persisted[0]; 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", { replacements: { id: mobile.id }, }); diff --git a/apps/server/src/record-deletion.integration.ts b/apps/server/src/record-deletion.integration.ts index 5158334..654b0c0 100644 --- a/apps/server/src/record-deletion.integration.ts +++ b/apps/server/src/record-deletion.integration.ts @@ -35,7 +35,7 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize needId: 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 } ); const subject = folderSubjects[0]; diff --git a/apps/server/src/remaining-normalization.integration.ts b/apps/server/src/remaining-normalization.integration.ts new file mode 100644 index 0000000..d601b22 --- /dev/null +++ b/apps/server/src/remaining-normalization.integration.ts @@ -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[]>(); + + 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>( + "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>( + "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((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, []); +} diff --git a/docs/normalizacion-de-la-persistencia.md b/docs/normalizacion-de-la-persistencia.md index f736998..3e1d9de 100644 --- a/docs/normalizacion-de-la-persistencia.md +++ b/docs/normalizacion-de-la-persistencia.md @@ -4,7 +4,7 @@ Se mantiene Sequelize 6 y MariaDB. El criterio del proyecto es almacenar datos d ## 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 | | --- | --- | @@ -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 | | Teletrabajo | Estado, periodo, notas y persona en columnas actuales e históricas | | Aplicaciones, certificados, impresoras y carpetas del perfil | Valores escalares y referencias en columnas actuales e históricas | -| Cuentas y accesos | Pendiente: catálogo discriminado, asignaciones, permisos, grupos y necesidades | -| Correo | Pendiente: proyecciones de buzones, usos y configuraciones, incluido su formato histórico anterior | -| Accesos remotos | Pendiente | -| Procesos y plantillas de procesos | Pendiente: pasos y revisiones | +| Cuentas y accesos | Subtipos en tablas actuales e históricas; permisos de necesidades en asociaciones ordenadas | +| Correo | Buzones, usos y configuraciones en columnas actuales e históricas; variantes legadas conservadas | +| Accesos remotos | Origen, destinos con claves foráneas independientes, VPN y estado en columnas actuales e históricas | +| 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 | -| 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. @@ -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. -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 @@ -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. -## 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 @@ -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. - 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. + +## 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. diff --git a/modules/companies/src/api/infrastructure/persistence/access-persistence.ts b/modules/companies/src/api/infrastructure/persistence/access-persistence.ts index 762d086..0c56572 100644 --- a/modules/companies/src/api/infrastructure/persistence/access-persistence.ts +++ b/modules/companies/src/api/infrastructure/persistence/access-persistence.ts @@ -18,6 +18,15 @@ import { AccessValues } from "../../domain/access/access-values.vo"; import { AccessWorkspace } from "../../domain/access/access-workspace.aggregate"; import type { Person } from "../../domain/person.entity"; +import { + type AccessItemRow, + type AccessRevisionRow, + normalizeAccess, + readAccessItems, + readAccessRevisions, + writeAccessRow, +} from "./access-relational"; + class WorkspaceModel extends Model< InferAttributes, InferCreationAttributes @@ -27,25 +36,6 @@ class WorkspaceModel extends Model< declare scopeKey: string; } -class ItemModel extends Model, InferCreationAttributes> { - declare id: string; - declare workspaceId: string; - declare kind: string; - declare values: unknown; -} - -class RevisionModel extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: string; - declare itemId: string; - declare sequence: number; - declare actorId: string; - declare recordedAt: string; - declare values: unknown; -} - class ReferenceModel extends Model< InferAttributes, InferCreationAttributes @@ -93,7 +83,6 @@ export async function migrateAccess(database: Sequelize) { id: idColumn, workspaceId: reference("access_workspaces"), kind: { type: DataTypes.STRING(30), allowNull: false }, - values: { type: DataTypes.JSON, allowNull: false }, }); } if (!exists("access_revisions")) { @@ -103,7 +92,6 @@ export async function migrateAccess(database: Sequelize) { sequence: { type: DataTypes.INTEGER, allowNull: false }, actorId: reference("users"), recordedAt: { type: DataTypes.STRING(24), allowNull: false }, - values: { type: DataTypes.JSON, allowNull: false }, }); } if (!exists("access_references")) { @@ -141,26 +129,7 @@ export async function migrateAccess(database: Sequelize) { }, { sequelize: database, tableName: "access_workspaces", timestamps: false } ); - ItemModel.init( - { - 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 } - ); + await normalizeAccess(database); ReferenceModel.init( { 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 } ); // 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; if ("personId" in value) { @@ -219,7 +190,11 @@ function domainValues(value: unknown) { } 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 values = domainValues(row.values); const history = revisions @@ -343,13 +318,12 @@ export class SequelizeAccessRepository implements IAccessRepository { root = created; } const roots = company ? [global, root] : await WorkspaceModel.findAll({ transaction }); - const rows = await ItemModel.findAll({ - where: { workspaceId: roots.map((item) => item.id) }, - transaction, - }); - const revisions = rows.length - ? await RevisionModel.findAll({ where: { itemId: rows.map((item) => item.id) }, transaction }) - : []; + const rows = (await readAccessItems(this.database, transaction)).filter((row) => + roots.some((root) => root.id === row.workspaceId) + ); + const revisions = (await readAccessRevisions(this.database, transaction)).filter((revision) => + rows.some((row) => row.id === revision.itemId) + ); const workspace = this.mapper.toDomain( root, rows.filter((row) => row.workspaceId === root.id), @@ -419,11 +393,20 @@ export class SequelizeAccessRepository implements IAccessRepository { ); 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)); 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]; diff --git a/modules/companies/src/api/infrastructure/persistence/access-relational.ts b/modules/companies/src/api/infrastructure/persistence/access-relational.ts new file mode 100644 index 0000000..da3b653 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/access-relational.ts @@ -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> = { + 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; + +export type AccessRevisionRow = z.infer; + +function decoded(row: Record, 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>( + `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>( + "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, + 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>("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>("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); +} diff --git a/modules/companies/src/api/infrastructure/persistence/configuration-snapshots.ts b/modules/companies/src/api/infrastructure/persistence/configuration-snapshots.ts index 6407b69..4dc84b9 100644 --- a/modules/companies/src/api/infrastructure/persistence/configuration-snapshots.ts +++ b/modules/companies/src/api/infrastructure/persistence/configuration-snapshots.ts @@ -145,7 +145,22 @@ export const folderSnapshot = { booleans: [], } satisfies SnapshotDefinition>; +import { + mailSnapshot, + mailboxSnapshot, + mailboxUseSnapshot, + remoteSnapshot, +} from "./mail-remote-snapshots"; + export const configurationSnapshots = new Map>([ + ["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_profile_revisions", profileSnapshot], ["telework_authorizations", teleworkSnapshot], diff --git a/modules/companies/src/api/infrastructure/persistence/mail-configuration-migration.ts b/modules/companies/src/api/infrastructure/persistence/mail-configuration-migration.ts index caed272..a7a54a6 100644 --- a/modules/companies/src/api/infrastructure/persistence/mail-configuration-migration.ts +++ b/modules/companies/src/api/infrastructure/persistence/mail-configuration-migration.ts @@ -1,6 +1,10 @@ import type { Sequelize } 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) { const query = database.getQueryInterface(); const rows = await database.query<{ name: string }>( @@ -19,12 +23,7 @@ export async function migrateMailConfigurations(database: Sequelize) { await query.createTable("mail_configurations", { id: { type: DataTypes.UUID, primaryKey: true }, companyId: reference("companies"), - personId: { ...reference("company_contacts"), allowNull: true }, - 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 }, + ...mailSnapshot.columns, 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 }, actorId: reference("users"), 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", }); } + 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)" + ); + } } diff --git a/modules/companies/src/api/infrastructure/persistence/mail-configuration-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/mail-configuration-persistence.mapper.ts index be14d79..f5c4b42 100644 --- a/modules/companies/src/api/infrastructure/persistence/mail-configuration-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/mail-configuration-persistence.mapper.ts @@ -20,6 +20,9 @@ export function persistedJson(value: unknown): unknown { return typeof value === "string" ? JSON.parse(value) : value; } +import { mailSnapshot } from "./mail-remote-snapshots"; +import { snapshotValues } from "./scalar-snapshot-normalization"; + export type MailRow = { id: string; companyId: string; @@ -71,10 +74,10 @@ function mailReferences(values: MailConfigurationValues) { export class MailConfigurationPersistenceMapper { 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 history = ordered.map((revision, position) => { - const revisionValues = valuesOf(revision.values); + const revisionValues = valuesOf(snapshotValues(mailSnapshot, revision)); if ( revision.position !== position || @@ -134,7 +137,7 @@ export class MailConfigurationPersistenceMapper { id: item.id.toPrimitive(), companyId: item.companyId.toPrimitive(), ...mailReferences(item.values), - values: JSON.stringify(values), + values: values, }, revisions: item.history.map((revision, position) => ({ id: revision.id.toPrimitive(), @@ -142,7 +145,7 @@ export class MailConfigurationPersistenceMapper { position, actorId: revision.actorId.toPrimitive(), recordedAt: revision.recordedAt, - values: JSON.stringify(revision.values.toPrimitive()), + values: revision.values.toPrimitive(), })), }; } diff --git a/modules/companies/src/api/infrastructure/persistence/mail-remote-constraints.ts b/modules/companies/src/api/infrastructure/persistence/mail-remote-constraints.ts new file mode 100644 index 0000000..a4493e2 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/mail-remote-constraints.ts @@ -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})` + ); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/mail-remote-snapshots.ts b/modules/companies/src/api/infrastructure/persistence/mail-remote-snapshots.ts new file mode 100644 index 0000000..982dd78 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/mail-remote-snapshots.ts @@ -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>; + +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>; + +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) { + 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>; + +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) { + 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>; diff --git a/modules/companies/src/api/infrastructure/persistence/mailbox-migration.ts b/modules/companies/src/api/infrastructure/persistence/mailbox-migration.ts index 10222a9..2e40784 100644 --- a/modules/companies/src/api/infrastructure/persistence/mailbox-migration.ts +++ b/modules/companies/src/api/infrastructure/persistence/mailbox-migration.ts @@ -1,6 +1,9 @@ import type { Sequelize } 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) { const query = database.getQueryInterface(); const tables = await database.query<{ name: string }>( @@ -18,7 +21,6 @@ export async function migrateMailboxes(database: Sequelize) { const root = { id: { type: DataTypes.UUID, primaryKey: true }, companyId: fk("companies"), - values: { type: DataTypes.JSON, allowNull: false }, }; for (const [table, columns] of [ @@ -26,18 +28,27 @@ export async function migrateMailboxes(database: Sequelize) { "mailboxes", { ...root, + ...mailboxSnapshot.columns, accountId: fk("access_items", 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", { id: root.id, itemId: fk("mailboxes"), position: { type: DataTypes.INTEGER, allowNull: false }, - values: root.values, + ...mailboxSnapshot.columns, actorId: fk("users"), recordedAt: { type: DataTypes.STRING(24), allowNull: false }, }, @@ -48,7 +59,7 @@ export async function migrateMailboxes(database: Sequelize) { id: root.id, itemId: fk("mailbox_uses"), position: { type: DataTypes.INTEGER, allowNull: false }, - values: root.values, + ...mailboxUseSnapshot.columns, actorId: fk("users"), 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 + ); } diff --git a/modules/companies/src/api/infrastructure/persistence/mailbox-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/mailbox-persistence.mapper.ts index 2304763..c1d20e3 100644 --- a/modules/companies/src/api/infrastructure/persistence/mailbox-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/mailbox-persistence.mapper.ts @@ -9,6 +9,9 @@ import { MailboxInputSchema, MailboxUseInputSchema } from "../../domain/mailbox- import { MailboxUseValues } from "../../domain/value-objects/mailbox-use-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) { const value = UniqueID.create(raw); @@ -86,11 +89,11 @@ function mailboxUseValues(raw: unknown) { export class MailboxPersistenceMapper { toDomain(row: MailboxRow, revisions: readonly MailboxRevisionRow[]) { - const values = mailboxValues(row.values); + const values = mailboxValues(snapshotValues(mailboxSnapshot, row)); const history = ordered(revisions).map((revision) => MailboxRevision.rehydrate( { - values: mailboxValues(revision.values), + values: mailboxValues(snapshotValues(mailboxSnapshot, revision)), actorId: mailboxIdentity(revision.actorId), recordedAt: revision.recordedAt, }, @@ -114,11 +117,11 @@ export class MailboxPersistenceMapper { } toUseDomain(row: MailboxUseRow, revisions: readonly MailboxRevisionRow[]) { - const values = mailboxUseValues(row.values); + const values = mailboxUseValues(snapshotValues(mailboxUseSnapshot, row)); const history = ordered(revisions).map((revision) => MailboxUseRevision.rehydrate( { - values: mailboxUseValues(revision.values), + values: mailboxUseValues(snapshotValues(mailboxUseSnapshot, revision)), actorId: mailboxIdentity(revision.actorId), recordedAt: revision.recordedAt, }, @@ -152,12 +155,12 @@ export class MailboxPersistenceMapper { return { id: item.id.toPrimitive(), companyId: item.companyId.toPrimitive(), - values: JSON.stringify(item.values.toPrimitive()), + values: item.values.toPrimitive(), revisions: item.history.map((revision, position) => ({ id: revision.id.toPrimitive(), itemId: item.id.toPrimitive(), position, - values: JSON.stringify(revision.values.toPrimitive()), + values: revision.values.toPrimitive(), actorId: revision.actorId.toPrimitive(), recordedAt: revision.recordedAt, })), diff --git a/modules/companies/src/api/infrastructure/persistence/process-migration.ts b/modules/companies/src/api/infrastructure/persistence/process-migration.ts index 4ca130f..01b4f95 100644 --- a/modules/companies/src/api/infrastructure/persistence/process-migration.ts +++ b/modules/companies/src/api/infrastructure/persistence/process-migration.ts @@ -5,6 +5,12 @@ import { DataTypes, QueryTypes } from "sequelize"; import { ProcessTemplate } from "../../domain/process-template.aggregate"; import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values.vo"; +import { + normalizeProcesses, + processColumns, + processTemplateColumns, + writeProcessRow, +} from "./process-relational"; import { ProcessTemplatePersistenceMapper, processTemplateNameKey, @@ -26,12 +32,10 @@ export async function migrateProcesses(database: Sequelize) { const root = { id: { type: DataTypes.UUID, primaryKey: true }, - values: { type: DataTypes.JSON, allowNull: false }, }; const revision = { id: root.id, position: { type: DataTypes.INTEGER, allowNull: false }, - values: root.values, actorId: fk("users", true), recordedAt: { type: DataTypes.STRING(24), allowNull: false }, }; @@ -42,22 +46,30 @@ export async function migrateProcesses(database: Sequelize) { "process_templates", { ...root, + ...processTemplateColumns, nameKey: { type: DataTypes.STRING(160), allowNull: false, 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", { ...root, + ...processColumns, companyId: fk("companies"), personId: fk("company_contacts"), templateId: fk("process_templates"), 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) { if (!tables.some((t) => t.name === table)) { 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 normalizeProcesses(database); const defaults = [ { key: "onboarding", @@ -153,14 +166,20 @@ export async function migrateProcesses(database: Sequelize) { } const record = new ProcessTemplatePersistenceMapper().toPersistence(template.data); - await database.query( - "INSERT INTO process_templates (id,nameKey,builtinKey,`values`) VALUES (:id,:nameKey,:builtinKey,:values)", - { replacements: { ...record.root, builtinKey: item.key }, transaction } + await writeProcessRow( + database, + "process_templates", + { ...record.root, builtinKey: item.key }, + transaction, + "insert" ); for (const revision of record.revisions) { - await database.query( - "INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)", - { replacements: revision, transaction } + await writeProcessRow( + database, + "process_template_revisions", + revision, + transaction, + "insert" ); } } @@ -169,7 +188,7 @@ export async function migrateProcesses(database: Sequelize) { async function normalizeTemplateNameKeys(database: Sequelize, transaction: Transaction) { 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 } ); diff --git a/modules/companies/src/api/infrastructure/persistence/process-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/process-persistence.mapper.ts index 7ac49e4..7be1545 100644 --- a/modules/companies/src/api/infrastructure/persistence/process-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/process-persistence.mapper.ts @@ -27,6 +27,23 @@ export type PersonProcessRevisionRow = { 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) { const parsed = UniqueID.create(value); @@ -85,7 +102,9 @@ function validateTransitions(history: readonly PersonProcessRevision[]) { } 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 ordered = [...revisions].sort((a, b) => a.position - b.position); const history = ordered.map((r, index) => { @@ -137,7 +156,7 @@ export class PersonProcessPersistenceMapper { currentKey: item.values.closed ? null : `${item.companyId.toPrimitive()}:${values.personId}:${values.kind}`, - values: JSON.stringify(values), + values: values, }, revisions: item.history.map((r, position) => ({ id: r.id.toPrimitive(), @@ -145,7 +164,7 @@ export class PersonProcessPersistenceMapper { position, actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null, recordedAt: r.recordedAt, - values: JSON.stringify(r.values.toPrimitive()), + values: r.values.toPrimitive(), })), }; } diff --git a/modules/companies/src/api/infrastructure/persistence/process-relational.ts b/modules/companies/src/api/infrastructure/persistence/process-relational.ts new file mode 100644 index 0000000..b13643c --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/process-relational.ts @@ -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 { + 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) { + 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[]> { + const rows = await database.query>( + `SELECT * FROM ${supportIdentifier(table)} ORDER BY id`, + { type: QueryTypes.SELECT, transaction } + ); + const childRows = await database.query>( + `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, + 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); + } + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/process-review-reader.ts b/modules/companies/src/api/infrastructure/persistence/process-review-reader.ts index 9296eef..33852a0 100644 --- a/modules/companies/src/api/infrastructure/persistence/process-review-reader.ts +++ b/modules/companies/src/api/infrastructure/persistence/process-review-reader.ts @@ -45,6 +45,8 @@ function configurationDetail( type Profile = { id: string; values: WorkProfileInput }; +import { readAccessItems } from "./access-relational"; + type Row = { id: string; values: unknown }; type Table = @@ -200,10 +202,13 @@ export class ProcessReviewReader { ...this.remote(await rows("remote_connections"), assetNames, resourceNames), ]; const relationships: ProcessReviewSources["relationships"][number][] = []; - const rawAccess = await this.database.query<{ id: string; values: unknown }>( - "SELECT i.id,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId", + const workspaces = await this.database.query<{ id: string }>( + "SELECT id FROM access_workspaces WHERE companyId=:companyId", { 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) => ({ id: profileIdentity(r.id).toPrimitive(), values: AccessDefinitionSchema.parse(persistedJson(r.values)), @@ -211,7 +216,7 @@ export class ProcessReviewReader { relationships.push(...accessReviewRelationships(access)); 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 } ); diff --git a/modules/companies/src/api/infrastructure/persistence/process-template-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/process-template-persistence.mapper.ts index 54b6105..afe4633 100644 --- a/modules/companies/src/api/infrastructure/persistence/process-template-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/process-template-persistence.mapper.ts @@ -22,6 +22,16 @@ export type ProcessTemplateRevisionRow = { 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) { const parsed = UniqueID.create(value); @@ -43,7 +53,9 @@ function mapped(raw: unknown) { } 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 ordered = [...revisions].sort((a, b) => a.position - b.position); const history = ordered.map((r, index) => { @@ -84,7 +96,7 @@ export class ProcessTemplatePersistenceMapper { root: { id: item.id.toPrimitive(), nameKey: processTemplateNameKey(values.name), - values: JSON.stringify(values), + values: values, }, revisions: item.history.map((r, position) => ({ id: r.id.toPrimitive(), @@ -92,7 +104,7 @@ export class ProcessTemplatePersistenceMapper { position, actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null, recordedAt: r.recordedAt, - values: JSON.stringify(r.values.toPrimitive()), + values: r.values.toPrimitive(), })), }; } diff --git a/modules/companies/src/api/infrastructure/persistence/remote-connection-migration.ts b/modules/companies/src/api/infrastructure/persistence/remote-connection-migration.ts index aa99530..5f0e2a5 100644 --- a/modules/companies/src/api/infrastructure/persistence/remote-connection-migration.ts +++ b/modules/companies/src/api/infrastructure/persistence/remote-connection-migration.ts @@ -1,6 +1,10 @@ import type { Sequelize } 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) { const query = database.getQueryInterface(); const tables = await database.query<{ name: string }>( @@ -19,12 +23,7 @@ export async function migrateRemoteConnections(database: Sequelize) { await query.createTable("remote_connections", { id: { type: DataTypes.UUID, primaryKey: true }, companyId: reference("companies"), - personId: reference("company_contacts"), - sourceAssetId: reference("assets", true), - targetAssetId: reference("assets", true), - targetResourceId: reference("resources", true), - siteId: reference("company_sites", true), - values: { type: DataTypes.JSON, allowNull: false }, + ...remoteSnapshot.columns, 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 }, actorId: reference("users"), recordedAt: { type: DataTypes.STRING(24), allowNull: false }, - values: { type: DataTypes.JSON, allowNull: false }, + ...remoteSnapshot.columns, }); } const indexes = await database.query<{ name: string }>( @@ -49,4 +48,17 @@ export async function migrateRemoteConnections(database: Sequelize) { 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))" + ); + } } diff --git a/modules/companies/src/api/infrastructure/persistence/remote-connection-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/remote-connection-persistence.mapper.ts index 7e3585d..2293828 100644 --- a/modules/companies/src/api/infrastructure/persistence/remote-connection-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/remote-connection-persistence.mapper.ts @@ -10,6 +10,8 @@ import { } from "../../domain/remote-connection-values"; 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"; export type RemoteRow = { @@ -47,10 +49,10 @@ function mapped(raw: unknown) { export class RemoteConnectionPersistenceMapper { 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 history = ordered.map((revision, index) => { - const value = mapped(revision.values); + const value = mapped(snapshotValues(remoteSnapshot, revision)); const previous = ordered[index - 1]; if ( @@ -123,7 +125,7 @@ export class RemoteConnectionPersistenceMapper { targetAssetId: values.targetKind === "asset" ? values.targetId : null, targetResourceId: values.targetKind === "resource" ? values.targetId : null, siteId: values.siteId, - values: JSON.stringify(values), + values: values, ...remoteKeys(item.values), }, revisions: item.history.map((revision, position) => ({ @@ -132,7 +134,7 @@ export class RemoteConnectionPersistenceMapper { position, actorId: revision.actorId.toPrimitive(), recordedAt: revision.recordedAt, - values: JSON.stringify(revision.values.toPrimitive()), + values: revision.values.toPrimitive(), })), }; } diff --git a/modules/companies/src/api/infrastructure/persistence/scalar-snapshot-normalization.ts b/modules/companies/src/api/infrastructure/persistence/scalar-snapshot-normalization.ts index 83c5d0c..385bba3 100644 --- a/modules/companies/src/api/infrastructure/persistence/scalar-snapshot-normalization.ts +++ b/modules/companies/src/api/infrastructure/persistence/scalar-snapshot-normalization.ts @@ -10,6 +10,8 @@ export type SnapshotDefinition = { schema: z.ZodType; columns: Columns; booleans?: readonly string[]; + fromColumns?: (row: Record) => unknown; + toColumns?: (input: T) => object; }; function identifier(value: string) { @@ -41,7 +43,7 @@ export function snapshotValues(definition: SnapshotDefinition, row: object }) ); - return definition.schema.parse(input); + return definition.schema.parse(definition.fromColumns ? definition.fromColumns(input) : input); } export async function normalizeScalarSnapshots( @@ -100,7 +102,7 @@ async function transferSnapshotRow( const expected = snapshotValues(definition, row); const values = z .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)) { if (name.endsWith("Id") && original.has(name) && !isDeepStrictEqual(row[name], value)) { diff --git a/modules/companies/src/api/infrastructure/persistence/scalar-snapshot-writer.ts b/modules/companies/src/api/infrastructure/persistence/scalar-snapshot-writer.ts new file mode 100644 index 0000000..104e701 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/scalar-snapshot-writer.ts @@ -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( + database: Sequelize, + table: string, + definition: SnapshotDefinition, + 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 + ); +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts index 76cf8f4..15c9749 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts @@ -13,11 +13,11 @@ import type { const OPEN_TICKET = "t.state IN ('open','in_progress','waiting')"; 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 ( - account.id IS NULL OR JSON_EXTRACT(account.\`values\`,'$.active')<>true OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.usage'))='technical' - OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.expiresOn')) < :today - OR NOT EXISTS (SELECT 1 FROM access_items s WHERE s.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.systemId')) AND s.kind='system' AND JSON_EXTRACT(s.\`values\`,'$.active')=true) - 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 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')) + account.ownerId IS NULL OR account.active<>true OR account.usage='technical' + OR account.expiresOn < :today + 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(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_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 { @@ -102,7 +102,7 @@ export class SequelizeDashboardRepository implements IDashboardRepository { private async issues(query: DashboardDetailsQuery) { if (query.issue === "profile") { 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 count = await this.select<{ total: number }>( `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'))` ) .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')))) - OR EXISTS (SELECT 1 FROM remote_connections r WHERE r.companyId=p.companyId AND r.personId=p.id AND JSON_UNQUOTE(JSON_EXTRACT(r.\`values\`,'$.state')) NOT IN ('removed','never_configured')) - OR EXISTS (SELECT 1 FROM mail_configurations m LEFT JOIN work_profiles w ON w.id=m.profileId WHERE m.companyId=p.companyId AND (m.personId=p.id OR w.personId=p.id) AND JSON_UNQUOTE(JSON_EXTRACT(m.\`values\`,'$.state')) NOT IN ('removed','never_configured')) + 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 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 m.state NOT IN ('removed','never_configured')) OR ${profileConfigurations} OR EXISTS (SELECT 1 FROM telework_authorizations ta WHERE ta.companyId=p.companyId AND ta.personId=p.id AND ta.active=true AND (ta.expiresOn IS NULL OR ta.expiresOn>=:today)))`; const extra = { ids: ids.length ? ids : [""] }; diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-folder-mapping.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-folder-mapping.repository.ts index f995cdf..59139a8 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-folder-mapping.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-folder-mapping.repository.ts @@ -9,18 +9,14 @@ import type { } from "../../application/folder-mapping.repository"; import { folderReferences } from "../../application/folder-mapping-policy"; import type { IWorkProfileInventoryService } from "../../application/work-profile.repository"; -import { AccessDefinitionSchema } from "../../domain/access/access-definition"; import { AccessValues } from "../../domain/access/access-values.vo"; import { FolderMappingError } from "../../domain/folder-mapping-values"; +import { readCompanyAccessItems } from "./access-relational"; import type { FolderRevisionRow, FolderRow } from "./folder-mapping-persistence.mapper"; import { FolderMappingPersistenceMapper } from "./folder-mapping-persistence.mapper"; import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper"; -import { - WorkProfilePersistenceMapper, - persistedJson, - profileIdentity, -} from "./work-profile-persistence.mapper"; +import { WorkProfilePersistenceMapper, profileIdentity } from "./work-profile-persistence.mapper"; export class SequelizeFolderMappingRepository implements IFolderMappingRepository { 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", { ...options, type: QueryTypes.SELECT } ); - const items = await this.database.query<{ id: string; values: 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", - { ...options, type: QueryTypes.SELECT } - ); + const items = await readCompanyAccessItems(this.database, company.toPrimitive(), transaction); const people = await this.database.query<{ id: string; active: boolean }>( "SELECT id,active FROM company_contacts WHERE companyId=:companyId", { ...options, type: QueryTypes.SELECT } @@ -87,9 +80,7 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor ) ), items: items.map((item) => { - const values = AccessValues.create( - AccessDefinitionSchema.parse(persistedJson(item.values)) - ); + const values = AccessValues.create(item.values); if (!values.isSuccess) { throw new Error("Recurso de carpeta persistido inconsistente."); diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-mail-configuration.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-mail-configuration.repository.ts index 2e57f38..5d1c2c6 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-mail-configuration.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-mail-configuration.repository.ts @@ -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 { private readonly mapper = new MailConfigurationPersistenceMapper(); @@ -228,18 +231,26 @@ export class SequelizeMailConfigurationRepository implements IMailConfigurationR if (previous !== candidate.data) { const record = this.mapper.toPersistence(candidate.data); - await this.database.query( - "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)", - { replacements: record.root, transaction } + await writeSnapshotRow( + this.database, + "mail_configurations", + mailSnapshot, + record.root, + transaction, + previous ? "update" : "insert" ); const stored = new Set( previous?.history.map((revision) => revision.id.toPrimitive()) ?? [] ); for (const revision of record.revisions.filter((item) => !stored.has(item.id))) { - await this.database.query( - "INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)", - { replacements: revision, transaction } + await writeSnapshotRow( + this.database, + "mail_configuration_revisions", + mailSnapshot, + revision, + transaction, + "insert" ); } } diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-mailbox.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-mailbox.repository.ts index 2d70528..e186f73 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-mailbox.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-mailbox.repository.ts @@ -9,8 +9,11 @@ import { Mailbox } from "../../domain/mailbox.aggregate"; import type { MailboxUse } from "../../domain/mailbox-use.aggregate"; 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 { MailboxPersistenceMapper, mailboxIdentity } from "./mailbox-persistence.mapper"; +import { writeSnapshotRow } from "./scalar-snapshot-writer"; export class SequelizeMailboxRepository implements IMailboxRepository { private readonly mapper = new MailboxPersistenceMapper(); @@ -51,10 +54,7 @@ export class SequelizeMailboxRepository implements IMailboxRepository { "SELECT id,active FROM company_contacts WHERE companyId=:companyId", { replacements, transaction, type: QueryTypes.SELECT } ); - const items = await this.database.query<{ id: string; values: 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", - { replacements, transaction, type: QueryTypes.SELECT } - ); + const items = await readCompanyAccessItems(this.database, companyId.toPrimitive(), transaction); const actors = await this.database.query<{ id: string; name: string }>( "SELECT id,name FROM users", { 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))) { const table = item instanceof Mailbox ? "mailbox_revisions" : "mailbox_use_revisions"; - await this.database.query( - `INSERT INTO ${table} (id,itemId,position,actorId,recordedAt,\`values\`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)`, - { replacements: revision, transaction } - ); + if (item instanceof Mailbox) { + await writeSnapshotRow( + 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)); } + 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[1]) { return this.database.transaction(async (transaction) => { 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)); if (previous !== item) { - const row = this.mapper.toPersistence(item); - - 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); + await this.persistItem(item, previous, transaction); } return Result.ok({ diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-process.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-process.repository.ts index 2781e26..c032e50 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-process.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-process.repository.ts @@ -12,13 +12,9 @@ import type { } from "../../application/process.repository"; import { ProcessError } from "../../domain/process-values"; -import type { PersonProcessRevisionRow, PersonProcessRow } from "./process-persistence.mapper"; import { PersonProcessPersistenceMapper } from "./process-persistence.mapper"; +import { readProcessRows, writeProcessRow } from "./process-relational"; import { ProcessReviewReader } from "./process-review-reader"; -import type { - ProcessTemplateRevisionRow, - ProcessTemplateRow, -} from "./process-template-persistence.mapper"; import { ProcessTemplatePersistenceMapper } from "./process-template-persistence.mapper"; export class SequelizeProcessRepository implements IProcessRepository { @@ -32,13 +28,11 @@ export class SequelizeProcessRepository implements IProcessRepository { ) {} private async loadCatalog(transaction: Transaction): Promise { - const templates = await this.database.query( - "SELECT * FROM process_templates", - { transaction, type: QueryTypes.SELECT } - ); - const revisions = await this.database.query( - "SELECT * FROM process_template_revisions", - { transaction, type: QueryTypes.SELECT } + const templates = await readProcessRows(this.database, "process_templates", transaction); + const revisions = await readProcessRows( + this.database, + "process_template_revisions", + transaction ); const actors = await this.database.query<{ id: string; name: string }>( "SELECT id,name FROM users", @@ -71,14 +65,10 @@ export class SequelizeProcessRepository implements IProcessRepository { return Result.fail(new ProcessError("COMPANY_NOT_FOUND")); } const catalog = await this.loadCatalog(transaction); - const rows = await this.database.query( - "SELECT * FROM person_processes WHERE companyId=:companyId", - { replacements: { companyId }, transaction, type: QueryTypes.SELECT } - ); - const revisions = await this.database.query( - "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 rows = (await readProcessRows(this.database, "person_processes", transaction)).filter( + (row) => row.companyId === companyId ); + const revisions = await readProcessRows(this.database, "person_process_revisions", transaction); const persons = await this.database.query<{ id: string; name: string }>( "SELECT id,name FROM company_contacts WHERE companyId=:companyId", { replacements: { companyId }, transaction, type: QueryTypes.SELECT } @@ -147,16 +137,22 @@ export class SequelizeProcessRepository implements IProcessRepository { if (previous !== candidate.data) { const record = this.templateMapper.toPersistence(candidate.data); - await this.database.query( - "INSERT INTO process_templates (id,nameKey,`values`) VALUES (:id,:nameKey,:values) ON DUPLICATE KEY UPDATE nameKey=VALUES(nameKey),`values`=VALUES(`values`)", - { replacements: record.root, transaction } + await writeProcessRow( + this.database, + "process_templates", + record.root, + transaction, + previous ? "update" : "insert" ); const known = new Set(previous?.history.map((r) => r.id.toPrimitive())); for (const r of record.revisions.filter((r) => !known.has(r.id))) { - await this.database.query( - "INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)", - { replacements: r, transaction } + await writeProcessRow( + this.database, + "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", { replacements: { id: process.toPrimitive() }, transaction, @@ -210,16 +208,22 @@ export class SequelizeProcessRepository implements IProcessRepository { if (previous !== candidate.data) { const record = this.processMapper.toPersistence(candidate.data); - await this.database.query( - "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`)", - { replacements: record.root, transaction } + await writeProcessRow( + this.database, + "person_processes", + record.root, + transaction, + previous ? "update" : "insert" ); const known = new Set(previous?.history.map((r) => r.id.toPrimitive())); for (const r of record.revisions.filter((r) => !known.has(r.id))) { - await this.database.query( - "INSERT INTO person_process_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)", - { replacements: r, transaction } + await writeProcessRow( + this.database, + "person_process_revisions", + r, + transaction, + "insert" ); } } diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts index c9a1271..b0e76e5 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts @@ -12,8 +12,10 @@ import { remoteReferences } from "../../application/remote-connection-policy"; import type { IWorkProfileInventoryService } from "../../application/work-profile.repository"; import { RemoteConnectionError } from "../../domain/remote-connection-values"; +import { remoteSnapshot } from "./mail-remote-snapshots"; import type { RemoteRevisionRow, RemoteRow } 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 { TeleworkAuthorizationPersistenceMapper, @@ -125,16 +127,24 @@ export class SequelizeRemoteConnectionRepository implements IRemoteConnectionRep if (previous !== candidate.data) { const record = this.mapper.toPersistence(candidate.data); - await this.database.query( - "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)", - { replacements: record.root, transaction } + await writeSnapshotRow( + this.database, + "remote_connections", + remoteSnapshot, + record.root, + transaction, + previous ? "update" : "insert" ); const stored = new Set(previous?.history.map((r) => r.id.toPrimitive())); for (const revision of record.revisions.filter((r) => !stored.has(r.id))) { - await this.database.query( - "INSERT INTO remote_connection_revisions (id,connectionId,position,actorId,recordedAt,`values`) VALUES (:id,:connectionId,:position,:actorId,:recordedAt,:values)", - { replacements: revision, transaction } + await writeSnapshotRow( + this.database, + "remote_connection_revisions", + remoteSnapshot, + revision, + transaction, + "insert" ); } } diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-work-profile.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-work-profile.repository.ts index 0420f04..832c73b 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-work-profile.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-work-profile.repository.ts @@ -8,15 +8,11 @@ import type { IWorkProfileRepository, WorkProfileContext, } from "../../application/work-profile.repository"; -import { AccessDefinitionSchema } from "../../domain/access/access-definition"; import { WorkProfileError } from "../../domain/work-profile-values"; +import { readCompanyAccessItems } from "./access-relational"; import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper"; -import { - WorkProfilePersistenceMapper, - persistedJson, - profileIdentity, -} from "./work-profile-persistence.mapper"; +import { WorkProfilePersistenceMapper, profileIdentity } from "./work-profile-persistence.mapper"; export class SequelizeWorkProfileRepository implements IWorkProfileRepository { private readonly mapper = new WorkProfilePersistenceMapper(); @@ -53,10 +49,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository { "SELECT id,active FROM company_contacts WHERE companyId=:companyId", { replacements: { companyId }, transaction, type: QueryTypes.SELECT } ); - const items = await this.database.query<{ id: string; values: 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", - { replacements: { companyId }, transaction, type: QueryTypes.SELECT } - ); + const items = await readCompanyAccessItems(this.database, companyId, transaction); const actors = await this.database.query<{ id: string; name: string }>( "SELECT id,name FROM users", { transaction, type: QueryTypes.SELECT } @@ -74,7 +67,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository { })), items: items.map((item) => ({ id: profileIdentity(item.id), - values: AccessDefinitionSchema.parse(persistedJson(item.values)), + values: item.values, })), assets: await this.inventory.list(company), actors: new Map(actors.map((actor) => [actor.id, actor.name])), diff --git a/modules/identity/src/api/infrastructure/persistence/migrate-identity.ts b/modules/identity/src/api/infrastructure/persistence/migrate-identity.ts index 8fa920e..c218dfb 100644 --- a/modules/identity/src/api/infrastructure/persistence/migrate-identity.ts +++ b/modules/identity/src/api/infrastructure/persistence/migrate-identity.ts @@ -1,7 +1,9 @@ import type { Sequelize } 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 { const query = database.getQueryInterface(); const tableRows = await database.query<{ name: string }>( @@ -50,9 +52,10 @@ export async function migrateIdentity(database: Sequelize): Promise { await query.createTable("http_sessions", { sid: { type: DataTypes.STRING(36), primaryKey: true }, expires: { type: DataTypes.DATE, allowNull: true }, - data: { type: DataTypes.TEXT, allowNull: true }, + ...sessionColumns, createdAt: { type: DataTypes.DATE, allowNull: false }, updatedAt: { type: DataTypes.DATE, allowNull: false }, }); } + await normalizeSessions(database); } diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize-session.store.ts b/modules/identity/src/api/infrastructure/persistence/sequelize-session.store.ts new file mode 100644 index 0000000..10e7c5c --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize-session.store.ts @@ -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>( + "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, callback?: Callback) { + let error: Error | null = null; + + try { + await operation(); + } catch (caught) { + if (!callback) { + throw caught; + } + error = failure(caught); + } + callback?.(error); + } +} diff --git a/modules/identity/src/api/infrastructure/persistence/session-normalization.ts b/modules/identity/src/api/infrastructure/persistence/session-normalization.ts new file mode 100644 index 0000000..35a6406 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/session-normalization.ts @@ -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) { + 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): 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>("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>( + "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"); +} diff --git a/modules/identity/src/api/module.ts b/modules/identity/src/api/module.ts index 71f4caf..f9c44f0 100644 --- a/modules/identity/src/api/module.ts +++ b/modules/identity/src/api/module.ts @@ -7,12 +7,13 @@ import { UserInputMapper } from "./application/user-input.mapper"; import { registerIdentityRoutes } from "./infrastructure/http/identity.routes"; import { requireAuthenticated } from "./infrastructure/http/require-authenticated"; import { migrateIdentity } from "./infrastructure/persistence/migrate-identity"; +import { SequelizeSessionStore } from "./infrastructure/persistence/sequelize-session.store"; import { SequelizeUserRepository } from "./infrastructure/persistence/sequelize-user.repository"; import { initializeIdentityModels } from "./infrastructure/persistence/user.model"; import { UserPersistenceMapper } from "./infrastructure/persistence/user-persistence.mapper"; import { ScryptPasswordHasher } from "./infrastructure/security/scrypt-password-hasher"; -export { migrateIdentity }; +export { SequelizeSessionStore, migrateIdentity }; export async function createIdentityModule(database: Sequelize, origin: string) { initializeIdentityModels(database); diff --git a/modules/resources/src/api/infrastructure/persistence/migrate-legacy-resources.ts b/modules/resources/src/api/infrastructure/persistence/migrate-legacy-resources.ts index 1f9cd20..338e92e 100644 --- a/modules/resources/src/api/infrastructure/persistence/migrate-legacy-resources.ts +++ b/modules/resources/src/api/infrastructure/persistence/migrate-legacy-resources.ts @@ -22,6 +22,10 @@ import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence import { readDefinitions } from "./resource-catalog-relational"; import { readInventory, writeInventory } from "./resource-inventory-relational"; import { ResourcePersistenceMapper } from "./resource-persistence.mapper"; +import { + readResourceProjectionHistory, + readResourceProjections, +} from "./resource-projection-reader"; const mailboxSchema = z.strictObject({ address: z.email().max(254), @@ -44,14 +48,6 @@ const accessSchema = z.strictObject({ 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 { return typeof value === "string" ? JSON.parse(value) : value; } @@ -178,12 +174,7 @@ async function importResource( return; } - const table = kind === "mailbox" ? "mailbox_revisions" : "access_revisions"; - const column = kind === "mailbox" ? "position" : "sequence"; - const rows = await database.query( - `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 rows = await readResourceProjectionHistory(database, kind, row.id, transaction); const specification = spec(catalog, kind); 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) { - const mailboxRows = await database.query( - "SELECT id,companyId,`values` FROM mailboxes WHERE companyId=:companyId", - { replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction } - ); - const accessRows = await database.query( - "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 mailboxRows = await readResourceProjections( + database, + "mailbox", + company.id, + transaction ); + const accessRows = await readResourceProjections(database, "access", company.id, transaction); for (const row of mailboxRows) { await importResource(database, catalog, "mailbox", row, transaction); diff --git a/modules/resources/src/api/infrastructure/persistence/resource-compatibility.ts b/modules/resources/src/api/infrastructure/persistence/resource-compatibility.ts index 1cd51ad..2aaf5ea 100644 --- a/modules/resources/src/api/infrastructure/persistence/resource-compatibility.ts +++ b/modules/resources/src/api/infrastructure/persistence/resource-compatibility.ts @@ -1,10 +1,13 @@ import { UniqueID } from "@repo/rdx-ddd"; import type { Sequelize, Transaction } from "sequelize"; import { QueryTypes } from "sequelize"; +import { z } from "zod/v4"; import type { Resource } from "../../domain/resource.aggregate"; 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. */ export async function projectResource( database: Sequelize, @@ -23,34 +26,32 @@ export async function projectResource( if (integration.kind === "mailbox") { const values = mailboxProjection(current); - await 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, - companyId, - accountId: values.accountId, - addressKey: `${companyId}:${values.address}`, - values: JSON.stringify(values), - }, - transaction, - } + const columns = scalarProjection(values); + + await writeResourceRow( + database, + "mailboxes", + { id, companyId, addressKey: `${companyId}:${values.address}`, ...columns }, + transaction ); for (const [position, revision] of resource.history.entries()) { - await database.query( - "INSERT INTO mailbox_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id", - { - replacements: { - id: revision.id.toPrimitive(), + const revisionId = revision.id.toPrimitive(); + + if (!(await projectionExists(database, "mailbox_revisions", revisionId, transaction))) { + await writeResourceRow( + database, + "mailbox_revisions", + { + id: revisionId, itemId: id, position, actorId: revision.actorId.toPrimitive(), recordedAt: revision.recordedAt, - values: JSON.stringify(mailboxProjection(revision.values.toPrimitive())), + ...scalarProjection(mailboxProjection(revision.values.toPrimitive())), }, - transaction, - } - ); + transaction + ); + } } return; @@ -70,25 +71,47 @@ export async function projectResource( } const values = accessProjection(current); - await database.query( - "INSERT INTO access_items (id,workspaceId,kind,`values`) VALUES (:id,:workspaceId,'resource',:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)", - { replacements: { id, workspaceId, values: JSON.stringify(values) }, transaction } + await writeResourceRow( + database, + "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()) { - await database.query( - "INSERT INTO access_revisions (id,itemId,sequence,actorId,recordedAt,`values`) VALUES (:id,:itemId,:sequence,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id", - { - replacements: { - id: revision.id.toPrimitive(), + const revisionId = revision.id.toPrimitive(); + + if (!(await projectionExists(database, "access_revisions", revisionId, transaction))) { + await writeResourceRow( + database, + "access_revisions", + { + id: revisionId, itemId: id, sequence, actorId: revision.actorId.toPrimitive(), 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. for (const targetId of [ @@ -145,3 +168,24 @@ function accessProjection(input: ResourceInput) { 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; +} diff --git a/modules/resources/src/api/infrastructure/persistence/resource-projection-reader.ts b/modules/resources/src/api/infrastructure/persistence/resource-projection-reader.ts new file mode 100644 index 0000000..35f0fa4 --- /dev/null +++ b/modules/resources/src/api/infrastructure/persistence/resource-projection-reader.ts @@ -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>(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>(sql, { + replacements: { id }, + transaction, + type: QueryTypes.SELECT, + }); + + return rows.map((row) => ({ + ...revision.parse(row), + values: kind === "mailbox" ? mailbox.parse(row) : access.parse(row), + })); +} diff --git a/modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts b/modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts index 9d87afb..06f18a7 100644 --- a/modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts +++ b/modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts @@ -101,20 +101,20 @@ export class SequelizeResourceRepository implements IResourceRepository { "SELECT id, name FROM users", { type: QueryTypes.SELECT, transaction } ); - const rawReferences = await this.database.query<{ id: string; values: 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')", + const rawReferences = await this.database.query>( + "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 } ); const references = rawReferences.map((row) => { const data = z .object({ kind: z.enum(["account", "system", "environment", "resource_type"]), - name: z.string().optional(), - identifier: z.string().optional(), - active: z.boolean(), - systemId: z.uuid().optional(), + name: z.string().nullish(), + identifier: z.string().nullish(), + active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean), + systemId: z.uuid().nullish(), }) - .parse(typeof row.values === "string" ? JSON.parse(row.values) : row.values); + .parse(row); return ResourceReferenceSchema.parse({ id: row.id, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1a0e5b..079d41f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: '@metis/resources': specifier: workspace:* version: link:../../modules/resources - connect-session-sequelize: - specifier: 8.0.6 - version: 8.0.6(sequelize@6.37.8(mariadb@3.5.4)) express: specifier: 4.22.1 version: 4.22.1 @@ -2224,12 +2221,6 @@ packages: confbox@0.1.8: 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: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -5591,13 +5582,6 @@ snapshots: 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: {} content-disposition@0.5.4: