262 lines
9.2 KiB
TypeScript
262 lines
9.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 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<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;
|
|
});
|
|
}
|
|
|
|
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>();
|
|
});
|
|
}
|
|
}
|