After each AI tick, every Collection Confirm card the page shows purple (cardAiState verified) is confirmed without a person. Customer lines go to ready for invoicing with the invoice number and date the AI read; stock orders go to stock booking once every line is collected. Cards with a problem flag, a photo for another PO, or no readable invoice number/date stay for review. Each push is recorded in ai_settings.collection_auto_push_log and shown at the top of the page for today and yesterday (PO, supplier, items, value). Off by default behind a Settings > AI switch; needs the manual live step npm run pb:migrate:collection-auto-push before it will run. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
20 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.
The POS matcher is shared and size-aware: src/lib/server/pos-match.ts
(the candidate pool, the size-aware ranking and pickPosItems) and
src/lib/item-size.ts (the size parser) are used by the quote assistant,
the order fill and the blue price check. quote-assistant-shortlist.ts
delegates to it.
- A line is pending (purple) only when
ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_pos"(isAiFillPending). - A price is written only for an exact match. A closest match writes no price and records
ai_fill.closest(the nearest item + what differs); the badge then reads "Closest: …". The AI answers exact, closest or none per line. - 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. - Auto-push (
collection-auto-push-service.ts, runbook section "Auto-push"): a purple card is confirmed by the tick itself and logged toai_settings.collection_auto_push_log; off by default, needsnpm run pb:migrate:collection-auto-push. Change what counts as pushable inplanAutoPush, never by calling confirm elsewhere. - 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).
Manager phone view (/manager) — the order pipeline on a phone
A hidden, installable PWA for the managers: one link, opened on a
phone, showing where every order is. It is deliberately NOT in the
sidebar — the route lives in the (mobile) route group, outside
(app), so <Sidebar /> never renders there.
Runbook: docs/manager-orders.md.
- Route group
src/app/(mobile)/manager/(layout + auth-gate + view), PWA assetspublic/manifest-manager.json+public/sw-manager.js, registered insrc/components/service-worker-registration.tsx. - Read model
src/lib/server/manager-orders-service.ts, exposed atGET /api/v1/manager-orders(browser-session auth, same shape as/api/v1/boss-dashboard). - Three buckets — New / Processing / Completed — from
PIPELINE_GROUP_ITEM_STATUSESinsrc/lib/item-status-ui.ts. That map is a COARSE view of the desk's eight workflow tabs; both read the same status lists so they can never disagree. - Completed is time-windowed (7 days on the phone, 30 on
/orders). "Settled" is defined ONCE initem-status-ui.ts(SETTLED_STATUSES+SETTLED_DATE_FIELDS) and the filter is shared (settledWindowFilterinorders-service.ts).
The date field is per-status — read this before changing the window
There is no single "finished at" column, and the obvious one is a trap. Measured on live PB (2026-10-26, 12,957 delivered lines):
delivered_atis populated on 2 lines. The rest are legacy rows whose status was set without a stamp, and theirupdatedis the MIGRATION timestamp. Sodeliveredcounts only whendelivered_atis set — an un-dated delivered row is ARCHIVED, not recent.invoiced_dateis populated on 2,199 lines and is the only properly stamped settlement date in the schema.completed,checked_inanddelivery_notehave no dedicated column, soupdated(bumped on every write) is the only signal.
SETTLED_DATE_FIELDS encodes that: primary = "use this stamp, and
skip the row if it is empty"; fallback = "no stamp exists for this
status, use updated". They are mutually exclusive — giving a status
BOTH would resurface archived rows every time an old row was touched.
Before the fix the Completed tab matched 0 orders, because the
filter only looked at delivered + completed dated on
delivered_at/updated, and the business stopped writing delivered
on 2026-08-14 (the live pipeline now ends at invoiced /
checked_in). It now matches 341 orders over 30 days, 112 over 7.
The post_processing tab was retired in the same change (a DN is not
a stage of its own) and delivery_note + checked_in moved into
completed. invoiced is listed in BOTH collected and completed
on purpose: itemStatusToTabGroup resolves it to collected (the
office's workflow tab keeps its rows) while the completed tab picks
invoiced rows up through its own windowed filter. Dropping it from
collected would make invoiced orders vanish from /orders.
The preview popup is REUSED, not rebuilt: the service emits
UniversalHits and the page hands them to the existing
SearchPreviewDialog (src/app/(app)/search/search-preview.tsx), so
the manager sees the same quick-overview the desk sees on /search.