import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize"; import { DataTypes, QueryTypes } from "sequelize"; import { z } from "zod/v4"; import { AccessDefinitionSchema, type AccessKind, AccessKindSchema, } from "../../domain/access/access-definition"; import { supportIdentifier, writeSupportScalars } from "./support-record-relational"; const ref = (model = "access_items", allowNull = false): ModelAttributeColumnOptions => ({ type: DataTypes.UUID, allowNull, references: { model, key: "id" }, onDelete: "RESTRICT", }); const named = { name: { type: DataTypes.STRING(100), allowNull: false }, active: { type: DataTypes.BOOLEAN, allowNull: false }, }; const note = { type: DataTypes.TEXT, allowNull: true }; const scope = { systemId: ref(), environmentId: ref("access_items", true) }; const period = { startDate: { type: DataTypes.STRING(10), allowNull: false }, endDate: { type: DataTypes.STRING(10), allowNull: true }, }; const configuration = { type: DataTypes.STRING(20), allowNull: false }; export const accessColumns: Record> = { system: named, resource_type: named, permission: { ...named, resourceTypeId: ref(), description: note }, template: { ...named, description: note, systemId: ref("access_items", true) }, environment: { ...named, systemId: ref() }, account: { ...scope, login: { type: DataTypes.STRING(255), allowNull: false }, usage: { type: DataTypes.STRING(16), allowNull: false }, active: named.active, expiresOn: { type: DataTypes.STRING(10), allowNull: true }, notes: note, }, account_person: { accountId: ref(), personId: ref("company_contacts"), ...period, configuration: { ...configuration, allowNull: true }, }, group: { ...scope, ...named, identifier: { type: DataTypes.STRING(255), allowNull: false }, description: note, templateId: ref("access_items", true), }, membership: { accountId: ref(), groupId: ref(), ...period, configuration }, resource: { ...named, resourceTypeId: ref(), systemId: ref("access_items", true), environmentId: ref("access_items", true), reference: { type: DataTypes.STRING(255), allowNull: true }, notes: note, }, grant: { resourceId: ref(), permissionId: ref(), accountId: ref("access_items", true), groupId: ref("access_items", true), ...period, configuration, }, need: { personId: ref("company_contacts"), resourceId: ref(), ...period, notes: note }, coverage: { needId: ref(), grantId: ref(), accountPersonId: ref(), membershipId: ref("access_items", true), ...period, }, }; export function accessValueTable(kind: AccessKind, history = false) { return `access_${kind}_${history ? "revision_" : ""}values`; } const itemSchema = z.object({ id: z.uuid(), workspaceId: z.uuid(), kind: AccessKindSchema, values: AccessDefinitionSchema, }); const revisionSchema = z.object({ id: z.uuid(), itemId: z.uuid(), sequence: z.number().int().nonnegative(), actorId: z.uuid(), recordedAt: z.iso.datetime(), values: AccessDefinitionSchema, }); export type AccessItemRow = z.infer; export type AccessRevisionRow = z.infer; function decoded(row: Record, permissionIds: string[]) { const kind = AccessKindSchema.parse(row.kind); const values = Object.fromEntries( Object.keys(accessColumns[kind]).map((key) => [ key, key === "active" ? z .union([z.boolean(), z.literal(0), z.literal(1)]) .transform(Boolean) .parse(row[key]) : row[key], ]) ); return AccessDefinitionSchema.parse({ kind, ...values, ...(kind === "need" ? { permissionIds } : {}), }); } export async function readAccessItems( database: Sequelize, transaction: Transaction, companyId?: string ) { const rows = await database.query>( `SELECT i.id,i.workspaceId,v.* FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId LEFT JOIN access_current_values v ON v.ownerId=i.id AND v.kind=i.kind${companyId ? " WHERE w.companyId=:companyId OR w.companyId IS NULL" : ""}`, { replacements: { companyId: companyId ?? null }, transaction, type: QueryTypes.SELECT } ); const permissions = await database.query<{ ownerId: string; permissionId: string; position: number; }>("SELECT * FROM access_need_permissions ORDER BY ownerId,position", { transaction, type: QueryTypes.SELECT, }); return rows.map((row) => itemSchema.parse({ ...row, values: decoded(row, permissionIds(permissions, row.id)) }) ); } export async function readAccessRevisions(database: Sequelize, transaction: Transaction) { const rows = await database.query>( "SELECT r.id,r.itemId,r.sequence,r.actorId,r.recordedAt,v.* FROM access_revisions r JOIN access_items i ON i.id=r.itemId LEFT JOIN access_historical_values v ON v.ownerId=r.id AND v.kind=i.kind", { transaction, type: QueryTypes.SELECT } ); const permissions = await database.query<{ ownerId: string; permissionId: string; position: number; }>("SELECT * FROM access_need_revision_permissions ORDER BY ownerId,position", { transaction, type: QueryTypes.SELECT, }); return rows.map((row) => revisionSchema.parse({ ...row, values: decoded(row, permissionIds(permissions, row.id)) }) ); } function permissionIds( rows: readonly { ownerId: string; permissionId: string; position: number }[], id: unknown ) { const selected = rows.filter((row) => row.ownerId === id); if (selected.some((row, position) => row.position !== position)) { throw new Error("Orden de permisos de necesidad inconsistente."); } return selected.map((row) => row.permissionId); } export async function writeAccessRow( database: Sequelize, row: AccessItemRow | AccessRevisionRow, transaction: Transaction, operation: "insert" | "update" ) { const { values, ...metadata } = row; const input = AccessDefinitionSchema.parse(values); const history = "itemId" in row; await writeSupportScalars( database, history ? "access_revisions" : "access_items", metadata, transaction, operation ); await writeAccessValues(database, row.id, input, history, transaction); } async function writeAccessValues( database: Sequelize, id: string, input: z.infer, history: boolean, transaction: Transaction ) { const { kind, ...values } = input; const { permissionIds: _permissions, ...columns } = z .record(z.string(), z.unknown()) .parse(values); const table = accessValueTable(kind, history); await database.query(`DELETE FROM ${supportIdentifier(table)} WHERE ownerId=:id`, { replacements: { id }, transaction, }); await writeSupportScalars(database, table, { ownerId: id, ...columns }, transaction, "insert"); if (kind === "need") { const links = history ? "access_need_revision_permissions" : "access_need_permissions"; await database.query(`DELETE FROM ${supportIdentifier(links)} WHERE ownerId=:id`, { replacements: { id }, transaction, }); for (const [position, permissionId] of input.permissionIds.entries()) { await writeSupportScalars( database, links, { ownerId: id, permissionId, position }, transaction, "insert" ); } } } async function prepareAccessValues(database: Sequelize) { const query = database.getQueryInterface(); const existing = await database.query<{ name: string }>( "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", { type: QueryTypes.SELECT } ); for (const kind of AccessKindSchema.options) { for (const history of [false, true]) { const table = accessValueTable(kind, history); if (!existing.some((row) => row.name === table)) { await query.createTable(table, { ownerId: { ...ref(history ? "access_revisions" : "access_items"), primaryKey: true, onDelete: "CASCADE", }, ...accessColumns[kind], }); } } } await prepareNeedPermissions(database, existing); await createAccessViews(database); } async function prepareNeedPermissions(database: Sequelize, existing: readonly { name: string }[]) { const query = database.getQueryInterface(); for (const history of [false, true]) { const table = history ? "access_need_revision_permissions" : "access_need_permissions"; if (!existing.some((row) => row.name === table)) { await query.createTable(table, { ownerId: { ...ref(accessValueTable("need", history)), references: { model: accessValueTable("need", history), key: "ownerId" }, primaryKey: true, onDelete: "CASCADE", }, position: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true }, permissionId: ref(), }); } const indexes = await database.query<{ name: string }>( "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table", { replacements: { table }, type: QueryTypes.SELECT } ); if (!indexes.some((index) => index.name === `${table}_unique`)) { await query.addIndex(table, ["ownerId", "permissionId"], { unique: true, name: `${table}_unique`, }); } } } async function createAccessViews(database: Sequelize) { // Vistas de lectura: columnas SQL, sin reconstruir documentos JSON en la base. const allColumns = [...new Set(Object.values(accessColumns).flatMap(Object.keys))]; for (const history of [false, true]) { const selections = AccessKindSchema.options.map( (kind) => `SELECT ownerId,'${kind}' AS kind,${allColumns.map((name) => (name in accessColumns[kind] ? supportIdentifier(name) : `NULL AS ${supportIdentifier(name)}`)).join(",")} FROM ${supportIdentifier(accessValueTable(kind, history))}` ); await database.query( `CREATE OR REPLACE VIEW ${history ? "access_historical_values" : "access_current_values"} AS ${selections.join(" UNION ALL ")}` ); } } export async function initializeAccessValues(database: Sequelize) { await prepareAccessValues(database); } export function readCompanyAccessItems( database: Sequelize, companyId: string, transaction: Transaction ) { return readAccessItems(database, transaction, companyId); }