WFDASH-WEBAPP/AGENTS.md
Dave f8294b4e1d feat(pocketbase): switch on the audit-log hooks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:41:20 +02:00

11 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

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.

  • 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_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 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, and an empty SKU when it is sure. It never overwrites a person's SKU, confirms a collection, or changes status.
  • 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.