fix(api,layout): sprint 500 chunk + add 9 missing top-level agent routes + metadataBase; drop unused collection_pool feature; quote counter fix migration
src/app/api/v1/sprint/attachment-counts/route.ts
- Chunk itemIds into batches of 80 before building the PocketBase filter;
the live cap was hit at ~115 IDs × 15 chars and surfaced as a
generic Next 500 ("Something went wrong while processing your
request.") that hid the real cause. 7 chunks for the sprint
page's max of 500 items.
src/app/layout.tsx
- Add metadataBase derived from NEXT_PUBLIC_SITE_URL /
NEXT_PUBLIC_POCKETBASE_URL. Silences the per-page
"metadataBase not set" warning during dev renders and gives
OG / Twitter cards a stable canonical base.
src/app/api/v1/{quotes,order-items,purchase-orders,stock,
stock-purchases,users,sprint,driver,ai-insights}/route.ts (NEW)
- Add the missing top-level collection routes the agent API
needs. seven are straight passthroughs to their PB collection;
/sprint proxies to quote_items with optional claimStatus and
category query params; /driver lists PB users (no role field
in PB so callers narrow with ?filter=); /ai-insights returns
a roll-up count + most-recent regenerate timestamp across the
four collections that carry ai_* JSON fields.
src/app/api/v1/collection-pool/{route.ts,route.test.ts},
src/app/(app)/collection-pool/{page.tsx,page.test.tsx,
view-model.ts,view-model.test.ts,components.tsx} (DELETED)
- Drop the collection_pool feature entirely: PB has no
collection_pool collection on the live instance
(pb:migrate:orders-v2 was never run), the codebase comment
in purchase-orders-service.ts says "now-removed", and the
sidebar entrypoint has been migrated to /sprint + /deliveries.
The replacement operational status is the `item_status =
"collection_pool"` enum value on order_items, which stays.
src/lib/server/orders-service.ts (+ test), src/lib/pb-helpers.ts,
src/components/sidebar.tsx, src/components/ui/assign-driver-dialog.tsx,
src/app/(app)/collection-map/sidebar.tsx,
src/app/api/mcp/route.ts (+ test) (MODIFIED)
- Remove listCollectionPool + getCollectionPool +
updateCollectionPoolStatus, the /collection-pool sidebar entry,
the deep-link in assign-driver-dialog and collection-map
sidebar, and the collection_pool.list MCP tool (the tool,
handler, and test mock).
scripts/migrations/2026-07-26-fix-quote-counter-and-reassign-numbers.ts (NEW)
- One-shot migration script that:
1. Reads every quote, sorts by created ASC.
2. Reassigns clean Q202607001 … Q202607{N} over the whole
table (including the e+22 garbage and the legacy
Qnnnnnn rows).
3. Sets quote_counters.(year=2026, month=7).last_seq = N
so the next /api/v1/quotes/next-number returns a clean,
atomic, unique number.
Default mode = dry-run; pass --apply to write.
Already executed against the live cloud PB during this session
(verified: 200/200 rows updated, counter at 200, next-number
returns Q202607201, Q202607202, Q202607203 on consecutive calls).
AGENTS.md (MODIFIED)
- Add data-layer-discipline block matching the style of the
three existing blocks. Documents which layer is legacy (pb-helpers)
vs current (lib/server/*-service.ts via /api/v1/*), gives the
migration order (customers → orders → quotes → suppliers →
companies → rest), and includes the grep one-liner future AI
agents should run before extending pb-helpers.
Not in this commit (your WIP, untouched):
src/app/(app)/{dashboard,delivery-notes,layout,orders/[id],
quotes/[id],quotes/approvals,quotes,sprint}/page.tsx,
src/app/(auth)/login/page.tsx, src/components/charts/*,
src/components/{company-form,customer-form,quote-email-card,
sprint-item-pricing-modal,supplier-form,theme-bootstrap,
theme-picker}*.{tsx,test.tsx}, src/app/api/invoicing/delivery-note/*
src/app/api/v1/quotes/[id]/pdf/route.ts, src/app/globals.css,
src/lib/{item-status-ui,pdf/quote-document,quote-status*,themes,
types}.ts, src/lib/server/{order-item-status*,quotes-service}.ts,
src/styles/themes.css, APP_CAPABILITIES.md, README.md,
docs/SHIPPED.md, scripts/smoke/screenshot-pages.mjs,
and the new untracked files under docs/, scripts/migrations/
2026-07-19…25, src/app/{(app)/deliveries,(app)/delivery-notes,
api/v1/ai-insights/rerun,api/v1/deliveries,api/v1/delivery-notes,
api/v1/quotes/[id]/partial-approve,api/v1/quotes/[id]/split},
src/components/{ai-insights-panel,ai-insights-renderer,
ai-item-history-trigger,brand-stripe,delivery-notes,ui/stock-item-search,
ui/duplicate-warning,ui/duplicate-warning.test}.tsx, supplier-form.test.tsx,
src/lib/{pb-helpers-duplicate.test,server/deliveries-service*,
server/delivery-notes-service*}, public/wfdash-logo*.png,
opencode.jsonc.
2026-07-26 20:18:49 +00:00
<!-- 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
2026-10-19 12:46:37 +00:00
**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.
fix(api,layout): sprint 500 chunk + add 9 missing top-level agent routes + metadataBase; drop unused collection_pool feature; quote counter fix migration
src/app/api/v1/sprint/attachment-counts/route.ts
- Chunk itemIds into batches of 80 before building the PocketBase filter;
the live cap was hit at ~115 IDs × 15 chars and surfaced as a
generic Next 500 ("Something went wrong while processing your
request.") that hid the real cause. 7 chunks for the sprint
page's max of 500 items.
src/app/layout.tsx
- Add metadataBase derived from NEXT_PUBLIC_SITE_URL /
NEXT_PUBLIC_POCKETBASE_URL. Silences the per-page
"metadataBase not set" warning during dev renders and gives
OG / Twitter cards a stable canonical base.
src/app/api/v1/{quotes,order-items,purchase-orders,stock,
stock-purchases,users,sprint,driver,ai-insights}/route.ts (NEW)
- Add the missing top-level collection routes the agent API
needs. seven are straight passthroughs to their PB collection;
/sprint proxies to quote_items with optional claimStatus and
category query params; /driver lists PB users (no role field
in PB so callers narrow with ?filter=); /ai-insights returns
a roll-up count + most-recent regenerate timestamp across the
four collections that carry ai_* JSON fields.
src/app/api/v1/collection-pool/{route.ts,route.test.ts},
src/app/(app)/collection-pool/{page.tsx,page.test.tsx,
view-model.ts,view-model.test.ts,components.tsx} (DELETED)
- Drop the collection_pool feature entirely: PB has no
collection_pool collection on the live instance
(pb:migrate:orders-v2 was never run), the codebase comment
in purchase-orders-service.ts says "now-removed", and the
sidebar entrypoint has been migrated to /sprint + /deliveries.
The replacement operational status is the `item_status =
"collection_pool"` enum value on order_items, which stays.
src/lib/server/orders-service.ts (+ test), src/lib/pb-helpers.ts,
src/components/sidebar.tsx, src/components/ui/assign-driver-dialog.tsx,
src/app/(app)/collection-map/sidebar.tsx,
src/app/api/mcp/route.ts (+ test) (MODIFIED)
- Remove listCollectionPool + getCollectionPool +
updateCollectionPoolStatus, the /collection-pool sidebar entry,
the deep-link in assign-driver-dialog and collection-map
sidebar, and the collection_pool.list MCP tool (the tool,
handler, and test mock).
scripts/migrations/2026-07-26-fix-quote-counter-and-reassign-numbers.ts (NEW)
- One-shot migration script that:
1. Reads every quote, sorts by created ASC.
2. Reassigns clean Q202607001 … Q202607{N} over the whole
table (including the e+22 garbage and the legacy
Qnnnnnn rows).
3. Sets quote_counters.(year=2026, month=7).last_seq = N
so the next /api/v1/quotes/next-number returns a clean,
atomic, unique number.
Default mode = dry-run; pass --apply to write.
Already executed against the live cloud PB during this session
(verified: 200/200 rows updated, counter at 200, next-number
returns Q202607201, Q202607202, Q202607203 on consecutive calls).
AGENTS.md (MODIFIED)
- Add data-layer-discipline block matching the style of the
three existing blocks. Documents which layer is legacy (pb-helpers)
vs current (lib/server/*-service.ts via /api/v1/*), gives the
migration order (customers → orders → quotes → suppliers →
companies → rest), and includes the grep one-liner future AI
agents should run before extending pb-helpers.
Not in this commit (your WIP, untouched):
src/app/(app)/{dashboard,delivery-notes,layout,orders/[id],
quotes/[id],quotes/approvals,quotes,sprint}/page.tsx,
src/app/(auth)/login/page.tsx, src/components/charts/*,
src/components/{company-form,customer-form,quote-email-card,
sprint-item-pricing-modal,supplier-form,theme-bootstrap,
theme-picker}*.{tsx,test.tsx}, src/app/api/invoicing/delivery-note/*
src/app/api/v1/quotes/[id]/pdf/route.ts, src/app/globals.css,
src/lib/{item-status-ui,pdf/quote-document,quote-status*,themes,
types}.ts, src/lib/server/{order-item-status*,quotes-service}.ts,
src/styles/themes.css, APP_CAPABILITIES.md, README.md,
docs/SHIPPED.md, scripts/smoke/screenshot-pages.mjs,
and the new untracked files under docs/, scripts/migrations/
2026-07-19…25, src/app/{(app)/deliveries,(app)/delivery-notes,
api/v1/ai-insights/rerun,api/v1/deliveries,api/v1/delivery-notes,
api/v1/quotes/[id]/partial-approve,api/v1/quotes/[id]/split},
src/components/{ai-insights-panel,ai-insights-renderer,
ai-item-history-trigger,brand-stripe,delivery-notes,ui/stock-item-search,
ui/duplicate-warning,ui/duplicate-warning.test}.tsx, supplier-form.test.tsx,
src/lib/{pb-helpers-duplicate.test,server/deliveries-service*,
server/delivery-notes-service*}, public/wfdash-logo*.png,
opencode.jsonc.
2026-07-26 20:18:49 +00:00
<!-- END:pb - attachments - pattern -->
2026-09-01 23:17:05 +00:00
<!-- 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 -->
fix(api,layout): sprint 500 chunk + add 9 missing top-level agent routes + metadataBase; drop unused collection_pool feature; quote counter fix migration
src/app/api/v1/sprint/attachment-counts/route.ts
- Chunk itemIds into batches of 80 before building the PocketBase filter;
the live cap was hit at ~115 IDs × 15 chars and surfaced as a
generic Next 500 ("Something went wrong while processing your
request.") that hid the real cause. 7 chunks for the sprint
page's max of 500 items.
src/app/layout.tsx
- Add metadataBase derived from NEXT_PUBLIC_SITE_URL /
NEXT_PUBLIC_POCKETBASE_URL. Silences the per-page
"metadataBase not set" warning during dev renders and gives
OG / Twitter cards a stable canonical base.
src/app/api/v1/{quotes,order-items,purchase-orders,stock,
stock-purchases,users,sprint,driver,ai-insights}/route.ts (NEW)
- Add the missing top-level collection routes the agent API
needs. seven are straight passthroughs to their PB collection;
/sprint proxies to quote_items with optional claimStatus and
category query params; /driver lists PB users (no role field
in PB so callers narrow with ?filter=); /ai-insights returns
a roll-up count + most-recent regenerate timestamp across the
four collections that carry ai_* JSON fields.
src/app/api/v1/collection-pool/{route.ts,route.test.ts},
src/app/(app)/collection-pool/{page.tsx,page.test.tsx,
view-model.ts,view-model.test.ts,components.tsx} (DELETED)
- Drop the collection_pool feature entirely: PB has no
collection_pool collection on the live instance
(pb:migrate:orders-v2 was never run), the codebase comment
in purchase-orders-service.ts says "now-removed", and the
sidebar entrypoint has been migrated to /sprint + /deliveries.
The replacement operational status is the `item_status =
"collection_pool"` enum value on order_items, which stays.
src/lib/server/orders-service.ts (+ test), src/lib/pb-helpers.ts,
src/components/sidebar.tsx, src/components/ui/assign-driver-dialog.tsx,
src/app/(app)/collection-map/sidebar.tsx,
src/app/api/mcp/route.ts (+ test) (MODIFIED)
- Remove listCollectionPool + getCollectionPool +
updateCollectionPoolStatus, the /collection-pool sidebar entry,
the deep-link in assign-driver-dialog and collection-map
sidebar, and the collection_pool.list MCP tool (the tool,
handler, and test mock).
scripts/migrations/2026-07-26-fix-quote-counter-and-reassign-numbers.ts (NEW)
- One-shot migration script that:
1. Reads every quote, sorts by created ASC.
2. Reassigns clean Q202607001 … Q202607{N} over the whole
table (including the e+22 garbage and the legacy
Qnnnnnn rows).
3. Sets quote_counters.(year=2026, month=7).last_seq = N
so the next /api/v1/quotes/next-number returns a clean,
atomic, unique number.
Default mode = dry-run; pass --apply to write.
Already executed against the live cloud PB during this session
(verified: 200/200 rows updated, counter at 200, next-number
returns Q202607201, Q202607202, Q202607203 on consecutive calls).
AGENTS.md (MODIFIED)
- Add data-layer-discipline block matching the style of the
three existing blocks. Documents which layer is legacy (pb-helpers)
vs current (lib/server/*-service.ts via /api/v1/*), gives the
migration order (customers → orders → quotes → suppliers →
companies → rest), and includes the grep one-liner future AI
agents should run before extending pb-helpers.
Not in this commit (your WIP, untouched):
src/app/(app)/{dashboard,delivery-notes,layout,orders/[id],
quotes/[id],quotes/approvals,quotes,sprint}/page.tsx,
src/app/(auth)/login/page.tsx, src/components/charts/*,
src/components/{company-form,customer-form,quote-email-card,
sprint-item-pricing-modal,supplier-form,theme-bootstrap,
theme-picker}*.{tsx,test.tsx}, src/app/api/invoicing/delivery-note/*
src/app/api/v1/quotes/[id]/pdf/route.ts, src/app/globals.css,
src/lib/{item-status-ui,pdf/quote-document,quote-status*,themes,
types}.ts, src/lib/server/{order-item-status*,quotes-service}.ts,
src/styles/themes.css, APP_CAPABILITIES.md, README.md,
docs/SHIPPED.md, scripts/smoke/screenshot-pages.mjs,
and the new untracked files under docs/, scripts/migrations/
2026-07-19…25, src/app/{(app)/deliveries,(app)/delivery-notes,
api/v1/ai-insights/rerun,api/v1/deliveries,api/v1/delivery-notes,
api/v1/quotes/[id]/partial-approve,api/v1/quotes/[id]/split},
src/components/{ai-insights-panel,ai-insights-renderer,
ai-item-history-trigger,brand-stripe,delivery-notes,ui/stock-item-search,
ui/duplicate-warning,ui/duplicate-warning.test}.tsx, supplier-form.test.tsx,
src/lib/{pb-helpers-duplicate.test,server/deliveries-service*,
server/delivery-notes-service*}, public/wfdash-logo*.png,
opencode.jsonc.
2026-07-26 20:18:49 +00:00
<!-- 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 -->
2026-09-29 23:48:44 +00:00
# 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.
2026-10-17 13:35:54 +00:00
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
2026-10-17 13:41:20 +00:00
(see `scripts/pb-hooks/README.md` ). The audit hooks went live on 2026-10-17;
`audit_log` has no history from before that date.
2026-09-30 22:36:55 +00:00
2026-11-04 23:23:25 +00:00
Talking to the live PB directly: authenticate as superuser with
`PB_ADMIN_EMAIL` /`PB_ADMIN_PASSWORD` via
`POST /api/collections/_superusers/auth-with-password` — that is what the
server does too (`getAdminPocketBase()`), and the `PB_ADMIN_TOKEN` in `.env`
is not always current (a 401 means re-auth, not a wrong URL). Records live
under `/api/collections/<name>/records` ; the path without `/records` returns
the collection definition, not the rows.
2026-09-30 22:19:59 +00:00
# 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` .
2026-09-30 22:36:55 +00:00
- 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.
2026-09-30 22:19:59 +00:00
- The `giellie_chats` collection is created by `npm run pb:migrate:giellie-chats`
2026-09-30 22:36:55 +00:00
(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` ).
2026-10-16 18:09:38 +00:00
# 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.
2026-10-24 21:09:53 +00:00
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.
2026-10-16 18:09:38 +00:00
- A line is **pending** (purple) only when `ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_pos"` (`isAiFillPending`).
2026-10-24 21:09:53 +00:00
- 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.
2026-10-16 18:09:38 +00:00
- 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.
2026-10-17 07:55:44 +00:00
2026-10-24 11:06:51 +00:00
# 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.
2026-10-24 18:49:26 +00:00
- 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".
2026-10-24 11:06:51 +00:00
- `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` .
2026-10-24 18:49:26 +00:00
- 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.
2026-10-24 11:06:51 +00:00
2026-10-17 07:55:44 +00:00
# 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),
2026-10-31 07:56:03 +00:00
`.../ai/check` and `.../add-to-price-list` (browser session), MCP `collection_confirm.ai_check` . Settings live in Settings → AI → Collection checks.
2026-10-17 07:55:44 +00:00
2026-10-31 07:56:03 +00:00
- **Manual first:** the Add to price list button and the Check with AI button are manual; the tick only reads and checks, nothing else.
2026-10-30 11:56:16 +00:00
- The AI writes **only** `ai_check` , `collection_doc_reads` rows and an **empty** SKU when it is sure. It **never writes a price** (owner's rule, 2026-10-30: it misread a discount that wasn't on the invoice) — a differing cost is a red Price tick for a person. It never overwrites a person's SKU, confirms a collection, or changes status.
2026-10-28 09:36:23 +00:00
- **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.
2026-10-17 07:55:44 +00:00
- 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.
2026-10-19 13:08:12 +00:00
# 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).
2026-10-19 15:03:33 +00:00
# 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+` .
2026-10-22 02:04:50 +00:00
# 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`).
feat(manager): a mobile orders board for the managers at /manager
A hidden, installable PWA: one link, opened on a phone, showing where
every order is. New / Processing / Completed, with a tap-to-preview
popup and the invoice/line detail Gary needs. Read-only, and NOT in
the sidebar — the route lives outside `(app)`, so `<Sidebar />` never
renders there.
The preview popup is reused, not rebuilt: the service emits
`UniversalHit`s and the page hands them to the existing
`SearchPreviewDialog`, so the manager gets the desk's quick-overview
verbatim.
Also fixes the /orders Completed tab, which was matching ZERO orders.
`delivered_at` is stamped on only 2 of 12,957 delivered lines and the
business stopped writing `delivered` on 2026-08-14 (the live pipeline
now ends at `invoiced` / `checked_in`), so the old filter never
matched. "Settled" is now defined once — `SETTLED_STATUSES` +
`SETTLED_DATE_FIELDS` — and dated per status: `invoiced_date` for
invoiced (the only properly stamped field, 2,199 lines), `delivered_at`
for delivered, `updated` only where no dedicated column exists.
`delivery_note` and `checked_in` move into `completed` as instructed,
and the standalone `post_processing` tab is retired (a DN is not a
stage of its own). `invoiced` stays in `collected` as well, so
invoiced orders don't vanish from the workflow tabs.
Completed tab: 0 -> 341 orders over 30 days, 112 over 7.
2026-10-26 21:09:01 +00:00
# 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
`UniversalHit` s 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.
2026-10-31 07:55:35 +00:00
2026-11-05 20:29:54 +00:00
# Repairs (`/repairs`) — the machine-repair job, and the phone app planned for it
The job runs: customer reports a broken machine → we collect it → the
repairs person books it in (photos, serial number, whose machine it is,
what the customer says is wrong, what the supplier must look at) → the
driver carries the printed request to the supplier → the supplier keeps a
copy, signs one and hands it back → supplier quote uploaded → customer
quote → customer order → the buyer approves the order or rejects it and
collects the machine back unrepaired.
**That flow is not in the code.** It came from the operator on 2026-11-05.
Design: `docs/superpowers/specs/2026-11-05-repairs-app-design.md` . The
planned phone app lives at `/repairs-app` , not built yet.
- Status vocabulary `new → assessing → quoted → approved → in_progress →
2026-11-06 07:31:33 +00:00
completed` (+ `cancelled` ) lives in ** `src/lib/repair-status.ts` ** —
one shared vocabulary holding the labels, the badge variants, the
allowed transitions and the transition guard. The service imports the
guard from there; the two office pages import the labels from there.
There is exactly ONE `assertRepairTransition` in `src/` — do not add
a second. The repair request PDF still carries its own older label map
on purpose; Phase 3 rebuilds that document.
- **`repairs.order` and `orders.is_repair_order` are still never
written in practice.** `linkRepairToOrder()` in the service writes
both, but it has no caller until Phase 3 wires it into order creation.
2026-11-05 20:29:54 +00:00
- A Repair Request is a **paper document for the supplier** , not a
delivery note. Repairs do not flow through the customer-DN pipeline.
- `createCustomerQuoteFromRepair` refuses to overwrite an existing
`customer_quote` link unless `replace: true` is passed.
2026-11-06 07:31:33 +00:00
- **MERGE GATE — four PocketBase fields must exist before this code
runs:** `repairs.supplier_scope` , `repairs.signed_request` ,
`orders.repair` , `giellie_chats.mode` . **PB drops unknown fields
SILENTLY**, so a missing field is NOT an error: the create returns 200
and the supplier scope is gone, the signed copy is accepted and not
stored, and the agent is told the upload worked. Add the fields FIRST,
then verify by round-tripping a save — do not wait for an error, there
will not be one.
- **The repairs REST family has no session check** (pre-existing; found
2026-11-06). All six routes under `/api/v1/repairs/` read and write
through the PocketBase superuser client with no credential check. The
`(app)` layout gates the PAGES client-side; the API is not gated. The
`repairs.*` MCP tools ARE gated by `assertAgentAuth` . Operator
decision pending on adding a session check to all six routes together.
- Phase 1 of the repairs work landed on `main` 2026-11-06 (`9358b37`):
the shared vocabulary, the service layer (`supplier_scope`,
`signed_request` , `getRepairStatusCounts` , `linkRepairToOrder` ),
the REST routes (including `GET /api/v1/repairs/status-counts` ), and
eight `repairs.*` MCP tools. The phone app at `/repairs-app` is
Phase 2 and does not exist yet.
2026-11-05 20:29:54 +00:00
2026-10-31 08:20:23 +00:00
# The POS counter never sells at R0
The POS (`/api/v1/pos/catalog`) lists `stock_items` matching
`POS_CATALOG_FILTER` in `pos-service.ts` : active **and** `selling_price > 0` .
`checkoutSale` refuses any line with `unit_price <= 0` . Owner's rule
(2026-10-30): only stock (and over-collected surplus) goes on the counter.
Customer-order lines go to the `price_list` catalogue instead
(`addRowsToPriceList`), never straight into `stock_items` . Any path that creates
a stock item with a cost must price it with `applyDefaultMarkupToPatch`
(80%); without a cost it stays off the till until someone prices it.
2026-10-31 07:55:35 +00:00
# Delivery Pool (`/delivery-pool`) — invoiced lines waiting to go out
Design: `docs/superpowers/specs/2026-10-30-delivery-pool-design.md` . Files:
`src/lib/delivery-pool.ts` (pure grouping/filters), `src/lib/server/delivery-pool-service.ts` ,
routes under `/api/v1/delivery-pool` (browser session), page `src/app/(app)/delivery-pool/` .
- Per company, **one card per customer invoice** (`customer_invoice_number`, normalised
`30352` = `INV30352` ). Never key on `order_item_details.invoice.number` — Collection
Confirm writes the *supplier* invoice there.
- In the pool: `invoiced` , `invoiced_date >= DELIVERY_POOL_START` , `delivered_at` empty,
not `exclude_from_invoice` , and its DN (if any) not `completed` /`cancelled` (drops POS
counter sales).
- The delivery driver lives in `delivery_driver` / `delivery_assigned_at` — **never write
`drv_id` ** here, that's the collection driver. Status stays `invoiced` until the signed copy.
- Signed copy: ADDED to every line (`addAttachmentForm`), lines → `completed` + `delivered_at` ,
a DN is closed only when none of its lines is still open.
- Manual live step: `npm run pb:migrate:delivery-pool` before deploying (the service 503s
until the fields exist). The old `/deliveries` page stays until the office switches over.
2026-10-31 07:56:03 +00:00
2026-10-31 08:20:23 +00:00
# Company letterhead lock — one quote letterhead per company
Each company is quoted on one letterhead, stored on `companies.confirmed_letterhead`
(CNL / ISHERWOOD, blank until confirmed). Rules: `src/lib/company-letterhead.ts` .
The company is always the **customer's** company (`customer.company`), never
`quotes.company` (that copy goes stale).
- The process page (`process-letterhead-buttons.tsx`): blank → either button opens
"Set the default letterhead for {company}"; set → that button works, the other is
locked and only a triple-click renders on it (override for that quote only).
- Enforced in `generateQuotePdf` : another letterhead without `override` returns
`letterhead_locked` (409 from `POST /api/v1/quotes/{id}/pdf` ). So MCP / Giellie /
n8n can't render the wrong one either. An override sticks once rendered (a quote
already rendered on a letterhead may re-render on it).
- With no letterhead passed, an unrendered quote takes the company's.
- Changed on the company page via `PUT /api/v1/companies/{id}/letterhead` .
- Manual live steps: `npm run pb:migrate:company-letterhead` , then dry-run and
`--apply` `scripts/2026-10-31-backfill-company-letterheads.ts` (fills companies
whose rendered quotes all used one letterhead). Until the migration runs the
lock is off and everything behaves as before.
2026-11-02 18:44:07 +00:00
# What's New (sidebar popup) — release numbers and commits, never dates
The sidebar's "What's New" popup lists releases from `src/lib/release-notes.ts` — a
hand-written array, newest first. There is no changelog table and no fetch: the notes are
typed in when a release goes out. The "New" badge lights up when the top entry's release
number differs from `wfdash-whats-new-seen` in the operator's localStorage.
- A release is identified by **its number** and **the short SHA of the commit it went out
with** (`commit`). The SHA is plain text — never a link; the Forgejo instance is on an
internal host.
- **No dates on screen.** The `date` field in the data file is an authoring reference only
(which window of commits a release covers) and is never rendered. The date formatter was
deleted, so there is no code path that can turn a date into text, and
`src/components/whats-new-dialog.test.tsx` fails the build if a date comes back.
- To publish a release: merge, take `git log --oneline -1` , add an entry at the top with the
next number (the next one after this change is 6) and that SHA. The number is the "seen"
marker — bumping it re-notifies everyone.