metis-desk/modules/resources/src/api/infrastructure/persistence/resource-reference-reader.ts

296 lines
10 KiB
TypeScript
Raw Normal View History

2026-10-24 23:19:17 +00:00
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 {
CompanyApplicationSummarySchema,
2026-10-24 23:19:17 +00:00
GlobalApplicationDetailSchema,
GlobalApplicationSummarySchema,
ListApplicationCandidatesResponseSchema,
ListCompanyApplicationsResponseSchema,
2026-10-24 23:19:17 +00:00
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<IResourceReferenceSession>({
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;
});
}
applications(company: UniqueID, query: ListGlobalApplicationsRequestDTO) {
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() }
);
const items = metadata.flatMap(({ reference, provider }) =>
reference?.kind === "application" &&
(query.active === undefined || reference.active === query.active)
? [CompanyApplicationSummarySchema.parse({ ...reference, provider })]
: []
);
const page = paginateResourceMetadata(items, {
...query,
context: JSON.stringify([
"company-applications",
company.toPrimitive(),
query.active ?? null,
]),
});
return page.isSuccess
? Result.ok(ListCompanyApplicationsResponseSchema.parse(page.data))
: page;
});
}
2026-10-24 23:19:17 +00:00
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<GlobalApplicationDetail>();
});
}
}