Task 7 of the AI order assistant plan.
- POST /api/v1/orders/assistant/tick (n8n, assertAgentAuth)
- POST /api/v1/orders/{id}/assistant/run (browser session; 409 for
"AI is disabled" and "not set up yet")
- MCP orders.assistant_match { orderId } -> matchOrder(force) with a
TOOL_HINTS entry; deliberately NOT in READ_TOOLS
- docs/order-assistant.md runbook and the AGENTS.md section
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.
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--applythe stale-suggestion clean-up script, 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).