metis-desk/modules/resources/src/api/infrastructure/persistence/application-coincidence-reader.ts

161 lines
5.0 KiB
TypeScript
Raw Normal View History

2026-10-24 23:19:17 +00:00
import type { UniqueID } from "@repo/rdx-ddd";
import { Result, normalizeSearchText } from "@repo/rdx-utils";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import type { ApplicationCoincidence, ApplicationCoincidenceQuery } from "../../../common";
import {
ApplicationCoincidencesResponseSchema,
GlobalApplicationSummarySchema,
} from "../../../common";
import { compareApplicationProduct } from "../../application/application-coincidence";
import { ResourceReferenceError } from "../../application/resource-reference-service";
import { readResourceReferenceMetadata } from "./resource-reference-metadata";
import { ResourceScopeSchemaReader } from "./resource-scope-schema";
const CursorSchema = z.strictObject({
version: z.literal(1),
context: z.string(),
afterId: z.uuid(),
});
function decodeCursor(cursor: string | undefined, context: string) {
if (!cursor) {
return Result.ok<string | undefined>(undefined);
}
try {
const parsed = CursorSchema.safeParse(
JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"))
);
return parsed.success && parsed.data.context === context
? Result.ok<string | undefined>(parsed.data.afterId)
: Result.fail(new ResourceReferenceError("INVALID_REQUEST"));
} catch {
return Result.fail(new ResourceReferenceError("INVALID_REQUEST"));
}
}
function scanMatches(
source: { name: string; provider: string | null },
rows: Awaited<ReturnType<typeof readResourceReferenceMetadata>>,
query: ApplicationCoincidenceQuery
) {
const items: ApplicationCoincidence[] = [];
let scanned = 0;
let last: string | undefined;
const search = normalizeSearchText(query.search);
for (const row of rows.slice(0, 200)) {
if (!row.reference) {
throw new Error("Referencia global incompatible.");
}
last = row.reference.id;
scanned++;
const candidate = GlobalApplicationSummarySchema.parse({
...row.reference,
provider: row.provider,
});
const match = compareApplicationProduct(
{ name: source.name, provider: source.provider },
candidate
);
if (
match &&
(!search ||
normalizeSearchText(`${candidate.name} ${candidate.provider ?? ""}`).includes(search))
) {
items.push(match);
}
if (items.length === query.pageSize) {
break;
}
}
return { items, scanned, last };
}
/** Lectura contextual y acotada: a lo sumo 200 productos globales comparados por petición, sin escrituras. */
export class ApplicationCoincidenceReader {
private readonly schema: ResourceScopeSchemaReader;
constructor(
private readonly database: Sequelize,
private readonly enabled: boolean
) {
this.schema = new ResourceScopeSchemaReader(database);
}
async list(company: UniqueID, resource: UniqueID, query: ApplicationCoincidenceQuery) {
return this.database.transaction(async (transaction) => {
const exists = await this.database.query("SELECT id FROM companies WHERE id=:id", {
replacements: { id: company.toPrimitive() },
transaction,
type: QueryTypes.SELECT,
});
if (!exists.length) {
return Result.fail(new ResourceReferenceError("COMPANY_NOT_FOUND"));
}
const mode = await this.schema.read();
const own = (
await readResourceReferenceMetadata(this.database, transaction, mode, {
companyId: company.toPrimitive(),
ids: [resource.toPrimitive()],
})
)[0];
if (!(own?.reference?.scope === "company" && own.reference.kind === "application")) {
return Result.fail(new ResourceReferenceError("RESOURCE_REFERENCE_NOT_FOUND"));
}
if (!this.enabled) {
return Result.fail(new ResourceReferenceError("RESOURCE_GLOBAL_FEATURE_DISABLED"));
}
const context = JSON.stringify([
company.toPrimitive(),
resource.toPrimitive(),
own.reference.name,
own.provider,
query.search,
query.pageSize,
]);
const decoded = decodeCursor(query.cursor, context);
if (!decoded.isSuccess) {
return decoded;
}
const afterId = decoded.data;
const rows = await readResourceReferenceMetadata(this.database, transaction, mode, {
globalOnly: true,
limit: 201,
...(afterId ? { afterId } : {}),
});
const { items, scanned, last } = scanMatches(
{ name: own.reference.name, provider: own.provider },
rows,
query
);
const complete = rows.length <= scanned;
const nextCursor =
!complete && last
? Buffer.from(JSON.stringify({ version: 1, context, afterId: last })).toString(
"base64url"
)
: null;
return Result.ok(
ApplicationCoincidencesResponseSchema.parse({
source: { ...own.reference, provider: own.provider },
items,
nextCursor,
complete,
scanned,
})
);
});
}
}