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>
261 lines
16 KiB
Markdown
261 lines
16 KiB
Markdown
<!-- BEGIN:nextjs-agent-rules -->
|
|
# 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.
|
|
<!-- END:nextjs-agent-rules -->
|
|
|
|
<!-- BEGIN:pb-select-field-gotcha -->
|
|
# 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).
|
|
<!-- END:pb-select-field-gotcha -->
|
|
|
|
<!-- BEGIN:pb-attachments-pattern -->
|
|
# 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.
|
|
<!-- END:pb-attachments-pattern -->
|
|
|
|
<!-- BEGIN:file-url-paths -->
|
|
# 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.
|
|
<!-- END:file-url-paths -->
|
|
|
|
<!-- BEGIN:data-layer-discipline -->
|
|
# 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:
|
|
|
|
```bash
|
|
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.
|
|
<!-- END:data-layer-discipline -->
|
|
# 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 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.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` the 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_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.
|
|
- 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`).
|