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 { GlobalApplicationDetail, ListApplicationCandidatesRequestDTO, ListGlobalApplicationsRequestDTO, ResolveApplicationReferencesRequestDTO, } from "../../../common"; import { GlobalApplicationDetailSchema, GlobalApplicationSummarySchema, ListApplicationCandidatesResponseSchema, ResolveApplicationReferencesRequestSchema, ResolveApplicationReferencesResponseSchema, ResourceCandidateSchema, } from "../../../common"; import type { ResourceGlobalGates } from "../../application/resource-global-gates"; import { globalResourcePurposeEnabled, resourceGlobalGates, } from "../../application/resource-global-gates"; import { paginateResourceMetadata } from "../../application/resource-reference-pagination"; import type { IGlobalApplicationReader, IResourceReferenceReader, IResourceReferenceSession, } from "../../application/resource-reference-service"; import { ResourceReferenceError } from "../../application/resource-reference-service"; import { readResourceReferenceMetadata } from "./resource-reference-metadata"; import { ResourceScopeSchemaReader } from "./resource-scope-schema"; export class ResourceReferenceReader implements IResourceReferenceReader, IGlobalApplicationReader { private readonly schema: ResourceScopeSchemaReader; constructor( private readonly database: Sequelize, private readonly gates: ResourceGlobalGates = resourceGlobalGates ) { this.schema = new ResourceScopeSchemaReader(database); } /** Debe ser la primera operación del consumidor, antes de bloquear compañías/raíces propias. */ async forTransaction(transaction: Transaction, company: UniqueID, options = { write: true }) { const mode = await this.schema.read(); if (options.write) { const catalog = await this.database.query( "SELECT id FROM resource_catalogs WHERE catalogKey='global' LOCK IN SHARE MODE", { transaction, type: QueryTypes.SELECT } ); if (catalog.length !== 1) { throw new Error("Catálogo de recursos no inicializado."); } } const exists = await this.companyExists(transaction, company, options.write); if (!exists) { return Result.fail(new ResourceReferenceError("COMPANY_NOT_FOUND")); } return Result.ok({ resolve: (query) => this.resolveIn(transaction, company, query, mode, options.write), validateNewAssociation: async (id, purpose) => { if (!options.write) { throw new Error("La validación de asociación requiere una sesión de escritura."); } const parsed = ResolveApplicationReferencesRequestSchema.safeParse({ purpose, ids: [id] }); if (!parsed.success) { return Result.fail(new ResourceReferenceError("INVALID_REQUEST")); } const result = await this.resolveIn(transaction, company, parsed.data, mode, true); const resolved = result.items[0]; if (!resolved || resolved.status === "unavailable") { return Result.fail(new ResourceReferenceError("RESOURCE_REFERENCE_NOT_FOUND")); } if (!resolved.reference.active) { return Result.fail(new ResourceReferenceError("RESOURCE_REFERENCE_UNAVAILABLE")); } if ( resolved.reference.scope === "global" && !globalResourcePurposeEnabled(purpose, this.gates) ) { return Result.fail(new ResourceReferenceError("RESOURCE_GLOBAL_FEATURE_DISABLED")); } return Result.ok(resolved.reference); }, }); } private async companyExists(transaction: Transaction, company: UniqueID, write = false) { const rows = await this.database.query( `SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`, { replacements: { id: company.toPrimitive() }, transaction, type: QueryTypes.SELECT } ); return rows.length === 1; } private async resolveIn( transaction: Transaction, company: UniqueID, query: ResolveApplicationReferencesRequestDTO, mode: "legacy" | "scoped", currentRead = false ) { const validated = ResolveApplicationReferencesRequestSchema.parse(query); const rows = await readResourceReferenceMetadata(this.database, transaction, mode, { companyId: company.toPrimitive(), ids: validated.ids, currentRead, }); const references = new Map( rows.flatMap(({ reference }) => reference && (validated.purpose === "software-installation" || reference.kind === "application") ? [[reference.id, reference] as const] : [] ) ); return ResolveApplicationReferencesResponseSchema.parse({ purpose: validated.purpose, items: validated.ids.map((id) => { const reference = references.get(id.toLowerCase()); return reference ? { id, status: "resolved", reference } : { id, status: "unavailable" }; }), }); } resolve(company: UniqueID, query: ResolveApplicationReferencesRequestDTO) { return this.database.transaction(async (transaction) => { if (!(await this.companyExists(transaction, company))) { return Result.fail(new ResourceReferenceError("COMPANY_NOT_FOUND")); } return Result.ok(await this.resolveIn(transaction, company, query, await this.schema.read())); }); } capabilities(company: UniqueID) { return this.database.transaction(async (transaction) => { if (!(await this.companyExists(transaction, company))) { return Result.fail(new ResourceReferenceError("COMPANY_NOT_FOUND")); } return Result.ok({ softwareSelection: this.gates.catalog && this.gates.mixedSelection, softwareAssociation: globalResourcePurposeEnabled("software-installation", this.gates), profileSelection: globalResourcePurposeEnabled("application-configuration", this.gates), profileAssociation: globalResourcePurposeEnabled("application-configuration", this.gates), }); }); } candidates(company: UniqueID, query: ListApplicationCandidatesRequestDTO) { return this.database.transaction(async (transaction) => { if (!(await this.companyExists(transaction, company))) { return Result.fail(new ResourceReferenceError("COMPANY_NOT_FOUND")); } const metadata = await readResourceReferenceMetadata( this.database, transaction, await this.schema.read(), { companyId: company.toPrimitive(), activeOnly: true } ); const candidates = metadata.flatMap(({ reference, provider }) => { if ( !(reference && reference.active) || (query.purpose === "application-configuration" && reference.kind !== "application") || (reference.scope === "global" && !(query.purpose === "software-installation" ? this.gates.catalog && this.gates.mixedSelection : globalResourcePurposeEnabled(query.purpose, this.gates))) ) { return []; } return [ResourceCandidateSchema.parse({ ...reference, provider })]; }); const page = paginateResourceMetadata(candidates, { ...query, sort: "name", direction: "asc", context: JSON.stringify([ company.toPrimitive(), query.purpose, ( query.purpose === "software-installation" ? this.gates.catalog && this.gates.mixedSelection : globalResourcePurposeEnabled(query.purpose, this.gates) ) ? query.purpose === "software-installation" ? "mixed-r24" : "mixed-r25" : "local-r22", ]), }); return page.isSuccess ? Result.ok( ListApplicationCandidatesResponseSchema.parse({ purpose: query.purpose, ...page.data }) ) : page; }); } list(query: ListGlobalApplicationsRequestDTO) { return this.database.transaction(async (transaction) => { const rows = await readResourceReferenceMetadata( this.database, transaction, await this.schema.read(), { globalOnly: true } ); const items = rows.flatMap(({ reference, provider }) => reference && reference.scope === "global" && (query.active === undefined || query.active === reference.active) ? [GlobalApplicationSummarySchema.parse({ ...reference, provider })] : [] ); return paginateResourceMetadata(items, { ...query, context: JSON.stringify(["global-catalog", query.active ?? null]), }); }); } detail(id: string) { return this.database.transaction(async (transaction) => { const rows = await readResourceReferenceMetadata( this.database, transaction, await this.schema.read(), { globalOnly: true, ids: [id] } ); const row = rows[0]; return row?.reference ? Maybe.some( GlobalApplicationDetailSchema.parse({ ...row.reference, provider: row.provider, url: row.url, description: row.description, }) ) : Maybe.none(); }); } }