WFDASH-WEBAPP/AGENTS.md
Dave 3eea87ec03 feat(collection-confirm): auto-push purple cards on to invoicing, with a 2-day log
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>
2026-10-28 11:36:23 +02:00

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:

  1. POST endpoint: generate the PDF, upload via multipart/form-data using the order_item_attachements field on each item in the group
  2. GET endpoint: query order_items by 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
  3. 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 under src/app/(app)/**/page.tsx and 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: *.delete and secret/base64 tools are denied; only tools in READ_TOOLS run 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 to READ_TOOLS or Giellie will ask approval for every call.
  • Every /api/v1/giellie/* route authenticates with getBrowserSessionUser(req) (PB token in Authorization). These routes hold AGENT_API_KEY; never expose one without that check.
  • Config (key, model, base URL, on/off) is the existing /settings/ai row; Giellie uses its own system prompt in giellie-system-prompt.ts.
  • Classification is args-aware: use classifyToolCall(name, input) (not bare classifyTool) 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_ai is denied (it wipes every quote item); orders.cancel, delivery_notes.cancel and delivery_notes.combine always 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 full https:// origin) so the MCP's loopback calls reach the app; scheme-less values like localhost:4001 are ignored.
  • The giellie_chats collection is created by npm 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 in giellie-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_edited or is_processed.
  • pricing_source "ai_pos" must survive human saves: route every save through nextPricingSourceOnSave / withAiReview, or a stale page turns the line back to manual.
  • Manual live steps (like select values and pb_hooks): run npm run pb:migrate:quote-assistant against 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 by matchOrder right after createOrder (when the assistant is on), the tick, the sweep route and the order page's "Match with AI".
  • orders.quote is written only through setOrderQuote, inside runWithAuditActor({ 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 paired same_item "sure", every pair on a line of the one chosen quote, and the quote's company = the order's company. Otherwise it only writes potential_quote (confidence 0.9 / 0.7 / 0.5) and quote_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_match with status error and is never thrown out of matchOrder.
  • Manual live steps (like select values and pb_hooks): run npm run pb:migrate:order-assistant against the live PB, publish the ai_quote_match / ai_fill IGNORED_FIELDS hook change, deploy, dry-run then --apply scripts/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_reads rows, 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 (via updateOrderItemCost, 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 to ai_settings.collection_auto_push_log; off by default, needs npm run pb:migrate:collection-auto-push. Change what counts as pushable in planAutoPush, never by calling confirm elsewhere.
  • The service refuses to run until ai_check exists (PB drops unknown fields silently); ai_check is in IGNORED_FIELDS of the audit hook.
  • Manual live steps (like select values and pb_hooks): run npm run pb:migrate:collection-ai against the live PB, redeploy pocketbase/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 assets public/manifest-manager.json + public/sw-manager.js, registered in src/components/service-worker-registration.tsx.
  • Read model src/lib/server/manager-orders-service.ts, exposed at GET /api/v1/manager-orders (browser-session auth, same shape as /api/v1/boss-dashboard).
  • Three buckets — New / Processing / Completed — from PIPELINE_GROUP_ITEM_STATUSES in src/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 in item-status-ui.ts (SETTLED_STATUSES + SETTLED_DATE_FIELDS) and the filter is shared (settledWindowFilter in orders-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_at is populated on 2 lines. The rest are legacy rows whose status was set without a stamp, and their updated is the MIGRATION timestamp. So delivered counts only when delivered_at is set — an un-dated delivered row is ARCHIVED, not recent.
  • invoiced_date is populated on 2,199 lines and is the only properly stamped settlement date in the schema.
  • completed, checked_in and delivery_note have no dedicated column, so updated (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.