Compare commits
7 Commits
b62132ab9e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aa246c2d91 | |||
| 9b66dd6452 | |||
| ffad5bb96d | |||
| 50165d2369 | |||
| c9f1219eaa | |||
| 1c8d7ea59c | |||
| 5ad8870dc5 |
@@ -0,0 +1,64 @@
|
||||
// GET /api/ancpi/cleanup?dryRun=1 — preview what would be deleted
|
||||
// POST /api/ancpi/cleanup — run the cleanup now
|
||||
//
|
||||
// On-demand control over the 45-day ePay extract auto-cleanup (the scheduler
|
||||
// in epay-cleanup.ts runs it automatically on boot + every 24h). Useful to
|
||||
// preview (dryRun) before trusting the automatic run, or to trigger it now.
|
||||
//
|
||||
// Auth: a staff session (requireCfAccess), OR a cron Bearer token
|
||||
// (EPAY_CLEANUP_CRON_SECRET / NOTIFICATION_CRON_SECRET) so an external
|
||||
// scheduler can call it. ?days overrides the retention window.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
cleanupExpiredEpayExtracts,
|
||||
EPAY_RETENTION_DAYS,
|
||||
} from "@/modules/parcel-sync/services/epay-cleanup";
|
||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function cronAuthorized(req: Request): boolean {
|
||||
const secret =
|
||||
process.env.EPAY_CLEANUP_CRON_SECRET ?? process.env.NOTIFICATION_CRON_SECRET;
|
||||
if (!secret) return false;
|
||||
const auth = req.headers.get("authorization") ?? "";
|
||||
return auth === `Bearer ${secret}`;
|
||||
}
|
||||
|
||||
async function authorize(req: Request): Promise<boolean> {
|
||||
if (cronAuthorized(req)) return true;
|
||||
const access = await requireCfAccess();
|
||||
return access.ok;
|
||||
}
|
||||
|
||||
function parseDays(req: Request): number {
|
||||
const raw = new URL(req.url).searchParams.get("days");
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : EPAY_RETENTION_DAYS;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
if (!(await authorize(req))) {
|
||||
return NextResponse.json({ error: "Neautorizat." }, { status: 401 });
|
||||
}
|
||||
// GET is always a dry-run (no side effects) — safe to preview from a browser.
|
||||
const result = await cleanupExpiredEpayExtracts({
|
||||
olderThanDays: parseDays(req),
|
||||
dryRun: true,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!(await authorize(req))) {
|
||||
return NextResponse.json({ error: "Neautorizat." }, { status: 401 });
|
||||
}
|
||||
const dryRun = new URL(req.url).searchParams.get("dryRun") === "1";
|
||||
const result = await cleanupExpiredEpayExtracts({
|
||||
olderThanDays: parseDays(req),
|
||||
dryRun,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||
import { enrichCfLocations } from "@/modules/parcel-sync/services/cf-enrich-location";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -57,6 +58,10 @@ export async function GET(req: Request) {
|
||||
prisma.cfExtract.count({ where }),
|
||||
]);
|
||||
|
||||
// Fill missing uatName/judetName from SIRUTA (old intern rows stored an
|
||||
// empty judetName) so the list shows localitate + judet for them too.
|
||||
await enrichCfLocations(orders);
|
||||
|
||||
// Build statusMap for multi-cadastral queries (or single if requested)
|
||||
if (cadastralNumbers.length > 0) {
|
||||
const now = new Date();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession } from "@/core/auth/require-auth";
|
||||
import { gisApi, GisApiError } from "@/lib/gis-api-client";
|
||||
import { enrichCfLocations } from "@/modules/parcel-sync/services/cf-enrich-location";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -30,9 +31,14 @@ export async function GET(request: Request) {
|
||||
const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0;
|
||||
|
||||
try {
|
||||
return NextResponse.json(
|
||||
await gisApi.enrichment.cf.list({ limit, offset, status }),
|
||||
);
|
||||
const data = await gisApi.enrichment.cf.list({ limit, offset, status });
|
||||
// gis-api returns uatName + siruta but judetName is null there — fill the
|
||||
// county (and any missing UAT name) from the local GisUat table so the UI
|
||||
// can show "localitate, jud. X" on intern rows too.
|
||||
if (Array.isArray(data?.rows)) {
|
||||
await enrichCfLocations(data.rows);
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
} catch (err) {
|
||||
if (err instanceof GisApiError) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -8,5 +8,9 @@ export async function register() {
|
||||
// ParcelSync auto-refresh scheduler DISABLED during GIS DB overhaul.
|
||||
// Re-enable by uncommenting the import below once the new schema is stable.
|
||||
// await import("@/modules/parcel-sync/services/auto-refresh-scheduler");
|
||||
|
||||
// ePay CF extract auto-cleanup (deletes rows + MinIO objects 45 days
|
||||
// after issuance). Self-contained scheduler; safe to run every deploy.
|
||||
await import("@/modules/parcel-sync/services/epay-cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ export interface CfExtractRow {
|
||||
userId: string;
|
||||
nrCadastral: string;
|
||||
nrCF?: string;
|
||||
type?: string;
|
||||
siruta?: string | null;
|
||||
uatName?: string | null;
|
||||
judetName?: string | null;
|
||||
status:
|
||||
| "pending"
|
||||
| "queued"
|
||||
|
||||
+1
-1
@@ -58,6 +58,6 @@ export const config = {
|
||||
* - /favicon.ico, /robots.txt, /sitemap.xml
|
||||
* - Files with extensions (images, fonts, etc.)
|
||||
*/
|
||||
"/((?!api/auth|api/version|api/basemap-|api/notifications/digest|api/eterra/auto-refresh|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
||||
"/((?!api/auth|api/version|api/basemap-|api/notifications/digest|api/eterra/auto-refresh|api/ancpi/cleanup|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -99,10 +99,10 @@ function adaptLegacyRow(row: LegacyCfExtract): CfExtractRecord {
|
||||
}
|
||||
|
||||
// Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape.
|
||||
// gis-api currently doesn't surface uatName/siruta/judetName on the list
|
||||
// endpoint, so we leave them blank; the row type defaults to "intern"
|
||||
// because gis_core's CfExtract is the cf-intern store (the cutover plan
|
||||
// hasn't yet moved ePay writes here).
|
||||
// gis-api returns siruta + uatName (judetName is null there, but the
|
||||
// /api/cf/orders proxy fills it from the local GisUat by SIRUTA — see
|
||||
// enrichCfLocations). The row type defaults to "intern" because gis's
|
||||
// CfExtract is primarily the cf-intern store.
|
||||
export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -110,9 +110,9 @@ export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractReco
|
||||
orderId: row.orderId ?? null,
|
||||
nrCadastral: row.nrCadastral,
|
||||
nrCF: row.nrCF ?? null,
|
||||
siruta: null,
|
||||
judetName: "",
|
||||
uatName: "",
|
||||
siruta: row.siruta ?? null,
|
||||
judetName: row.judetName ?? "",
|
||||
uatName: row.uatName ?? "",
|
||||
status: row.status,
|
||||
epayStatus: row.epayStatus ?? null,
|
||||
documentName: row.documentName ?? null,
|
||||
@@ -167,12 +167,18 @@ async function fetchGisAc(
|
||||
// single timeline shows ePay + intern history together. Sort newest-
|
||||
// first; dedupe by id (in case the same record ever lands in both
|
||||
// stores during the cutover migration).
|
||||
// Cancelled rows are dead (payment refused / cleaned-up bad orders) and just
|
||||
// clutter the list — hide them. failed/review stay (they're actionable).
|
||||
const isListable = (r: CfExtractRecord): boolean => r.status !== "cancelled";
|
||||
|
||||
export async function fetchCfOrdersList(
|
||||
useGisAc: boolean,
|
||||
params: { limit?: number; nrCadastral?: string; status?: string } = {},
|
||||
): Promise<{ orders: CfExtractRecord[]; total: number }> {
|
||||
if (!useGisAc) {
|
||||
return fetchLegacy(params);
|
||||
const r = await fetchLegacy(params);
|
||||
const orders = r.orders.filter(isListable);
|
||||
return { orders, total: orders.length };
|
||||
}
|
||||
|
||||
// Pull more rows from each side than the caller asked for so that the
|
||||
@@ -191,6 +197,7 @@ export async function fetchCfOrdersList(
|
||||
|
||||
const seen = new Set<string>();
|
||||
const dedup = merged.filter((r) => {
|
||||
if (!isListable(r)) return false;
|
||||
if (seen.has(r.id)) return false;
|
||||
seen.add(r.id);
|
||||
return true;
|
||||
@@ -198,14 +205,8 @@ export async function fetchCfOrdersList(
|
||||
|
||||
dedup.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
||||
|
||||
const total =
|
||||
(legacy.status === "fulfilled" ? legacy.value.total : 0) +
|
||||
(gisac.status === "fulfilled" ? gisac.value.total : 0);
|
||||
|
||||
return {
|
||||
orders: params.limit ? dedup.slice(0, params.limit) : dedup,
|
||||
total,
|
||||
};
|
||||
const orders = params.limit ? dedup.slice(0, params.limit) : dedup;
|
||||
return { orders, total: orders.length };
|
||||
}
|
||||
|
||||
// Existence check used by the per-parcel order button. We check both
|
||||
|
||||
@@ -141,17 +141,21 @@ export function EpayConnect({
|
||||
if (cancelled) return;
|
||||
setError("Eroare retea");
|
||||
shouldRetry = attempt < maxRetries;
|
||||
} finally {
|
||||
// ALWAYS clear the connecting spinner unless we're about to retry —
|
||||
// including the `cancelled` early-returns above. Otherwise a re-run
|
||||
// of this effect (e.g. when status.connected flips true) cancels the
|
||||
// in-flight attempt and leaves connecting stuck true → a perpetual
|
||||
// spinner on an already-connected (green) pill.
|
||||
if (!shouldRetry) setConnecting(false);
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (shouldRetry) {
|
||||
// Keep connecting state true during retry wait
|
||||
autoConnectTimerRef.current = setTimeout(() => {
|
||||
void attemptConnect(attempt + 1);
|
||||
}, 3000);
|
||||
} else {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,7 +168,11 @@ export function EpayConnect({
|
||||
autoConnectTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [triggerConnect, status.connected, connecting, fetchStatus]);
|
||||
// `connecting` intentionally excluded: setConnecting(true) inside this
|
||||
// effect would otherwise re-trigger it and cancel its own in-flight
|
||||
// attempt. autoConnectAttempted (a ref) already prevents double-starts.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [triggerConnect, status.connected, fetchStatus]);
|
||||
|
||||
const disconnect = async () => {
|
||||
try {
|
||||
@@ -202,10 +210,10 @@ export function EpayConnect({
|
||||
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{connecting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : status.connected ? (
|
||||
{status.connected ? (
|
||||
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||
) : connecting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
|
||||
<span className="hidden sm:inline">ePay</span>
|
||||
|
||||
@@ -57,6 +57,10 @@ export function EpayOrderButton({
|
||||
|
||||
const [ordering, setOrdering] = useState(false);
|
||||
const [ordered, setOrdered] = useState(false);
|
||||
// After enqueue the order keeps processing in the background (~1–2 min on
|
||||
// the legacy queue): cart → submit → poll → download. Show that instead of
|
||||
// flipping straight to a misleading "valid" the instant it's queued.
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
||||
connected: false,
|
||||
@@ -111,7 +115,10 @@ export function EpayOrderButton({
|
||||
});
|
||||
if (mountedRef.current) {
|
||||
if (result.ok) {
|
||||
setOrdered(true);
|
||||
// Queued, not done — enter the processing state and let the poll
|
||||
// effect below flip to "valid" only once the extract is actually
|
||||
// ready (or surface a failure).
|
||||
setProcessing(true);
|
||||
} else {
|
||||
setError(result.error ?? "Eroare comanda");
|
||||
}
|
||||
@@ -119,6 +126,36 @@ export function EpayOrderButton({
|
||||
}
|
||||
}, [nrCadastral, siruta, judetName, uatName, useGisAc]);
|
||||
|
||||
// Poll while processing: flip to "valid" only when a completed extract
|
||||
// actually exists. Caps at ~3 min, then stops (the parent list refresh
|
||||
// will reflect the final state).
|
||||
useEffect(() => {
|
||||
if (!processing) return;
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
const tick = async () => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const has = await fetchCfHasCompletedForCadastral(useGisAc, nrCadastral);
|
||||
if (cancelled) return;
|
||||
if (has) {
|
||||
setOrdered(true);
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
if (!cancelled && attempts >= 36) setProcessing(false); // ~3 min
|
||||
};
|
||||
const id = setInterval(() => void tick(), 5000);
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [processing, useGisAc, nrCadastral]);
|
||||
|
||||
// On the (future) gis.ac path, the orchestrator dispatches ePay calls
|
||||
// through a shared account pool — no personally-connected ePay session
|
||||
// needed. The legacy queue (current route while the guard is on)
|
||||
@@ -143,6 +180,29 @@ export function EpayOrderButton({
|
||||
return tooltipText ?? "Comanda extras CF (1 credit)";
|
||||
};
|
||||
|
||||
if (processing) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 px-1.5 text-yellow-600 dark:text-yellow-400"
|
||||
disabled
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<span className="text-[10px]">Se procesează...</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Comanda CF este în curs (coș → plată → descărcare, ~1–2 min)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (ordered) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -74,7 +74,23 @@ function isActiveStatus(status: string): boolean {
|
||||
|
||||
type StatusStyle = { label: string; className: string; pulse?: boolean };
|
||||
|
||||
function statusBadge(status: string, expiresAt: string | null): StatusStyle {
|
||||
function statusBadge(
|
||||
status: string,
|
||||
expiresAt: string | null,
|
||||
type?: "epay" | "intern",
|
||||
): StatusStyle {
|
||||
// Intern (copycf) extracts have NO validity term — never label them "Valid"
|
||||
// or "Expirat" (which imply an ePay-style 30-day validity). The source
|
||||
// badge next to this already says "intern", so the STATUS just states the
|
||||
// document is available (no term).
|
||||
if (type === "intern" && status === "completed") {
|
||||
return {
|
||||
label: "Disponibil",
|
||||
className:
|
||||
"bg-muted text-foreground/70 border-muted-foreground/20",
|
||||
};
|
||||
}
|
||||
|
||||
if (status === "completed" && isExpired(expiresAt)) {
|
||||
return {
|
||||
label: "Expirat",
|
||||
@@ -641,7 +657,7 @@ export function EpayTab() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredOrders.map((order, idx) => {
|
||||
const badge = statusBadge(order.status, order.expiresAt);
|
||||
const badge = statusBadge(order.status, order.expiresAt, order.type);
|
||||
const expired =
|
||||
order.status === "completed" && isExpired(order.expiresAt);
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Fill in uatName + judetName on CF extract rows from their SIRUTA.
|
||||
//
|
||||
// Intern (cf-intern) extracts — and ePay rows on the gis-api side — often
|
||||
// arrive without a judetName (it's null in gis_enrichment) and sometimes
|
||||
// without a uatName. Both are derivable from `siruta` via the local GisUat
|
||||
// table. This batches one query for the whole page instead of N lookups.
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
type LocatableRow = {
|
||||
siruta?: string | null;
|
||||
uatName?: string | null;
|
||||
judetName?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutates rows in place: for any row with a SIRUTA whose uatName/judetName is
|
||||
* blank, fill it from GisUat. Best-effort — a missing SIRUTA or a DB error
|
||||
* leaves the row unchanged. Returns the same array for convenience.
|
||||
*/
|
||||
export async function enrichCfLocations<T extends LocatableRow>(
|
||||
rows: T[],
|
||||
): Promise<T[]> {
|
||||
const sirutas = Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.map((r) => (r.siruta ? String(r.siruta).trim() : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
if (sirutas.length === 0) return rows;
|
||||
|
||||
try {
|
||||
const uats = await prisma.gisUat.findMany({
|
||||
where: { siruta: { in: sirutas } },
|
||||
select: { siruta: true, name: true, county: true },
|
||||
});
|
||||
const bySiruta = new Map(uats.map((u) => [u.siruta, u]));
|
||||
|
||||
for (const row of rows) {
|
||||
const s = row.siruta ? String(row.siruta).trim() : "";
|
||||
if (!s) continue;
|
||||
const uat = bySiruta.get(s);
|
||||
if (!uat) continue;
|
||||
if (!row.uatName && uat.name) row.uatName = uat.name;
|
||||
if (!row.judetName && uat.county) row.judetName = uat.county;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cf-enrich-location] lookup failed:", error);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Auto-cleanup of old ePay CF extracts.
|
||||
*
|
||||
* An ePay extract is valid 30 days after issuance (documentDate); after that
|
||||
* it's worthless (you'd re-order). At 45 days we delete the row + its MinIO
|
||||
* object to declutter the list and free storage. Only `type='epay'` rows are
|
||||
* touched — the free `cf-intern` (copycf) extracts are kept.
|
||||
*
|
||||
* Self-contained scheduler (same pattern as auto-refresh-scheduler): started
|
||||
* by importing this module from instrumentation.ts. Runs once shortly after
|
||||
* boot (so it happens at least once per deploy, since redeploys reset the
|
||||
* interval) and then every 24h. The cleanup is idempotent — a partial run
|
||||
* (e.g. interrupted by a restart) is simply finished by the next run.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { deleteCfExtractObjects } from "./epay-storage";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/** Delete ePay extracts this many days after issuance. */
|
||||
export const EPAY_RETENTION_DAYS = 45;
|
||||
|
||||
const g = globalThis as {
|
||||
__epayCleanupTimer?: ReturnType<typeof setInterval>;
|
||||
__epayCleanupRunning?: boolean;
|
||||
};
|
||||
|
||||
export type CleanupResult = {
|
||||
candidates: number;
|
||||
rowsDeleted: number;
|
||||
objectsDeleted: number;
|
||||
cutoff: string;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find and (unless dryRun) delete ePay extracts older than `olderThanDays`
|
||||
* from issuance. Issuance = documentDate, falling back to createdAt for rows
|
||||
* that never got a document (old failed/cancelled). Deletes the MinIO object
|
||||
* first, then the DB rows.
|
||||
*/
|
||||
export async function cleanupExpiredEpayExtracts(opts?: {
|
||||
olderThanDays?: number;
|
||||
dryRun?: boolean;
|
||||
}): Promise<CleanupResult> {
|
||||
const olderThanDays = opts?.olderThanDays ?? EPAY_RETENTION_DAYS;
|
||||
const dryRun = opts?.dryRun ?? false;
|
||||
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// COALESCE(documentDate, createdAt) < cutoff — raw query so the date logic
|
||||
// runs in Postgres and uses the createdAt index where possible.
|
||||
const rows = (await prisma.$queryRaw`
|
||||
SELECT id, "minioPath"
|
||||
FROM "CfExtract"
|
||||
WHERE type = 'epay'
|
||||
AND COALESCE("documentDate", "createdAt") < ${cutoff}
|
||||
`) as Array<{ id: string; minioPath: string | null }>;
|
||||
|
||||
const result: CleanupResult = {
|
||||
candidates: rows.length,
|
||||
rowsDeleted: 0,
|
||||
objectsDeleted: 0,
|
||||
cutoff: cutoff.toISOString(),
|
||||
dryRun,
|
||||
};
|
||||
|
||||
if (rows.length === 0 || dryRun) {
|
||||
console.log(
|
||||
`[epay-cleanup] ${dryRun ? "(dry-run) " : ""}${rows.length} ePay extract(s) older than ${olderThanDays}d (cutoff ${cutoff.toISOString().slice(0, 10)})`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Delete MinIO objects first (best-effort) so a deleted DB row never leaves
|
||||
// an orphan file.
|
||||
const paths = rows.map((r) => r.minioPath).filter((p): p is string => !!p);
|
||||
result.objectsDeleted = await deleteCfExtractObjects(paths);
|
||||
|
||||
const del = await prisma.cfExtract.deleteMany({
|
||||
where: { id: { in: rows.map((r) => r.id) } },
|
||||
});
|
||||
result.rowsDeleted = del.count;
|
||||
|
||||
console.log(
|
||||
`[epay-cleanup] deleted ${result.rowsDeleted} row(s) + ${result.objectsDeleted} object(s) older than ${olderThanDays}d`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Run the cleanup once, guarded against overlap. Never throws. */
|
||||
async function runCleanupSafely(): Promise<void> {
|
||||
if (g.__epayCleanupRunning) return;
|
||||
g.__epayCleanupRunning = true;
|
||||
try {
|
||||
await cleanupExpiredEpayExtracts();
|
||||
} catch (error) {
|
||||
console.error("[epay-cleanup] run failed:", error);
|
||||
} finally {
|
||||
g.__epayCleanupRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const BOOT_DELAY_MS = 90_000; // let the app finish starting first
|
||||
|
||||
// Start the scheduler (idempotent — one per process).
|
||||
if (!g.__epayCleanupTimer) {
|
||||
setTimeout(() => void runCleanupSafely(), BOOT_DELAY_MS);
|
||||
g.__epayCleanupTimer = setInterval(() => void runCleanupSafely(), ONE_DAY_MS);
|
||||
console.log(
|
||||
`[epay-cleanup] scheduler armed (retention ${EPAY_RETENTION_DAYS}d, boot run in ${BOOT_DELAY_MS / 1000}s, then every 24h)`,
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,9 @@ 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;
|
||||
// Document download retry (transient ANCPI 5xx / timeout / error-page).
|
||||
const DOWNLOAD_MAX_ATTEMPTS = 4;
|
||||
const DOWNLOAD_RETRY_DELAY_MS = 3_000; // linear backoff: 3s, 6s, 9s
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Session cache */
|
||||
@@ -233,9 +236,11 @@ export class EpayClient {
|
||||
|
||||
const data = response.data as EpayCartResponse;
|
||||
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];
|
||||
// ePay returns the full cart NEWEST-FIRST, so the just-added row is
|
||||
// items[0]. (Taking items[last] broke 2+ item batches: every add
|
||||
// reported the OLDEST row's id, so two rows collapsed onto one
|
||||
// basketRowId and metadata was saved to the wrong row — 2026-06-05.)
|
||||
const added = items[0];
|
||||
if (!added?.id) {
|
||||
throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`);
|
||||
}
|
||||
@@ -604,28 +609,34 @@ export class EpayClient {
|
||||
});
|
||||
const html = String(response.data ?? "");
|
||||
|
||||
// Find ALL orderIds on the page
|
||||
// ePay order numbers are sequential, so a genuinely NEW order is always
|
||||
// numerically GREATER than the latest order that existed before submit.
|
||||
// Requiring oid > previousOrderId is what stops us from adopting an
|
||||
// unrelated OLD order when our submit didn't actually create one — the
|
||||
// "!= previousOrderId" check alone let an older id through (2026-06-05:
|
||||
// a new batch grabbed yesterday's order 10009605 and attached its PDFs).
|
||||
const prevNum = previousOrderId ? Number(previousOrderId) : 0;
|
||||
const isGenuinelyNew = (oid: string): boolean =>
|
||||
!!oid &&
|
||||
oid !== previousOrderId &&
|
||||
!knownOrderIds?.has(oid) &&
|
||||
(!Number.isFinite(prevNum) || prevNum === 0 || Number(oid) > prevNum);
|
||||
|
||||
// Find ALL orderIds on the page; take the highest genuinely-new one.
|
||||
const allMatches = html.matchAll(/ShowOrderDetails\.action\?orderId=(\d+)/g);
|
||||
let best = "";
|
||||
for (const m of allMatches) {
|
||||
const oid = m[1] ?? "";
|
||||
if (!oid) continue;
|
||||
if (oid === previousOrderId) continue;
|
||||
if (knownOrderIds?.has(oid)) continue;
|
||||
console.log(`[epay] New orderId: ${oid}`);
|
||||
return oid;
|
||||
}
|
||||
|
||||
// If no new orderId found, the latest one might be it (first order) —
|
||||
// but NEVER adopt the previous/known order: after a submit that timed
|
||||
// out without creating anything, returning the stale id would attach
|
||||
// the wrong order and download its old documents.
|
||||
const latest = html.match(/ShowOrderDetails\.action\?orderId=(\d+)/);
|
||||
const latestId = latest?.[1];
|
||||
if (latestId && latestId !== previousOrderId && !knownOrderIds?.has(latestId)) {
|
||||
console.log(`[epay] Using latest orderId: ${latestId}`);
|
||||
return latestId;
|
||||
if (!isGenuinelyNew(oid)) continue;
|
||||
if (!best || Number(oid) > Number(best)) best = oid;
|
||||
}
|
||||
if (best) {
|
||||
console.log(`[epay] New orderId: ${best}`);
|
||||
return best;
|
||||
}
|
||||
|
||||
// No genuinely-new order on the dashboard → the submit created nothing.
|
||||
// Fail (recoverable) rather than adopting a stale/previous/known order.
|
||||
throw new Error("Could not determine orderId after checkout");
|
||||
}
|
||||
|
||||
@@ -771,10 +782,20 @@ export class EpayClient {
|
||||
onProgress?: (attempt: number, status: string) => void,
|
||||
): Promise<EpayOrderStatus> {
|
||||
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
||||
const status = await this.getOrderStatus(orderId);
|
||||
if (onProgress) onProgress(attempt, status.status);
|
||||
if (["Finalizata", "Anulata", "Plata refuzata"].includes(status.status)) {
|
||||
return status;
|
||||
try {
|
||||
const status = await this.getOrderStatus(orderId);
|
||||
if (onProgress) onProgress(attempt, status.status);
|
||||
if (["Finalizata", "Anulata", "Plata refuzata"].includes(status.status)) {
|
||||
return status;
|
||||
}
|
||||
} catch (err) {
|
||||
// A transient ANCPI error (5xx, timeout) on ONE poll must not abort
|
||||
// the whole batch — the order is paid and still being processed.
|
||||
// Log and try again on the next cycle.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
`[epay] poll ${attempt}/${POLL_MAX_ATTEMPTS} for order ${orderId} errored (${msg}); continuing`,
|
||||
);
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
@@ -785,29 +806,57 @@ export class EpayClient {
|
||||
|
||||
async downloadDocument(idDocument: number, typeD = 4): Promise<Buffer> {
|
||||
const url = `${BASE_URL}/DownloadFile.action?typeD=${typeD}&id=${idDocument}&source=&browser=chrome`;
|
||||
// Angular sends Content-Type: application/pdf in the REQUEST
|
||||
const response = await this.client.post(url, null, {
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
let lastErr = "unknown";
|
||||
|
||||
const data = response.data;
|
||||
if (!data || data.length < 100) {
|
||||
throw new Error(`ePay download empty (${data?.length ?? 0} bytes)`);
|
||||
// ANCPI's DownloadFile occasionally returns a transient 5xx / times out /
|
||||
// hands back an error page even when the order is finalized (2026-06-05:
|
||||
// 327649 got one 500, then succeeded on the very next attempt). The
|
||||
// download is idempotent, so retry transient failures with backoff before
|
||||
// giving up. A 4xx is treated as permanent (stop immediately).
|
||||
for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const response = await this.client.post(url, null, {
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
responseType: "arraybuffer",
|
||||
validateStatus: () => true, // inspect status ourselves for retry
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
lastErr = `HTTP ${response.status}`;
|
||||
if (response.status < 500) break; // client error — won't fix on retry
|
||||
} else {
|
||||
const buf = Buffer.from(response.data ?? Buffer.alloc(0));
|
||||
if (buf.length < 100) {
|
||||
lastErr = `empty (${buf.length} bytes)`;
|
||||
} else if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
|
||||
// Not a PDF — usually a transient ANCPI error page or an expired
|
||||
// session. Retry; a fresh attempt often returns the real PDF.
|
||||
const head = buf.subarray(0, 48).toString("latin1").replace(/\s+/g, " ");
|
||||
lastErr = `not a PDF (head="${head.slice(0, 40)}")`;
|
||||
} else {
|
||||
if (attempt > 1) {
|
||||
console.log(`[epay] download ${idDocument} recovered on attempt ${attempt}`);
|
||||
}
|
||||
console.log(`[epay] Downloaded document ${idDocument}: ${buf.length} bytes`);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Network error / timeout — retryable.
|
||||
lastErr = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
if (attempt < DOWNLOAD_MAX_ATTEMPTS) {
|
||||
console.warn(
|
||||
`[epay] download ${idDocument} attempt ${attempt} failed (${lastErr}); retrying in ${DOWNLOAD_RETRY_DELAY_MS * attempt}ms`,
|
||||
);
|
||||
await sleep(DOWNLOAD_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
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;
|
||||
throw new Error(
|
||||
`ePay download failed after ${DOWNLOAD_MAX_ATTEMPTS} attempts (idDocument=${idDocument}): ${lastErr}`,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -574,10 +574,10 @@ async function finalizeOrder(
|
||||
plans.push({ item, doc, matchedByIndex, index: next });
|
||||
}
|
||||
|
||||
// Step 6: download + store in parallel (bounded). Each task is fully
|
||||
// self-contained so a failure on one row doesn't abort the others. The
|
||||
// file index is pre-allocated above, so parallel stores never overwrite.
|
||||
await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async ({ item, doc, matchedByIndex, index: fileIndex }) => {
|
||||
// One plan's download + store. Returns true on success. On failure it
|
||||
// marks the row failed and returns false so the caller can retry it.
|
||||
const downloadAndStore = async (plan: Plan): Promise<boolean> => {
|
||||
const { item, doc, matchedByIndex, index: fileIndex } = plan;
|
||||
try {
|
||||
await updateStatus(item.extractId, "downloading", {
|
||||
idDocument: doc.idDocument,
|
||||
@@ -629,15 +629,43 @@ async function finalizeOrder(
|
||||
console.log(
|
||||
`[epay-queue] ${matchedByIndex ? "Review" : "Completed"}: ${item.input.nrCadastral} → ${path}`,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Eroare download/stocare";
|
||||
await updateStatus(item.extractId, "failed", {
|
||||
errorMessage: message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 6: download + store in parallel (bounded). Each task is fully
|
||||
// self-contained so a failure on one row doesn't abort the others. The
|
||||
// file index is pre-allocated above, so parallel stores never overwrite.
|
||||
// downloadDocument already retries transient ANCPI errors per call; this
|
||||
// adds a SECOND layer — a final sweep that re-attempts any row still
|
||||
// failed (covers a longer ANCPI blip or a MinIO hiccup) with no new
|
||||
// charge, since the order is already paid.
|
||||
const failed: Plan[] = [];
|
||||
await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async (plan) => {
|
||||
const ok = await downloadAndStore(plan);
|
||||
if (!ok) failed.push(plan);
|
||||
});
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.warn(
|
||||
`[epay-queue] ${failed.length}/${plans.length} downloads failed for order ${orderId} — retry sweep in 5s...`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
for (const plan of failed) {
|
||||
const ok = await downloadAndStore(plan);
|
||||
console.log(
|
||||
`[epay-queue] retry sweep ${plan.item.input.nrCadastral}: ${ok ? "recovered" : "still failed"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update credits after successful order
|
||||
const newCredits = await client.getCredits();
|
||||
updateEpayCredits(newCredits);
|
||||
|
||||
@@ -134,6 +134,35 @@ export async function getCfExtractStream(
|
||||
return minioClient.getObject(BUCKET, minioPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored CF extract objects from MinIO (best-effort, batched).
|
||||
* Returns how many were removed. Used by the 45-day auto-cleanup.
|
||||
*/
|
||||
export async function deleteCfExtractObjects(
|
||||
minioPaths: string[],
|
||||
): Promise<number> {
|
||||
const paths = minioPaths.filter(Boolean);
|
||||
if (paths.length === 0) return 0;
|
||||
try {
|
||||
await minioClient.removeObjects(BUCKET, paths);
|
||||
return paths.length;
|
||||
} catch (error) {
|
||||
// removeObjects can fail wholesale on a transport error — fall back to
|
||||
// per-object deletes so one bad key doesn't block the rest.
|
||||
console.warn("[epay-storage] batch delete failed, retrying per-object:", error);
|
||||
let removed = 0;
|
||||
for (const p of paths) {
|
||||
try {
|
||||
await minioClient.removeObject(BUCKET, p);
|
||||
removed++;
|
||||
} catch (err) {
|
||||
console.warn(`[epay-storage] could not delete ${p}:`, err);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all stored CF extracts for a cadastral number.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user