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> { // 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[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> { 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> { 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[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[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 }); }); } }