The rule-based matcher no longer writes or shows suggestions: on
2026-10-24 it had left 2,888 on unlinked orders, one quote suggested
to 309 of them.
- createOrder runs the AI order assistant (matchOrder) straight after
intake when it is switched on, instead of the rule-based suggestion
- order page: Re-match / Try again and the rule-based suggestion card
are gone; Match with AI is the action, and "Match by reference" saves
the reference then runs the AI (the run route takes { reference })
- the unauthenticated /orders/[id]/quote-match route is removed
- the sweep route and MCP orders.quote_match_sweep run the AI batch
(matchUnlinkedOrders, shared with the tick)
- scripts/2026-10-24-clear-rule-quote-suggestions.ts clears the old
suggestions (dry run unless --apply); replaces the rematch script,
which would have written rule-based ones again
- also fixes the setState-in-effect lint error on the order page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 KiB
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.
PocketBase select fields are not self-service
The order_items.item_status field is a select with a fixed list of
values. If you want to set a new value (e.g. delivery_note for the
new delivery-note flow), you must add it to the schema in the PB admin
first — the API will 500 with "Failed to update record." otherwise.
The local pb-exports/pb_schema-2.json is a snapshot for reference
only; the source of truth is the running PB instance.
If a new code path sets a select value that doesn't exist yet, ask the user to add it via the PB admin before debugging the API. They do this manually (it's a UI-only change in the PB admin).
Reuse existing multi-file fields for new attachment types
The order_items collection has a multi-file field order_item_attachements
(maxSelect: 10, accepts any MIME). When adding a new document type
to attach to an order item (e.g. delivery note PDF), upload the file
to this field rather than creating a new attachment collection. The
operator queries by some key field on the item (e.g.
delivery_note_number) to find the items, then reads the attachments
off each item.
Pattern in code:
- POST endpoint: generate the PDF, upload via multipart/form-data
using the
order_item_attachementsfield on each item in the group - GET endpoint: query
order_itemsby the key field, find a file whose name matches the expected pattern (e.g.DN26071401.pdf), stream the bytes back via PB's/api/files/...URL - Stable filename per document type so the retrieval side can disambiguate from other attachments on the same item
Always ADD, never replace: a PocketBase file PATCH under the bare
field name replaces the whole list — it deleted a driver's photo on
2026-10-19. Build uploads with addAttachmentForm() from
src/lib/server/order-item-files.ts (order_item_attachements+, and
- only for the same document's older copies), and find a file by name
with pickStoredCopy(), never by position.
File URLs go through /api/v1/files/, never /api/files/
The WFDASH file-serving proxy lives at
/api/v1/files/{collection}/{recordId}/{filename} (route file:
src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts).
Every <a href={...}> and every fetch from a client component that
opens a repair/quote/order attachment must use the /api/v1/ prefix.
/api/files/{collection}/{recordId}/{filename} (no v1/) 404s —
that's the PocketBase convention, not a route in this app. The
PB instance serves files at that path, and the WFDASH proxy
forwards to it server-side, but a client that points directly at
/api/files/... gets the Next.js 404 page.
Gotcha: PocketBase record responses (e.g. record.delivery_note
as a string[] of filenames) sometimes come back as fully-qualified
PB URLs like http://pb-host/api/files/... — those should be
re-written to /api/v1/files/... on the client before rendering.
The existing detail page builds the path from scratch from the
record id + filename, which sidesteps the issue. Don't blindly
trust the URL PB hands back.
The src/app/api/invoicing/delivery-note-pdf/route.test.ts test
file intentionally matches /api/files/... in an expect — that's
asserting the raw PB response shape, not the URL the client
should use. Different concern.
Data access layer discipline
The app has two coexisting data layers:
- Legacy:
src/lib/pb-helpers.ts— used directly by Server Components undersrc/app/(app)/**/page.tsxand form components. - Current:
src/lib/server/*-service.ts— exposed via/api/v1/*route handlers and/api/mcp, used by the agent API and external integrations.
Both layers read the same PocketBase collections. Pick the right one depending on what you're doing.
Rules
For new endpoints / features: put business logic in
src/lib/server/<feature>-service.ts and expose it via a thin
src/app/api/v1/<feature>/<op>/route.ts. Do NOT extend pb-helpers.ts.
For new pages / form components: call /api/v1/* (or have the
Server Component call the corresponding lib/server/*-service.ts
function directly). Do NOT import from pb-helpers.ts.
When touching a page that already uses pb-helpers.ts: migrate its
data calls to the service layer in the same commit. One resource at a
time, no big-bang rewrites. Lowest-risk order: customers → orders →
quotes → suppliers → companies → everything else.
How to spot you're reaching for the legacy layer
pb-helpers.ts is the file in question. Grep for it:
grep -rn 'from "@/lib/pb-helpers"' src/ --include="*.ts" --include="*.tsx"
Each hit is one caller to migrate when you're in the area.
Migration cost indicator
A single page rewrite is typically: replace getX(...) import + call
with await fetch('/api/v1/x?...') (or a direct service-layer call),
then update the page to await. Half-day budget for 5 pages if no
weird cross-resource joins are involved.
Audit trail (audit_log) is written by a PB hook, not by app code
Every create / update / delete on quotes, quote_items,
quote_attachments, orders, order_items and delivery_notes is
recorded by pocketbase/pb_hooks/audit_log.pb.js (+ audit_log_utils.js)
into the audit_log collection. Don't add hand-written "audit" notes for
field changes — the hook already captures from → to per field.
Attribution: dashboard users writing straight to PB are credited
automatically. Server-side writes go through getAdminPocketBase(), which
attaches X-Audit-* headers from src/lib/server/audit-context.ts (the
browser forwards its PB token as x-wfdash-auth; PB verifies it). If a
server path knows the actor better than the request does (MCP actor
args), wrap it in runWithAuditActor(...). n8n writes appear as
"Automation" unless the workflow sends X-Audit-Actor-Name.
Read side: src/lib/server/audit-log-service.ts →
/api/v1/audit/{quote|order|delivery_note}/{id} → <AuditTimeline>.
Noisy fields (search/AI/timers) are skipped via IGNORED_FIELDS in the
hook utils — add new auto-updated fields there.
Hooks deploy from git: commit changes in pocketbase/pb_hooks/, then
npm run pb:hooks:publish (pushes that folder to Forgejo David/WFDASH-PBHOOKS);
a VPS cron syncs the files listed in DEPLOY into the live PB hooks volume
(see scripts/pb-hooks/README.md). The audit hooks went live on 2026-10-17;
audit_log has no history from before that date.
Giellie agent (/giellie) — tools come from the MCP, policy lives in one file
The sidebar "Giellie Agent" is a MiniMax chat agent (src/lib/server/giellie-*.ts,
routes under /api/v1/giellie/*, chats in the giellie_chats PB collection).
It calls the same MCP handler as Hermes/n8n (src/app/api/mcp/route.ts),
in-process — a new MCP tool is automatically available to Giellie.
- Safety lives in
src/lib/server/giellie-tool-policy.ts:*.deleteand secret/base64 tools are denied; only tools inREAD_TOOLSrun without approval; everything else (including brand-new tools) pauses for an Approve/Reject card; email/PO sends always ask. When you add a read-only MCP tool, add it toREAD_TOOLSor Giellie will ask approval for every call. - Every
/api/v1/giellie/*route authenticates withgetBrowserSessionUser(req)(PB token inAuthorization). These routes holdAGENT_API_KEY; never expose one without that check. - Config (key, model, base URL, on/off) is the existing
/settings/airow; Giellie uses its own system prompt ingiellie-system-prompt.ts. - Classification is args-aware: use
classifyToolCall(name, input)(not bareclassifyTool) for routing. A plain write whose input carries a contact-routing key (email*,bcc,cc,sender,recipient, any depth ≤ 4) is escalated to "confirm".quotes.re_run_aiis denied (it wipes every quote item);orders.cancel,delivery_notes.cancelanddelivery_notes.combinealways ask. - The "Trust this chat" toggle is per chat and only auto-approves ordinary writes. It never covers confirm-class actions: email/PO sends, cancels/merges, and changes to email/contact fields.
- Env:
AGENT_API_KEY(server-side MCP auth, required) and, in containers,AGENT_API_BASE_URL(a fullhttps://origin) so the MCP's loopback calls reach the app; scheme-less values likelocalhost:4001are ignored. - The
giellie_chatscollection is created bynpm run pb:migrate:giellie-chats(manual step on the live PB, like select values and pb_hooks). Its API rules are stricter than the original spec: users may list/view their own chats only; create/update/delete are null (all writes go through the admin client ingiellie-chat-store.ts).
AI quote assistant — fills unpriced quote lines from the POS
Runbook: docs/quote-assistant.md. Files: src/lib/server/quote-assistant-{core,shortlist,ai,prompt,service}.ts
(logic, POS shortlist, MiniMax call, default prompt, orchestration) and src/lib/quote-assistant-{review,save}.ts
(client-safe review/save helpers). Routes: POST /api/v1/quotes/assistant/tick (n8n, assertAgentAuth),
POST /api/v1/quotes/{id}/assistant/run (browser), MCP quotes.assistant_fill. Settings live in Settings → AI.
- A line is pending (purple) only when
ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_pos"(isAiFillPending). - AI writes never set
is_processed, never change quote status, never send, and never touch a line with a price,user_editedoris_processed. pricing_source "ai_pos"must survive human saves: route every save throughnextPricingSourceOnSave/withAiReview, or a stale page turns the line back tomanual.- Manual live steps (like select values and pb_hooks): run
npm run pb:migrate:quote-assistantagainst the live PB before deploying, then activate the n8n workflow "WFDASH Quote assistant tick" (every 5 min, POSTs the tick route). See the runbook.
AI order assistant — matches orders to the quote they came from
Runbook: docs/order-assistant.md. Files: src/lib/server/order-assistant-{core,ai,prompt,service}.ts
(eligibility + auto-link rule, AI call, default prompt, orchestration) and
src/lib/server/order-quote-link-service.ts (the deterministic shortlist). Routes:
POST /api/v1/orders/assistant/tick (n8n, assertAgentAuth),
POST /api/v1/orders/{id}/assistant/run (browser), MCP orders.assistant_match. Settings live in
Settings → AI → Order assistant.
- Quote matching is AI-only. The rule-based scorer (
order-quote-match.ts,computeQuoteSuggestion) survives only as the assistant's shortlist; nothing shows or writes its suggestions. New orders are matched bymatchOrderright aftercreateOrder(when the assistant is on), the tick, the sweep route and the order page's "Match with AI". orders.quoteis written only throughsetOrderQuote, insiderunWithAuditActor({ name: "AI order assistant" }, …). A 409 from it is not an error — the assistant falls back to a suggestion.- Auto-link needs all of: the setting on, confidence
high, every real order line pairedsame_item "sure", every pair on a line of the one chosen quote, and the quote's company = the order's company. Otherwise it only writespotential_quote(confidence 0.9 / 0.7 / 0.5) andquote_match_method "ai_suggest". - The AI never changes a line's status, creates a PO or sends anything. An AI failure is recorded on
orders.ai_quote_matchwith statuserrorand is never thrown out ofmatchOrder. - Manual live steps (like select values and pb_hooks): run
npm run pb:migrate:order-assistantagainst the live PB, publish theai_quote_match/ai_fillIGNORED_FIELDShook change, deploy, dry-run then--applyscripts/2026-10-24-clear-rule-quote-suggestions.ts, activate the n8n workflow "WFDASH Order assistant tick" (every 5 min), then switch the order assistant on in Settings → AI. See the runbook.
AI Collection Confirm checks — reads driver photos, ticks lines, never confirms
Runbook: docs/collection-ai-checks.md. Files: src/lib/server/collection-ai-{read,pair,findings,service,prompt}.ts
(vision read, pairing call, document-level findings, orchestration, default prompt) and src/lib/collection-ai-ticks.ts
(pure, client-safe tick logic). Routes: POST /api/v1/collection-confirm/ai/tick (n8n, agent auth),
.../ai/check and .../add-to-pos (browser session), MCP collection_confirm.ai_check. Settings live in Settings → AI → Collection checks.
- Manual first: the Add to POS button and the Check with AI button are manual; the tick only reads and checks, nothing else.
- The AI writes only
ai_check,collection_doc_readsrows, an empty SKU when it is sure, and — only when a "sure" tax-invoice line shows a discount — the cost set to the price after discount (viaupdateOrderItemCost, with a note). It never overwrites a person's SKU, confirms a collection, or changes status. - The service refuses to run until
ai_checkexists (PB drops unknown fields silently);ai_checkis inIGNORED_FIELDSof the audit hook. - Manual live steps (like select values and pb_hooks): run
npm run pb:migrate:collection-aiagainst the live PB, redeploypocketbase/pb_hooks/audit_log_utils.js, deploy, activate the n8n workflow "WFDASH Collection check tick" (every 5 min), then switch Collection checks on in Settings → AI. See the runbook.
PO numbers: create customer POs only through createPurchaseOrderWithNumber
src/lib/server/po-number.ts numbers a PO by reading the month's highest
number and adding one. Called in parallel (one order, several suppliers)
that gave 34 duplicate PO numbers by 2026-10-19. createPurchaseOrderWithNumber
numbers and creates one PO at a time and retries when the unique index on
po_number rejects a clash. Never call generatePONumber and then
purchase_orders.create yourself; the browser goes through
POST /api/v1/orders/[id]/purchase-orders. The partial unique index is a
manual live step: npm run pb:migrate:po-number-unique (after deploying).
Stock orders on Collection Confirm
Collected stock lines show on Collection Confirm until their stock order is
received (STOCK_ON_PAGE_FILTER in collection-confirm-service.ts, shared
with the AI check). Confirm stock calls markStockOrdersReceived, which flips
the order to received once every line is collected/booked — the
accountant's booking queue lists received. Stock photos live on
stock_purchases.additional_attachments (multi-file after the manual live
step npm run pb:migrate:stock-photos; run it before deploying the driver
app). Always add with additional_attachments+.
Short collections split on Collection Confirm
When a collected customer line has qty_collected < quantity, Confirm
(confirmOrderItemsWithSplits) cuts it to what was collected — that part
goes to invoicing and closes in full — and creates the rest as a new
pending line on the same order (src/lib/short-collection.ts, marker
order_item_details.shortCollection). The process page offers "Back order"
(relink to the original PO: po_generated, or collection_pool when ready)
via POST /api/v1/order-items/{id}/back-order; otherwise the buyer orders it
elsewhere or cancels it like any new line. Never put a part-invoiced line
back to ready_invoice expecting a later top-up: invoicing subtracts
qty_invoiced, and the split is what re-orders the shortfall.
Supplier-side lines (exclude_from_invoice) close to invoiced at Confirm
when every billable line on their order is already billed; stragglers get a
"Close supplier-side lines" button on Invoicing
(POST /api/v1/invoicing/close-supplier-side).