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,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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { FileText, Loader2, Check, RefreshCw } from "lucide-react";
|
import { FileText, Loader2, Check, RefreshCw } from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +12,10 @@ import {
|
|||||||
} from "@/shared/components/ui/tooltip";
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import type { EpaySessionStatus } from "./epay-connect";
|
import type { EpaySessionStatus } from "./epay-connect";
|
||||||
|
import {
|
||||||
|
fetchCfHasCompletedForCadastral,
|
||||||
|
placeCfOrder,
|
||||||
|
} from "./cf-api-base";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Props */
|
/* Props */
|
||||||
@@ -39,6 +44,12 @@ export function EpayOrderButton({
|
|||||||
label,
|
label,
|
||||||
tooltipText,
|
tooltipText,
|
||||||
}: Props) {
|
}: 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 [ordering, setOrdering] = useState(false);
|
||||||
const [ordered, setOrdered] = useState(false);
|
const [ordered, setOrdered] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -59,19 +70,20 @@ export function EpayOrderButton({
|
|||||||
|
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
try {
|
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 sRes = await fetch("/api/ancpi/session");
|
||||||
const sData = (await sRes.json()) as EpaySessionStatus;
|
const sData = (await sRes.json()) as EpaySessionStatus;
|
||||||
if (!cancelled) setEpayStatus(sData);
|
if (!cancelled) setEpayStatus(sData);
|
||||||
|
|
||||||
// Check if a completed extract already exists
|
// Check if a completed/fresh extract already exists for this
|
||||||
const oRes = await fetch(
|
// cadastral. Routes the call through legacy or gis-ac per flag.
|
||||||
`/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrCadastral)}&status=completed&limit=1`,
|
const has = await fetchCfHasCompletedForCadastral(
|
||||||
|
useGisAc,
|
||||||
|
nrCadastral,
|
||||||
);
|
);
|
||||||
const oData = (await oRes.json()) as { orders?: unknown[]; total?: number };
|
if (!cancelled && has) setOrdered(true);
|
||||||
if (!cancelled && oData.total && oData.total > 0) {
|
|
||||||
setOrdered(true);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* silent */
|
/* silent */
|
||||||
}
|
}
|
||||||
@@ -81,40 +93,26 @@ export function EpayOrderButton({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [nrCadastral]);
|
}, [nrCadastral, useGisAc]);
|
||||||
|
|
||||||
const handleOrder = useCallback(async () => {
|
const handleOrder = useCallback(async () => {
|
||||||
setOrdering(true);
|
setOrdering(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
const result = await placeCfOrder(useGisAc, {
|
||||||
const res = await fetch("/api/ancpi/order", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
parcels: [
|
|
||||||
{
|
|
||||||
nrCadastral,
|
nrCadastral,
|
||||||
siruta,
|
siruta,
|
||||||
judetIndex: 0, // server resolves from SIRUTA
|
|
||||||
judetName,
|
judetName,
|
||||||
uatId: 0, // server resolves from SIRUTA
|
|
||||||
uatName,
|
uatName,
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as { orders?: unknown[]; error?: string };
|
if (mountedRef.current) {
|
||||||
if (!res.ok || data.error) {
|
if (result.ok) {
|
||||||
if (mountedRef.current) setError(data.error ?? "Eroare comanda");
|
setOrdered(true);
|
||||||
} else {
|
} else {
|
||||||
if (mountedRef.current) setOrdered(true);
|
setError(result.error ?? "Eroare comanda");
|
||||||
}
|
}
|
||||||
} catch {
|
setOrdering(false);
|
||||||
if (mountedRef.current) setError("Eroare retea");
|
|
||||||
} finally {
|
|
||||||
if (mountedRef.current) setOrdering(false);
|
|
||||||
}
|
}
|
||||||
}, [nrCadastral, siruta, judetName, uatName]);
|
}, [nrCadastral, siruta, judetName, uatName, useGisAc]);
|
||||||
|
|
||||||
const disabled =
|
const disabled =
|
||||||
!epayStatus.connected ||
|
!epayStatus.connected ||
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Download,
|
Download,
|
||||||
@@ -23,31 +24,12 @@ import {
|
|||||||
} from "@/shared/components/ui/tooltip";
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import type { EpaySessionStatus } from "./epay-connect";
|
import type { EpaySessionStatus } from "./epay-connect";
|
||||||
|
import {
|
||||||
/* ------------------------------------------------------------------ */
|
cfDownloadUrl,
|
||||||
/* Types */
|
fetchCfOrdersList,
|
||||||
/* ------------------------------------------------------------------ */
|
placeCfOrder,
|
||||||
|
type CfExtractRecord,
|
||||||
type CfExtractRecord = {
|
} from "./cf-api-base";
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GisUatResult = {
|
type GisUatResult = {
|
||||||
siruta: string;
|
siruta: string;
|
||||||
@@ -165,6 +147,12 @@ const FILTER_OPTIONS: { value: FilterValue; label: string }[] = [
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function EpayTab() {
|
export function EpayTab() {
|
||||||
|
/* -- Cutover flag (Plan 003, Faza F) ----------------------------- */
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const useGisAc = Boolean(
|
||||||
|
(session as { useGisAc?: boolean } | null)?.useGisAc,
|
||||||
|
);
|
||||||
|
|
||||||
/* -- ePay session ------------------------------------------------ */
|
/* -- ePay session ------------------------------------------------ */
|
||||||
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -193,11 +181,24 @@ export function EpayTab() {
|
|||||||
if (selectedIds.length === 0) return;
|
if (selectedIds.length === 0) return;
|
||||||
setDownloadingSelection(true);
|
setDownloadingSelection(true);
|
||||||
try {
|
try {
|
||||||
|
// 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 ids = selectedIds.join(",");
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = `/api/ancpi/download-zip?ids=${encodeURIComponent(ids)}`;
|
a.href = `/api/ancpi/download-zip?ids=${encodeURIComponent(ids)}`;
|
||||||
a.download = `Extrase_CF_selectie_${selectedIds.length}.zip`;
|
a.download = `Extrase_CF_selectie_${selectedIds.length}.zip`;
|
||||||
a.click();
|
a.click();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => setDownloadingSelection(false), 2000);
|
setTimeout(() => setDownloadingSelection(false), 2000);
|
||||||
}
|
}
|
||||||
@@ -234,11 +235,7 @@ export function EpayTab() {
|
|||||||
async (showRefreshing = false) => {
|
async (showRefreshing = false) => {
|
||||||
if (showRefreshing) setRefreshing(true);
|
if (showRefreshing) setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/ancpi/orders?limit=200");
|
const data = await fetchCfOrdersList(useGisAc, { limit: 200 });
|
||||||
const data = (await res.json()) as {
|
|
||||||
orders: CfExtractRecord[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
setOrders(data.orders);
|
setOrders(data.orders);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -248,7 +245,7 @@ export function EpayTab() {
|
|||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[useGisAc],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* -- Initial load ------------------------------------------------ */
|
/* -- Initial load ------------------------------------------------ */
|
||||||
@@ -307,34 +304,18 @@ export function EpayTab() {
|
|||||||
|
|
||||||
/* -- Re-order (for expired extracts) ----------------------------- */
|
/* -- Re-order (for expired extracts) ----------------------------- */
|
||||||
const handleReorder = async (order: CfExtractRecord) => {
|
const handleReorder = async (order: CfExtractRecord) => {
|
||||||
try {
|
const result = await placeCfOrder(useGisAc, {
|
||||||
const res = await fetch("/api/ancpi/order", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
parcels: [
|
|
||||||
{
|
|
||||||
nrCadastral: order.nrCadastral,
|
nrCadastral: order.nrCadastral,
|
||||||
nrCF: order.nrCF,
|
nrCF: order.nrCF,
|
||||||
siruta: order.siruta,
|
siruta: order.siruta,
|
||||||
judetIndex: 0,
|
|
||||||
judetName: order.judetName,
|
judetName: order.judetName,
|
||||||
uatId: 0,
|
|
||||||
uatName: order.uatName,
|
uatName: order.uatName,
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as { error?: string };
|
if (result.ok) {
|
||||||
if (!res.ok || data.error) {
|
|
||||||
/* show inline later */
|
|
||||||
} else {
|
|
||||||
void fetchOrders(true);
|
void fetchOrders(true);
|
||||||
void fetchEpayStatus();
|
void fetchEpayStatus();
|
||||||
}
|
}
|
||||||
} catch {
|
/* errors surfaced inline via downstream polling later */
|
||||||
/* silent */
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* -- Download all valid as ZIP ----------------------------------- */
|
/* -- Download all valid as ZIP ----------------------------------- */
|
||||||
@@ -346,6 +327,17 @@ export function EpayTab() {
|
|||||||
|
|
||||||
setDownloadingAll(true);
|
setDownloadingAll(true);
|
||||||
try {
|
try {
|
||||||
|
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 ids = validOrders.map((o) => o.id).join(",");
|
||||||
const res = await fetch(`/api/ancpi/download-zip?ids=${ids}`);
|
const res = await fetch(`/api/ancpi/download-zip?ids=${ids}`);
|
||||||
if (!res.ok) throw new Error("Eroare descarcare ZIP");
|
if (!res.ok) throw new Error("Eroare descarcare ZIP");
|
||||||
@@ -353,7 +345,9 @@ export function EpayTab() {
|
|||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const cd = res.headers.get("Content-Disposition") ?? "";
|
const cd = res.headers.get("Content-Disposition") ?? "";
|
||||||
const match = /filename="?([^"]+)"?/.exec(cd);
|
const match = /filename="?([^"]+)"?/.exec(cd);
|
||||||
const filename = match?.[1] ? decodeURIComponent(match[1]) : "Extrase_CF.zip";
|
const filename = match?.[1]
|
||||||
|
? decodeURIComponent(match[1])
|
||||||
|
: "Extrase_CF.zip";
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -363,6 +357,7 @@ export function EpayTab() {
|
|||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* silent */
|
/* silent */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -735,7 +730,7 @@ export function EpayTab() {
|
|||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={`/api/ancpi/download?id=${order.id}`}
|
href={cfDownloadUrl(useGisAc, order.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
@@ -761,7 +756,7 @@ export function EpayTab() {
|
|||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={`/api/ancpi/download?id=${order.id}`}
|
href={cfDownloadUrl(useGisAc, order.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user