diff --git a/src/modules/parcel-sync/components/cf-api-base.ts b/src/modules/parcel-sync/components/cf-api-base.ts index 2e69413..4d2f070 100644 --- a/src/modules/parcel-sync/components/cf-api-base.ts +++ b/src/modules/parcel-sync/components/cf-api-base.ts @@ -1,33 +1,29 @@ // 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). +// Two backends with overlapping but distinct semantics: // -// 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. +// /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). // -// Response shapes differ between the two backends. Components keep the -// existing UI shape (CfExtractRecord) and call `adaptCfRow` to convert -// gisApi.CfExtractRow → CfExtractRecord on read. +// 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"; -export function cfApiBase(_useGisAc: boolean): string { - // Temporarily pinned to legacy /api/ancpi until Faza H lands. - // Background: pilot users (useGisAc=true) were routed to /api/cf - // which proxies to gis-api → gis_core."CfExtract". But the ePay queue - // still writes only to architools_postgres."CfExtract" (legacy). Pilot - // users were missing their own fresh orders in the listing + catalog - // checks. Until the queue mirrors writes to gis-api, every caller - // uses the legacy endpoint so the read source matches the write source. - return "/api/ancpi"; +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; @@ -47,12 +43,61 @@ export type CfExtractRecord = { 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 { +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, @@ -73,67 +118,127 @@ export function adaptCfRow(row: CfExtractRow): CfExtractRecord { }; } +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. -// Caller passes `useGisAc` (already read from useSession()). +// 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 }> { - 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, - }; + 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: (data as { orders?: CfExtractRecord[] }).orders ?? [], - total: (data as { total?: number }).total ?? 0, + orders: params.limit ? dedup.slice(0, params.limit) : dedup, + total, }; } -// 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). +// 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) { - 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`, + 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 data = (await res.json()) as { total?: number }; - return Boolean(data?.total && data.total > 0); - } catch { - return false; } + const results = await Promise.all(checks); + return results.some(Boolean); } // Per-parcel order placement. The two endpoints take different body @@ -204,8 +309,23 @@ export async function placeCfOrder( } } -// 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}`; +// 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}`; } diff --git a/src/modules/parcel-sync/components/epay-tab.tsx b/src/modules/parcel-sync/components/epay-tab.tsx index 174292a..ccbe28f 100644 --- a/src/modules/parcel-sync/components/epay-tab.tsx +++ b/src/modules/parcel-sync/components/epay-tab.tsx @@ -670,15 +670,32 @@ export function EpayTab() { {/* Status */} - - {badge.label} - +
+ + {badge.label} + + + {order.type === "intern" ? "intern" : "ePay"} + +
{order.errorMessage && (

@@ -756,7 +773,7 @@ export function EpayTab() { asChild >