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

179 lines
6.2 KiB
TypeScript

import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { ResourceReferenceSchema } from "../../../common/inventory";
import type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
import { ResourceCatalogError } from "../../domain/resource-definition";
import { ResourceModel, ResourceRevisionModel } from "./resource.model";
import {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
ResourceDefinitionModel,
} from "./resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
import { projectResource } from "./resource-compatibility";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
export class SequelizeResourceRepository implements IResourceRepository {
private readonly mapper = new ResourcePersistenceMapper();
constructor(private readonly database: Sequelize) {}
private async load(
companyId: UniqueID,
transaction: Transaction,
write: boolean
): Promise<Result<InventoryContext, ResourceCatalogError>> {
// 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.");
}
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 ResourceDefinitionModel.findAll({
where: { catalogId: 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 = await ResourceModel.findAll({
where: { companyId: companyId.toPrimitive() },
transaction,
});
const revisions = rows.length
? await ResourceRevisionModel.findAll({
where: { resourceId: rows.map((item) => item.id) },
transaction,
})
: [];
const resources = rows.map((row) => {
const resource = this.mapper.toDomain(
row,
revisions.filter((revision) => revision.resourceId === row.id)
);
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<{ id: string; values: unknown }>(
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId 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().optional(),
identifier: z.string().optional(),
active: z.boolean(),
systemId: z.uuid().optional(),
})
.parse(typeof row.values === "string" ? JSON.parse(row.values) : row.values);
return ResourceReferenceSchema.parse({
id: row.id,
kind: data.kind,
name: data.name ?? data.identifier ?? "",
active: data.active,
systemId: data.systemId ?? null,
});
});
return Result.ok({
references,
catalog,
resources,
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
});
}
get(companyId: UniqueID) {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
save(companyId: UniqueID, action: Parameters<IResourceRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return loaded;
}
const changed = action(loaded.data);
if (!changed.isSuccess) {
return changed;
}
const persistence = this.mapper.toPersistence(changed.data);
await projectResource(this.database, changed.data, transaction);
await ResourceModel.upsert(persistence.resource, { transaction });
const existing = await ResourceRevisionModel.findAll({
where: { resourceId: persistence.resource.id },
transaction,
});
for (const revision of persistence.revisions) {
if (!existing.some((item) => item.id === revision.id)) {
await ResourceRevisionModel.create(revision, { transaction });
}
}
return Result.ok({ resource: changed.data, actorNames: loaded.data.actorNames });
});
}
}