metis-desk/modules/companies/src/api/infrastructure/persistence/access-relational.ts
2026-10-10 17:57:05 +02:00

328 lines
10 KiB
TypeScript

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[]) {
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 initializeAccessValues(database: Sequelize) {
await prepareAccessValues(database);
}
export function readCompanyAccessItems(
database: Sequelize,
companyId: string,
transaction: Transaction
) {
return readAccessItems(database, transaction, companyId);
}