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:
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user