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,
|
2026-10-25 19:39:06 +00:00
|
|
|
globalKind: "application",
|
2026-10-24 23:19:17 +00:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|