328 lines
10 KiB
TypeScript
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);
|
|
}
|