- 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.
495 lines
17 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|