BD normalizada
This commit is contained in:
parent
ee21c0a80e
commit
bf4831a34f
@ -11,23 +11,22 @@
|
|||||||
"start": "node --env-file=../../.env dist/index.js"
|
"start": "node --env-file=../../.env dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@metis/core": "workspace:*",
|
|
||||||
"express": "4.22.1",
|
|
||||||
"helmet": "8.1.0",
|
|
||||||
"zod": "4.3.6",
|
|
||||||
"@metis/identity": "workspace:*",
|
|
||||||
"sequelize": "6.37.8",
|
|
||||||
"mariadb": "3.5.4",
|
|
||||||
"express-session": "1.19.0",
|
|
||||||
"connect-session-sequelize": "8.0.6",
|
|
||||||
"@metis/companies": "workspace:*",
|
|
||||||
"@metis/assets": "workspace:*",
|
"@metis/assets": "workspace:*",
|
||||||
"@metis/resources": "workspace:*"
|
"@metis/companies": "workspace:*",
|
||||||
|
"@metis/core": "workspace:*",
|
||||||
|
"@metis/identity": "workspace:*",
|
||||||
|
"@metis/resources": "workspace:*",
|
||||||
|
"express": "4.22.1",
|
||||||
|
"express-session": "1.19.0",
|
||||||
|
"helmet": "8.1.0",
|
||||||
|
"mariadb": "3.5.4",
|
||||||
|
"sequelize": "6.37.8",
|
||||||
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.21",
|
||||||
"tsup": "8.5.1",
|
"@types/express-session": "^1.18.2",
|
||||||
"@types/express-session": "^1.18.2"
|
"tsup": "8.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import { createResourceModule } from "@metis/resources/module";
|
|||||||
import { QueryTypes } from "sequelize";
|
import { QueryTypes } from "sequelize";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { AccessKindSchema } from "../../../modules/companies/src/api/domain/access/access-definition";
|
||||||
|
import { accessValueTable } from "../../../modules/companies/src/api/infrastructure/persistence/access-relational";
|
||||||
import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration";
|
import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration";
|
||||||
|
|
||||||
import { verifyAccess } from "./access.integration";
|
import { verifyAccess } from "./access.integration";
|
||||||
@ -46,6 +48,7 @@ import { verifyPrinters } from "./printers.integration";
|
|||||||
import { verifyProcesses } from "./processes.integration";
|
import { verifyProcesses } from "./processes.integration";
|
||||||
import { verifyRecordDeletion } from "./record-deletion.integration";
|
import { verifyRecordDeletion } from "./record-deletion.integration";
|
||||||
import { verifyRelationships } from "./relationships.integration";
|
import { verifyRelationships } from "./relationships.integration";
|
||||||
|
import { verifyRemainingNormalization } from "./remaining-normalization.integration";
|
||||||
import { verifyRemoteConnections } from "./remote.integration";
|
import { verifyRemoteConnections } from "./remote.integration";
|
||||||
import { verifyResourceMigration } from "./resource-migration.integration";
|
import { verifyResourceMigration } from "./resource-migration.integration";
|
||||||
import { verifyResourceNormalization } from "./resource-normalization.integration";
|
import { verifyResourceNormalization } from "./resource-normalization.integration";
|
||||||
@ -142,6 +145,9 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
|||||||
await database.query("DELETE FROM access_person_references");
|
await database.query("DELETE FROM access_person_references");
|
||||||
await database.query("DELETE FROM access_references");
|
await database.query("DELETE FROM access_references");
|
||||||
await database.query("DELETE FROM access_revisions");
|
await database.query("DELETE FROM access_revisions");
|
||||||
|
for (const kind of AccessKindSchema.options) {
|
||||||
|
await database.query(`DELETE FROM ${accessValueTable(kind)}`);
|
||||||
|
}
|
||||||
await database.query("DELETE FROM access_items");
|
await database.query("DELETE FROM access_items");
|
||||||
await database.query("DELETE FROM access_workspaces WHERE companyId IS NOT NULL");
|
await database.query("DELETE FROM access_workspaces WHERE companyId IS NOT NULL");
|
||||||
await database.query("DELETE FROM users");
|
await database.query("DELETE FROM users");
|
||||||
@ -992,6 +998,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
|||||||
customer.id
|
customer.id
|
||||||
);
|
);
|
||||||
await verifyConfigurationNormalization(database);
|
await verifyConfigurationNormalization(database);
|
||||||
|
await verifyRemainingNormalization(database);
|
||||||
await verifyRecordDeletion((path, body) => operator.request(path, body), database);
|
await verifyRecordDeletion((path, body) => operator.request(path, body), database);
|
||||||
const countAfterAccess = await database.query<{ total: number }>(
|
const countAfterAccess = await database.query<{ total: number }>(
|
||||||
"SELECT COUNT(*) AS total FROM users",
|
"SELECT COUNT(*) AS total FROM users",
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import { createAssetModule } from "@metis/assets/module";
|
import { createAssetModule } from "@metis/assets/module";
|
||||||
import { createCompanyModule } from "@metis/companies/module";
|
import { createCompanyModule } from "@metis/companies/module";
|
||||||
import { createIdentityModule, migrateIdentity } from "@metis/identity/module";
|
import {
|
||||||
|
SequelizeSessionStore,
|
||||||
|
createIdentityModule,
|
||||||
|
migrateIdentity,
|
||||||
|
} from "@metis/identity/module";
|
||||||
import { createResourceModule } from "@metis/resources/module";
|
import { createResourceModule } from "@metis/resources/module";
|
||||||
import connectSequelize from "connect-session-sequelize";
|
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import type { Sequelize } from "sequelize";
|
import type { Sequelize } from "sequelize";
|
||||||
|
|
||||||
@ -34,12 +37,7 @@ export async function createIdentityRuntime(
|
|||||||
assets.services.dashboard,
|
assets.services.dashboard,
|
||||||
resources.services.dashboard
|
resources.services.dashboard
|
||||||
);
|
);
|
||||||
const SequelizeStore = connectSequelize(session.Store);
|
const store = new SequelizeSessionStore(database);
|
||||||
const store = new SequelizeStore({
|
|
||||||
db: database,
|
|
||||||
tableName: "http_sessions",
|
|
||||||
expiration: 8 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
const middleware = session({
|
const middleware = session({
|
||||||
name: "metis.sid",
|
name: "metis.sid",
|
||||||
secret: environment.SESSION_SECRET,
|
secret: environment.SESSION_SECRET,
|
||||||
@ -65,6 +63,7 @@ export async function createIdentityRuntime(
|
|||||||
dashboard.register(app);
|
dashboard.register(app);
|
||||||
},
|
},
|
||||||
middleware,
|
middleware,
|
||||||
stop: () => store.stopExpiringSessions(),
|
// El almacén relacional no utiliza un temporizador de fondo.
|
||||||
|
stop: () => undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,10 @@ import type { Sequelize } from "sequelize";
|
|||||||
import { QueryTypes } from "sequelize";
|
import { QueryTypes } from "sequelize";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { mailSnapshot } from "../../../modules/companies/src/api/infrastructure/persistence/mail-remote-snapshots";
|
||||||
|
import { snapshotValues } from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-normalization";
|
||||||
|
import { writeSnapshotRow } from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-writer";
|
||||||
|
|
||||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||||
|
|
||||||
export async function verifyMail(
|
export async function verifyMail(
|
||||||
@ -304,32 +308,37 @@ export async function verifyMail(
|
|||||||
state: "configured",
|
state: "configured",
|
||||||
notes: "Conservar",
|
notes: "Conservar",
|
||||||
};
|
};
|
||||||
const raw = JSON.stringify(legacyValues);
|
|
||||||
|
|
||||||
await database.query(
|
await database.transaction(async (transaction) => {
|
||||||
"INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,NULL,NULL,:values,:key)",
|
await writeSnapshotRow(
|
||||||
{
|
database,
|
||||||
replacements: {
|
"mail_configurations",
|
||||||
|
mailSnapshot,
|
||||||
|
{
|
||||||
id: legacyId,
|
id: legacyId,
|
||||||
companyId,
|
companyId,
|
||||||
...legacyValues,
|
values: legacyValues,
|
||||||
values: raw,
|
currentKey: [person.id, account.id, first.id].join(":"),
|
||||||
key: [person.id, account.id, first.id].join(":"),
|
|
||||||
},
|
},
|
||||||
}
|
transaction,
|
||||||
);
|
"insert"
|
||||||
await database.query(
|
);
|
||||||
"INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,0,:actorId,:recordedAt,:values)",
|
await writeSnapshotRow(
|
||||||
{
|
database,
|
||||||
replacements: {
|
"mail_configuration_revisions",
|
||||||
|
mailSnapshot,
|
||||||
|
{
|
||||||
id: revisionId,
|
id: revisionId,
|
||||||
configurationId: legacyId,
|
configurationId: legacyId,
|
||||||
|
position: 0,
|
||||||
actorId,
|
actorId,
|
||||||
recordedAt: new Date().toISOString(),
|
recordedAt: new Date().toISOString(),
|
||||||
values: raw,
|
values: legacyValues,
|
||||||
},
|
},
|
||||||
}
|
transaction,
|
||||||
);
|
"insert"
|
||||||
|
);
|
||||||
|
});
|
||||||
const legacy = MailConfigurationsResponseSchema.parse(
|
const legacy = MailConfigurationsResponseSchema.parse(
|
||||||
await (await request(path)).json()
|
await (await request(path)).json()
|
||||||
).configurations.find((item) => item.id === legacyId);
|
).configurations.find((item) => item.id === legacyId);
|
||||||
@ -351,14 +360,14 @@ export async function verifyMail(
|
|||||||
assert.equal(adopted.history[0]?.id, revisionId);
|
assert.equal(adopted.history[0]?.id, revisionId);
|
||||||
assert.equal(adopted.history.length, 2);
|
assert.equal(adopted.history.length, 2);
|
||||||
assert.deepEqual(adopted.history[0]?.values, legacyValues);
|
assert.deepEqual(adopted.history[0]?.values, legacyValues);
|
||||||
const persisted = await database.query<{ values: string }>(
|
const persisted = await database.query<Record<string, unknown>>(
|
||||||
"SELECT `values` FROM mail_configuration_revisions WHERE id=:id",
|
"SELECT * FROM mail_configuration_revisions WHERE id=:id",
|
||||||
{ replacements: { id: revisionId }, type: QueryTypes.SELECT }
|
{ replacements: { id: revisionId }, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
const original = persisted[0]?.values;
|
const original = persisted[0];
|
||||||
|
|
||||||
assert.ok(original);
|
assert.ok(original);
|
||||||
assert.deepEqual(typeof original === "string" ? JSON.parse(original) : original, legacyValues);
|
assert.deepEqual(snapshotValues(mailSnapshot, original), legacyValues);
|
||||||
await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", {
|
await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", {
|
||||||
replacements: { id: mobile.id },
|
replacements: { id: mobile.id },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
|
|||||||
needId: string;
|
needId: string;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
}>(
|
}>(
|
||||||
"SELECT p.id AS profileId,p.companyId,i.id AS needId,u.id AS actorId FROM work_profiles p JOIN access_workspaces w ON w.companyId=p.companyId JOIN access_items i ON i.workspaceId=w.id CROSS JOIN users u WHERE JSON_UNQUOTE(JSON_EXTRACT(i.`values`, '$.kind'))='need' LIMIT 1",
|
"SELECT p.id AS profileId,p.companyId,i.id AS needId,u.id AS actorId FROM work_profiles p JOIN access_workspaces w ON w.companyId=p.companyId JOIN access_items i ON i.workspaceId=w.id CROSS JOIN users u WHERE i.kind='need' LIMIT 1",
|
||||||
{ type: QueryTypes.SELECT }
|
{ type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
const subject = folderSubjects[0];
|
const subject = folderSubjects[0];
|
||||||
|
|||||||
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
|
## Estado de la normalización
|
||||||
|
|
||||||
La normalización completa todavía está en curso. La revisión anterior extendió el modelo relacional de activos a recursos, puestos, asistencia y configuraciones de perfiles. La continuación incorpora tickets, intervenciones y plantillas de intervención. No representa el cierre de la auditoría de toda la base.
|
La auditoría y normalización de la persistencia actual están completadas. Activos, recursos, configuraciones, tickets e intervenciones se completan con cuentas y accesos, correo, accesos remotos, procesos y sesiones HTTP. No se mantienen documentos JSON en la base de desarrollo; los contratos HTTP continúan usando JSON como transporte.
|
||||||
|
|
||||||
| Ámbito | Persistencia actual |
|
| Ámbito | Persistencia actual |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@ -16,12 +16,12 @@ La normalización completa todavía está en curso. La revisión anterior extend
|
|||||||
| Perfiles de usuario | Nombre, estado, notas y referencias en columnas actuales e históricas |
|
| Perfiles de usuario | Nombre, estado, notas y referencias en columnas actuales e históricas |
|
||||||
| Teletrabajo | Estado, periodo, notas y persona en columnas actuales e históricas |
|
| Teletrabajo | Estado, periodo, notas y persona en columnas actuales e históricas |
|
||||||
| Aplicaciones, certificados, impresoras y carpetas del perfil | Valores escalares y referencias en columnas actuales e históricas |
|
| Aplicaciones, certificados, impresoras y carpetas del perfil | Valores escalares y referencias en columnas actuales e históricas |
|
||||||
| Cuentas y accesos | Pendiente: catálogo discriminado, asignaciones, permisos, grupos y necesidades |
|
| Cuentas y accesos | Subtipos en tablas actuales e históricas; permisos de necesidades en asociaciones ordenadas |
|
||||||
| Correo | Pendiente: proyecciones de buzones, usos y configuraciones, incluido su formato histórico anterior |
|
| Correo | Buzones, usos y configuraciones en columnas actuales e históricas; variantes legadas conservadas |
|
||||||
| Accesos remotos | Pendiente |
|
| Accesos remotos | Origen, destinos con claves foráneas independientes, VPN y estado en columnas actuales e históricas |
|
||||||
| Procesos y plantillas de procesos | Pendiente: pasos y revisiones |
|
| Procesos y plantillas de procesos | Columnas escalares y tablas de instrucciones/tareas actuales e históricas |
|
||||||
| Tickets e intervenciones | Columnas actuales e históricas; afectados, pasos, procedimientos congelados y plantillas en tablas relacionadas |
|
| Tickets e intervenciones | Columnas actuales e históricas; afectados, pasos, procedimientos congelados y plantillas en tablas relacionadas |
|
||||||
| Sesiones HTTP | Pendiente de evaluar el contrato del almacén de sesiones existente; no se introduce una excepción nueva para JSON |
|
| Sesiones HTTP | Almacén Sequelize propio: autenticación, CSRF y opciones de cookie en columnas; sin documento serializado |
|
||||||
|
|
||||||
Las columnas `data` de fotos y logotipos son datos binarios; no son documentos JSON de negocio.
|
Las columnas `data` de fotos y logotipos son datos binarios; no son documentos JSON de negocio.
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ Las tablas existentes `resource_definitions`, `resource_template_fields`, `resou
|
|||||||
|
|
||||||
La ausencia de una característica se representa mediante ausencia de fila. Una cadena vacía, `0` y `false` siguen siendo valores y se conservan. Las fechas se leen como fechas ISO. La especificación congelada no se reemplaza por el estado actual del catálogo.
|
La ausencia de una característica se representa mediante ausencia de fila. Una cadena vacía, `0` y `false` siguen siendo valores y se conservan. Las fechas se leen como fechas ISO. La especificación congelada no se reemplaza por el estado actual del catálogo.
|
||||||
|
|
||||||
Los vínculos de integración con buzones y accesos se conservan en columnas con claves foráneas. Eliminar una referencia externa no debe eliminar en cascada el recurso ni sus revisiones. Las proyecciones de compatibilidad de correo/accesos siguen existiendo hasta normalizar sus consumidores.
|
Los vínculos de integración con buzones y accesos se conservan en columnas con claves foráneas. Eliminar una referencia externa no debe eliminar en cascada el recurso ni sus revisiones. Las proyecciones de compatibilidad de correo/accesos conservan las integraciones y sus identidades; sus lectores y escritores también utilizan columnas y tablas normalizadas.
|
||||||
|
|
||||||
## Puestos, perfiles y configuraciones
|
## Puestos, perfiles y configuraciones
|
||||||
|
|
||||||
@ -79,9 +79,57 @@ El DDL de MariaDB no es transaccional. Una interrupción puede dejar columnas de
|
|||||||
|
|
||||||
Las pruebas de integración utilizan exclusivamente `metis_desk_test`. Comprueban origen inválido, conservación de UUID/historial/orden/valores, referencias incompatibles, idempotencia, arranque vacío y mantenimiento del comportamiento HTTP. Las fixtures SQL que antes escribían JSON se adaptan a las tablas nuevas.
|
Las pruebas de integración utilizan exclusivamente `metis_desk_test`. Comprueban origen inválido, conservación de UUID/historial/orden/valores, referencias incompatibles, idempotencia, arranque vacío y mantenimiento del comportamiento HTTP. Las fixtures SQL que antes escribían JSON se adaptan a las tablas nuevas.
|
||||||
|
|
||||||
## Continuación pendiente
|
## Cuentas y accesos
|
||||||
|
|
||||||
Antes de dar por terminada la normalización hay que implementar y verificar las filas pendientes del inventario, adaptar sus lectores de dashboard/procesos y proyecciones de compatibilidad, y repetir una auditoría del esquema real y del código de persistencia. No basta con cambiar una columna JSON a LONGTEXT ni con renombrar los documentos serializados.
|
Se conservan las raíces access_workspaces, access_items y access_revisions, y las proyecciones access_references y access_person_references. La raíz identifica compañía/ámbito y tipo; las revisiones mantienen UUID, secuencia, autor e instante. Cada tipo tiene una tabla de valores y otra de valores históricos:
|
||||||
|
|
||||||
|
| Tipo | Actual | Histórica |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sistema | access_system_values | access_system_revision_values |
|
||||||
|
| Tipo de recurso | access_resource_type_values | access_resource_type_revision_values |
|
||||||
|
| Permiso | access_permission_values | access_permission_revision_values |
|
||||||
|
| Plantilla de grupo | access_template_values | access_template_revision_values |
|
||||||
|
| Entorno | access_environment_values | access_environment_revision_values |
|
||||||
|
| Cuenta | access_account_values | access_account_revision_values |
|
||||||
|
| Titular/usuario de cuenta | access_account_person_values | access_account_person_revision_values |
|
||||||
|
| Grupo | access_group_values | access_group_revision_values |
|
||||||
|
| Pertenencia a grupo | access_membership_values | access_membership_revision_values |
|
||||||
|
| Proyección de recurso | access_resource_values | access_resource_revision_values |
|
||||||
|
| Permiso concedido | access_grant_values | access_grant_revision_values |
|
||||||
|
| Necesidad | access_need_values | access_need_revision_values |
|
||||||
|
| Cobertura | access_coverage_values | access_coverage_revision_values |
|
||||||
|
|
||||||
|
ownerId es clave primaria y foránea a la raíz o revisión correspondiente. Las referencias entre elementos y personas tienen claves foráneas. access_need_permissions y access_need_revision_permissions conservan permisos y orden; no permiten duplicar un permiso dentro de la misma necesidad. Las vistas access_current_values y access_historical_values unen columnas de los subtipos para lecturas comunes, sin generar JSON SQL.
|
||||||
|
|
||||||
|
El catálogo global y los ámbitos de compañía mantienen sus reglas. Se conservan titularidad histórica, cuentas compartidas, caducidad opcional, grupos, permisos, coberturas, bloqueos de concurrencia y proyecciones de referencias.
|
||||||
|
|
||||||
|
## Correo y accesos remotos
|
||||||
|
|
||||||
|
mailboxes, mailbox_uses, mail_configurations y sus revisiones almacenan dirección, tipo/contexto, cuenta vinculada, personas autorizadas, periodo, perfil, protocolo, estado y notas en columnas. snapshotKind diferencia configuraciones históricas anteriores (persona/cuenta/activo) de las actuales (perfil/buzón). No se inventa un perfil para transformar el historial anterior. Las restricciones SQL exigen las referencias apropiadas a cada variante.
|
||||||
|
|
||||||
|
remote_connections y remote_connection_revisions mantienen persona, tipo de origen, activo corporativo cuando corresponde, sede/red, método, VPN, estado y notas. El destino se separa en targetAssetId y targetResourceId, con claves foráneas reales. Las restricciones SQL impiden destinos incompatibles, un origen personal con activo corporativo y acceso al propio equipo como destino.
|
||||||
|
|
||||||
|
Los repositorios, lectores de dashboard/procesos y proyecciones de recursos reconstruyen los mismos contratos anteriores. Se mantienen las configuraciones múltiples por persona, la adopción de configuraciones legadas y todo el historial.
|
||||||
|
|
||||||
|
## Procesos
|
||||||
|
|
||||||
|
process_templates y process_template_revisions tienen columnas de nombre, tipo, estado y notas. Sus pasos ordenados se guardan en process_template_instructions y process_template_revision_instructions.
|
||||||
|
|
||||||
|
person_processes y person_process_revisions tienen persona, plantilla, nombre congelado de plantilla, nombre del proceso, tipo, estado y notas. person_process_tasks y person_process_revision_tasks conservan UUID de tarea, orden, título, estado, notas y referencia de revisión (tipo, UUID, etiqueta y procedencia).
|
||||||
|
|
||||||
|
La referencia de una tarea es información congelada para revisar una situación, no una asignación viva. No lleva FK al elemento revisado: debe conservarse aunque ese elemento se retire o elimine. Sí existen claves foráneas entre procesos, personas, plantillas, revisiones y sus tablas dependientes. Las instrucciones originales y tareas históricas no se reemplazan por una plantilla modificada.
|
||||||
|
|
||||||
|
## Sesiones
|
||||||
|
|
||||||
|
http_sessions conserva sid, expiración y fechas de creación/actualización; añade columnas de usuario, versión de autenticación, token CSRF y opciones de cookie. La columna data desaparece. El almacén propio SequelizeSessionStore sustituye connect-session-sequelize, que se retira de las dependencias.
|
||||||
|
|
||||||
|
Se conservan duración original y expiración de la cookie, opciones booleanas, autenticación y protección CSRF. touch renueva la cookie sin sobrescribir autenticación o CSRF de otra petición. Las sesiones vencidas no se recuperan y se purgan al guardar sesiones; la expiración tiene índice. No se necesita temporizador de fondo. La migración rechaza atributos de sesión desconocidos en lugar de descartarlos.
|
||||||
|
|
||||||
|
## Auditoría final
|
||||||
|
|
||||||
|
La auditoría de information_schema en desarrollo no encuentra tipos JSON, restricciones JSON_VALID ni columnas values, specification, characteristics, procedure u otros documentos serializados. Las columnas instructions de intervenciones son texto de instrucciones de un paso; data en fotos/logotipos es binario.
|
||||||
|
|
||||||
|
El código de producción no declara DataTypes.JSON. Los lectores JSON restantes corresponden a migraciones del origen anterior; las escrituras JSON de pruebas reconstruyen ese origen exclusivamente en metis_desk_test. El uso de JSON en HTTP, traducciones, logs y fixtures no es persistencia de documentos de negocio.
|
||||||
|
|
||||||
## Verificación de la revisión anterior
|
## Verificación de la revisión anterior
|
||||||
|
|
||||||
@ -101,3 +149,12 @@ El typecheck global sigue encontrando el problema anterior de `locale` en `packa
|
|||||||
- La prueba de integración reconstruye el origen JSON exclusivamente en `metis_desk_test`, rechaza documentos y referencias incoherentes, conserva las instantáneas y contadores, comprueba restricciones de destinos y repite la migración después de simular un corte entre raíz y revisiones. También se ha ejercitado el arranque desde una base de pruebas vacía.
|
- La prueba de integración reconstruye el origen JSON exclusivamente en `metis_desk_test`, rechaza documentos y referencias incoherentes, conserva las instantáneas y contadores, comprueba restricciones de destinos y repite la migración después de simular un corte entre raíz y revisiones. También se ha ejercitado el arranque desde una base de pruebas vacía.
|
||||||
- Base de desarrollo migrada e inicialización verificada. Auditoría del esquema: desaparecen las columnas `values`/`procedure` de los seis registros principales y existen las diez tablas relacionadas. Copia previa: `/home/david/backups/metis-desk/20261010-133639-tickets-intervenciones.sql`.
|
- Base de desarrollo migrada e inicialización verificada. Auditoría del esquema: desaparecen las columnas `values`/`procedure` de los seis registros principales y existen las diez tablas relacionadas. Copia previa: `/home/david/backups/metis-desk/20261010-133639-tickets-intervenciones.sql`.
|
||||||
- Los chequeos globales mantienen el error previo de `locale` en el calendario de shadcn/ui y errores de lint/formato en otros ficheros. No se modifica el código vendor ni cambios de interfaz realizados simultáneamente.
|
- Los chequeos globales mantienen el error previo de `locale` en el calendario de shadcn/ui y errores de lint/formato en otros ficheros. No se modifica el código vendor ni cambios de interfaz realizados simultáneamente.
|
||||||
|
|
||||||
|
## Verificación de cuentas, correo, accesos remotos, procesos y sesiones
|
||||||
|
|
||||||
|
- Suite completa: 277 pruebas superadas; 3 pruebas de espaciado superadas.
|
||||||
|
- Typecheck de servidor, identidad, compañías y recursos: correcto. Build: correcto.
|
||||||
|
- Biome y ESLint sobre todos los ficheros modificados: correctos, sin avisos.
|
||||||
|
- Integración HTTP/MariaDB desde una base de pruebas vacía: correcta. Se verifica transferencia del origen legado, rechazo de datos inválidos, conservación de valores actuales e históricos, UUID, orden, duración de cookies, CSRF, restricciones SQL e idempotencia, incluido un corte entre raíz e historial.
|
||||||
|
- Base de desarrollo migrada, inicialización verificada y auditoría de esquema sin columnas JSON ni documentos serializados. Copia previa: /home/david/backups/metis-desk/20261010-140951-normalizacion-restante.sql.
|
||||||
|
- El typecheck global mantiene el error previo de locale en el calendario vendor; lint/check global mantienen problemas de código y formato ajenos a estos cambios. No se modifica código vendor.
|
||||||
|
|||||||
@ -18,6 +18,15 @@ import { AccessValues } from "../../domain/access/access-values.vo";
|
|||||||
import { AccessWorkspace } from "../../domain/access/access-workspace.aggregate";
|
import { AccessWorkspace } from "../../domain/access/access-workspace.aggregate";
|
||||||
import type { Person } from "../../domain/person.entity";
|
import type { Person } from "../../domain/person.entity";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AccessItemRow,
|
||||||
|
type AccessRevisionRow,
|
||||||
|
normalizeAccess,
|
||||||
|
readAccessItems,
|
||||||
|
readAccessRevisions,
|
||||||
|
writeAccessRow,
|
||||||
|
} from "./access-relational";
|
||||||
|
|
||||||
class WorkspaceModel extends Model<
|
class WorkspaceModel extends Model<
|
||||||
InferAttributes<WorkspaceModel>,
|
InferAttributes<WorkspaceModel>,
|
||||||
InferCreationAttributes<WorkspaceModel>
|
InferCreationAttributes<WorkspaceModel>
|
||||||
@ -27,25 +36,6 @@ class WorkspaceModel extends Model<
|
|||||||
declare scopeKey: string;
|
declare scopeKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ItemModel extends Model<InferAttributes<ItemModel>, InferCreationAttributes<ItemModel>> {
|
|
||||||
declare id: string;
|
|
||||||
declare workspaceId: string;
|
|
||||||
declare kind: string;
|
|
||||||
declare values: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
class RevisionModel extends Model<
|
|
||||||
InferAttributes<RevisionModel>,
|
|
||||||
InferCreationAttributes<RevisionModel>
|
|
||||||
> {
|
|
||||||
declare id: string;
|
|
||||||
declare itemId: string;
|
|
||||||
declare sequence: number;
|
|
||||||
declare actorId: string;
|
|
||||||
declare recordedAt: string;
|
|
||||||
declare values: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReferenceModel extends Model<
|
class ReferenceModel extends Model<
|
||||||
InferAttributes<ReferenceModel>,
|
InferAttributes<ReferenceModel>,
|
||||||
InferCreationAttributes<ReferenceModel>
|
InferCreationAttributes<ReferenceModel>
|
||||||
@ -93,7 +83,6 @@ export async function migrateAccess(database: Sequelize) {
|
|||||||
id: idColumn,
|
id: idColumn,
|
||||||
workspaceId: reference("access_workspaces"),
|
workspaceId: reference("access_workspaces"),
|
||||||
kind: { type: DataTypes.STRING(30), allowNull: false },
|
kind: { type: DataTypes.STRING(30), allowNull: false },
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!exists("access_revisions")) {
|
if (!exists("access_revisions")) {
|
||||||
@ -103,7 +92,6 @@ export async function migrateAccess(database: Sequelize) {
|
|||||||
sequence: { type: DataTypes.INTEGER, allowNull: false },
|
sequence: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
actorId: reference("users"),
|
actorId: reference("users"),
|
||||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!exists("access_references")) {
|
if (!exists("access_references")) {
|
||||||
@ -141,26 +129,7 @@ export async function migrateAccess(database: Sequelize) {
|
|||||||
},
|
},
|
||||||
{ sequelize: database, tableName: "access_workspaces", timestamps: false }
|
{ sequelize: database, tableName: "access_workspaces", timestamps: false }
|
||||||
);
|
);
|
||||||
ItemModel.init(
|
await normalizeAccess(database);
|
||||||
{
|
|
||||||
id: idColumn,
|
|
||||||
workspaceId: { type: DataTypes.UUID, allowNull: false },
|
|
||||||
kind: { type: DataTypes.STRING(30), allowNull: false },
|
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
|
||||||
},
|
|
||||||
{ sequelize: database, tableName: "access_items", timestamps: false }
|
|
||||||
);
|
|
||||||
RevisionModel.init(
|
|
||||||
{
|
|
||||||
id: idColumn,
|
|
||||||
itemId: { type: DataTypes.UUID, allowNull: false },
|
|
||||||
sequence: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
actorId: { type: DataTypes.UUID, allowNull: false },
|
|
||||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
|
||||||
},
|
|
||||||
{ sequelize: database, tableName: "access_revisions", timestamps: false }
|
|
||||||
);
|
|
||||||
ReferenceModel.init(
|
ReferenceModel.init(
|
||||||
{
|
{
|
||||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||||
@ -177,7 +146,9 @@ export async function migrateAccess(database: Sequelize) {
|
|||||||
{ sequelize: database, tableName: "access_person_references", timestamps: false }
|
{ sequelize: database, tableName: "access_person_references", timestamps: false }
|
||||||
);
|
);
|
||||||
// Completar referencias de registros ya existentes sin reescribir sus valores ni su historial.
|
// Completar referencias de registros ya existentes sin reescribir sus valores ni su historial.
|
||||||
for (const item of await ItemModel.findAll()) {
|
for (const item of await database.transaction((transaction) =>
|
||||||
|
readAccessItems(database, transaction)
|
||||||
|
)) {
|
||||||
const value = domainValues(item.values).data;
|
const value = domainValues(item.values).data;
|
||||||
|
|
||||||
if ("personId" in value) {
|
if ("personId" in value) {
|
||||||
@ -219,7 +190,11 @@ function domainValues(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AccessPersistenceMapper {
|
export class AccessPersistenceMapper {
|
||||||
toDomain(root: WorkspaceModel, rows: readonly ItemModel[], revisions: readonly RevisionModel[]) {
|
toDomain(
|
||||||
|
root: WorkspaceModel,
|
||||||
|
rows: readonly AccessItemRow[],
|
||||||
|
revisions: readonly AccessRevisionRow[]
|
||||||
|
) {
|
||||||
const items = rows.map((row) => {
|
const items = rows.map((row) => {
|
||||||
const values = domainValues(row.values);
|
const values = domainValues(row.values);
|
||||||
const history = revisions
|
const history = revisions
|
||||||
@ -343,13 +318,12 @@ export class SequelizeAccessRepository implements IAccessRepository {
|
|||||||
root = created;
|
root = created;
|
||||||
}
|
}
|
||||||
const roots = company ? [global, root] : await WorkspaceModel.findAll({ transaction });
|
const roots = company ? [global, root] : await WorkspaceModel.findAll({ transaction });
|
||||||
const rows = await ItemModel.findAll({
|
const rows = (await readAccessItems(this.database, transaction)).filter((row) =>
|
||||||
where: { workspaceId: roots.map((item) => item.id) },
|
roots.some((root) => root.id === row.workspaceId)
|
||||||
transaction,
|
);
|
||||||
});
|
const revisions = (await readAccessRevisions(this.database, transaction)).filter((revision) =>
|
||||||
const revisions = rows.length
|
rows.some((row) => row.id === revision.itemId)
|
||||||
? await RevisionModel.findAll({ where: { itemId: rows.map((item) => item.id) }, transaction })
|
);
|
||||||
: [];
|
|
||||||
const workspace = this.mapper.toDomain(
|
const workspace = this.mapper.toDomain(
|
||||||
root,
|
root,
|
||||||
rows.filter((row) => row.workspaceId === root.id),
|
rows.filter((row) => row.workspaceId === root.id),
|
||||||
@ -419,11 +393,20 @@ export class SequelizeAccessRepository implements IAccessRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
await ItemModel.upsert(record.item, { transaction });
|
await writeAccessRow(
|
||||||
|
this.database,
|
||||||
|
record.item,
|
||||||
|
transaction,
|
||||||
|
previousWorkspace.items.some((item) => item.id.toPrimitive() === record.item.id)
|
||||||
|
? "update"
|
||||||
|
: "insert"
|
||||||
|
);
|
||||||
const revisions = record.revisions.filter((revision) => !previous.has(revision.id));
|
const revisions = record.revisions.filter((revision) => !previous.has(revision.id));
|
||||||
|
|
||||||
if (revisions.length) {
|
if (revisions.length) {
|
||||||
await RevisionModel.bulkCreate(revisions, { transaction });
|
for (const revision of revisions) {
|
||||||
|
await writeAccessRow(this.database, revision, transaction, "insert");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const personId = Object.entries(record.item.values).find(([key]) => key === "personId")?.[1];
|
const personId = Object.entries(record.item.values).find(([key]) => key === "personId")?.[1];
|
||||||
|
|
||||||
|
|||||||
@ -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: [],
|
booleans: [],
|
||||||
} satisfies SnapshotDefinition<ReturnType<typeof FolderMappingInputSchema.parse>>;
|
} satisfies SnapshotDefinition<ReturnType<typeof FolderMappingInputSchema.parse>>;
|
||||||
|
|
||||||
|
import {
|
||||||
|
mailSnapshot,
|
||||||
|
mailboxSnapshot,
|
||||||
|
mailboxUseSnapshot,
|
||||||
|
remoteSnapshot,
|
||||||
|
} from "./mail-remote-snapshots";
|
||||||
|
|
||||||
export const configurationSnapshots = new Map<string, SnapshotDefinition<unknown>>([
|
export const configurationSnapshots = new Map<string, SnapshotDefinition<unknown>>([
|
||||||
|
["remote_connection_revisions", remoteSnapshot],
|
||||||
|
["mailbox_use_revisions", mailboxUseSnapshot],
|
||||||
|
["mailbox_revisions", mailboxSnapshot],
|
||||||
|
["mail_configuration_revisions", mailSnapshot],
|
||||||
|
["mail_configurations", mailSnapshot],
|
||||||
|
["mailboxes", mailboxSnapshot],
|
||||||
|
["mailbox_uses", mailboxUseSnapshot],
|
||||||
|
["remote_connections", remoteSnapshot],
|
||||||
["work_profiles", profileSnapshot],
|
["work_profiles", profileSnapshot],
|
||||||
["work_profile_revisions", profileSnapshot],
|
["work_profile_revisions", profileSnapshot],
|
||||||
["telework_authorizations", teleworkSnapshot],
|
["telework_authorizations", teleworkSnapshot],
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import type { Sequelize } from "sequelize";
|
import type { Sequelize } from "sequelize";
|
||||||
import { DataTypes, QueryTypes } from "sequelize";
|
import { DataTypes, QueryTypes } from "sequelize";
|
||||||
|
|
||||||
|
import { ensureSnapshotConstraint } from "./mail-remote-constraints";
|
||||||
|
import { mailSnapshot } from "./mail-remote-snapshots";
|
||||||
|
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
|
||||||
|
|
||||||
export async function migrateMailConfigurations(database: Sequelize) {
|
export async function migrateMailConfigurations(database: Sequelize) {
|
||||||
const query = database.getQueryInterface();
|
const query = database.getQueryInterface();
|
||||||
const rows = await database.query<{ name: string }>(
|
const rows = await database.query<{ name: string }>(
|
||||||
@ -19,12 +23,7 @@ export async function migrateMailConfigurations(database: Sequelize) {
|
|||||||
await query.createTable("mail_configurations", {
|
await query.createTable("mail_configurations", {
|
||||||
id: { type: DataTypes.UUID, primaryKey: true },
|
id: { type: DataTypes.UUID, primaryKey: true },
|
||||||
companyId: reference("companies"),
|
companyId: reference("companies"),
|
||||||
personId: { ...reference("company_contacts"), allowNull: true },
|
...mailSnapshot.columns,
|
||||||
accountId: { ...reference("access_items"), allowNull: true },
|
|
||||||
assetId: { ...reference("assets"), allowNull: true },
|
|
||||||
profileId: { ...reference("work_profiles"), allowNull: true },
|
|
||||||
mailboxId: { ...reference("mailboxes"), allowNull: true },
|
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
|
||||||
currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true },
|
currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -35,7 +34,7 @@ export async function migrateMailConfigurations(database: Sequelize) {
|
|||||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
actorId: reference("users"),
|
actorId: reference("users"),
|
||||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
...mailSnapshot.columns,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,4 +70,17 @@ export async function migrateMailConfigurations(database: Sequelize) {
|
|||||||
name: "mail_revision_position",
|
name: "mail_revision_position",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await normalizeScalarSnapshots(
|
||||||
|
database,
|
||||||
|
["mail_configurations", "mail_configuration_revisions"],
|
||||||
|
mailSnapshot
|
||||||
|
);
|
||||||
|
for (const table of ["mail_configurations", "mail_configuration_revisions"]) {
|
||||||
|
await ensureSnapshotConstraint(
|
||||||
|
database,
|
||||||
|
table,
|
||||||
|
`${table}_snapshot_shape`,
|
||||||
|
"(snapshotKind='legacy' AND personId IS NOT NULL AND accountId IS NOT NULL AND assetId IS NOT NULL AND profileId IS NULL AND mailboxId IS NULL) OR (snapshotKind='profile' AND profileId IS NOT NULL AND mailboxId IS NOT NULL AND personId IS NULL AND accountId IS NULL AND assetId IS NULL)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,9 @@ export function persistedJson(value: unknown): unknown {
|
|||||||
return typeof value === "string" ? JSON.parse(value) : value;
|
return typeof value === "string" ? JSON.parse(value) : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { mailSnapshot } from "./mail-remote-snapshots";
|
||||||
|
import { snapshotValues } from "./scalar-snapshot-normalization";
|
||||||
|
|
||||||
export type MailRow = {
|
export type MailRow = {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@ -71,10 +74,10 @@ function mailReferences(values: MailConfigurationValues) {
|
|||||||
|
|
||||||
export class MailConfigurationPersistenceMapper {
|
export class MailConfigurationPersistenceMapper {
|
||||||
toDomain(row: MailRow, revisions: readonly MailRevisionRow[]) {
|
toDomain(row: MailRow, revisions: readonly MailRevisionRow[]) {
|
||||||
const values = valuesOf(row.values);
|
const values = valuesOf(snapshotValues(mailSnapshot, row));
|
||||||
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
||||||
const history = ordered.map((revision, position) => {
|
const history = ordered.map((revision, position) => {
|
||||||
const revisionValues = valuesOf(revision.values);
|
const revisionValues = valuesOf(snapshotValues(mailSnapshot, revision));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
revision.position !== position ||
|
revision.position !== position ||
|
||||||
@ -134,7 +137,7 @@ export class MailConfigurationPersistenceMapper {
|
|||||||
id: item.id.toPrimitive(),
|
id: item.id.toPrimitive(),
|
||||||
companyId: item.companyId.toPrimitive(),
|
companyId: item.companyId.toPrimitive(),
|
||||||
...mailReferences(item.values),
|
...mailReferences(item.values),
|
||||||
values: JSON.stringify(values),
|
values: values,
|
||||||
},
|
},
|
||||||
revisions: item.history.map((revision, position) => ({
|
revisions: item.history.map((revision, position) => ({
|
||||||
id: revision.id.toPrimitive(),
|
id: revision.id.toPrimitive(),
|
||||||
@ -142,7 +145,7 @@ export class MailConfigurationPersistenceMapper {
|
|||||||
position,
|
position,
|
||||||
actorId: revision.actorId.toPrimitive(),
|
actorId: revision.actorId.toPrimitive(),
|
||||||
recordedAt: revision.recordedAt,
|
recordedAt: revision.recordedAt,
|
||||||
values: JSON.stringify(revision.values.toPrimitive()),
|
values: revision.values.toPrimitive(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 type { Sequelize } from "sequelize";
|
||||||
import { DataTypes, QueryTypes } from "sequelize";
|
import { DataTypes, QueryTypes } from "sequelize";
|
||||||
|
|
||||||
|
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
|
||||||
|
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
|
||||||
|
|
||||||
export async function migrateMailboxes(database: Sequelize) {
|
export async function migrateMailboxes(database: Sequelize) {
|
||||||
const query = database.getQueryInterface();
|
const query = database.getQueryInterface();
|
||||||
const tables = await database.query<{ name: string }>(
|
const tables = await database.query<{ name: string }>(
|
||||||
@ -18,7 +21,6 @@ export async function migrateMailboxes(database: Sequelize) {
|
|||||||
const root = {
|
const root = {
|
||||||
id: { type: DataTypes.UUID, primaryKey: true },
|
id: { type: DataTypes.UUID, primaryKey: true },
|
||||||
companyId: fk("companies"),
|
companyId: fk("companies"),
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [table, columns] of [
|
for (const [table, columns] of [
|
||||||
@ -26,18 +28,27 @@ export async function migrateMailboxes(database: Sequelize) {
|
|||||||
"mailboxes",
|
"mailboxes",
|
||||||
{
|
{
|
||||||
...root,
|
...root,
|
||||||
|
...mailboxSnapshot.columns,
|
||||||
accountId: fk("access_items", true),
|
accountId: fk("access_items", true),
|
||||||
addressKey: { type: DataTypes.STRING(300), allowNull: false, unique: true },
|
addressKey: { type: DataTypes.STRING(300), allowNull: false, unique: true },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
["mailbox_uses", { ...root, mailboxId: fk("mailboxes"), personId: fk("company_contacts") }],
|
[
|
||||||
|
"mailbox_uses",
|
||||||
|
{
|
||||||
|
...root,
|
||||||
|
...mailboxUseSnapshot.columns,
|
||||||
|
mailboxId: fk("mailboxes"),
|
||||||
|
personId: fk("company_contacts"),
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"mailbox_revisions",
|
"mailbox_revisions",
|
||||||
{
|
{
|
||||||
id: root.id,
|
id: root.id,
|
||||||
itemId: fk("mailboxes"),
|
itemId: fk("mailboxes"),
|
||||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
values: root.values,
|
...mailboxSnapshot.columns,
|
||||||
actorId: fk("users"),
|
actorId: fk("users"),
|
||||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||||
},
|
},
|
||||||
@ -48,7 +59,7 @@ export async function migrateMailboxes(database: Sequelize) {
|
|||||||
id: root.id,
|
id: root.id,
|
||||||
itemId: fk("mailbox_uses"),
|
itemId: fk("mailbox_uses"),
|
||||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
values: root.values,
|
...mailboxUseSnapshot.columns,
|
||||||
actorId: fk("users"),
|
actorId: fk("users"),
|
||||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||||
},
|
},
|
||||||
@ -71,4 +82,10 @@ export async function migrateMailboxes(database: Sequelize) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await normalizeScalarSnapshots(database, ["mailboxes", "mailbox_revisions"], mailboxSnapshot);
|
||||||
|
await normalizeScalarSnapshots(
|
||||||
|
database,
|
||||||
|
["mailbox_uses", "mailbox_use_revisions"],
|
||||||
|
mailboxUseSnapshot
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import { MailboxInputSchema, MailboxUseInputSchema } from "../../domain/mailbox-
|
|||||||
import { MailboxUseValues } from "../../domain/value-objects/mailbox-use-values.vo";
|
import { MailboxUseValues } from "../../domain/value-objects/mailbox-use-values.vo";
|
||||||
import { MailboxValues } from "../../domain/value-objects/mailbox-values.vo";
|
import { MailboxValues } from "../../domain/value-objects/mailbox-values.vo";
|
||||||
|
|
||||||
|
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
|
||||||
|
import { snapshotValues } from "./scalar-snapshot-normalization";
|
||||||
|
|
||||||
export function mailboxIdentity(raw: string) {
|
export function mailboxIdentity(raw: string) {
|
||||||
const value = UniqueID.create(raw);
|
const value = UniqueID.create(raw);
|
||||||
|
|
||||||
@ -86,11 +89,11 @@ function mailboxUseValues(raw: unknown) {
|
|||||||
|
|
||||||
export class MailboxPersistenceMapper {
|
export class MailboxPersistenceMapper {
|
||||||
toDomain(row: MailboxRow, revisions: readonly MailboxRevisionRow[]) {
|
toDomain(row: MailboxRow, revisions: readonly MailboxRevisionRow[]) {
|
||||||
const values = mailboxValues(row.values);
|
const values = mailboxValues(snapshotValues(mailboxSnapshot, row));
|
||||||
const history = ordered(revisions).map((revision) =>
|
const history = ordered(revisions).map((revision) =>
|
||||||
MailboxRevision.rehydrate(
|
MailboxRevision.rehydrate(
|
||||||
{
|
{
|
||||||
values: mailboxValues(revision.values),
|
values: mailboxValues(snapshotValues(mailboxSnapshot, revision)),
|
||||||
actorId: mailboxIdentity(revision.actorId),
|
actorId: mailboxIdentity(revision.actorId),
|
||||||
recordedAt: revision.recordedAt,
|
recordedAt: revision.recordedAt,
|
||||||
},
|
},
|
||||||
@ -114,11 +117,11 @@ export class MailboxPersistenceMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toUseDomain(row: MailboxUseRow, revisions: readonly MailboxRevisionRow[]) {
|
toUseDomain(row: MailboxUseRow, revisions: readonly MailboxRevisionRow[]) {
|
||||||
const values = mailboxUseValues(row.values);
|
const values = mailboxUseValues(snapshotValues(mailboxUseSnapshot, row));
|
||||||
const history = ordered(revisions).map((revision) =>
|
const history = ordered(revisions).map((revision) =>
|
||||||
MailboxUseRevision.rehydrate(
|
MailboxUseRevision.rehydrate(
|
||||||
{
|
{
|
||||||
values: mailboxUseValues(revision.values),
|
values: mailboxUseValues(snapshotValues(mailboxUseSnapshot, revision)),
|
||||||
actorId: mailboxIdentity(revision.actorId),
|
actorId: mailboxIdentity(revision.actorId),
|
||||||
recordedAt: revision.recordedAt,
|
recordedAt: revision.recordedAt,
|
||||||
},
|
},
|
||||||
@ -152,12 +155,12 @@ export class MailboxPersistenceMapper {
|
|||||||
return {
|
return {
|
||||||
id: item.id.toPrimitive(),
|
id: item.id.toPrimitive(),
|
||||||
companyId: item.companyId.toPrimitive(),
|
companyId: item.companyId.toPrimitive(),
|
||||||
values: JSON.stringify(item.values.toPrimitive()),
|
values: item.values.toPrimitive(),
|
||||||
revisions: item.history.map((revision, position) => ({
|
revisions: item.history.map((revision, position) => ({
|
||||||
id: revision.id.toPrimitive(),
|
id: revision.id.toPrimitive(),
|
||||||
itemId: item.id.toPrimitive(),
|
itemId: item.id.toPrimitive(),
|
||||||
position,
|
position,
|
||||||
values: JSON.stringify(revision.values.toPrimitive()),
|
values: revision.values.toPrimitive(),
|
||||||
actorId: revision.actorId.toPrimitive(),
|
actorId: revision.actorId.toPrimitive(),
|
||||||
recordedAt: revision.recordedAt,
|
recordedAt: revision.recordedAt,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@ -5,6 +5,12 @@ import { DataTypes, QueryTypes } from "sequelize";
|
|||||||
import { ProcessTemplate } from "../../domain/process-template.aggregate";
|
import { ProcessTemplate } from "../../domain/process-template.aggregate";
|
||||||
import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values.vo";
|
import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values.vo";
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeProcesses,
|
||||||
|
processColumns,
|
||||||
|
processTemplateColumns,
|
||||||
|
writeProcessRow,
|
||||||
|
} from "./process-relational";
|
||||||
import {
|
import {
|
||||||
ProcessTemplatePersistenceMapper,
|
ProcessTemplatePersistenceMapper,
|
||||||
processTemplateNameKey,
|
processTemplateNameKey,
|
||||||
@ -26,12 +32,10 @@ export async function migrateProcesses(database: Sequelize) {
|
|||||||
|
|
||||||
const root = {
|
const root = {
|
||||||
id: { type: DataTypes.UUID, primaryKey: true },
|
id: { type: DataTypes.UUID, primaryKey: true },
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
|
||||||
};
|
};
|
||||||
const revision = {
|
const revision = {
|
||||||
id: root.id,
|
id: root.id,
|
||||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
values: root.values,
|
|
||||||
actorId: fk("users", true),
|
actorId: fk("users", true),
|
||||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||||
};
|
};
|
||||||
@ -42,22 +46,30 @@ export async function migrateProcesses(database: Sequelize) {
|
|||||||
"process_templates",
|
"process_templates",
|
||||||
{
|
{
|
||||||
...root,
|
...root,
|
||||||
|
...processTemplateColumns,
|
||||||
nameKey: { type: DataTypes.STRING(160), allowNull: false, unique: true },
|
nameKey: { type: DataTypes.STRING(160), allowNull: false, unique: true },
|
||||||
builtinKey: { type: DataTypes.STRING(30), allowNull: true, unique: true },
|
builtinKey: { type: DataTypes.STRING(30), allowNull: true, unique: true },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
["process_template_revisions", { ...revision, itemId: fk("process_templates") }],
|
[
|
||||||
|
"process_template_revisions",
|
||||||
|
{ ...revision, ...processTemplateColumns, itemId: fk("process_templates") },
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"person_processes",
|
"person_processes",
|
||||||
{
|
{
|
||||||
...root,
|
...root,
|
||||||
|
...processColumns,
|
||||||
companyId: fk("companies"),
|
companyId: fk("companies"),
|
||||||
personId: fk("company_contacts"),
|
personId: fk("company_contacts"),
|
||||||
templateId: fk("process_templates"),
|
templateId: fk("process_templates"),
|
||||||
currentKey: { type: DataTypes.STRING(100), allowNull: true, unique: true },
|
currentKey: { type: DataTypes.STRING(100), allowNull: true, unique: true },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
["person_process_revisions", { ...revision, itemId: fk("person_processes") }],
|
[
|
||||||
|
"person_process_revisions",
|
||||||
|
{ ...revision, ...processColumns, itemId: fk("person_processes") },
|
||||||
|
],
|
||||||
] as const) {
|
] as const) {
|
||||||
if (!tables.some((t) => t.name === table)) {
|
if (!tables.some((t) => t.name === table)) {
|
||||||
await query.createTable(table, columns);
|
await query.createTable(table, columns);
|
||||||
@ -77,6 +89,7 @@ export async function migrateProcesses(database: Sequelize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await database.query("INSERT IGNORE INTO process_catalog_lock (id) VALUES (1)");
|
await database.query("INSERT IGNORE INTO process_catalog_lock (id) VALUES (1)");
|
||||||
|
await normalizeProcesses(database);
|
||||||
const defaults = [
|
const defaults = [
|
||||||
{
|
{
|
||||||
key: "onboarding",
|
key: "onboarding",
|
||||||
@ -153,14 +166,20 @@ export async function migrateProcesses(database: Sequelize) {
|
|||||||
}
|
}
|
||||||
const record = new ProcessTemplatePersistenceMapper().toPersistence(template.data);
|
const record = new ProcessTemplatePersistenceMapper().toPersistence(template.data);
|
||||||
|
|
||||||
await database.query(
|
await writeProcessRow(
|
||||||
"INSERT INTO process_templates (id,nameKey,builtinKey,`values`) VALUES (:id,:nameKey,:builtinKey,:values)",
|
database,
|
||||||
{ replacements: { ...record.root, builtinKey: item.key }, transaction }
|
"process_templates",
|
||||||
|
{ ...record.root, builtinKey: item.key },
|
||||||
|
transaction,
|
||||||
|
"insert"
|
||||||
);
|
);
|
||||||
for (const revision of record.revisions) {
|
for (const revision of record.revisions) {
|
||||||
await database.query(
|
await writeProcessRow(
|
||||||
"INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)",
|
database,
|
||||||
{ replacements: revision, transaction }
|
"process_template_revisions",
|
||||||
|
revision,
|
||||||
|
transaction,
|
||||||
|
"insert"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -169,7 +188,7 @@ export async function migrateProcesses(database: Sequelize) {
|
|||||||
|
|
||||||
async function normalizeTemplateNameKeys(database: Sequelize, transaction: Transaction) {
|
async function normalizeTemplateNameKeys(database: Sequelize, transaction: Transaction) {
|
||||||
const existingNames = await database.query<{ id: string; nameKey: string; name: string }>(
|
const existingNames = await database.query<{ id: string; nameKey: string; name: string }>(
|
||||||
"SELECT id,nameKey,JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.name')) AS name FROM process_templates",
|
"SELECT id,nameKey,name FROM process_templates",
|
||||||
{ transaction, type: QueryTypes.SELECT }
|
{ transaction, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,23 @@ export type PersonProcessRevisionRow = {
|
|||||||
values: unknown;
|
values: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rowSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
companyId: z.uuid(),
|
||||||
|
personId: z.uuid(),
|
||||||
|
templateId: z.uuid(),
|
||||||
|
currentKey: z.string().nullable(),
|
||||||
|
values: z.unknown(),
|
||||||
|
});
|
||||||
|
const revisionSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
itemId: z.uuid(),
|
||||||
|
position: z.number().int().nonnegative(),
|
||||||
|
actorId: z.uuid().nullable(),
|
||||||
|
recordedAt: z.iso.datetime(),
|
||||||
|
values: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
function identity(value: string) {
|
function identity(value: string) {
|
||||||
const parsed = UniqueID.create(value);
|
const parsed = UniqueID.create(value);
|
||||||
|
|
||||||
@ -85,7 +102,9 @@ function validateTransitions(history: readonly PersonProcessRevision[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PersonProcessPersistenceMapper {
|
export class PersonProcessPersistenceMapper {
|
||||||
toDomain(row: PersonProcessRow, revisions: readonly PersonProcessRevisionRow[]) {
|
toDomain(source: unknown, revisionRows: readonly unknown[]) {
|
||||||
|
const row = rowSchema.parse(source);
|
||||||
|
const revisions = revisionRows.map((item) => revisionSchema.parse(item));
|
||||||
const values = mapped(row.values);
|
const values = mapped(row.values);
|
||||||
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
||||||
const history = ordered.map((r, index) => {
|
const history = ordered.map((r, index) => {
|
||||||
@ -137,7 +156,7 @@ export class PersonProcessPersistenceMapper {
|
|||||||
currentKey: item.values.closed
|
currentKey: item.values.closed
|
||||||
? null
|
? null
|
||||||
: `${item.companyId.toPrimitive()}:${values.personId}:${values.kind}`,
|
: `${item.companyId.toPrimitive()}:${values.personId}:${values.kind}`,
|
||||||
values: JSON.stringify(values),
|
values: values,
|
||||||
},
|
},
|
||||||
revisions: item.history.map((r, position) => ({
|
revisions: item.history.map((r, position) => ({
|
||||||
id: r.id.toPrimitive(),
|
id: r.id.toPrimitive(),
|
||||||
@ -145,7 +164,7 @@ export class PersonProcessPersistenceMapper {
|
|||||||
position,
|
position,
|
||||||
actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null,
|
actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null,
|
||||||
recordedAt: r.recordedAt,
|
recordedAt: r.recordedAt,
|
||||||
values: JSON.stringify(r.values.toPrimitive()),
|
values: r.values.toPrimitive(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
type Profile = { id: string; values: WorkProfileInput };
|
||||||
|
|
||||||
|
import { readAccessItems } from "./access-relational";
|
||||||
|
|
||||||
type Row = { id: string; values: unknown };
|
type Row = { id: string; values: unknown };
|
||||||
|
|
||||||
type Table =
|
type Table =
|
||||||
@ -200,10 +202,13 @@ export class ProcessReviewReader {
|
|||||||
...this.remote(await rows("remote_connections"), assetNames, resourceNames),
|
...this.remote(await rows("remote_connections"), assetNames, resourceNames),
|
||||||
];
|
];
|
||||||
const relationships: ProcessReviewSources["relationships"][number][] = [];
|
const relationships: ProcessReviewSources["relationships"][number][] = [];
|
||||||
const rawAccess = await this.database.query<{ id: string; values: unknown }>(
|
const workspaces = await this.database.query<{ id: string }>(
|
||||||
"SELECT i.id,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId",
|
"SELECT id FROM access_workspaces WHERE companyId=:companyId",
|
||||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
|
const rawAccess = (await readAccessItems(this.database, transaction)).filter((row) =>
|
||||||
|
workspaces.some((workspace) => workspace.id === row.workspaceId)
|
||||||
|
);
|
||||||
const access = rawAccess.map((r) => ({
|
const access = rawAccess.map((r) => ({
|
||||||
id: profileIdentity(r.id).toPrimitive(),
|
id: profileIdentity(r.id).toPrimitive(),
|
||||||
values: AccessDefinitionSchema.parse(persistedJson(r.values)),
|
values: AccessDefinitionSchema.parse(persistedJson(r.values)),
|
||||||
@ -211,7 +216,7 @@ export class ProcessReviewReader {
|
|||||||
|
|
||||||
relationships.push(...accessReviewRelationships(access));
|
relationships.push(...accessReviewRelationships(access));
|
||||||
const mailboxes = await this.database.query<{ id: string; address: string }>(
|
const mailboxes = await this.database.query<{ id: string; address: string }>(
|
||||||
"SELECT id,JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.address')) AS address FROM mailboxes WHERE companyId=:companyId",
|
"SELECT id,address FROM mailboxes WHERE companyId=:companyId",
|
||||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,16 @@ export type ProcessTemplateRevisionRow = {
|
|||||||
values: unknown;
|
values: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rowSchema = z.object({ id: z.uuid(), nameKey: z.string(), values: z.unknown() });
|
||||||
|
const revisionSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
itemId: z.uuid(),
|
||||||
|
position: z.number().int().nonnegative(),
|
||||||
|
actorId: z.uuid().nullable(),
|
||||||
|
recordedAt: z.iso.datetime(),
|
||||||
|
values: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
function identity(value: string) {
|
function identity(value: string) {
|
||||||
const parsed = UniqueID.create(value);
|
const parsed = UniqueID.create(value);
|
||||||
|
|
||||||
@ -43,7 +53,9 @@ function mapped(raw: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ProcessTemplatePersistenceMapper {
|
export class ProcessTemplatePersistenceMapper {
|
||||||
toDomain(row: ProcessTemplateRow, revisions: readonly ProcessTemplateRevisionRow[]) {
|
toDomain(source: unknown, revisionRows: readonly unknown[]) {
|
||||||
|
const row = rowSchema.parse(source);
|
||||||
|
const revisions = revisionRows.map((item) => revisionSchema.parse(item));
|
||||||
const values = mapped(row.values);
|
const values = mapped(row.values);
|
||||||
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
||||||
const history = ordered.map((r, index) => {
|
const history = ordered.map((r, index) => {
|
||||||
@ -84,7 +96,7 @@ export class ProcessTemplatePersistenceMapper {
|
|||||||
root: {
|
root: {
|
||||||
id: item.id.toPrimitive(),
|
id: item.id.toPrimitive(),
|
||||||
nameKey: processTemplateNameKey(values.name),
|
nameKey: processTemplateNameKey(values.name),
|
||||||
values: JSON.stringify(values),
|
values: values,
|
||||||
},
|
},
|
||||||
revisions: item.history.map((r, position) => ({
|
revisions: item.history.map((r, position) => ({
|
||||||
id: r.id.toPrimitive(),
|
id: r.id.toPrimitive(),
|
||||||
@ -92,7 +104,7 @@ export class ProcessTemplatePersistenceMapper {
|
|||||||
position,
|
position,
|
||||||
actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null,
|
actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null,
|
||||||
recordedAt: r.recordedAt,
|
recordedAt: r.recordedAt,
|
||||||
values: JSON.stringify(r.values.toPrimitive()),
|
values: r.values.toPrimitive(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import type { Sequelize } from "sequelize";
|
import type { Sequelize } from "sequelize";
|
||||||
import { DataTypes, QueryTypes } from "sequelize";
|
import { DataTypes, QueryTypes } from "sequelize";
|
||||||
|
|
||||||
|
import { ensureSnapshotConstraint } from "./mail-remote-constraints";
|
||||||
|
import { remoteSnapshot } from "./mail-remote-snapshots";
|
||||||
|
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
|
||||||
|
|
||||||
export async function migrateRemoteConnections(database: Sequelize) {
|
export async function migrateRemoteConnections(database: Sequelize) {
|
||||||
const query = database.getQueryInterface();
|
const query = database.getQueryInterface();
|
||||||
const tables = await database.query<{ name: string }>(
|
const tables = await database.query<{ name: string }>(
|
||||||
@ -19,12 +23,7 @@ export async function migrateRemoteConnections(database: Sequelize) {
|
|||||||
await query.createTable("remote_connections", {
|
await query.createTable("remote_connections", {
|
||||||
id: { type: DataTypes.UUID, primaryKey: true },
|
id: { type: DataTypes.UUID, primaryKey: true },
|
||||||
companyId: reference("companies"),
|
companyId: reference("companies"),
|
||||||
personId: reference("company_contacts"),
|
...remoteSnapshot.columns,
|
||||||
sourceAssetId: reference("assets", true),
|
|
||||||
targetAssetId: reference("assets", true),
|
|
||||||
targetResourceId: reference("resources", true),
|
|
||||||
siteId: reference("company_sites", true),
|
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
|
||||||
currentKey: { type: DataTypes.STRING(64), allowNull: true, unique: true },
|
currentKey: { type: DataTypes.STRING(64), allowNull: true, unique: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -35,7 +34,7 @@ export async function migrateRemoteConnections(database: Sequelize) {
|
|||||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
actorId: reference("users"),
|
actorId: reference("users"),
|
||||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||||
values: { type: DataTypes.JSON, allowNull: false },
|
...remoteSnapshot.columns,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const indexes = await database.query<{ name: string }>(
|
const indexes = await database.query<{ name: string }>(
|
||||||
@ -49,4 +48,17 @@ export async function migrateRemoteConnections(database: Sequelize) {
|
|||||||
name: "remote_revision_position",
|
name: "remote_revision_position",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await normalizeScalarSnapshots(
|
||||||
|
database,
|
||||||
|
["remote_connections", "remote_connection_revisions"],
|
||||||
|
remoteSnapshot
|
||||||
|
);
|
||||||
|
for (const table of ["remote_connections", "remote_connection_revisions"]) {
|
||||||
|
await ensureSnapshotConstraint(
|
||||||
|
database,
|
||||||
|
table,
|
||||||
|
`${table}_snapshot_shape`,
|
||||||
|
"((sourceKind='company_asset' AND sourceAssetId IS NOT NULL) OR (sourceKind IN ('personal_computer','personal_mobile') AND sourceAssetId IS NULL)) AND ((targetKind='network' AND targetAssetId IS NULL AND targetResourceId IS NULL) OR (targetKind='asset' AND targetAssetId IS NOT NULL AND targetResourceId IS NULL AND siteId IS NULL AND (sourceAssetId IS NULL OR sourceAssetId<>targetAssetId)) OR (targetKind='resource' AND targetResourceId IS NOT NULL AND targetAssetId IS NULL AND siteId IS NULL))"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
} from "../../domain/remote-connection-values";
|
} from "../../domain/remote-connection-values";
|
||||||
import { RemoteConnectionValues } from "../../domain/value-objects/remote-connection-values.vo";
|
import { RemoteConnectionValues } from "../../domain/value-objects/remote-connection-values.vo";
|
||||||
|
|
||||||
|
import { remoteSnapshot } from "./mail-remote-snapshots";
|
||||||
|
import { snapshotValues } from "./scalar-snapshot-normalization";
|
||||||
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
|
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
|
||||||
|
|
||||||
export type RemoteRow = {
|
export type RemoteRow = {
|
||||||
@ -47,10 +49,10 @@ function mapped(raw: unknown) {
|
|||||||
|
|
||||||
export class RemoteConnectionPersistenceMapper {
|
export class RemoteConnectionPersistenceMapper {
|
||||||
toDomain(row: RemoteRow, revisions: readonly RemoteRevisionRow[]) {
|
toDomain(row: RemoteRow, revisions: readonly RemoteRevisionRow[]) {
|
||||||
const values = mapped(row.values);
|
const values = mapped(snapshotValues(remoteSnapshot, row));
|
||||||
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
||||||
const history = ordered.map((revision, index) => {
|
const history = ordered.map((revision, index) => {
|
||||||
const value = mapped(revision.values);
|
const value = mapped(snapshotValues(remoteSnapshot, revision));
|
||||||
const previous = ordered[index - 1];
|
const previous = ordered[index - 1];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -123,7 +125,7 @@ export class RemoteConnectionPersistenceMapper {
|
|||||||
targetAssetId: values.targetKind === "asset" ? values.targetId : null,
|
targetAssetId: values.targetKind === "asset" ? values.targetId : null,
|
||||||
targetResourceId: values.targetKind === "resource" ? values.targetId : null,
|
targetResourceId: values.targetKind === "resource" ? values.targetId : null,
|
||||||
siteId: values.siteId,
|
siteId: values.siteId,
|
||||||
values: JSON.stringify(values),
|
values: values,
|
||||||
...remoteKeys(item.values),
|
...remoteKeys(item.values),
|
||||||
},
|
},
|
||||||
revisions: item.history.map((revision, position) => ({
|
revisions: item.history.map((revision, position) => ({
|
||||||
@ -132,7 +134,7 @@ export class RemoteConnectionPersistenceMapper {
|
|||||||
position,
|
position,
|
||||||
actorId: revision.actorId.toPrimitive(),
|
actorId: revision.actorId.toPrimitive(),
|
||||||
recordedAt: revision.recordedAt,
|
recordedAt: revision.recordedAt,
|
||||||
values: JSON.stringify(revision.values.toPrimitive()),
|
values: revision.values.toPrimitive(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export type SnapshotDefinition<T> = {
|
|||||||
schema: z.ZodType<T>;
|
schema: z.ZodType<T>;
|
||||||
columns: Columns;
|
columns: Columns;
|
||||||
booleans?: readonly string[];
|
booleans?: readonly string[];
|
||||||
|
fromColumns?: (row: Record<string, unknown>) => unknown;
|
||||||
|
toColumns?: (input: T) => object;
|
||||||
};
|
};
|
||||||
|
|
||||||
function identifier(value: string) {
|
function identifier(value: string) {
|
||||||
@ -41,7 +43,7 @@ export function snapshotValues<T>(definition: SnapshotDefinition<T>, row: object
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return definition.schema.parse(input);
|
return definition.schema.parse(definition.fromColumns ? definition.fromColumns(input) : input);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function normalizeScalarSnapshots<T>(
|
export async function normalizeScalarSnapshots<T>(
|
||||||
@ -100,7 +102,7 @@ async function transferSnapshotRow<T>(
|
|||||||
const expected = snapshotValues(definition, row);
|
const expected = snapshotValues(definition, row);
|
||||||
const values = z
|
const values = z
|
||||||
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
|
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
|
||||||
.parse(expected);
|
.parse(definition.toColumns ? definition.toColumns(expected) : expected);
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(values)) {
|
for (const [name, value] of Object.entries(values)) {
|
||||||
if (name.endsWith("Id") && original.has(name) && !isDeepStrictEqual(row[name], value)) {
|
if (name.endsWith("Id") && original.has(name) && !isDeepStrictEqual(row[name], value)) {
|
||||||
|
|||||||
@ -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 OPEN_TICKET = "t.state IN ('open','in_progress','waiting')";
|
||||||
const PENDING_INTERVENTION = "i.state IN ('pending','in_progress')";
|
const PENDING_INTERVENTION = "i.state IN ('pending','in_progress')";
|
||||||
const PROFILE_REVIEW = `p.active=true AND (person.active=0 OR a.status='retired' OR (p.accountId IS NOT NULL AND (
|
const PROFILE_REVIEW = `p.active=true AND (person.active=0 OR a.status='retired' OR (p.accountId IS NOT NULL AND (
|
||||||
account.id IS NULL OR JSON_EXTRACT(account.\`values\`,'$.active')<>true OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.usage'))='technical'
|
account.ownerId IS NULL OR account.active<>true OR account.usage='technical'
|
||||||
OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.expiresOn')) < :today
|
OR account.expiresOn < :today
|
||||||
OR NOT EXISTS (SELECT 1 FROM access_items s WHERE s.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.systemId')) AND s.kind='system' AND JSON_EXTRACT(s.\`values\`,'$.active')=true)
|
OR NOT EXISTS (SELECT 1 FROM access_current_values s WHERE s.ownerId=account.systemId AND s.kind='system' AND s.active=true)
|
||||||
OR (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.environmentId')),'null') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM access_items e WHERE e.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.environmentId')) AND e.kind='environment' AND JSON_EXTRACT(e.\`values\`,'$.active')=true))
|
OR (NULLIF(account.environmentId,'null') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM access_current_values e WHERE e.ownerId=account.environmentId AND e.kind='environment' AND e.active=true))
|
||||||
OR NOT EXISTS (SELECT 1 FROM access_items link WHERE link.kind='account_person' AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.personId'))=p.personId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.accountId'))=p.accountId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.startDate'))<=:today AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate'))>:today) AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.configuration')) IN ('pending','configured'))
|
OR NOT EXISTS (SELECT 1 FROM access_current_values link WHERE link.kind='account_person' AND link.personId=p.personId AND link.accountId=p.accountId AND link.startDate<=:today AND (NULLIF(link.endDate,'null') IS NULL OR link.endDate>:today) AND link.configuration IN ('pending','configured'))
|
||||||
)))`;
|
)))`;
|
||||||
|
|
||||||
export class SequelizeDashboardRepository implements IDashboardRepository {
|
export class SequelizeDashboardRepository implements IDashboardRepository {
|
||||||
@ -102,7 +102,7 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
|
|||||||
private async issues(query: DashboardDetailsQuery) {
|
private async issues(query: DashboardDetailsQuery) {
|
||||||
if (query.issue === "profile") {
|
if (query.issue === "profile") {
|
||||||
const from =
|
const from =
|
||||||
"FROM work_profiles p JOIN companies c ON c.id=p.companyId JOIN company_contacts person ON person.id=p.personId JOIN assets a ON a.id=p.assetId LEFT JOIN access_items account ON account.id=p.accountId";
|
"FROM work_profiles p JOIN companies c ON c.id=p.companyId JOIN company_contacts person ON person.id=p.personId JOIN assets a ON a.id=p.assetId LEFT JOIN access_current_values account ON account.ownerId=p.accountId";
|
||||||
const where = `WHERE (:companyId IS NULL OR p.companyId=:companyId) AND ${PROFILE_REVIEW}`;
|
const where = `WHERE (:companyId IS NULL OR p.companyId=:companyId) AND ${PROFILE_REVIEW}`;
|
||||||
const count = await this.select<{ total: number }>(
|
const count = await this.select<{ total: number }>(
|
||||||
`SELECT COUNT(*) AS total ${from} ${where}`,
|
`SELECT COUNT(*) AS total ${from} ${where}`,
|
||||||
@ -138,9 +138,9 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
|
|||||||
`EXISTS (SELECT 1 FROM ${table} cfg JOIN work_profiles w ON w.id=cfg.profileId WHERE cfg.companyId=p.companyId AND w.personId=p.id AND cfg.state NOT IN ('removed','never_configured'))`
|
`EXISTS (SELECT 1 FROM ${table} cfg JOIN work_profiles w ON w.id=cfg.profileId WHERE cfg.companyId=p.companyId AND w.personId=p.id AND cfg.state NOT IN ('removed','never_configured'))`
|
||||||
)
|
)
|
||||||
.join(" OR ");
|
.join(" OR ");
|
||||||
const where = `WHERE p.active=0 AND (:companyId IS NULL OR p.companyId=:companyId) AND (p.id IN (:ids) OR EXISTS (SELECT 1 FROM work_profiles w WHERE w.personId=p.id AND w.active=true) OR EXISTS (SELECT 1 FROM access_items link JOIN access_workspaces ws ON ws.id=link.workspaceId WHERE ws.companyId=p.companyId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.personId'))=p.id AND ((link.kind='need' AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate'))>:today)) OR (link.kind='account_person' AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.configuration')) IN ('pending','configured','removal_pending'))))
|
const where = `WHERE p.active=0 AND (:companyId IS NULL OR p.companyId=:companyId) AND (p.id IN (:ids) OR EXISTS (SELECT 1 FROM work_profiles w WHERE w.personId=p.id AND w.active=true) OR EXISTS (SELECT 1 FROM access_current_values link JOIN access_items li ON li.id=link.ownerId JOIN access_workspaces ws ON ws.id=li.workspaceId WHERE ws.companyId=p.companyId AND link.personId=p.id AND ((link.kind='need' AND (NULLIF(link.endDate,'null') IS NULL OR link.endDate>:today)) OR (link.kind='account_person' AND link.configuration IN ('pending','configured','removal_pending'))))
|
||||||
OR EXISTS (SELECT 1 FROM remote_connections r WHERE r.companyId=p.companyId AND r.personId=p.id AND JSON_UNQUOTE(JSON_EXTRACT(r.\`values\`,'$.state')) NOT IN ('removed','never_configured'))
|
OR EXISTS (SELECT 1 FROM remote_connections r WHERE r.companyId=p.companyId AND r.personId=p.id AND r.state NOT IN ('removed','never_configured'))
|
||||||
OR EXISTS (SELECT 1 FROM mail_configurations m LEFT JOIN work_profiles w ON w.id=m.profileId WHERE m.companyId=p.companyId AND (m.personId=p.id OR w.personId=p.id) AND JSON_UNQUOTE(JSON_EXTRACT(m.\`values\`,'$.state')) NOT IN ('removed','never_configured'))
|
OR EXISTS (SELECT 1 FROM mail_configurations m LEFT JOIN work_profiles w ON w.id=m.profileId WHERE m.companyId=p.companyId AND (m.personId=p.id OR w.personId=p.id) AND m.state NOT IN ('removed','never_configured'))
|
||||||
OR ${profileConfigurations}
|
OR ${profileConfigurations}
|
||||||
OR EXISTS (SELECT 1 FROM telework_authorizations ta WHERE ta.companyId=p.companyId AND ta.personId=p.id AND ta.active=true AND (ta.expiresOn IS NULL OR ta.expiresOn>=:today)))`;
|
OR EXISTS (SELECT 1 FROM telework_authorizations ta WHERE ta.companyId=p.companyId AND ta.personId=p.id AND ta.active=true AND (ta.expiresOn IS NULL OR ta.expiresOn>=:today)))`;
|
||||||
const extra = { ids: ids.length ? ids : [""] };
|
const extra = { ids: ids.length ? ids : [""] };
|
||||||
|
|||||||
@ -9,18 +9,14 @@ import type {
|
|||||||
} from "../../application/folder-mapping.repository";
|
} from "../../application/folder-mapping.repository";
|
||||||
import { folderReferences } from "../../application/folder-mapping-policy";
|
import { folderReferences } from "../../application/folder-mapping-policy";
|
||||||
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
|
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
|
||||||
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
|
|
||||||
import { AccessValues } from "../../domain/access/access-values.vo";
|
import { AccessValues } from "../../domain/access/access-values.vo";
|
||||||
import { FolderMappingError } from "../../domain/folder-mapping-values";
|
import { FolderMappingError } from "../../domain/folder-mapping-values";
|
||||||
|
|
||||||
|
import { readCompanyAccessItems } from "./access-relational";
|
||||||
import type { FolderRevisionRow, FolderRow } from "./folder-mapping-persistence.mapper";
|
import type { FolderRevisionRow, FolderRow } from "./folder-mapping-persistence.mapper";
|
||||||
import { FolderMappingPersistenceMapper } from "./folder-mapping-persistence.mapper";
|
import { FolderMappingPersistenceMapper } from "./folder-mapping-persistence.mapper";
|
||||||
import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper";
|
import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper";
|
||||||
import {
|
import { WorkProfilePersistenceMapper, profileIdentity } from "./work-profile-persistence.mapper";
|
||||||
WorkProfilePersistenceMapper,
|
|
||||||
persistedJson,
|
|
||||||
profileIdentity,
|
|
||||||
} from "./work-profile-persistence.mapper";
|
|
||||||
|
|
||||||
export class SequelizeFolderMappingRepository implements IFolderMappingRepository {
|
export class SequelizeFolderMappingRepository implements IFolderMappingRepository {
|
||||||
private readonly mapper = new FolderMappingPersistenceMapper();
|
private readonly mapper = new FolderMappingPersistenceMapper();
|
||||||
@ -61,10 +57,7 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
|
|||||||
"SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId",
|
"SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId",
|
||||||
{ ...options, type: QueryTypes.SELECT }
|
{ ...options, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
const items = await this.database.query<{ id: string; values: unknown }>(
|
const items = await readCompanyAccessItems(this.database, company.toPrimitive(), transaction);
|
||||||
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
|
|
||||||
{ ...options, type: QueryTypes.SELECT }
|
|
||||||
);
|
|
||||||
const people = await this.database.query<{ id: string; active: boolean }>(
|
const people = await this.database.query<{ id: string; active: boolean }>(
|
||||||
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
|
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
|
||||||
{ ...options, type: QueryTypes.SELECT }
|
{ ...options, type: QueryTypes.SELECT }
|
||||||
@ -87,9 +80,7 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
items: items.map((item) => {
|
items: items.map((item) => {
|
||||||
const values = AccessValues.create(
|
const values = AccessValues.create(item.values);
|
||||||
AccessDefinitionSchema.parse(persistedJson(item.values))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!values.isSuccess) {
|
if (!values.isSuccess) {
|
||||||
throw new Error("Recurso de carpeta persistido inconsistente.");
|
throw new Error("Recurso de carpeta persistido inconsistente.");
|
||||||
|
|||||||
@ -92,6 +92,9 @@ function validateReferences(context: MailContext, accounts: MailboxContext["item
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { mailSnapshot } from "./mail-remote-snapshots";
|
||||||
|
import { writeSnapshotRow } from "./scalar-snapshot-writer";
|
||||||
|
|
||||||
export class SequelizeMailConfigurationRepository implements IMailConfigurationRepository {
|
export class SequelizeMailConfigurationRepository implements IMailConfigurationRepository {
|
||||||
private readonly mapper = new MailConfigurationPersistenceMapper();
|
private readonly mapper = new MailConfigurationPersistenceMapper();
|
||||||
|
|
||||||
@ -228,18 +231,26 @@ export class SequelizeMailConfigurationRepository implements IMailConfigurationR
|
|||||||
if (previous !== candidate.data) {
|
if (previous !== candidate.data) {
|
||||||
const record = this.mapper.toPersistence(candidate.data);
|
const record = this.mapper.toPersistence(candidate.data);
|
||||||
|
|
||||||
await this.database.query(
|
await writeSnapshotRow(
|
||||||
"INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,:profileId,:mailboxId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey),personId=VALUES(personId),accountId=VALUES(accountId),assetId=VALUES(assetId),profileId=VALUES(profileId),mailboxId=VALUES(mailboxId)",
|
this.database,
|
||||||
{ replacements: record.root, transaction }
|
"mail_configurations",
|
||||||
|
mailSnapshot,
|
||||||
|
record.root,
|
||||||
|
transaction,
|
||||||
|
previous ? "update" : "insert"
|
||||||
);
|
);
|
||||||
const stored = new Set(
|
const stored = new Set(
|
||||||
previous?.history.map((revision) => revision.id.toPrimitive()) ?? []
|
previous?.history.map((revision) => revision.id.toPrimitive()) ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const revision of record.revisions.filter((item) => !stored.has(item.id))) {
|
for (const revision of record.revisions.filter((item) => !stored.has(item.id))) {
|
||||||
await this.database.query(
|
await writeSnapshotRow(
|
||||||
"INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)",
|
this.database,
|
||||||
{ replacements: revision, transaction }
|
"mail_configuration_revisions",
|
||||||
|
mailSnapshot,
|
||||||
|
revision,
|
||||||
|
transaction,
|
||||||
|
"insert"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,8 +9,11 @@ import { Mailbox } from "../../domain/mailbox.aggregate";
|
|||||||
import type { MailboxUse } from "../../domain/mailbox-use.aggregate";
|
import type { MailboxUse } from "../../domain/mailbox-use.aggregate";
|
||||||
import { MailboxError } from "../../domain/mailbox-values";
|
import { MailboxError } from "../../domain/mailbox-values";
|
||||||
|
|
||||||
|
import { readCompanyAccessItems } from "./access-relational";
|
||||||
|
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
|
||||||
import type { MailboxRevisionRow, MailboxRow, MailboxUseRow } from "./mailbox-persistence.mapper";
|
import type { MailboxRevisionRow, MailboxRow, MailboxUseRow } from "./mailbox-persistence.mapper";
|
||||||
import { MailboxPersistenceMapper, mailboxIdentity } from "./mailbox-persistence.mapper";
|
import { MailboxPersistenceMapper, mailboxIdentity } from "./mailbox-persistence.mapper";
|
||||||
|
import { writeSnapshotRow } from "./scalar-snapshot-writer";
|
||||||
|
|
||||||
export class SequelizeMailboxRepository implements IMailboxRepository {
|
export class SequelizeMailboxRepository implements IMailboxRepository {
|
||||||
private readonly mapper = new MailboxPersistenceMapper();
|
private readonly mapper = new MailboxPersistenceMapper();
|
||||||
@ -51,10 +54,7 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
|
|||||||
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
|
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
|
||||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
const items = await this.database.query<{ id: string; values: unknown }>(
|
const items = await readCompanyAccessItems(this.database, companyId.toPrimitive(), transaction);
|
||||||
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
|
|
||||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
|
||||||
);
|
|
||||||
const actors = await this.database.query<{ id: string; name: string }>(
|
const actors = await this.database.query<{ id: string; name: string }>(
|
||||||
"SELECT id,name FROM users",
|
"SELECT id,name FROM users",
|
||||||
{ transaction, type: QueryTypes.SELECT }
|
{ transaction, type: QueryTypes.SELECT }
|
||||||
@ -122,10 +122,25 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
|
|||||||
for (const revision of row.revisions.filter((revision) => !existing.has(revision.id))) {
|
for (const revision of row.revisions.filter((revision) => !existing.has(revision.id))) {
|
||||||
const table = item instanceof Mailbox ? "mailbox_revisions" : "mailbox_use_revisions";
|
const table = item instanceof Mailbox ? "mailbox_revisions" : "mailbox_use_revisions";
|
||||||
|
|
||||||
await this.database.query(
|
if (item instanceof Mailbox) {
|
||||||
`INSERT INTO ${table} (id,itemId,position,actorId,recordedAt,\`values\`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)`,
|
await writeSnapshotRow(
|
||||||
{ replacements: revision, transaction }
|
this.database,
|
||||||
);
|
table,
|
||||||
|
mailboxSnapshot,
|
||||||
|
revision,
|
||||||
|
transaction,
|
||||||
|
"insert"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await writeSnapshotRow(
|
||||||
|
this.database,
|
||||||
|
table,
|
||||||
|
mailboxUseSnapshot,
|
||||||
|
revision,
|
||||||
|
transaction,
|
||||||
|
"insert"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,6 +148,43 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
|
|||||||
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
|
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async persistItem(
|
||||||
|
item: Mailbox | MailboxUse,
|
||||||
|
previous: Mailbox | MailboxUse | undefined,
|
||||||
|
transaction: Transaction
|
||||||
|
) {
|
||||||
|
const operation = previous ? "update" : "insert";
|
||||||
|
const row = this.mapper.toPersistence(item);
|
||||||
|
|
||||||
|
if (item instanceof Mailbox) {
|
||||||
|
const values = item.values.toPrimitive();
|
||||||
|
|
||||||
|
await writeSnapshotRow(
|
||||||
|
this.database,
|
||||||
|
"mailboxes",
|
||||||
|
mailboxSnapshot,
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
companyId: row.companyId,
|
||||||
|
values,
|
||||||
|
addressKey: `${row.companyId}:${values.address}`,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
operation
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await writeSnapshotRow(
|
||||||
|
this.database,
|
||||||
|
"mailbox_uses",
|
||||||
|
mailboxUseSnapshot,
|
||||||
|
{ id: row.id, companyId: row.companyId, values: item.values.toPrimitive() },
|
||||||
|
transaction,
|
||||||
|
operation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.saveRevisions(item, previous, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
save(companyId: UniqueID, action: Parameters<IMailboxRepository["save"]>[1]) {
|
save(companyId: UniqueID, action: Parameters<IMailboxRepository["save"]>[1]) {
|
||||||
return this.database.transaction(async (transaction) => {
|
return this.database.transaction(async (transaction) => {
|
||||||
const loaded = await this.load(companyId, transaction, true);
|
const loaded = await this.load(companyId, transaction, true);
|
||||||
@ -152,42 +204,7 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
|
|||||||
: loaded.data.uses.find((old) => old.id.equals(item.id));
|
: loaded.data.uses.find((old) => old.id.equals(item.id));
|
||||||
|
|
||||||
if (previous !== item) {
|
if (previous !== item) {
|
||||||
const row = this.mapper.toPersistence(item);
|
await this.persistItem(item, previous, transaction);
|
||||||
|
|
||||||
if (item instanceof Mailbox) {
|
|
||||||
const values = item.values.toPrimitive();
|
|
||||||
|
|
||||||
await this.database.query(
|
|
||||||
"INSERT INTO mailboxes (id,companyId,accountId,addressKey,`values`) VALUES (:id,:companyId,:accountId,:addressKey,:values) ON DUPLICATE KEY UPDATE accountId=VALUES(accountId),addressKey=VALUES(addressKey),`values`=VALUES(`values`)",
|
|
||||||
{
|
|
||||||
replacements: {
|
|
||||||
id: row.id,
|
|
||||||
companyId: row.companyId,
|
|
||||||
values: row.values,
|
|
||||||
accountId: values.accountId,
|
|
||||||
addressKey: `${row.companyId}:${values.address}`,
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const values = item.values.toPrimitive();
|
|
||||||
|
|
||||||
await this.database.query(
|
|
||||||
"INSERT INTO mailbox_uses (id,companyId,mailboxId,personId,`values`) VALUES (:id,:companyId,:mailboxId,:personId,:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)",
|
|
||||||
{
|
|
||||||
replacements: {
|
|
||||||
id: row.id,
|
|
||||||
companyId: row.companyId,
|
|
||||||
values: row.values,
|
|
||||||
mailboxId: values.mailboxId,
|
|
||||||
personId: values.personId,
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.saveRevisions(item, previous, transaction);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.ok({
|
return Result.ok({
|
||||||
|
|||||||
@ -12,13 +12,9 @@ import type {
|
|||||||
} from "../../application/process.repository";
|
} from "../../application/process.repository";
|
||||||
import { ProcessError } from "../../domain/process-values";
|
import { ProcessError } from "../../domain/process-values";
|
||||||
|
|
||||||
import type { PersonProcessRevisionRow, PersonProcessRow } from "./process-persistence.mapper";
|
|
||||||
import { PersonProcessPersistenceMapper } from "./process-persistence.mapper";
|
import { PersonProcessPersistenceMapper } from "./process-persistence.mapper";
|
||||||
|
import { readProcessRows, writeProcessRow } from "./process-relational";
|
||||||
import { ProcessReviewReader } from "./process-review-reader";
|
import { ProcessReviewReader } from "./process-review-reader";
|
||||||
import type {
|
|
||||||
ProcessTemplateRevisionRow,
|
|
||||||
ProcessTemplateRow,
|
|
||||||
} from "./process-template-persistence.mapper";
|
|
||||||
import { ProcessTemplatePersistenceMapper } from "./process-template-persistence.mapper";
|
import { ProcessTemplatePersistenceMapper } from "./process-template-persistence.mapper";
|
||||||
|
|
||||||
export class SequelizeProcessRepository implements IProcessRepository {
|
export class SequelizeProcessRepository implements IProcessRepository {
|
||||||
@ -32,13 +28,11 @@ export class SequelizeProcessRepository implements IProcessRepository {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async loadCatalog(transaction: Transaction): Promise<ProcessCatalogContext> {
|
private async loadCatalog(transaction: Transaction): Promise<ProcessCatalogContext> {
|
||||||
const templates = await this.database.query<ProcessTemplateRow>(
|
const templates = await readProcessRows(this.database, "process_templates", transaction);
|
||||||
"SELECT * FROM process_templates",
|
const revisions = await readProcessRows(
|
||||||
{ transaction, type: QueryTypes.SELECT }
|
this.database,
|
||||||
);
|
"process_template_revisions",
|
||||||
const revisions = await this.database.query<ProcessTemplateRevisionRow>(
|
transaction
|
||||||
"SELECT * FROM process_template_revisions",
|
|
||||||
{ transaction, type: QueryTypes.SELECT }
|
|
||||||
);
|
);
|
||||||
const actors = await this.database.query<{ id: string; name: string }>(
|
const actors = await this.database.query<{ id: string; name: string }>(
|
||||||
"SELECT id,name FROM users",
|
"SELECT id,name FROM users",
|
||||||
@ -71,14 +65,10 @@ export class SequelizeProcessRepository implements IProcessRepository {
|
|||||||
return Result.fail(new ProcessError("COMPANY_NOT_FOUND"));
|
return Result.fail(new ProcessError("COMPANY_NOT_FOUND"));
|
||||||
}
|
}
|
||||||
const catalog = await this.loadCatalog(transaction);
|
const catalog = await this.loadCatalog(transaction);
|
||||||
const rows = await this.database.query<PersonProcessRow>(
|
const rows = (await readProcessRows(this.database, "person_processes", transaction)).filter(
|
||||||
"SELECT * FROM person_processes WHERE companyId=:companyId",
|
(row) => row.companyId === companyId
|
||||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
|
||||||
);
|
|
||||||
const revisions = await this.database.query<PersonProcessRevisionRow>(
|
|
||||||
"SELECT r.* FROM person_process_revisions r JOIN person_processes p ON p.id=r.itemId WHERE p.companyId=:companyId",
|
|
||||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
|
||||||
);
|
);
|
||||||
|
const revisions = await readProcessRows(this.database, "person_process_revisions", transaction);
|
||||||
const persons = await this.database.query<{ id: string; name: string }>(
|
const persons = await this.database.query<{ id: string; name: string }>(
|
||||||
"SELECT id,name FROM company_contacts WHERE companyId=:companyId",
|
"SELECT id,name FROM company_contacts WHERE companyId=:companyId",
|
||||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||||
@ -147,16 +137,22 @@ export class SequelizeProcessRepository implements IProcessRepository {
|
|||||||
if (previous !== candidate.data) {
|
if (previous !== candidate.data) {
|
||||||
const record = this.templateMapper.toPersistence(candidate.data);
|
const record = this.templateMapper.toPersistence(candidate.data);
|
||||||
|
|
||||||
await this.database.query(
|
await writeProcessRow(
|
||||||
"INSERT INTO process_templates (id,nameKey,`values`) VALUES (:id,:nameKey,:values) ON DUPLICATE KEY UPDATE nameKey=VALUES(nameKey),`values`=VALUES(`values`)",
|
this.database,
|
||||||
{ replacements: record.root, transaction }
|
"process_templates",
|
||||||
|
record.root,
|
||||||
|
transaction,
|
||||||
|
previous ? "update" : "insert"
|
||||||
);
|
);
|
||||||
const known = new Set(previous?.history.map((r) => r.id.toPrimitive()));
|
const known = new Set(previous?.history.map((r) => r.id.toPrimitive()));
|
||||||
|
|
||||||
for (const r of record.revisions.filter((r) => !known.has(r.id))) {
|
for (const r of record.revisions.filter((r) => !known.has(r.id))) {
|
||||||
await this.database.query(
|
await writeProcessRow(
|
||||||
"INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)",
|
this.database,
|
||||||
{ replacements: r, transaction }
|
"process_template_revisions",
|
||||||
|
r,
|
||||||
|
transaction,
|
||||||
|
"insert"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,7 +172,9 @@ export class SequelizeProcessRepository implements IProcessRepository {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!rows.length) return Result.fail(new ProcessError("PROCESS_NOT_FOUND"));
|
if (!rows.length) {
|
||||||
|
return Result.fail(new ProcessError("PROCESS_NOT_FOUND"));
|
||||||
|
}
|
||||||
await this.database.query("DELETE FROM person_process_revisions WHERE itemId=:id", {
|
await this.database.query("DELETE FROM person_process_revisions WHERE itemId=:id", {
|
||||||
replacements: { id: process.toPrimitive() },
|
replacements: { id: process.toPrimitive() },
|
||||||
transaction,
|
transaction,
|
||||||
@ -210,16 +208,22 @@ export class SequelizeProcessRepository implements IProcessRepository {
|
|||||||
if (previous !== candidate.data) {
|
if (previous !== candidate.data) {
|
||||||
const record = this.processMapper.toPersistence(candidate.data);
|
const record = this.processMapper.toPersistence(candidate.data);
|
||||||
|
|
||||||
await this.database.query(
|
await writeProcessRow(
|
||||||
"INSERT INTO person_processes (id,companyId,personId,templateId,currentKey,`values`) VALUES (:id,:companyId,:personId,:templateId,:currentKey,:values) ON DUPLICATE KEY UPDATE currentKey=VALUES(currentKey),`values`=VALUES(`values`)",
|
this.database,
|
||||||
{ replacements: record.root, transaction }
|
"person_processes",
|
||||||
|
record.root,
|
||||||
|
transaction,
|
||||||
|
previous ? "update" : "insert"
|
||||||
);
|
);
|
||||||
const known = new Set(previous?.history.map((r) => r.id.toPrimitive()));
|
const known = new Set(previous?.history.map((r) => r.id.toPrimitive()));
|
||||||
|
|
||||||
for (const r of record.revisions.filter((r) => !known.has(r.id))) {
|
for (const r of record.revisions.filter((r) => !known.has(r.id))) {
|
||||||
await this.database.query(
|
await writeProcessRow(
|
||||||
"INSERT INTO person_process_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)",
|
this.database,
|
||||||
{ replacements: r, transaction }
|
"person_process_revisions",
|
||||||
|
r,
|
||||||
|
transaction,
|
||||||
|
"insert"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,10 @@ import { remoteReferences } from "../../application/remote-connection-policy";
|
|||||||
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
|
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
|
||||||
import { RemoteConnectionError } from "../../domain/remote-connection-values";
|
import { RemoteConnectionError } from "../../domain/remote-connection-values";
|
||||||
|
|
||||||
|
import { remoteSnapshot } from "./mail-remote-snapshots";
|
||||||
import type { RemoteRevisionRow, RemoteRow } from "./remote-connection-persistence.mapper";
|
import type { RemoteRevisionRow, RemoteRow } from "./remote-connection-persistence.mapper";
|
||||||
import { RemoteConnectionPersistenceMapper } from "./remote-connection-persistence.mapper";
|
import { RemoteConnectionPersistenceMapper } from "./remote-connection-persistence.mapper";
|
||||||
|
import { writeSnapshotRow } from "./scalar-snapshot-writer";
|
||||||
import type { TeleworkRevisionRow, TeleworkRow } from "./telework-authorization-persistence.mapper";
|
import type { TeleworkRevisionRow, TeleworkRow } from "./telework-authorization-persistence.mapper";
|
||||||
import {
|
import {
|
||||||
TeleworkAuthorizationPersistenceMapper,
|
TeleworkAuthorizationPersistenceMapper,
|
||||||
@ -125,16 +127,24 @@ export class SequelizeRemoteConnectionRepository implements IRemoteConnectionRep
|
|||||||
if (previous !== candidate.data) {
|
if (previous !== candidate.data) {
|
||||||
const record = this.mapper.toPersistence(candidate.data);
|
const record = this.mapper.toPersistence(candidate.data);
|
||||||
|
|
||||||
await this.database.query(
|
await writeSnapshotRow(
|
||||||
"INSERT INTO remote_connections (id,companyId,personId,sourceAssetId,targetAssetId,targetResourceId,siteId,`values`,currentKey) VALUES (:id,:companyId,:personId,:sourceAssetId,:targetAssetId,:targetResourceId,:siteId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)",
|
this.database,
|
||||||
{ replacements: record.root, transaction }
|
"remote_connections",
|
||||||
|
remoteSnapshot,
|
||||||
|
record.root,
|
||||||
|
transaction,
|
||||||
|
previous ? "update" : "insert"
|
||||||
);
|
);
|
||||||
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
|
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
|
||||||
|
|
||||||
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
|
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
|
||||||
await this.database.query(
|
await writeSnapshotRow(
|
||||||
"INSERT INTO remote_connection_revisions (id,connectionId,position,actorId,recordedAt,`values`) VALUES (:id,:connectionId,:position,:actorId,:recordedAt,:values)",
|
this.database,
|
||||||
{ replacements: revision, transaction }
|
"remote_connection_revisions",
|
||||||
|
remoteSnapshot,
|
||||||
|
revision,
|
||||||
|
transaction,
|
||||||
|
"insert"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,15 +8,11 @@ import type {
|
|||||||
IWorkProfileRepository,
|
IWorkProfileRepository,
|
||||||
WorkProfileContext,
|
WorkProfileContext,
|
||||||
} from "../../application/work-profile.repository";
|
} from "../../application/work-profile.repository";
|
||||||
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
|
|
||||||
import { WorkProfileError } from "../../domain/work-profile-values";
|
import { WorkProfileError } from "../../domain/work-profile-values";
|
||||||
|
|
||||||
|
import { readCompanyAccessItems } from "./access-relational";
|
||||||
import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper";
|
import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper";
|
||||||
import {
|
import { WorkProfilePersistenceMapper, profileIdentity } from "./work-profile-persistence.mapper";
|
||||||
WorkProfilePersistenceMapper,
|
|
||||||
persistedJson,
|
|
||||||
profileIdentity,
|
|
||||||
} from "./work-profile-persistence.mapper";
|
|
||||||
|
|
||||||
export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
|
export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
|
||||||
private readonly mapper = new WorkProfilePersistenceMapper();
|
private readonly mapper = new WorkProfilePersistenceMapper();
|
||||||
@ -53,10 +49,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
|
|||||||
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
|
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
|
||||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
const items = await this.database.query<{ id: string; values: unknown }>(
|
const items = await readCompanyAccessItems(this.database, companyId, transaction);
|
||||||
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
|
|
||||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
|
||||||
);
|
|
||||||
const actors = await this.database.query<{ id: string; name: string }>(
|
const actors = await this.database.query<{ id: string; name: string }>(
|
||||||
"SELECT id,name FROM users",
|
"SELECT id,name FROM users",
|
||||||
{ transaction, type: QueryTypes.SELECT }
|
{ transaction, type: QueryTypes.SELECT }
|
||||||
@ -74,7 +67,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
|
|||||||
})),
|
})),
|
||||||
items: items.map((item) => ({
|
items: items.map((item) => ({
|
||||||
id: profileIdentity(item.id),
|
id: profileIdentity(item.id),
|
||||||
values: AccessDefinitionSchema.parse(persistedJson(item.values)),
|
values: item.values,
|
||||||
})),
|
})),
|
||||||
assets: await this.inventory.list(company),
|
assets: await this.inventory.list(company),
|
||||||
actors: new Map(actors.map((actor) => [actor.id, actor.name])),
|
actors: new Map(actors.map((actor) => [actor.id, actor.name])),
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import type { Sequelize } from "sequelize";
|
import type { Sequelize } from "sequelize";
|
||||||
import { DataTypes, QueryTypes } from "sequelize";
|
import { DataTypes, QueryTypes } from "sequelize";
|
||||||
|
|
||||||
/** Migración aditiva; nunca altera ni borra tablas existentes. */
|
import { normalizeSessions, sessionColumns } from "./session-normalization";
|
||||||
|
|
||||||
|
/** Migraciones explícitas de identidad y normalización de sesiones. */
|
||||||
export async function migrateIdentity(database: Sequelize): Promise<void> {
|
export async function migrateIdentity(database: Sequelize): Promise<void> {
|
||||||
const query = database.getQueryInterface();
|
const query = database.getQueryInterface();
|
||||||
const tableRows = await database.query<{ name: string }>(
|
const tableRows = await database.query<{ name: string }>(
|
||||||
@ -50,9 +52,10 @@ export async function migrateIdentity(database: Sequelize): Promise<void> {
|
|||||||
await query.createTable("http_sessions", {
|
await query.createTable("http_sessions", {
|
||||||
sid: { type: DataTypes.STRING(36), primaryKey: true },
|
sid: { type: DataTypes.STRING(36), primaryKey: true },
|
||||||
expires: { type: DataTypes.DATE, allowNull: true },
|
expires: { type: DataTypes.DATE, allowNull: true },
|
||||||
data: { type: DataTypes.TEXT, allowNull: true },
|
...sessionColumns,
|
||||||
createdAt: { type: DataTypes.DATE, allowNull: false },
|
createdAt: { type: DataTypes.DATE, allowNull: false },
|
||||||
updatedAt: { type: DataTypes.DATE, allowNull: false },
|
updatedAt: { type: DataTypes.DATE, allowNull: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await normalizeSessions(database);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { registerIdentityRoutes } from "./infrastructure/http/identity.routes";
|
||||||
import { requireAuthenticated } from "./infrastructure/http/require-authenticated";
|
import { requireAuthenticated } from "./infrastructure/http/require-authenticated";
|
||||||
import { migrateIdentity } from "./infrastructure/persistence/migrate-identity";
|
import { migrateIdentity } from "./infrastructure/persistence/migrate-identity";
|
||||||
|
import { SequelizeSessionStore } from "./infrastructure/persistence/sequelize-session.store";
|
||||||
import { SequelizeUserRepository } from "./infrastructure/persistence/sequelize-user.repository";
|
import { SequelizeUserRepository } from "./infrastructure/persistence/sequelize-user.repository";
|
||||||
import { initializeIdentityModels } from "./infrastructure/persistence/user.model";
|
import { initializeIdentityModels } from "./infrastructure/persistence/user.model";
|
||||||
import { UserPersistenceMapper } from "./infrastructure/persistence/user-persistence.mapper";
|
import { UserPersistenceMapper } from "./infrastructure/persistence/user-persistence.mapper";
|
||||||
import { ScryptPasswordHasher } from "./infrastructure/security/scrypt-password-hasher";
|
import { ScryptPasswordHasher } from "./infrastructure/security/scrypt-password-hasher";
|
||||||
|
|
||||||
export { migrateIdentity };
|
export { SequelizeSessionStore, migrateIdentity };
|
||||||
|
|
||||||
export async function createIdentityModule(database: Sequelize, origin: string) {
|
export async function createIdentityModule(database: Sequelize, origin: string) {
|
||||||
initializeIdentityModels(database);
|
initializeIdentityModels(database);
|
||||||
|
|||||||
@ -22,6 +22,10 @@ import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence
|
|||||||
import { readDefinitions } from "./resource-catalog-relational";
|
import { readDefinitions } from "./resource-catalog-relational";
|
||||||
import { readInventory, writeInventory } from "./resource-inventory-relational";
|
import { readInventory, writeInventory } from "./resource-inventory-relational";
|
||||||
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
|
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
|
||||||
|
import {
|
||||||
|
readResourceProjectionHistory,
|
||||||
|
readResourceProjections,
|
||||||
|
} from "./resource-projection-reader";
|
||||||
|
|
||||||
const mailboxSchema = z.strictObject({
|
const mailboxSchema = z.strictObject({
|
||||||
address: z.email().max(254),
|
address: z.email().max(254),
|
||||||
@ -44,14 +48,6 @@ const accessSchema = z.strictObject({
|
|||||||
|
|
||||||
type SourceRow = { id: string; companyId: string; values: unknown };
|
type SourceRow = { id: string; companyId: string; values: unknown };
|
||||||
|
|
||||||
type RevisionRow = {
|
|
||||||
id: string;
|
|
||||||
actorId: string;
|
|
||||||
recordedAt: string;
|
|
||||||
values: unknown;
|
|
||||||
position: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function json(value: unknown): unknown {
|
function json(value: unknown): unknown {
|
||||||
return typeof value === "string" ? JSON.parse(value) : value;
|
return typeof value === "string" ? JSON.parse(value) : value;
|
||||||
}
|
}
|
||||||
@ -178,12 +174,7 @@ async function importResource(
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const table = kind === "mailbox" ? "mailbox_revisions" : "access_revisions";
|
const rows = await readResourceProjectionHistory(database, kind, row.id, transaction);
|
||||||
const column = kind === "mailbox" ? "position" : "sequence";
|
|
||||||
const rows = await database.query<RevisionRow>(
|
|
||||||
`SELECT id,actorId,recordedAt,\`values\`,${column} AS position FROM ${table} WHERE itemId=:id ORDER BY ${column}`,
|
|
||||||
{ replacements: { id: row.id }, type: QueryTypes.SELECT, transaction }
|
|
||||||
);
|
|
||||||
const specification = spec(catalog, kind);
|
const specification = spec(catalog, kind);
|
||||||
const values = ResourceValues.create(map(catalog, kind, row.values), specification);
|
const values = ResourceValues.create(map(catalog, kind, row.values), specification);
|
||||||
|
|
||||||
@ -257,14 +248,13 @@ export async function migrateLegacyResources(database: Sequelize) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const company of companies) {
|
for (const company of companies) {
|
||||||
const mailboxRows = await database.query<SourceRow>(
|
const mailboxRows = await readResourceProjections(
|
||||||
"SELECT id,companyId,`values` FROM mailboxes WHERE companyId=:companyId",
|
database,
|
||||||
{ replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction }
|
"mailbox",
|
||||||
);
|
company.id,
|
||||||
const accessRows = await database.query<SourceRow>(
|
transaction
|
||||||
"SELECT i.id,w.companyId,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE i.kind='resource' AND w.companyId=:companyId",
|
|
||||||
{ replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction }
|
|
||||||
);
|
);
|
||||||
|
const accessRows = await readResourceProjections(database, "access", company.id, transaction);
|
||||||
|
|
||||||
for (const row of mailboxRows) {
|
for (const row of mailboxRows) {
|
||||||
await importResource(database, catalog, "mailbox", row, transaction);
|
await importResource(database, catalog, "mailbox", row, transaction);
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import type { Sequelize, Transaction } from "sequelize";
|
import type { Sequelize, Transaction } from "sequelize";
|
||||||
import { QueryTypes } from "sequelize";
|
import { QueryTypes } from "sequelize";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { Resource } from "../../domain/resource.aggregate";
|
import type { Resource } from "../../domain/resource.aggregate";
|
||||||
import type { ResourceInput } from "../../domain/resource-values";
|
import type { ResourceInput } from "../../domain/resource-values";
|
||||||
|
|
||||||
|
import { writeResourceRow } from "./resource-relational-sql";
|
||||||
|
|
||||||
/** Proyecciones para los contratos de accesos y perfiles: la ficha editable pertenece a resources. */
|
/** Proyecciones para los contratos de accesos y perfiles: la ficha editable pertenece a resources. */
|
||||||
export async function projectResource(
|
export async function projectResource(
|
||||||
database: Sequelize,
|
database: Sequelize,
|
||||||
@ -23,34 +26,32 @@ export async function projectResource(
|
|||||||
if (integration.kind === "mailbox") {
|
if (integration.kind === "mailbox") {
|
||||||
const values = mailboxProjection(current);
|
const values = mailboxProjection(current);
|
||||||
|
|
||||||
await database.query(
|
const columns = scalarProjection(values);
|
||||||
"INSERT INTO mailboxes (id,companyId,accountId,addressKey,`values`) VALUES (:id,:companyId,:accountId,:addressKey,:values) ON DUPLICATE KEY UPDATE accountId=VALUES(accountId),addressKey=VALUES(addressKey),`values`=VALUES(`values`)",
|
|
||||||
{
|
await writeResourceRow(
|
||||||
replacements: {
|
database,
|
||||||
id,
|
"mailboxes",
|
||||||
companyId,
|
{ id, companyId, addressKey: `${companyId}:${values.address}`, ...columns },
|
||||||
accountId: values.accountId,
|
transaction
|
||||||
addressKey: `${companyId}:${values.address}`,
|
|
||||||
values: JSON.stringify(values),
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
for (const [position, revision] of resource.history.entries()) {
|
for (const [position, revision] of resource.history.entries()) {
|
||||||
await database.query(
|
const revisionId = revision.id.toPrimitive();
|
||||||
"INSERT INTO mailbox_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id",
|
|
||||||
{
|
if (!(await projectionExists(database, "mailbox_revisions", revisionId, transaction))) {
|
||||||
replacements: {
|
await writeResourceRow(
|
||||||
id: revision.id.toPrimitive(),
|
database,
|
||||||
|
"mailbox_revisions",
|
||||||
|
{
|
||||||
|
id: revisionId,
|
||||||
itemId: id,
|
itemId: id,
|
||||||
position,
|
position,
|
||||||
actorId: revision.actorId.toPrimitive(),
|
actorId: revision.actorId.toPrimitive(),
|
||||||
recordedAt: revision.recordedAt,
|
recordedAt: revision.recordedAt,
|
||||||
values: JSON.stringify(mailboxProjection(revision.values.toPrimitive())),
|
...scalarProjection(mailboxProjection(revision.values.toPrimitive())),
|
||||||
},
|
},
|
||||||
transaction,
|
transaction
|
||||||
}
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -70,25 +71,47 @@ export async function projectResource(
|
|||||||
}
|
}
|
||||||
const values = accessProjection(current);
|
const values = accessProjection(current);
|
||||||
|
|
||||||
await database.query(
|
await writeResourceRow(
|
||||||
"INSERT INTO access_items (id,workspaceId,kind,`values`) VALUES (:id,:workspaceId,'resource',:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)",
|
database,
|
||||||
{ replacements: { id, workspaceId, values: JSON.stringify(values) }, transaction }
|
"access_items",
|
||||||
|
{ id, workspaceId, kind: "resource" },
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
const { kind: _kind, ...columns } = scalarProjection(values);
|
||||||
|
|
||||||
|
await writeResourceRow(
|
||||||
|
database,
|
||||||
|
"access_resource_values",
|
||||||
|
{ ownerId: id, ...columns },
|
||||||
|
transaction
|
||||||
);
|
);
|
||||||
for (const [sequence, revision] of resource.history.entries()) {
|
for (const [sequence, revision] of resource.history.entries()) {
|
||||||
await database.query(
|
const revisionId = revision.id.toPrimitive();
|
||||||
"INSERT INTO access_revisions (id,itemId,sequence,actorId,recordedAt,`values`) VALUES (:id,:itemId,:sequence,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id",
|
|
||||||
{
|
if (!(await projectionExists(database, "access_revisions", revisionId, transaction))) {
|
||||||
replacements: {
|
await writeResourceRow(
|
||||||
id: revision.id.toPrimitive(),
|
database,
|
||||||
|
"access_revisions",
|
||||||
|
{
|
||||||
|
id: revisionId,
|
||||||
itemId: id,
|
itemId: id,
|
||||||
sequence,
|
sequence,
|
||||||
actorId: revision.actorId.toPrimitive(),
|
actorId: revision.actorId.toPrimitive(),
|
||||||
recordedAt: revision.recordedAt,
|
recordedAt: revision.recordedAt,
|
||||||
values: JSON.stringify(accessProjection(revision.values.toPrimitive())),
|
|
||||||
},
|
},
|
||||||
transaction,
|
transaction
|
||||||
}
|
);
|
||||||
);
|
const { kind: _revisionKind, ...frozen } = scalarProjection(
|
||||||
|
accessProjection(revision.values.toPrimitive())
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeResourceRow(
|
||||||
|
database,
|
||||||
|
"access_resource_revision_values",
|
||||||
|
{ ownerId: revisionId, ...frozen },
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// El contexto es fijo; estas referencias preservan las FK utilizadas por permisos y necesidades.
|
// El contexto es fijo; estas referencias preservan las FK utilizadas por permisos y necesidades.
|
||||||
for (const targetId of [
|
for (const targetId of [
|
||||||
@ -145,3 +168,24 @@ function accessProjection(input: ResourceInput) {
|
|||||||
notes: input.notes || null,
|
notes: input.notes || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scalarProjection(values: unknown) {
|
||||||
|
return z
|
||||||
|
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
|
||||||
|
.parse(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function projectionExists(
|
||||||
|
database: Sequelize,
|
||||||
|
table: "mailbox_revisions" | "access_revisions",
|
||||||
|
id: string,
|
||||||
|
transaction: Transaction
|
||||||
|
) {
|
||||||
|
const rows = await database.query(`SELECT id FROM ${table} WHERE id=:id`, {
|
||||||
|
replacements: { id },
|
||||||
|
transaction,
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -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",
|
"SELECT id, name FROM users",
|
||||||
{ type: QueryTypes.SELECT, transaction }
|
{ type: QueryTypes.SELECT, transaction }
|
||||||
);
|
);
|
||||||
const rawReferences = await this.database.query<{ id: string; values: unknown }>(
|
const rawReferences = await this.database.query<Record<string, unknown>>(
|
||||||
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE (w.companyId=:companyId OR w.companyId IS NULL) AND i.kind IN ('account','system','environment','resource_type')",
|
"SELECT i.id,v.kind,v.name,v.identifier,v.active,v.systemId FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId JOIN access_current_values v ON v.ownerId=i.id AND v.kind=i.kind WHERE (w.companyId=:companyId OR w.companyId IS NULL) AND i.kind IN ('account','system','environment','resource_type')",
|
||||||
{ replacements: { companyId: companyId.toPrimitive() }, transaction, type: QueryTypes.SELECT }
|
{ replacements: { companyId: companyId.toPrimitive() }, transaction, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
const references = rawReferences.map((row) => {
|
const references = rawReferences.map((row) => {
|
||||||
const data = z
|
const data = z
|
||||||
.object({
|
.object({
|
||||||
kind: z.enum(["account", "system", "environment", "resource_type"]),
|
kind: z.enum(["account", "system", "environment", "resource_type"]),
|
||||||
name: z.string().optional(),
|
name: z.string().nullish(),
|
||||||
identifier: z.string().optional(),
|
identifier: z.string().nullish(),
|
||||||
active: z.boolean(),
|
active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean),
|
||||||
systemId: z.uuid().optional(),
|
systemId: z.uuid().nullish(),
|
||||||
})
|
})
|
||||||
.parse(typeof row.values === "string" ? JSON.parse(row.values) : row.values);
|
.parse(row);
|
||||||
|
|
||||||
return ResourceReferenceSchema.parse({
|
return ResourceReferenceSchema.parse({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@ -50,9 +50,6 @@ importers:
|
|||||||
'@metis/resources':
|
'@metis/resources':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../modules/resources
|
version: link:../../modules/resources
|
||||||
connect-session-sequelize:
|
|
||||||
specifier: 8.0.6
|
|
||||||
version: 8.0.6(sequelize@6.37.8(mariadb@3.5.4))
|
|
||||||
express:
|
express:
|
||||||
specifier: 4.22.1
|
specifier: 4.22.1
|
||||||
version: 4.22.1
|
version: 4.22.1
|
||||||
@ -2224,12 +2221,6 @@ packages:
|
|||||||
confbox@0.1.8:
|
confbox@0.1.8:
|
||||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||||
|
|
||||||
connect-session-sequelize@8.0.6:
|
|
||||||
resolution: {integrity: sha512-SAcXvRHKA3T1z5udDYK1dy783gh5CX7ok5dZNvuJEBd52FOi8ZfZjTMA8lwS357NNB2TmWDaV3bIBbypYco/CA==}
|
|
||||||
engines: {node: '>= 22'}
|
|
||||||
peerDependencies:
|
|
||||||
sequelize: '>= 6.37.7'
|
|
||||||
|
|
||||||
consola@3.4.2:
|
consola@3.4.2:
|
||||||
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
||||||
engines: {node: ^14.18.0 || >=16.10.0}
|
engines: {node: ^14.18.0 || >=16.10.0}
|
||||||
@ -5591,13 +5582,6 @@ snapshots:
|
|||||||
|
|
||||||
confbox@0.1.8: {}
|
confbox@0.1.8: {}
|
||||||
|
|
||||||
connect-session-sequelize@8.0.6(sequelize@6.37.8(mariadb@3.5.4)):
|
|
||||||
dependencies:
|
|
||||||
debug: 4.4.3
|
|
||||||
sequelize: 6.37.8(mariadb@3.5.4)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
consola@3.4.2: {}
|
consola@3.4.2: {}
|
||||||
|
|
||||||
content-disposition@0.5.4:
|
content-disposition@0.5.4:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user