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 { 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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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ă.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user