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
@@ -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 ||