From 21a058b429b734839c92bc09adb15f19d220bbc0 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Tue, 19 May 2026 00:11:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(faza-f):=20ePay/CF=20backend=20swap=20?= =?UTF-8?q?=E2=80=94=20/api/cf/*=20proxies=20to=20gis-api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 003 Faza F. Pilot users (session.useGisAc=true) get their CF extract flow routed through api.gis.ac (RLS-filtered, RLS-owned writes); everyone else keeps the legacy /api/ancpi/* path unchanged. Feature-flag preserves rollback. New routes (5): - POST /api/cf/order → gisApi.enrichment.cf.create. Forwards 409 catalog_hit verbatim. - GET /api/cf/orders → gisApi.enrichment.cf.list (limit, offset, status). - GET /api/cf/[id] → gisApi.enrichment.cf.get. - PATCH /api/cf/[id] → gisApi.enrichment.cf.patch. - GET /api/cf/[id]/pdf → streams gisApi.enrichment.cf.getPdf through to browser. Filename from documentName via cf.get; falls back to cf-.pdf. - GET /api/cf/catalog → gisApi.enrichment.catalog. All use getAuthSession() → 401 on no session, forward GisApiError status+code+body, fallback {error:"internal_error", hint} at 500. runtime=nodejs, dynamic=force-dynamic. Helper module `cf-api-base.ts`: - cfApiBase(useGisAc) → "/api/cf" | "/api/ancpi" - adaptCfRow(row) → maps gisApi.CfExtractRow into the UI shape expected by epay-tab.tsx (CfExtractRecord). Fields not in gis-api (siruta, judetName, uatName, errorMessage, etc.) default to empty/zero — filter-by-judet/uat on the pilot path is reduced until gis-api enriches the response. - fetchCfOrdersList, fetchCfHasCompletedForCadastral, placeCfOrder, cfDownloadUrl — used by components. UI changes: - epay-tab.tsx: reads session.useGisAc; list fetch, reorder, single + bulk download routed via helpers. UI shape unchanged. - epay-order-button.tsx: existence check uses catalog endpoint on gis-ac path; order placement uses placeCfOrder which treats 409 catalog_hit as a soft success ("Extras CF valid"). Known gaps (followups): - /api/ancpi/session still serves ePay session/credits — no gis-api equivalent today. epay-connect.tsx untouched. - ZIP bulk download has no gis-api analog; "Descarcă tot" falls back to N tabs on gis-ac path. - src/app/api/geoportal/cf-status returns hardcoded /api/ancpi/download URL — separate followup. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/cf/[id]/pdf/route.ts | 82 +++++++ src/app/api/cf/[id]/route.ts | 87 ++++++++ src/app/api/cf/catalog/route.ts | 44 ++++ src/app/api/cf/order/route.ts | 71 ++++++ src/app/api/cf/orders/route.ts | 50 +++++ .../parcel-sync/components/cf-api-base.ts | 204 ++++++++++++++++++ .../components/epay-order-button.tsx | 68 +++--- .../parcel-sync/components/epay-tab.tsx | 155 +++++++------ 8 files changed, 646 insertions(+), 115 deletions(-) create mode 100644 src/app/api/cf/[id]/pdf/route.ts create mode 100644 src/app/api/cf/[id]/route.ts create mode 100644 src/app/api/cf/catalog/route.ts create mode 100644 src/app/api/cf/order/route.ts create mode 100644 src/app/api/cf/orders/route.ts create mode 100644 src/modules/parcel-sync/components/cf-api-base.ts diff --git a/src/app/api/cf/[id]/pdf/route.ts b/src/app/api/cf/[id]/pdf/route.ts new file mode 100644 index 0000000..b176d00 --- /dev/null +++ b/src/app/api/cf/[id]/pdf/route.ts @@ -0,0 +1,82 @@ +// GET /api/cf/[id]/pdf — Plan 003, Faza F. +// +// Streams the CF extract PDF from gis-api back to the browser. The thin +// client's getPdf() returns the raw Response so we can pipe the body +// without buffering it in Node memory. +// +// Filename: tries to read documentName from gis-api.cf.get(id) first; falls +// back to "cf-.pdf". A failure on the metadata lookup is non-fatal — +// the PDF stream is what matters. +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +import { gisApi, GisApiError } from "@/lib/gis-api-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function sanitizeFilename(name: string): string { + // ASCII-safe, no path separators or quotes. + return name.replace(/[\\/"\r\n\t]/g, "_").slice(0, 200); +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + if (!id) { + return NextResponse.json({ error: "missing_id" }, { status: 400 }); + } + + // Best-effort filename resolution. Non-fatal on failure. + let filename = `cf-${id}.pdf`; + try { + const meta = await gisApi.enrichment.cf.get(id); + const fromDoc = meta.documentName?.trim(); + const fromCadastral = meta.nrCadastral?.trim(); + if (fromDoc) { + filename = fromDoc.toLowerCase().endsWith(".pdf") + ? fromDoc + : `${fromDoc}.pdf`; + } else if (fromCadastral) { + filename = `extras-cf-${fromCadastral}.pdf`; + } + } catch { + /* keep default filename */ + } + + try { + const upstream = await gisApi.enrichment.cf.getPdf(id); + const headers = new Headers(); + headers.set( + "Content-Type", + upstream.headers.get("Content-Type") || "application/pdf", + ); + const len = upstream.headers.get("Content-Length"); + if (len) headers.set("Content-Length", len); + headers.set( + "Content-Disposition", + `attachment; filename="${sanitizeFilename(filename)}"`, + ); + headers.set("Cache-Control", "private, no-store"); + return new Response(upstream.body, { status: 200, headers }); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[cf-pdf] internal error:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/cf/[id]/route.ts b/src/app/api/cf/[id]/route.ts new file mode 100644 index 0000000..a62f1d8 --- /dev/null +++ b/src/app/api/cf/[id]/route.ts @@ -0,0 +1,87 @@ +// /api/cf/[id] — Plan 003, Faza F. +// +// GET → gisApi.enrichment.cf.get(id) +// PATCH → gisApi.enrichment.cf.patch(id, body) (RLS-owned write) +// +// RLS on the gis-api side ensures only the owner sees / mutates the row. +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +import { + gisApi, + GisApiError, + type CfExtractRow, +} from "@/lib/gis-api-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + if (!id) { + return NextResponse.json({ error: "missing_id" }, { status: 400 }); + } + + try { + return NextResponse.json(await gisApi.enrichment.cf.get(id)); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[cf-get] internal error:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + if (!id) { + return NextResponse.json({ error: "missing_id" }, { status: 400 }); + } + + let body: Partial; + try { + body = (await request.json()) as Partial; + } catch { + return NextResponse.json({ error: "invalid_body" }, { status: 400 }); + } + + try { + return NextResponse.json(await gisApi.enrichment.cf.patch(id, body)); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[cf-patch] internal error:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/cf/catalog/route.ts b/src/app/api/cf/catalog/route.ts new file mode 100644 index 0000000..8111d49 --- /dev/null +++ b/src/app/api/cf/catalog/route.ts @@ -0,0 +1,44 @@ +// GET /api/cf/catalog?nrCadastral=... — Plan 003, Faza F. +// +// Wraps gisApi.enrichment.catalog(nrCadastral). Lets the UI check if a +// fresh CF extract already exists in the shared catalog before placing a +// new order (avoids duplicate spend on the user's ePay credits). +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +import { gisApi, GisApiError } from "@/lib/gis-api-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const nrCadastral = searchParams.get("nrCadastral")?.trim(); + if (!nrCadastral) { + return NextResponse.json( + { error: "missing_fields", required: ["nrCadastral"] }, + { status: 400 }, + ); + } + + try { + return NextResponse.json(await gisApi.enrichment.catalog(nrCadastral)); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[cf-catalog] internal error:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/cf/order/route.ts b/src/app/api/cf/order/route.ts new file mode 100644 index 0000000..16fd3f8 --- /dev/null +++ b/src/app/api/cf/order/route.ts @@ -0,0 +1,71 @@ +// POST /api/cf/order — Plan 003, Faza F. +// +// Server-side proxy to gisApi.enrichment.cf.create(). Used by parcel-sync's +// ePay UI when session.useGisAc === true. Legacy callers (flag=0) continue +// hitting /api/ancpi/order; this route MUST NOT replace it. +// +// Body: { nrCadastral: string, siruta?: string, gisFeatureId?: string } +// +// Responses: +// 200 → CfExtractRow (new order created or pending one returned) +// 409 → { error: "catalog_hit", existing: CfExtractRow } — forwarded verbatim +// 401 → no session +// 400 → invalid body / missing nrCadastral +// Other → forwards GisApiError.status + .code +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +import { gisApi, GisApiError } from "@/lib/gis-api-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +interface CfOrderBody { + nrCadastral?: string; + siruta?: string; + gisFeatureId?: string; +} + +export async function POST(request: Request) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: CfOrderBody; + try { + body = (await request.json()) as CfOrderBody; + } catch { + return NextResponse.json({ error: "invalid_body" }, { status: 400 }); + } + + const nrCadastral = body?.nrCadastral?.trim(); + if (!nrCadastral) { + return NextResponse.json( + { error: "missing_fields", required: ["nrCadastral"] }, + { status: 400 }, + ); + } + + try { + const result = await gisApi.enrichment.cf.create({ + nrCadastral, + siruta: body.siruta, + gisFeatureId: body.gisFeatureId, + }); + return NextResponse.json(result); + } catch (err) { + if (err instanceof GisApiError) { + // Forward catalog_hit (409) verbatim so the UI can show the existing row. + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[cf-order] internal error:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/cf/orders/route.ts b/src/app/api/cf/orders/route.ts new file mode 100644 index 0000000..10e8f32 --- /dev/null +++ b/src/app/api/cf/orders/route.ts @@ -0,0 +1,50 @@ +// GET /api/cf/orders — Plan 003, Faza F. +// +// Server-side proxy to gisApi.enrichment.cf.list(). RLS-filtered on the +// gis-api side — only the caller's own CfExtract rows are returned. +// +// Query params: ?limit=&offset=&status= +// +// Response shape (forwarded verbatim from gis-api): +// { total, limit, offset, rows: CfExtractRow[] } +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +import { gisApi, GisApiError } from "@/lib/gis-api-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const limitRaw = Number(searchParams.get("limit") ?? "50"); + const offsetRaw = Number(searchParams.get("offset") ?? "0"); + const status = searchParams.get("status")?.trim() || undefined; + + const limit = + Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 200) : 50; + const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0; + + try { + return NextResponse.json( + await gisApi.enrichment.cf.list({ limit, offset, status }), + ); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[cf-orders] internal error:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} diff --git a/src/modules/parcel-sync/components/cf-api-base.ts b/src/modules/parcel-sync/components/cf-api-base.ts new file mode 100644 index 0000000..321e905 --- /dev/null +++ b/src/modules/parcel-sync/components/cf-api-base.ts @@ -0,0 +1,204 @@ +// Plan 003, Faza F — feature-flag glue for ePay/CF UI. +// +// The legacy backend lives under `/api/ancpi/*` and is backed by the local +// Prisma CfExtract table. The new backend lives under `/api/cf/*` and is +// backed by gisApi.enrichment.cf.* (api.gis.ac, RLS-filtered). +// +// Pilot users (m.tarau@beletage.ro for now) carry `session.useGisAc === true` +// and we route their UI through the new endpoints. Everyone else keeps the +// legacy path unchanged. +// +// Response shapes differ between the two backends. Components keep the +// existing UI shape (CfExtractRecord) and call `adaptCfRow` to convert +// gisApi.CfExtractRow → CfExtractRecord on read. + +import type { CfExtractRow } from "@/lib/gis-api-client"; + +export function cfApiBase(useGisAc: boolean): string { + return useGisAc ? "/api/cf" : "/api/ancpi"; +} + +// UI-side row shape (mirrors what epay-tab.tsx already expects). +export type CfExtractRecord = { + id: string; + orderId: string | null; + nrCadastral: string; + nrCF: string | null; + siruta: string | null; + judetName: string; + uatName: string; + status: string; + epayStatus: string | null; + documentName: string | null; + documentDate: string | null; + minioPath: string | null; + expiresAt: string | null; + errorMessage: string | null; + version: number; + creditsUsed: number; + createdAt: string; + completedAt: string | null; +}; + +// Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape. +// Fields not exposed by gis-api are filled with safe defaults so the UI +// keeps rendering without conditional branches. +export function adaptCfRow(row: CfExtractRow): CfExtractRecord { + return { + id: row.id, + orderId: row.orderId ?? null, + nrCadastral: row.nrCadastral, + nrCF: row.nrCF ?? null, + siruta: null, + judetName: "", + uatName: "", + status: row.status, + epayStatus: row.epayStatus ?? null, + documentName: row.documentName ?? null, + documentDate: row.documentDate ?? null, + minioPath: row.minioPath ?? null, + expiresAt: row.expiresAt ?? null, + errorMessage: null, + version: 1, + creditsUsed: 0, + createdAt: row.createdAt, + completedAt: row.completedAt ?? null, + }; +} + +// Fetch the orders list and normalize the response into a single shape. +// Caller passes `useGisAc` (already read from useSession()). +export async function fetchCfOrdersList( + useGisAc: boolean, + params: { limit?: number; nrCadastral?: string; status?: string } = {}, +): Promise<{ orders: CfExtractRecord[]; total: number }> { + const base = cfApiBase(useGisAc); + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + if (params.status) qs.set("status", params.status); + // Legacy endpoint supports ?nrCadastral filter; new endpoint does not. + // The new flow uses the catalog endpoint instead for the single-cadastral + // pre-check (see fetchCfHasCompletedForCadastral below). + if (!useGisAc && params.nrCadastral) + qs.set("nrCadastral", params.nrCadastral); + const url = `${base}/orders${qs.toString() ? `?${qs.toString()}` : ""}`; + const res = await fetch(url); + const data = (await res.json()) as + | { orders?: CfExtractRecord[]; total?: number } // legacy + | { rows?: CfExtractRow[]; total?: number }; // gis-ac + if (useGisAc) { + const rows = (data as { rows?: CfExtractRow[] }).rows ?? []; + return { + orders: rows.map(adaptCfRow), + total: (data as { total?: number }).total ?? rows.length, + }; + } + return { + orders: (data as { orders?: CfExtractRecord[] }).orders ?? [], + total: (data as { total?: number }).total ?? 0, + }; +} + +// Existence check used by the per-parcel order button. On the legacy +// backend this hits /api/ancpi/orders?nrCadastral=…&status=completed; on +// the gis-ac backend it uses the catalog endpoint (cheaper, RLS-safe). +export async function fetchCfHasCompletedForCadastral( + useGisAc: boolean, + nrCadastral: string, +): Promise { + if (useGisAc) { + try { + const res = await fetch( + `/api/cf/catalog?nrCadastral=${encodeURIComponent(nrCadastral)}`, + ); + if (!res.ok) return false; + const data = (await res.json()) as { isFresh?: boolean }; + return Boolean(data?.isFresh); + } catch { + return false; + } + } + try { + const res = await fetch( + `/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrCadastral)}&status=completed&limit=1`, + ); + const data = (await res.json()) as { total?: number }; + return Boolean(data?.total && data.total > 0); + } catch { + return false; + } +} + +// Per-parcel order placement. The two endpoints take different body +// shapes; we normalize at the call site here. +export async function placeCfOrder( + useGisAc: boolean, + parcel: { + nrCadastral: string; + nrCF?: string | null; + siruta?: string | null; + judetName: string; + uatName: string; + gisFeatureId?: string; + }, +): Promise<{ ok: boolean; error?: string }> { + if (useGisAc) { + try { + const res = await fetch("/api/cf/order", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nrCadastral: parcel.nrCadastral, + siruta: parcel.siruta ?? undefined, + gisFeatureId: parcel.gisFeatureId, + }), + }); + const data = (await res.json()) as { error?: string }; + // 409 catalog_hit is a "soft success": the user already has (or shares + // access to) a fresh extract for this cadastral. The UI should reflect + // that as "Extras CF valid", same as if we just created one. + if (res.status === 409 && data?.error === "catalog_hit") { + return { ok: true }; + } + if (!res.ok || data.error) { + return { ok: false, error: data.error ?? "Eroare comanda" }; + } + return { ok: true }; + } catch { + return { ok: false, error: "Eroare retea" }; + } + } + // Legacy path — preserved verbatim from the existing component. + try { + const res = await fetch("/api/ancpi/order", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parcels: [ + { + nrCadastral: parcel.nrCadastral, + nrCF: parcel.nrCF ?? null, + siruta: parcel.siruta ?? null, + judetIndex: 0, + judetName: parcel.judetName, + uatId: 0, + uatName: parcel.uatName, + }, + ], + }), + }); + const data = (await res.json()) as { error?: string }; + if (!res.ok || data.error) { + return { ok: false, error: data.error ?? "Eroare comanda" }; + } + return { ok: true }; + } catch { + return { ok: false, error: "Eroare retea" }; + } +} + +// Per-row download URL. Legacy path returns a Minio-backed PDF; new path +// streams via /api/cf/[id]/pdf. +export function cfDownloadUrl(useGisAc: boolean, id: string): string { + return useGisAc ? `/api/cf/${encodeURIComponent(id)}/pdf` : `/api/ancpi/download?id=${id}`; +} diff --git a/src/modules/parcel-sync/components/epay-order-button.tsx b/src/modules/parcel-sync/components/epay-order-button.tsx index 4089ed2..ed9feae 100644 --- a/src/modules/parcel-sync/components/epay-order-button.tsx +++ b/src/modules/parcel-sync/components/epay-order-button.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; +import { useSession } from "next-auth/react"; import { FileText, Loader2, Check, RefreshCw } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { @@ -11,6 +12,10 @@ import { } from "@/shared/components/ui/tooltip"; import { cn } from "@/shared/lib/utils"; import type { EpaySessionStatus } from "./epay-connect"; +import { + fetchCfHasCompletedForCadastral, + placeCfOrder, +} from "./cf-api-base"; /* ------------------------------------------------------------------ */ /* Props */ @@ -39,6 +44,12 @@ export function EpayOrderButton({ label, tooltipText, }: Props) { + // Plan 003, Faza F — flag drives which backend handles the order. + const { data: session } = useSession(); + const useGisAc = Boolean( + (session as { useGisAc?: boolean } | null)?.useGisAc, + ); + const [ordering, setOrdering] = useState(false); const [ordered, setOrdered] = useState(false); const [error, setError] = useState(""); @@ -59,19 +70,20 @@ export function EpayOrderButton({ const check = async () => { try { - // Check session + // ePay credit/session status is still served by the legacy route + // (gis-api does not expose ePay session state — server-side creds + // there). Read it regardless of cutover flag. const sRes = await fetch("/api/ancpi/session"); const sData = (await sRes.json()) as EpaySessionStatus; if (!cancelled) setEpayStatus(sData); - // Check if a completed extract already exists - const oRes = await fetch( - `/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrCadastral)}&status=completed&limit=1`, + // Check if a completed/fresh extract already exists for this + // cadastral. Routes the call through legacy or gis-ac per flag. + const has = await fetchCfHasCompletedForCadastral( + useGisAc, + nrCadastral, ); - const oData = (await oRes.json()) as { orders?: unknown[]; total?: number }; - if (!cancelled && oData.total && oData.total > 0) { - setOrdered(true); - } + if (!cancelled && has) setOrdered(true); } catch { /* silent */ } @@ -81,40 +93,26 @@ export function EpayOrderButton({ return () => { cancelled = true; }; - }, [nrCadastral]); + }, [nrCadastral, useGisAc]); const handleOrder = useCallback(async () => { setOrdering(true); setError(""); - try { - const res = await fetch("/api/ancpi/order", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - parcels: [ - { - nrCadastral, - siruta, - judetIndex: 0, // server resolves from SIRUTA - judetName, - uatId: 0, // server resolves from SIRUTA - uatName, - }, - ], - }), - }); - const data = (await res.json()) as { orders?: unknown[]; error?: string }; - if (!res.ok || data.error) { - if (mountedRef.current) setError(data.error ?? "Eroare comanda"); + const result = await placeCfOrder(useGisAc, { + nrCadastral, + siruta, + judetName, + uatName, + }); + if (mountedRef.current) { + if (result.ok) { + setOrdered(true); } else { - if (mountedRef.current) setOrdered(true); + setError(result.error ?? "Eroare comanda"); } - } catch { - if (mountedRef.current) setError("Eroare retea"); - } finally { - if (mountedRef.current) setOrdering(false); + setOrdering(false); } - }, [nrCadastral, siruta, judetName, uatName]); + }, [nrCadastral, siruta, judetName, uatName, useGisAc]); const disabled = !epayStatus.connected || diff --git a/src/modules/parcel-sync/components/epay-tab.tsx b/src/modules/parcel-sync/components/epay-tab.tsx index 03450b2..174292a 100644 --- a/src/modules/parcel-sync/components/epay-tab.tsx +++ b/src/modules/parcel-sync/components/epay-tab.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; +import { useSession } from "next-auth/react"; import { FileText, Download, @@ -23,31 +24,12 @@ import { } from "@/shared/components/ui/tooltip"; import { cn } from "@/shared/lib/utils"; import type { EpaySessionStatus } from "./epay-connect"; - -/* ------------------------------------------------------------------ */ -/* Types */ -/* ------------------------------------------------------------------ */ - -type CfExtractRecord = { - id: string; - orderId: string | null; - nrCadastral: string; - nrCF: string | null; - siruta: string | null; - judetName: string; - uatName: string; - status: string; - epayStatus: string | null; - documentName: string | null; - documentDate: string | null; - minioPath: string | null; - expiresAt: string | null; - errorMessage: string | null; - version: number; - creditsUsed: number; - createdAt: string; - completedAt: string | null; -}; +import { + cfDownloadUrl, + fetchCfOrdersList, + placeCfOrder, + type CfExtractRecord, +} from "./cf-api-base"; type GisUatResult = { siruta: string; @@ -165,6 +147,12 @@ const FILTER_OPTIONS: { value: FilterValue; label: string }[] = [ /* ------------------------------------------------------------------ */ export function EpayTab() { + /* -- Cutover flag (Plan 003, Faza F) ----------------------------- */ + const { data: session } = useSession(); + const useGisAc = Boolean( + (session as { useGisAc?: boolean } | null)?.useGisAc, + ); + /* -- ePay session ------------------------------------------------ */ const [epayStatus, setEpayStatus] = useState({ connected: false, @@ -193,11 +181,24 @@ export function EpayTab() { if (selectedIds.length === 0) return; setDownloadingSelection(true); try { - const ids = selectedIds.join(","); - const a = document.createElement("a"); - a.href = `/api/ancpi/download-zip?ids=${encodeURIComponent(ids)}`; - a.download = `Extrase_CF_selectie_${selectedIds.length}.zip`; - a.click(); + // ZIP endpoint only exists on the legacy backend today. For pilot + // users on the gis-ac path we fall back to triggering individual + // PDF downloads (one-by-one) until gis-api ships a batch endpoint. + if (useGisAc) { + for (const id of selectedIds) { + const a = document.createElement("a"); + a.href = cfDownloadUrl(true, id); + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.click(); + } + } else { + const ids = selectedIds.join(","); + const a = document.createElement("a"); + a.href = `/api/ancpi/download-zip?ids=${encodeURIComponent(ids)}`; + a.download = `Extrase_CF_selectie_${selectedIds.length}.zip`; + a.click(); + } } finally { setTimeout(() => setDownloadingSelection(false), 2000); } @@ -234,11 +235,7 @@ export function EpayTab() { async (showRefreshing = false) => { if (showRefreshing) setRefreshing(true); try { - const res = await fetch("/api/ancpi/orders?limit=200"); - const data = (await res.json()) as { - orders: CfExtractRecord[]; - total: number; - }; + const data = await fetchCfOrdersList(useGisAc, { limit: 200 }); setOrders(data.orders); setTotal(data.total); } catch { @@ -248,7 +245,7 @@ export function EpayTab() { setRefreshing(false); } }, - [], + [useGisAc], ); /* -- Initial load ------------------------------------------------ */ @@ -307,34 +304,18 @@ export function EpayTab() { /* -- Re-order (for expired extracts) ----------------------------- */ const handleReorder = async (order: CfExtractRecord) => { - try { - const res = await fetch("/api/ancpi/order", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - parcels: [ - { - nrCadastral: order.nrCadastral, - nrCF: order.nrCF, - siruta: order.siruta, - judetIndex: 0, - judetName: order.judetName, - uatId: 0, - uatName: order.uatName, - }, - ], - }), - }); - const data = (await res.json()) as { error?: string }; - if (!res.ok || data.error) { - /* show inline later */ - } else { - void fetchOrders(true); - void fetchEpayStatus(); - } - } catch { - /* silent */ + const result = await placeCfOrder(useGisAc, { + nrCadastral: order.nrCadastral, + nrCF: order.nrCF, + siruta: order.siruta, + judetName: order.judetName, + uatName: order.uatName, + }); + if (result.ok) { + void fetchOrders(true); + void fetchEpayStatus(); } + /* errors surfaced inline via downstream polling later */ }; /* -- Download all valid as ZIP ----------------------------------- */ @@ -346,23 +327,37 @@ export function EpayTab() { setDownloadingAll(true); try { - const ids = validOrders.map((o) => o.id).join(","); - const res = await fetch(`/api/ancpi/download-zip?ids=${ids}`); - if (!res.ok) throw new Error("Eroare descarcare ZIP"); + if (useGisAc) { + // No bulk-zip endpoint on api.gis.ac yet — trigger individual + // PDF downloads. Browser dedup will handle these as separate tabs. + for (const o of validOrders) { + const a = document.createElement("a"); + a.href = cfDownloadUrl(true, o.id); + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.click(); + } + } else { + const ids = validOrders.map((o) => o.id).join(","); + const res = await fetch(`/api/ancpi/download-zip?ids=${ids}`); + if (!res.ok) throw new Error("Eroare descarcare ZIP"); - const blob = await res.blob(); - const cd = res.headers.get("Content-Disposition") ?? ""; - const match = /filename="?([^"]+)"?/.exec(cd); - const filename = match?.[1] ? decodeURIComponent(match[1]) : "Extrase_CF.zip"; + const blob = await res.blob(); + const cd = res.headers.get("Content-Disposition") ?? ""; + const match = /filename="?([^"]+)"?/.exec(cd); + const filename = match?.[1] + ? decodeURIComponent(match[1]) + : "Extrase_CF.zip"; - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } } catch { /* silent */ } finally { @@ -735,7 +730,7 @@ export function EpayTab() { asChild > @@ -761,7 +756,7 @@ export function EpayTab() { asChild >