0c4b91707f
CRITICAL fixes: - Fix SQL injection in geoportal search (template literal in $queryRaw) - Preserve enrichment data during GIS re-sync (upsert update explicit fields only) - Fix ePay version race condition (advisory lock in transaction) - Add requireAuth() to compress-pdf and unlock routes (were unauthenticated) - Remove hardcoded Stirling PDF API key (env vars now required) IMPORTANT fixes: - Add admin role check on registratura debug-sequences endpoint - Fix reserved slot race condition with advisory lock in transaction - Use SSO identity in close-guard-dialog instead of hardcoded "Utilizator" - Storage DELETE catches only P2025 (not found), re-throws real errors - Add onDelete: SetNull for GisFeature → GisSyncRun relation - Move portal-only users to PORTAL_ONLY_USERS env var - Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) - Add periodic cleanup for eTerra/ePay session caches and progress store - Log warning when ePay dataDocument is missing (expiry fallback) Cleanup: - Delete orphaned rgi-test page (1086 lines, unregistered, inaccessible) - Delete legacy/ folder (5 files, unreferenced from src/) - Remove unused ensureBucketExists() from minio-client.ts Documentation: - Optimize CLAUDE.md: 464 → 197 lines (moved per-module details to docs/) - Create docs/ARCHITECTURE-QUICK.md (80 lines: data flow, deps, env vars) - Create docs/MODULE-MAP.md (140 lines: entry points, API routes, cross-deps) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
472 lines
15 KiB
TypeScript
472 lines
15 KiB
TypeScript
/**
|
|
* ePay batch order queue.
|
|
*
|
|
* ePay has a GLOBAL cart per account — only one order can be processed
|
|
* at a time. This queue ensures sequential execution.
|
|
*
|
|
* BATCH flow (correct — ONE order for N parcels):
|
|
* 1. Check credits (need >= N)
|
|
* 2. For each parcel: addToCart → saveMetadata
|
|
* 3. ONE EditCartSubmit → ONE orderId
|
|
* 4. Poll that orderId until "Finalizata"
|
|
* 5. Download ALL documents from that order
|
|
* 6. Store each in MinIO, update DB records
|
|
*/
|
|
|
|
import { prisma } from "@/core/storage/prisma";
|
|
import { EpayClient } from "./epay-client";
|
|
import { getEpayCredentials, updateEpayCredits } from "./epay-session-store";
|
|
import { storeCfExtract } from "./epay-storage";
|
|
import type { CfExtractCreateInput } from "./epay-types";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type QueueItem = {
|
|
extractId: string; // CfExtract.id in DB
|
|
input: CfExtractCreateInput;
|
|
basketRowId?: number; // set during cart phase
|
|
};
|
|
|
|
type BatchJob = {
|
|
items: QueueItem[];
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Global singleton queue */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const g = globalThis as {
|
|
__epayBatchQueue?: BatchJob[];
|
|
__epayQueueProcessing?: boolean;
|
|
__epayDedupMap?: Map<string, { timestamp: number; extractIds: string[] }>;
|
|
};
|
|
if (!g.__epayBatchQueue) g.__epayBatchQueue = [];
|
|
if (!g.__epayDedupMap) g.__epayDedupMap = new Map();
|
|
|
|
/** TTL for dedup entries in milliseconds (60 seconds). */
|
|
const DEDUP_TTL_MS = 60_000;
|
|
|
|
/**
|
|
* Build a dedup key from a list of cadastral numbers.
|
|
* Sorted and joined so order doesn't matter.
|
|
*/
|
|
function batchDedupKey(inputs: CfExtractCreateInput[]): string {
|
|
return inputs
|
|
.map((i) => i.nrCadastral)
|
|
.sort()
|
|
.join(",");
|
|
}
|
|
|
|
/**
|
|
* Remove expired entries from the dedup map.
|
|
*/
|
|
function cleanupDedupMap(): void {
|
|
const now = Date.now();
|
|
const map = g.__epayDedupMap!;
|
|
for (const [key, entry] of map) {
|
|
if (now - entry.timestamp > DEDUP_TTL_MS) {
|
|
map.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Public API */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/**
|
|
* Enqueue a single CF extract order (backwards compatible).
|
|
* Creates a DB record, wraps as a batch of 1, and adds to the queue.
|
|
*/
|
|
export async function enqueueOrder(
|
|
input: CfExtractCreateInput,
|
|
): Promise<string> {
|
|
const ids = await enqueueBatch([input]);
|
|
const first = ids[0];
|
|
if (!first) throw new Error("enqueueBatch returned empty array");
|
|
return first;
|
|
}
|
|
|
|
/**
|
|
* Enqueue a batch of CF extract orders.
|
|
* Creates all DB records, then processes as ONE ePay order.
|
|
* Returns the CfExtract IDs immediately.
|
|
*
|
|
* Dedup protection: if the same set of cadastral numbers was enqueued
|
|
* within the last 60 seconds, returns the existing extract IDs instead
|
|
* of creating duplicate DB records and orders.
|
|
*/
|
|
export async function enqueueBatch(
|
|
inputs: CfExtractCreateInput[],
|
|
): Promise<string[]> {
|
|
if (inputs.length === 0) return [];
|
|
|
|
// ── Dedup check ──
|
|
cleanupDedupMap();
|
|
const dedupKey = batchDedupKey(inputs);
|
|
const existing = g.__epayDedupMap!.get(dedupKey);
|
|
if (existing && Date.now() - existing.timestamp < DEDUP_TTL_MS) {
|
|
console.log(
|
|
`[epay-queue] Dedup hit: batch [${dedupKey}] was enqueued ${Math.round((Date.now() - existing.timestamp) / 1000)}s ago — returning existing IDs`,
|
|
);
|
|
return existing.extractIds;
|
|
}
|
|
|
|
const items: QueueItem[] = [];
|
|
|
|
for (const input of inputs) {
|
|
// Create DB record in "queued" status — use transaction + advisory lock
|
|
// to prevent duplicate version numbers from concurrent requests
|
|
const record = await prisma.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'cfextract:' + input.nrCadastral}))`;
|
|
const agg = await tx.cfExtract.aggregate({
|
|
where: { nrCadastral: input.nrCadastral },
|
|
_max: { version: true },
|
|
});
|
|
return tx.cfExtract.create({
|
|
data: {
|
|
nrCadastral: input.nrCadastral,
|
|
nrCF: input.nrCF ?? input.nrCadastral,
|
|
siruta: input.siruta,
|
|
judetIndex: input.judetIndex,
|
|
judetName: input.judetName,
|
|
uatId: input.uatId,
|
|
uatName: input.uatName,
|
|
gisFeatureId: input.gisFeatureId,
|
|
prodId: input.prodId ?? 14200,
|
|
status: "queued",
|
|
version: (agg._max.version ?? 0) + 1,
|
|
},
|
|
});
|
|
});
|
|
|
|
items.push({ extractId: record.id, input });
|
|
}
|
|
|
|
const extractIds = items.map((i) => i.extractId);
|
|
|
|
// ── Record in dedup map ──
|
|
g.__epayDedupMap!.set(dedupKey, {
|
|
timestamp: Date.now(),
|
|
extractIds,
|
|
});
|
|
|
|
g.__epayBatchQueue!.push({ items });
|
|
|
|
console.log(
|
|
`[epay-queue] Enqueued batch of ${items.length}: ${items.map((i) => i.input.nrCadastral).join(", ")} (queue=${g.__epayBatchQueue!.length})`,
|
|
);
|
|
|
|
// Start processing if not already running
|
|
void processQueue();
|
|
|
|
return extractIds;
|
|
}
|
|
|
|
/**
|
|
* Get queue status for UI display.
|
|
*/
|
|
export function getQueueStatus(): {
|
|
length: number;
|
|
processing: boolean;
|
|
} {
|
|
return {
|
|
length: g.__epayBatchQueue?.length ?? 0,
|
|
processing: g.__epayQueueProcessing ?? false,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Queue Processor */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
async function processQueue(): Promise<void> {
|
|
if (g.__epayQueueProcessing) return; // already running
|
|
g.__epayQueueProcessing = true;
|
|
|
|
// Track all orderIds from this session to avoid duplicates
|
|
const knownOrderIds = new Set<string>();
|
|
|
|
try {
|
|
while (g.__epayBatchQueue && g.__epayBatchQueue.length > 0) {
|
|
const batch = g.__epayBatchQueue.shift()!;
|
|
const orderId = await processBatch(batch.items, knownOrderIds);
|
|
if (orderId) knownOrderIds.add(orderId);
|
|
}
|
|
} finally {
|
|
g.__epayQueueProcessing = false;
|
|
}
|
|
}
|
|
|
|
async function updateStatus(
|
|
id: string,
|
|
status: string,
|
|
extra?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
await prisma.cfExtract.update({
|
|
where: { id },
|
|
data: { status, ...extra },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process a batch of items as ONE ePay order:
|
|
* 1. Check credits (>= N)
|
|
* 2. addToCart + saveMetadata for each
|
|
* 3. ONE submitOrder
|
|
* 4. Poll until complete
|
|
* 5. Download + store ALL documents
|
|
*/
|
|
async function processBatch(
|
|
items: QueueItem[],
|
|
knownOrderIds: Set<string>,
|
|
): Promise<string | null> {
|
|
const extractIds = items.map((i) => i.extractId);
|
|
const count = items.length;
|
|
|
|
try {
|
|
// Get ePay credentials
|
|
const creds = getEpayCredentials();
|
|
if (!creds) {
|
|
for (const id of extractIds) {
|
|
await updateStatus(id, "failed", {
|
|
errorMessage: "Nu ești conectat la ePay.",
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const client = await EpayClient.create(creds.username, creds.password);
|
|
|
|
// Step 1: Check credits (need >= count)
|
|
const credits = await client.getCredits();
|
|
updateEpayCredits(credits);
|
|
if (credits < count) {
|
|
for (const id of extractIds) {
|
|
await updateStatus(id, "failed", {
|
|
errorMessage: `Credite insuficiente: ${credits} disponibile, ${count} necesare.`,
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Step 2: addToCart + saveMetadata for EACH item
|
|
for (const item of items) {
|
|
const { extractId, input } = item;
|
|
|
|
await updateStatus(extractId, "cart");
|
|
const basketRowId = await client.addToCart(input.prodId ?? 14200);
|
|
item.basketRowId = basketRowId;
|
|
await updateStatus(extractId, "cart", { basketRowId });
|
|
|
|
// Resolve county/UAT from SIRUTA if available
|
|
let countyInternalId = input.judetIndex;
|
|
let uatInternalId = input.uatId;
|
|
let countyName = input.judetName;
|
|
let uatName = input.uatName;
|
|
|
|
if (input.siruta) {
|
|
const uat = await prisma.gisUat.findUnique({
|
|
where: { siruta: input.siruta },
|
|
select: { workspacePk: true, county: true, name: true },
|
|
});
|
|
if (uat?.workspacePk) {
|
|
countyInternalId = uat.workspacePk;
|
|
uatInternalId = Number(input.siruta);
|
|
if (uat.county) countyName = uat.county;
|
|
if (uat.name) uatName = uat.name;
|
|
}
|
|
}
|
|
|
|
const nrCF = input.nrCF ?? input.nrCadastral;
|
|
await updateStatus(extractId, "ordering");
|
|
|
|
const saved = await client.saveMetadata(
|
|
basketRowId,
|
|
countyInternalId,
|
|
countyName,
|
|
uatInternalId,
|
|
uatName,
|
|
nrCF,
|
|
input.nrCadastral,
|
|
process.env.ANCPI_DEFAULT_SOLICITANT_ID || "14452",
|
|
);
|
|
|
|
if (!saved) {
|
|
await updateStatus(extractId, "failed", {
|
|
errorMessage: "Salvarea metadatelor în ePay a eșuat.",
|
|
});
|
|
// Continue with remaining items — the cart still has them
|
|
// but this one won't get metadata. Remove from batch.
|
|
item.basketRowId = undefined;
|
|
}
|
|
}
|
|
|
|
// Filter to only items that had successful metadata saves
|
|
const validItems = items.filter((i) => i.basketRowId !== undefined);
|
|
if (validItems.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Step 3: ONE submitOrder for ALL items
|
|
console.log(
|
|
`[epay-queue] Submitting order for ${validItems.length} items...`,
|
|
);
|
|
const orderId = await client.submitOrder(knownOrderIds);
|
|
|
|
// Update all valid items with the shared orderId
|
|
for (const item of validItems) {
|
|
await updateStatus(item.extractId, "polling", { orderId });
|
|
}
|
|
|
|
// Step 4: Poll until complete
|
|
const finalStatus = await client.pollUntilComplete(
|
|
orderId,
|
|
async (attempt, status) => {
|
|
for (const item of validItems) {
|
|
await updateStatus(item.extractId, "polling", {
|
|
epayStatus: status,
|
|
pollAttempts: attempt,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
if (
|
|
finalStatus.status === "Anulata" ||
|
|
finalStatus.status === "Plata refuzata"
|
|
) {
|
|
for (const item of validItems) {
|
|
await updateStatus(item.extractId, "cancelled", {
|
|
epayStatus: finalStatus.status,
|
|
errorMessage: `Comanda ${finalStatus.status.toLowerCase()}.`,
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Step 5: Download ALL documents and match to items
|
|
// The order may contain multiple documents — match by filename/nrCadastral
|
|
const downloadableDocs = finalStatus.documents.filter(
|
|
(d) => d.downloadValabil && d.contentType === "application/pdf",
|
|
);
|
|
|
|
console.log(
|
|
`[epay-queue] Order ${orderId}: ${downloadableDocs.length} documents for ${validItems.length} items`,
|
|
);
|
|
|
|
// Match documents to items by CF number (from documentsByCadastral)
|
|
// This is the CORRECT way — ePay returns docs in its own order, not ours
|
|
|
|
if (downloadableDocs.length === 0) {
|
|
for (const item of validItems) {
|
|
await updateStatus(item.extractId, "failed", {
|
|
epayStatus: finalStatus.status,
|
|
errorMessage: "Nu s-au găsit documente PDF în comanda finalizată.",
|
|
});
|
|
}
|
|
return orderId;
|
|
}
|
|
|
|
for (let i = 0; i < validItems.length; i++) {
|
|
const item = validItems[i]!;
|
|
const nrCF = item.input.nrCF ?? item.input.nrCadastral;
|
|
|
|
// Try CF-based matching first (correct for batch orders)
|
|
let doc = finalStatus.documentsByCadastral.get(nrCF);
|
|
// Also try nrCadastral if different from nrCF
|
|
if (!doc && item.input.nrCadastral !== nrCF) {
|
|
doc = finalStatus.documentsByCadastral.get(item.input.nrCadastral);
|
|
}
|
|
// Last resort: fall back to index matching
|
|
if (!doc) {
|
|
doc = downloadableDocs[i];
|
|
console.warn(
|
|
`[epay-queue] Could not match by CF for ${item.input.nrCadastral}, using index ${i}`,
|
|
);
|
|
}
|
|
|
|
if (!doc) {
|
|
await updateStatus(item.extractId, "failed", {
|
|
epayStatus: finalStatus.status,
|
|
errorMessage: `Document lipsă (${downloadableDocs.length} documente pentru ${validItems.length} parcele).`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await updateStatus(item.extractId, "downloading", {
|
|
idDocument: doc.idDocument,
|
|
documentName: doc.nume,
|
|
documentDate: doc.dataDocument ? new Date(doc.dataDocument) : null,
|
|
});
|
|
|
|
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
|
|
|
|
// Step 6: Store in MinIO
|
|
const { path, index } = await storeCfExtract(
|
|
pdfBuffer,
|
|
item.input.nrCadastral,
|
|
{
|
|
"ancpi-order-id": orderId,
|
|
"nr-cadastral": item.input.nrCadastral,
|
|
judet: item.input.judetName,
|
|
uat: item.input.uatName,
|
|
"data-document": doc.dataDocument ?? "",
|
|
stare: finalStatus.status,
|
|
produs: "EXI_ONLINE",
|
|
},
|
|
);
|
|
|
|
// Complete — require document date from ANCPI for accurate expiry
|
|
if (!doc.dataDocument) {
|
|
console.warn(`[epay-queue] Missing dataDocument for extract ${item.extractId}, using download date`);
|
|
}
|
|
const documentDate = doc.dataDocument
|
|
? new Date(doc.dataDocument)
|
|
: new Date();
|
|
const expiresAt = new Date(documentDate);
|
|
expiresAt.setDate(expiresAt.getDate() + 30);
|
|
|
|
await updateStatus(item.extractId, "completed", {
|
|
minioPath: path,
|
|
minioIndex: index,
|
|
epayStatus: finalStatus.status,
|
|
completedAt: new Date(),
|
|
documentDate,
|
|
expiresAt,
|
|
});
|
|
|
|
console.log(
|
|
`[epay-queue] Completed: ${item.input.nrCadastral} → ${path}`,
|
|
);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Eroare download/stocare";
|
|
await updateStatus(item.extractId, "failed", {
|
|
errorMessage: message,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update credits after successful order
|
|
const newCredits = await client.getCredits();
|
|
updateEpayCredits(newCredits);
|
|
console.log(
|
|
`[epay-queue] Batch complete: orderId=${orderId}, credits remaining=${newCredits}`,
|
|
);
|
|
|
|
return orderId;
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Eroare necunoscută";
|
|
console.error(`[epay-queue] Batch failed:`, message);
|
|
for (const id of extractIds) {
|
|
await updateStatus(id, "failed", { errorMessage: message });
|
|
}
|
|
return null;
|
|
}
|
|
}
|