metis-desk/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts

389 lines
14 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 type { IAssetRepository, InventoryContext } from "../../application/asset.repository";
import { AssetCatalogError } from "../../domain/asset-definition";
import type { Workstation } from "../../domain/workstation.aggregate";
import { AvailabilityModel, CatalogModel } from "./asset-catalog.model";
import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper";
import { readBindings, readDefinitions } from "./asset-catalog-relational";
import { readInventory, writeInventory } from "./asset-inventory-relational";
import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper";
import { sqlIdentifier } from "./asset-relational-sql";
import { RelationshipModel } from "./asset-relationship.model";
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
import { loadWorkstations } from "./load-workstations";
import { readBrands } from "./sequelize-brand.repository";
import { WorkstationAssetModel } from "./workstation.model";
import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
import { writeWorkstation } from "./workstation-relational";
export class SequelizeAssetRepository implements IAssetRepository {
private readonly mapper = new AssetPersistenceMapper();
constructor(private readonly database: Sequelize) {}
private async load(
companyId: UniqueID,
transaction: Transaction,
write: boolean
): Promise<Result<InventoryContext, AssetCatalogError>> {
// Orden fijo de bloqueos: catálogo antes de compañía, compartido con las escrituras del catálogo.
const root = await CatalogModel.findOne({
where: { catalogKey: "global" },
transaction,
...(write ? { lock: transaction.LOCK.UPDATE } : {}),
});
if (!root) {
throw new Error("Catálogo no inicializado.");
}
const replacements = { id: companyId.toPrimitive() };
const companies = await this.database.query<{ id: string }>(
`SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`,
{ replacements, type: QueryTypes.SELECT, transaction }
);
if (!companies.length) {
return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND"));
}
const definitions = await readDefinitions(this.database, root.id, transaction);
const bindings = await readBindings(
this.database,
definitions.map((item) => item.id),
transaction
);
const overrides = await AvailabilityModel.findAll({
where: { catalogId: root.id },
transaction,
});
const catalog = new AssetCatalogPersistenceMapper().toDomain(
root,
definitions,
bindings,
overrides
);
const { rows, revisions } = await readInventory(
this.database,
companyId.toPrimitive(),
transaction
);
const sites = await this.database.query<{ id: string }>(
"SELECT id FROM company_sites WHERE companyId=:id",
{ replacements, type: QueryTypes.SELECT, transaction }
);
const people = await this.database.query<{ id: string; active: boolean }>(
"SELECT id, active FROM company_contacts WHERE companyId=:id",
{ replacements, type: QueryTypes.SELECT, transaction }
);
const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id, name FROM users",
{ type: QueryTypes.SELECT, transaction }
);
const assets = rows.map((row) => {
const asset = this.mapper.toDomain(
row,
revisions.filter((revision) => revision.assetId === row.id)
);
const spec = asset.specification.toPrimitive();
const template = definitions.find((item) => item.id === spec.templateId);
if (
!template ||
(template.companyId !== null && template.companyId !== companyId.toPrimitive())
) {
throw new Error("Plantilla de activo fuera de su compañía.");
}
for (const revision of asset.history) {
const values = revision.values.toPrimitive();
if (values.siteId && !sites.some((site) => site.id === values.siteId)) {
throw new Error("Asignación de inventario fuera de su compañía.");
}
}
return asset;
});
const relationships = (
await RelationshipModel.findAll({
where: { companyId: companyId.toPrimitive() },
transaction,
})
).map((row) => new AssetRelationshipPersistenceMapper().toDomain(row));
for (const relationship of relationships) {
const values = relationship.values;
const definition = catalog
.visible(Maybe.some(companyId))
.find((item) => item.id.equals(values.typeId));
if (
!(
definition &&
definition.values.data.kind === "relation" &&
definition.values.data.symmetric === values.symmetric &&
[values.sourceId, values.targetId].every((id) =>
assets.some((asset) => asset.id.equals(id))
)
)
) {
throw new Error("Relación de activos fuera de su compañía.");
}
}
const workstations = await loadWorkstations(companyId, transaction, assets, sites, people);
return Result.ok({
workstations,
brands: await readBrands(this.database, transaction, write),
relationships,
catalog,
assets,
sites: sites.map((site) => inventoryIdentity(site.id)),
people: people.map((person) => ({
id: inventoryIdentity(person.id),
active: Boolean(person.active),
})),
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
});
}
saveWorkstation(companyId: UniqueID, action: Parameters<IAssetRepository["saveWorkstation"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return Result.fail(loaded.error);
}
const changed = action(loaded.data);
if (!changed.isSuccess) {
return Result.fail(changed.error);
}
const previous = loaded.data.workstations.find((item) => item.id.equals(changed.data.id));
if (previous !== changed.data) {
await this.persistWorkstation(changed.data, previous, transaction);
}
return Result.ok({ workstation: changed.data, actorNames: loaded.data.actorNames });
});
}
removeWorkstation(companyId: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return Result.fail(loaded.error);
}
if (!loaded.data.workstations.some((item) => item.id.equals(id))) {
return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND"));
}
const references = await this.database.query<{ tableName: string; columnName: string }>(
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME='workstations' AND REFERENCED_COLUMN_NAME='id'",
{ type: QueryTypes.SELECT, transaction }
);
const owned = new Set(["workstation_assets", "workstation_members", "workstation_revisions"]);
const replacements = { id: id.toPrimitive(), companyId: companyId.toPrimitive() };
for (const reference of references) {
if (owned.has(reference.tableName)) {
continue;
}
const rows = await this.database.query<{ value: number }>(
`SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:id LIMIT 1`,
{ replacements, transaction, type: QueryTypes.SELECT }
);
if (rows.length) {
return Result.fail(new AssetCatalogError("WORKSTATION_IN_USE"));
}
}
// Las asociaciones de composición se eliminan; sus activos no se borran.
await this.database.query("DELETE FROM workstation_assets WHERE workstationId=:id", {
replacements,
transaction,
});
await this.database.query("DELETE FROM workstation_revisions WHERE workstationId=:id", {
replacements,
transaction,
});
await this.database.query("DELETE FROM workstations WHERE companyId=:companyId AND id=:id", {
replacements,
transaction,
});
return Result.ok(undefined);
});
}
private async persistWorkstation(
item: Workstation,
previous: Workstation | undefined,
transaction: Transaction
) {
const rows = new WorkstationPersistenceMapper().toPersistence(item);
const existing = new Set(previous?.history.map((item) => item.id.toPrimitive()));
await writeWorkstation(this.database, rows, existing, transaction);
for (const reference of rows.references) {
await WorkstationAssetModel.upsert(reference, { transaction });
}
}
private async hasExternalReferences(assetId: string, transaction: Transaction) {
const references = await this.database.query<{ tableName: string; columnName: string }>(
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME='assets' AND REFERENCED_COLUMN_NAME='id'",
{ type: QueryTypes.SELECT, transaction }
);
const ownedTables = new Set([
"asset_characteristics",
"asset_revision_characteristics",
"asset_revisions",
"asset_specification_field_choices",
"asset_specification_fields",
"asset_specification_sections",
]);
for (const reference of references) {
if (ownedTables.has(reference.tableName)) {
continue;
}
const rows = await this.database.query<{ value: number }>(
`SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:assetId LIMIT 1`,
{ replacements: { assetId }, type: QueryTypes.SELECT, transaction }
);
if (rows.length) {
return true;
}
}
return false;
}
private async removeInventory(assetId: string, companyId: string, transaction: Transaction) {
for (const table of [
"asset_revision_characteristics",
"asset_characteristics",
"asset_specification_field_choices",
"asset_specification_fields",
"asset_specification_sections",
"asset_revisions",
]) {
await this.database.query(`DELETE FROM ${sqlIdentifier(table)} WHERE assetId=:assetId`, {
replacements: { assetId },
transaction,
});
}
await this.database.query("DELETE FROM assets WHERE companyId=:companyId AND id=:assetId", {
replacements: { assetId, companyId },
transaction,
});
}
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 asset = loaded.data.assets.find((item) => item.id.equals(id));
if (!asset) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
}
if (await this.hasExternalReferences(id.toPrimitive(), transaction)) {
return Result.fail(new AssetCatalogError("ASSET_IN_USE"));
}
await this.removeInventory(id.toPrimitive(), companyId.toPrimitive(), transaction);
return Result.ok({ asset, actorNames: loaded.data.actorNames });
});
}
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> {
return this.database.transaction(async (transaction) => {
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
replacements: { companyId: company.toPrimitive() },
transaction,
type: QueryTypes.SELECT,
});
const row = await RelationshipModel.findOne({
where: { companyId: company.toPrimitive(), id: id.toPrimitive() },
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!row) {
return Result.fail(new AssetCatalogError("RELATIONSHIP_NOT_FOUND"));
}
await row.destroy({ transaction });
return Result.ok(undefined);
});
}
saveRelationship(
companyId: UniqueID,
action: Parameters<IAssetRepository["saveRelationship"]>[1]
) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return Result.fail(loaded.error);
}
const changed = action(loaded.data);
if (!changed.isSuccess) {
return Result.fail(changed.error);
}
const previous = loaded.data.relationships.find((item) => item.id.equals(changed.data.id));
if (previous !== changed.data) {
await RelationshipModel.upsert(
new AssetRelationshipPersistenceMapper().toPersistence(changed.data),
{ transaction }
);
}
return Result.ok({ relationship: changed.data, actorNames: loaded.data.actorNames });
});
}
get(companyId: UniqueID) {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
save(companyId: UniqueID, action: Parameters<IAssetRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return Result.fail(loaded.error);
}
const changed = action(loaded.data);
if (!changed.isSuccess) {
return Result.fail(changed.error);
}
const previous = loaded.data.assets.find((asset) => asset.id.equals(changed.data.id));
if (changed.data !== previous) {
const rows = this.mapper.toPersistence(changed.data);
const existing = new Set(previous?.history.map((revision) => revision.id.toPrimitive()));
await writeInventory(this.database, rows, existing, transaction);
}
return Result.ok({ asset: changed.data, actorNames: loaded.data.actorNames });
});
}
}