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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user