fix(cf): merge ePay + intern extracts into a single Extrase CF list
Yesterday's pin to /api/ancpi exposed a real architecture split: there are two CfExtract stores with no overlap and the previous pilot routing only ever showed one at a time: architools_postgres.CfExtract → ePay paid orders (type=epay) gis_core.CfExtract via gis-api → CF intern (type=intern) The pin made today's 50198 ePay visible but hid the 51 historic intern rows; the pre-pin state was the opposite. Neither was right — users think of "my CF extracts" as one timeline regardless of source. Revert the pin and add client-side merge for pilot users (`useGisAc=true`): fetchCfOrdersList now fans out to both /api/ancpi/orders and /api/cf/orders in parallel, normalizes each row through a dedicated adapter (legacy or gisApi), dedupes by id, and sorts by createdAt descending. fetchCfHas- CompletedForCadastral checks both backends too (either a fresh intern or a recent ePay row means "you already have one"). CfExtractRecord grows a required `type: 'epay' | 'intern'` field; the existing rendering adds a small colored badge (sky=intern, emerald=ePay) next to the status pill so users can tell where each row came from at a glance. cfDownloadUrl is now type-aware — intern rows download via /api/cf/:id/pdf, ePay rows via /api/ancpi/download regardless of pilot flag, matching how each store keeps its files. Legacy (useGisAc, id) signature still works for the few call sites that don't have the full row in scope. No data was deleted yesterday; the 51 intern rows in gis_core stayed intact (verified via gis_superuser). The single edit was cancelling the stuck 354686 pending row from 2026-05-19. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,29 @@
|
|||||||
// Plan 003, Faza F — feature-flag glue for ePay/CF UI.
|
// Plan 003, Faza F — feature-flag glue for ePay/CF UI.
|
||||||
//
|
//
|
||||||
// The legacy backend lives under `/api/ancpi/*` and is backed by the local
|
// Two backends with overlapping but distinct semantics:
|
||||||
// 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`
|
// /api/ancpi/* → architools_postgres.CfExtract → ePay paid orders
|
||||||
// and we route their UI through the new endpoints. Everyone else keeps the
|
// (`type=epay`, 1 credit each, written by the local ePay queue).
|
||||||
// legacy path unchanged.
|
// /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
|
// They are NOT the same dataset. A user's complete CF history is the
|
||||||
// existing UI shape (CfExtractRecord) and call `adaptCfRow` to convert
|
// union. Pilot users (`useGisAc=true`) merge both client-side so the
|
||||||
// gisApi.CfExtractRow → CfExtractRecord on read.
|
// 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";
|
import type { CfExtractRow } from "@/lib/gis-api-client";
|
||||||
|
|
||||||
export function cfApiBase(_useGisAc: boolean): string {
|
export function cfApiBase(useGisAc: boolean): string {
|
||||||
// Temporarily pinned to legacy /api/ancpi until Faza H lands.
|
return useGisAc ? "/api/cf" : "/api/ancpi";
|
||||||
// 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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI-side row shape (mirrors what epay-tab.tsx already expects).
|
// UI-side row shape (mirrors what epay-tab.tsx already expects).
|
||||||
export type CfExtractRecord = {
|
export type CfExtractRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** epay | intern — drives the source badge + download URL routing. */
|
||||||
|
type: "epay" | "intern";
|
||||||
orderId: string | null;
|
orderId: string | null;
|
||||||
nrCadastral: string;
|
nrCadastral: string;
|
||||||
nrCF: string | null;
|
nrCF: string | null;
|
||||||
@@ -47,12 +43,61 @@ export type CfExtractRecord = {
|
|||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape.
|
type LegacyCfExtract = {
|
||||||
// Fields not exposed by gis-api are filled with safe defaults so the UI
|
id: string;
|
||||||
// keeps rendering without conditional branches.
|
type?: string | null;
|
||||||
export function adaptCfRow(row: CfExtractRow): CfExtractRecord {
|
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 {
|
return {
|
||||||
id: row.id,
|
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,
|
orderId: row.orderId ?? null,
|
||||||
nrCadastral: row.nrCadastral,
|
nrCadastral: row.nrCadastral,
|
||||||
nrCF: row.nrCF ?? null,
|
nrCF: row.nrCF ?? null,
|
||||||
@@ -73,47 +118,112 @@ 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<CfExtractRow & { type?: string }>;
|
||||||
|
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.
|
// 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(
|
export async function fetchCfOrdersList(
|
||||||
useGisAc: boolean,
|
useGisAc: boolean,
|
||||||
params: { limit?: number; nrCadastral?: string; status?: string } = {},
|
params: { limit?: number; nrCadastral?: string; status?: string } = {},
|
||||||
): Promise<{ orders: CfExtractRecord[]; total: number }> {
|
): Promise<{ orders: CfExtractRecord[]; total: number }> {
|
||||||
const base = cfApiBase(useGisAc);
|
if (!useGisAc) {
|
||||||
const qs = new URLSearchParams();
|
return fetchLegacy(params);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<string>();
|
||||||
|
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 {
|
return {
|
||||||
orders: (data as { orders?: CfExtractRecord[] }).orders ?? [],
|
orders: params.limit ? dedup.slice(0, params.limit) : dedup,
|
||||||
total: (data as { total?: number }).total ?? 0,
|
total,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existence check used by the per-parcel order button. On the legacy
|
// Existence check used by the per-parcel order button. We check both
|
||||||
// backend this hits /api/ancpi/orders?nrCadastral=…&status=completed; on
|
// backends on pilot accounts — a fresh cf-intern in gis_core OR an ePay
|
||||||
// the gis-ac backend it uses the catalog endpoint (cheaper, RLS-safe).
|
// extract in architools_postgres both mean "you already have one".
|
||||||
export async function fetchCfHasCompletedForCadastral(
|
export async function fetchCfHasCompletedForCadastral(
|
||||||
useGisAc: boolean,
|
useGisAc: boolean,
|
||||||
nrCadastral: string,
|
nrCadastral: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const checks: Array<Promise<boolean>> = [];
|
||||||
|
// 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) {
|
if (useGisAc) {
|
||||||
|
checks.push(
|
||||||
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/cf/catalog?nrCadastral=${encodeURIComponent(nrCadastral)}`,
|
`/api/cf/catalog?nrCadastral=${encodeURIComponent(nrCadastral)}`,
|
||||||
@@ -124,16 +234,11 @@ export async function fetchCfHasCompletedForCadastral(
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
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;
|
|
||||||
}
|
}
|
||||||
|
const results = await Promise.all(checks);
|
||||||
|
return results.some(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-parcel order placement. The two endpoints take different body
|
// 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
|
// Per-row download URL. Two call shapes:
|
||||||
// streams via /api/cf/[id]/pdf.
|
// - cfDownloadUrl(row) — new, type-aware (preferred).
|
||||||
export function cfDownloadUrl(useGisAc: boolean, id: string): string {
|
// Routes intern → /api/cf, ePay → /api/ancpi regardless of pilot flag.
|
||||||
return useGisAc ? `/api/cf/${encodeURIComponent(id)}/pdf` : `/api/ancpi/download?id=${id}`;
|
// - 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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -670,6 +670,7 @@ export function EpayTab() {
|
|||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium",
|
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium",
|
||||||
@@ -679,6 +680,22 @@ export function EpayTab() {
|
|||||||
>
|
>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-1.5 py-0.5 text-[9px] font-medium",
|
||||||
|
order.type === "intern"
|
||||||
|
? "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
||||||
|
: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
order.type === "intern"
|
||||||
|
? "CF intern (gratuit, copycf)"
|
||||||
|
: "Extras CF ePay (1 credit ANCPI)"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{order.type === "intern" ? "intern" : "ePay"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{order.errorMessage && (
|
{order.errorMessage && (
|
||||||
<p
|
<p
|
||||||
className="text-[10px] text-destructive mt-0.5 max-w-48 truncate"
|
className="text-[10px] text-destructive mt-0.5 max-w-48 truncate"
|
||||||
@@ -730,7 +747,7 @@ export function EpayTab() {
|
|||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={cfDownloadUrl(useGisAc, order.id)}
|
href={cfDownloadUrl(order)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
@@ -756,7 +773,7 @@ export function EpayTab() {
|
|||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={cfDownloadUrl(useGisAc, order.id)}
|
href={cfDownloadUrl(order)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user