BD normalizada
This commit is contained in:
parent
ee21c0a80e
commit
bf4831a34f
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<Response>;
|
||||
|
||||
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<Record<string, unknown>>(
|
||||
"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 },
|
||||
});
|
||||
|
||||
@ -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];
|
||||
|
||||
284
apps/server/src/remaining-normalization.integration.ts
Normal file
284
apps/server/src/remaining-normalization.integration.ts
Normal file
@ -0,0 +1,284 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import session from "express-session";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
|
||||
import { AccessKindSchema } from "../../../modules/companies/src/api/domain/access/access-definition";
|
||||
import {
|
||||
accessValueTable,
|
||||
normalizeAccess,
|
||||
readAccessItems,
|
||||
readAccessRevisions,
|
||||
} from "../../../modules/companies/src/api/infrastructure/persistence/access-relational";
|
||||
import {
|
||||
type ProcessTable,
|
||||
normalizeProcesses,
|
||||
readProcessRows,
|
||||
} from "../../../modules/companies/src/api/infrastructure/persistence/process-relational";
|
||||
import { supportIdentifier } from "../../../modules/companies/src/api/infrastructure/persistence/support-record-relational";
|
||||
import { SequelizeSessionStore } from "../../../modules/identity/src/api/infrastructure/persistence/sequelize-session.store";
|
||||
import {
|
||||
normalizeSessions,
|
||||
sessionFromRow,
|
||||
sessionRow,
|
||||
} from "../../../modules/identity/src/api/infrastructure/persistence/session-normalization";
|
||||
|
||||
const processTables: readonly ProcessTable[] = [
|
||||
"process_templates",
|
||||
"process_template_revisions",
|
||||
"person_processes",
|
||||
"person_process_revisions",
|
||||
];
|
||||
const processChildren = [
|
||||
"process_template_instructions",
|
||||
"process_template_revision_instructions",
|
||||
"person_process_tasks",
|
||||
"person_process_revision_tasks",
|
||||
];
|
||||
|
||||
async function accessSnapshot(database: Sequelize) {
|
||||
return database.transaction(async (transaction) => ({
|
||||
items: (await readAccessItems(database, transaction)).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
revisions: (await readAccessRevisions(database, transaction)).sort((a, b) =>
|
||||
a.id.localeCompare(b.id)
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async function processSnapshot(database: Sequelize) {
|
||||
return database.transaction(async (transaction) => {
|
||||
const snapshots = new Map<ProcessTable, Record<string, unknown>[]>();
|
||||
|
||||
for (const table of processTables) {
|
||||
snapshots.set(table, await readProcessRows(database, table, transaction));
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
});
|
||||
}
|
||||
|
||||
async function legacyRows(
|
||||
database: Sequelize,
|
||||
table: string,
|
||||
rows: readonly { id: unknown; values: unknown }[],
|
||||
transaction?: Transaction
|
||||
) {
|
||||
for (const row of rows) {
|
||||
await database.query(`UPDATE ${supportIdentifier(table)} SET \`values\`=:values WHERE id=:id`, {
|
||||
replacements: { id: row.id, values: JSON.stringify(row.values) },
|
||||
...(transaction ? { transaction } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAccessMigration(database: Sequelize) {
|
||||
const expected = await accessSnapshot(database);
|
||||
|
||||
assert.ok(expected.items.some((item) => item.kind === "need"));
|
||||
assert.ok(expected.revisions.length > expected.items.length);
|
||||
const query = database.getQueryInterface();
|
||||
|
||||
for (const table of ["access_items", "access_revisions"]) {
|
||||
await query.addColumn(table, "values", { type: DataTypes.JSON, allowNull: true });
|
||||
}
|
||||
await database.transaction(async (transaction) => {
|
||||
await legacyRows(database, "access_items", expected.items, transaction);
|
||||
await legacyRows(database, "access_revisions", expected.revisions, transaction);
|
||||
for (const history of [false, true]) {
|
||||
for (const kind of AccessKindSchema.options) {
|
||||
await database.query(`DELETE FROM ${supportIdentifier(accessValueTable(kind, history))}`, {
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
const first = expected.items[0];
|
||||
|
||||
assert.ok(first);
|
||||
await legacyRows(database, "access_items", [{ id: first.id, values: { kind: first.kind } }]);
|
||||
await assert.rejects(() => normalizeAccess(database));
|
||||
assert.ok((await query.describeTable("access_items")).values);
|
||||
await legacyRows(database, "access_items", [first]);
|
||||
await normalizeAccess(database);
|
||||
assert.deepEqual(await accessSnapshot(database), expected);
|
||||
for (const table of ["access_items", "access_revisions"]) {
|
||||
assert.equal((await query.describeTable(table)).values, undefined);
|
||||
}
|
||||
// Corte entre la transferencia de la raíz y el historial.
|
||||
await query.addColumn("access_items", "values", { type: DataTypes.JSON, allowNull: true });
|
||||
await legacyRows(database, "access_items", expected.items);
|
||||
await normalizeAccess(database);
|
||||
await normalizeAccess(database);
|
||||
assert.deepEqual(await accessSnapshot(database), expected);
|
||||
await assert.rejects(() =>
|
||||
database.query(
|
||||
"INSERT INTO access_need_permissions (ownerId,position,permissionId) VALUES (:ownerId,999,:permissionId)",
|
||||
{ replacements: { ownerId: randomUUID(), permissionId: randomUUID() } }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyProcessMigration(database: Sequelize) {
|
||||
const expected = await processSnapshot(database);
|
||||
|
||||
assert.ok((expected.get("person_processes")?.length ?? 0) > 0);
|
||||
const query = database.getQueryInterface();
|
||||
|
||||
for (const table of processTables) {
|
||||
await query.addColumn(table, "values", { type: DataTypes.JSON, allowNull: true });
|
||||
}
|
||||
await database.transaction(async (transaction) => {
|
||||
for (const [table, rows] of expected) {
|
||||
for (const row of rows) {
|
||||
await legacyRows(database, table, [{ id: row.id, values: row.values }], transaction);
|
||||
}
|
||||
}
|
||||
for (const table of processChildren) {
|
||||
await database.query(`DELETE FROM ${supportIdentifier(table)}`, { transaction });
|
||||
}
|
||||
});
|
||||
const first = expected.get("person_processes")?.[0];
|
||||
|
||||
assert.ok(first);
|
||||
await legacyRows(database, "person_processes", [{ id: first.id, values: {} }]);
|
||||
await assert.rejects(() => normalizeProcesses(database));
|
||||
assert.ok((await query.describeTable("person_processes")).values);
|
||||
await legacyRows(database, "person_processes", [{ id: first.id, values: first.values }]);
|
||||
await normalizeProcesses(database);
|
||||
assert.deepEqual(await processSnapshot(database), expected);
|
||||
for (const table of processTables) {
|
||||
assert.equal((await query.describeTable(table)).values, undefined);
|
||||
}
|
||||
await query.addColumn("person_processes", "values", { type: DataTypes.JSON, allowNull: true });
|
||||
for (const row of expected.get("person_processes") ?? []) {
|
||||
await legacyRows(database, "person_processes", [{ id: row.id, values: row.values }]);
|
||||
}
|
||||
await normalizeProcesses(database);
|
||||
await normalizeProcesses(database);
|
||||
assert.deepEqual(await processSnapshot(database), expected);
|
||||
}
|
||||
|
||||
async function sessionSnapshot(database: Sequelize) {
|
||||
const rows = await database.query<Record<string, unknown>>(
|
||||
"SELECT * FROM http_sessions ORDER BY sid",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
sid: row.sid,
|
||||
expires: row.expires,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
values: sessionRow(sessionFromRow(row)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function verifySessionMigration(database: Sequelize) {
|
||||
const expected = await sessionSnapshot(database);
|
||||
|
||||
assert.ok(expected.length);
|
||||
const query = database.getQueryInterface();
|
||||
|
||||
await query.addColumn("http_sessions", "data", { type: DataTypes.TEXT, allowNull: true });
|
||||
const original = await database.query<Record<string, unknown>>(
|
||||
"SELECT * FROM http_sessions ORDER BY sid",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
for (const row of original) {
|
||||
await database.query("UPDATE http_sessions SET data=:data WHERE sid=:sid", {
|
||||
replacements: { sid: row.sid, data: JSON.stringify(sessionFromRow(row)) },
|
||||
});
|
||||
}
|
||||
const first = original[0];
|
||||
|
||||
assert.ok(first);
|
||||
await database.query("UPDATE http_sessions SET data=:data WHERE sid=:sid", {
|
||||
replacements: {
|
||||
sid: first.sid,
|
||||
data: JSON.stringify({ unknown: "No omitir silenciosamente" }),
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => normalizeSessions(database));
|
||||
assert.ok((await query.describeTable("http_sessions")).data);
|
||||
await database.query("UPDATE http_sessions SET data=:data WHERE sid=:sid", {
|
||||
replacements: { sid: first.sid, data: JSON.stringify(sessionFromRow(first)) },
|
||||
});
|
||||
await normalizeSessions(database);
|
||||
await normalizeSessions(database);
|
||||
assert.deepEqual(await sessionSnapshot(database), expected);
|
||||
assert.equal((await query.describeTable("http_sessions")).data, undefined);
|
||||
|
||||
const store = new SequelizeSessionStore(database);
|
||||
const sid = randomUUID();
|
||||
const cookie = Object.assign(new session.Cookie(), {
|
||||
maxAge: 3600000,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: false,
|
||||
});
|
||||
const data = { cookie, userId: undefined, authVersion: undefined, csrfToken: "test-csrf-token" };
|
||||
|
||||
await store.set(sid, data);
|
||||
const restored = await new Promise<session.SessionData>((resolve, reject) => {
|
||||
store
|
||||
.get(sid, (error, result) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else if (result) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error("Sesión no recuperada."));
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
assert.equal(restored.cookie.originalMaxAge, 3600000);
|
||||
assert.equal(restored.cookie.secure, false);
|
||||
assert.equal(restored.cookie.sameSite, false);
|
||||
assert.equal(restored.csrfToken, data.csrfToken);
|
||||
await store.touch(sid, { ...data, csrfToken: "No sobrescribir al tocar la cookie" });
|
||||
const stored = await database.query<{ csrfToken: string }>(
|
||||
"SELECT csrfToken FROM http_sessions WHERE sid=:sid",
|
||||
{ replacements: { sid }, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
assert.equal(stored[0]?.csrfToken, data.csrfToken);
|
||||
await store.destroy(sid);
|
||||
assert.deepEqual(
|
||||
await database.query("SELECT sid FROM http_sessions WHERE sid=:sid", {
|
||||
replacements: { sid },
|
||||
type: QueryTypes.SELECT,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
await assert.rejects(() =>
|
||||
store.set(sid, { ...data, cookie: { ...cookie, originalMaxAge: -1 } })
|
||||
);
|
||||
}
|
||||
|
||||
/** Recrea el formato legado únicamente en la base de pruebas y verifica recuperación e idempotencia. */
|
||||
export async function verifyRemainingNormalization(database: Sequelize) {
|
||||
assert.equal(database.getDatabaseName(), "metis_desk_test");
|
||||
await verifyAccessMigration(database);
|
||||
await verifyProcessMigration(database);
|
||||
await verifySessionMigration(database);
|
||||
// Las variantes polimórficas también se protegen frente a escritura SQL directa.
|
||||
await assert.rejects(() =>
|
||||
database.query(
|
||||
"UPDATE remote_connections SET targetKind='asset',targetAssetId=NULL,targetResourceId=NULL"
|
||||
)
|
||||
);
|
||||
await assert.rejects(() =>
|
||||
database.query("UPDATE mail_configurations SET snapshotKind='profile',mailboxId=NULL")
|
||||
);
|
||||
const jsonChecks = await database.query(
|
||||
"SELECT CONSTRAINT_NAME FROM information_schema.CHECK_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND LOWER(CHECK_CLAUSE) LIKE '%json_valid%'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
assert.deepEqual(jsonChecks, []);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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<WorkspaceModel>,
|
||||
InferCreationAttributes<WorkspaceModel>
|
||||
@ -27,25 +36,6 @@ class WorkspaceModel extends Model<
|
||||
declare scopeKey: string;
|
||||
}
|
||||
|
||||
class ItemModel extends Model<InferAttributes<ItemModel>, InferCreationAttributes<ItemModel>> {
|
||||
declare id: string;
|
||||
declare workspaceId: string;
|
||||
declare kind: string;
|
||||
declare values: unknown;
|
||||
}
|
||||
|
||||
class RevisionModel extends Model<
|
||||
InferAttributes<RevisionModel>,
|
||||
InferCreationAttributes<RevisionModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare itemId: string;
|
||||
declare sequence: number;
|
||||
declare actorId: string;
|
||||
declare recordedAt: string;
|
||||
declare values: unknown;
|
||||
}
|
||||
|
||||
class ReferenceModel extends Model<
|
||||
InferAttributes<ReferenceModel>,
|
||||
InferCreationAttributes<ReferenceModel>
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -0,0 +1,409 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
AccessDefinitionSchema,
|
||||
type AccessKind,
|
||||
AccessKindSchema,
|
||||
} from "../../domain/access/access-definition";
|
||||
|
||||
import { supportIdentifier, writeSupportScalars } from "./support-record-relational";
|
||||
|
||||
const ref = (model = "access_items", allowNull = false): ModelAttributeColumnOptions => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull,
|
||||
references: { model, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
|
||||
const named = {
|
||||
name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
};
|
||||
const note = { type: DataTypes.TEXT, allowNull: true };
|
||||
const scope = { systemId: ref(), environmentId: ref("access_items", true) };
|
||||
const period = {
|
||||
startDate: { type: DataTypes.STRING(10), allowNull: false },
|
||||
endDate: { type: DataTypes.STRING(10), allowNull: true },
|
||||
};
|
||||
const configuration = { type: DataTypes.STRING(20), allowNull: false };
|
||||
|
||||
export const accessColumns: Record<AccessKind, Record<string, ModelAttributeColumnOptions>> = {
|
||||
system: named,
|
||||
resource_type: named,
|
||||
permission: { ...named, resourceTypeId: ref(), description: note },
|
||||
template: { ...named, description: note, systemId: ref("access_items", true) },
|
||||
environment: { ...named, systemId: ref() },
|
||||
account: {
|
||||
...scope,
|
||||
login: { type: DataTypes.STRING(255), allowNull: false },
|
||||
usage: { type: DataTypes.STRING(16), allowNull: false },
|
||||
active: named.active,
|
||||
expiresOn: { type: DataTypes.STRING(10), allowNull: true },
|
||||
notes: note,
|
||||
},
|
||||
account_person: {
|
||||
accountId: ref(),
|
||||
personId: ref("company_contacts"),
|
||||
...period,
|
||||
configuration: { ...configuration, allowNull: true },
|
||||
},
|
||||
group: {
|
||||
...scope,
|
||||
...named,
|
||||
identifier: { type: DataTypes.STRING(255), allowNull: false },
|
||||
description: note,
|
||||
templateId: ref("access_items", true),
|
||||
},
|
||||
membership: { accountId: ref(), groupId: ref(), ...period, configuration },
|
||||
resource: {
|
||||
...named,
|
||||
resourceTypeId: ref(),
|
||||
systemId: ref("access_items", true),
|
||||
environmentId: ref("access_items", true),
|
||||
reference: { type: DataTypes.STRING(255), allowNull: true },
|
||||
notes: note,
|
||||
},
|
||||
grant: {
|
||||
resourceId: ref(),
|
||||
permissionId: ref(),
|
||||
accountId: ref("access_items", true),
|
||||
groupId: ref("access_items", true),
|
||||
...period,
|
||||
configuration,
|
||||
},
|
||||
need: { personId: ref("company_contacts"), resourceId: ref(), ...period, notes: note },
|
||||
coverage: {
|
||||
needId: ref(),
|
||||
grantId: ref(),
|
||||
accountPersonId: ref(),
|
||||
membershipId: ref("access_items", true),
|
||||
...period,
|
||||
},
|
||||
};
|
||||
|
||||
export function accessValueTable(kind: AccessKind, history = false) {
|
||||
return `access_${kind}_${history ? "revision_" : ""}values`;
|
||||
}
|
||||
|
||||
const itemSchema = z.object({
|
||||
id: z.uuid(),
|
||||
workspaceId: z.uuid(),
|
||||
kind: AccessKindSchema,
|
||||
values: AccessDefinitionSchema,
|
||||
});
|
||||
const revisionSchema = z.object({
|
||||
id: z.uuid(),
|
||||
itemId: z.uuid(),
|
||||
sequence: z.number().int().nonnegative(),
|
||||
actorId: z.uuid(),
|
||||
recordedAt: z.iso.datetime(),
|
||||
values: AccessDefinitionSchema,
|
||||
});
|
||||
|
||||
export type AccessItemRow = z.infer<typeof itemSchema>;
|
||||
|
||||
export type AccessRevisionRow = z.infer<typeof revisionSchema>;
|
||||
|
||||
function decoded(row: Record<string, unknown>, permissionIds: string[]) {
|
||||
if (row.values !== undefined) {
|
||||
return AccessDefinitionSchema.parse(
|
||||
typeof row.values === "string" ? JSON.parse(row.values) : row.values
|
||||
);
|
||||
}
|
||||
const kind = AccessKindSchema.parse(row.kind);
|
||||
const values = Object.fromEntries(
|
||||
Object.keys(accessColumns[kind]).map((key) => [
|
||||
key,
|
||||
key === "active"
|
||||
? z
|
||||
.union([z.boolean(), z.literal(0), z.literal(1)])
|
||||
.transform(Boolean)
|
||||
.parse(row[key])
|
||||
: row[key],
|
||||
])
|
||||
);
|
||||
|
||||
return AccessDefinitionSchema.parse({
|
||||
kind,
|
||||
...values,
|
||||
...(kind === "need" ? { permissionIds } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function readAccessItems(
|
||||
database: Sequelize,
|
||||
transaction: Transaction,
|
||||
companyId?: string
|
||||
) {
|
||||
const rows = await database.query<Record<string, unknown>>(
|
||||
`SELECT i.id,i.workspaceId,v.* FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId LEFT JOIN access_current_values v ON v.ownerId=i.id AND v.kind=i.kind${companyId ? " WHERE w.companyId=:companyId OR w.companyId IS NULL" : ""}`,
|
||||
{ replacements: { companyId: companyId ?? null }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const permissions = await database.query<{
|
||||
ownerId: string;
|
||||
permissionId: string;
|
||||
position: number;
|
||||
}>("SELECT * FROM access_need_permissions ORDER BY ownerId,position", {
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
return rows.map((row) =>
|
||||
itemSchema.parse({ ...row, values: decoded(row, permissionIds(permissions, row.id)) })
|
||||
);
|
||||
}
|
||||
|
||||
export async function readAccessRevisions(database: Sequelize, transaction: Transaction) {
|
||||
const rows = await database.query<Record<string, unknown>>(
|
||||
"SELECT r.id,r.itemId,r.sequence,r.actorId,r.recordedAt,v.* FROM access_revisions r JOIN access_items i ON i.id=r.itemId LEFT JOIN access_historical_values v ON v.ownerId=r.id AND v.kind=i.kind",
|
||||
{ transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const permissions = await database.query<{
|
||||
ownerId: string;
|
||||
permissionId: string;
|
||||
position: number;
|
||||
}>("SELECT * FROM access_need_revision_permissions ORDER BY ownerId,position", {
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
return rows.map((row) =>
|
||||
revisionSchema.parse({ ...row, values: decoded(row, permissionIds(permissions, row.id)) })
|
||||
);
|
||||
}
|
||||
|
||||
function permissionIds(
|
||||
rows: readonly { ownerId: string; permissionId: string; position: number }[],
|
||||
id: unknown
|
||||
) {
|
||||
const selected = rows.filter((row) => row.ownerId === id);
|
||||
|
||||
if (selected.some((row, position) => row.position !== position)) {
|
||||
throw new Error("Orden de permisos de necesidad inconsistente.");
|
||||
}
|
||||
|
||||
return selected.map((row) => row.permissionId);
|
||||
}
|
||||
|
||||
export async function writeAccessRow(
|
||||
database: Sequelize,
|
||||
row: AccessItemRow | AccessRevisionRow,
|
||||
transaction: Transaction,
|
||||
operation: "insert" | "update"
|
||||
) {
|
||||
const { values, ...metadata } = row;
|
||||
const input = AccessDefinitionSchema.parse(values);
|
||||
const history = "itemId" in row;
|
||||
|
||||
await writeSupportScalars(
|
||||
database,
|
||||
history ? "access_revisions" : "access_items",
|
||||
metadata,
|
||||
transaction,
|
||||
operation
|
||||
);
|
||||
await writeAccessValues(database, row.id, input, history, transaction);
|
||||
}
|
||||
|
||||
async function writeAccessValues(
|
||||
database: Sequelize,
|
||||
id: string,
|
||||
input: z.infer<typeof AccessDefinitionSchema>,
|
||||
history: boolean,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const { kind, ...values } = input;
|
||||
const { permissionIds: _permissions, ...columns } = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(values);
|
||||
const table = accessValueTable(kind, history);
|
||||
|
||||
await database.query(`DELETE FROM ${supportIdentifier(table)} WHERE ownerId=:id`, {
|
||||
replacements: { id },
|
||||
transaction,
|
||||
});
|
||||
await writeSupportScalars(database, table, { ownerId: id, ...columns }, transaction, "insert");
|
||||
if (kind === "need") {
|
||||
const links = history ? "access_need_revision_permissions" : "access_need_permissions";
|
||||
|
||||
await database.query(`DELETE FROM ${supportIdentifier(links)} WHERE ownerId=:id`, {
|
||||
replacements: { id },
|
||||
transaction,
|
||||
});
|
||||
for (const [position, permissionId] of input.permissionIds.entries()) {
|
||||
await writeSupportScalars(
|
||||
database,
|
||||
links,
|
||||
{ ownerId: id, permissionId, position },
|
||||
transaction,
|
||||
"insert"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareAccessValues(database: Sequelize) {
|
||||
const query = database.getQueryInterface();
|
||||
const existing = await database.query<{ name: string }>(
|
||||
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
for (const kind of AccessKindSchema.options) {
|
||||
for (const history of [false, true]) {
|
||||
const table = accessValueTable(kind, history);
|
||||
|
||||
if (!existing.some((row) => row.name === table)) {
|
||||
await query.createTable(table, {
|
||||
ownerId: {
|
||||
...ref(history ? "access_revisions" : "access_items"),
|
||||
primaryKey: true,
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
...accessColumns[kind],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await prepareNeedPermissions(database, existing);
|
||||
await createAccessViews(database);
|
||||
}
|
||||
|
||||
async function prepareNeedPermissions(database: Sequelize, existing: readonly { name: string }[]) {
|
||||
const query = database.getQueryInterface();
|
||||
|
||||
for (const history of [false, true]) {
|
||||
const table = history ? "access_need_revision_permissions" : "access_need_permissions";
|
||||
|
||||
if (!existing.some((row) => row.name === table)) {
|
||||
await query.createTable(table, {
|
||||
ownerId: {
|
||||
...ref(accessValueTable("need", history)),
|
||||
references: { model: accessValueTable("need", history), key: "ownerId" },
|
||||
primaryKey: true,
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
position: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true },
|
||||
permissionId: ref(),
|
||||
});
|
||||
}
|
||||
const indexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
|
||||
{ replacements: { table }, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (!indexes.some((index) => index.name === `${table}_unique`)) {
|
||||
await query.addIndex(table, ["ownerId", "permissionId"], {
|
||||
unique: true,
|
||||
name: `${table}_unique`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createAccessViews(database: Sequelize) {
|
||||
// Vistas de lectura: columnas SQL, sin reconstruir documentos JSON en la base.
|
||||
const allColumns = [...new Set(Object.values(accessColumns).flatMap(Object.keys))];
|
||||
|
||||
for (const history of [false, true]) {
|
||||
const selections = AccessKindSchema.options.map(
|
||||
(kind) =>
|
||||
`SELECT ownerId,'${kind}' AS kind,${allColumns.map((name) => (name in accessColumns[kind] ? supportIdentifier(name) : `NULL AS ${supportIdentifier(name)}`)).join(",")} FROM ${supportIdentifier(accessValueTable(kind, history))}`
|
||||
);
|
||||
|
||||
await database.query(
|
||||
`CREATE OR REPLACE VIEW ${history ? "access_historical_values" : "access_current_values"} AS ${selections.join(" UNION ALL ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeAccess(database: Sequelize) {
|
||||
await prepareAccessValues(database);
|
||||
const query = database.getQueryInterface();
|
||||
const original = await query.describeTable("access_items");
|
||||
const history = await query.describeTable("access_revisions");
|
||||
|
||||
if (!(original.values || history.values)) {
|
||||
return;
|
||||
}
|
||||
await database.transaction(async (transaction) => {
|
||||
const items = original.values
|
||||
? (
|
||||
await database.query<Record<string, unknown>>("SELECT * FROM access_items", {
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
})
|
||||
).map((row) => itemSchema.parse({ ...row, values: decoded(row, []) }))
|
||||
: await readAccessItems(database, transaction);
|
||||
const revisions = history.values
|
||||
? (
|
||||
await database.query<Record<string, unknown>>("SELECT * FROM access_revisions", {
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
})
|
||||
).map((row) => revisionSchema.parse({ ...row, values: decoded(row, []) }))
|
||||
: await readAccessRevisions(database, transaction);
|
||||
|
||||
for (const item of items) {
|
||||
const ordered = revisions
|
||||
.filter((row) => row.itemId === item.id)
|
||||
.sort((a, b) => a.sequence - b.sequence);
|
||||
|
||||
if (
|
||||
item.kind !== item.values.kind ||
|
||||
!ordered.length ||
|
||||
ordered.some(
|
||||
(row, index) =>
|
||||
row.sequence !== index ||
|
||||
row.values.kind !== item.kind ||
|
||||
row.recordedAt < (ordered[index - 1]?.recordedAt ?? "")
|
||||
) ||
|
||||
!isDeepStrictEqual(ordered.at(-1)?.values, item.values)
|
||||
) {
|
||||
throw new Error("Historial de acceso inconsistente durante la normalización.");
|
||||
}
|
||||
}
|
||||
// Insertar valores de todos los propietarios antes de validar referencias entre subtipos.
|
||||
for (const row of [...items, ...revisions]) {
|
||||
await writeAccessValues(database, row.id, row.values, "itemId" in row, transaction);
|
||||
}
|
||||
if (
|
||||
!(
|
||||
isDeepStrictEqual(
|
||||
(await readAccessItems(database, transaction)).sort(byId),
|
||||
items.sort(byId)
|
||||
) &&
|
||||
isDeepStrictEqual(
|
||||
(await readAccessRevisions(database, transaction)).sort(byId),
|
||||
revisions.sort(byId)
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new Error("Los accesos normalizados no coinciden con el origen.");
|
||||
}
|
||||
});
|
||||
for (const [table, columns] of [
|
||||
["access_items", original],
|
||||
["access_revisions", history],
|
||||
] as const) {
|
||||
if (columns.values) {
|
||||
await database.query(`ALTER TABLE ${supportIdentifier(table)} DROP COLUMN \`values\``);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function byId(left: { id: string }, right: { id: string }) {
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
export function readCompanyAccessItems(
|
||||
database: Sequelize,
|
||||
companyId: string,
|
||||
transaction: Transaction
|
||||
) {
|
||||
return readAccessItems(database, transaction, companyId);
|
||||
}
|
||||
@ -145,7 +145,22 @@ export const folderSnapshot = {
|
||||
booleans: [],
|
||||
} satisfies SnapshotDefinition<ReturnType<typeof FolderMappingInputSchema.parse>>;
|
||||
|
||||
import {
|
||||
mailSnapshot,
|
||||
mailboxSnapshot,
|
||||
mailboxUseSnapshot,
|
||||
remoteSnapshot,
|
||||
} from "./mail-remote-snapshots";
|
||||
|
||||
export const configurationSnapshots = new Map<string, SnapshotDefinition<unknown>>([
|
||||
["remote_connection_revisions", remoteSnapshot],
|
||||
["mailbox_use_revisions", mailboxUseSnapshot],
|
||||
["mailbox_revisions", mailboxSnapshot],
|
||||
["mail_configuration_revisions", mailSnapshot],
|
||||
["mail_configurations", mailSnapshot],
|
||||
["mailboxes", mailboxSnapshot],
|
||||
["mailbox_uses", mailboxUseSnapshot],
|
||||
["remote_connections", remoteSnapshot],
|
||||
["work_profiles", profileSnapshot],
|
||||
["work_profile_revisions", profileSnapshot],
|
||||
["telework_authorizations", teleworkSnapshot],
|
||||
|
||||
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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})`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
import { DataTypes } from "sequelize";
|
||||
|
||||
import { MailSnapshotSchema } from "../../domain/mail-configuration-values";
|
||||
import { MailboxInputSchema, MailboxUseInputSchema } from "../../domain/mailbox-values";
|
||||
import { RemoteConnectionInputSchema } from "../../domain/remote-connection-values";
|
||||
|
||||
import type { SnapshotDefinition } from "./scalar-snapshot-normalization";
|
||||
|
||||
const reference = (model: string, allowNull = true) => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull,
|
||||
references: { model, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
|
||||
const note = { type: DataTypes.TEXT, allowNull: true };
|
||||
const state = { type: DataTypes.STRING(20), allowNull: false };
|
||||
|
||||
export const mailboxSnapshot = {
|
||||
schema: MailboxInputSchema,
|
||||
columns: {
|
||||
address: { type: DataTypes.STRING(254), allowNull: false },
|
||||
kind: { type: DataTypes.STRING(20), allowNull: false },
|
||||
context: { type: DataTypes.STRING(255), allowNull: true },
|
||||
accountId: reference("access_items"),
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
notes: note,
|
||||
},
|
||||
booleans: ["active"],
|
||||
} satisfies SnapshotDefinition<ReturnType<typeof MailboxInputSchema.parse>>;
|
||||
|
||||
export const mailboxUseSnapshot = {
|
||||
schema: MailboxUseInputSchema,
|
||||
columns: {
|
||||
mailboxId: reference("mailboxes", false),
|
||||
personId: reference("company_contacts", false),
|
||||
startDate: { type: DataTypes.STRING(10), allowNull: false },
|
||||
endDate: { type: DataTypes.STRING(10), allowNull: true },
|
||||
},
|
||||
} satisfies SnapshotDefinition<ReturnType<typeof MailboxUseInputSchema.parse>>;
|
||||
|
||||
export const mailSnapshot = {
|
||||
schema: MailSnapshotSchema,
|
||||
columns: {
|
||||
snapshotKind: { type: DataTypes.STRING(10), allowNull: false },
|
||||
personId: reference("company_contacts"),
|
||||
accountId: reference("access_items"),
|
||||
assetId: reference("assets"),
|
||||
profileId: reference("work_profiles"),
|
||||
mailboxId: reference("mailboxes"),
|
||||
address: { type: DataTypes.STRING(254), allowNull: false },
|
||||
method: { type: DataTypes.STRING(16), allowNull: false },
|
||||
state,
|
||||
notes: note,
|
||||
},
|
||||
fromColumns(row: Record<string, unknown>) {
|
||||
const { snapshotKind, personId, accountId, assetId, profileId, mailboxId, ...common } = row;
|
||||
|
||||
if (snapshotKind === "legacy") {
|
||||
return { ...common, personId, accountId, assetId };
|
||||
}
|
||||
if (snapshotKind !== "profile") {
|
||||
throw new Error("Tipo de instantánea de correo inconsistente.");
|
||||
}
|
||||
|
||||
return { ...common, profileId, mailboxId };
|
||||
},
|
||||
toColumns(source: unknown) {
|
||||
const input = MailSnapshotSchema.parse(source);
|
||||
|
||||
return {
|
||||
personId: null,
|
||||
accountId: null,
|
||||
assetId: null,
|
||||
profileId: null,
|
||||
mailboxId: null,
|
||||
snapshotKind: "profileId" in input ? "profile" : "legacy",
|
||||
...input,
|
||||
};
|
||||
},
|
||||
} satisfies SnapshotDefinition<ReturnType<typeof MailSnapshotSchema.parse>>;
|
||||
|
||||
export const remoteSnapshot = {
|
||||
schema: RemoteConnectionInputSchema,
|
||||
columns: {
|
||||
personId: reference("company_contacts", false),
|
||||
sourceKind: { type: DataTypes.STRING(20), allowNull: false },
|
||||
sourceAssetId: reference("assets"),
|
||||
targetKind: { type: DataTypes.STRING(16), allowNull: false },
|
||||
targetAssetId: reference("assets"),
|
||||
targetResourceId: reference("resources"),
|
||||
siteId: reference("company_sites"),
|
||||
method: { type: DataTypes.STRING(80), allowNull: false },
|
||||
requiresVpn: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
state,
|
||||
notes: note,
|
||||
},
|
||||
booleans: ["requiresVpn"],
|
||||
fromColumns(row: Record<string, unknown>) {
|
||||
const { targetAssetId, targetResourceId, ...values } = row;
|
||||
|
||||
return {
|
||||
...values,
|
||||
targetId:
|
||||
row.targetKind === "asset"
|
||||
? targetAssetId
|
||||
: row.targetKind === "resource"
|
||||
? targetResourceId
|
||||
: null,
|
||||
};
|
||||
},
|
||||
toColumns(source: unknown) {
|
||||
const input = RemoteConnectionInputSchema.parse(source);
|
||||
const { targetId, ...values } = input;
|
||||
|
||||
return {
|
||||
...values,
|
||||
targetAssetId: input.targetKind === "asset" ? targetId : null,
|
||||
targetResourceId: input.targetKind === "resource" ? targetId : null,
|
||||
};
|
||||
},
|
||||
} satisfies SnapshotDefinition<ReturnType<typeof RemoteConnectionInputSchema.parse>>;
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
|
||||
|
||||
@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,318 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ProcessTemplateInputSchema, ProcessValuesSchema } from "../../domain/process-values";
|
||||
|
||||
import { PersonProcessPersistenceMapper } from "./process-persistence.mapper";
|
||||
import { ProcessTemplatePersistenceMapper } from "./process-template-persistence.mapper";
|
||||
import { supportIdentifier, writeSupportScalars } from "./support-record-relational";
|
||||
|
||||
const name = { type: DataTypes.STRING(160), allowNull: false };
|
||||
const note = { type: DataTypes.TEXT, allowNull: true };
|
||||
const kind = { type: DataTypes.STRING(20), allowNull: false };
|
||||
|
||||
const reference = (model: string): ModelAttributeColumnOptions => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
|
||||
export const processTemplateColumns = {
|
||||
name,
|
||||
kind,
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
notes: note,
|
||||
};
|
||||
|
||||
export const processColumns = {
|
||||
personId: reference("company_contacts"),
|
||||
templateId: reference("process_templates"),
|
||||
templateName: name,
|
||||
kind,
|
||||
name,
|
||||
state: { type: DataTypes.STRING(20), allowNull: false },
|
||||
notes: note,
|
||||
};
|
||||
|
||||
export type ProcessTable =
|
||||
| "process_templates"
|
||||
| "process_template_revisions"
|
||||
| "person_processes"
|
||||
| "person_process_revisions";
|
||||
|
||||
function templateTable(table: ProcessTable) {
|
||||
return table === "process_templates" || table === "process_template_revisions";
|
||||
}
|
||||
|
||||
function columns(table: ProcessTable): Record<string, ModelAttributeColumnOptions> {
|
||||
return templateTable(table) ? processTemplateColumns : processColumns;
|
||||
}
|
||||
|
||||
function children(table: ProcessTable) {
|
||||
switch (table) {
|
||||
case "process_templates":
|
||||
return "process_template_instructions";
|
||||
case "process_template_revisions":
|
||||
return "process_template_revision_instructions";
|
||||
case "person_processes":
|
||||
return "person_process_tasks";
|
||||
case "person_process_revisions":
|
||||
return "person_process_revision_tasks";
|
||||
}
|
||||
}
|
||||
|
||||
function input(table: ProcessTable, value: unknown) {
|
||||
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
||||
|
||||
return templateTable(table)
|
||||
? ProcessTemplateInputSchema.parse(parsed)
|
||||
: ProcessValuesSchema.parse(parsed);
|
||||
}
|
||||
|
||||
function task(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: row.taskId,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
notes: row.notes,
|
||||
reference:
|
||||
row.referenceKind === null
|
||||
? null
|
||||
: {
|
||||
kind: row.referenceKind,
|
||||
id: row.referenceId,
|
||||
label: row.referenceLabel,
|
||||
...(row.sourceKind === null ? {} : { sourceKind: row.sourceKind }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readProcessRows(
|
||||
database: Sequelize,
|
||||
table: ProcessTable,
|
||||
transaction: Transaction,
|
||||
mode: "compatible" | "normalized" = "compatible"
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const rows = await database.query<Record<string, unknown>>(
|
||||
`SELECT * FROM ${supportIdentifier(table)} ORDER BY id`,
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const childRows = await database.query<Record<string, unknown>>(
|
||||
`SELECT * FROM ${supportIdentifier(children(table))} ORDER BY ownerId,position`,
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
|
||||
return rows.map((row) => {
|
||||
const metadata = Object.fromEntries(
|
||||
Object.entries(row).filter(
|
||||
([key]) =>
|
||||
!(key in columns(table)) ||
|
||||
(table === "person_processes" && (key === "personId" || key === "templateId"))
|
||||
)
|
||||
);
|
||||
|
||||
if (mode === "compatible" && row.values !== undefined) {
|
||||
return { ...metadata, values: input(table, row.values) };
|
||||
}
|
||||
const values = Object.fromEntries(
|
||||
Object.keys(columns(table)).map((name) => [
|
||||
name,
|
||||
name === "active"
|
||||
? z
|
||||
.union([z.boolean(), z.literal(0), z.literal(1)])
|
||||
.transform(Boolean)
|
||||
.parse(row[name])
|
||||
: row[name],
|
||||
])
|
||||
);
|
||||
const selected = childRows.filter((child) => child.ownerId === row.id);
|
||||
|
||||
if (selected.some((child, position) => child.position !== position)) {
|
||||
throw new Error("Orden de tareas de proceso inconsistente.");
|
||||
}
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
values: input(table, {
|
||||
...values,
|
||||
...(templateTable(table)
|
||||
? { instructions: selected.map((child) => child.title) }
|
||||
: { tasks: selected.map(task) }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeProcessRow(
|
||||
database: Sequelize,
|
||||
table: ProcessTable,
|
||||
row: Record<string, unknown>,
|
||||
transaction: Transaction,
|
||||
operation: "insert" | "update"
|
||||
) {
|
||||
const values = input(table, row.values);
|
||||
const { values: _values, ...metadata } = row;
|
||||
const {
|
||||
instructions: _instructions,
|
||||
tasks: _tasks,
|
||||
...scalars
|
||||
} = z.record(z.string(), z.unknown()).parse(values);
|
||||
|
||||
await writeSupportScalars(database, table, { ...metadata, ...scalars }, transaction, operation);
|
||||
const ownerId = z.uuid().parse(row.id);
|
||||
const childTable = children(table);
|
||||
|
||||
await database.query(`DELETE FROM ${supportIdentifier(childTable)} WHERE ownerId=:ownerId`, {
|
||||
replacements: { ownerId },
|
||||
transaction,
|
||||
});
|
||||
if ("instructions" in values) {
|
||||
for (const [position, title] of values.instructions.entries()) {
|
||||
await writeSupportScalars(
|
||||
database,
|
||||
childTable,
|
||||
{ ownerId, position, title },
|
||||
transaction,
|
||||
"insert"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const [position, item] of values.tasks.entries()) {
|
||||
await writeSupportScalars(
|
||||
database,
|
||||
childTable,
|
||||
{
|
||||
ownerId,
|
||||
position,
|
||||
taskId: item.id,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
notes: item.notes,
|
||||
referenceKind: item.reference?.kind ?? null,
|
||||
referenceId: item.reference?.id ?? null,
|
||||
referenceLabel: item.reference?.label ?? null,
|
||||
sourceKind: item.reference?.sourceKind ?? null,
|
||||
},
|
||||
transaction,
|
||||
"insert"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function prepare(database: Sequelize, table: ProcessTable) {
|
||||
const query = database.getQueryInterface();
|
||||
const original = await query.describeTable(table);
|
||||
|
||||
for (const [name, column] of Object.entries(columns(table))) {
|
||||
if (!(name in original)) {
|
||||
await query.addColumn(table, name, { ...column, allowNull: true });
|
||||
}
|
||||
}
|
||||
const childTable = children(table);
|
||||
const tables = await database.query<{ name: string }>(
|
||||
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
|
||||
{ replacements: { table: childTable }, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (!tables.length) {
|
||||
await query.createTable(childTable, {
|
||||
ownerId: { ...reference(table), onDelete: "CASCADE", primaryKey: true },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false, primaryKey: templateTable(table) },
|
||||
title: name,
|
||||
...(templateTable(table)
|
||||
? {}
|
||||
: {
|
||||
taskId: { type: DataTypes.UUID, allowNull: false, primaryKey: true },
|
||||
status: { type: DataTypes.STRING(20), allowNull: false },
|
||||
notes: note,
|
||||
referenceKind: { type: DataTypes.STRING(20), allowNull: true },
|
||||
referenceId: { type: DataTypes.UUID, allowNull: true },
|
||||
referenceLabel: { type: DataTypes.STRING(500), allowNull: true },
|
||||
sourceKind: { type: DataTypes.STRING(20), allowNull: true },
|
||||
}),
|
||||
});
|
||||
}
|
||||
const indexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
|
||||
{ replacements: { table: childTable }, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (!indexes.some((row) => row.name === `${childTable}_position`)) {
|
||||
await query.addIndex(childTable, ["ownerId", "position"], {
|
||||
unique: true,
|
||||
name: `${childTable}_position`,
|
||||
});
|
||||
}
|
||||
|
||||
return original.values !== undefined;
|
||||
}
|
||||
|
||||
async function transfer(database: Sequelize, rootTable: ProcessTable, revisionTable: ProcessTable) {
|
||||
await database.transaction(async (transaction) => {
|
||||
const roots = await readProcessRows(database, rootTable, transaction);
|
||||
const revisions = await readProcessRows(database, revisionTable, transaction);
|
||||
|
||||
for (const root of roots) {
|
||||
const history = revisions.filter((row) => row.itemId === root.id);
|
||||
|
||||
if (templateTable(rootTable)) {
|
||||
new ProcessTemplatePersistenceMapper().toDomain(root, history);
|
||||
} else {
|
||||
new PersonProcessPersistenceMapper().toDomain(root, history);
|
||||
}
|
||||
}
|
||||
for (const [table, rows] of [
|
||||
[rootTable, roots],
|
||||
[revisionTable, revisions],
|
||||
] as const) {
|
||||
for (const row of rows) {
|
||||
await writeProcessRow(database, table, row, transaction, "update");
|
||||
}
|
||||
if (
|
||||
!isDeepStrictEqual(rows, await readProcessRows(database, table, transaction, "normalized"))
|
||||
) {
|
||||
throw new Error("Los procesos normalizados no coinciden con el origen.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function normalizeProcesses(database: Sequelize) {
|
||||
for (const [root, revision] of [
|
||||
["process_templates", "process_template_revisions"],
|
||||
["person_processes", "person_process_revisions"],
|
||||
] as const) {
|
||||
const rootLegacy = await prepare(database, root);
|
||||
const revisionLegacy = await prepare(database, revision);
|
||||
|
||||
if (rootLegacy || revisionLegacy) {
|
||||
await transfer(database, root, revision);
|
||||
for (const [table, legacy] of [
|
||||
[root, rootLegacy],
|
||||
[revision, revisionLegacy],
|
||||
] as const) {
|
||||
if (legacy) {
|
||||
await database.query(`ALTER TABLE ${supportIdentifier(table)} DROP COLUMN \`values\``);
|
||||
}
|
||||
}
|
||||
}
|
||||
await tightenProcessColumns(database, root);
|
||||
await tightenProcessColumns(database, revision);
|
||||
}
|
||||
}
|
||||
|
||||
async function tightenProcessColumns(database: Sequelize, table: ProcessTable) {
|
||||
const query = database.getQueryInterface();
|
||||
const final = await query.describeTable(table);
|
||||
|
||||
for (const [name, column] of Object.entries(columns(table))) {
|
||||
if (column.allowNull === false && final[name]?.allowNull) {
|
||||
await query.changeColumn(table, name, column);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
|
||||
|
||||
@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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))"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ export type SnapshotDefinition<T> = {
|
||||
schema: z.ZodType<T>;
|
||||
columns: Columns;
|
||||
booleans?: readonly string[];
|
||||
fromColumns?: (row: Record<string, unknown>) => unknown;
|
||||
toColumns?: (input: T) => object;
|
||||
};
|
||||
|
||||
function identifier(value: string) {
|
||||
@ -41,7 +43,7 @@ export function snapshotValues<T>(definition: SnapshotDefinition<T>, row: object
|
||||
})
|
||||
);
|
||||
|
||||
return definition.schema.parse(input);
|
||||
return definition.schema.parse(definition.fromColumns ? definition.fromColumns(input) : input);
|
||||
}
|
||||
|
||||
export async function normalizeScalarSnapshots<T>(
|
||||
@ -100,7 +102,7 @@ async function transferSnapshotRow<T>(
|
||||
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)) {
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { type SnapshotDefinition, snapshotValues } from "./scalar-snapshot-normalization";
|
||||
import { writeSupportScalars } from "./support-record-relational";
|
||||
|
||||
export async function writeSnapshotRow<T>(
|
||||
database: Sequelize,
|
||||
table: string,
|
||||
definition: SnapshotDefinition<T>,
|
||||
row: object,
|
||||
transaction: Transaction,
|
||||
operation: "insert" | "update"
|
||||
) {
|
||||
const { values: _values, ...metadata } = z.record(z.string(), z.unknown()).parse(row);
|
||||
const input = snapshotValues(definition, row);
|
||||
const columns = definition.toColumns ? definition.toColumns(input) : input;
|
||||
|
||||
await writeSupportScalars(
|
||||
database,
|
||||
table,
|
||||
{ ...metadata, ...z.record(z.string(), z.unknown()).parse(columns) },
|
||||
transaction,
|
||||
operation
|
||||
);
|
||||
}
|
||||
@ -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 : [""] };
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<IMailboxRepository["save"]>[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({
|
||||
|
||||
@ -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<ProcessCatalogContext> {
|
||||
const templates = await this.database.query<ProcessTemplateRow>(
|
||||
"SELECT * FROM process_templates",
|
||||
{ transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const revisions = await this.database.query<ProcessTemplateRevisionRow>(
|
||||
"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<PersonProcessRow>(
|
||||
"SELECT * FROM person_processes WHERE companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const revisions = await this.database.query<PersonProcessRevisionRow>(
|
||||
"SELECT r.* FROM person_process_revisions r JOIN person_processes p ON p.id=r.itemId WHERE p.companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
const 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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])),
|
||||
|
||||
@ -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<void> {
|
||||
const query = database.getQueryInterface();
|
||||
const tableRows = await database.query<{ name: string }>(
|
||||
@ -50,9 +52,10 @@ export async function migrateIdentity(database: Sequelize): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import session from "express-session";
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
import { restoreSession, sessionRow, writeSession } from "./session-normalization";
|
||||
|
||||
type Callback = (error?: Error | null) => void;
|
||||
|
||||
function failure(error: unknown) {
|
||||
return error instanceof Error ? error : new Error("Fallo del almacén de sesiones.");
|
||||
}
|
||||
|
||||
/** Almacén propio: persistencia relacional del contrato de sesión usado por Metis Desk. */
|
||||
export class SequelizeSessionStore extends session.Store {
|
||||
constructor(private readonly database: Sequelize) {
|
||||
super();
|
||||
}
|
||||
|
||||
async get(
|
||||
sid: string,
|
||||
callback: (error: Error | null, data?: session.SessionData | null) => void
|
||||
) {
|
||||
let result: session.SessionData | null = null;
|
||||
let error: Error | null = null;
|
||||
|
||||
try {
|
||||
const rows = await this.database.query<Record<string, unknown>>(
|
||||
"SELECT * FROM http_sessions WHERE sid=:sid AND expires>NOW()",
|
||||
{ replacements: { sid }, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
result = rows[0] ? restoreSession(rows[0]) : null;
|
||||
} catch (caught) {
|
||||
error = failure(caught);
|
||||
}
|
||||
callback(error, result);
|
||||
}
|
||||
|
||||
set(sid: string, data: session.SessionData, callback?: Callback) {
|
||||
return this.complete(async () => {
|
||||
await writeSession(this.database, sid, data);
|
||||
await this.database.query("DELETE FROM http_sessions WHERE expires<=NOW()");
|
||||
}, callback);
|
||||
}
|
||||
|
||||
destroy(sid: string, callback?: Callback) {
|
||||
return this.complete(async () => {
|
||||
await this.database.query("DELETE FROM http_sessions WHERE sid=:sid", {
|
||||
replacements: { sid },
|
||||
});
|
||||
}, callback);
|
||||
}
|
||||
|
||||
touch(sid: string, data: session.SessionData, callback?: Callback) {
|
||||
return this.complete(async () => {
|
||||
const cookie = sessionRow(data);
|
||||
const expires = cookie.cookieExpires
|
||||
? new Date(cookie.cookieExpires)
|
||||
: new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||
|
||||
// Tocar la sesión renueva la cookie; no sobrescribe la autenticación de otra petición.
|
||||
await this.database.query(
|
||||
"UPDATE http_sessions SET expires=:expires,cookieExpires=:cookieExpires,updatedAt=NOW() WHERE sid=:sid",
|
||||
{ replacements: { sid, expires, cookieExpires: cookie.cookieExpires } }
|
||||
);
|
||||
}, callback);
|
||||
}
|
||||
|
||||
private async complete(operation: () => Promise<void>, callback?: Callback) {
|
||||
let error: Error | null = null;
|
||||
|
||||
try {
|
||||
await operation();
|
||||
} catch (caught) {
|
||||
if (!callback) {
|
||||
throw caught;
|
||||
}
|
||||
error = failure(caught);
|
||||
}
|
||||
callback?.(error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,190 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import session from "express-session";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const optionalBoolean = { type: DataTypes.BOOLEAN, allowNull: true };
|
||||
|
||||
export const sessionColumns = {
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model: "users", key: "id" },
|
||||
onDelete: "SET NULL",
|
||||
},
|
||||
authVersion: { type: DataTypes.INTEGER, allowNull: true },
|
||||
csrfToken: { type: DataTypes.STRING(128), allowNull: true },
|
||||
cookieExpires: { type: DataTypes.STRING(30), allowNull: true },
|
||||
originalMaxAge: { type: DataTypes.BIGINT, allowNull: true },
|
||||
cookiePath: { type: DataTypes.STRING(255), allowNull: true },
|
||||
cookieDomain: { type: DataTypes.STRING(255), allowNull: true },
|
||||
cookieSecure: { type: DataTypes.STRING(10), allowNull: true },
|
||||
cookieHttpOnly: optionalBoolean,
|
||||
cookieSameSite: { type: DataTypes.STRING(10), allowNull: true },
|
||||
cookiePartitioned: optionalBoolean,
|
||||
cookiePriority: { type: DataTypes.STRING(10), allowNull: true },
|
||||
};
|
||||
|
||||
const cookieSchema = z.object({
|
||||
originalMaxAge: z.number().int().nonnegative().nullable(),
|
||||
expires: z.preprocess(
|
||||
(value) => (value instanceof Date ? value.toISOString() : value),
|
||||
z.iso.datetime().nullish()
|
||||
),
|
||||
path: z.string().max(255).optional(),
|
||||
domain: z.string().max(255).optional(),
|
||||
secure: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||
httpOnly: z.boolean().optional(),
|
||||
sameSite: z.union([z.boolean(), z.enum(["lax", "strict", "none"])]).optional(),
|
||||
partitioned: z.boolean().optional(),
|
||||
priority: z.enum(["low", "medium", "high"]).optional(),
|
||||
});
|
||||
const sessionSchema = z.strictObject({
|
||||
cookie: cookieSchema,
|
||||
userId: z.uuid().optional(),
|
||||
authVersion: z.number().int().nonnegative().optional(),
|
||||
csrfToken: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
export function sessionValues(source: unknown) {
|
||||
return sessionSchema.parse(source);
|
||||
}
|
||||
|
||||
export function sessionRow(source: unknown) {
|
||||
const data = sessionValues(source);
|
||||
const cookie = data.cookie;
|
||||
|
||||
return {
|
||||
userId: data.userId ?? null,
|
||||
authVersion: data.authVersion ?? null,
|
||||
csrfToken: data.csrfToken ?? null,
|
||||
cookieExpires: cookie.expires ?? null,
|
||||
originalMaxAge: cookie.originalMaxAge,
|
||||
cookiePath: cookie.path ?? null,
|
||||
cookieDomain: cookie.domain ?? null,
|
||||
cookieSecure: cookie.secure === undefined ? null : String(cookie.secure),
|
||||
cookieHttpOnly: cookie.httpOnly ?? null,
|
||||
cookieSameSite: cookie.sameSite === undefined ? null : String(cookie.sameSite),
|
||||
cookiePartitioned: cookie.partitioned ?? null,
|
||||
cookiePriority: cookie.priority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function flag(value: unknown) {
|
||||
return z
|
||||
.union([z.boolean(), z.literal(0), z.literal(1)])
|
||||
.transform(Boolean)
|
||||
.parse(value);
|
||||
}
|
||||
|
||||
function literal(value: unknown) {
|
||||
return value === "true" ? true : value === "false" ? false : value;
|
||||
}
|
||||
|
||||
export function sessionFromRow(row: Record<string, unknown>) {
|
||||
return sessionValues({
|
||||
cookie: {
|
||||
originalMaxAge:
|
||||
row.originalMaxAge === null
|
||||
? null
|
||||
: z.coerce.number().int().nonnegative().parse(row.originalMaxAge),
|
||||
expires: row.cookieExpires,
|
||||
...(row.cookiePath === null ? {} : { path: row.cookiePath }),
|
||||
...(row.cookieDomain === null ? {} : { domain: row.cookieDomain }),
|
||||
...(row.cookieSecure === null ? {} : { secure: literal(row.cookieSecure) }),
|
||||
...(row.cookieHttpOnly === null ? {} : { httpOnly: flag(row.cookieHttpOnly) }),
|
||||
...(row.cookieSameSite === null ? {} : { sameSite: literal(row.cookieSameSite) }),
|
||||
...(row.cookiePartitioned === null ? {} : { partitioned: flag(row.cookiePartitioned) }),
|
||||
...(row.cookiePriority === null ? {} : { priority: row.cookiePriority }),
|
||||
},
|
||||
...(row.userId === null ? {} : { userId: row.userId }),
|
||||
...(row.authVersion === null ? {} : { authVersion: row.authVersion }),
|
||||
...(row.csrfToken === null ? {} : { csrfToken: row.csrfToken }),
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreSession(row: Record<string, unknown>): session.SessionData {
|
||||
const data = sessionFromRow(row);
|
||||
const cookie = Object.assign(new session.Cookie(), {
|
||||
...data.cookie,
|
||||
expires: data.cookie.expires ? new Date(data.cookie.expires) : null,
|
||||
});
|
||||
|
||||
cookie.originalMaxAge = data.cookie.originalMaxAge;
|
||||
|
||||
return {
|
||||
cookie,
|
||||
userId: data.userId,
|
||||
authVersion: data.authVersion,
|
||||
csrfToken: data.csrfToken ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeSession(
|
||||
database: Sequelize,
|
||||
sid: string,
|
||||
source: unknown,
|
||||
transaction?: Transaction
|
||||
) {
|
||||
z.string().min(1).max(36).parse(sid);
|
||||
const row = sessionRow(source);
|
||||
const expires = row.cookieExpires
|
||||
? new Date(row.cookieExpires)
|
||||
: new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||
const names = Object.keys(row);
|
||||
|
||||
await database.query(
|
||||
`INSERT INTO http_sessions (sid,expires,createdAt,updatedAt,${names.map((name) => `\`${name}\``).join(",")}) VALUES (:sid,:expires,NOW(),NOW(),${names.map((name) => `:${name}`).join(",")}) ON DUPLICATE KEY UPDATE expires=VALUES(expires),updatedAt=NOW(),${names.map((name) => `\`${name}\`=VALUES(\`${name}\`)`).join(",")}`,
|
||||
{ replacements: { sid, expires, ...row }, ...(transaction ? { transaction } : {}) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function normalizeSessions(database: Sequelize) {
|
||||
const query = database.getQueryInterface();
|
||||
const original = await query.describeTable("http_sessions");
|
||||
|
||||
for (const [name, column] of Object.entries(sessionColumns)) {
|
||||
if (!(name in original)) {
|
||||
await query.addColumn("http_sessions", name, column);
|
||||
}
|
||||
}
|
||||
const indexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='http_sessions'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (!indexes.some((index) => index.name === "http_sessions_expires")) {
|
||||
await query.addIndex("http_sessions", ["expires"], { name: "http_sessions_expires" });
|
||||
}
|
||||
if (!original.data) {
|
||||
return;
|
||||
}
|
||||
await database.transaction(async (transaction) => {
|
||||
const rows = await database.query<Record<string, unknown>>("SELECT * FROM http_sessions", {
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
const source = typeof row.data === "string" ? JSON.parse(row.data) : row.data;
|
||||
const expected = sessionRow(source);
|
||||
const names = Object.keys(expected);
|
||||
|
||||
await database.query(
|
||||
`UPDATE http_sessions SET ${names.map((name) => `\`${name}\`=:${name}`).join(",")} WHERE sid=:sid`,
|
||||
{ replacements: { sid: row.sid, ...expected }, transaction }
|
||||
);
|
||||
const actual = await database.query<Record<string, unknown>>(
|
||||
"SELECT * FROM http_sessions WHERE sid=:sid",
|
||||
{ replacements: { sid: row.sid }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (!(actual[0] && isDeepStrictEqual(expected, sessionRow(sessionFromRow(actual[0]))))) {
|
||||
throw new Error("La sesión normalizada no coincide con el origen.");
|
||||
}
|
||||
}
|
||||
});
|
||||
await database.query("ALTER TABLE http_sessions DROP COLUMN data");
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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<RevisionRow>(
|
||||
`SELECT id,actorId,recordedAt,\`values\`,${column} AS position FROM ${table} WHERE itemId=:id ORDER BY ${column}`,
|
||||
{ replacements: { id: row.id }, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const 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<SourceRow>(
|
||||
"SELECT id,companyId,`values` FROM mailboxes WHERE companyId=:companyId",
|
||||
{ replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const accessRows = await database.query<SourceRow>(
|
||||
"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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const bool = z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean);
|
||||
const mailbox = z.object({
|
||||
address: z.email(),
|
||||
kind: z.enum(["corporate", "external"]),
|
||||
context: z.string().nullable(),
|
||||
accountId: z.uuid().nullable(),
|
||||
active: bool,
|
||||
notes: z.string().nullable(),
|
||||
});
|
||||
const access = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
active: bool,
|
||||
resourceTypeId: z.uuid(),
|
||||
systemId: z.uuid().nullable(),
|
||||
environmentId: z.uuid().nullable(),
|
||||
reference: z.string().nullable(),
|
||||
notes: z.string().nullable(),
|
||||
})
|
||||
.transform((values) => ({ kind: "resource", ...values }));
|
||||
const root = z.object({ id: z.uuid(), companyId: z.uuid() });
|
||||
const revision = z.object({
|
||||
id: z.uuid(),
|
||||
actorId: z.uuid(),
|
||||
recordedAt: z.iso.datetime(),
|
||||
position: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export async function readResourceProjections(
|
||||
database: Sequelize,
|
||||
kind: "mailbox" | "access",
|
||||
companyId: string,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const sql =
|
||||
kind === "mailbox"
|
||||
? "SELECT * FROM mailboxes WHERE companyId=:companyId"
|
||||
: "SELECT i.id,w.companyId,v.* FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId JOIN access_resource_values v ON v.ownerId=i.id WHERE w.companyId=:companyId";
|
||||
const rows = await database.query<Record<string, unknown>>(sql, {
|
||||
replacements: { companyId },
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
...root.parse(row),
|
||||
values: kind === "mailbox" ? mailbox.parse(row) : access.parse(row),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function readResourceProjectionHistory(
|
||||
database: Sequelize,
|
||||
kind: "mailbox" | "access",
|
||||
id: string,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const sql =
|
||||
kind === "mailbox"
|
||||
? "SELECT * FROM mailbox_revisions WHERE itemId=:id ORDER BY position"
|
||||
: "SELECT r.id,r.actorId,r.recordedAt,r.sequence AS position,v.* FROM access_revisions r JOIN access_resource_revision_values v ON v.ownerId=r.id WHERE r.itemId=:id ORDER BY r.sequence";
|
||||
const rows = await database.query<Record<string, unknown>>(sql, {
|
||||
replacements: { id },
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
...revision.parse(row),
|
||||
values: kind === "mailbox" ? mailbox.parse(row) : access.parse(row),
|
||||
}));
|
||||
}
|
||||
@ -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<Record<string, unknown>>(
|
||||
"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,
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user