// Plan 003, Faza F — feature-flag glue for ePay/CF UI. // // Two backends with overlapping but distinct semantics: // // /api/ancpi/* → architools_postgres.CfExtract → ePay paid orders // (`type=epay`, 1 credit each, written by the local ePay queue). // /api/cf/* → gis-api → gis_core.CfExtract → CF intern downloads // (`type=intern`, free copycf flow, plus historic non-ePay rows). // // They are NOT the same dataset. A user's complete CF history is the // union. Pilot users (`useGisAc=true`) merge both client-side so the // Extrase CF tab shows one timeline. Non-pilot users see only the // legacy ePay flow (their gis_core rows are typically empty anyway — // the cf-intern path is wired through gis-api which only pilots use). import type { CfExtractRow } from "@/lib/gis-api-client"; // GUARD (2026-06-04): ePay ordering via gis-api is NOT live yet. gis-api's // POST /enrichment/cf only inserts a pending CfExtract row — there is no // orchestrator-side worker that executes the paid ePay purchase, so orders // placed through /api/cf/order silently never complete (UI even shows // "Extras CF valid"). Until that fulfiller ships and is verified, ALL paid // ePay orders MUST go through the legacy local queue (/api/ancpi/order). // Flip to true only after the orchestrator ePay worker is deployed. export const EPAY_ORDERING_VIA_GIS_AC = false; 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; /** epay | intern — drives the source badge + download URL routing. */ type: "epay" | "intern"; 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 LegacyCfExtract = { id: string; type?: string | null; orderId?: string | null; nrCadastral: string; nrCF?: string | null; siruta?: string | null; judetName?: string | null; uatName?: string | null; status: string; epayStatus?: string | null; documentName?: string | null; documentDate?: string | null; minioPath?: string | null; expiresAt?: string | null; errorMessage?: string | null; version?: number | null; creditsUsed?: number | null; createdAt: string; completedAt?: string | null; }; function adaptLegacyRow(row: LegacyCfExtract): CfExtractRecord { return { id: row.id, type: row.type === "intern" ? "intern" : "epay", orderId: row.orderId ?? null, nrCadastral: row.nrCadastral, nrCF: row.nrCF ?? null, siruta: row.siruta ?? null, judetName: row.judetName ?? "", uatName: row.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: row.errorMessage ?? null, version: row.version ?? 1, creditsUsed: row.creditsUsed ?? 0, createdAt: row.createdAt, completedAt: row.completedAt ?? null, }; } // Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape. // gis-api currently doesn't surface uatName/siruta/judetName on the list // endpoint, so we leave them blank; the row type defaults to "intern" // because gis_core's CfExtract is the cf-intern store (the cutover plan // hasn't yet moved ePay writes here). export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractRecord { return { id: row.id, type: row.type === "epay" ? "epay" : "intern", 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, }; } async function fetchLegacy( params: { limit?: number; nrCadastral?: string; status?: string }, ): Promise<{ orders: CfExtractRecord[]; total: number }> { const qs = new URLSearchParams(); if (params.limit !== undefined) qs.set("limit", String(params.limit)); if (params.status) qs.set("status", params.status); if (params.nrCadastral) qs.set("nrCadastral", params.nrCadastral); const url = `/api/ancpi/orders${qs.toString() ? `?${qs.toString()}` : ""}`; const res = await fetch(url); const data = (await res.json()) as { orders?: LegacyCfExtract[]; total?: number }; return { orders: (data.orders ?? []).map(adaptLegacyRow), total: data.total ?? 0, }; } async function fetchGisAc( params: { limit?: number; status?: string }, ): Promise<{ orders: CfExtractRecord[]; total: number }> { const qs = new URLSearchParams(); if (params.limit !== undefined) qs.set("limit", String(params.limit)); if (params.status) qs.set("status", params.status); const url = `/api/cf/orders${qs.toString() ? `?${qs.toString()}` : ""}`; const res = await fetch(url); const data = (await res.json()) as { rows?: Array; total?: number; }; const rows = data.rows ?? []; return { orders: rows.map(adaptCfRow), total: data.total ?? rows.length, }; } // Fetch the orders list and normalize the response into a single shape. // Pilot users (`useGisAc=true`) merge the two backends client-side so a // single timeline shows ePay + intern history together. Sort newest- // first; dedupe by id (in case the same record ever lands in both // stores during the cutover migration). export async function fetchCfOrdersList( useGisAc: boolean, params: { limit?: number; nrCadastral?: string; status?: string } = {}, ): Promise<{ orders: CfExtractRecord[]; total: number }> { if (!useGisAc) { return fetchLegacy(params); } // Pull more rows from each side than the caller asked for so that the // merge+truncate keeps a representative window from both backends. const fetchLimit = params.limit ? Math.min(params.limit * 2, 400) : undefined; const fetchParams = { ...params, limit: fetchLimit }; const [legacy, gisac] = await Promise.allSettled([ fetchLegacy(fetchParams), fetchGisAc(fetchParams), ]); const merged: CfExtractRecord[] = []; if (legacy.status === "fulfilled") merged.push(...legacy.value.orders); if (gisac.status === "fulfilled") merged.push(...gisac.value.orders); const seen = new Set(); const dedup = merged.filter((r) => { if (seen.has(r.id)) return false; seen.add(r.id); return true; }); dedup.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); const total = (legacy.status === "fulfilled" ? legacy.value.total : 0) + (gisac.status === "fulfilled" ? gisac.value.total : 0); return { orders: params.limit ? dedup.slice(0, params.limit) : dedup, total, }; } // Existence check used by the per-parcel order button. We check both // backends on pilot accounts — a fresh cf-intern in gis_core OR an ePay // extract in architools_postgres both mean "you already have one". export async function fetchCfHasCompletedForCadastral( useGisAc: boolean, nrCadastral: string, ): Promise { const checks: Array> = []; // Always check legacy (ePay store) — every user can have ePay rows. checks.push( (async () => { 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; } })(), ); if (useGisAc) { checks.push( (async () => { 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; } })(), ); } const results = await Promise.all(checks); return results.some(Boolean); } // 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 }> { // See EPAY_ORDERING_VIA_GIS_AC — gis-api can't fulfill paid orders yet, // so the pilot flag alone must NOT route ordering away from the queue. if (useGisAc && EPAY_ORDERING_VIA_GIS_AC) { 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. Two call shapes: // - cfDownloadUrl(row) — new, type-aware (preferred). // Routes intern → /api/cf, ePay → /api/ancpi regardless of pilot flag. // - cfDownloadUrl(useGisAc, id) — legacy, falls back to the // old per-pilot split. Kept for unchanged call sites that don't // have the full row. export function cfDownloadUrl( a: { id: string; type?: "epay" | "intern" } | boolean, b?: string, ): string { if (typeof a === "boolean") { const id = b ?? ""; return a ? `/api/cf/${encodeURIComponent(id)}/pdf` : `/api/ancpi/download?id=${id}`; } return a.type === "intern" ? `/api/cf/${encodeURIComponent(a.id)}/pdf` : `/api/ancpi/download?id=${a.id}`; }