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
+82
View File
@@ -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-<id>.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 },
);
}
}
+87
View File
@@ -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<CfExtractRow>;
try {
body = (await request.json()) as Partial<CfExtractRow>;
} 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 },
);
}
}
+44
View File
@@ -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 },
);
}
}
+71
View File
@@ -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 },
);
}
}
+50
View File
@@ -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 },
);
}
}