metis-desk/modules/companies/src/api/infrastructure/persistence/access-persistence.ts
David 0a34ddf752 feat: add local identity management features and UI components
- Implement folder account options helper for filtering access items based on person ID and date.
- Create FolderAccessAccountField component for selecting accounts with profile integration.
- Develop AssetLocalIdentities component for managing local identities associated with assets.
- Introduce LocalIdentityOwner component to display and manage local identity ownership.
- Add LocalIdentityEditor for creating, associating, and retiring local identities.
- Implement local identity API for fetching and executing local identity commands.
- Create tests for local identity UI components to ensure functionality and correctness.
- Add NetworkShareOptions class for structured and paginated SMB selection.
- Introduce NetworkShareAddress component for displaying and copying network share addresses.
- Create RemoteOptionList and RemoteOptionPicker components for remote option selection with pagination and search.
- Add tests for RemoteOptionList to validate rendering and behavior under various states.
2026-10-21 12:37:59 +02:00

495 lines
17 KiB
TypeScript

import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type {
CreationOptional,
InferAttributes,
InferCreationAttributes,
Sequelize,
Transaction,
} from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { LocalIdentityOwnerSchema } from "../../../common/dto/shared/access.dto";
import type { AccessSnapshot, IAccessRepository } from "../../application/access.repository";
import type { ICompanyRepository } from "../../application/company.repository";
import type { DirectoryContextSnapshot } from "../../application/directory-context-plan";
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
import { AccessError } from "../../domain/access/access-error";
import { AccessItem, AccessRevision } from "../../domain/access/access-item.entity";
import { AccessValues } from "../../domain/access/access-values.vo";
import { AccessWorkspace } from "../../domain/access/access-workspace.aggregate";
import type { Person } from "../../domain/person.entity";
import {
type AccessItemRow,
type AccessRevisionRow,
initializeAccessValues,
readAccessItems,
readAccessRevisions,
writeAccessRow,
} from "./access-relational";
import { migrateDirectoryContexts } from "./directory-context-migration";
import { migrateLocalIdentityEnvironments } from "./local-identity-migration";
class WorkspaceModel extends Model<
InferAttributes<WorkspaceModel>,
InferCreationAttributes<WorkspaceModel>
> {
declare id: string;
declare companyId: string | null;
declare scopeKey: string;
}
class ReferenceModel extends Model<
InferAttributes<ReferenceModel>,
InferCreationAttributes<ReferenceModel>
> {
declare id: CreationOptional<number>;
declare itemId: string;
declare targetId: string;
}
class PersonReferenceModel extends Model<
InferAttributes<PersonReferenceModel>,
InferCreationAttributes<PersonReferenceModel>
> {
declare itemId: string;
declare personId: string;
}
const idColumn = { type: DataTypes.UUID, primaryKey: true };
const reference = (table: string) => ({
type: DataTypes.UUID,
allowNull: false,
references: { model: table, key: "id" },
onDelete: "RESTRICT",
});
export async function migrateAccess(database: Sequelize) {
const query = database.getQueryInterface();
const rows = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
);
const exists = (name: string) => rows.some((row) => row.name === name);
if (!exists("access_workspaces")) {
await query.createTable("access_workspaces", {
id: idColumn,
companyId: { ...reference("companies"), allowNull: true },
scopeKey: { type: DataTypes.STRING(40), allowNull: false, unique: true },
});
}
if (!exists("access_items")) {
await query.createTable("access_items", {
id: idColumn,
workspaceId: reference("access_workspaces"),
kind: { type: DataTypes.STRING(30), allowNull: false },
});
}
if (!exists("access_revisions")) {
await query.createTable("access_revisions", {
id: idColumn,
itemId: reference("access_items"),
sequence: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
});
}
if (!exists("access_references")) {
await query.createTable("access_references", {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
itemId: reference("access_items"),
targetId: reference("access_items"),
});
}
if (!exists("access_person_references")) {
await query.createTable("access_person_references", {
itemId: { ...reference("access_items"), primaryKey: true },
personId: { ...reference("company_contacts"), primaryKey: true },
});
}
// Índices fuera de las ramas de creación: permiten completar un arranque interrumpido.
for (const [table, fields, name] of [
["access_revisions", ["itemId", "sequence"], "access_revision_sequence_unique"],
["access_references", ["itemId", "targetId"], "access_reference_unique"],
] as const) {
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((item) => item.name === name)) {
await query.addIndex(table, [...fields], { unique: true, name });
}
}
WorkspaceModel.init(
{
id: idColumn,
companyId: { type: DataTypes.UUID, allowNull: true },
scopeKey: { type: DataTypes.STRING(40), allowNull: false },
},
{ sequelize: database, tableName: "access_workspaces", timestamps: false }
);
await initializeAccessValues(database);
await migrateDirectoryContexts(database);
await migrateLocalIdentityEnvironments(database);
ReferenceModel.init(
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
itemId: { type: DataTypes.UUID, allowNull: false },
targetId: { type: DataTypes.UUID, allowNull: false },
},
{ sequelize: database, tableName: "access_references", timestamps: false }
);
PersonReferenceModel.init(
{
itemId: { type: DataTypes.UUID, primaryKey: true },
personId: { type: DataTypes.UUID, primaryKey: true },
},
{ sequelize: database, tableName: "access_person_references", timestamps: false }
);
// Completar referencias de registros ya existentes sin reescribir sus valores ni su historial.
for (const item of await database.transaction((transaction) =>
readAccessItems(database, transaction)
)) {
const value = domainValues(item.values).data;
if ("personId" in value) {
await PersonReferenceModel.findOrCreate({
where: { itemId: item.id, personId: value.personId },
defaults: { itemId: item.id, personId: value.personId },
});
}
}
await WorkspaceModel.findOrCreate({
where: { scopeKey: "global" },
defaults: { id: UniqueID.generateNewID().toPrimitive(), companyId: null, scopeKey: "global" },
});
}
function validId(value: string) {
const id = UniqueID.create(value);
if (!id.isSuccess) {
throw new Error("Identidad de acceso persistida inconsistente.");
}
return id.data;
}
function domainValues(value: unknown) {
const parsed = AccessDefinitionSchema.safeParse(value);
if (!parsed.success) {
throw new Error("Valores de acceso persistidos inconsistentes.");
}
const values = AccessValues.create(parsed.data);
if (!values.isSuccess) {
throw new Error("Valores de acceso persistidos inconsistentes.");
}
return values.data;
}
export class AccessPersistenceMapper {
toDomain(
root: WorkspaceModel,
rows: readonly AccessItemRow[],
revisions: readonly AccessRevisionRow[]
) {
const items = rows.map((row) => {
const values = domainValues(row.values);
const history = revisions
.filter((revision) => revision.itemId === row.id)
.sort((a, b) => a.sequence - b.sequence)
.map((revision, index) => {
if (
revision.sequence !== index ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(revision.recordedAt)
) {
throw new Error("Historial de acceso persistido inconsistente.");
}
return AccessRevision.rehydrate(
domainValues(revision.values),
{ actorId: validId(revision.actorId), recordedAt: revision.recordedAt },
validId(revision.id)
);
});
if (!history.at(-1)?.values.equals(values) || row.kind !== values.data.kind) {
throw new Error("Historial de acceso persistido inconsistente.");
}
return AccessItem.rehydrate(
{
companyId: root.companyId ? Maybe.some(validId(root.companyId)) : Maybe.none(),
values,
history,
},
validId(row.id)
);
});
return AccessWorkspace.rehydrate(
root.companyId ? Maybe.some(validId(root.companyId)) : Maybe.none(),
items,
validId(root.id)
);
}
toPersistence(workspace: AccessWorkspace) {
return workspace.items.map((item) => ({
item: {
id: item.id.toPrimitive(),
workspaceId: workspace.id.toPrimitive(),
kind: item.values.data.kind,
values: item.values.data,
},
revisions: item.history.map((revision, sequence) => ({
id: revision.id.toPrimitive(),
itemId: item.id.toPrimitive(),
sequence,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: revision.values.data,
})),
references: item.values
.references()
.filter((id) => {
const person = item.values.reference("personId").getOrUndefined();
return !person?.equals(id);
})
.map((id) => ({ itemId: item.id.toPrimitive(), targetId: id.toPrimitive() })),
}));
}
}
export class SequelizeAccessRepository implements IAccessRepository {
constructor(
private readonly database: Sequelize,
private readonly companies: ICompanyRepository,
private readonly mapper: AccessPersistenceMapper
) {}
private async load(
companyId: Maybe<UniqueID>,
transaction: Transaction,
write: boolean
): Promise<Result<AccessSnapshot, AccessError>> {
// Orden de bloqueo único: catálogo global, compañía y espacio de accesos. Serializa altas y cambios de catálogos.
const global = await WorkspaceModel.findOne({
where: { scopeKey: "global" },
transaction,
...(write ? { lock: transaction.LOCK.UPDATE } : {}),
});
if (!global) {
throw new Error("Catálogo no inicializado.");
}
let root = global;
let people: readonly Person[] = [];
const company = companyId.getOrUndefined();
if (company) {
const exists = await this.database.query<{ id: string }>(
`SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`,
{ replacements: { id: company.toPrimitive() }, transaction, type: QueryTypes.SELECT }
);
if (!exists.length) {
return Result.fail(new AccessError("COMPANY_NOT_FOUND"));
}
const aggregate = (await this.companies.findById(company)).getOrUndefined();
if (!aggregate) {
return Result.fail(new AccessError("COMPANY_NOT_FOUND"));
}
people = aggregate.people;
const [created] = await WorkspaceModel.findOrCreate({
where: { scopeKey: company.toPrimitive() },
defaults: {
id: UniqueID.generateNewID().toPrimitive(),
scopeKey: company.toPrimitive(),
companyId: company.toPrimitive(),
},
transaction,
});
root = created;
}
const roots = company ? [global, root] : await WorkspaceModel.findAll({ transaction });
const rows = (await readAccessItems(this.database, transaction)).filter((row) =>
roots.some((root) => root.id === row.workspaceId)
);
const revisions = (await readAccessRevisions(this.database, transaction)).filter((revision) =>
rows.some((row) => row.id === revision.itemId)
);
const workspace = this.mapper.toDomain(
root,
rows.filter((row) => row.workspaceId === root.id),
revisions
);
const catalog = roots
.filter((item) => item.id !== root.id)
.flatMap(
(item) =>
this.mapper.toDomain(
item,
rows.filter((row) => row.workspaceId === item.id),
revisions
).items
);
const actorRows = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users",
{ transaction, type: QueryTypes.SELECT }
);
const actors = new Map(actorRows.map((row) => [row.id, row.name]));
const localEnvironments = company
? z.array(LocalIdentityOwnerSchema).parse(
await this.database.query(
"SELECT l.environmentId,l.assetId,a.name AS assetName,l.endedAt FROM access_local_identity_environments l JOIN assets a ON a.id=l.assetId AND a.companyId=l.companyId WHERE l.companyId=:companyId",
{
replacements: { companyId: company.toPrimitive() },
transaction,
type: QueryTypes.SELECT,
}
)
)
: [];
const directories = company
? await this.database.query<{ environmentId: string }>(
"SELECT environmentId FROM access_directory_environments WHERE workspaceId=:workspaceId",
{ replacements: { workspaceId: root.id }, transaction, type: QueryTypes.SELECT }
)
: [];
return Result.ok({
localEnvironments,
directoryEnvironmentIds: directories.map((row) => row.environmentId),
actors,
workspace,
context: { catalog, people, today: new Date().toISOString().slice(0, 10) },
});
}
get(companyId: Maybe<UniqueID>) {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
update(companyId: Maybe<UniqueID>, action: Parameters<IAccessRepository["update"]>[1]) {
return this.database.transaction((transaction) =>
this.updateInTransaction(companyId, action, transaction)
);
}
async updateInTransaction(
companyId: Maybe<UniqueID>,
action: (
snapshot: DirectoryContextSnapshot
) => ReturnType<Parameters<IAccessRepository["update"]>[1]>,
transaction: Transaction
) {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return loaded;
}
const replacements = { workspaceId: loaded.data.workspace.id.toPrimitive() };
const systems = await this.database.query<{ systemId: string }>(
"SELECT systemId FROM access_directory_systems WHERE workspaceId=:workspaceId",
{ replacements, transaction, type: QueryTypes.SELECT }
);
const environments = await this.database.query<{ environmentId: string }>(
"SELECT environmentId FROM access_directory_environments WHERE workspaceId=:workspaceId",
{ replacements, transaction, type: QueryTypes.SELECT }
);
const changed = action({
...loaded.data,
directorySystems: systems.map((row) => row.systemId),
directoryEnvironments: environments.map((row) => row.environmentId),
});
if (!changed.isSuccess) {
return changed;
}
// Un contexto documental no se desactiva por una edición lateral del sistema o del entorno.
if (
changed.data.items.some((item) => {
const linked =
systems.some((row) => row.systemId === item.id.toPrimitive()) ||
environments.some((row) => row.environmentId === item.id.toPrimitive());
return linked && "active" in item.values.data && !item.values.data.active;
})
) {
return Result.fail(new AccessError("IMMUTABLE_FIELDS"));
}
if (changed.data !== loaded.data.workspace) {
await this.persistChanges(changed.data, loaded.data.workspace, transaction);
}
return Result.ok({
localEnvironments: loaded.data.localEnvironments,
directoryEnvironmentIds: loaded.data.directoryEnvironmentIds ?? [],
workspace: changed.data,
context: loaded.data.context,
actors: loaded.data.actors,
});
}
private async persistChanges(
workspace: AccessWorkspace,
previousWorkspace: AccessWorkspace,
transaction: Transaction
) {
const records = this.mapper.toPersistence(workspace);
const previous = new Set(
previousWorkspace.items.flatMap((item) =>
item.history.map((revision) => revision.id.toPrimitive())
)
);
for (const record of records) {
await writeAccessRow(
this.database,
record.item,
transaction,
previousWorkspace.items.some((item) => item.id.toPrimitive() === record.item.id)
? "update"
: "insert"
);
const revisions = record.revisions.filter((revision) => !previous.has(revision.id));
if (revisions.length) {
for (const revision of revisions) {
await writeAccessRow(this.database, revision, transaction, "insert");
}
}
const personId = Object.entries(record.item.values).find(([key]) => key === "personId")?.[1];
if (typeof personId === "string") {
await PersonReferenceModel.findOrCreate({
where: { itemId: record.item.id, personId },
defaults: { itemId: record.item.id, personId },
transaction,
});
}
for (const reference of record.references) {
await ReferenceModel.findOrCreate({
where: reference,
defaults: reference,
transaction,
});
}
}
}
}