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:
@@ -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<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: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
|
||||
"Content-Length": String(zipBuffer.length),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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=<id> de pe portalul ePay.",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
orderId = row.orderId;
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(orderId)) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -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ă.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (<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 step = url.searchParams.get("step") ?? "login";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
"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<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 ----------------------------------- */
|
||||
const handleDownloadAll = async () => {
|
||||
const validOrders = filteredOrders.filter(
|
||||
@@ -812,6 +838,69 @@ export function EpayTab() {
|
||||
</Tooltip>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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<string, SessionEntry>();
|
||||
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<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();
|
||||
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<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) ─────────────────── */
|
||||
@@ -617,8 +689,14 @@ export class EpayClient {
|
||||
}
|
||||
|
||||
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(
|
||||
`${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>; 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=<page>), 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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:
|
||||
* 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<string | null> {
|
||||
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", {
|
||||
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", {
|
||||
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();
|
||||
|
||||
@@ -29,24 +29,31 @@ export async function ensureAncpiBucket(): Promise<void> {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
nrCadastral: string,
|
||||
): Promise<number> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user