feat(ancpi): complete ePay UI + dedup protection

UI Components (Phase 4):
- epay-connect.tsx: connection widget with credit badge, auto-connect
- epay-order-button.tsx: per-parcel "Extras CF" button with status
- epay-tab.tsx: full "Extrase CF" tab with orders table, filters,
  download/refresh actions, new order form
- Minimal changes to parcel-sync-module.tsx: 5th tab + button on
  search results + ePay connect widget

Dedup Protection:
- epay-queue.ts: batch-level dedup (60s window, canonical key from
  sorted cadastral numbers)
- order/route.ts: request nonce idempotency (60s cache)
- test/route.ts: refresh protection (30s cache)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 04:19:19 +02:00
parent fcc6f8cc20
commit c9ecd284c7
7 changed files with 1221 additions and 26 deletions
+53 -1
View File
@@ -40,8 +40,37 @@ type BatchJob = {
const g = globalThis as {
__epayBatchQueue?: BatchJob[];
__epayQueueProcessing?: boolean;
__epayDedupMap?: Map<string, { timestamp: number; extractIds: string[] }>;
};
if (!g.__epayBatchQueue) g.__epayBatchQueue = [];
if (!g.__epayDedupMap) g.__epayDedupMap = new Map();
/** TTL for dedup entries in milliseconds (60 seconds). */
const DEDUP_TTL_MS = 60_000;
/**
* Build a dedup key from a list of cadastral numbers.
* Sorted and joined so order doesn't matter.
*/
function batchDedupKey(inputs: CfExtractCreateInput[]): string {
return inputs
.map((i) => i.nrCadastral)
.sort()
.join(",");
}
/**
* Remove expired entries from the dedup map.
*/
function cleanupDedupMap(): void {
const now = Date.now();
const map = g.__epayDedupMap!;
for (const [key, entry] of map) {
if (now - entry.timestamp > DEDUP_TTL_MS) {
map.delete(key);
}
}
}
/* ------------------------------------------------------------------ */
/* Public API */
@@ -64,12 +93,27 @@ export async function enqueueOrder(
* Enqueue a batch of CF extract orders.
* Creates all DB records, then processes as ONE ePay order.
* Returns the CfExtract IDs immediately.
*
* Dedup protection: if the same set of cadastral numbers was enqueued
* within the last 60 seconds, returns the existing extract IDs instead
* of creating duplicate DB records and orders.
*/
export async function enqueueBatch(
inputs: CfExtractCreateInput[],
): Promise<string[]> {
if (inputs.length === 0) return [];
// ── Dedup check ──
cleanupDedupMap();
const dedupKey = batchDedupKey(inputs);
const existing = g.__epayDedupMap!.get(dedupKey);
if (existing && Date.now() - existing.timestamp < DEDUP_TTL_MS) {
console.log(
`[epay-queue] Dedup hit: batch [${dedupKey}] was enqueued ${Math.round((Date.now() - existing.timestamp) / 1000)}s ago — returning existing IDs`,
);
return existing.extractIds;
}
const items: QueueItem[] = [];
for (const input of inputs) {
@@ -99,6 +143,14 @@ export async function enqueueBatch(
items.push({ extractId: record.id, input });
}
const extractIds = items.map((i) => i.extractId);
// ── Record in dedup map ──
g.__epayDedupMap!.set(dedupKey, {
timestamp: Date.now(),
extractIds,
});
g.__epayBatchQueue!.push({ items });
console.log(
@@ -108,7 +160,7 @@ export async function enqueueBatch(
// Start processing if not already running
void processQueue();
return items.map((i) => i.extractId);
return extractIds;
}
/**