harden(epay): cart hygiene, auth/IDOR gates, single-page fetch, parallel downloads

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) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-06-04 23:59:44 +03:00
parent f7f7c59d17
commit f49fdb1da0
13 changed files with 602 additions and 124 deletions
+35 -8
View File
@@ -1,20 +1,31 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma"; import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage"; import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import { requireCfAccess } from "@/core/auth/cf-access";
import JSZip from "jszip"; import JSZip from "jszip";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; 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 * GET /api/ancpi/download-zip?ids=id1,id2,id3
* *
* Streams a ZIP file containing all requested CF extract PDFs. * Streams a ZIP file containing all requested CF extract PDFs.
* Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf * Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
* Index = position in the ids array (preserves list order). * 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) { export async function GET(req: Request) {
try { try {
const access = await requireCfAccess();
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const url = new URL(req.url); const url = new URL(req.url);
const idsParam = url.searchParams.get("ids"); const idsParam = url.searchParams.get("ids");
@@ -32,6 +43,12 @@ export async function GET(req: Request) {
{ status: 400 }, { 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 // Fetch all extract records
const extracts = await prisma.cfExtract.findMany({ 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 today = new Date();
const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`; const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`;
const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`; 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<Uint8Array>({
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: { headers: {
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`, "Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
"Content-Length": String(zipBuffer.length),
}, },
}); });
} catch (error) { } catch (error) {
+7 -1
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma"; import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage"; 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 runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -10,9 +10,15 @@ export const dynamic = "force-dynamic";
* GET /api/ancpi/download?id={extractId} * GET /api/ancpi/download?id={extractId}
* *
* Streams the CF extract PDF from MinIO with proper filename. * 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) { export async function GET(req: Request) {
try { try {
const access = await requireCfAccess();
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const url = new URL(req.url); const url = new URL(req.url);
const id = url.searchParams.get("id"); const id = url.searchParams.get("id");
+39 -7
View File
@@ -5,7 +5,8 @@ import {
enqueueBatch, enqueueBatch,
} from "@/modules/parcel-sync/services/epay-queue"; } from "@/modules/parcel-sync/services/epay-queue";
import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types"; 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 runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -48,6 +49,13 @@ function cleanupNonceMap(): void {
*/ */
export async function POST(req: Request) { export async function POST(req: Request) {
try { 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(); const creds = getEpayCredentials();
if (!creds) { if (!creds) {
return NextResponse.json( 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 // Stamp the orderer's session id on each enqueued row so CfExtract
// carries ownership info (was NULL before — see // carries ownership info (was NULL before — see
// feedback_cfextract_schema_drift.md). Falls back to undefined when // feedback_cfextract_schema_drift.md).
// the route is hit without a session (dev tools / cron). const userId = access.actor.userId;
const session = await getAuthSession();
const userId =
((session?.user as { id?: string } | undefined)?.id ||
session?.user?.email) ?? undefined;
const stampedParcels: CfExtractCreateInput[] = parcels.map((p) => ({ const stampedParcels: CfExtractCreateInput[] = parcels.map((p) => ({
...p, ...p,
userId: p.userId ?? userId, userId: p.userId ?? userId,
+26 -3
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma"; import { prisma } from "@/core/storage/prisma";
import { requireCfAccess } from "@/core/auth/cf-access";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,6 +22,11 @@ export const dynamic = "force-dynamic";
*/ */
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const access = await requireCfAccess();
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const url = new URL(req.url); const url = new URL(req.url);
const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined; const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined;
const status = url.searchParams.get("status") || 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({ const activeRecords = await prisma.cfExtract.findMany({
where: { where: {
nrCadastral: { in: cadastralNumbers }, nrCadastral: { in: cadastralNumbers },
@@ -104,8 +128,7 @@ export async function GET(req: Request) {
}); });
for (const rec of activeRecords) { for (const rec of activeRecords) {
// If there's an active order, mark as "processing" (takes priority over "none") if (statusMap[rec.nrCadastral] !== "valid") {
if (statusMap[rec.nrCadastral] === "none") {
statusMap[rec.nrCadastral] = "processing"; statusMap[rec.nrCadastral] = "processing";
} }
} }
+63 -21
View File
@@ -1,38 +1,79 @@
// GET /api/ancpi/recover?orderId=<id> // GET /api/ancpi/recover?orderId=<id> — recover a whole ANCPI order
// GET /api/ancpi/recover?extractId=<id> — retry one row (resolves its order)
// //
// Recovery for ePay orders that ANCPI processed even though our // Recovery for ePay orders that ANCPI processed/finalized even though our
// EditCartSubmit request timed out: the CfExtract rows sit on // pipeline lost them (EditCartSubmit timed out, or the container restarted
// status "failed" / "timeout..." with no orderId while the credits are // mid-batch): the CfExtract rows sit on a non-terminal/failed status while
// already spent (2026-06-04: order 10009605, 15 extracts, Feleacu). // 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 // GET on purpose — operator action from the browser URL bar / a retry button
// orderId and runs the shared poll → download → MinIO pipeline // with the NextAuth cookie. No new spend, so no CSRF exposure to credits;
// (epay-queue.finalizeOrder via recoverBatch). Requires an active ePay // still staff-gated (C3).
// 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.
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma"; import { prisma } from "@/core/storage/prisma";
import { recoverBatch } from "@/modules/parcel-sync/services/epay-queue"; import { recoverBatch } from "@/modules/parcel-sync/services/epay-queue";
import { requireCfAccess } from "@/core/auth/cf-access";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; 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 // Single-flight: poll+download for a batch takes minutes; a second click
// must not start a parallel run over the same rows. // must not start a parallel run over the same rows.
const g = globalThis as { __epayRecoverRunning?: boolean }; const g = globalThis as { __epayRecoverRunning?: boolean };
export async function GET(req: Request) { 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 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=<id> de pe portalul ePay.",
},
{ status: 409 },
);
}
orderId = row.orderId;
}
if (!/^\d+$/.test(orderId)) { if (!/^\d+$/.test(orderId)) {
return NextResponse.json( return NextResponse.json(
{ error: "orderId lipsă sau invalid. Folosește ?orderId=<id ePay>." }, { error: "orderId lipsă sau invalid. Folosește ?orderId=<id> sau ?extractId=<id>." },
{ status: 400 }, { status: 400 },
); );
} }
@@ -44,18 +85,19 @@ export async function GET(req: Request) {
); );
} }
// Candidate rows: recent paid-flow failures that never got an orderId, // Candidate rows: anything already tagged with this order that isn't
// plus rows already attached to this order by a previous partial run. // 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({ const rows = await prisma.cfExtract.findMany({
where: { where: {
type: "epay", type: "epay",
OR: [ OR: [
{ orderId, status: { notIn: ["completed", "cancelled"] } },
{ {
status: "failed",
orderId: null, orderId: null,
status: { in: RECOVERABLE_STATES },
createdAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) }, createdAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) },
}, },
{ orderId, status: { notIn: ["completed", "cancelled"] } },
], ],
}, },
select: { select: {
@@ -73,7 +115,7 @@ export async function GET(req: Request) {
if (rows.length === 0) { if (rows.length === 0) {
return NextResponse.json({ return NextResponse.json({
recovered: 0, recovered: 0,
message: "Niciun extras failed (ultimele 48h) de recuperat.", message: "Niciun extras de recuperat pentru această comandă.",
}); });
} }
+8
View File
@@ -36,6 +36,14 @@ if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = nul
* Zero discovery calls needed! * Zero discovery calls needed!
*/ */
export async function GET(req: Request) { 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 (<img src=...?step=order>) 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 url = new URL(req.url);
const step = url.searchParams.get("step") ?? "login"; const step = url.searchParams.get("step") ?? "login";
+7
View File
@@ -6,11 +6,18 @@
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma"; import { prisma } from "@/core/storage/prisma";
import { requireCfAccess } from "@/core/auth/cf-access";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(req: Request) { 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 url = new URL(req.url);
const nrCad = url.searchParams.get("nrCad")?.trim(); const nrCad = url.searchParams.get("nrCad")?.trim();
-9
View File
@@ -193,15 +193,6 @@ export const authOptions: NextAuthOptions = {
(session as any).accessToken = token.accessToken; (session as any).accessToken = token.accessToken;
// Surface refresh failure so the client can force a re-login UX. // Surface refresh failure so the client can force a re-login UX.
if (token.error) (session as any).error = token.error; 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 // Faza C cutover flag — exposed on session so client components can
// branch the same way server routes do (env-driven, evaluated per // branch the same way server routes do (env-driven, evaluated per
// request so flag flip + container restart picks up without rebuild). // request so flag flip + container restart picks up without rebuild).
+75
View File
@@ -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<CfAccessResult> {
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",
},
};
}
@@ -114,6 +114,12 @@ function statusBadge(status: string, expiresAt: string | null): StatusStyle {
className: className:
"bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/40 dark:text-rose-400 dark:border-rose-800", "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": case "cancelled":
return { return {
label: "Anulat", label: "Anulat",
@@ -318,6 +324,26 @@ export function EpayTab() {
/* errors surfaced inline via downstream polling later */ /* 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<string | null>(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 ----------------------------------- */ /* -- Download all valid as ZIP ----------------------------------- */
const handleDownloadAll = async () => { const handleDownloadAll = async () => {
const validOrders = filteredOrders.filter( const validOrders = filteredOrders.filter(
@@ -812,6 +838,69 @@ export function EpayTab() {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
{/* Review row — PDF exists but match was ambiguous;
let the operator download + verify (QW3/R4). */}
{order.status === "review" && order.minioPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-amber-700 dark:text-amber-400"
asChild
>
<a
href={cfDownloadUrl(order)}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3 w-3 mr-1" />
Verifica
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
Descarca PDF pentru verificare manuala
(potrivire ambigua)
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Failed-with-order row — retry poll+download, no
new charge (QW3). */}
{(order.status === "failed" ||
order.status === "review") && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={
!epayStatus.connected ||
retryingId === order.id
}
onClick={() =>
void handleRetryDownload(order)
}
>
{retryingId === order.id ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1" />
)}
Reincearca
</Button>
</TooltipTrigger>
<TooltipContent>
Reia descarcarea (fara cost nou) daca comanda
exista la ANCPI
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div> </div>
</td> </td>
</tr> </tr>
+103 -38
View File
@@ -42,9 +42,15 @@ const DEFAULT_TIMEOUT_MS = 60_000;
// timed out, the rows were marked failed, but ANCPI completed the order // timed out, the rows were marked failed, but ANCPI completed the order
// and spent the credits. Submit/confirmation steps get a generous budget. // and spent the credits. Submit/confirmation steps get a generous budget.
const SUBMIT_TIMEOUT_MS = 180_000; 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_INTERVAL_MS = 15_000;
const POLL_MAX_ATTEMPTS = 40; 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 */ /* Session cache */
@@ -65,16 +71,17 @@ const sessionCache =
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>(); globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
globalStore.__epaySessionCache = sessionCache; 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) { if (!globalStore.__epayCleanupTimer) {
globalStore.__epayCleanupTimer = setInterval(() => { globalStore.__epayCleanupTimer = setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [key, entry] of sessionCache.entries()) { for (const [key, entry] of sessionCache.entries()) {
if (now - entry.lastUsed > 9 * 60_000) { if (now - entry.lastUsed > SESSION_TTL_MS) {
sessionCache.delete(key); sessionCache.delete(key);
} }
} }
}, 5 * 60_000); }, 2 * 60_000);
} }
const makeCacheKey = (u: string, p: string) => const makeCacheKey = (u: string, p: string) =>
@@ -195,7 +202,16 @@ export class EpayClient {
/* ── Cart ───────────────────────────────────────────────────── */ /* ── Cart ───────────────────────────────────────────────────── */
async addToCart(prodId = 14200): Promise<number> { /**
* 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(); const body = new URLSearchParams();
body.set("prodId", String(prodId)); body.set("prodId", String(prodId));
body.set("productQtyModif", "1"); body.set("productQtyModif", "1");
@@ -216,12 +232,68 @@ export class EpayClient {
); );
const data = response.data as EpayCartResponse; const data = response.data as EpayCartResponse;
const item = data?.items?.[0]; const items = Array.isArray(data?.items) ? data.items : [];
if (!item?.id) { // 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)}`); throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`);
} }
console.log(`[epay] Added to cart: basketRowId=${item.id}`); const numberOfItems =
return item.id; 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<number> {
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<boolean> {
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<void> {
for (let i = 0; i < basketIds.length; i++) {
await this.deleteCartItem(basketIds[i]!, i);
}
} }
/* ── EpayJsonInterceptor (form-urlencoded) ─────────────────── */ /* ── EpayJsonInterceptor (form-urlencoded) ─────────────────── */
@@ -617,8 +689,14 @@ export class EpayClient {
} }
async getOrderStatus(orderId: string): Promise<EpayOrderStatus> { async getOrderStatus(orderId: string): Promise<EpayOrderStatus> {
// 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( 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 }, { timeout: DEFAULT_TIMEOUT_MS },
); );
const html = String(response.data ?? ""); const html = String(response.data ?? "");
@@ -655,10 +733,9 @@ export class EpayClient {
collect(html); collect(html);
// ShowOrderDetails paginates the requests (5/page, "Total items: N", // Fallback: if ANCPI ignored itemsPerPage and still paginated (5/page,
// page selected via &navDir=<page>; page 1 == no param). Without this, // "Total items: N", &navDir=<page>), walk the remaining pages so we
// a 15-item batch only ever saw its first 5 documents (2026-06-04, // never miss documents (2026-06-04 incident, order 10009605).
// order 10009605).
const totalMatch = html.match(/Total items:\s*(?:<[^>]*>)?\s*(\d+)/i); const totalMatch = html.match(/Total items:\s*(?:<[^>]*>)?\s*(\d+)/i);
const totalItems = totalMatch ? parseInt(totalMatch[1] ?? "0", 10) : 0; const totalItems = totalMatch ? parseInt(totalMatch[1] ?? "0", 10) : 0;
const perPage = Math.max(documents.length, 1); const perPage = Math.max(documents.length, 1);
@@ -719,30 +796,18 @@ export class EpayClient {
if (!data || data.length < 100) { if (!data || data.length < 100) {
throw new Error(`ePay download empty (${data?.length ?? 0} bytes)`); throw new Error(`ePay download empty (${data?.length ?? 0} bytes)`);
} }
console.log(`[epay] Downloaded document ${idDocument}: ${data.length} bytes`); const buf = Buffer.from(data);
return 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<string> {
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<string, string>): Promise<any> {
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;
}
} }
+134 -28
View File
@@ -48,6 +48,9 @@ if (!g.__epayDedupMap) g.__epayDedupMap = new Map();
/** TTL for dedup entries in milliseconds (60 seconds). */ /** TTL for dedup entries in milliseconds (60 seconds). */
const DEDUP_TTL_MS = 60_000; 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. * Build a dedup key from a list of cadastral numbers.
* Sorted and joined so order doesn't matter. * 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<string, unknown>,
): Promise<void> {
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<T>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<void>,
): Promise<void> {
let cursor = 0;
const worker = async (): Promise<void> => {
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: * Process a batch of items as ONE ePay order:
* 1. Check credits (>= N) * 1. Check credits (>= N)
@@ -232,6 +268,13 @@ async function processBatch(
const extractIds = items.map((i) => i.extractId); const extractIds = items.map((i) => i.extractId);
const count = items.length; 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 { try {
// Get ePay credentials // Get ePay credentials
const creds = getEpayCredentials(); const creds = getEpayCredentials();
@@ -245,6 +288,7 @@ async function processBatch(
} }
const client = await EpayClient.create(creds.username, creds.password); const client = await EpayClient.create(creds.username, creds.password);
cleanupClient = client;
// Step 1: Check credits (need >= count) // Step 1: Check credits (need >= count)
const credits = await client.getCredits(); const credits = await client.getCredits();
@@ -258,12 +302,43 @@ async function processBatch(
return null; return null;
} }
// Step 2: addToCart + saveMetadata for EACH item // Step 2: build the cart — one row per item — under the cart-hygiene
for (const item of items) { // 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; const { extractId, input } = item;
await updateStatus(extractId, "cart"); 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; item.basketRowId = basketRowId;
await updateStatus(extractId, "cart", { basketRowId }); await updateStatus(extractId, "cart", { basketRowId });
@@ -304,8 +379,11 @@ async function processBatch(
await updateStatus(extractId, "failed", { await updateStatus(extractId, "failed", {
errorMessage: "Salvarea metadatelor în ePay a eșuat.", errorMessage: "Salvarea metadatelor în ePay a eșuat.",
}); });
// Continue with remaining items — the cart still has them // Remove this metadata-less row from the cart so it can't be
// but this one won't get metadata. Remove from batch. // checked out and charged. Drop it from our tracking + batch.
await client.deleteCartItem(basketRowId, idx);
ourBasketIdsForCleanup.pop();
addedCount--;
item.basketRowId = undefined; item.basketRowId = undefined;
} }
} }
@@ -316,7 +394,11 @@ async function processBatch(
return null; 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( console.log(
`[epay-queue] Submitting order for ${validItems.length} items...`, `[epay-queue] Submitting order for ${validItems.length} items...`,
); );
@@ -328,6 +410,12 @@ async function processBatch(
const message = const message =
error instanceof Error ? error.message : "Eroare necunoscută"; error instanceof Error ? error.message : "Eroare necunoscută";
console.error(`[epay-queue] Batch failed:`, message); 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) { for (const id of extractIds) {
await updateStatus(id, "failed", { errorMessage: message }); await updateStatus(id, "failed", { errorMessage: message });
} }
@@ -348,18 +436,21 @@ async function finalizeOrder(
validItems: QueueItem[], validItems: QueueItem[],
orderId: string, orderId: string,
): Promise<string | null> { ): Promise<string | null> {
const allIds = validItems.map((i) => i.extractId);
try { try {
// Update all valid items with the shared orderId // Attach the shared orderId to every row (one write).
for (const item of validItems) { await updateManyStatus(allIds, "polling", { orderId });
await updateStatus(item.extractId, "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( const finalStatus = await client.pollUntilComplete(
orderId, orderId,
async (attempt, status) => { async (attempt, status) => {
for (const item of validItems) { if (status !== lastWrittenStatus) {
await updateStatus(item.extractId, "polling", { lastWrittenStatus = status;
await updateManyStatus(allIds, "polling", {
epayStatus: status, epayStatus: status,
pollAttempts: attempt, pollAttempts: attempt,
}); });
@@ -371,12 +462,10 @@ async function finalizeOrder(
finalStatus.status === "Anulata" || finalStatus.status === "Anulata" ||
finalStatus.status === "Plata refuzata" finalStatus.status === "Plata refuzata"
) { ) {
for (const item of validItems) { await updateManyStatus(allIds, "cancelled", {
await updateStatus(item.extractId, "cancelled", {
epayStatus: finalStatus.status, epayStatus: finalStatus.status,
errorMessage: `Comanda ${finalStatus.status.toLowerCase()}.`, errorMessage: `Comanda ${finalStatus.status.toLowerCase()}.`,
}); });
}
return null; return null;
} }
@@ -394,30 +483,38 @@ async function finalizeOrder(
// This is the CORRECT way — ePay returns docs in its own order, not ours // This is the CORRECT way — ePay returns docs in its own order, not ours
if (downloadableDocs.length === 0) { if (downloadableDocs.length === 0) {
for (const item of validItems) { await updateManyStatus(allIds, "failed", {
await updateStatus(item.extractId, "failed", {
epayStatus: finalStatus.status, epayStatus: finalStatus.status,
errorMessage: "Nu s-au găsit documente PDF în comanda finalizată.", errorMessage: "Nu s-au găsit documente PDF în comanda finalizată.",
}); });
}
return orderId; 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++) { for (let i = 0; i < validItems.length; i++) {
const item = validItems[i]!; const item = validItems[i]!;
const nrCF = item.input.nrCF ?? item.input.nrCadastral; const nrCF = item.input.nrCF ?? item.input.nrCadastral;
// Try CF-based matching first (correct for batch orders)
let doc = finalStatus.documentsByCadastral.get(nrCF); let doc = finalStatus.documentsByCadastral.get(nrCF);
// Also try nrCadastral if different from nrCF
if (!doc && item.input.nrCadastral !== nrCF) { if (!doc && item.input.nrCadastral !== nrCF) {
doc = finalStatus.documentsByCadastral.get(item.input.nrCadastral); doc = finalStatus.documentsByCadastral.get(item.input.nrCadastral);
} }
// Last resort: fall back to index matching let matchedByIndex = false;
if (!doc) { if (!doc) {
doc = downloadableDocs[i]; doc = downloadableDocs[i];
matchedByIndex = true;
console.warn( 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; 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 { try {
await updateStatus(item.extractId, "downloading", { await updateStatus(item.extractId, "downloading", {
idDocument: doc.idDocument, idDocument: doc.idDocument,
@@ -438,7 +540,6 @@ async function finalizeOrder(
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4); const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
// Step 6: Store in MinIO
const { path, index } = await storeCfExtract( const { path, index } = await storeCfExtract(
pdfBuffer, pdfBuffer,
item.input.nrCadastral, item.input.nrCadastral,
@@ -453,7 +554,6 @@ async function finalizeOrder(
}, },
); );
// Complete — require document date from ANCPI for accurate expiry
if (!doc.dataDocument) { if (!doc.dataDocument) {
console.warn(`[epay-queue] Missing dataDocument for extract ${item.extractId}, using download date`); 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); const expiresAt = new Date(documentDate);
expiresAt.setDate(expiresAt.getDate() + 30); 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, minioPath: path,
minioIndex: index, minioIndex: index,
epayStatus: finalStatus.status, epayStatus: finalStatus.status,
completedAt: new Date(), completedAt: new Date(),
documentDate, documentDate,
expiresAt, expiresAt,
errorMessage: matchedByIndex
? "Verifică manual: potrivire ambiguă document↔parcelă (fallback pe index)."
: null,
}); });
console.log( console.log(
`[epay-queue] Completed: ${item.input.nrCadastral}${path}`, `[epay-queue] ${matchedByIndex ? "Review" : "Completed"}: ${item.input.nrCadastral}${path}`,
); );
} catch (error) { } catch (error) {
const message = const message =
@@ -482,7 +588,7 @@ async function finalizeOrder(
errorMessage: message, errorMessage: message,
}); });
} }
} });
// Update credits after successful order // Update credits after successful order
const newCredits = await client.getCredits(); const newCredits = await client.getCredits();
@@ -29,24 +29,31 @@ export async function ensureAncpiBucket(): Promise<void> {
/** /**
* Get the next file index for a given cadastral number. * 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/<cad>/NN_Extras CF_…`), which
* never matched, so every file silently got index 1. We now list under the
* `parcele/<cad>/` prefix and match the basename, so versioning works.
*/ */
export async function getNextFileIndex( export async function getNextFileIndex(
nrCadastral: string, nrCadastral: string,
): Promise<number> { ): Promise<number> {
await ensureAncpiBucket(); await ensureAncpiBucket();
const prefix = `parcele/${nrCadastral}/`;
const pattern = new RegExp( const pattern = new RegExp(
`^(\\d+)_Extras CF_${nrCadastral.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} -`, `^(\\d+)_Extras CF_${nrCadastral.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} -`,
); );
let maxIndex = 0; let maxIndex = 0;
const stream = minioClient.listObjects(BUCKET, "", true); const stream = minioClient.listObjects(BUCKET, prefix, true);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
stream.on("data", (obj) => { stream.on("data", (obj) => {
if (!obj.name) return; 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) { if (match) {
const idx = parseInt(match[1] ?? "0", 10); const idx = parseInt(match[1] ?? "0", 10);
if (idx > maxIndex) maxIndex = idx; if (idx > maxIndex) maxIndex = idx;