From f49fdb1da0f5e6e46efb99281ea903bb8150f219 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Thu, 4 Jun 2026 23:59:44 +0300 Subject: [PATCH] harden(epay): cart hygiene, auth/IDOR gates, single-page fetch, parallel downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-path hardening from the 2026-06-04 deep-dive (11 confirmed criticals). ArchiTools-only; the legacy queue is still the sole fulfiller. Security: - requireCfAccess() — staff-only, portal accounts blocked, fail-closed in-route on download / download-zip / cf-status / orders (C4 IDOR/PII) and order / recover (C3). order also enforces a daily credit cap (ANCPI_DAILY_CREDIT_CAP, default 200) and stamps userId. - /api/ancpi/test returns 404 in production — it was a GET that spends 2 real credits, CSRF-able (C5). - drop the token-metadata debug blob from the session (QW8). Correctness / robustness: - cart hygiene (C1): build the ePay cart under an invariant — the Nth add must report N items; any excess = pre-existing junk, so we wipe + abort (never submit a cart we didn't fully build). Pre-submit failures clean up our basket rows; post-submit we never touch the cart (recover owns it). metadata-less rows are deleted from the cart. - getOrderStatus fetches the whole order in ONE page (itemsPerPage, QW4); navDir loop kept only as fallback. index-fallback matches are flagged 'review' instead of silently 'completed' with a possibly-wrong PDF (R4). - downloadDocument asserts %PDF magic bytes — a login page returned mid session no longer gets stored as a .pdf (R2). Session reuse TTL aligned under ANCPI's ~10min expiry. - recover accepts ?extractId= and pre-submit states; retry buttons in the ePay tab re-run poll+download with no new charge (QW2/QW3). Performance: - parallel document downloads (V1, concurrency 4); poll writes only on status change via updateMany (QW5); getNextFileIndex scans the cadastral prefix instead of the whole bucket — and actually works now (it was ^-anchoring the full key, so every file got index 1) (V2); download-zip streams instead of buffering the whole archive, capped at 100 (V3). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/ancpi/download-zip/route.ts | 43 ++++- src/app/api/ancpi/download/route.ts | 8 +- src/app/api/ancpi/order/route.ts | 46 ++++- src/app/api/ancpi/orders/route.ts | 29 ++- src/app/api/ancpi/recover/route.ts | 84 ++++++--- src/app/api/ancpi/test/route.ts | 8 + src/app/api/geoportal/cf-status/route.ts | 7 + src/core/auth/auth-options.ts | 9 - src/core/auth/cf-access.ts | 75 ++++++++ .../parcel-sync/components/epay-tab.tsx | 89 +++++++++ .../parcel-sync/services/epay-client.ts | 141 ++++++++++---- .../parcel-sync/services/epay-queue.ts | 174 ++++++++++++++---- .../parcel-sync/services/epay-storage.ts | 13 +- 13 files changed, 602 insertions(+), 124 deletions(-) create mode 100644 src/core/auth/cf-access.ts diff --git a/src/app/api/ancpi/download-zip/route.ts b/src/app/api/ancpi/download-zip/route.ts index bd54544..6de7c12 100644 --- a/src/app/api/ancpi/download-zip/route.ts +++ b/src/app/api/ancpi/download-zip/route.ts @@ -1,20 +1,31 @@ import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage"; +import { requireCfAccess } from "@/core/auth/cf-access"; import JSZip from "jszip"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +// Cap per request so a "Descarcă tot" over hundreds of extracts can't +// balloon the in-memory ZIP buffer (V3). The UI batches above this. +const MAX_ZIP_IDS = 100; + /** * GET /api/ancpi/download-zip?ids=id1,id2,id3 * * Streams a ZIP file containing all requested CF extract PDFs. * Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf * Index = position in the ids array (preserves list order). + * Guarded: PDFs contain owner PII — staff only, no portal accounts (C4). */ export async function GET(req: Request) { try { + const access = await requireCfAccess(); + if (!access.ok) { + return NextResponse.json({ error: access.error }, { status: access.status }); + } + const url = new URL(req.url); const idsParam = url.searchParams.get("ids"); @@ -32,6 +43,12 @@ export async function GET(req: Request) { { status: 400 }, ); } + if (ids.length > MAX_ZIP_IDS) { + return NextResponse.json( + { error: `Prea multe extrase într-o arhivă (max ${MAX_ZIP_IDS}).` }, + { status: 400 }, + ); + } // Fetch all extract records const extracts = await prisma.cfExtract.findMany({ @@ -90,21 +107,31 @@ export async function GET(req: Request) { ); } - const zipBuffer = await zip.generateAsync({ - type: "nodebuffer", - compression: "DEFLATE", - compressionOptions: { level: 6 }, - }); - const today = new Date(); const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`; const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`; - return new Response(new Uint8Array(zipBuffer), { + // Stream the ZIP out instead of materializing the whole archive in + // memory (V3) — pull from JSZip's internal stream into a Web stream, + // and drop Content-Length (unknown until the stream ends). + const nodeStream = zip.generateInternalStream({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 6 }, + }); + const webStream = new ReadableStream({ + start(controller) { + nodeStream.on("data", (chunk: Uint8Array) => controller.enqueue(chunk)); + nodeStream.on("end", () => controller.close()); + nodeStream.on("error", (err: Error) => controller.error(err)); + nodeStream.resume(); + }, + }); + + return new Response(webStream, { headers: { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`, - "Content-Length": String(zipBuffer.length), }, }); } catch (error) { diff --git a/src/app/api/ancpi/download/route.ts b/src/app/api/ancpi/download/route.ts index dad58ba..5425ca1 100644 --- a/src/app/api/ancpi/download/route.ts +++ b/src/app/api/ancpi/download/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage"; -import { Readable } from "stream"; +import { requireCfAccess } from "@/core/auth/cf-access"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -10,9 +10,15 @@ export const dynamic = "force-dynamic"; * GET /api/ancpi/download?id={extractId} * * Streams the CF extract PDF from MinIO with proper filename. + * Guarded: PDFs contain owner PII — staff only, no portal accounts (C4). */ export async function GET(req: Request) { try { + const access = await requireCfAccess(); + if (!access.ok) { + return NextResponse.json({ error: access.error }, { status: access.status }); + } + const url = new URL(req.url); const id = url.searchParams.get("id"); diff --git a/src/app/api/ancpi/order/route.ts b/src/app/api/ancpi/order/route.ts index 326f226..879ab80 100644 --- a/src/app/api/ancpi/order/route.ts +++ b/src/app/api/ancpi/order/route.ts @@ -5,7 +5,8 @@ import { enqueueBatch, } from "@/modules/parcel-sync/services/epay-queue"; import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types"; -import { getAuthSession } from "@/core/auth/require-auth"; +import { requireCfAccess } from "@/core/auth/cf-access"; +import { prisma } from "@/core/storage/prisma"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -48,6 +49,13 @@ function cleanupNonceMap(): void { */ export async function POST(req: Request) { try { + // C3: spending the company's credits requires a staff session — not + // merely a connected ePay session. Portal accounts are rejected. + const access = await requireCfAccess(); + if (!access.ok) { + return NextResponse.json({ error: access.error }, { status: access.status }); + } + const creds = getEpayCredentials(); if (!creds) { return NextResponse.json( @@ -96,14 +104,38 @@ export async function POST(req: Request) { } } + // C3: daily spend guardrail. ePay credits are real money; a runaway + // loop or fat-finger batch shouldn't be able to drain the account. + // Counts credit-consuming rows created today (UTC) and refuses the + // batch if it would push past the cap. Default 200/day, override via + // ANCPI_DAILY_CREDIT_CAP. + const dailyCap = parseInt(process.env.ANCPI_DAILY_CREDIT_CAP ?? "200", 10); + if (dailyCap > 0) { + const startOfDay = new Date(); + startOfDay.setUTCHours(0, 0, 0, 0); + const spentToday = await prisma.cfExtract.count({ + where: { + type: "epay", + createdAt: { gte: startOfDay }, + status: { notIn: ["failed", "cancelled"] }, + }, + }); + if (spentToday + parcels.length > dailyCap) { + return NextResponse.json( + { + error: `Plafon zilnic de credite atins (${spentToday}/${dailyCap}). Comanda de ${parcels.length} depășește limita.`, + spentToday, + dailyCap, + }, + { status: 429 }, + ); + } + } + // Stamp the orderer's session id on each enqueued row so CfExtract // carries ownership info (was NULL before — see - // feedback_cfextract_schema_drift.md). Falls back to undefined when - // the route is hit without a session (dev tools / cron). - const session = await getAuthSession(); - const userId = - ((session?.user as { id?: string } | undefined)?.id || - session?.user?.email) ?? undefined; + // feedback_cfextract_schema_drift.md). + const userId = access.actor.userId; const stampedParcels: CfExtractCreateInput[] = parcels.map((p) => ({ ...p, userId: p.userId ?? userId, diff --git a/src/app/api/ancpi/orders/route.ts b/src/app/api/ancpi/orders/route.ts index 5f4dff9..8730591 100644 --- a/src/app/api/ancpi/orders/route.ts +++ b/src/app/api/ancpi/orders/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; +import { requireCfAccess } from "@/core/auth/cf-access"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -21,6 +22,11 @@ export const dynamic = "force-dynamic"; */ export async function GET(req: Request) { try { + const access = await requireCfAccess(); + if (!access.ok) { + return NextResponse.json({ error: access.error }, { status: access.status }); + } + const url = new URL(req.url); const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined; const status = url.searchParams.get("status") || undefined; @@ -92,7 +98,25 @@ export async function GET(req: Request) { } } - // Also check for active (in-progress) orders + // QW6: surface terminal failure/review so the UI can flag them (a + // cadastral whose latest record failed used to show as "none"). Only + // applies where there's no valid extract — a fresh valid one wins. + const attentionRecords = await prisma.cfExtract.findMany({ + where: { + nrCadastral: { in: cadastralNumbers }, + status: { in: ["failed", "review"] }, + }, + orderBy: { createdAt: "desc" }, + select: { nrCadastral: true, status: true }, + }); + for (const rec of attentionRecords) { + if (statusMap[rec.nrCadastral] === "none") { + statusMap[rec.nrCadastral] = rec.status; // "failed" | "review" + } + } + + // Active (in-progress) orders take priority over none/failed/review/ + // expired — an in-flight re-order should read as "processing". const activeRecords = await prisma.cfExtract.findMany({ where: { nrCadastral: { in: cadastralNumbers }, @@ -104,8 +128,7 @@ export async function GET(req: Request) { }); for (const rec of activeRecords) { - // If there's an active order, mark as "processing" (takes priority over "none") - if (statusMap[rec.nrCadastral] === "none") { + if (statusMap[rec.nrCadastral] !== "valid") { statusMap[rec.nrCadastral] = "processing"; } } diff --git a/src/app/api/ancpi/recover/route.ts b/src/app/api/ancpi/recover/route.ts index 4c56339..1f908e2 100644 --- a/src/app/api/ancpi/recover/route.ts +++ b/src/app/api/ancpi/recover/route.ts @@ -1,38 +1,79 @@ -// GET /api/ancpi/recover?orderId= +// GET /api/ancpi/recover?orderId= — recover a whole ANCPI order +// GET /api/ancpi/recover?extractId= — retry one row (resolves its order) // -// Recovery for ePay orders that ANCPI processed even though our -// EditCartSubmit request timed out: the CfExtract rows sit on -// status "failed" / "timeout..." with no orderId while the credits are -// already spent (2026-06-04: order 10009605, 15 extracts, Feleacu). +// Recovery for ePay orders that ANCPI processed/finalized even though our +// pipeline lost them (EditCartSubmit timed out, or the container restarted +// mid-batch): the CfExtract rows sit on a non-terminal/failed status while +// the credits are ALREADY spent. This re-attaches the order and re-runs the +// shared poll → download → MinIO pipeline (epay-queue.recoverBatch). It does +// NOT place a new order or spend new credits — so it's idempotent and safe +// to re-run. Requires an active ePay session (connect in the ePay tab first). // -// Picks up the recent failed-without-orderId rows, attaches the given -// orderId and runs the shared poll → download → MinIO pipeline -// (epay-queue.finalizeOrder via recoverBatch). Requires an active ePay -// session (connect in the ePay tab first). Idempotent: already-completed -// rows are not selected; re-running after a partial failure only retries -// the still-failed rows. -// -// GET (not POST) on purpose — it's an operator action triggered from the -// browser URL bar with the NextAuth session cookie. +// GET on purpose — operator action from the browser URL bar / a retry button +// with the NextAuth cookie. No new spend, so no CSRF exposure to credits; +// still staff-gated (C3). import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; import { recoverBatch } from "@/modules/parcel-sync/services/epay-queue"; +import { requireCfAccess } from "@/core/auth/cf-access"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +// Pre-submit / in-flight states that a crash can orphan. Widened from the +// original "failed only" so a batch killed mid-flight (orderId still null) +// can be attached to an operator-supplied orderId (C2/QW2). +const RECOVERABLE_STATES = [ + "failed", + "queued", + "cart", + "searching", + "ordering", + "polling", + "downloading", +]; + // Single-flight: poll+download for a batch takes minutes; a second click // must not start a parallel run over the same rows. const g = globalThis as { __epayRecoverRunning?: boolean }; export async function GET(req: Request) { + // Staff only — recovery completes/downloads orders with PII. + const access = await requireCfAccess(); + if (!access.ok) { + return NextResponse.json({ error: access.error }, { status: access.status }); + } + const url = new URL(req.url); - const orderId = url.searchParams.get("orderId")?.trim() ?? ""; + let orderId = url.searchParams.get("orderId")?.trim() ?? ""; + const extractId = url.searchParams.get("extractId")?.trim() ?? ""; + + // QW3: retry-by-row. Resolve the row's orderId server-side; recover the + // whole sibling set that shares it. + if (!orderId && extractId) { + const row = await prisma.cfExtract.findUnique({ + where: { id: extractId }, + select: { orderId: true, status: true }, + }); + if (!row) { + return NextResponse.json({ error: "Extras inexistent." }, { status: 404 }); + } + if (!row.orderId) { + return NextResponse.json( + { + error: + "Extrasul nu are încă un orderId ANCPI — recuperează cu ?orderId= de pe portalul ePay.", + }, + { status: 409 }, + ); + } + orderId = row.orderId; + } if (!/^\d+$/.test(orderId)) { return NextResponse.json( - { error: "orderId lipsă sau invalid. Folosește ?orderId=." }, + { error: "orderId lipsă sau invalid. Folosește ?orderId= sau ?extractId=." }, { status: 400 }, ); } @@ -44,18 +85,19 @@ export async function GET(req: Request) { ); } - // Candidate rows: recent paid-flow failures that never got an orderId, - // plus rows already attached to this order by a previous partial run. + // Candidate rows: anything already tagged with this order that isn't + // terminal, PLUS recent orphaned rows (orderId:null) in a recoverable + // state — the operator asserts they belong to this order. const rows = await prisma.cfExtract.findMany({ where: { type: "epay", OR: [ + { orderId, status: { notIn: ["completed", "cancelled"] } }, { - status: "failed", orderId: null, + status: { in: RECOVERABLE_STATES }, createdAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) }, }, - { orderId, status: { notIn: ["completed", "cancelled"] } }, ], }, select: { @@ -73,7 +115,7 @@ export async function GET(req: Request) { if (rows.length === 0) { return NextResponse.json({ recovered: 0, - message: "Niciun extras failed (ultimele 48h) de recuperat.", + message: "Niciun extras de recuperat pentru această comandă.", }); } diff --git a/src/app/api/ancpi/test/route.ts b/src/app/api/ancpi/test/route.ts index 87476f3..4838316 100644 --- a/src/app/api/ancpi/test/route.ts +++ b/src/app/api/ancpi/test/route.ts @@ -36,6 +36,14 @@ if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = nul * Zero discovery calls needed! */ export async function GET(req: Request) { + // SECURITY (C5): this is a GET with real side-effects — step=order spends + // 2 ePay credits on hardcoded parcels. A GET endpoint that spends money is + // CSRF-able () from any page an authenticated + // operator visits. Disabled entirely outside development. + if (process.env.NODE_ENV === "production") { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + const url = new URL(req.url); const step = url.searchParams.get("step") ?? "login"; diff --git a/src/app/api/geoportal/cf-status/route.ts b/src/app/api/geoportal/cf-status/route.ts index 97fe689..e6f590b 100644 --- a/src/app/api/geoportal/cf-status/route.ts +++ b/src/app/api/geoportal/cf-status/route.ts @@ -6,11 +6,18 @@ */ import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; +import { requireCfAccess } from "@/core/auth/cf-access"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(req: Request) { + // Guarded: returns extract id + download URL — staff only (C4). + const access = await requireCfAccess(); + if (!access.ok) { + return NextResponse.json({ error: access.error }, { status: access.status }); + } + const url = new URL(req.url); const nrCad = url.searchParams.get("nrCad")?.trim(); diff --git a/src/core/auth/auth-options.ts b/src/core/auth/auth-options.ts index ec92cca..8e40005 100644 --- a/src/core/auth/auth-options.ts +++ b/src/core/auth/auth-options.ts @@ -193,15 +193,6 @@ export const authOptions: NextAuthOptions = { (session as any).accessToken = token.accessToken; // Surface refresh failure so the client can force a re-login UX. if (token.error) (session as any).error = token.error; - // Temporary diagnostic — confirm token state in session. - (session as any).debug = { - hasRefreshToken: !!token.refreshToken, - accessTokenExpiresIn: - typeof token.accessTokenExpires === "number" - ? Math.round((token.accessTokenExpires - Date.now()) / 1000) - : null, - tokenError: token.error ?? null, - }; // Faza C cutover flag — exposed on session so client components can // branch the same way server routes do (env-driven, evaluated per // request so flag flip + container restart picks up without rebuild). diff --git a/src/core/auth/cf-access.ts b/src/core/auth/cf-access.ts new file mode 100644 index 0000000..8acdbfd --- /dev/null +++ b/src/core/auth/cf-access.ts @@ -0,0 +1,75 @@ +// Shared authorization for the ePay / CF-extract surface. +// +// Why this exists (deep-dive 2026-06-04, criticals C3 + C4): +// - The order/recover routes only checked that an ePay session existed, not +// WHO was calling — any authenticated user (incl. portal-only) could spend +// the company's credits. +// - The download/list routes returned ANY user's PDFs (which contain owner +// names + addresses = PII). `CfExtract.userId` exists but was never used. +// +// Model for the CURRENT internal tool: the 3 companies' staff legitimately +// SHARE extracts (a CF extract is a company asset), so we do NOT clamp reads +// per-user. We instead fail-closed in-route (defense-in-depth — middleware +// has a `NODE_ENV==='development'` bypass) and block portal-only accounts +// from the CF surface entirely. Per-tenant RLS isolation lives in gis-api +// for the future multi-tenant path; this guard is the internal-tool fix. + +import { getAuthSession } from "./require-auth"; + +export type CfActor = { + userId: string; + email: string; + role: string; + company: string; +}; + +export type CfAccessResult = + | { ok: true; actor: CfActor } + | { ok: false; status: 401 | 403; error: string }; + +/** Portal-only accounts (e.g. the eTerra pool owner) — mirrors middleware. */ +function isPortalOnly(email: string, name: string): boolean { + const portalUsers = (process.env.PORTAL_ONLY_USERS ?? "dtiurbe,d.tiurbe") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + const e = email.toLowerCase(); + const n = name.toLowerCase(); + return portalUsers.some((u) => e.includes(u) || n.includes(u)); +} + +/** + * Resolve + authorize the caller for any CF/ePay route. Fail-closed: + * returns a 401/403 result that the route forwards verbatim, or an actor + * on success. Use for BOTH reads (PII) and writes (spending). + */ +export async function requireCfAccess(): Promise { + const session = await getAuthSession(); + if (!session?.user) { + return { ok: false, status: 401, error: "Autentificare necesară." }; + } + const user = session.user as { + id?: string; + email?: string | null; + name?: string | null; + role?: string; + company?: string; + }; + const email = user.email ?? ""; + if (isPortalOnly(email, user.name ?? "")) { + return { + ok: false, + status: 403, + error: "Cont fără acces la extrasele CF.", + }; + } + return { + ok: true, + actor: { + userId: user.id ?? email, + email, + role: user.role ?? "user", + company: user.company ?? "group", + }, + }; +} diff --git a/src/modules/parcel-sync/components/epay-tab.tsx b/src/modules/parcel-sync/components/epay-tab.tsx index ccbe28f..d2fb970 100644 --- a/src/modules/parcel-sync/components/epay-tab.tsx +++ b/src/modules/parcel-sync/components/epay-tab.tsx @@ -114,6 +114,12 @@ function statusBadge(status: string, expiresAt: string | null): StatusStyle { className: "bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/40 dark:text-rose-400 dark:border-rose-800", }; + case "review": + return { + label: "De verificat", + className: + "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-800", + }; case "cancelled": return { label: "Anulat", @@ -318,6 +324,26 @@ export function EpayTab() { /* errors surfaced inline via downstream polling later */ }; + /* -- Retry download (QW3) — re-runs poll+download for an already-paid + * order, no new charge. For rows that failed at the download/poll + * stage (the order exists at ANCPI but we never stored the PDF). -- */ + const [retryingId, setRetryingId] = useState(null); + const handleRetryDownload = async (order: CfExtractRecord) => { + setRetryingId(order.id); + try { + const res = await fetch( + `/api/ancpi/recover?extractId=${encodeURIComponent(order.id)}`, + ); + // 409 → the row has no orderId yet (never reached ANCPI); nothing to + // recover by row. Other errors surface on the next refresh. + await res.json().catch(() => ({})); + void fetchOrders(true); + void fetchEpayStatus(); + } finally { + setRetryingId(null); + } + }; + /* -- Download all valid as ZIP ----------------------------------- */ const handleDownloadAll = async () => { const validOrders = filteredOrders.filter( @@ -812,6 +838,69 @@ export function EpayTab() { )} + {/* Review row — PDF exists but match was ambiguous; + let the operator download + verify (QW3/R4). */} + {order.status === "review" && order.minioPath && ( + + + + + + + Descarca PDF pentru verificare manuala + (potrivire ambigua) + + + + )} + {/* Failed-with-order row — retry poll+download, no + new charge (QW3). */} + {(order.status === "failed" || + order.status === "review") && ( + + + + + + + Reia descarcarea (fara cost nou) daca comanda + exista la ANCPI + + + + )} diff --git a/src/modules/parcel-sync/services/epay-client.ts b/src/modules/parcel-sync/services/epay-client.ts index cd010df..3cf98cb 100644 --- a/src/modules/parcel-sync/services/epay-client.ts +++ b/src/modules/parcel-sync/services/epay-client.ts @@ -42,9 +42,15 @@ const DEFAULT_TIMEOUT_MS = 60_000; // timed out, the rows were marked failed, but ANCPI completed the order // and spent the credits. Submit/confirmation steps get a generous budget. const SUBMIT_TIMEOUT_MS = 180_000; -const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour +// ANCPI OpenAM sessions expire ~10 min server-side. Keep BOTH the reuse +// window and the cleanup sweep safely under that so we never reuse a +// server-dead session (R2 — they used to disagree: 1h reuse vs 9min sweep). +const SESSION_TTL_MS = 8 * 60 * 1000; // 8 minutes const POLL_INTERVAL_MS = 15_000; const POLL_MAX_ATTEMPTS = 40; +// ShowOrderDetails page size — large enough to fetch any realistic batch in +// one request (see getOrderStatus / QW4). +const ORDER_PAGE_SIZE = 50; /* ------------------------------------------------------------------ */ /* Session cache */ @@ -65,16 +71,17 @@ const sessionCache = globalStore.__epaySessionCache ?? new Map(); globalStore.__epaySessionCache = sessionCache; -// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL) +// Periodic cleanup of idle sessions — same TTL as the reuse window so a +// session is never both "too old to reuse" and "still cached". if (!globalStore.__epayCleanupTimer) { globalStore.__epayCleanupTimer = setInterval(() => { const now = Date.now(); for (const [key, entry] of sessionCache.entries()) { - if (now - entry.lastUsed > 9 * 60_000) { + if (now - entry.lastUsed > SESSION_TTL_MS) { sessionCache.delete(key); } } - }, 5 * 60_000); + }, 2 * 60_000); } const makeCacheKey = (u: string, p: string) => @@ -195,7 +202,16 @@ export class EpayClient { /* ── Cart ───────────────────────────────────────────────────── */ - async addToCart(prodId = 14200): Promise { + /** + * Add one product to the cart. Returns the new basketRowId PLUS the + * server-reported cart size after the add and the full item-id list — + * the queue uses `numberOfItems` to assert the cart is exclusively ours + * (cart-hygiene invariant, C1): on a clean cart the first add yields + * numberOfItems === 1; anything else means pre-existing junk to abort on. + */ + async addToCartDetailed( + prodId = 14200, + ): Promise<{ basketRowId: number; numberOfItems: number; itemIds: number[] }> { const body = new URLSearchParams(); body.set("prodId", String(prodId)); body.set("productQtyModif", "1"); @@ -216,12 +232,68 @@ export class EpayClient { ); const data = response.data as EpayCartResponse; - const item = data?.items?.[0]; - if (!item?.id) { + const items = Array.isArray(data?.items) ? data.items : []; + // The freshly added row is the one we didn't know about; ePay returns + // the full cart in `items`, newest typically last. Be defensive. + const added = items[items.length - 1] ?? items[0]; + if (!added?.id) { throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`); } - console.log(`[epay] Added to cart: basketRowId=${item.id}`); - return item.id; + const numberOfItems = + typeof data?.numberOfItems === "number" ? data.numberOfItems : items.length; + console.log( + `[epay] Added to cart: basketRowId=${added.id} (cart now ${numberOfItems})`, + ); + return { + basketRowId: added.id, + numberOfItems, + itemIds: items.map((i) => i.id).filter((n): n is number => typeof n === "number"), + }; + } + + /** Back-compat thin wrapper — returns only the basketRowId. */ + async addToCart(prodId = 14200): Promise { + return (await this.addToCartDetailed(prodId)).basketRowId; + } + + /** + * Delete one cart row. Mirrors ePay's client-side deleteBasketItem: + * POST EditCartItemJson.action {bid, index}. Best-effort — never throws; + * returns whether ANCPI accepted it. Used to clean up our own orphaned + * basket rows after a pre-submit failure (C1). + */ + async deleteCartItem(basketId: number, index = 0): Promise { + try { + const body = new URLSearchParams(); + body.set("bid", String(basketId)); + body.set("index", String(index)); + const res = await this.client.post( + `${BASE_URL}/EditCartItemJson.action`, + body.toString(), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Requested-With": "XMLHttpRequest", + }, + timeout: DEFAULT_TIMEOUT_MS, + validateStatus: () => true, + }, + ); + const ok = res.status >= 200 && res.status < 300; + console.log(`[epay] deleteCartItem bid=${basketId}: ${ok ? "ok" : res.status}`); + return ok; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[epay] deleteCartItem bid=${basketId} error: ${msg}`); + return false; + } + } + + /** Best-effort delete of a known set of basket rows (failure cleanup). */ + async deleteCartItems(basketIds: number[]): Promise { + for (let i = 0; i < basketIds.length; i++) { + await this.deleteCartItem(basketIds[i]!, i); + } } /* ── EpayJsonInterceptor (form-urlencoded) ─────────────────── */ @@ -617,8 +689,14 @@ export class EpayClient { } async getOrderStatus(orderId: string): Promise { + // QW4: ask for the whole order in ONE page. ShowOrderDetails honors + // `itemsPerPage` (ePay's own "Items / pagina" control, page0.html:3128); + // ORDER_PAGE_SIZE covers any realistic batch so the per-page CF↔doc zip + // is computed over the complete set in document order — no navDir loop, + // no cross-page positional mismatch. The loop below stays only as a + // defensive fallback if ANCPI ever caps the page size server-side. const response = await this.client.get( - `${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}`, + `${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}&navDir=1&itemsPerPage=${ORDER_PAGE_SIZE}`, { timeout: DEFAULT_TIMEOUT_MS }, ); const html = String(response.data ?? ""); @@ -655,10 +733,9 @@ export class EpayClient { collect(html); - // ShowOrderDetails paginates the requests (5/page, "Total items: N", - // page selected via &navDir=; page 1 == no param). Without this, - // a 15-item batch only ever saw its first 5 documents (2026-06-04, - // order 10009605). + // Fallback: if ANCPI ignored itemsPerPage and still paginated (5/page, + // "Total items: N", &navDir=), walk the remaining pages so we + // never miss documents (2026-06-04 incident, order 10009605). const totalMatch = html.match(/Total items:\s*(?:<[^>]*>)?\s*(\d+)/i); const totalItems = totalMatch ? parseInt(totalMatch[1] ?? "0", 10) : 0; const perPage = Math.max(documents.length, 1); @@ -719,30 +796,18 @@ export class EpayClient { if (!data || data.length < 100) { throw new Error(`ePay download empty (${data?.length ?? 0} bytes)`); } - console.log(`[epay] Downloaded document ${idDocument}: ${data.length} bytes`); - return Buffer.from(data); + const buf = Buffer.from(data); + // R2: if the ePay session expired mid-batch, DownloadFile returns the + // login/error HTML page (200 OK) instead of the PDF. Storing that as a + // ".pdf" silently corrupts the extract. Assert the PDF magic bytes. + if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") { + const head = buf.subarray(0, 64).toString("latin1"); + throw new Error( + `ePay download not a PDF (idDocument=${idDocument}, ${buf.length} bytes, head="${head.replace(/\s+/g, " ").slice(0, 40)}") — session may have expired`, + ); + } + console.log(`[epay] Downloaded document ${idDocument}: ${buf.length} bytes`); + return buf; } - /* ── Utility ───────────────────────────────────────────────── */ - - async getRawHtml(url: string): Promise { - const response = await this.client.get(url, { - timeout: DEFAULT_TIMEOUT_MS, - validateStatus: () => true, - }); - return String(response.data ?? ""); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async postRaw(url: string, body: string, extraHeaders?: Record): Promise { - const response = await this.client.post(url, body, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - ...extraHeaders, - }, - timeout: DEFAULT_TIMEOUT_MS, - validateStatus: () => true, - }); - return response.data; - } } diff --git a/src/modules/parcel-sync/services/epay-queue.ts b/src/modules/parcel-sync/services/epay-queue.ts index 2145596..67b02c6 100644 --- a/src/modules/parcel-sync/services/epay-queue.ts +++ b/src/modules/parcel-sync/services/epay-queue.ts @@ -48,6 +48,9 @@ if (!g.__epayDedupMap) g.__epayDedupMap = new Map(); /** TTL for dedup entries in milliseconds (60 seconds). */ const DEDUP_TTL_MS = 60_000; +/** Parallel document downloads per order (V1). ANCPI tolerates a few. */ +const DOWNLOAD_CONCURRENCY = 4; + /** * Build a dedup key from a list of cadastral numbers. * Sorted and joined so order doesn't matter. @@ -217,6 +220,39 @@ async function updateStatus( }); } +/** Update many rows in one statement (QW5 — collapses N poll writes to 1). */ +async function updateManyStatus( + ids: string[], + status: string, + extra?: Record, +): Promise { + if (ids.length === 0) return; + await prisma.cfExtract.updateMany({ + where: { id: { in: ids } }, + data: { status, ...extra }, + }); +} + +/** Run async tasks with a bounded concurrency (V1 — parallel downloads). */ +async function runWithConcurrency( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + let cursor = 0; + const worker = async (): Promise => { + while (cursor < items.length) { + const idx = cursor++; + await fn(items[idx]!, idx); + } + }; + const workers = Array.from( + { length: Math.min(limit, items.length) }, + () => worker(), + ); + await Promise.all(workers); +} + /** * Process a batch of items as ONE ePay order: * 1. Check credits (>= N) @@ -232,6 +268,13 @@ async function processBatch( const extractIds = items.map((i) => i.extractId); const count = items.length; + // Hoisted for the catch block: whether we've reached submit (after which + // the cart must NOT be cleaned up), and the client + basket ids needed to + // clean up a pre-submit failure. + let submitted = false; + let cleanupClient: EpayClient | null = null; + const ourBasketIdsForCleanup: number[] = []; + try { // Get ePay credentials const creds = getEpayCredentials(); @@ -245,6 +288,7 @@ async function processBatch( } const client = await EpayClient.create(creds.username, creds.password); + cleanupClient = client; // Step 1: Check credits (need >= count) const credits = await client.getCredits(); @@ -258,12 +302,43 @@ async function processBatch( return null; } - // Step 2: addToCart + saveMetadata for EACH item - for (const item of items) { + // Step 2: build the cart — one row per item — under the cart-hygiene + // invariant that the cart contains ONLY our rows (C1). ePay has a single + // global cart per account; submitOrder checks out EVERYTHING in it, so a + // leftover row from a previously-crashed batch would be paid for and + // attach the wrong PDF. We track our own basket ids for cleanup, and + // bail the moment ANCPI reports more rows than we put in. + let addedCount = 0; + for (let idx = 0; idx < items.length; idx++) { + const item = items[idx]!; const { extractId, input } = item; await updateStatus(extractId, "cart"); - const basketRowId = await client.addToCart(input.prodId ?? 14200); + const { basketRowId, numberOfItems, itemIds } = + await client.addToCartDetailed(input.prodId ?? 14200); + + // After N successful adds a clean cart reports exactly N items. More + // than that = pre-existing junk (orphans from a crash). Never submit a + // cart we didn't fully build: wipe everything ANCPI listed and abort — + // the next retry starts clean. No charge happens (we never submit). + if (numberOfItems > addedCount + 1) { + console.error( + `[epay-queue] Dirty cart: expected ${addedCount + 1} rows, ANCPI reports ${numberOfItems}. Wiping + aborting batch.`, + ); + const toWipe = itemIds.length + ? itemIds + : [...ourBasketIdsForCleanup, basketRowId]; + await client.deleteCartItems(toWipe); + for (const id of extractIds) { + await updateStatus(id, "failed", { + errorMessage: `Coș ePay murdar (${numberOfItems} articole pre-existente) — curățat automat. Reîncearcă comanda.`, + }); + } + return null; + } + + ourBasketIdsForCleanup.push(basketRowId); + addedCount++; item.basketRowId = basketRowId; await updateStatus(extractId, "cart", { basketRowId }); @@ -304,8 +379,11 @@ async function processBatch( 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. + // Remove this metadata-less row from the cart so it can't be + // checked out and charged. Drop it from our tracking + batch. + await client.deleteCartItem(basketRowId, idx); + ourBasketIdsForCleanup.pop(); + addedCount--; item.basketRowId = undefined; } } @@ -316,7 +394,11 @@ async function processBatch( return null; } - // Step 3: ONE submitOrder for ALL items + // Step 3: ONE submitOrder for ALL items. Past this point the cart may be + // consumed by ANCPI even if our request errors (2026-06-04 incident), so + // we must NOT clean up the cart on failure — finalizeOrder/recover owns + // resolving the order. `submitted` gates that. + submitted = true; console.log( `[epay-queue] Submitting order for ${validItems.length} items...`, ); @@ -328,6 +410,12 @@ async function processBatch( const message = error instanceof Error ? error.message : "Eroare necunoscută"; console.error(`[epay-queue] Batch failed:`, message); + // Pre-submit failure → our rows are still sitting in the cart; clean + // them so they don't contaminate the next batch. Post-submit we leave + // the cart alone (the order may exist server-side — recover handles it). + if (!submitted && cleanupClient && ourBasketIdsForCleanup.length) { + await cleanupClient.deleteCartItems(ourBasketIdsForCleanup); + } for (const id of extractIds) { await updateStatus(id, "failed", { errorMessage: message }); } @@ -348,18 +436,21 @@ async function finalizeOrder( validItems: QueueItem[], orderId: string, ): Promise { + const allIds = validItems.map((i) => i.extractId); try { - // Update all valid items with the shared orderId - for (const item of validItems) { - await updateStatus(item.extractId, "polling", { orderId }); - } + // Attach the shared orderId to every row (one write). + await updateManyStatus(allIds, "polling", { orderId }); - // Step 4: Poll until complete + // Step 4: Poll until complete. QW5 — only write to the DB when the ePay + // status actually changes, and do it in a single updateMany, instead of + // N writes per poll attempt (was ~N×40 redundant UPDATEs per batch). + let lastWrittenStatus = ""; const finalStatus = await client.pollUntilComplete( orderId, async (attempt, status) => { - for (const item of validItems) { - await updateStatus(item.extractId, "polling", { + if (status !== lastWrittenStatus) { + lastWrittenStatus = status; + await updateManyStatus(allIds, "polling", { epayStatus: status, pollAttempts: attempt, }); @@ -371,12 +462,10 @@ async function finalizeOrder( 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()}.`, - }); - } + await updateManyStatus(allIds, "cancelled", { + epayStatus: finalStatus.status, + errorMessage: `Comanda ${finalStatus.status.toLowerCase()}.`, + }); return null; } @@ -394,30 +483,38 @@ async function finalizeOrder( // 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ă.", - }); - } + await updateManyStatus(allIds, "failed", { + epayStatus: finalStatus.status, + errorMessage: "Nu s-au găsit documente PDF în comanda finalizată.", + }); return orderId; } + // Match each item to its document (cheap, sequential) BEFORE downloading, + // so we can then download in parallel. CF-number matching is authoritative; + // index fallback is a last resort that can attach the WRONG PDF — so any + // row resolved only by index is flagged for manual review (R4), never + // silently trusted as a valid extract. + type Plan = { + item: QueueItem; + doc: (typeof downloadableDocs)[number]; + matchedByIndex: boolean; + }; + const plans: Plan[] = []; 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 + let matchedByIndex = false; if (!doc) { doc = downloadableDocs[i]; + matchedByIndex = true; console.warn( - `[epay-queue] Could not match by CF for ${item.input.nrCadastral}, using index ${i}`, + `[epay-queue] Could not match by CF for ${item.input.nrCadastral}, falling back to index ${i} (will flag for review)`, ); } @@ -428,7 +525,12 @@ async function finalizeOrder( }); continue; } + plans.push({ item, doc, matchedByIndex }); + } + // Step 6: download + store in parallel (bounded). Each task is fully + // self-contained so a failure on one row doesn't abort the others. + await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async ({ item, doc, matchedByIndex }) => { try { await updateStatus(item.extractId, "downloading", { idDocument: doc.idDocument, @@ -438,7 +540,6 @@ async function finalizeOrder( const pdfBuffer = await client.downloadDocument(doc.idDocument, 4); - // Step 6: Store in MinIO const { path, index } = await storeCfExtract( pdfBuffer, item.input.nrCadastral, @@ -453,7 +554,6 @@ async function finalizeOrder( }, ); - // 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`); } @@ -463,17 +563,23 @@ async function finalizeOrder( const expiresAt = new Date(documentDate); expiresAt.setDate(expiresAt.getDate() + 30); - await updateStatus(item.extractId, "completed", { + // R4: a row matched only by index keeps its (downloaded) PDF for the + // operator to verify, but is NOT marked completed/valid — the PDF + // could belong to another parcel. Status "review" + a clear note. + await updateStatus(item.extractId, matchedByIndex ? "review" : "completed", { minioPath: path, minioIndex: index, epayStatus: finalStatus.status, completedAt: new Date(), documentDate, expiresAt, + errorMessage: matchedByIndex + ? "Verifică manual: potrivire ambiguă document↔parcelă (fallback pe index)." + : null, }); console.log( - `[epay-queue] Completed: ${item.input.nrCadastral} → ${path}`, + `[epay-queue] ${matchedByIndex ? "Review" : "Completed"}: ${item.input.nrCadastral} → ${path}`, ); } catch (error) { const message = @@ -482,7 +588,7 @@ async function finalizeOrder( errorMessage: message, }); } - } + }); // Update credits after successful order const newCredits = await client.getCredits(); diff --git a/src/modules/parcel-sync/services/epay-storage.ts b/src/modules/parcel-sync/services/epay-storage.ts index 19159f6..2225969 100644 --- a/src/modules/parcel-sync/services/epay-storage.ts +++ b/src/modules/parcel-sync/services/epay-storage.ts @@ -29,24 +29,31 @@ export async function ensureAncpiBucket(): Promise { /** * Get the next file index for a given cadastral number. - * Scans existing objects to find the highest index. + * + * V2: scan only this cadastral's prefix, not the whole bucket — the old + * full-bucket scan was both O(all extracts) AND broken: it `^`-anchored the + * index pattern against the FULL key (`parcele//NN_Extras CF_…`), which + * never matched, so every file silently got index 1. We now list under the + * `parcele//` prefix and match the basename, so versioning works. */ export async function getNextFileIndex( nrCadastral: string, ): Promise { await ensureAncpiBucket(); + const prefix = `parcele/${nrCadastral}/`; const pattern = new RegExp( `^(\\d+)_Extras CF_${nrCadastral.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} -`, ); let maxIndex = 0; - const stream = minioClient.listObjects(BUCKET, "", true); + const stream = minioClient.listObjects(BUCKET, prefix, true); return new Promise((resolve, reject) => { stream.on("data", (obj) => { if (!obj.name) return; - const match = obj.name.match(pattern); + const basename = obj.name.split("/").pop() ?? obj.name; + const match = basename.match(pattern); if (match) { const idx = parseInt(match[1] ?? "0", 10); if (idx > maxIndex) maxIndex = idx;