From 03ac13d951d01c1d01cdd8cc190704d1cb138cce Mon Sep 17 00:00:00 2001 From: DEls Date: Sat, 19 Sep 2026 20:56:25 +0200 Subject: [PATCH] fix: keep queue model server safe --- .../delivery-notes/work-queue/page.tsx | 3 +- .../work-queue/work-queue-model.ts | 42 ++++++++++++ .../work-queue/work-queue-view.tsx | 66 +------------------ .../work-queue/work-queue.test.ts | 14 +++- 4 files changed, 57 insertions(+), 68 deletions(-) create mode 100644 src/app/dashboard/financial/delivery-notes/work-queue/work-queue-model.ts diff --git a/src/app/dashboard/financial/delivery-notes/work-queue/page.tsx b/src/app/dashboard/financial/delivery-notes/work-queue/page.tsx index a4ca53f..7098e56 100644 --- a/src/app/dashboard/financial/delivery-notes/work-queue/page.tsx +++ b/src/app/dashboard/financial/delivery-notes/work-queue/page.tsx @@ -2,7 +2,8 @@ import { Topbar } from "@/components/layout/sidebar"; import { pbAdmin } from "@/lib/pb"; import { composeFilter, filterOpt, siteFilter, type SearchParams } from "@/lib/site-scope"; import { getSiteScope } from "@/lib/site-scope-server"; -import { WorkQueueView, mapCandidateRecord, matchesCandidate } from "./work-queue-view"; +import { WorkQueueView } from "./work-queue-view"; +import { mapCandidateRecord, matchesCandidate } from "./work-queue-model"; export const dynamic = "force-dynamic"; diff --git a/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-model.ts b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-model.ts new file mode 100644 index 0000000..cc76dd8 --- /dev/null +++ b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-model.ts @@ -0,0 +1,42 @@ +import { displayCandidateStage } from "@/lib/dn-pipeline/stages"; + +export type QueueStage = "matching" | "needs_review" | "ready_to_post" | "posted" | "dismissed"; +export interface WorkQueueCandidate { + id: string; siteId: string; siteName: string; supplierId: string; supplierName: string; + purchaseOrderId: string; purchaseOrderNumber: string; documentId: string; documentNumber: string; + documentType: string; documentDate: string; batchId: string; batchName: string; stage: QueueStage; + reason: string; matchMethod: string; unresolvedLineItems: number; +} + +export function mapCandidateRecord(row: Record): WorkQueueCandidate { + const batch = row.expand?.batch ?? {}; + const site = batch.expand?.site ?? row.expand?.delivery_note?.expand?.site ?? {}; + const supplier = row.expand?.supplier ?? row.expand?.purchase_order?.expand?.supplier_ref ?? {}; + const po = row.expand?.purchase_order ?? {}; + const deliveryNote = row.expand?.delivery_note ?? {}; + const legacyPosted = row.status === "posted" || row.status === "promoted" || row.stage === "posted" || Boolean(row.posted_at); + const checks = Array.isArray(row.checks) ? row.checks : []; + const unresolvedLineItems = Array.isArray(row.items) ? row.items.filter((item: any) => !item?.purchase_order_item && !item?.purchaseOrderItemId).length : Number(row.unresolved_line_items ?? 0); + const reason = checks.find((check: any) => check?.passed === false)?.reason ?? row.reason ?? "Needs review"; + return { + id: String(row.id ?? ""), siteId: String(row.site ?? batch.site ?? deliveryNote.site ?? ""), siteName: String(site.name ?? "Unknown site"), + supplierId: String(row.supplier ?? po.supplier_ref ?? ""), supplierName: String(supplier.name ?? row.supplier_name_raw ?? po.supplier ?? ""), + purchaseOrderId: String(row.purchase_order ?? deliveryNote.purchase_order ?? ""), purchaseOrderNumber: String(row.doc_po_number ?? po.purchase_order_number ?? row.po_number_raw ?? ""), + documentId: String(row.delivery_note ?? deliveryNote.id ?? ""), documentNumber: String(row.doc_number ?? (legacyPosted ? row.delivery_note_number ?? deliveryNote.delivery_note_number : "")), + documentType: String(row.doc_type ?? (legacyPosted ? deliveryNote.document_type : "unknown")), documentDate: String(row.doc_date || (legacyPosted ? deliveryNote.date_received : "") || row.posted_at || batch.uploaded_at || ""), + batchId: String(row.batch ?? batch.id ?? ""), batchName: String(batch.file ?? batch.id ?? ""), + stage: displayCandidateStage(String(row.status ?? ""), String(row.stage ?? "")), reason, matchMethod: String(row.match_method ?? ""), unresolvedLineItems, + }; +} + +export function matchesCandidate(candidate: WorkQueueCandidate, params: Record): boolean { + const value = (key: string) => params[key] ?? ""; + return [["site", candidate.siteId, candidate.siteName], ["supplier", candidate.supplierId, candidate.supplierName], ["purchase_order", candidate.purchaseOrderId, candidate.purchaseOrderNumber], ["stage", candidate.stage], ["reason", candidate.reason], ["document_type", candidate.documentType]].every(([key, ...values]) => !value(key) || values.some((item) => String(item).toLowerCase() === value(key).toLowerCase())); +} + +export function buildQueueHref(current: Record, changes: Record): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries({ ...current, ...changes })) if (value) params.set(key, value); + const query = params.toString(); + return `/dashboard/financial/delivery-notes/work-queue${query ? `?${query}` : ""}`; +} diff --git a/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-view.tsx b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-view.tsx index 7834f78..ca77d5f 100644 --- a/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-view.tsx +++ b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-view.tsx @@ -4,29 +4,8 @@ import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { displayCandidateStage } from "@/lib/dn-pipeline/stages"; - -export type QueueStage = "matching" | "needs_review" | "ready_to_post" | "posted" | "dismissed"; - -export interface WorkQueueCandidate { - id: string; - siteId: string; - siteName: string; - supplierId: string; - supplierName: string; - purchaseOrderId: string; - purchaseOrderNumber: string; - documentId: string; - documentNumber: string; - documentType: string; - documentDate: string; - batchId: string; - batchName: string; - stage: QueueStage; - reason: string; - matchMethod: string; - unresolvedLineItems: number; -} +import { buildQueueHref, type WorkQueueCandidate } from "./work-queue-model"; +export type { WorkQueueCandidate } from "./work-queue-model"; export interface QueuePoGroup { key: string; @@ -43,35 +22,6 @@ export interface QueueGroup { purchaseOrders: QueuePoGroup[]; } -export function mapCandidateRecord(row: Record): WorkQueueCandidate { - const batch = row.expand?.batch ?? {}; - const site = batch.expand?.site ?? row.expand?.delivery_note?.expand?.site ?? {}; - const supplier = row.expand?.supplier ?? row.expand?.purchase_order?.expand?.supplier_ref ?? {}; - const po = row.expand?.purchase_order ?? {}; - const deliveryNote = row.expand?.delivery_note ?? {}; - const legacyPosted = row.status === "posted" || row.status === "promoted" || row.stage === "posted" || Boolean(row.posted_at); - const checks = Array.isArray(row.checks) ? row.checks : []; - const unresolvedLineItems = Array.isArray(row.items) - ? row.items.filter((item: any) => !item?.purchase_order_item && !item?.purchaseOrderItemId).length - : Number(row.unresolved_line_items ?? 0); - const reason = checks.find((check: any) => check?.passed === false)?.reason ?? row.reason ?? "Needs review"; - return { - id: String(row.id ?? ""), siteId: String(row.site ?? batch.site ?? deliveryNote.site ?? ""), siteName: String(site.name ?? "Unknown site"), - supplierId: String(row.supplier ?? po.supplier_ref ?? ""), supplierName: String(supplier.name ?? row.supplier_name_raw ?? po.supplier ?? ""), - purchaseOrderId: String(row.purchase_order ?? deliveryNote.purchase_order ?? ""), purchaseOrderNumber: String(row.doc_po_number ?? po.purchase_order_number ?? row.po_number_raw ?? ""), - documentId: String(row.delivery_note ?? deliveryNote.id ?? ""), documentNumber: String(row.doc_number ?? (legacyPosted ? row.delivery_note_number ?? deliveryNote.delivery_note_number : "")), - documentType: String(row.doc_type ?? (legacyPosted ? deliveryNote.document_type : "unknown")), documentDate: String(row.doc_date || (legacyPosted ? deliveryNote.date_received : "") || row.posted_at || batch.uploaded_at || ""), - batchId: String(row.batch ?? batch.id ?? ""), batchName: String(batch.file ?? batch.id ?? ""), - stage: displayCandidateStage(String(row.status ?? ""), String(row.stage ?? "")), reason, matchMethod: String(row.match_method ?? ""), unresolvedLineItems, - }; -} - -export function matchesCandidate(candidate: WorkQueueCandidate, params: Record): boolean { - const value = (key: string) => params[key] ?? ""; - return [["site", candidate.siteId, candidate.siteName], ["supplier", candidate.supplierId, candidate.supplierName], ["purchase_order", candidate.purchaseOrderId, candidate.purchaseOrderNumber], ["stage", candidate.stage], ["reason", candidate.reason], ["document_type", candidate.documentType]] - .every(([key, ...values]) => !value(key) || values.some((item) => String(item).toLowerCase() === value(key).toLowerCase())); -} - const EXCEPTION_LABELS = { unknownSupplier: "Unknown supplier", supplierKnownPoUnknown: "Supplier known / PO unknown", @@ -132,18 +82,6 @@ function getException(map: Map, key: string, label: string): return created; } -export function buildQueueHref( - current: Record, - changes: Record, -): string { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries({ ...current, ...changes })) { - if (value) params.set(key, value); - } - const query = params.toString(); - return `/dashboard/financial/delivery-notes/work-queue${query ? `?${query}` : ""}`; -} - export function WorkQueueView({ candidates, filters, diff --git a/src/app/dashboard/financial/delivery-notes/work-queue/work-queue.test.ts b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue.test.ts index 175dd3a..91a40d6 100644 --- a/src/app/dashboard/financial/delivery-notes/work-queue/work-queue.test.ts +++ b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; import { - buildQueueHref, groupCandidates, - mapCandidateRecord, - matchesCandidate, WorkQueueView, type WorkQueueCandidate, } from "./work-queue-view"; +import { buildQueueHref, mapCandidateRecord, matchesCandidate } from "./work-queue-model"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { SuppliersView } from "../../suppliers/suppliers-view"; const candidate = ( @@ -35,6 +35,14 @@ const candidate = ( }); describe("supplier/PO queue grouping", () => { + it("keeps server-callable mapping out of the use-client view module", () => { + const page = readFileSync(resolve(__dirname, "page.tsx"), "utf8"); + const view = readFileSync(resolve(__dirname, "work-queue-view.tsx"), "utf8"); + expect(page).toContain('from "./work-queue-model"'); + expect(view).toContain('from "./work-queue-model"'); + expect(view).not.toContain("export function mapCandidateRecord"); + }); + it("groups known suppliers by purchase order and keeps exception groups explicit", () => { const groups = groupCandidates([ candidate(),