feat(faza-f): ePay/CF backend swap — /api/cf/* proxies to gis-api

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-<id>.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) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 00:11:55 +03:00
parent 3d389bf10a
commit 21a058b429
8 changed files with 646 additions and 115 deletions
@@ -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<boolean> {
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}`;
}
@@ -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 ||
+75 -80
View File
@@ -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<EpaySessionStatus>({
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
>
<a
href={`/api/ancpi/download?id=${order.id}`}
href={cfDownloadUrl(useGisAc, order.id)}
target="_blank"
rel="noopener noreferrer"
>
@@ -761,7 +756,7 @@ export function EpayTab() {
asChild
>
<a
href={`/api/ancpi/download?id=${order.id}`}
href={cfDownloadUrl(useGisAc, order.id)}
target="_blank"
rel="noopener noreferrer"
>