metis-desk/modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts
2026-10-25 01:19:17 +02:00

306 lines
11 KiB
TypeScript

import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes, UniqueConstraintError } from "sequelize";
import { z } from "zod/v4";
import { ResourceReferenceSchema } from "../../../common/inventory";
import type { IDirectoryContextWriter } from "../../application/directory-context-writer";
import type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
import type { IResourceAccessWriter } from "../../application/resource-access-writer";
import { ResourceCatalogError } from "../../domain/resource-definition";
import { projectDirectoryDomain, readDirectoryDomains } from "./directory-domain-projection";
import {
networkShareResponse,
projectNetworkShare,
readNetworkShares,
} from "./network-share-projection";
import { prepareSmbAccess } from "./prepare-smb-access";
import { removeResource } from "./remove-resource";
import {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
} from "./resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
import { readDefinitions } from "./resource-catalog-relational";
import { projectResource } from "./resource-compatibility";
import { readInventory, writeInventory } from "./resource-inventory-relational";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
import { ResourceScopeSchemaReader } from "./resource-scope-schema";
export class SequelizeResourceRepository implements IResourceRepository {
private readonly mapper = new ResourcePersistenceMapper();
private readonly schema: ResourceScopeSchemaReader;
constructor(
private readonly database: Sequelize,
private readonly directoryContexts?: (transaction: Transaction) => IDirectoryContextWriter,
private readonly accessWriters?: (transaction: Transaction) => IResourceAccessWriter,
private readonly readTransaction?: Transaction
) {
this.schema = new ResourceScopeSchemaReader(database);
}
remove(companyId: UniqueID, id: UniqueID) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return Result.fail(loaded.error);
}
const resource = loaded.data.resources.find((item) => item.id.equals(id));
if (!resource) {
return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND"));
}
return removeResource(this.database, resource, transaction);
});
}
private async load(
companyId: UniqueID,
transaction: Transaction,
write: boolean
): Promise<Result<InventoryContext, ResourceCatalogError>> {
const mode = await this.schema.read();
// Orden compartido con las escrituras del catálogo para evitar interbloqueos.
const root = await ResourceCatalogModel.findOne({
where: { catalogKey: "global" },
transaction,
...(write ? { lock: transaction.LOCK.UPDATE } : {}),
});
if (!root) {
throw new Error("Catálogo de recursos no inicializado.");
}
if (write && (this.directoryContexts || this.accessWriters)) {
// Orden: catálogo de recursos, catálogo de accesos, compañía y workspace de accesos.
await this.database.query(
"SELECT id FROM access_workspaces WHERE scopeKey='global' FOR UPDATE",
{ transaction, type: QueryTypes.SELECT }
);
}
const companies = await this.database.query<{ id: string }>(
`SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`,
{ replacements: { id: companyId.toPrimitive() }, type: QueryTypes.SELECT, transaction }
);
if (!companies.length) {
return Result.fail(new ResourceCatalogError("COMPANY_NOT_FOUND"));
}
const definitions = await readDefinitions(this.database, root.id, transaction);
const bindings = definitions.length
? await ResourceBindingModel.findAll({
where: { templateId: definitions.map((item) => item.id) },
transaction,
})
: [];
const overrides = await ResourceAvailabilityModel.findAll({
where: { catalogId: root.id },
transaction,
});
const catalog = new ResourceCatalogPersistenceMapper().toDomain(
root,
definitions,
bindings,
overrides
);
const { rows, revisions } = await readInventory(
this.database,
companyId.toPrimitive(),
transaction
);
const resources = rows.map((row) => {
const history = revisions.filter((revision) => revision.resourceId === row.id);
const resource =
mode === "scoped"
? this.mapper.toScopedDomain(row, history)
: this.mapper.toDomain(row, history);
if (!resource.belongsToCompany(companyId)) {
throw new ResourceCatalogError("INVALID_RESOURCE_SCOPE");
}
const spec = resource.specification.toPrimitive();
const template = catalog
.visible(Maybe.some(companyId))
.find((item) => item.id.toPrimitive() === spec.templateId);
if (
!template ||
template.values.data.kind !== "template" ||
spec.fields.some(
(field) =>
!catalog
.visible(Maybe.some(companyId))
.some(
(item) =>
item.id.toPrimitive() === field.fieldId && item.values.data.kind === "field"
)
)
) {
throw new Error("Especificación de recurso fuera de su catálogo o compañía.");
}
return resource;
});
const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id, name FROM users",
{ type: QueryTypes.SELECT, transaction }
);
const rawReferences = await this.database.query<Record<string, unknown>>(
"SELECT i.id,v.kind,v.name,v.identifier,v.active,v.systemId FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId JOIN access_current_values v ON v.ownerId=i.id AND v.kind=i.kind WHERE (w.companyId=:companyId OR w.companyId IS NULL) AND i.kind IN ('account','system','environment','resource_type')",
{ replacements: { companyId: companyId.toPrimitive() }, transaction, type: QueryTypes.SELECT }
);
const references = rawReferences.map((row) => {
const data = z
.object({
kind: z.enum(["account", "system", "environment", "resource_type"]),
name: z.string().nullish(),
identifier: z.string().nullish(),
active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean),
systemId: z.uuid().nullish(),
})
.parse(row);
return ResourceReferenceSchema.parse({
id: row.id,
kind: data.kind,
name: data.name ?? data.identifier ?? "",
active: data.active,
systemId: data.systemId ?? null,
});
});
const shareRows = await readNetworkShares(this.database, companyId.toPrimitive(), transaction);
const networkShares = new Map(
shareRows.map((row) => {
const resource = resources.find((item) => item.id.toPrimitive() === row.resourceId);
if (!resource) {
throw new Error("Recurso SMB fuera de su compañía.");
}
return [row.resourceId, networkShareResponse(resource, catalog, row.normalizedAddress)];
})
);
return Result.ok({
networkShares,
directoryDomains: await readDirectoryDomains(
this.database,
companyId.toPrimitive(),
transaction
),
references,
catalog,
resources,
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
});
}
private persistence(
resource: Parameters<ResourcePersistenceMapper["toPersistence"]>[0],
mode: "legacy" | "scoped"
) {
return mode === "scoped"
? this.mapper.toScopedPersistence(resource)
: this.mapper.toPersistence(resource);
}
get(companyId: UniqueID) {
return this.readTransaction
? this.load(companyId, this.readTransaction, false)
: this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
async save(
companyId: UniqueID,
action: Parameters<IResourceRepository["save"]>[1],
creation?: Parameters<IResourceRepository["save"]>[2]
) {
try {
return await this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return loaded;
}
const accessWriter = this.accessWriters?.(transaction);
const context = await prepareSmbAccess(loaded.data, companyId, creation, accessWriter);
const changed = action(context);
if (!changed.isSuccess) {
throw changed.error;
}
if (!changed.data.belongsToCompany(companyId)) {
throw new ResourceCatalogError("RESOURCE_NOT_FOUND");
}
const mode = await this.schema.read();
const persistence = this.persistence(changed.data, mode);
await writeInventory(
this.database,
persistence,
new Set(
loaded.data.resources
.find((item) => item.id.equals(changed.data.id))
?.history.map((revision) => revision.id.toPrimitive()) ?? []
),
transaction
);
const directory = await projectDirectoryDomain(
this.database,
changed.data,
loaded.data.catalog,
loaded.data.directoryDomains?.get(changed.data.id.toPrimitive()),
this.directoryContexts?.(transaction),
transaction
);
if (!directory.isSuccess) {
// Lanzar dentro del callback gestionado garantiza rollback de Resource y Companies.
throw directory.error;
}
const networkShare = await projectNetworkShare(
this.database,
changed.data,
loaded.data.catalog,
loaded.data.resources.find((item) => item.id.equals(changed.data.id)),
loaded.data.networkShares?.has(changed.data.id.toPrimitive()) ?? false,
transaction
);
await projectResource(this.database, changed.data, transaction, accessWriter);
return Result.ok({
...(networkShare ? { networkShare } : {}),
resource: changed.data,
actorNames: loaded.data.actorNames,
...(directory.data ? { directory: directory.data } : {}),
});
});
} catch (error) {
if (
error instanceof UniqueConstraintError &&
error.errors.some(
(item) =>
item.path === "network_share_active_address" ||
item.message.includes("network_share_active_address")
)
) {
return Result.fail(new ResourceCatalogError("SMB_ADDRESS_EXISTS"));
}
if (error instanceof ResourceCatalogError) {
return Result.fail(error);
}
throw error;
}
}
}