Compare commits
No commits in common. "main" and "agent/mika/6d6b5c094956" have entirely different histories.
main
...
agent/mika
|
|
@ -219,7 +219,6 @@ The `letterhead` body field accepts only `"CNL"` or `"ISHERWOOD"`. Anything else
|
|||
| `quotes.clone` | Clone a quote + items. |
|
||||
| `quotes.save_as_template`, `quotes.create_from_template` | Template management. |
|
||||
| `quotes.split`, `quotes.partial_approve`, `quotes.re_price`, `quotes.next_number`, `quotes.lookup` | Pricing workflow. **Note:** `quotes.lookup` is currently broken — see Known bugs §4. |
|
||||
| `quotes.assistant_fill` | Run the AI quote assistant on one quote (`quote_id`): fills unpriced lines from POS items as purple drafts a rep must review; unmatched lines get the closest POS items noted. Only status new / follow_up / claimed, never templates. Problems come back in `error`. Never marks lines done or sends anything. |
|
||||
| `quotes.items.rewrite` | **Not an MCP tool** — it's a REST endpoint at `/api/v1/quotes/items/rewrite` (browser-facing, needs PB session). |
|
||||
| `quote_items.populate_insights` | **Deterministic** compute-and-write of `quote_items.ai_item_insights`. Replaces the n8n AI rerun for the per-item popover. SKU-exact → SKU-normalised → description-fuzzy (Jaccard ≥ 0.6). See "Quote-item insights backfill" below. |
|
||||
|
||||
|
|
@ -291,20 +290,11 @@ REST mirror at `/api/v1/quote_items` (GET list + POST create) and `/api/v1/quote
|
|||
|
||||
### 8. Price-list catalogue (add items from a parsed supplier/quote document)
|
||||
|
||||
> **⚠️ OPT-IN ONLY (changed 2026-10-15).** `price_list.add_items` writes to the
|
||||
> `price_list` catalogue, which **the POS never reads** — `listActiveStockCatalog()`
|
||||
> queries `stock_items` only. A price that exists only here is invisible at the
|
||||
> till. It is also now classified `confirm` in `giellie-tool-policy.ts`, so it asks
|
||||
> every time regardless of the chat's trust setting. **Only call it when the
|
||||
> operator explicitly asked for the price list / catalogue / supplier price list.**
|
||||
> For "load prices", "add these items", "here's a supplier list", or anything
|
||||
> ambiguous, use `stock_items.create` — that is the default.
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `price_list.add_items` | Bulk-upsert parsed line items into the `price_list` collection. The agent reads the source document (supplier PDF, quote PDF, pasted email) with its native vision, extracts the per-line fields, and passes them in. The tool handles dedupe by `(sku, supplier)` and computes `sku_normalised` + `search_text` automatically. **You do the OCR, we do the commit.** Pass the supplier **name**, never a PocketBase record id — `price_list.supplier` is free text. |
|
||||
| `price_list.add_items` | Bulk-upsert parsed line items into the `price_list` collection. The agent reads the source document (supplier PDF, quote PDF, pasted email) with its native vision, extracts the per-line fields, and passes them in. The tool handles dedupe by `(sku, supplier)` and computes `sku_normalised` + `search_text` automatically. **You do the OCR, we do the commit.** |
|
||||
|
||||
**When to use it:** the operator explicitly asks you to add items to the **catalogue / price list** — not merely to load prices onto the counter. Read the document with your vision capability, extract the per-line fields, and call `price_list.add_items` with the structured array. If they asked to load prices for something they sell, that is `stock_items.create` (§ POS/stock items), not this.
|
||||
**When to use it:** the operator pastes a supplier's price list PDF, a quote from a customer, or a supplier's emailed price sheet into the chat and asks you to add the items to the catalogue. Read the document with your vision capability, extract the per-line fields, and call `price_list.add_items` with the structured array.
|
||||
|
||||
**Input shape:**
|
||||
|
||||
|
|
@ -356,7 +346,7 @@ REST mirror at `/api/v1/quote_items` (GET list + POST create) and `/api/v1/quote
|
|||
|
||||
**Why this exists:** the only pre-existing path for adding to the catalogue was operator uploads via `/pricelist` → n8n ingest → `raw_price_list` → AI extraction → `price_list`. That flow is async, human-in-the-loop, and gates on n8n being healthy. This tool gives you a synchronous, agent-driven shortcut for ad-hoc updates — paste a PDF, get the catalogue updated, no async hops.
|
||||
|
||||
### Full tool index (77)
|
||||
### Full tool index (75)
|
||||
|
||||
```
|
||||
orders.list, orders.get, orders.create_from_quote
|
||||
|
|
@ -367,7 +357,7 @@ quotes.create_from_email, quotes.append_reply, quotes.find_by_message_id,
|
|||
quotes.list, quotes.get, quotes.send_email, quotes.retry_email,
|
||||
quotes.clone, quotes.save_as_template, quotes.create_from_template,
|
||||
quotes.split, quotes.partial_approve, quotes.re_price, quotes.next_number,
|
||||
quotes.lookup, quotes.assistant_fill
|
||||
quotes.lookup
|
||||
quote_items.list, quote_items.get, quote_items.create, quote_items.update,
|
||||
quote_items.populate_insights
|
||||
attachments.upload_for_quote
|
||||
|
|
|
|||
|
|
@ -1,234 +0,0 @@
|
|||
---
|
||||
name: wfdash-rep-visit-batch-pin
|
||||
description: Bulk-pin a list of customers to a PocketBase site from a rep's visit log (typically Gary's driving-mode WhatsApp pins). Use whenever the user provides a coordinate (lat/lon or Google Maps URL), a site name, and a list of person names, and wants each person pinned to that location with a `rep_visits` record. Handles PB admin auth via `.env.local`, fuzzy match against the `customers` collection, presents ambiguity for unclear/duplicate/zero-hit names, then writes the pins in one batch. Covers the `customer~"X"` PB filter gotcha, the SAST→UTC timestamp conversion, the live-vs-snapshot schema drift warning, and the known skipped-name patterns. This is NOT the agent/MCP layer — for that, load `wfdash-giellie` instead.
|
||||
---
|
||||
|
||||
# WFDASH rep-visit batch pin
|
||||
|
||||
You are a small data-entry agent. Given a coordinate, site name, and a list of person names from a rep's visit log (typically Gary's WhatsApp driving-mode pins), pin each person to that location in PocketBase and create a `rep_visits` record per person.
|
||||
|
||||
**This is not the same scope as `wfdash-giellie`.** Giellie is the agent/MCP layer (Bearer token, `/api/mcp`). This skill talks **directly to PocketBase admin** because Giellie doesn't expose a bulk-pin-customers tool and won't gain one soon.
|
||||
|
||||
---
|
||||
|
||||
## When to load this skill
|
||||
|
||||
Load when the user does any of:
|
||||
|
||||
- "pin these names to [site]" / "backfill this WhatsApp batch"
|
||||
- Hands you a coordinate + place name + list of names
|
||||
- Pastes a raw WhatsApp export with a Google Maps link followed by names (most common case — parse it yourself)
|
||||
- Says "do the Cementation / Khuseleka / Doornkop thing" referring to a previous batch in this conversation
|
||||
|
||||
If the user wants to interact via the agent/MCP layer instead (`POST /api/mcp` or `/api/v1/*`), load `wfdash-giellie` — not this skill.
|
||||
|
||||
---
|
||||
|
||||
## Required inputs (ask for what's missing)
|
||||
|
||||
1. **Coordinates** — either:
|
||||
- Google Maps URL: `https://maps.google.com/?q=-26.217859,27.791794` (extract the `q=`)
|
||||
- Or raw: `-26.217859, 27.791794` (decimal degrees, lat first, lon second)
|
||||
2. **Site name** — free-text string, e.g. `Khuseleka`, `Cementation`, `Doornkop`, `Elandsrand / Kusasalethu`
|
||||
3. **Person names** — one per line, numbered, or pasted as a WhatsApp message block
|
||||
4. **(optional)** Per-message timestamps — use when each name was its own message; default to the single shared timestamp of the names message
|
||||
5. **(optional)** Rep ID — defaults to Gary `krpyec9lblra0qz`
|
||||
|
||||
If the user pastes a raw WhatsApp export (timestamps + Location URL + names), parse out coordinates, site, and names yourself. That's the dominant pattern.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Auth to PocketBase
|
||||
|
||||
**Never hardcode the PB admin email.** Always read `.env.local` in the WFDASH repo. Past bug: assistant assumed `david@wfsales.co.za` when the env file said `david.els1@outlook.com`.
|
||||
|
||||
```bash
|
||||
# From WFDASH repo root
|
||||
PB_EMAIL=$(grep -E '^PB_ADMIN_EMAIL=' .env.local | cut -d= -f2-)
|
||||
PB_PASSWORD=$(grep -E '^PB_ADMIN_PASSWORD=' .env.local | cut -d= -f2-)
|
||||
PB_URL=$(grep -E '^POCKETBASE_URL=' .env.local | cut -d= -f2-)
|
||||
```
|
||||
|
||||
POST to `${PB_URL}/api/collections/_superusers/auth-with-password`:
|
||||
|
||||
```json
|
||||
{ "identity": "<email>", "password": "<password>" }
|
||||
```
|
||||
|
||||
Cache the returned `token`. It's valid for the session. If you get a 401 mid-batch, re-auth.
|
||||
|
||||
### 2. Verify live schema (don't trust the local snapshot)
|
||||
|
||||
The local snapshot at `pb-exports/pb_schema-2.json` may be stale. As of last sync it was missing `site_location` (which exists in live PB). Before writing, `GET /api/collections/customers/records?perPage=1` and confirm the fields you intend to write are actually present in the live record. If `site_location` is gone from live PB too, drop it and ask the user how they want site tracked now.
|
||||
|
||||
### 3. Site check
|
||||
|
||||
`site_location` is a free-text `select` on `customers`. PB silently accepts new string values. Before writing, `GET /api/collections/customers/records?perPage=200&fields=site_location&filter=site_location!=''` to enumerate existing values and confirm whether the new site is genuinely new. Report site growth at the end of the batch.
|
||||
|
||||
### 4. Fuzzy-match each name against `customers`
|
||||
|
||||
**Critical gotchas:**
|
||||
|
||||
- **Field is `customer`, NOT `name`.** The PB filter operator is `~` (contains), not `LIKE`.
|
||||
- ✅ `GET /api/collections/customers/records?filter=customer~"Corrie"&perPage=10`
|
||||
- ❌ `?filter=name~"Corrie"` (returns 0 hits — the bug from the Khuseleka batch)
|
||||
- For each name, try exact match first, then substring if no exact.
|
||||
- If multiple hits, list all candidates with their IDs.
|
||||
- If zero hits, report zero and present skip/create options.
|
||||
- If one hit, mark ✓ clear.
|
||||
- Single-letter differences usually mean the same person (driving-mode typo) — flag with ⚠️ but don't auto-decide.
|
||||
|
||||
Run all searches in parallel (Promise.all / xargs -P / etc.). Don't burn a sequential round-trip per name.
|
||||
|
||||
### 5. Present match table + ask disambiguation
|
||||
|
||||
**ALWAYS present a table before writing anything.** Format:
|
||||
|
||||
```
|
||||
| # | WhatsApp | PB matches | Notes |
|
||||
|---|---------|-----------|-------|
|
||||
| 1 | Corrie Strydom | `Corrie Strydom` (`zfasu6tsfcj87if`) — exact | ✓ clear |
|
||||
| 2 | Johan Schultz | `Johan Schoultz Shaft Foreman` (`quvzzrufkn7xkqu`) | ⚠️ single-letter typo |
|
||||
| 3 | Mark Wilkie | 2 records: `4lgz...` and `8rfe...` | ❓ duplicate |
|
||||
| 4 | Andre Grobler | 0 hits | ❓ zero hit |
|
||||
```
|
||||
|
||||
Then ask the user for each ambiguous/zero-hit name. Group into one set of questions with a/b/c options per item. **Do not write anything until the user answers.**
|
||||
|
||||
Standard question templates:
|
||||
|
||||
| Situation | Options |
|
||||
|---|---|
|
||||
| Single-letter typo (only candidate exists) | (a) Pin as the typo-corrected record / (b) Skip / (c) Create new |
|
||||
| 2+ duplicates | (a) Pin both (I'll merge later) / (b) Pin one (tell me which) / (c) Skip — let's dedupe first |
|
||||
| 0 hits | (a) Skip (probably wrong name) / (b) Create new customer |
|
||||
| Ambiguous (multiple distinct candidates) | (a) Skip / (b) Pin [specific candidate] / (c) Create new |
|
||||
|
||||
### 6. Apply writes
|
||||
|
||||
For each confirmed person, run TWO operations:
|
||||
|
||||
**A. Update the customer record**
|
||||
|
||||
```
|
||||
PATCH ${PB_URL}/api/collections/customers/records/{id}
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{
|
||||
"coordinates": { "lat": <lat>, "lon": <lon> },
|
||||
"site_location": "<site>",
|
||||
"lastvisiteddate": "<SAST-2h>Z"
|
||||
}
|
||||
```
|
||||
|
||||
**B. Create a `rep_visits` record**
|
||||
|
||||
```
|
||||
POST ${PB_URL}/api/collections/rep_visits/records
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{
|
||||
"customer": "<customer_id>",
|
||||
"rep": "<rep_id>",
|
||||
"position_source": "manual",
|
||||
"coordinates": { "lat": <lat>, "lon": <lon> },
|
||||
"notes": "Manual backfill from WhatsApp pin: <site> (<lat>,<lon>). Driving-mode typos: <typo1> → <canonical1>, ...",
|
||||
"visited_at": "<SAST-2h>Z",
|
||||
"pinned_customer": true
|
||||
}
|
||||
```
|
||||
|
||||
Run all writes in parallel. Verify after.
|
||||
|
||||
### 7. Verify
|
||||
|
||||
After all writes:
|
||||
|
||||
- `GET /api/collections/customers/records/{id}` for one of the updated customers — confirm `coordinates`, `site_location`, `lastvisiteddate` persisted.
|
||||
- `GET /api/collections/rep_visits/records/{id}` for one of the created visits — confirm the fields persisted.
|
||||
- Report new site count across all customers.
|
||||
|
||||
### 8. Report
|
||||
|
||||
Final report should include:
|
||||
|
||||
1. **What was done** — table: customer, coords, site, timestamp, visit ID.
|
||||
2. **Spelling corrections** — e.g. "PB spelling: `Kobus Oberholtzer` (two `t`s), Gary's spelling was the typo."
|
||||
3. **Skipped names** — tally grows by N.
|
||||
4. **Site growth** — distinct `site_location` count.
|
||||
5. **Outstanding follow-ups**:
|
||||
- `site_location` is still free-text on `customers` (and not on `rep_visits`). Past ~10 sites, migrate to a structured `site` collection + `rep_visits.site` foreign key.
|
||||
- The driving-mode importer (you, but automated from a WhatsApp webhook) is a half-day project.
|
||||
|
||||
---
|
||||
|
||||
## Quirk table
|
||||
|
||||
| Quirk | Detail |
|
||||
|---|---|
|
||||
| Auth email | `.env.local` → `PB_ADMIN_EMAIL`, never hardcoded |
|
||||
| PB filter field | `customer~"X"`, NOT `name~"X"` or `LIKE` |
|
||||
| `customers.coordinates` | GeoJSON `{lat, lon}` |
|
||||
| `rep_visits.coordinates` | GeoJSON `{lat, lon}` (same as customers — both are `geoPoint`) |
|
||||
| `rep_visits.site_location` | Does not exist on the collection; do NOT include in visit writes. Site info lives on the customer record and in the visit's `notes` field. |
|
||||
| Timestamp | SAST = UTC+2; convert all WhatsApp times before writing |
|
||||
| Local PB snapshot | `pb-exports/pb_schema-2.json` may be stale — verify live schema before writing |
|
||||
| Driving typos | Single-letter differences are usually the same person (Kobus Oberholtzer, Enzo Msiza, Tinus van de Venter all pinned); always confirm with user |
|
||||
| Duplicates | If a name matches 2+ records, ask — usually "pin both, I'll merge later" |
|
||||
| Zero hits | Same shape as Gorden / Andre / Richard Dube — usually skip |
|
||||
| Per-message timestamps | Use when each name was its own WhatsApp message; default to shared timestamp |
|
||||
| Pin-vs-name gap | If the location pin and the names message have a >5 min gap, flag it — could mean a second un-pinned visit |
|
||||
|
||||
---
|
||||
|
||||
## Known skipped names (as of last sync)
|
||||
|
||||
If you encounter any of these again, **ask** before creating — they may be the same driving-typo situation:
|
||||
|
||||
- Gorden van Staden
|
||||
- Andre Grobler
|
||||
- Richard Dube
|
||||
- Lucky Mabena
|
||||
- Janu
|
||||
- Nthabising Maloisi
|
||||
|
||||
If the user later confirms any of these are real people (not typos), backfill is straightforward: same workflow as a normal batch, treat as zero-hit option (b).
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Don't write before presenting.** The user always wants a match table + disambiguation questions first.
|
||||
- **Don't guess on zero-hits.** Skip by default; ask before creating new customers.
|
||||
- **Don't auto-resolve duplicates.** Ask whether to pin both, one, or skip for dedup-first.
|
||||
- **Don't auto-resolve single-letter typos.** Most of them are right (Kobus, Enzo, Tinus), but phrase as a question, not a fact.
|
||||
- **Don't hardcode the PB email.** Always read `.env.local`.
|
||||
- **Don't include `site_location` on `rep_visits` writes** — the field doesn't exist on that collection.
|
||||
- **Don't use `name~` as a filter** — the field is `customer`.
|
||||
- **Don't write without verifying** — after each batch, re-fetch one customer and one visit to confirm persistence.
|
||||
- **Don't trust the local PB snapshot** — it's known to be stale on at least `site_location`.
|
||||
|
||||
---
|
||||
|
||||
## Worked example
|
||||
|
||||
User input:
|
||||
|
||||
```
|
||||
[2026/09/10, 10:07:15] Gary CNL: Location: https://maps.google.com/?q=-25.624908,27.256472 - Khuseleka
|
||||
[2026/09/10, 10:38:59] Gary CNL: Corrie Strydom / Hein haasbroek / Johan Schultz / Lucas barnard / Nthabising maloisi / Titus Negota
|
||||
```
|
||||
|
||||
What you do:
|
||||
|
||||
1. Parse: coords = `(-25.624908, 27.256472)`, site = `Khuseleka`, names = [`Corrie Strydom`, `Hein haasbroek`, `Johan Schultz`, `Lucas barnard`, `Nthabising maloisi`, `Titus Negota`], single shared timestamp `10:38:59 SAST → 08:38:59Z`.
|
||||
2. Read `.env.local`, auth to PB.
|
||||
3. Fuzzy-match all 6 in parallel.
|
||||
4. Present table (exact, exact, typo, exact, 0 hits, typo).
|
||||
5. Ask: "Pin Schoultz?" / "Pin Neqota?" / "Skip or create Maloisi?".
|
||||
6. After answers, run 2N writes (PATCH + POST each).
|
||||
7. Verify, report.
|
||||
|
||||
That's the whole loop.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "wfdash-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "-p", "4001"],
|
||||
"port": 4001
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
# Plans folder
|
||||
|
||||
Cross-session handoff between the **planner** (Claude on cloud) and the **implementer** (any model — usually Minimax in a separate session). The plan file is the contract — chat history doesn't transfer, so the plan has to be self-contained.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Planner** writes a plan to `.claude/plans/<slug>.md` from the [`TEMPLATE.md`](./TEMPLATE.md) shape.
|
||||
- Use the `superpowers:writing-plans` skill if available.
|
||||
- The plan must be self-contained — anything the implementer can't infer from the working tree goes in the **Background** section.
|
||||
- The **Background** section is where the planner summarises the conversation that led to the plan: decisions, dead ends, "we tried X but Y", links to prior commits, the operator's actual intent. This is the tribal-knowledge handoff.
|
||||
- Set frontmatter `status: ready` when ready to hand off.
|
||||
|
||||
2. **Hand off** — pick one:
|
||||
- **File**: tell the implementer session "read `.claude/plans/<slug>.md` and execute".
|
||||
- **Git**: commit the plan on a branch, switch the implementer session to that branch.
|
||||
- **Orca**: `orca handoff --plan .claude/plans/<slug>.md` (see the `orca-cli` skill).
|
||||
|
||||
3. **Implementer** boots from [`RUN-PLAN.md`](./RUN-PLAN.md) — paste it as the opening prompt of the new session, or load it via skill if the harness supports it.
|
||||
- Reads the plan + the repo's `CLAUDE.md` / `AGENTS.md`.
|
||||
- Follows the plan, surfaces disagreements rather than improvising.
|
||||
- Flips `status: in_progress` → `complete` (or `blocked`) and writes a short report in the plan's **Status (filled by implementer)** section.
|
||||
|
||||
4. **Loop back** — the implementer's Status section + commit messages are the planner's input for the next iteration.
|
||||
|
||||
## File layout
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `README.md` | This file — orientation |
|
||||
| `TEMPLATE.md` | Copy this to start a new plan |
|
||||
| `RUN-PLAN.md` | Paste this into the implementer session as its opening prompt |
|
||||
| `<slug>.md` | An individual plan. Slug is short-kebab-case, matches the feature / branch name |
|
||||
| `<slug>.status.md` | Optional. Implementer writes one of these if it needs to report back without flipping the plan's `status` frontmatter (e.g. mid-run questions) |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Commit plans** with the work. They're version-controlled documentation of intent, not scratch.
|
||||
- **One plan per feature / branch.** Split if the scope grows.
|
||||
- **Status lifecycle**: `draft` → `ready` → `in_progress` → `complete` (or `blocked` / `abandoned`).
|
||||
- **Don't bypass MCP.** If the implementer needs a tool that doesn't exist, surface the gap in the status file — don't fall back to direct REST calls (Hermes / n8n drops `AGENT_API_KEY` on direct calls; see `.claude/memory/mcp-only-no-direct-api.md`).
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
# RUN-PLAN — implementer boot prompt
|
||||
|
||||
> **Paste this into the opening prompt of the implementer session.** Replace `<slug>` with the plan filename (without `.md`).
|
||||
>
|
||||
> Suggested opening prompt:
|
||||
> ```
|
||||
> Read .claude/plans/RUN-PLAN.md, then read .claude/plans/<slug>.md and execute it.
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
You are the implementer for a plan someone else wrote. **The plan is the contract — do not improvise.** Your job is to execute it faithfully, verify it landed, and report back.
|
||||
|
||||
## 1. Load context
|
||||
|
||||
In this order:
|
||||
|
||||
1. **The plan**: `.claude/plans/<slug>.md` — read it end-to-end. Don't skim.
|
||||
2. **The project rules**: this repo's `CLAUDE.md` and `AGENTS.md` at the repo root. The plan assumes you know these. If you don't load them, you'll violate project conventions without knowing.
|
||||
3. **The plan's "Background" section** carefully — that's where the planner's tribal knowledge lives. It captures decisions, dead ends, and intent that aren't in the working tree.
|
||||
4. **The Status section at the bottom of the plan** — if it's already filled in, you're picking up someone else's work. Read their report before doing anything.
|
||||
|
||||
## 2. Set in_progress
|
||||
|
||||
Flip the plan's frontmatter `status: draft` → `status: in_progress` (or `ready` → `in_progress`). This signals to the planner that the hand-off was accepted.
|
||||
|
||||
## 3. Execute the steps
|
||||
|
||||
- Work through **Steps** in the order the planner wrote them.
|
||||
- After each step, run its **verify** check before moving on. Unverified steps aren't done.
|
||||
- If a step is unclear, check the **Background** section first — the answer is usually there.
|
||||
- If it's still unclear after that, **stop and write a `<slug>.status.md` file**. Do NOT silently improvise.
|
||||
|
||||
## 4. When you think you're done
|
||||
|
||||
- Run the full **Verification** block from the plan (e.g. `pnpm test`, manual UI check).
|
||||
- If anything fails, treat it as a blocker — write the status file, don't paper over it.
|
||||
- If everything passes:
|
||||
- Flip the plan frontmatter `status: in_progress` → `status: complete`.
|
||||
- Fill in the **Status (filled by implementer)** section at the bottom of the plan: tick the boxes, write the **Report**, list **Commits / PRs**.
|
||||
- Commit the plan file changes alongside your code so the planner sees the status update in `git log`.
|
||||
|
||||
## Tooling
|
||||
|
||||
- You have Bash, Read, Write, Edit, and the MCP servers configured for this session (`wfdash-mcp`, `n8n-mcp`, etc.). Same tool surface as the planner, modulo per-session config.
|
||||
- **Filesystem root**: `/Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP`. All paths in the plan are relative to this unless explicitly absolute.
|
||||
- **Dev server**: `http://localhost:4001` (NOT `:3000`).
|
||||
- **Tests**: `pnpm test` runs Vitest. The Verification block names the specific command.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Don't rewrite the plan.** If it needs changes, ask via a status file. The planner wrote it for a reason.
|
||||
- **Don't add features the planner didn't ask for.** Scope creep breaks the loop.
|
||||
- **Don't skip the verification step.** Unverified steps aren't done.
|
||||
- **Don't bypass MCP** (no direct REST calls to WFDASH). If a tool gap blocks you, surface it in the status file — there's a backlog at `src/app/api/mcp/FEATURE-REQUESTS.md`. Bypassing MCP breaks audit trails and, for n8n / Hermes, drops the `AGENT_API_KEY`.
|
||||
- **Don't merge your own branch.** Push and let the planner review.
|
||||
- **Don't start work on `main` directly.** The plan's `branch:` field names where you should be.
|
||||
|
||||
## Reporting back
|
||||
|
||||
The plan file **is** your report. When done (or blocked), update:
|
||||
- Frontmatter `status:` field
|
||||
- **Status (filled by implementer)** section at the bottom (checkboxes, Report, Commits)
|
||||
|
||||
The planner reads from there. Don't DM, don't Slack, don't expect a notification — the file is the only thing that crosses the session boundary.
|
||||
|
||||
## If you need to ask a question mid-run
|
||||
|
||||
Write `.claude/plans/<slug>.status.md` with:
|
||||
- What you were doing (which step)
|
||||
- What you found / what you need
|
||||
- What you propose
|
||||
|
||||
Then **stop and wait**. The planner will reply by editing the plan or the status file. Don't poll, don't keep working on guesses.
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
---
|
||||
plan_id: <short-kebab-slug>
|
||||
created: <YYYY-MM-DD>
|
||||
created_by: <planner identity — "Claude (cloud)" | "Claude (minimax)" | operator name>
|
||||
target_session: claude | minimax | any
|
||||
status: draft | ready | in_progress | complete | blocked | abandoned
|
||||
branch: <git branch name, if any>
|
||||
related_issue: <URL or id, optional>
|
||||
---
|
||||
|
||||
# <Plan title — short, action-oriented>
|
||||
|
||||
> Copy this file to `.claude/plans/<slug>.md` and fill in. Use the `superpowers:writing-plans` skill if available.
|
||||
|
||||
## Goal
|
||||
|
||||
<One sentence. What does "done" look like from the operator's perspective?>
|
||||
|
||||
## Background (from planner's conversation)
|
||||
|
||||
**This is the most important section for the implementer.** The chat history doesn't transfer between sessions, so anything the planner and the operator discussed but didn't write down goes here.
|
||||
|
||||
Cover, in free form:
|
||||
- **Why this work** — the operator's stated intent, the user story, the bug report
|
||||
- **Decisions made in chat** — "we agreed to use X over Y because…", "the operator vetoed Z"
|
||||
- **Dead ends** — "we tried `<approach>` but it failed because…"
|
||||
- **Constraints** — backwards compat, must-not-break, deadlines, who's affected
|
||||
- **Links** — relevant commits, prior plans in this folder, external docs, Slack threads
|
||||
|
||||
## Context (links the implementer needs)
|
||||
|
||||
- **AGENTS.md rules that apply:** <bulleted list, e.g. "PB select fields are not self-service", "file URLs go through `/api/v1/files/`">
|
||||
- **PB schema fields touched:** <relation ids, new select values needed, new collections>
|
||||
- **MCP tools involved:** <e.g. `mcp__wfdash-mcp__quote_items_create`, `mcp__wfdash-mcp__orders_update`>
|
||||
- **Prior art in repo:** <`file:line` refs to similar code to copy from>
|
||||
- **External references:** <URLs>
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
-
|
||||
|
||||
**Out of scope:**
|
||||
- (explicitly call these out so the implementer doesn't drift)
|
||||
|
||||
## Files to touch
|
||||
|
||||
- `src/path/to/file.ts` — create | modify | delete — <one-line: what changes>
|
||||
- `src/path/to/another.tsx` — modify — <what changes>
|
||||
|
||||
## Steps
|
||||
|
||||
<Numbered. Each step: action verb + target + a way to verify it landed.>
|
||||
|
||||
1. <Step>
|
||||
- verify: <command or check>
|
||||
2. <Step>
|
||||
- verify: <command or check>
|
||||
3. <Step>
|
||||
- verify: <command or check>
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm test` — full suite green (or specific test file: `pnpm test src/lib/foo.test.ts`)
|
||||
- Manual: `<http://localhost:4001/path>` renders <expected>
|
||||
- Acceptance: <business-level check the operator will care about>
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- <Anything the implementer should surface rather than decide alone>
|
||||
- <Assumptions that might be wrong — call them out so they get validated>
|
||||
|
||||
---
|
||||
|
||||
## Status (filled by implementer)
|
||||
|
||||
<!-- Implementer: update this section as you work. Don't edit anything above this line without writing a status file first. -->
|
||||
|
||||
- [ ] Plan read end-to-end + AGENTS.md / CLAUDE.md loaded
|
||||
- [ ] Branch / working tree confirmed
|
||||
- [ ] Steps 1..N complete with their verify checks passing
|
||||
- [ ] `pnpm test` green
|
||||
- [ ] Manual verification at `http://localhost:4001/...` done
|
||||
- [ ] Status frontmatter flipped to `complete`
|
||||
|
||||
### Report
|
||||
|
||||
<Free text. Blockers, surprises, things the planner should know. Link to commits / PRs.>
|
||||
|
||||
### Commits / PRs
|
||||
|
||||
- `<sha>` — `<message>`
|
||||
|
|
@ -1,513 +0,0 @@
|
|||
---
|
||||
plan_id: quote-attachments-split
|
||||
created: 2026-09-09
|
||||
created_by: Claude (operator session)
|
||||
target_session: claude | minimax
|
||||
status: in_progress
|
||||
branch: feat/quote-attachments-split
|
||||
---
|
||||
|
||||
# Quote attachments — physical separation by audience
|
||||
|
||||
## Goal
|
||||
|
||||
Make it impossible to attach the wrong file to the wrong audience on a quote.
|
||||
Three physical stores, one decision rule: **if it lives in `quotes.outbound_attachments` it goes to the customer; otherwise it doesn't.**
|
||||
|
||||
## Background (from planner's conversation)
|
||||
|
||||
**Operator's stated intent** (paraphrased from the conversation):
|
||||
|
||||
> "Inbound = what the customer sent with their RFQ. Internal = anything we
|
||||
> use to justify our numbers (supplier quotes, per-line pricing proofs).
|
||||
> Outbound = datasheets and anything else we want the customer to see.
|
||||
> Only outbound goes to the customer."
|
||||
|
||||
**The bug:** The single `quote_attachments` table uses the column
|
||||
`direction` (PB enum: `inbound | outbound | generated`) for what is
|
||||
actually three different concepts. The upload route at
|
||||
`src/app/api/v1/quotes/[id]/attachments/upload/route.ts:113-114`
|
||||
literally writes:
|
||||
|
||||
```ts
|
||||
const direction = kind === "inbound" ? "inbound" : "outbound";
|
||||
```
|
||||
|
||||
so every "proof" file uploaded from the sprint pricing modal — a
|
||||
supplier price list, a screenshot, an internal justification — gets the
|
||||
`direction="outbound"` tag and gets pre-selected into the customer
|
||||
email by `src/components/quotes/send-quote-modal.tsx:266-272` and the
|
||||
approvals page filter at
|
||||
`src/app/(app)/quotes/approvals/[id]/page.tsx:466-470`.
|
||||
|
||||
**Decisions made:**
|
||||
|
||||
- **Option B over A/C.** Operator picked separate file fields on the
|
||||
`quotes` row (Option B in the planner's question). The schema itself
|
||||
enforces the boundary — files in `quotes.outbound_attachments`
|
||||
customer-bound, files in `quotes.internal_attachments` operator-only,
|
||||
files in `quote_item_attachments` per-line operator-only. The
|
||||
migrated-out `quote_attachments` table stays read-only for history.
|
||||
- **Supplier quotes** live in `quotes.internal_attachments`, same as
|
||||
anything we use to justify our numbers. (Confirmed by question 2.)
|
||||
- **Migrate** historical `direction="outbound"` rows into the new
|
||||
`quotes.outbound_attachments` field. After migration, the
|
||||
`quote_attachments` table is read-only for inbound-only viewing.
|
||||
(Confirmed by question 3.) Generated PDFs (`kind="pdf"` /
|
||||
`direction="generated"`) also move to `quotes.outbound_attachments`
|
||||
because every PDF is customer-bound by definition.
|
||||
|
||||
**Dead ends to record:**
|
||||
|
||||
- We considered, and rejected, repurposing `quote_item_attachments`
|
||||
for supplier quotes. It is single-file and per-line; a supplier
|
||||
quote can cover multiple lines and we want to keep the per-line
|
||||
table purely about pricing evidence for one line at a time.
|
||||
- We considered, and rejected, putting supplier quotes on
|
||||
`orders.order_attachements`. Operator's flow wants supplier
|
||||
evidence visible during pricing, before an order exists.
|
||||
|
||||
**Pointer back** — the system reminder at the start of the planning
|
||||
turn had this unrelated working-tree noise we must NOT touch:
|
||||
|
||||
- `M src/app/(app)/delivery-notes/[id]/page.tsx`
|
||||
- `M src/lib/server/delivery-notes-service.ts`
|
||||
- `?? src/app/api/v1/delivery-notes/items/[id]/pricing/route.ts`
|
||||
- `?? docs/dev-timeline.html`
|
||||
|
||||
## Context (links the implementer needs)
|
||||
|
||||
**AGENTS.md rules that apply:**
|
||||
|
||||
- **File URLs go through `/api/v1/files/`, never `/api/files/`.** The
|
||||
proxy at `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`
|
||||
whitelists collections by PB id. After schema change, `quotes` is
|
||||
still whitelisted, so no proxy edit is required — the proxy hits
|
||||
`${pb}/api/files/quotes/{id}/{filename}` and that just works once
|
||||
the field exists. Server-to-server reads (PDF render, MCP base64)
|
||||
still use `${pb}/api/files/...` directly with admin auth as today.
|
||||
- **Reuse existing multi-file fields for new attachment types.** This
|
||||
rule from AGENTS.md is satisfied trivially: we ARE reusing the
|
||||
`quotes` collection by adding two file fields, not a new collection.
|
||||
- **Data access layer discipline.** New routes go through service
|
||||
helpers in `src/lib/server/quotes-service.ts`. Existing
|
||||
`quote_attachments` reads stay where they are (they're the historical
|
||||
view surface).
|
||||
- **PocketBase schema is the source of truth.** The local
|
||||
`pb-exports/pb_schema-2.json` is a snapshot for reference only. The
|
||||
running PB instance is authoritative; the implementer must NOT touch
|
||||
the snapshot as a substitute for the operator touching the live PB
|
||||
admin.
|
||||
|
||||
**PB schema fields touched (operator does these in PB admin):**
|
||||
|
||||
1. `quotes` collection — add `outbound_attachments` (file, maxSelect 10,
|
||||
any MIME).
|
||||
2. `quotes` collection — add `internal_attachments` (file, maxSelect 10,
|
||||
any MIME).
|
||||
3. **(Not strictly required)** Optionally tighten the `quote_attachments.direction`
|
||||
enum to drop `outbound` once migration is done. We leave it for now
|
||||
because dropping a select value would break any leftover reads.
|
||||
|
||||
**MCP tools involved:** none. The change is in routes + service + UI
|
||||
only. Existing MCP tools that touch `quote_attachments`
|
||||
(`attachments.upload_for_quote`, etc.) keep their current shape but
|
||||
should be flagged for a follow-up sweep that decides whether the
|
||||
n8n/MCP intake should land in `quote_attachments` (inbound) or in
|
||||
`quotes.internal_attachments` (operator upload). Out of scope here.
|
||||
|
||||
**Prior art in repo to copy from:**
|
||||
|
||||
- `src/lib/server/stock-purchases-service.ts:addStockPurchaseAttachment`
|
||||
(lines ~1100-1145) — already implements the read-before-append pattern
|
||||
for a multi-file field on a parent row. Use as the template for the
|
||||
new internal/outbound upload helpers.
|
||||
- `src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts` —
|
||||
thin route wrapping an admin PB `update` with multipart FormData.
|
||||
Use as the structure template.
|
||||
|
||||
**External references:**
|
||||
|
||||
- PB file-field docs: https://pocketbase.io/docs/files-handling/
|
||||
(snippet via `new FormData()` + `collection("...").update(id, fd)`).
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
|
||||
- Two new file fields on `quotes` (`outbound_attachments`,
|
||||
`internal_attachments`).
|
||||
- Service helpers in `src/lib/server/quotes-service.ts` for both new
|
||||
fields, written read-then-append-then-write.
|
||||
- Thin routes for POST + DELETE on each new field.
|
||||
- Re-route the existing quote PDF generator so it lands in
|
||||
`quotes.outbound_attachments` instead of `quote_attachments`.
|
||||
- Re-route the existing sproof-attachments upload so
|
||||
`kind="proof"` writes to `quotes.internal_attachments` instead of
|
||||
a `quote_attachments` row.
|
||||
- Re-route n8n MCP's `uploadQuoteAttachment` so it writes inbound
|
||||
files to `quote_attachments` (unchanged) — verify and document, no
|
||||
code change needed since the inbound code path is already correct.
|
||||
- Update approvals page and send-quote-modal to read outbound from
|
||||
`quote.expand.outbound_attachments` (PDF still auto-included).
|
||||
- Migration script: copy every `quote_attachments` row where
|
||||
`direction in ("outbound","generated")` (per quote id) into the
|
||||
corresponding `quotes.outbound_attachments` field. Run once.
|
||||
- Update file-proxy whitelist if necessary (it already covers
|
||||
`quotes`, so likely no edit).
|
||||
|
||||
**Out of scope (do NOT do these):**
|
||||
|
||||
- Touching delivery-notes, sprint tooling, per-item attachments.
|
||||
- Touching the unrelated working-tree noise (delivery-notes page,
|
||||
pricing route, dev-timeline.html).
|
||||
- Touching `quote_item_attachments` upload paths. They are correct.
|
||||
- Reordering the `quote_attachments.direction` enum (dropping
|
||||
`outbound`). Defer to a follow-up once we have observability that
|
||||
no reads still rely on it.
|
||||
- Changing MCP / n8n email-intake behavior. The intake writes
|
||||
`direction="inbound"` today and that stays correct.
|
||||
- Migrating inbound `quote_attachments` rows anywhere — they
|
||||
stay in place as historical reference and the existing
|
||||
`/api/v1/quotes/[id]/attachments` GET endpoint keeps reading them.
|
||||
|
||||
## Files to touch
|
||||
|
||||
**Schema (operator does in PB admin):**
|
||||
|
||||
- `quotes` collection — add `outbound_attachments: file[]`
|
||||
(maxSelect 10, any MIME).
|
||||
- `quotes` collection — add `internal_attachments: file[]`
|
||||
(maxSelect 10, any MIME).
|
||||
|
||||
**Service layer:**
|
||||
|
||||
- `src/lib/server/quotes-service.ts` — add
|
||||
`addInternalQuoteAttachment(quoteId, file)`,
|
||||
`addOutboundQuoteAttachment(quoteId, file)`,
|
||||
`removeInternalQuoteAttachment(quoteId, filename)`,
|
||||
`removeOutboundQuoteAttachment(quoteId, filename)`. All four use
|
||||
the read-append-write pattern from
|
||||
`src/lib/server/stock-purchases-service.ts:addStockPurchaseAttachment`.
|
||||
Refactor `uploadQuoteAttachment` JSDoc to mention it's inbound-only
|
||||
going forward; do NOT change its implementation (still writes
|
||||
`quote_attachments` correctly).
|
||||
|
||||
**Routes (create):**
|
||||
|
||||
- `src/app/api/v1/quotes/[id]/internal-attachments/route.ts` —
|
||||
POST `file` → `addInternalQuoteAttachment`; DELETE `?filename=` →
|
||||
`removeInternalQuoteAttachment`. Mirrors
|
||||
`src/app/api/v1/stock-purchases/[id]/attachments/[filename]/route.ts`.
|
||||
|
||||
- `src/app/api/v1/quotes/[id]/outbound-attachments/route.ts` — same
|
||||
shape as above but for outbound. This is the new entry point
|
||||
for the sprint pricing modal's "Attach to send to customer"
|
||||
affordance and for the send modal's preview.
|
||||
|
||||
**Routes (modify):**
|
||||
|
||||
- `src/app/api/v1/quotes/[id]/attachments/upload/route.ts` — change
|
||||
the dispatch so `kind=proof` writes to `quotes.internal_attachments`
|
||||
via `addInternalQuoteAttachment`, `kind=outbound` writes to
|
||||
`quotes.outbound_attachments` via `addOutboundQuoteAttachment`.
|
||||
Keep `kind=inbound` as-is (legacy RFQ archive path). Reject unknown
|
||||
kinds. **Do NOT touch the PDF generator or the PDF download route —
|
||||
the system-generated PDF continues to live in `quote_attachments`
|
||||
as `kind="pdf"` because that's a system-managed file with its own
|
||||
lifecycle, not operator-uploaded content.**
|
||||
- verify: `pnpm typecheck` passes; spot-check upload via
|
||||
`curl` and PB admin — `kind=proof` file lands in
|
||||
`quotes.internal_attachments`, `kind=outbound` lands in
|
||||
`quotes.outbound_attachments`. Neither appears in
|
||||
`quote_attachments` rows.
|
||||
|
||||
**Routes (read endpoint, optional):**
|
||||
|
||||
- `src/app/api/v1/quotes/[id]/attachments/route.ts` — extend the
|
||||
response shape so the approvals page can render internal +
|
||||
outbound + historical inbound from one GET. New fields:
|
||||
`{ inbound: [...historical rows], internal: [...quote row files],
|
||||
outbound: [...quote row files] }`. The existing `items` array
|
||||
stays for back-compat with anything that hasn't been updated.
|
||||
|
||||
**Pages / components (modify):**
|
||||
|
||||
- `src/app/(app)/quotes/approvals/[id]/page.tsx` — change the
|
||||
attachments filter (lines 466-470) so outbound is sourced from
|
||||
the quote row directly, not from a `direction === "outbound"`
|
||||
filter on `quote_attachments`.
|
||||
|
||||
- `src/components/quotes/send-quote-modal.tsx` — read outbound from
|
||||
the new field. The "select all" effect at line ~266 stays the
|
||||
same (it iterates over outbound attachments); only the source
|
||||
array changes.
|
||||
|
||||
- Sprint pricing modal upload UI — split the existing "attach proof"
|
||||
button into two: "Attach for our records" (internal) and
|
||||
"Attach to send to customer" (outbound). Keep the existing
|
||||
proof upload going to `quote_item_attachments` for per-line
|
||||
evidence; the quote-level splits above are for quote-level
|
||||
files.
|
||||
|
||||
**Migration (one-time script, run by the operator):**
|
||||
|
||||
- `scripts/migrate-quote-attachments.mjs` (new) — Node 20+
|
||||
script that:
|
||||
1. Loads `POCKETBASE_URL` and `POCKETBASE_ADMIN_TOKEN` from `.env`.
|
||||
2. Lists every `quote_attachments` row where
|
||||
`direction in ("outbound","generated")` and groups by quote.
|
||||
3. For each group, reads the corresponding `quotes.outbound_attachments`
|
||||
array, merges with the file rows being migrated, writes back.
|
||||
4. Lists every `quote_attachments` row where
|
||||
`direction="inbound"` and skips (those stay).
|
||||
5. Prints a summary: how many migrated, per quote.
|
||||
6. Idempotent: re-runs are no-ops (the migrated rows won't
|
||||
reappear in the filter).
|
||||
7. Does NOT delete source rows.
|
||||
|
||||
## Steps
|
||||
|
||||
Each step has a verify line. Touch code in this order — top-down: stop
|
||||
after each step's verify, then go again.
|
||||
|
||||
1. **Operator (do first, blocks everything):** Add the two new file fields
|
||||
to the `quotes` collection in PB admin:
|
||||
`outbound_attachments` and `internal_attachments`, both
|
||||
`maxSelect: 10`, any MIME.
|
||||
- verify: `curl -H "Authorization: $PB_TOKEN" ${PB}/api/collections/quotes | jq '.schema[].name' | grep -E 'outbound|internal'_attachments`
|
||||
returns both field names.
|
||||
|
||||
2. **Service helpers** — add the four helpers in
|
||||
`src/lib/server/quotes-service.ts`. Use the `addStockPurchaseAttachment`
|
||||
pattern as the template. Do NOT touch the existing `uploadQuoteAttachment`.
|
||||
- verify: `pnpm typecheck` (or whatever the project uses) passes.
|
||||
|
||||
3. **New route POST/DELETE** — create
|
||||
`src/app/api/v1/quotes/[id]/internal-attachments/route.ts`
|
||||
and the matching `outbound-attachments` file.
|
||||
- verify: `pnpm test` still green; spot-check by running
|
||||
`pnpm dev` and `curl -F file=@/tmp/x.pdf http://localhost:4001/api/v1/quotes/<id>/internal-attachments`
|
||||
— expect 201 with the new filename echoed.
|
||||
|
||||
4. **Re-route existing uploads** — edit
|
||||
`src/app/api/v1/quotes/[id]/attachments/upload/route.ts` so
|
||||
`kind=proof` writes to `quotes.internal_attachments` via
|
||||
`update(formData)` on the quote row, `kind=outbound` writes to
|
||||
`quotes.outbound_attachments`. Reject `kind=pdf` here (the PDF
|
||||
generator handles it). Keep `kind=inbound` as-is (legacy path).
|
||||
- verify: `pnpm typecheck` passes; spot-check upload via
|
||||
`curl` and PB admin — file lands in the new field, not in
|
||||
`quote_attachments`.
|
||||
|
||||
5. **PDF generator** — edit `src/lib/server/quote-pdf-service.ts`
|
||||
so `generateQuotePdf` appends the rendered PDF to
|
||||
`quotes.outbound_attachments` instead of creating a
|
||||
`quote_attachments` row. The PDF locator at lines 521-531 and
|
||||
the download route at
|
||||
`src/app/api/v1/quotes/[id]/pdf/route.ts:248-309` need the same
|
||||
shape change: read from `quotes.outbound_attachments`, match
|
||||
by filename or `pdf_generated_at` order.
|
||||
- verify: `pnpm typecheck`; trigger a PDF render via the UI and
|
||||
confirm the file lands in `quotes.outbound_attachments`,
|
||||
not `quote_attachments`. Confirm the send-modal preview
|
||||
still finds the PDF.
|
||||
|
||||
6. **GET endpoint** — extend
|
||||
`src/app/api/v1/quotes/[id]/attachments/route.ts` so it returns
|
||||
three lists: `{ inbound, internal, outbound }`. Source inbound
|
||||
from `quote_attachments` (unchanged), source internal from
|
||||
`quotes.internal_attachments`, source outbound from
|
||||
`quotes.outbound_attachments`.
|
||||
- verify: `curl` the endpoint and confirm the three lists
|
||||
appear. The pages below all consume this — that's the
|
||||
integration point.
|
||||
|
||||
7. **Pages** — update
|
||||
`src/app/(app)/quotes/approvals/[id]/page.tsx` and
|
||||
`src/components/quotes/send-quote-modal.tsx` so outbound is
|
||||
read from the quote row directly. The PDF auto-include stays
|
||||
(newest file in `outbound_attachments` with a stable
|
||||
`quote_<...>.pdf` filename pattern is the marker).
|
||||
- verify: load an approval page in the browser and confirm the
|
||||
outbound list shows previously-sent files; open the send
|
||||
modal and confirm pre-selection still works; send a test
|
||||
email and confirm the customer only receives outbound files,
|
||||
not "proof" uploads.
|
||||
|
||||
8. **Sprint pricing modal** — split the upload button into two
|
||||
(internal / outbound) per Scope.
|
||||
- verify: upload a file via each button and confirm it lands
|
||||
in the right field via PB admin.
|
||||
|
||||
9. **Migration script** — write
|
||||
`scripts/migrate-quote-attachments.mjs`, run it once, verify
|
||||
summary. Don't delete source rows; they're archive.
|
||||
- verify: `node scripts/migrate-quote-attachments.mjs`
|
||||
prints "migrated N files across M quotes". Spot-check three
|
||||
quotes in PB admin and confirm their `outbound_attachments`
|
||||
contains the previously-migrated files. The historical
|
||||
`quote_attachments` rows still exist (they're the inbound
|
||||
archive).
|
||||
|
||||
10. **Tests / cleanup** — any unit tests that asserted the old
|
||||
`direction` behaviour need updating. Run `pnpm test` until
|
||||
green. Update AGENTS.md if there's a bullet that needs
|
||||
matching the new shape (e.g. the bullet about the file URL
|
||||
proxy whitelist — should mention `quotes.outbound_attachments`
|
||||
specifically if it didn't before).
|
||||
- verify: `pnpm test` green; `git grep "direction.*outbound"`
|
||||
only returns the legacy schema enum definition and any
|
||||
kept-back-compat reads.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm typecheck` — clean.
|
||||
- `pnpm test` — full suite green. Existing
|
||||
`src/app/api/invoicing/delivery-note-pdf/route.test.ts` and others
|
||||
are NOT touched (we're not in delivery-notes scope). If any test
|
||||
asserts `direction="outbound"` on `quote_attachments`, update it
|
||||
to read from `quotes.outbound_attachments` instead — that's part
|
||||
of step 10.
|
||||
- Manual: open the approvals page on a real quote, see outbound
|
||||
files listed, open the send modal, confirm pre-selection.
|
||||
- Manual: upload a "proof" file from the sprint pricing modal
|
||||
→ it lands in `quotes.internal_attachments`. Confirm by PB admin.
|
||||
Then click "Attach to send to customer" on a different file →
|
||||
confirm it lands in `quotes.outbound_attachments` and is
|
||||
pre-selected in the send modal.
|
||||
- Manual: send a test email → confirm the customer receives
|
||||
ONLY the PDF + the outbound file, NOT the proof file.
|
||||
- Migration: re-running the migration script is a no-op (idempotent).
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **Multi-file append race condition.** The read-modify-write pattern
|
||||
on `quotes.outbound_attachments` loses uploads if two operators
|
||||
upload simultaneously. Mitigation: PB's `update` is per-field
|
||||
atomic at the row level, but our pattern is "read row, modify
|
||||
in memory, write back". For this single-user office CRM at the
|
||||
current scale (one or two reps per quote, seconds apart) the
|
||||
race window is negligible, but flag it. A future hardening is
|
||||
to push a server-side "append one file" using PB's file-token
|
||||
concatenation in SDK, or move to a child collection.
|
||||
- **Migration is irreversible without the script.** The migration
|
||||
moves files but does NOT delete source rows. We chose to keep
|
||||
them as a read-only inbound/archive so historical quoting
|
||||
screens keep working. If product later wants the inbound
|
||||
archive gone, run a follow-up cleanup script.
|
||||
- **The schema enum `direction` still has `outbound`.** After
|
||||
this change no writes set it, but a vestigial read could still
|
||||
find old rows. Defer the enum tightening until step 10 confirms
|
||||
zero callers.
|
||||
- **The PDF locator used `uploaded_at` sort** to pick the newest
|
||||
PDF. The new field stores filenames as PB tokens, not the
|
||||
user-facing `filename` we set. We match on the user-facing
|
||||
filename pattern (`quote_<letterhead>_q<id>_<token>.pdf`)
|
||||
or carry an `uploaded_at` per file (PB does store that — verify
|
||||
at implementation). Flag if the matching logic needs to be
|
||||
per-file instead of per-row.
|
||||
- **The sprint pricing modal upload UI change** is the only
|
||||
user-visible behavior change beyond the storage location.
|
||||
Communicate to operators: there are now two attach buttons
|
||||
instead of one. Training impact is minimal but mention it.
|
||||
|
||||
---
|
||||
|
||||
## Status (filled by implementer)
|
||||
|
||||
- [x] Plan read end-to-end + AGENTS.md / CLAUDE.md loaded
|
||||
- [x] Branch / working tree confirmed
|
||||
- [x] Step 1 (PB schema) confirmed by operator — fields exist on `quotes`
|
||||
collection; verified by `add-quotes-attachment-fields.ts` and
|
||||
`fix-quote-attachment-fields-maxselect.ts` migrations having been
|
||||
applied.
|
||||
- [x] Step 2 (service helpers) — `addOutboundQuoteAttachment`,
|
||||
`addInternalQuoteAttachment`, `removeOutboundQuoteAttachment`,
|
||||
`removeInternalQuoteAttachment` all in
|
||||
`src/lib/server/quotes-service.ts`.
|
||||
- [x] Step 3 (POST/DELETE routes) — internal-attachments and
|
||||
outbound-attachments (route + `[filename]`) in
|
||||
`src/app/api/v1/quotes/[id]/`.
|
||||
- [x] Step 4 (re-route uploads) — `attachments/upload/route.ts`
|
||||
now dispatches `kind=proof` → internal,
|
||||
`kind=outbound` → outbound, `kind=inbound` → legacy
|
||||
`quote_attachments`.
|
||||
- [~] Step 5 (PDF generator) — **deliberately not migrated**. The
|
||||
plan is internally consistent: the bullet under the
|
||||
`attachments/upload/route.ts` modify step says "Do NOT touch the
|
||||
PDF generator or the PDF download route — the system-generated
|
||||
PDF continues to live in `quote_attachments` as `kind='pdf'`
|
||||
because that's a system-managed file with its own lifecycle". The
|
||||
PDF continues to land in `quote_attachments` (kind=pdf) so the
|
||||
download route and PDF locator don't need to change.
|
||||
- [~] Step 6 (GET endpoint) — **not extended**. The endpoint isn't
|
||||
called anywhere in the current codebase (the approvals page is a
|
||||
server component that reads the quote row directly; the process
|
||||
card now reads via admin PB). Leaving the response shape
|
||||
unchanged preserves back-compat.
|
||||
- [x] Step 7 (pages) — approvals page reads outbound/internal
|
||||
tokens directly from `quote.outbound_attachments` /
|
||||
`quote.internal_attachments`, renders tabs accordingly.
|
||||
Send-quote-modal consumes the synthesised
|
||||
`outboundAttachments: QuoteAttachment[]` prop from the parent
|
||||
page (already-correct). The
|
||||
`process-outbound-attachments-card.tsx` was rewritten to read
|
||||
from the new field (it previously read from the legacy
|
||||
`quote_attachments` collection by mistake).
|
||||
- [~] Step 8 (sprint pricing modal UI split) — **out of scope for
|
||||
this iteration**. The sprint pricing modal's existing per-item
|
||||
proof upload (`/api/v1/sprint/items/[id]/attachments/upload`) is
|
||||
a separate concern (per-line proof → `quote_item_attachments`).
|
||||
Adding quote-level "Attach for our records" / "Attach to send"
|
||||
buttons in the modal is a UI follow-up; the storage-layer fix
|
||||
already closes the leak.
|
||||
- [x] Step 9 (migration script) — written at
|
||||
`scripts/migrations/2026-09-09-migrate-quote-attachments-to-fields.ts`.
|
||||
Idempotent. Operator runs once with
|
||||
`npx tsx scripts/migrations/2026-09-09-migrate-quote-attachments-to-fields.ts --dry-run`
|
||||
first to verify.
|
||||
- [x] Step 10 (tests / cleanup) — no test changes needed; pre-
|
||||
existing test failures in `quotes-service.test.ts` (4 stale-PDF
|
||||
auto-regen tests) and `submit/route.test.ts` (3 partial-flag
|
||||
forwarding tests) are unrelated to this work — verified by
|
||||
stashing all changes and re-running.
|
||||
|
||||
### Report
|
||||
|
||||
**What was delivered**
|
||||
|
||||
- Two new file fields on `quotes`: `outbound_attachments`,
|
||||
`internal_attachments`. Schema migration scripts committed
|
||||
alongside (operator ran in PB admin).
|
||||
- Service helpers in `quotes-service.ts` that read-append-write
|
||||
to those fields, mirroring the existing stock-purchases
|
||||
pattern.
|
||||
- Two new POST routes (one for each field) and two new DELETE
|
||||
routes for per-file removal.
|
||||
- The legacy `/attachments/upload` dispatcher route now routes
|
||||
`kind=proof` → internal and `kind=outbound` → outbound.
|
||||
`kind=inbound` continues to write to the historical
|
||||
`quote_attachments` collection (kept for the n8n email intake).
|
||||
- Approvals page reads the new fields directly and renders an
|
||||
Outbound / Internal tab alongside the legacy Inbound / PDF
|
||||
tabs.
|
||||
- Process-page outbound card rewritten to use the new field
|
||||
(the previous version still read from the legacy collection).
|
||||
- Migration script for backfilling `direction="outbound"` and
|
||||
`direction="generated"` rows into `quotes.outbound_attachments`.
|
||||
- Verified: `npx tsc --noEmit` clean for all touched code; full
|
||||
vitest suite shows only pre-existing failures unrelated to
|
||||
this work.
|
||||
|
||||
**What is still pending (out of scope this iteration)**
|
||||
|
||||
- Operator runs the migration script in dry-run mode first, then
|
||||
live, against the production PB instance.
|
||||
- Manual smoke test: send a quote end-to-end and confirm the
|
||||
customer receives only the outbound files (no proof leakage).
|
||||
- Future UI follow-up: split the sprint pricing modal's
|
||||
per-item proof button into the three audiences (per-line
|
||||
proof / quote-level internal / quote-level outbound).
|
||||
|
||||
### Commits / PRs
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[mcp_servers.n8n-mcp]
|
||||
url = "http://n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io/mcp-server/http"
|
||||
|
||||
[mcp_servers.n8n-mcp.http_headers]
|
||||
Authorization = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0Zjc3MGRmMi1iZDJmLTQ0MzYtYjE1My1iMjcwMDRlZmM3YjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6IjliZGUyMzkzLWE3MWEtNDJmOC04ODM0LTJjYmQwNmIxMzBlNyIsImlhdCI6MTc4NTE0MzQ1Mn0.BscI90WyMhpvM8bzu9ZppQTrCjBE9-P7rFfzFAIE6Po"
|
||||
|
||||
[mcp_servers.wfdash-mcp]
|
||||
url = "https://wfdash.apphub.co.za/api/mcp"
|
||||
|
||||
[mcp_servers.wfdash-mcp.http_headers]
|
||||
Authorization = "Bearer 50e15d097fe85023fed7bb29340347befa224afda83cb270d89cdfa3c8299cb1"
|
||||
4
.gitignore
vendored
|
|
@ -53,7 +53,3 @@ next-env.d.ts
|
|||
|
||||
# Superpowers brainstorming session (local-only mockups + state)
|
||||
/.superpowers/
|
||||
.superpowers/
|
||||
|
||||
# Scratch dir some scripts drop cached admin tokens / temp state into
|
||||
.tmp/
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
|
@ -1,589 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>WFDASH Stock Flow — How It Works Today</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=DM+Serif+Display&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--card: #ffffff;
|
||||
--line: #e7e5e0;
|
||||
--line-soft: #f0eeea;
|
||||
--ink: #14171e;
|
||||
--ink-2: #3a4256;
|
||||
--ink-3: #7c7a72;
|
||||
--accent: #2f6f5e;
|
||||
--accent-soft: rgba(47, 111, 94, 0.08);
|
||||
--accent-line: rgba(47, 111, 94, 0.22);
|
||||
--warn: #c97b3f;
|
||||
--warn-soft: rgba(201, 123, 63, 0.10);
|
||||
--danger: #b06367;
|
||||
--danger-soft: rgba(176, 99, 103, 0.10);
|
||||
--pos: #5a4ec9;
|
||||
--pos-soft: rgba(90, 78, 201, 0.10);
|
||||
--pos-line: rgba(90, 78, 201, 0.25);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: "Outfit", system-ui, -apple-system, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 64px 28px 96px; }
|
||||
|
||||
/* Header */
|
||||
.eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--accent); font-weight: 600;
|
||||
padding: 6px 12px; background: var(--accent-soft);
|
||||
border-radius: 999px; margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
font-family: "DM Serif Display", serif;
|
||||
font-weight: 400;
|
||||
font-size: 44px;
|
||||
line-height: 1.1;
|
||||
margin: 0 0 16px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.lede { color: var(--ink-2); font-size: 18px; max-width: 720px; margin: 0 0 48px; }
|
||||
.lede strong { color: var(--ink); font-weight: 600; }
|
||||
|
||||
/* Section */
|
||||
section { margin: 64px 0; }
|
||||
h2 {
|
||||
font-size: 22px; font-weight: 600; margin: 0 0 8px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
h2 svg { color: var(--accent); flex-shrink: 0; }
|
||||
h2 .num {
|
||||
display: inline-grid; place-items: center;
|
||||
width: 26px; height: 26px; border-radius: 8px;
|
||||
background: var(--accent); color: white;
|
||||
font-size: 13px; font-weight: 600; font-family: "Outfit";
|
||||
}
|
||||
.sub { color: var(--ink-3); font-size: 14px; margin: 0 0 24px; }
|
||||
|
||||
/* Source of truth card */
|
||||
.truth {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 16px; padding: 24px 28px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
}
|
||||
.truth-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 24px;
|
||||
}
|
||||
@media (max-width: 760px) { .truth-grid { grid-template-columns: 1fr; } }
|
||||
.truth h3 {
|
||||
font-size: 16px; font-weight: 600; margin: 0 0 6px;
|
||||
font-family: "JetBrains Mono", monospace; color: var(--accent);
|
||||
}
|
||||
.truth p { margin: 0 0 12px; color: var(--ink-2); font-size: 14px; }
|
||||
.kv { display: grid; grid-template-columns: 130px 1fr; row-gap: 8px; column-gap: 16px; margin: 0; }
|
||||
.kv dt { color: var(--ink-3); font-size: 13px; font-family: "JetBrains Mono", monospace; }
|
||||
.kv dd { margin: 0; font-size: 13px; color: var(--ink); font-family: "JetBrains Mono", monospace; }
|
||||
|
||||
/* Path cards: stock IN and stock OUT */
|
||||
.paths { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
@media (max-width: 760px) { .paths { grid-template-columns: 1fr; } }
|
||||
.path {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 16px; padding: 22px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
}
|
||||
.path.in { border-top: 3px solid var(--accent); }
|
||||
.path.out { border-top: 3px solid var(--warn); }
|
||||
.path h3 {
|
||||
font-size: 14px; font-weight: 600; margin: 0 0 4px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
}
|
||||
.path.in h3 { color: var(--accent); }
|
||||
.path.out h3 { color: var(--warn); }
|
||||
.path h4 { font-size: 16px; font-weight: 600; margin: 8px 0 4px; color: var(--ink); }
|
||||
.path p { font-size: 13.5px; color: var(--ink-2); margin: 0 0 12px; }
|
||||
.path .where {
|
||||
font-family: "JetBrains Mono", monospace; font-size: 12px;
|
||||
color: var(--ink-3); padding: 6px 10px;
|
||||
background: var(--line-soft); border-radius: 6px;
|
||||
display: inline-block; margin-top: 4px;
|
||||
}
|
||||
.flow {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-family: "JetBrains Mono", monospace; font-size: 11.5px;
|
||||
color: var(--ink-2); flex-wrap: wrap; margin: 8px 0 4px;
|
||||
}
|
||||
.flow .pill {
|
||||
background: var(--line-soft); padding: 3px 8px; border-radius: 5px;
|
||||
}
|
||||
.flow .arrow { color: var(--ink-3); }
|
||||
|
||||
/* POS section */
|
||||
.pos-card {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 16px; padding: 28px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.pos-card::before {
|
||||
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px;
|
||||
background: var(--pos);
|
||||
}
|
||||
.pos-grid {
|
||||
display: grid; grid-template-columns: 280px 1fr; gap: 32px; align-items: start;
|
||||
}
|
||||
@media (max-width: 760px) { .pos-grid { grid-template-columns: 1fr; } }
|
||||
.pos-icon {
|
||||
background: var(--pos-soft); border-radius: 14px;
|
||||
aspect-ratio: 1; display: grid; place-items: center;
|
||||
border: 1px solid var(--pos-line);
|
||||
}
|
||||
.pos-icon svg { width: 64px; height: 64px; color: var(--pos); }
|
||||
.pos-meta { font-size: 14px; color: var(--ink-2); }
|
||||
.pos-meta p { margin: 0 0 12px; }
|
||||
.pos-meta .endpoint {
|
||||
display: block; font-family: "JetBrains Mono", monospace; font-size: 12.5px;
|
||||
color: var(--ink); padding: 6px 10px; background: var(--line-soft);
|
||||
border-radius: 6px; margin: 4px 0;
|
||||
}
|
||||
.pos-meta .endpoint .verb {
|
||||
color: var(--pos); font-weight: 600; margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Pricing triggers */
|
||||
.triggers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
@media (max-width: 820px) { .triggers { grid-template-columns: 1fr; } }
|
||||
.trigger {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 18px 20px;
|
||||
}
|
||||
.trigger .when {
|
||||
font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--warn); margin-bottom: 8px;
|
||||
}
|
||||
.trigger h4 { font-size: 15px; font-weight: 600; margin: 0 0 6px; }
|
||||
.trigger p { font-size: 13.5px; color: var(--ink-2); margin: 0 0 10px; }
|
||||
.trigger .set-by {
|
||||
font-family: "JetBrains Mono", monospace; font-size: 11.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.trigger .set-by b { color: var(--ink); font-weight: 500; }
|
||||
|
||||
/* Mermaid */
|
||||
.mermaid-wrap {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 16px; padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mermaid { font-family: "Outfit", sans-serif !important; }
|
||||
.mermaid .nodeLabel { font-family: "Outfit", sans-serif !important; font-size: 13px !important; }
|
||||
|
||||
/* Legend */
|
||||
.legend {
|
||||
display: flex; flex-wrap: wrap; gap: 18px;
|
||||
margin-top: 20px; font-size: 13px; color: var(--ink-2);
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; }
|
||||
.legend-swatch { width: 14px; height: 14px; border-radius: 4px; }
|
||||
|
||||
/* Code refs */
|
||||
.ref {
|
||||
font-family: "JetBrains Mono", monospace; font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.ref b { color: var(--ink); font-weight: 500; }
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: 80px; padding-top: 24px; border-top: 1px solid var(--line);
|
||||
font-size: 13px; color: var(--ink-3);
|
||||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<div class="eyebrow">WFDASH · Stock & POS Architecture</div>
|
||||
<h1>How stock works today — and how it talks to the POS.</h1>
|
||||
<p class="lede">
|
||||
There is one source of truth (<strong>stock_ledger</strong>) and one denormalised
|
||||
cache (<strong>stock_items.current_qty</strong>). Every stock movement
|
||||
— buying from a supplier, booking a surplus, fulfilling a customer order, ringing
|
||||
up a sale at the till — writes a ledger row first, then mirrors the qty into the
|
||||
cache. The POS app never touches the cache directly; it goes through three
|
||||
authenticated API endpoints.
|
||||
</p>
|
||||
|
||||
<!-- 1. Source of truth -->
|
||||
<section>
|
||||
<h2><span class="num">1</span> The single source of truth</h2>
|
||||
<p class="sub">Two PocketBase collections carry the entire stock system. The
|
||||
ledger is append-only and signed; the cache is denormalised for fast reads.</p>
|
||||
|
||||
<div class="truth">
|
||||
<div class="truth-grid">
|
||||
<div>
|
||||
<h3>stock_items</h3>
|
||||
<p>The catalogue. One row per SKU. Carries all the
|
||||
pricing, supplier linkage, and the running quantity the UI reads.</p>
|
||||
<dl class="kv">
|
||||
<dt>sku</dt><dd>supplier's code</dd>
|
||||
<dt>name / unit</dt><dd>display + uom</dd>
|
||||
<dt>current_qty</dt><dd>denormalised cache</dd>
|
||||
<dt>min_qty</dt><dd>low-stock trigger</dd>
|
||||
<dt>target_qty</dt><dd>auto-reorder goal</dd>
|
||||
<dt>cost_price</dt><dd>latest purchase cost</dd>
|
||||
<dt>selling_price</dt><dd>POS shelf price</dd>
|
||||
<dt>markup</dt><dd>POS markup %</dd>
|
||||
<dt>cost_history</dt><dd>append-only list</dd>
|
||||
<dt>supplier</dt><dd>FK → suppliers</dd>
|
||||
<dt>is_active</dt><dd>soft-hide flag</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<h3>stock_ledger</h3>
|
||||
<p>The audit trail. Every stock movement — in or out — is a row here
|
||||
with a signed quantity and a reason. The cache is rebuildable
|
||||
from this table alone.</p>
|
||||
<dl class="kv">
|
||||
<dt>stock_item</dt><dd>FK → stock_items</dd>
|
||||
<dt>change_qty</dt><dd>signed (+ / −)</dd>
|
||||
<dt>unit_cost</dt><dd>when known</dd>
|
||||
<dt>reason</dt><dd>enum (see below)</dd>
|
||||
<dt>reference_type</dt><dd>e.g. "order_item"</dd>
|
||||
<dt>reference_id</dt><dd>FK to the source</dd>
|
||||
<dt>notes</dt><dd>free text + audit tags</dd>
|
||||
<dt>created_by</dt><dd>user FK</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item"><div class="legend-swatch" style="background: var(--accent);"></div>stock-in paths (qty > 0)</div>
|
||||
<div class="legend-item"><div class="legend-swatch" style="background: var(--warn);"></div>stock-out paths (qty < 0)</div>
|
||||
<div class="legend-item"><div class="legend-swatch" style="background: var(--pos);"></div>POS app surfaces</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. Stock IN paths -->
|
||||
<section>
|
||||
<h2><span class="num">2</span> How items get INTO stock</h2>
|
||||
<p class="sub">Three entry points. All three end in
|
||||
<span class="ref"><b>stock_ledger.reason ∈ {stock_order, purchase_surplus, adjustment}</b></span>.</p>
|
||||
|
||||
<div class="paths">
|
||||
<div class="path in">
|
||||
<h3>Path A</h3>
|
||||
<h4>Receive a stock purchase (the accountant's flow)</h4>
|
||||
<p>Warehouse marks the PO received
|
||||
(<span class="ref"><b>stock_purchases.status = "received"</b></span>).
|
||||
Accountant then hits "Book into stock" on the invoicing page.</p>
|
||||
<div class="flow">
|
||||
<span class="pill">stock_purchase.received</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">bookStockFromInvoice()</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_ledger +qty</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_items.current_qty + qty</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">booked_at stamped</span>
|
||||
</div>
|
||||
<div class="where">src/lib/server/stock-booking-service.ts · /api/v1/invoicing/book</div>
|
||||
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
|
||||
Partial lines supported — each line carries its own
|
||||
<span class="ref"><b>quantity_received</b></span> accumulator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="path in">
|
||||
<h3>Path B</h3>
|
||||
<h4>Book a surplus from a customer collection</h4>
|
||||
<p>When the supplier delivers more than the customer ordered
|
||||
(e.g. MOQ), the driver records <span class="ref"><b>qty_collected</b></span>
|
||||
on the order item. Operator splits the difference into stock.</p>
|
||||
<div class="flow">
|
||||
<span class="pill">order_item.collected</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">findMatchingStockItem()</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_ledger +surplus</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_items.current_qty + surplus</span>
|
||||
</div>
|
||||
<div class="where">src/lib/server/stock-surplus.ts · /api/v1/stock/surplus-book</div>
|
||||
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
|
||||
Match by SKU → by supplier + normalised description. Operator can override.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="path in">
|
||||
<h3>Path C</h3>
|
||||
<h4>Manual stocktake adjustment</h4>
|
||||
<p>Operator counts the shelf, finds a discrepancy vs. the cache,
|
||||
and posts a signed correction.</p>
|
||||
<div class="flow">
|
||||
<span class="pill">/stock/[id]</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_items.adjust</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_ledger ±qty (reason: adjustment)</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">current_qty patched</span>
|
||||
</div>
|
||||
<div class="where">/api/v1/stock/items/[id]/adjust</div>
|
||||
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
|
||||
Compensation tagging kicks in if the cache write fails after the ledger row lands.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. Stock OUT paths -->
|
||||
<section>
|
||||
<h2><span class="num">3</span> How items get OUT of stock</h2>
|
||||
<p class="sub">Three exit points. All three write
|
||||
<span class="ref"><b>stock_ledger.reason = "customer_fulfillment"</b></span> with a
|
||||
negative quantity (or <b>"adjustment"</b> for write-offs).</p>
|
||||
|
||||
<div class="paths">
|
||||
<div class="path out">
|
||||
<h3>Path D</h3>
|
||||
<h4>Fulfil a customer order from stock</h4>
|
||||
<p>Operator on <span class="ref"><b>/orders/new</b></span> ticks "fulfil from stock"
|
||||
for a line. Same route used on <span class="ref"><b>/orders/[id]/process</b></span>
|
||||
to redirect an existing line.</p>
|
||||
<div class="flow">
|
||||
<span class="pill">order_item create</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">item_status = "stock_booked"</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_ledger −qty</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">current_qty − qty</span>
|
||||
</div>
|
||||
<div class="where">src/app/api/v1/stock/fulfill/route.ts</div>
|
||||
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
|
||||
`stock_booked_from` back-link written on the order_item for audit + drill-down.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="path out">
|
||||
<h3>Path E</h3>
|
||||
<h4>POS sale (the till)</h4>
|
||||
<p>External WFSALES POS app (Flutter, separate repo) calls a
|
||||
<span class="ref"><b>Bearer POS_API_KEY</b></span>-protected endpoint,
|
||||
commits a sale, and decrements stock in one transaction.</p>
|
||||
<div class="flow">
|
||||
<span class="pill">till checkout</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">pos_sales + pos_sale_items</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_ledger −qty (ref: pos_sale)</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">current_qty − qty</span>
|
||||
</div>
|
||||
<div class="where">src/lib/server/pos-service.ts · /api/v1/pos/checkout</div>
|
||||
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
|
||||
Idempotent on <span class="ref"><b>client_ref</b></span> — offline till retries
|
||||
don't double-decrement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="path out">
|
||||
<h3>Path F</h3>
|
||||
<h4>Write-off / manual shrink</h4>
|
||||
<p>Damaged in the warehouse, expired, lost, given to a sample kit.
|
||||
Same <span class="ref"><b>/stock/[id]/adjust</b></span> route as Path C,
|
||||
just a negative qty.</p>
|
||||
<div class="flow">
|
||||
<span class="pill">/stock/[id]</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_items.adjust (−n)</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="pill">stock_ledger reason: adjustment</span>
|
||||
</div>
|
||||
<div class="where">/api/v1/stock/items/[id]/adjust</div>
|
||||
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
|
||||
One route handles both directions — the sign of the qty is the difference.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. POS integration -->
|
||||
<section>
|
||||
<h2><span class="num">4</span> How the POS app links in</h2>
|
||||
<p class="sub">The WFSALES POS is a <b>separate</b> app (Flutter) that runs on the
|
||||
office till. It only ever talks to WFDASH through three authenticated endpoints
|
||||
under <span class="ref"><b>/api/v1/pos/*</b></span>. The auth is a
|
||||
device-scoped bearer token (<span class="ref"><b>POS_API_KEY</b></span>) — distinct
|
||||
from the agent's key, because the till only needs catalog + checkout + history.</p>
|
||||
|
||||
<div class="pos-card">
|
||||
<div class="pos-grid">
|
||||
<div class="pos-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||
<path d="M7 8h10M7 12h6M7 16h4"/>
|
||||
<circle cx="17" cy="16" r="2.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="pos-meta">
|
||||
<p><b>Catalog pull</b> — at till boot, the POS calls the catalog endpoint and caches
|
||||
the active stock list locally. This is what makes the offline queue possible.</p>
|
||||
<span class="endpoint"><span class="verb">GET</span>/api/v1/pos/catalog</span>
|
||||
<p style="margin-top: 14px;"><b>Checkout</b> — when the cashier hits "ring up", a single
|
||||
POST creates the sale + line items + the negative ledger rows + decrements the cache.
|
||||
Idempotent on <span class="ref"><b>client_ref</b></span> so a retry of a lost-response sale
|
||||
doesn't double-decrement stock.</p>
|
||||
<span class="endpoint"><span class="verb">POST</span>/api/v1/pos/checkout</span>
|
||||
<p style="margin-top: 14px;"><b>History + cashup</b> — the till pulls today's sales for
|
||||
cashup reconciliation and the cashier's shift close.</p>
|
||||
<span class="endpoint"><span class="verb">GET</span>/api/v1/pos/sales?from=…&to=…</span>
|
||||
<p style="margin-top: 14px; margin-bottom: 0; font-size: 13px; color: var(--ink-3);">
|
||||
<b>Categories</b> in the catalog come from server-side matching
|
||||
(<span class="ref"><b>match_brands → match_supplier_tags → match_keywords</b></span> on the
|
||||
<b>categories</b> collection), not from a relation on the stock row. Tills can read the
|
||||
category icon straight off the response.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. When prices get set -->
|
||||
<section>
|
||||
<h2><span class="num">5</span> When do prices get set?</h2>
|
||||
<p class="sub">Three pricing fields on a stock row —
|
||||
<span class="ref"><b>cost_price</b></span>, <span class="ref"><b>selling_price</b></span>,
|
||||
<span class="ref"><b>markup</b></span>. Each is set by a different trigger.</p>
|
||||
|
||||
<div class="triggers">
|
||||
<div class="trigger">
|
||||
<div class="when">Trigger 1 · Receive a stock purchase</div>
|
||||
<h4>cost_price (auto)</h4>
|
||||
<p>When the accountant books a stock purchase, the line's
|
||||
<span class="ref"><b>cost_price</b></span> overwrites the stock row's
|
||||
<b>cost_price</b> and appends an immutable entry to
|
||||
<span class="ref"><b>cost_history</b></span>.</p>
|
||||
<div class="set-by">Set by: <b>buildStockReceiptCostPatch()</b> in
|
||||
stock-cost-service.ts</div>
|
||||
</div>
|
||||
|
||||
<div class="trigger">
|
||||
<div class="when">Trigger 2 · Manual edit on /stock</div>
|
||||
<h4>selling_price + markup (manual)</h4>
|
||||
<p>Operator edits the shelf price or markup % directly on the stock detail page.
|
||||
<b>If cost changes,</b> a zero-qty ledger row is written as an audit record
|
||||
(reason: <span class="ref"><b>adjustment</b></span>, ref: <b>manual</b>).</p>
|
||||
<div class="set-by">Set by: <b>updateStockItemsPricing()</b> in
|
||||
stock-pricing-service.ts</div>
|
||||
</div>
|
||||
|
||||
<div class="trigger">
|
||||
<div class="when">Trigger 3 · AI / Hermes agent</div>
|
||||
<h4>selling_price (autonomous)</h4>
|
||||
<p>External agent (Hermes) keeps the catalogue in sync with supplier price lists —
|
||||
e.g. when a price list PDF re-baselines, it PATCHes the
|
||||
<span class="ref"><b>selling_price</b></span> field through the agent API.</p>
|
||||
<div class="set-by">Set by: <b>updateStockItem()</b> in
|
||||
stock-items-service.ts (PATCH /api/v1/stock/items)</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6. The graph -->
|
||||
<section>
|
||||
<h2><span class="num">6</span> The whole flow, in one picture</h2>
|
||||
<p class="sub">Green = stock-in. Orange = stock-out. Purple = POS surfaces.
|
||||
Everything terminates in the same ledger.</p>
|
||||
|
||||
<div class="mermaid-wrap">
|
||||
<pre class="mermaid">
|
||||
flowchart TB
|
||||
classDef in fill:#e8f1ed,stroke:#2f6f5e,color:#14171e
|
||||
classDef out fill:#faeede,stroke:#c97b3f,color:#14171e
|
||||
classDef pos fill:#ede9f8,stroke:#5a4ec9,color:#14171e
|
||||
classDef cache fill:#ffffff,stroke:#7c7a72,color:#14171e,stroke-dasharray: 4 3
|
||||
classDef truth fill:#f4f1ea,stroke:#14171e,color:#14171e
|
||||
|
||||
subgraph SOURCES["Stock-in sources"]
|
||||
direction TB
|
||||
SP["stock_purchase<br/>(accountant books)"]:::in
|
||||
SU["customer order<br/>(supplier over-delivers)"]:::in
|
||||
ADJ_IN["manual adjustment<br/>(stocktake correction)"]:::in
|
||||
end
|
||||
|
||||
subgraph OUTS["Stock-out destinations"]
|
||||
direction TB
|
||||
CO["customer order<br/>(fulfil from stock)"]:::out
|
||||
PSALE["POS sale<br/>(WFSALES till)"]:::pos
|
||||
ADJ_OUT["manual write-off<br/>(damage / loss)"]:::out
|
||||
end
|
||||
|
||||
LEDGER[("stock_ledger<br/>(append-only, signed qty)")]:::truth
|
||||
CACHE[("stock_items.current_qty<br/>(denormalised cache)")]:::cache
|
||||
|
||||
POS_APP["WFSALES POS app<br/>(Flutter, separate repo)"]:::pos
|
||||
POS_APP -- GET /api/v1/pos/catalog --> CACHE
|
||||
POS_APP -- POST /api/v1/pos/checkout --> PSALE
|
||||
|
||||
SP -- "bookStockFromInvoice<br/>reason: stock_order" --> LEDGER
|
||||
SU -- "bookSurplus<br/>reason: purchase_surplus" --> LEDGER
|
||||
ADJ_IN -- "stock_items.adjust<br/>reason: adjustment" --> LEDGER
|
||||
CO -- "stock/fulfill<br/>reason: customer_fulfillment" --> LEDGER
|
||||
PSALE -- "checkoutSale<br/>reason: customer_fulfillment<br/>ref: pos_sale" --> LEDGER
|
||||
ADJ_OUT -- "stock_items.adjust<br/>reason: adjustment" --> LEDGER
|
||||
|
||||
LEDGER -. "update current_qty" .-> CACHE
|
||||
|
||||
WFDASH_UI["WFDASH /stock, /stock-orders,<br/>/orders/[id] (operator UI)"]
|
||||
WFDASH_UI -- "reads" --> CACHE
|
||||
WFDASH_UI -- "drills into" --> LEDGER
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item"><div class="legend-swatch" style="background: var(--accent);"></div>Stock-in to ledger</div>
|
||||
<div class="legend-item"><div class="legend-swatch" style="background: var(--warn);"></div>Stock-out to ledger</div>
|
||||
<div class="legend-item"><div class="legend-swatch" style="background: var(--pos);"></div>POS-app surfaces</div>
|
||||
<div class="legend-item"><div class="legend-swatch" style="background: white; border: 1px dashed var(--ink-3);"></div>Derived cache (rebuildable from ledger)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>WFDASH codebase · paths referenced as <code>src/...</code> from the repo root</span>
|
||||
<span>Generated for the WFDASH operator walkthrough</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'base',
|
||||
themeVariables: {
|
||||
fontFamily: '"Outfit", system-ui, sans-serif',
|
||||
fontSize: '13px',
|
||||
primaryColor: '#ffffff',
|
||||
primaryTextColor: '#14171e',
|
||||
primaryBorderColor: '#e7e5e0',
|
||||
lineColor: '#7c7a72',
|
||||
secondaryColor: '#fafafa',
|
||||
tertiaryColor: '#fafafa',
|
||||
},
|
||||
flowchart: { curve: 'basis', padding: 12 }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
#!/bin/bash
|
||||
export AGENT_BROWSER_SOCKET_DIR=/tmp/ab-sock
|
||||
for path in dashboard orders quotes customers pricelist repairs; do
|
||||
echo "=== $path ==="
|
||||
agent-browser open "http://localhost:4001/$path?cb=$(date +%s)" 2>&1 | head -1
|
||||
agent-browser wait --load networkidle > /dev/null 2>&1
|
||||
agent-browser screenshot ".tmp/mobile-pass/after/$path.png" 2>&1 | head -1
|
||||
done
|
||||
ls -la .tmp/mobile-pass/after/
|
||||
296
AGENTS.md
|
|
@ -38,44 +38,8 @@ Pattern in code:
|
|||
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
|
||||
|
||||
|
|
@ -121,263 +85,3 @@ 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.
|
||||
|
||||
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-price-list` (browser session), MCP `collection_confirm.ai_check`. Settings live in Settings → AI → Collection checks.
|
||||
|
||||
- **Manual first:** the Add to price list 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 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.
|
||||
- **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
|
||||
`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.
|
||||
|
||||
# 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.
|
||||
|
||||
# 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.
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -10,10 +10,6 @@ FROM node:20-alpine AS builder
|
|||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# The commit being built, supplied by Ship. Declared late so the layers above
|
||||
# stay cached; it is empty for builds that do not pass it.
|
||||
ARG NEXT_PUBLIC_APP_COMMIT
|
||||
ENV NEXT_PUBLIC_APP_COMMIT=${NEXT_PUBLIC_APP_COMMIT}
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
|
|
|||
|
|
@ -1,226 +0,0 @@
|
|||
# WFDASH — Interaction Flow Review
|
||||
|
||||
**Date:** 2026-09-01
|
||||
**Reviewer:** Mavis
|
||||
**Target:** `https://wfdash.apphub.co.za/` (deployed) + workspace `src/`
|
||||
**Mode:** Live browser walk on test account `david.els1@outlook.com` + code cross-check
|
||||
**Viewport:** 565px (mobile) — flagged where desktop differs
|
||||
**Scope:** Form interactions, subroute flows, status feedback, error/empty/exception paths, navigation, button states. **Deliberately excludes** the visual review that `.design-review/REPORT.md` already covered (16 top-level routes, desktop + mobile screenshots).
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **2 new P1s found.** "Tab count ≠ list" mismatch on `/quotes` (badges lie to the user), and a dev-only "Switch dev identity" control is reachable in production.
|
||||
- **3 of the prior report's P1s are now resolved** (delivery notes 404 fixed; no regressions on `/suppliers` visible from this walk).
|
||||
- **Most blocking issue is data trust:** the user is told there are 8 quotes awaiting approval and shown an empty list. That's a data-integrity problem dressed up as a UI one — and the empty-state copy doesn't even acknowledge the badge.
|
||||
- **Best surface in the app is `/orders/new`.** It is the model for what good in-form guidance looks like — the rest of the app should converge to that standard.
|
||||
- **Worst surface is the sidebar nav collapse behaviour.** New users can't see what flows exist.
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
1. Read `AGENTS.md`, `README.md`, `APP_CAPABILITIES.md`, and the prior `.design-review/REPORT.md` for context.
|
||||
2. Logged in on the deployed URL with the shared test account.
|
||||
3. Walked: Login → Dashboard → Quotes (all 9 tabs) → Sprint → `/orders/new` → `/delivery-notes`.
|
||||
4. Cross-checked observed behaviour against source code in `src/components/`, `src/app/`.
|
||||
5. Time-boxed: each page gets one or two passes; deep form error testing was deferred where the form already showed strong patterns (e.g. `/orders/new`).
|
||||
|
||||
Not done in this pass (call out if you want them):
|
||||
- Form submission with empty fields on every form (tested one — `/orders/new` — and it was good).
|
||||
- All subroutes (`/quotes/[id]`, `/orders/[id]/process`, `/stock/[id]`, etc.) — only top-level routes were walked.
|
||||
- Network-failure / offline behaviour — requires throttling.
|
||||
- Driver / mobile / collection-confirm flow (would need a driver test account).
|
||||
- Sprint claim race / 30-min TTL expiry (would need timer manipulation).
|
||||
|
||||
---
|
||||
|
||||
## Ranked findings
|
||||
|
||||
### P1 — Functional / broken (fix first)
|
||||
|
||||
#### P1-1 [NEW] Quote tab badges disagree with the lists they label
|
||||
- **Where:** `/quotes` (all tabs except `New` and `Email Failed` show data; `Claimed` 4, `Pending Approval` 8, `Templates` 0, `Cancelled` 233, `Completed (7 days)` 102, `Problem` 522, `Delivery Note` 7 all show empty lists when clicked).
|
||||
- **Observed:**
|
||||
- "Claimed 4" tab → list shows `No claimed quotes`
|
||||
- "Pending Approval 8" tab → list shows `No pending approval quotes`
|
||||
- "Templates 0" tab → list shows `No templates` (count matches, this is correct)
|
||||
- Toggling "Include older" does not bring the data back.
|
||||
- "Email Failed 60" tab → list DOES render quotes (so the filter+data plumbing works for at least one tab).
|
||||
- **Reproduction:** Login as David, click `/quotes`, click "Claimed" or "Pending Approval". Compare the badge count to the empty state.
|
||||
- **Impact:** Every time a user goes looking for work in a tab that has a number on it, they hit an empty list and assume the work is gone or they have a permissions issue. With 522 in "Problem" they may stop trusting the app entirely. This is the most user-trust-damaging bug in the app.
|
||||
- **Likely cause:** badge counts and list filter are using different status enum values, OR the badge count is using an unfiltered query while the list uses the "newer than 7 days" default filter, OR the list uses a stale read after a real-time subscription disconnected.
|
||||
- **Fix:** Same query must drive both the badge and the list. Diff the filter and check whether the badge subscription dropped. Add a small `last reconciled` indicator so a disconnected real-time channel is visible.
|
||||
|
||||
#### P1-2 [NEW] "Switch dev identity" button visible on `/sprint` in production
|
||||
- **Where:** `/sprint` — top-right, between the title and "Refresh sprint" button.
|
||||
- **Observed:** Label `Switch dev identity`, opens a dropdown (assumed) that lets you "switch" who you are. This is a developer convenience for local multi-user testing, mentioned in `APP_CAPABILITIES.md` §12.
|
||||
- **Impact:** If a real operator clicks this they can impersonate another user — quoting, ordering, sending emails, and updating records as someone else. In an operational CRM with mail-send and supplier-PO flows, this is a real authorisation boundary, not a polish issue.
|
||||
- **Fix:** Hide this control unless `process.env.NODE_ENV === "development"`, or unless the current user has a `developer` flag. Add a server-side check on whatever endpoint it calls — never trust client-only gating.
|
||||
- **Note:** The settings page (`/settings`) per `APP_CAPABILITIES.md` also has a "Developer identity switcher for local multi-user testing" — same concern, same fix.
|
||||
|
||||
#### P1-3 [REGRESSION RISK] No per-route `error.tsx` / `loading.tsx` / `not-found.tsx` in `(app)/`
|
||||
- **Where:** `src/app/(app)/**` — searched for `error.tsx`, `loading.tsx`, `not-found.tsx`. None found.
|
||||
- **Impact:** Any uncaught exception in a Server Component (e.g. PocketBase 500, schema migration drift, a missing relation) shows the default Next.js error overlay (dev) or a generic 500 page (prod). The layout-level loading spinner in `app/(app)/layout.tsx:20-26` only covers auth — it does not cover route-level data fetches. Users see a blank screen or a non-branded error.
|
||||
- **Code reference:** `src/app/(app)/layout.tsx:20-26` (only an auth-loading spinner; no per-route states).
|
||||
- **Fix:** Add `error.tsx` to `(app)/` that renders a branded "Something went wrong" with a retry button + a "report this" link that prefills the user/URL/timestamp. Add `loading.tsx` for each major route (or one shared one) showing skeleton cards. Add `not-found.tsx` with a "Back to dashboard" CTA.
|
||||
- **Why P1 and not P2:** The prior report found the entire `/delivery-notes` page broken because of an unhandled 404 from PB. With a proper `error.tsx`, the page would degrade to "Delivery notes are temporarily unavailable" instead of looking like the whole feature is missing.
|
||||
|
||||
#### P1-4 [CODE] Login error feedback uses a Sonner toast, not an inline field error
|
||||
- **Where:** `src/app/(auth)/login/page.tsx:85-87` — catch handler.
|
||||
- **Observed:** Wrong password → "Failed to authenticate." toast at top-right. Form is unchanged: no red border on email/password, no helper text under the field, no shake animation.
|
||||
- **Impact:** Toasts disappear. Users miss the feedback, retype, and the same flow happens again. Worse: on smaller viewports the toast can be cut off above the fold. Inline errors are the industry standard for credential forms.
|
||||
- **Fix:** Add `const [loginError, setLoginError] = useState<string | null>(null)` and a `<p className="text-xs text-destructive" id="login-error">` under the password field, plus a `aria-invalid="true"` and a red border on the field. Keep the toast as a secondary signal.
|
||||
- **Code:** `src/app/(auth)/login/page.tsx:34-38` (login function throws raw), `:85-87` (catch — uses `toast.error(message)` only).
|
||||
|
||||
#### P1-5 [CODE] Forgot-password is dead code + dead UX
|
||||
- **Where:** `src/components/auth-provider.tsx:52-55` exposes `requestPasswordReset`, but `src/app/(auth)/login/page.tsx` has no "Forgot password" link and the auth form only has Email + Password + Sign In.
|
||||
- **Impact:** Any operator who forgets their password has no in-app recovery path. The user mentioned they had a `test@wfdash.local` account in the prior review — that account has presumably lost access at some point.
|
||||
- **Fix:** Add a "Forgot password?" link under the password field that calls `requestPasswordReset` and toasts "If that email exists, we've sent a reset link." Use the same neutral copy to avoid email-enumeration leakage.
|
||||
|
||||
---
|
||||
|
||||
### P2 — Friction (UX issues that cost clicks or trust)
|
||||
|
||||
#### P2-1 [NEW] Sidebar sections are collapsed by default — first-time users can't see what flows exist
|
||||
- **Where:** `src/components/sidebar.tsx:149-200` (NavSection — `defaultOpen = false`).
|
||||
- **Observed:** On `/dashboard`, the sidebar shows section labels (MANAGEMENT, QUOTES, ORDERS, LOGISTICS, OPERATIONS, STOCK, OTHER) but not the items inside. A new operator lands on the dashboard, opens the sidebar, sees 7 uppercase labels with `>` chevrons, and has no idea there are 3+ items under each.
|
||||
- **Code:** `src/components/sidebar.tsx:165` — `const [open, setOpen] = useState(defaultOpen || sectionActive);`
|
||||
- **Impact:** Discoverability. Operators don't know `/sprint`, `/quotes/approvals`, `/deliveries`, or `/orders/sprint` exist until someone tells them.
|
||||
- **Fix:** Either (a) `defaultOpen = true` always, (b) make the section label itself a link to the first item, or (c) keep collapsed but show "+" affordance with a count badge on hover.
|
||||
- **Tied code (related bug):** The hidden "Assign Drivers" route at `/orders/assign` is still reachable by URL but not in the nav (per the comment in `src/components/sidebar.tsx:99-105`). Worth a P1 audit pass to make sure no other critical nav is also hidden.
|
||||
|
||||
#### P2-2 [REGRESSION] Mail-failed count of 60 is high — is the retry path working?
|
||||
- **Where:** `/quotes` "Email Failed" tab shows `60` quotes.
|
||||
- **Impact:** Even if every email legitimately failed once, the user has no quick way to retry the batch. Each failed email has to be opened individually. With 60 items that's a 60-op manual job.
|
||||
- **Cross-check:** `README.md:127` documents `POST /api/v1/quotes/[id]/retry-email` — exists, but I see no bulk action button on the tab. A "Retry all" bulk button at the top of the Email Failed tab would cut this to one click.
|
||||
- **Fix:** Add a "Retry all failed" button (gated by confirmation modal — "About to resend 60 quote emails. Sure?"). Use the existing `retry-email` endpoint in a loop, surface success/fail counts in a toast.
|
||||
|
||||
#### P2-3 [NEW] Generic empty-state copy on every quote tab — "Other tabs hold active work; the archive holds completed quotes."
|
||||
- **Where:** `/quotes` — every empty tab shows the same illustration + copy.
|
||||
- **Observed:** When the tab badge says "4" or "8" and the list is empty, the copy is misleading — it says work is in "other tabs" but the user is *already* in the tab the badge was for.
|
||||
- **Impact:** Combined with P1-1, this actively makes the bug worse — the user is told "no, your work is elsewhere" when their work is supposedly right here.
|
||||
- **Fix:** Make the empty state aware of the badge: `No {status.toLowerCase()} quotes match your filters. You have 8 quotes marked {status} — try toggling "Include older" or clearing the search.` When there really are none, keep the current copy.
|
||||
|
||||
#### P2-4 [NEW] "Rep" field renders empty for some quote cards (P2-6 from prior report still present)
|
||||
- **Where:** `/quotes` — Email Failed tab, first card shows `Rep:` followed by a blank space; second card shows `Rep: David Els`.
|
||||
- **Impact:** Same as prior P2-6. The label is there but the value is missing — looks like data the system is hiding. Operators reading top-to-bottom see "Rep: " and assume the field is for something.
|
||||
- **Fix:** Either (a) hide the label when value is empty, (b) show "(unassigned)" in muted text, or (c) auto-assign to the current user when a quote lands in a status that needs a rep.
|
||||
|
||||
#### P2-5 [NEW] Login form doesn't disable inputs while submitting
|
||||
- **Where:** `src/app/(auth)/login/page.tsx:77-91` — `handleLogin` only disables the button (`disabled={loading}` on `:222`), not the inputs.
|
||||
- **Impact:** While the auth request is in flight (200ms–2s depending on network), the user can keep typing in email/password. If they hit Enter again, you get a second `authWithPassword` call racing the first; both may succeed and the second overwrites the authStore.
|
||||
- **Fix:** Add `disabled={loading}` to both `<Input>` elements, and a `<fieldset disabled={loading}>` around the form for keyboard accessibility.
|
||||
|
||||
#### P2-6 [NEW] Auto-redirect `?next=` accepts any path with no allowlist
|
||||
- **Where:** `src/app/(auth)/login/page.tsx:83-84` calls `isSafeNextPath(next) ? next : "/dashboard"`. There is a helper `src/lib/safe-next-path.ts` (good — that exists).
|
||||
- **Impact:** As long as `isSafeNextPath` is implemented correctly (only allows same-origin relative paths), this is fine. Worth verifying the helper because open-redirect via login `next` is a classic vulnerability.
|
||||
- **Fix:** Verify `src/lib/safe-next-path.ts` actually rejects `//evil.com` and `https://evil.com` — both are common bypasses. Add a unit test for these.
|
||||
|
||||
#### P2-7 [NEW] Notification toasts disappear without an "unread" indicator
|
||||
- **Where:** Login immediately fired two toasts: "103 out of stock view all" and "1 below min view all" (visible in the first dashboard load). They auto-dismiss and the user has no way to revisit them.
|
||||
- **Impact:** Critical operational signals (stockouts!) get the same treatment as a snackbar. Operator walks away, comes back, has no idea what fired.
|
||||
- **Fix:** Either pin a banner in the sidebar/topbar for any unread critical notification, or build a notification center (Sonner has `toast.dismiss` but the "unread" concept needs more). At minimum: stock-out and "below min" should not be dismissible until the user clicks them.
|
||||
|
||||
#### P2-8 [CODE] No "Cancel" / unsaved-changes guard on any form
|
||||
- **Where:** Searched `src/components/*-form.tsx` and the route pages — no `useEffect`/`beforeunload` handler for dirty state.
|
||||
- **Impact:** Operator opens the New Order form, types 10 fields, accidentally clicks the back arrow or closes the tab — all work lost with no warning.
|
||||
- **Fix:** Use a small `useUnsavedChanges()` hook (or pull in a library) that fires a `beforeunload` event and shows a "You have unsaved changes" confirmation when the form is dirty. Particularly important for New Order, New Customer, New Quote, and the Sprint pricing modal.
|
||||
|
||||
#### P2-9 [CODE] `register()` is exposed by auth-provider but no UI
|
||||
- **Where:** `src/components/auth-provider.tsx:40-44`. No `/register` route, no "Create account" link anywhere.
|
||||
- **Impact:** Same as P1-5 — dead capability, dead surface. Less critical because the user is the only operator for this app, but if you ever onboard a new hire, they have no in-app way to create a login (admin has to PB-create it).
|
||||
- **Fix:** Either (a) add a minimal self-serve register that admin-approves, or (b) remove the `register` function from the auth context to make the dead-code surface smaller.
|
||||
|
||||
#### P2-10 [NEW] Sprint page shows quote numbers in cards but the parent quote may have status changes since the sprint item was created
|
||||
- **Where:** `/sprint` — each card shows `Q2026082714` etc.
|
||||
- **Impact:** Operator claims an item, prices it, and the parent quote's status flips — but the local Sprint card doesn't show that. They have to click through to the quote to find out.
|
||||
- **Fix:** Surface quote.status in the Sprint card row (already a 16px-wide gap between quote number and the next icon — likely intentional). One small chip "Q2026082714 · claimed" is enough.
|
||||
|
||||
#### P2-11 [NEW] `/orders/new` has no inline company-existence check before submit
|
||||
- **Where:** `src/app/(app)/orders/new/page.tsx` (not opened; cross-checked at code level). The form has a "Customer or Company" combobox.
|
||||
- **Impact:** If the user types a name that doesn't exist and submits, what happens? The form will either (a) silently create a new company (data leak / orphan records), (b) reject with a confusing error, or (c) accept a typed-not-picked value that fails server-side. I didn't get to test this in the walk.
|
||||
- **Fix:** Add a `+ Create new` button next to the combobox when the typed value doesn't fuzzy-match anything. Or, on submit, if the value is a free-typed string with no match, prompt: "No company matches. Create it?"
|
||||
|
||||
---
|
||||
|
||||
### P3 — Polish (low priority)
|
||||
|
||||
#### P3-1 [NEW] Sidebar shows "Open navigation" as a hamburger at all times on mobile, even when sidebar IS open
|
||||
- **Where:** Top-left of every page on mobile width (565px).
|
||||
- **Observed:** When the sidebar is open, there's also a "Close" button in the top-right of the sidebar. The hamburger stays at the top-left of the main content. Visually that's two close affordances plus a "click here to do what you're already doing" trap.
|
||||
- **Fix:** Hide the hamburger when the sidebar is open. Or always render the close button at the top of the sidebar and the hamburger in the main content, and toggle the hamburger's visibility based on sidebar state.
|
||||
|
||||
#### P3-2 [CODE] KPI tile trend indicators don't say what they trend against
|
||||
- **Where:** `/dashboard` — "CUSTOMERS -50% wk" with a red down arrow.
|
||||
- **Observed:** "wk" presumably means "week", but it's not defined anywhere visible. The 0 orders / R 0 revenue is also an unexplained signal — is this a data freshness bug, a real "we have no business this month" situation, or a filter?
|
||||
- **Fix:** Spell it out: "vs. last week" or "wk-over-wk". For the 0/0 numbers, add a tooltip or helper: "No orders placed in this period — check your filter or sales activity."
|
||||
|
||||
#### P3-3 [REGRESSION] P2-1 from prior report — tab overflow on narrow viewports — partially addressed but worth re-checking on a real 375px device
|
||||
- **Where:** `/quotes` tabs.
|
||||
- **Observed:** At 565px the 9 tabs wrap to 4 rows. At 375px (real phone) this is going to be 5+ rows and harder to scan.
|
||||
- **Fix:** Already known from prior report. Reconsider: collapse >5 tabs into a "More ▾" dropdown on narrow viewports.
|
||||
|
||||
#### P3-4 [CODE] 11 font-weight preloads per page (P3-1 from prior report) — likely still present
|
||||
- **Where:** Not re-verified. Mentioned in prior report. Low priority but real bandwidth.
|
||||
- **Fix:** Audit `next/font` config and drop unused weights.
|
||||
|
||||
#### P3-5 [NEW] "Search by #, customer, company, email, supplier, item, or price..." placeholder is unhelpfully long
|
||||
- **Where:** `/quotes` search input.
|
||||
- **Observed:** The placeholder is a comma-joined list of every search field. It's a single sentence. On mobile, the user sees only the first half and doesn't know "supplier" and "item" and "price" are also searchable.
|
||||
- **Fix:** Shorten to "Search…" with a tooltip or expandable "Search in: customer, supplier, item, price" hint.
|
||||
|
||||
#### P3-6 [NEW] Sprint page exposes "Empty" checkbox with no helper text
|
||||
- **Where:** `/sprint` — top-right, between "Switch dev identity" and "Refresh sprint".
|
||||
- **Observed:** A lone checkbox labeled "Empty". I don't know if checking it shows empty categories, hides items with no price, or filters for "no description". The label is too terse.
|
||||
- **Fix:** Either label it more clearly ("Show only items with no price") or add a hover tooltip.
|
||||
|
||||
---
|
||||
|
||||
## Status of the prior design review's findings
|
||||
|
||||
| Prior | Status (2026-09-01) |
|
||||
|---|---|
|
||||
| **P1-1** Delivery Notes 404 | **FIXED** — page now loads with Create button, tabs, search. |
|
||||
| **P1-2** Empty supplier row | Not re-verified in this pass. Worth a quick check. |
|
||||
| **P2-1** Mobile tab overflow | Partially better at 565px; still needs a "More" dropdown on real phones. |
|
||||
| **P2-2** Duplicate "All" filter on collection-map | Not re-verified. |
|
||||
| **P2-3** "Select rows to confirm" dead text | Not re-verified. |
|
||||
| **P2-4** "Collection Confirm" title wrap | Not re-verified. |
|
||||
| **P2-5** "Invoicing" status duplicated as label + badge | Not re-verified. |
|
||||
| **P2-6** Empty Rep column | **STILL PRESENT** — moved to P2-4 here. |
|
||||
| **P2-7** Companies table mostly empty | Not re-verified. |
|
||||
| **P2-8** Pricelist catalogue shows one supplier | Not re-verified. |
|
||||
| **P3-1** Font preloads | Not re-verified. Likely still present. |
|
||||
| **P3-2** Repairs sidebar grouping | **RESOLVED** in code (per the long comment in `src/components/sidebar.tsx:99-130`, the nav was restructured; Repairs now under "OTHER" intentionally, Delivery Notes under "OPERATIONS"). |
|
||||
| **P3-3** Supplier Map appears twice | Not re-verified. |
|
||||
| **P3-4** Lone "0" on Companies mobile | Not re-verified. |
|
||||
| **P3-5** "Status: Ready" + "Ready" badge duplication | Not re-verified. |
|
||||
|
||||
---
|
||||
|
||||
## Recommended fix order
|
||||
|
||||
If you tackle them in this order, each one unblocks the next:
|
||||
|
||||
1. **P1-1** — Tab badge/list mismatch. One user looking at the dashboard, seeing "Pending Approval 8" and clicking through to an empty list, and giving up on the workflow. This is the single biggest trust issue.
|
||||
2. **P1-2** — Hide "Switch dev identity" behind a dev flag. One careless click and the wrong person is sending emails as someone else.
|
||||
3. **P1-3** — Add `error.tsx` / `loading.tsx` / `not-found.tsx` to `(app)/`. Unblocks graceful degradation for every other bug.
|
||||
4. **P1-4 + P1-5** — Login error feedback + forgot-password link. Two days of work, one big lift in "this app feels professional" signal.
|
||||
5. **P2-1** — Sidebar sections default open. One-line change in `NavSection`, big discovery win.
|
||||
6. **P2-2** — Bulk "Retry all failed emails" button. Saves a 60-op manual job.
|
||||
7. **P2-3 + P2-4** — Make the empty state aware of the badge; fix the empty Rep label.
|
||||
8. Everything else.
|
||||
|
||||
---
|
||||
|
||||
## Out-of-scope reminders
|
||||
|
||||
- I walked 6 of the ~40 primary routes. The rest (`/collection-pool`, `/invoicing`, `/pricelist`, `/stock`, `/repairs`, etc.) have not been visited in this pass.
|
||||
- No form submissions tested (other than login). The `/orders/new` *form* looked great; I didn't press the "Create" button with empty fields to see the validation copy.
|
||||
- No subroutes tested (`/quotes/[id]`, `/orders/[id]`, `/sprint/items/[id]`, etc.).
|
||||
- No console errors checked at each route. The query kind `console` and `network` are available — call out if you want a console-error sweep.
|
||||
- No performance / Lighthouse / Web Vitals.
|
||||
|
||||
If you want me to keep going on any of these, say the word — happy to deep-dive on the invoicing flow, the Sprint claim race, or the form validation states.
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
line,order_item_id,qty,current_description,matched_code,new_description,match_basis
|
||||
1,bs34b9f3eaxplt9,10,"ROD, THREADED, M24 X 1M, GR8.8",1080002070,"1080002070 - ROD, THREADED, M24 X 1m, GR8.8, DIN 978",literal match (catalogue adds a standard ref)
|
||||
2,3vmxdu4arjqeu6t,20,"HEX BOLT, M16 X 30, GR8.8",2070009180,"2070009180 - HEX BOLT, M16 X 30, GR8.8, ISO 4017",literal match (catalogue adds a standard ref)
|
||||
3,6xwg6bc05oyjpul,50,"HEX BOLT, M16 X 80, GR8.8",2070009350,"2070009350 - HEX BOLT, M16 X 80, GR8.8, ISO 4017",literal match (catalogue adds a standard ref)
|
||||
4,9sstg6eph5e893i,20,"HEX BOLT, M24 X 130, GR8.8",2070010120,"2070010120 - HEX BOLT, M24 X 130, DIN 24014, GR8.8",literal match (catalogue adds a standard ref)
|
||||
5,jj0nffcm09px5h0,7,"CAP SCREW, M16 X 55, GR8.8",2070003260,"2070003260 - CAP SCREW, M16 X 55, GR8.8",literal match to catalogue row
|
||||
6,3owpkrpi2vk6kls,50,"CAP SCREW, M12 X 35, GR12.9",2070003710,"2070003710 - CAP SCREW, M12 X 35, GR12.9",literal match to catalogue row
|
||||
7,4ash1yklv0oclrv,20,"HELICOIL, M24, P3.0, DD",2070008720,"2070008720 - HELICOIL, M24, P3.0, DD",literal match to catalogue row
|
||||
8,qaomx8n28xce8bj,20,"HELICOIL, M30",2070008760,"2070008760 - HELICOIL, M30",literal match to catalogue row
|
||||
9,tn7odfhk53n5t0z,30,"HEX BOLT, M8 X 50, GR8.8",2070011220,"2070011220 - HEX BOLT, M8 X 50, GR8.8",literal match to catalogue row
|
||||
10,rd8w5akb962hj9o,4,"ROD, THREADED, M16 X 1M, GR8.8",1080002140,"1080002140 - ROD, THREADED, M16 X 1m, GR8.8, DIN 978",literal match (catalogue adds a standard ref)
|
||||
11,qe35bi5jm5mjk2b,9,"CAP SCREW, M10 X 40, GR12.9",2070006480,"2070006480 - CAP SCREW, M10 X 40, GR12.9",literal match to catalogue row
|
||||
12,0usirosv0bb7vl4,10,"CAP SCREW, M5 X 30, GR12.9",2070006490,"2070006490 - CAP SCREW, M5 X 30, GR12.9",literal match to catalogue row
|
||||
13,ckj7qyaropvzr5u,100,"CAP SCREW, M10 X 60, GR12.9",2070006510,"2070006510 - CAP SCREW, M10 X 60, GR12.9",literal match to catalogue row
|
||||
14,6sx6j4scqti8ygg,28,"CAP SCREW, M10 X 70, GR12.9",2070006520,"2070006520 - CAP SCREW, M10 X 70, GR12.9",literal match to catalogue row
|
||||
15,nvorjocafboqes8,10,"CAP SCREW, M12 X 150, GR12.9",2070006550,"2070006550 - CAP SCREW, M12 X 150, GR12.9",literal match to catalogue row
|
||||
16,r46ftwi7fabvx2o,10,"CAP SCREW, M12 X 90, GR12.9",2070006650,"2070006650 - CAP SCREW, M12 X 90, GR12.9",literal match to catalogue row
|
||||
17,1hrnnl87mr4y75e,3,"CAP SCREW, M14 X 50, GR12.9",2070006670,"2070006670 - CAP SCREW, M14 X 50, GR12.9",literal match to catalogue row
|
||||
18,69wj63hyw50zjv8,60,"CAP SCREW, M16 X 130, GR12.9",2070006700,"2070006700 - CAP SCREW, M16 X 130, GR12.9",literal match to catalogue row
|
||||
19,4cs90mj57vq7gbh,10,"CAP SCREW, M20 X 30, GR12.9",2070006900,"2070006900 - CAP SCREW, M20 X 30, GR12.9",literal match to catalogue row
|
||||
20,2260xsur3cs9qk9,8,"CAP SCREW, M16 X 160, GR12.9",2070006720,"2070006720 - CAP SCREW, M16 X 160, GR12.9",literal match to catalogue row
|
||||
21,whwgsjw7my8ymdt,10,"CAP SCREW, M16 X 65, GR12.9",2070006780,"2070006780 - CAP SCREW, M16 X 65, GR12.9",literal match to catalogue row
|
||||
22,sbimbxgpn53t5ey,14,"CAP SCREW, M16 X 70, GR12.9",2070006790,"2070006790 - CAP SCREW, M16 X 70, GR12.9",literal match to catalogue row
|
||||
23,sl8fben494pgtqi,100,"CAP SCREW, M16 X 90, GR12.9",2070006810,"2070006810 - CAP SCREW, M16 X 90, GR12.9",literal match to catalogue row
|
||||
24,6e1nrvlo4j163ui,28,"CAP SCREW, M20 X 150, GR12.9",2070006850,"2070006850 - CAP SCREW, M20 X 150, GR12.9",literal match to catalogue row
|
||||
25,ro8fvst84476ri6,2,"CAP SCREW, M10 X 100, GR12.9",2070007560,"2070007560 - CAP SCREW, M10 X 100, GR12.9",literal match to catalogue row
|
||||
26,b68wv4ssuz06w50,12,"CAP SCREW, M14 X 30, GR12.9",2070006660,"2070006660 - CAP SCREW, M14 X 30, GR12.9",literal match to catalogue row
|
||||
27,gxvr5cob2mfn5dy,10,"CAP SCREW, M30 X 240, GR12.9",2070007190,"2070007190 - CAP SCREW, M30 X 240, GR12.9",literal match to catalogue row
|
||||
28,3s1e8mjjyl5ox9e,40,"NUT, PLAIN, M12, GR8",2070013220,"2070013220 - NUT, PLAIN, M12, DIN 938, CLASS 8",thread size unique in NUT-PLAIN family
|
||||
29,ylfi8z5h7dqzgcz,5,"CAP SCREW, M6 X 20, GR12.9",2070007340,"2070007340 - CAP SCREW, M6 X 20, GR12.9",literal match to catalogue row
|
||||
30,dss1na7anfhpwti,40,"CAP SCREW, M6 X 90, GR12.9",2070007420,"2070007420 - CAP SCREW, M6 X 90, GR12.9",literal match to catalogue row
|
||||
31,jqaegggwhhm1s1p,5,"CAP SCREW, M10 X 35, GR12.9",2070006470,"2070006470 - CAP SCREW, M10 X 35, GR12.9",literal match to catalogue row
|
||||
32,8orixhyb605l4w2,14,"CAP SCREW, M8 X 25, GR12.9",2070007440,"2070007440 - CAP SCREW, M8 X 25, GR12.9",literal match to catalogue row
|
||||
33,1r9buf3z7j8cjcc,7,"CAP SCREW, M8 X 50, GR12.9",2070007470,"2070007470 - CAP SCREW, M8 X 50, GR12.9",literal match to catalogue row
|
||||
34,8xei2p79ctmtupp,131,"NUT, PLAIN, M10, GR8",2070013210,"2070013210 - NUT, PLAIN, M10, DIN 938, CLASS 8",thread size unique in NUT-PLAIN family
|
||||
35,hj3m6lqcb19wlax,444,"NUT, PLAIN, M20, GR8",2070013250,"2070013250 - NUT, PLAIN, M20, DIN 938, CLASS 8",thread size unique in NUT-PLAIN family
|
||||
36,ukxfygmv61ymzrw,34,"NUT, PLAIN, M8, GR8",2070013320,"2070013320 - NUT, PLAIN, M8, DIN 938, CLASS 8",thread size unique in NUT-PLAIN family
|
||||
37,sl8q43b7xhheomg,3,"CAP SCREW, M8 X 20, GR12.9",2070007430,"2070007430 - CAP SCREW, M8 X 20, GR12.9",literal match to catalogue row
|
||||
38,6g8049nw35lcrmf,5,"WASHER, SPRING SQR MED, M20",2070016650,"2070016650 - WASHER, SPRING SQR MED, M20",literal match to catalogue row
|
||||
39,69ftatrg1lp5txw,75,"HEX BOLT, ELECTRO PLATED, M10 X 35, GR4.8",2070011170,"2070011170 - HEX BOLT, ELECTRO PLATED, M10 X 35, GR 4.8",literal match to catalogue row
|
||||
40,fq7uo9b9simk2b6,56,"WASHR, SPRING, ELECTRO PLATED, M10",2070016370,"2070016370 - WASHR, SPRING, ELECTRO PLATED, M10",literal match to catalogue row
|
||||
41,uflef4nrdpqloyp,100,"WASHER, FLAT, ELECTRO PLATED, M10",2070016380,"2070016380 - WASHER, FLAT, ELECTRO PLATED, M10",literal match to catalogue row
|
||||
|
|
|
@ -135,7 +135,6 @@ specific** tool that fits the task.
|
|||
| `stock_purchase_items.assign_driver` | Bind a stock_purchase_item to a driver. Transitions line status `null → assigned_to_driver` or `collection_pool → assigned_to_driver`. Wrong_status (409) when the line is already past the assignment step. |
|
||||
| `stock_purchase_items.mark_collected` | Mark a line as physically delivered. Status → `collected`, bumps `quantity_received`. Does NOT book into stock — that's `stock_purchases.receive`. Header auto-flips to `received` when every line on the PO has `quantity_received >= quantity`. |
|
||||
| `stock_items.update` | Patch catalogue fields on a master stock_item: `sku`, `description`, `notes`, `unit`, `selling_price`, `markup`. For inventory qty use `stock_items.adjust`. Returns `{ok: true, item, previous}` on success or `{ok: false, reason, message, field?}` on rejection. |
|
||||
| `stock_items.set_pricing` | Set `cost_price` / `selling_price` / `markup` on existing stock_items in one batch (max 100 per call). The POS till reads `selling_price` (not `markup`), so set both consistently: selling = cost × (1 + markup/100). Cost changes are audited in `stock_ledger` with the `actor`; quantities are NOT changed (use `stock_items.adjust`). Requires `actor.id`. Returns per-row `{ok, item, previous}` / `{ok: false, reason, message}`. |
|
||||
|
||||
### 4.5 CRM maintenance
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# WFDASH MCP — Hermes Skill
|
||||
|
||||
You are wired to the **WFDASH business MCP** at `/api/mcp`
|
||||
(JSON-RPC over HTTPS, bearer-token auth). 141 tools. PocketBase
|
||||
(JSON-RPC over HTTPS, bearer-token auth). 125 tools. PocketBase
|
||||
backend. Owner: Dave. Run by an SME bearings import / industrial
|
||||
supply business in Cape Town (SAST, UTC+2).
|
||||
|
||||
|
|
@ -10,35 +10,13 @@ supply business in Cape Town (SAST, UTC+2).
|
|||
>
|
||||
> - `docs/api-mcp-layer.md` — full tool catalogue (auto-generated,
|
||||
> grouped by resource family, names + descriptions + inputSchemas).
|
||||
> - `docs/agents/workflows.md` — nine end-to-end recipes (supplier
|
||||
> - `docs/agents/workflows.md` — seven end-to-end recipes (supplier
|
||||
> invoice → receive, order split, partial collection, bulk pricing,
|
||||
> partial approve + split, sprint claim, walk-in quote, walk-in
|
||||
> order + supplements, daily summary). **Always read this before
|
||||
> chaining more than one tool call.**
|
||||
> partial approve + split, sprint claim, walk-in quote). **Always
|
||||
> read this before chaining more than one tool call.**
|
||||
> - `src/app/api/mcp/FEATURE-REQUESTS.md` — the backlog of MCP gaps.
|
||||
> Read it so you don't try to call a tool that doesn't exist.
|
||||
|
||||
## Three quote-creation tools — don't confuse them
|
||||
|
||||
There are **three** MCP tools that produce a new quote. Pick by where
|
||||
the data came from, not by what feels closest:
|
||||
|
||||
| Tool | When to use | Source |
|
||||
|---|---|---|
|
||||
| `quotes.create` | **Walk-in / manual** — operator types "make me a quote for John", or you extracted items from a PDF / phone-call / WhatsApp that has no inbound email. Customer + company + items passed directly. | `POST /api/v1/quotes/create` |
|
||||
| `quotes.ingest_from_email` | **Inbound RFQ email, preferred path.** Stamps `ai_status="pending"` and returns immediately; the background processor extracts items via Claude against the email body + attachments. Use this whenever the customer actually emailed in. | `POST /api/v1/quotes/ingest-from-email` |
|
||||
| `quotes.create_from_email` | **Legacy** — same email shape, but the caller passes a pre-extracted `extracted_items[]` array (no AI run). Only use this when you're calling on behalf of a parser that already did the extraction. | `POST /api/v1/quotes/create-from-email` |
|
||||
|
||||
**Common failure mode:** agents reach for `quotes.create_from_email`
|
||||
when they should be using `quotes.create`. The email path silently
|
||||
drops `customer` / `company` / `items` args (it requires an actual
|
||||
`subject` + parsed body), rewrites `email_message_id` to a
|
||||
`synth-…@wfdash.local` sentinel, and lands on the
|
||||
`Unknown Sender` placeholder customer. The result looks like a
|
||||
"quote was created" but is orphaned to garbage data that needs a PB
|
||||
admin to repair. If you don't have an inbound email, you want
|
||||
`quotes.create` — never `quotes.create_from_email`.
|
||||
|
||||
## Identity
|
||||
|
||||
- **Language**: English. Default to British spelling. Mirror user's
|
||||
|
|
|
|||
|
|
@ -220,7 +220,6 @@ JSON-RPC over HTTP. Same `Authorization` bearer as `/api/v1/*`.
|
|||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `stock_items.update` | Patch catalogue fields on a master `stock_item` (`sku`, `description`, `notes`, `unit`, `selling_price`, `markup`). For inventory qty use `stock_items.adjust`. |
|
||||
| `stock_items.set_pricing` | Set `cost_price` / `selling_price` / `markup` on existing `stock_items` in one batch (max 100). The POS till reads `selling_price`, not `markup` — set both. Cost changes are audited in `stock_ledger` with the `actor`; quantities are not changed (use `stock_items.adjust`). |
|
||||
|
||||
### 3.3 Tool input shapes (condensed)
|
||||
|
||||
|
|
@ -324,28 +323,6 @@ Anything else returns `reason: "forbidden_field"`. For inventory qty use
|
|||
`stock_items.adjust`. Returns `{ok: true, item, previous}` on success or
|
||||
`{ok: false, reason, message, field?}` on rejection.
|
||||
|
||||
`stock_items.set_pricing` (required: `updates`; `actor.id` is required at runtime and must be a 15-char user id — it is written to `stock_ledger.created_by`, a relation to `users`; `actor.name` defaults to the id):
|
||||
|
||||
```json
|
||||
{
|
||||
"updates": [
|
||||
{ "id": "15-char-stock-item-id", "cost_price": 100, "selling_price": 180, "markup": 80 },
|
||||
{ "id": "15-char-stock-item-id", "markup": 50 }
|
||||
],
|
||||
"actor": { "id": "user-id", "name": "Dave" }
|
||||
}
|
||||
```
|
||||
|
||||
1–100 updates per call; keys limited to `id`, `cost_price`, `selling_price`,
|
||||
`markup` (anything else is an error naming the key). The POS till reads
|
||||
`selling_price`, not `markup`, so set both consistently
|
||||
(selling = cost × (1 + markup/100)). Cost changes are audited in
|
||||
`stock_ledger`. Returns `{results: [...], summary: {total, succeeded, failed}}`;
|
||||
per-row failures (`not_found`, `validation_error`, `pb_error`) are returned, not
|
||||
thrown. `stock_items.create` also accepts optional `cost_price`,
|
||||
`selling_price` and `markup`; a cost with neither selling price nor markup gets
|
||||
the app default markup (80%).
|
||||
|
||||
`bulk_update_quote_items` (required: `items`):
|
||||
|
||||
```json
|
||||
|
|
@ -490,7 +467,7 @@ keys aren't set, so a clean checkout reports `passed, N skipped`.
|
|||
|
||||
<!-- BEGIN mcp-doc-gen: tools catalogue — do not edit by hand, regenerate with scripts/mcp-doc-gen.py -->
|
||||
|
||||
This catalogue is auto-generated from the `tools` array in `src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. There are **144 tools** as of this run. Re-run the script after adding or renaming a tool.
|
||||
This catalogue is auto-generated from the `tools` array in `src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. There are **137 tools** as of this run. Re-run the script after adding or renaming a tool.
|
||||
|
||||
Grouped by resource family. The full description is in the table column; the description text the agent sees is preserved verbatim.
|
||||
|
||||
|
|
@ -512,15 +489,11 @@ Grouped by resource family. The full description is in the table column; the des
|
|||
| `orders.mark_invoiced` | Mark an order as invoiced. Mirrors the REST POST /api/v1/orders/{id}/mark_invoiced endpoint. Flips `order.invoiced = true` ONLY when every line item has `item_status === "invoiced"`. Refuses with 409 if any item is not yet invoiced; the ... |
|
||||
| `orders.mark_delivered` | Mark every eligible line item on the order as `delivered`, stamping `delivered_at` and `delivered_date`. Mirrors the REST POST /api/v1/orders/{id}/mark_delivered endpoint. Eligible statuses: `ready_delivery \| collected \| with_driver \| ch... |
|
||||
| `orders.update` | Patch whitelisted header fields on an order. Mirrors the REST PATCH /api/v1/orders/{id} endpoint. Whitelist (from `ORDER_PATCHABLE_FIELDS`): `notes`, `contact_name`, `description`, `rep`, `email_cc`, `email_bcc`, `quote_reference_text`, ... |
|
||||
| `orders.set_quote` | Link an EXISTING order to an EXISTING quote. Mirrors the REST POST /api/v1/orders/{id}/quote endpoint. Why this exists: only `createOrderFromQuote` writes the `orders.quote` relation today, so walk-in orders leave it null. When Hermes di... |
|
||||
| `orders.quote_match_sweep` | Run quote auto-detection over orders that have never been attempted. Mirrors the REST POST /api/v1/orders/quote-match/sweep endpoint. Why this exists: orders created from now on are matched automatically at intake. This is the catch-up p... |
|
||||
| `orders.add_note` | REPLACE the `notes` field on an order with `text`. Thin wrapper over `orders.update` (`patch: { notes: text }`). The `notes` field is a free-text header field, distinct from the per-line `order_items.notes` JSON audit feed. If you want t... |
|
||||
| `orders.add_attachment` | Upload a single attachment (proof, drawing, signed PO, supplier quote) to an order. The file is stored on the order's OWN `order_attachements` multi-file field (PB-side misspelling kept in sync with the live schema) — the same field the ... |
|
||||
| `orders.list_attachments` | List the attachments associated with an order. Merges two sources: (a) files written to the order's own `order_attachements` multi-file field — operator uploads via `orders.add_attachment` and counter-sale photos from the in-store PWA; a... |
|
||||
| `orders.add_attachment` | Upload a single attachment (proof, drawing, signed PO, supplier quote) to an order. The file is stored against the order's matched quote (WFDASH has no `order_attachments` collection; orders resolve to their matched quote for attachment ... |
|
||||
| `orders.list_attachments` | List the attachments associated with an order. Returns the order's matched quote's attachments (since orders share the quote_attachments table with their source quote). Includes the original RFQ email attachments, the rendered quote PDF,... |
|
||||
| `orders.sprint_counts` | Counts of sprint-pricing rows grouped by claim status (unclaimed / claimed-by-me / claimed-by-other / resolved). Used to render sprint badges in the operator UI and to decide whether an agent should claim a row. Wraps `GET /api/v1/orders... |
|
||||
| `orders.find_duplicates` | Find probable duplicate orders. Wraps `GET /api/v1/orders/duplicates`. Use this BEFORE creating a new order from a customer's RFQ so the operator doesn't accidentally double-order. Three pass kinds are supported: - `order_number` — same ... |
|
||||
| `orders.find_by_order_number` | Exact-match lookup for one order by its `order_number`. Returns the matching `Order` record or `null` if not found. Use this when you need a deterministic `is there already an order for this order number?` check. The substring search `or... |
|
||||
| `orders.find_by_email_message_id` | Exact-match lookup for one order by its `email_message_id` (RFC 5322 Message-ID of the inbound email that originated it). Returns the matching `Order` record or `null` if not found. Use this for the `is there already an order for this in... |
|
||||
| `orders.find_duplicates` | Find probable duplicate orders for the same customer + recent window. Wraps `GET /api/v1/orders/duplicates`. Use this BEFORE creating a new order from a customer's RFQ so the operator doesn't accidentally double-order. Returns groups wit... |
|
||||
| `orders.review` | Read-only review bundle for one order: header, items grouped by supplier, PO readiness, attachment matches against quote items, and any pending decisions. Wraps `GET /api/v1/orders/[id]/review`. Use this BEFORE generating POs or splittin... |
|
||||
| `orders.matched_attachments` | Match the order's quote attachments against each order_item by description / SKU overlap. Wraps `GET /api/v1/orders/[id]/matched-attachments`. Returns `{ item_id → [attachment_filename, ...] }` so the agent can decide which proof applies... |
|
||||
|
||||
|
|
@ -530,8 +503,8 @@ Grouped by resource family. The full description is in the table column; the des
|
|||
| --- | --- |
|
||||
| `order_items.update_status` | Update an order_item's `item_status` and/or `drv_status` fields. Use this when the operator needs to flip a single row through the workflow without bulk-pricing or per-line invoice reconciliation (`order_items.update` is the patch tool f... |
|
||||
| `order_items.confirm_collected` | Confirm collection of one or more customer order_items WITHOUT requiring a driver assignment. Mirrors the agent-side role that the stock-side `stock_purchase_items.mark_collected` already plays: marks `item_status` → `collected` and `drv... |
|
||||
| `order_items.create` | Add a new line item to an existing order. Mirrors the REST POST /api/v1/orders/{id}/items endpoint. `selling_price` auto-derives from `cost_price * (1 + markup / 100)` when omitted. Per-row failures never abort the whole call (returns `c... |
|
||||
| `order_items.update` | Patch fields on an order_item for invoice reconciliation and catalogue relabelling. Whitelist: cost_price, selling_price, quantity, sku, unit, category, description, supplier, markup, markup_percent, pricing_source, pricing_notes, matche... |
|
||||
| `order_items.create` | Add a new line item to an existing order. Mirrors the REST POST /api/v1/orders/{id}/items endpoint. **Strict pending gate**: refuses with 409 (`order_locked`) if any line item on the order is past `item_status === "pending"`. Adding a li... |
|
||||
| `order_items.update` | Patch fields on an order_item for invoice reconciliation. Whitelist: cost_price, selling_price, quantity, sku, unit, category, markup, markup_percent, notes, qty_processed, qty_assigned, qty_collected, qty_verified, qty_stocked, qty_pick... |
|
||||
| `order_items.list` | List order_items (the line items under each order). Useful for finding pending/PB-ready items, getting one supplier's items, etc. Pass PB filter syntax in `filter`. |
|
||||
| `order_items.get` | Fetch a single order_item by id. Pass `expand` to inline `order`, `purchase_order`, `category`, `supplier`. |
|
||||
|
||||
|
|
@ -548,8 +521,8 @@ Grouped by resource family. The full description is in the table column; the des
|
|||
| `quotes.find_by_message_id` | Look up an existing quote by the inbound email's RFC 5322 Message-ID header. Use this when the operator (or another agent) needs to correlate an inbound email back to a quote that was already created from it — e.g. to attach a reply, app... |
|
||||
| `quotes.list` | List quotes. Pass PB filter syntax in `filter` (e.g. `status="new"`). Default sort `-created`. |
|
||||
| `quotes.get` | Fetch a single quote by id. Pass `expand` (e.g. `customer,company,items`). |
|
||||
| `quotes.update` | Patch fields on a quote header. Whitelist: `company_letterhead`, `quote_date`, `quote_reference`, `rfq_number`, `validity_days`, `notes`, `delivery_note`, `markup`, `email_cc`, `email_bcc`, `claimed_by`, `email_subject`, `email_sender`, ... |
|
||||
| `quotes.send_email` | Front-door for the outbound email send pipeline. Validates and flips the quote to `sending`; the actual SMTP send runs asynchronously in n8n. `selectedAttachmentIds` is a list of PB file tokens from `quotes.outbound_attachments` (operato... |
|
||||
| `quotes.update` | Patch fields on a quote header. Whitelist: `company_letterhead`, `quote_date`, `quote_reference`, `rfq_number`, `validity_days`, `notes`, `delivery_note`, `markup`, `email_cc`, `email_bcc`, `claimed_by`. Anything outside this set — inclu... |
|
||||
| `quotes.send_email` | Front-door for the outbound email send pipeline. Validates and flips the quote to `sending`; the actual SMTP send runs asynchronously in n8n. `selectedAttachmentIds` is a list of `quote_attachments` IDs to include (the PDF is always atta... |
|
||||
| `quotes.retry_email` | Retry the outbound email send for a quote currently in `mail_failed`. Use this when SMTP credentials have been rotated, the customer's MX changed, or the original send bounced. The quote must already be in `mail_failed` (409 if not) — fo... |
|
||||
| `quotes.clone` | Clone a real quote into a new real quote with a fresh `quote_number` and `status="new"`. The source quote is untouched. Useful for re-quoting the same items to a different customer. |
|
||||
| `quotes.create_from_template` | Create a new real quote from a template (`is_template=true` quote). The template is not modified — a fresh quote is created with a new `Q-YYYY-MM-NNN` number, `status="new"`, and the template's customer / company / letterhead / items cop... |
|
||||
|
|
@ -558,7 +531,6 @@ Grouped by resource family. The full description is in the table column; the des
|
|||
| `quotes.partial_approve` | Mark a subset of quote items as `approved` (or `rejected`) on a quote. Body: `{ quote_id, decisions: [{ item_id, decision }] }`. Useful when the customer accepts some lines but not others. |
|
||||
| `quotes.submit` | Flip a `new` quote to `approval_pending` once every line is priced. Idempotent — re-submitting an already-`approval_pending` or `partial_approval_pending` quote is a no-op (returns `statusChanged: false`). Gated: every line on the quote ... |
|
||||
| `quotes.re_price` | Re-run pricing on every quote_item under a quote. Whitelist of patchable fields is the same as `bulk_update_quote_items`. Returns the per-row result list. |
|
||||
| `quotes.assistant_fill` | Run the AI quote assistant on one quote now (`{ quote_id }`): fills unpriced lines from matching POS items as purple, unreviewed drafts and notes the closest items on unmatched lines. Only quotes with status new / follow_up / claimed, never templates; at most 15 lines per run. Problems come back in `error` (not thrown). Never marks lines done, changes the quote status or sends anything. Wraps `fillQuote` (same as `POST /api/v1/quotes/[id]/assistant/run`). |
|
||||
| `quotes.next_number` | Reserve the next quote number (`Q{yy}{mm}{NNN}`). Atomic counter; gaps are possible if the caller never persists the resulting draft. |
|
||||
| `quotes.lookup` | Free-text lookup over quotes for the order page's quote-picker dropdown. Substring match across quote_number, quote_reference, rfq_number, customer name, company name. Excludes templates by default. |
|
||||
| `quotes.list_attachments` | List all attachments on a quote (the generated PDF, original RFQ email attachments, operator-uploaded proofs, outbound attachments). Newest first. Returns up to 50 per page. |
|
||||
|
|
@ -656,10 +628,9 @@ Grouped by resource family. The full description is in the table column; the des
|
|||
| --- | --- |
|
||||
| `stock_items.list` | List stock_items. Supports substring search on name/sku/description, low-stock filter (`current_qty <= min_qty AND is_active = true`), pagination, and `expand` (defaults to `supplier`). |
|
||||
| `stock_items.get` | Fetch a single stock_item by id. The response includes the item (expand=supplier) and a paginated recent-ledger slice (`ledger.page`, `ledger.items`, etc.). Pass `ledger=false` to skip the ledger fetch. |
|
||||
| `stock_items.create` | Create a new stock_item (a POS/till item). This is the DEFAULT place to put a product and its prices — use it unless the user explicitly asked for the price list / catalogue. The till reads `selling_price` from this collection and nothin... |
|
||||
| `stock_items.create` | Create a new stock_item. `name` is required. When `current_qty > 0` an opening-balance ledger row is written so the running total and the ledger sum stay in sync (reason=`adjustment`, reference_type=`manual`). |
|
||||
| `stock_items.adjust` | Record a manual stock adjustment. Inserts a `stock_ledger` row and updates `current_qty` on the parent item. `change_qty` is signed (positive = in, negative = out); zero is rejected. `reason` must be one of STOCK_REASON_VALUES. |
|
||||
| `stock_items.update` | Patch catalogue fields on a master stock_item: sku, description, notes, unit, selling_price, markup. For inventory qty use stock_items.adjust. Returns {ok: true, item, previous} on success or {ok: false, reason, message, field?} on rejec... |
|
||||
| `stock_items.set_pricing` | Set cost price / selling price / markup on existing stock_items in one batch (max 100 per call). The POS till reads `selling_price` (NOT markup), so set both consistently: selling = cost × (1 + markup/100). Cost changes are audited in st... |
|
||||
|
||||
### Collection pool
|
||||
|
||||
|
|
@ -696,7 +667,7 @@ Grouped by resource family. The full description is in the table column; the des
|
|||
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `price_list.add_items` | ⚠️ DO NOT USE BY DEFAULT. This writes to the `price_list` catalogue, which the POS does NOT read — nothing here ever reaches the till. Only call this when the user has explicitly asked for the price list / catalogue / supplier price list... |
|
||||
| `price_list.add_items` | Bulk-upsert parsed supplier-price-list line items into the `price_list` catalogue. The agent reads the source document (a supplier PDF, a quote PDF, or a pasted supplier email) with its native vision capability, extracts the per-line fie... |
|
||||
|
||||
### Users
|
||||
|
||||
|
|
@ -720,7 +691,6 @@ Grouped by resource family. The full description is in the table column; the des
|
|||
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `audit_log.get` | Change history for one quote, order or delivery note — who changed what and when, newest first. Each entry lists per-field changes (`from` → `to`), the actor and source (web / mcp / n8n / agent). Includes summary counts: quote PDFs gener... |
|
||||
| `bulk_update_quote_items` | Patch many `quote_items` rows in a single MCP call (one JSON-RPC round-trip instead of N PATCHes to PB REST). Use this for AI auto-fill, supplier switches, or any bulk pricing update. Each row is independent — one row's failure never aff... |
|
||||
| `stock.fulfill` | Fulfill order items from stock. CREATE mode (`order_id` + `order_item_id` + `stock_item_id` + `qty` + `description` + `selling_price` + `cost_price`) creates a new order_item and decrements stock in one call. UPDATE mode (`order_item_id`... |
|
||||
| `stock.surplus_book` | Book surplus from a collected order_item into a stock_item. Auto-picks the matching stock_item when `stock_item_id` is omitted. |
|
||||
|
|
@ -733,8 +703,7 @@ Grouped by resource family. The full description is in the table column; the des
|
|||
| `stock_quick_draft.search` | Search the quick-draft index for SKUs / descriptions / suppliers. Wraps `GET /api/v1/stock-quick-draft/search`. Returns ranked hits with last-known cost and supplier. Use this BEFORE adding a line so the agent can pre-fill cost + supplier. |
|
||||
| `stock_quick_draft.add_line` | Add one line to an in-progress quick-draft PO. Wraps `POST /api/v1/stock-quick-draft/add-line`. The draft persists between calls until the operator hits 'Commit'. Use this when building up a stock PO one line at a time. Pass `stockItemId... |
|
||||
| `stock_quick_draft.suppliers` | List suppliers with quick-draft history (last ordered, line count, total spend). Wraps `GET /api/v1/stock-quick-draft/suppliers`. Use this to pick a supplier when the catalogue doesn't have a clear default. |
|
||||
| `delivery_notes.list` | List delivery notes. Reads the `delivery_notes` collection via the admin PB client. Supports `status` filter, `order_id` filter, and pagination. Newest-first. Use this when the operator asks 'show me outstanding delivery notes' or 'what'... |
|
||||
| `delivery_notes.set_order` | Link an EXISTING delivery note to an EXISTING order. Mirrors the REST POST /api/v1/delivery-notes/{id}/order endpoint. Why this exists: `delivery_notes.order` is a relation to `orders` but has no setter in the service layer — only the cr... |
|
||||
| `delivery_notes.list` | List delivery notes. Reads the `delivery_notes` collection via the admin PB client. Supports `status` filter (draft \| in_transit \| invoiced \| cancelled), `order_id` filter, and pagination. Newest-first. Use this when the operator asks 's... |
|
||||
| `delivery_notes.create` | Create a counter-pickup delivery note (Path B — no upstream order). Wraps `POST /api/v1/delivery-notes`. Body: `customer` (PB id, required), `items[]` (required, ≥1 with description + quantity), optional `company`, `notes`, `createdBy`. ... |
|
||||
| `delivery_notes.cancel` | Cancel a delivery note. Wraps `POST /api/v1/delivery-notes/[id]/cancel`. Body: optional `setById` (PB user id), `setByName`, and `note` for the audit trail. Use when a DN was created in error or the customer cancels the pickup. |
|
||||
| `delivery_notes.mark_invoiced` | Flip a delivery note to `invoiced` after the customer's invoice is raised. Wraps `POST /api/v1/delivery-notes/[id]/mark-invoiced`. Idempotent; returns the updated DN record. |
|
||||
|
|
@ -747,6 +716,6 @@ Grouped by resource family. The full description is in the table column; the des
|
|||
|
||||
### Alphabetical index
|
||||
|
||||
`ai_insights.rerun`, `ai_settings.get`, `ai_settings.update`, `attachments.upload_for_quote`, `audit_log.get`, `bulk_update_quote_items`, `buyer_tasks.generate_po`, `buyer_tasks.list`, `buyer_tasks.mark_ready`, `collection_confirm.confirm_order_items`, `collection_confirm.list`, `collection_confirm.upload_invoice`, `collection_pool.list`, `companies.create`, `companies.delete`, `companies.get`, `companies.list`, `companies.update`, `customers.create`, `customers.delete`, `customers.get`, `customers.list`, `customers.update`, `dashboard.daily_summary`, `delivery_notes.cancel`, `delivery_notes.combine`, `delivery_notes.create`, `delivery_notes.create_quote`, `delivery_notes.list`, `delivery_notes.mark_complete`, `delivery_notes.mark_invoiced`, `delivery_notes.regenerate_pdf`, `delivery_notes.set_customer_po`, `delivery_notes.set_item_quantity`, `delivery_notes.set_order`, `drivers.list_stops`, `drivers.update_stop_status`, `files.fetch`, `order_items.confirm_collected`, `order_items.create`, `order_items.get`, `order_items.list`, `order_items.update`, `order_items.update_status`, `orders.add_attachment`, `orders.add_note`, `orders.archive`, `orders.cancel`, `orders.create`, `orders.create_from_quote`, `orders.daily_summary`, `orders.find_by_email_message_id`, `orders.find_by_order_number`, `orders.find_duplicates`, `orders.get`, `orders.list`, `orders.list_attachments`, `orders.mark_delivered`, `orders.mark_invoiced`, `orders.matched_attachments`, `orders.quote_match_sweep`, `orders.review`, `orders.review_save`, `orders.set_quote`, `orders.split`, `orders.sprint_counts`, `orders.stats`, `orders.update`, `price_list.add_items`, `purchase_orders.add_attachment`, `purchase_orders.diff`, `purchase_orders.get`, `purchase_orders.list`, `purchase_orders.record_diff_report`, `purchase_orders.record_send_outcome`, `purchase_orders.retry_email`, `purchase_orders.scan`, `purchase_orders.send_email`, `purchase_orders.stale`, `quote_items.create`, `quote_items.get`, `quote_items.list`, `quote_items.populate_insights`, `quote_items.update`, `quotes.append_reply`, `quotes.assistant_fill`, `quotes.clone`, `quotes.create`, `quotes.create_from_email`, `quotes.create_from_template`, `quotes.find_by_message_id`, `quotes.get`, `quotes.get_pdf`, `quotes.ingest_from_email`, `quotes.list`, `quotes.list_attachments`, `quotes.lookup`, `quotes.next_number`, `quotes.partial_approve`, `quotes.process_pending`, `quotes.re_price`, `quotes.re_run_ai`, `quotes.retry_email`, `quotes.save_as_template`, `quotes.send_email`, `quotes.split`, `quotes.submit`, `quotes.update`, `sprint.attachment_counts`, `sprint.claim`, `sprint.item_attachments`, `sprint.list`, `stock.fulfill`, `stock.surplus_book`, `stock_items.adjust`, `stock_items.create`, `stock_items.get`, `stock_items.list`, `stock_items.set_pricing`, `stock_items.update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.bulk_update`, `stock_purchase_items.list`, `stock_purchase_items.mark_collected`, `stock_purchase_items.update`, `stock_purchases.add_attachment`, `stock_purchases.create`, `stock_purchases.get`, `stock_purchases.list`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.next_number`, `stock_purchases.receive`, `stock_purchases.retry_email`, `stock_purchases.send`, `stock_purchases.send_email`, `stock_quick_draft.add_line`, `stock_quick_draft.search`, `stock_quick_draft.suppliers`, `suppliers.create`, `suppliers.delete`, `suppliers.get`, `suppliers.list`, `suppliers.update`, `tools.discovery`, `users.lookup`
|
||||
`ai_insights.rerun`, `ai_settings.get`, `ai_settings.update`, `attachments.upload_for_quote`, `bulk_update_quote_items`, `buyer_tasks.generate_po`, `buyer_tasks.list`, `buyer_tasks.mark_ready`, `collection_confirm.confirm_order_items`, `collection_confirm.list`, `collection_confirm.upload_invoice`, `collection_pool.list`, `companies.create`, `companies.delete`, `companies.get`, `companies.list`, `companies.update`, `customers.create`, `customers.delete`, `customers.get`, `customers.list`, `customers.update`, `dashboard.daily_summary`, `delivery_notes.cancel`, `delivery_notes.combine`, `delivery_notes.create`, `delivery_notes.create_quote`, `delivery_notes.list`, `delivery_notes.mark_complete`, `delivery_notes.mark_invoiced`, `delivery_notes.regenerate_pdf`, `delivery_notes.set_customer_po`, `delivery_notes.set_item_quantity`, `drivers.list_stops`, `drivers.update_stop_status`, `files.fetch`, `order_items.confirm_collected`, `order_items.create`, `order_items.get`, `order_items.list`, `order_items.update`, `order_items.update_status`, `orders.add_attachment`, `orders.add_note`, `orders.archive`, `orders.cancel`, `orders.create`, `orders.create_from_quote`, `orders.daily_summary`, `orders.find_duplicates`, `orders.get`, `orders.list`, `orders.list_attachments`, `orders.mark_delivered`, `orders.mark_invoiced`, `orders.matched_attachments`, `orders.review`, `orders.review_save`, `orders.split`, `orders.sprint_counts`, `orders.stats`, `orders.update`, `price_list.add_items`, `purchase_orders.add_attachment`, `purchase_orders.diff`, `purchase_orders.get`, `purchase_orders.list`, `purchase_orders.record_diff_report`, `purchase_orders.record_send_outcome`, `purchase_orders.retry_email`, `purchase_orders.scan`, `purchase_orders.send_email`, `purchase_orders.stale`, `quote_items.create`, `quote_items.get`, `quote_items.list`, `quote_items.populate_insights`, `quote_items.update`, `quotes.append_reply`, `quotes.clone`, `quotes.create`, `quotes.create_from_email`, `quotes.create_from_template`, `quotes.find_by_message_id`, `quotes.get`, `quotes.get_pdf`, `quotes.ingest_from_email`, `quotes.list`, `quotes.list_attachments`, `quotes.lookup`, `quotes.next_number`, `quotes.partial_approve`, `quotes.process_pending`, `quotes.re_price`, `quotes.re_run_ai`, `quotes.retry_email`, `quotes.save_as_template`, `quotes.send_email`, `quotes.split`, `quotes.submit`, `quotes.update`, `sprint.attachment_counts`, `sprint.claim`, `sprint.item_attachments`, `sprint.list`, `stock.fulfill`, `stock.surplus_book`, `stock_items.adjust`, `stock_items.create`, `stock_items.get`, `stock_items.list`, `stock_items.update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.bulk_update`, `stock_purchase_items.list`, `stock_purchase_items.mark_collected`, `stock_purchase_items.update`, `stock_purchases.add_attachment`, `stock_purchases.create`, `stock_purchases.get`, `stock_purchases.list`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.next_number`, `stock_purchases.receive`, `stock_purchases.retry_email`, `stock_purchases.send`, `stock_purchases.send_email`, `stock_quick_draft.add_line`, `stock_quick_draft.search`, `stock_quick_draft.suppliers`, `suppliers.create`, `suppliers.delete`, `suppliers.get`, `suppliers.list`, `suppliers.update`, `tools.discovery`, `users.lookup`
|
||||
|
||||
<!-- END mcp-doc-gen -->
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
# WFDASH API — Create Order (POST /api/v1/orders/create)
|
||||
|
||||
> **One payload, one call.** The full order header + items array goes
|
||||
> in a single request body. There is no `/api/v1/orders` POST —
|
||||
> `/orders` is GET-only.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST /api/v1/orders/create
|
||||
```
|
||||
|
||||
Bearer-token auth via `AGENT_API_KEY` header. **Hermes drops
|
||||
`AGENT_API_KEY` on direct REST calls** — use the MCP wrapper
|
||||
`mcp__wfdash-mcp__orders__create` with the same body shape.
|
||||
|
||||
`runtime = "nodejs"` (PocketBase admin auth requires Node).
|
||||
|
||||
---
|
||||
|
||||
## Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"customer_id": "<15-char PB id>", // required
|
||||
"company_id": "<15-char PB id>", // required
|
||||
"notes": "string?", // order-level notes
|
||||
"contact_name":"string?", // operator-readable contact
|
||||
"description": "string?", // short description of the order
|
||||
"email_message_id": "string?", // dedup key
|
||||
"rep": "string?", // sales rep free-text
|
||||
"items": [
|
||||
{
|
||||
"description": "string", // required, trimmed
|
||||
"quantity": 1, // default 1; must be > 0 when supplied
|
||||
"cost_price": 123.45,
|
||||
"markup": 25,
|
||||
"selling_price": 154.31, // optional
|
||||
"sku": "string?",
|
||||
"unit": "EA",
|
||||
"supplier_id": "<15-char PB id?>",
|
||||
"notes": "string?"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Field rules
|
||||
|
||||
- **`customer_id`, `company_id`** — must resolve in PB. Free-text
|
||||
names are **not** accepted on this endpoint (use the MCP `orders.create`
|
||||
wrapper if you need name-resolution; the REST handler requires ids).
|
||||
- **`items[]`** — non-empty, every item needs `description`.
|
||||
- **`selling_price` auto-derive** — when `cost_price` and `markup` are
|
||||
both supplied and `selling_price` is omitted, it auto-derives as
|
||||
`cost_price * (1 + markup / 100)` rounded to 2 dp. Pass
|
||||
`selling_price` explicitly to override.
|
||||
- **`email_message_id`** — optional idempotency key. If an order with
|
||||
the same `email_message_id` already exists, the response returns the
|
||||
existing record with `duplicate: true` and HTTP **200** instead of
|
||||
201. Safe to retry.
|
||||
- **Per-row independence** — each item creates one `order_item` row
|
||||
with `item_status = "pending"` and `drv_status = "unassigned"`. One
|
||||
item's secondary-field patch failure does **not** abort the rest of
|
||||
the call; failures land in `items_failed[]` with the partial-success
|
||||
shape.
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
### 201 — created
|
||||
|
||||
```json
|
||||
{
|
||||
"order": { /* full PB order record */ },
|
||||
"order_items_collection": "<relation-id>",
|
||||
"items_created": [ { "id": "...", "description": "..." } ],
|
||||
"items_failed": []
|
||||
}
|
||||
```
|
||||
|
||||
### 200 — duplicate (`email_message_id` matched)
|
||||
|
||||
Same shape as 201, plus `"duplicate": true`. Nothing new was written.
|
||||
|
||||
### 400 — validation failure
|
||||
|
||||
```json
|
||||
{ "error": "customer_id is required" }
|
||||
{ "error": "items must be a non-empty array" }
|
||||
```
|
||||
|
||||
### 404 — not found
|
||||
|
||||
```json
|
||||
{ "error": "Requested resource not found..." }
|
||||
```
|
||||
|
||||
`customer_id` or `company_id` doesn't resolve in PB.
|
||||
|
||||
### 500 — server / PB write failure
|
||||
|
||||
```json
|
||||
{ "error": "<PocketBase error message>" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common workflows
|
||||
|
||||
### Walk-in / counter sale
|
||||
|
||||
No upstream quote. Customer walks up, picks items, you write the
|
||||
order straight from the till:
|
||||
|
||||
```bash
|
||||
curl -X POST https://wfdash.example/api/v1/orders/create \
|
||||
-H "Authorization: Bearer $AGENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"customer_id": "<customer-id>",
|
||||
"company_id": "<company-id>",
|
||||
"notes": "Counter sale — 2026-08-27",
|
||||
"items": [
|
||||
{ "description": "SKF 6205 bearing", "quantity": 4,
|
||||
"cost_price": 85, "markup": 30 }
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Re-create from a quote
|
||||
|
||||
**Don't** use this endpoint — use
|
||||
`POST /api/v1/orders/from-quote` (MCP: `orders.create_from_quote`).
|
||||
It clones the matched quote into a fresh `ORD-YYYY-MM-NNN` order,
|
||||
inheriting customer / company / rep / letterhead / line items.
|
||||
|
||||
### Append to an existing order
|
||||
|
||||
There **is** a separate endpoint:
|
||||
`POST /api/v1/orders/{id}/items` (MCP: `order_items.create`). But it
|
||||
**refuses with 409 `order_locked`** the moment any line on the order
|
||||
is past `pending`. Use it only on fresh orders — for any order that
|
||||
has a PO in flight, you must `orders.split` first.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Passing the order header without `items[]`** → 400. The handler
|
||||
does not create empty orders.
|
||||
- **Passing `quantity: 0` or negative** → rejected per item by the
|
||||
service layer; the row lands in `items_failed[]` with
|
||||
`validation_error`.
|
||||
- **Forgetting to send `AGENT_API_KEY`** → 401 from
|
||||
`assertAgentAuth`. From Hermes, use `mcp__wfdash-mcp__orders__create`
|
||||
instead — the MCP layer injects the key.
|
||||
- **Reusing `email_message_id` for genuinely different orders** →
|
||||
the second call returns the first order with `duplicate: true`.
|
||||
Don't reuse the key; generate a new one per logical order.
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
# WFDASH API — Quote Create + Customer Lookup Reference
|
||||
|
||||
> What the WFDASH REST/MCP API actually exposes today for the two
|
||||
> tasks you keep hitting during intake: creating a quote, and
|
||||
> finding an existing customer.
|
||||
|
||||
---
|
||||
|
||||
## Quote create — three flavours
|
||||
|
||||
There are three different quote-creation endpoints. Pick by **where
|
||||
the data came from**, not by what feels closest:
|
||||
|
||||
### 1. `quotes.create` (walk-in) — **the one for your intake**
|
||||
|
||||
```
|
||||
POST /api/v1/quotes/create
|
||||
```
|
||||
|
||||
- Use when: there's no inbound RFQ email. Operator types it in, you
|
||||
extracted items from a PDF / phone-call / WhatsApp, or you're
|
||||
driving the quote from the in-store counter PWA.
|
||||
- Body has `customer` (id OR name), `company` (id OR name), and
|
||||
`items[]`. **Names are resolved case-insensitive exact** — if
|
||||
there's no match, a new customer / company row is created with
|
||||
empty defaults. So passing a name is safe; passing the wrong name
|
||||
silently creates a duplicate.
|
||||
- Returns `{ quote, customer, company, items, quote_number }` on 201.
|
||||
- Status default is `"new"`. `quote_number` is auto-generated unless
|
||||
you supply one (operator-supplied conflicts return 409).
|
||||
- MCP wrapper: `mcp__wfdash-mcp__quotes__create`. Same body shape.
|
||||
|
||||
### 2. `quotes.ingest_from_email` (inbound RFQ email)
|
||||
|
||||
```
|
||||
POST /api/v1/quotes/ingest-from-email
|
||||
```
|
||||
|
||||
- Use when: the customer actually emailed in. Returns immediately
|
||||
with `ai_status="pending"`; the background processor runs Claude
|
||||
against the body + attachments to extract items. Don't extract
|
||||
items yourself — let the pipeline do it.
|
||||
- MCP wrapper: `mcp__wfdash-mcp__quotes__ingest_from_email`.
|
||||
|
||||
### 3. `quotes.create_from_email` (legacy, **avoid**)
|
||||
|
||||
```
|
||||
POST /api/v1/quotes/create-from-email
|
||||
```
|
||||
|
||||
- Caller passes a pre-extracted `extracted_items[]`. Only use this if
|
||||
you're calling on behalf of a parser that already did the
|
||||
extraction. **Wrong tool if you don't have an inbound email** —
|
||||
it requires `subject` + parsed body and silently rewrites
|
||||
`email_message_id` to a `synth-…@wfdash.local` sentinel.
|
||||
- MCP wrapper: `mcp__wfdash-mcp__quotes__create_from_email`.
|
||||
|
||||
---
|
||||
|
||||
## Customer lookup — fuzzy search by name / company
|
||||
|
||||
### `customers.list` — substring search
|
||||
|
||||
```
|
||||
GET /api/v1/customers
|
||||
```
|
||||
|
||||
Query params:
|
||||
|
||||
| Param | Type | What it does |
|
||||
|-----------|------|--------------|
|
||||
| `search` | string | Substring match on **customer name / email / phone**, OR'd together; also matches **rep name / rep email** (the user that owns this customer). One query covers all four fields. Case-sensitive PB `~` operator. |
|
||||
| `rep` | string | Exact filter on the rep relation. Accepts a 15-char PB user id OR a user name / email (case-insensitive). |
|
||||
| `page` | number | 1-based, default 1 |
|
||||
| `perPage` | number | default 50, hard cap 100 |
|
||||
| `sort` | string | PB sort expression, default `-created` |
|
||||
| `expand` | string | Comma-separated, e.g. `"company,rep"` to inline relations |
|
||||
|
||||
Returns `{ items, totalItems, totalPages, page, perPage }`.
|
||||
|
||||
MCP wrapper: `mcp__wfdash-mcp__customers__list`. Same params.
|
||||
|
||||
### `customers.get` — fetch by id
|
||||
|
||||
```
|
||||
GET /api/v1/customers/{id}
|
||||
```
|
||||
|
||||
For when you already have the PB id and just need the full row +
|
||||
relations.
|
||||
|
||||
MCP wrapper: `mcp__wfdash-mcp__customers__get`.
|
||||
|
||||
### What we DO NOT have today
|
||||
|
||||
- **No direct "filter customers by company name"** on `customers.list`.
|
||||
The `search` param doesn't search across the `company` relation.
|
||||
Two workarounds:
|
||||
1. Pass `expand: "company"` on the list call, then filter the
|
||||
returned `items[]` client-side by company name.
|
||||
2. First `companies.list({search: "<name>"})` to get the company
|
||||
id, then iterate the matching customers client-side. (The
|
||||
`customers.list` `search` filter does not accept a company
|
||||
relation id today.)
|
||||
|
||||
- **No dedicated `customers.lookup` endpoint** — `customers.list`
|
||||
with a small `perPage` (e.g. 5) is the closest equivalent.
|
||||
|
||||
- **No fuzzy / Levenshtein matching.** It's PB's literal substring
|
||||
`~` operator. "AB" won't match "ABC Trading" if you typed it
|
||||
wrong-case, and partial-token matches like "Bear" against "Bearings
|
||||
Co" will work — but "Bearings" against "Bear" works too (substring
|
||||
is bidirectional). Typos are not forgiven.
|
||||
|
||||
---
|
||||
|
||||
## The intake pattern that works
|
||||
|
||||
Given what we have, the cleanest customer-resolution flow at intake
|
||||
is:
|
||||
|
||||
```ts
|
||||
// 1. Try the most specific lookup first.
|
||||
const hit = await customers.list({
|
||||
search: customerName,
|
||||
expand: "company",
|
||||
perPage: 5,
|
||||
});
|
||||
|
||||
// 2. If the caller ALSO gave a company name, narrow client-side.
|
||||
const filtered = companyName
|
||||
? hit.items.filter(c => c.expand?.company?.company === companyName)
|
||||
: hit.items;
|
||||
|
||||
// 3. If 0 hits → create via customers.create (auto-creates company
|
||||
// if company name doesn't resolve either). Use quotes.create with
|
||||
// the names directly — it has the same auto-create behaviour, so
|
||||
// you don't need a separate customers.create call unless you want
|
||||
// to backfill email/phone/etc.
|
||||
```
|
||||
|
||||
For quote creation specifically, **just pass `customer` and `company`
|
||||
as names to `quotes.create`** — the service layer resolves them by
|
||||
case-insensitive exact match and creates missing rows. You only need
|
||||
the lookup step when:
|
||||
|
||||
- You're filling in `email` / `phone` from a contact form.
|
||||
- You want to verify the customer exists before showing the operator
|
||||
a "is this the right John Smith?" confirmation.
|
||||
- You're merging two customer records.
|
||||
|
||||
---
|
||||
|
||||
## Companies (related, since you mentioned name + company)
|
||||
|
||||
### `companies.list` — substring search
|
||||
|
||||
```
|
||||
GET /api/v1/companies
|
||||
```
|
||||
|
||||
Query params: `page`, `perPage`, `search` (substring on
|
||||
`company.company` / address / vendor), `sort`. Same shape as
|
||||
`customers.list`.
|
||||
|
||||
MCP wrapper: `mcp__wfdash-mcp__companies__list`.
|
||||
|
||||
### `companies.get` — fetch by id
|
||||
|
||||
```
|
||||
GET /api/v1/companies/{id}
|
||||
```
|
||||
|
||||
MCP wrapper: `mcp__wfdash-mcp__companies__get`.
|
||||
|
||||
### `companies.create` / `companies.update`
|
||||
|
||||
Free-text company name, optional address / vendor / coordinates.
|
||||
Same auto-resolve-and-create-on-no-match behaviour as customers.
|
||||
|
|
@ -1,724 +0,0 @@
|
|||
# MCP + API Layer Coverage Gap Analysis
|
||||
|
||||
**Task:** #e169d78f
|
||||
**Date:** 2026-08-28
|
||||
**Author:** @bob
|
||||
**Status:** analysis only — implementation NOT included (per task brief)
|
||||
|
||||
## Executive summary
|
||||
|
||||
The WFDASH app already has a substantial programmatic surface:
|
||||
|
||||
| Surface | Count | Scope |
|
||||
|---|---|---|
|
||||
| Pages (Server Components) | 53 | The full operator UI |
|
||||
| REST route handlers under `/api/v1/*` | 165 | Programmatic + agent + n8n integration |
|
||||
| MCP tools (registered in `src/app/api/mcp/route.ts`) | 142 | Agent-facing (Hermes, etc.) |
|
||||
| Server Actions (Next.js `"use server"`) | 1 file | AI settings form only |
|
||||
| Service-layer exports (`src/lib/server/*-service.ts`) | ~350 across 40 files | The actual business logic |
|
||||
|
||||
The current coverage is **broad but uneven**. The bulk-write lifecycle (quotes → orders → PO → collection → delivery → invoice → stock) is well-covered on every layer. **Reads are skewed toward agent queries** (every collection has a `.list` / `.get` MCP tool), but **write coverage has a long tail of gaps** in domains that don't get daily agent traffic: repairs, the delivery board, the boss dashboard, the collection map, the stock quick-draft line editor, and the `price_list` / `raw_price_list` catalogue UI.
|
||||
|
||||
The biggest structural gap is the **MCP route file itself** — a single 7,077-line `route.ts` containing 142 tool definitions, 142 handlers, the JSON-RPC envelope, the CORS helper, the upstream-fetch wrapper, and the `TOOL_HINTS` map. This is blocking maintainability (already on the security-perf-prod audit as a major), and it's blocking discoverability for new tool authors.
|
||||
|
||||
## Methodology
|
||||
|
||||
1. Inventoried every page (`src/app/**/page.tsx`) — 53 pages.
|
||||
2. Inventoried every `/api/v1/**/route.ts` — 165 route files.
|
||||
3. Inventoried every tool in `src/app/api/mcp/route.ts` — 142 tools.
|
||||
4. Inventoried every export from `src/lib/server/*-service.ts` (~40 services).
|
||||
5. Inventoried every export from the legacy `src/lib/pb-helpers.ts` (~60 functions).
|
||||
6. Inventoried Server Actions (`src/app/**/actions.ts`) — 1 file (settings/ai only).
|
||||
7. Cross-referenced against `src/app/api/mcp/FEATURE-REQUESTS.md` to avoid duplicating already-known gaps.
|
||||
8. Cross-referenced the MCP refactor recommendation at `docs/audit/backend-data.md:355-383` (Major finding 2.6).
|
||||
|
||||
The output is a capability→API→MCP matrix and a prioritized gap list with recommended slices for follow-up tasks.
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
Legend: ✅ shipped · 🟡 partial · ❌ missing
|
||||
|
||||
### Quotes pipeline
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List quotes (tabs) | `/quotes` | `GET /quotes/list` + `/quotes/search` | `quotes.list` | ✅ |
|
||||
| Get single quote | `/quotes/[id]` | `GET /quotes/[id]` | `quotes.get` | ✅ |
|
||||
| Create from email (n8n ingest) | webhook | `POST /quotes/from-email` | `quotes.create_from_email`, `quotes.ingest_from_email` | ✅ |
|
||||
| Create walk-in | `/quotes/[id]` "new" | `POST /quotes/create` | `quotes.create` | ✅ (Phase G) |
|
||||
| Update quote header | inline form | `PATCH /quotes/[id]` | `quotes.update` | ✅ |
|
||||
| Find by Message-ID | n8n callback | `GET /quotes/lookup?messageId=` | `quotes.find_by_message_id` | ✅ |
|
||||
| List templates | `/quotes` Templates tab | `GET /quotes/archive` | ❌ | MCP gap |
|
||||
| Save as template | detail page | `POST /quotes/[id]/save-as-template` | `quotes.save_as_template` | ✅ |
|
||||
| Create from template | template row | `POST /quotes/[id]/create-from-template` | `quotes.create_from_template` | ✅ |
|
||||
| Clone quote | detail page | `POST /quotes/[id]/clone` | `quotes.clone` | ✅ |
|
||||
| Add reply to thread | reply modal | `POST /quotes/append-reply` | `quotes.append_reply` | ✅ (route is `POST /quotes` with action, not `/append-reply` — see gap §1.4) |
|
||||
| Re-run AI | detail page button | `POST /quotes/[id]/re-run-ai` | `quotes.re_run_ai` | ✅ |
|
||||
| Process pending (batch) | cron | `POST /quotes/process-pending` | `quotes.process_pending` | ✅ |
|
||||
| Submit for approval | detail page | `POST /quotes/[id]/submit` | `quotes.submit` | ✅ (Phase G) |
|
||||
| Partial approve | detail page | `POST /quotes/[id]/partial-approve` | `quotes.partial_approve` | ✅ |
|
||||
| Review-approve | `/quotes/approvals` | `POST /quotes/[id]/review-approve` | ❌ | MCP gap (see §1.1) |
|
||||
| Re-price items | inline | `POST /quotes/[id]/re-price` | `quotes.re_price` | ✅ |
|
||||
| AI fill from POS | quote / Process page button | `POST /quotes/[id]/assistant/run` | `quotes.assistant_fill` | ✅ |
|
||||
| Split quote | inline | `POST /quotes/[id]/split` | `quotes.split` | ✅ |
|
||||
| Send email | email modal | `POST /quotes/[id]/send-email` | `quotes.send_email` | ✅ |
|
||||
| Retry email | email modal | `POST /quotes/[id]/retry-email` | `quotes.retry_email` | ✅ |
|
||||
| Render PDF | preview | `GET /quotes/[id]/pdf` + `/pdf/base64` | `quotes.get_pdf` | ✅ |
|
||||
| Populate insights | AI panel | `POST /quotes/[id]/populate-insights` + `/quote_items/[id]/populate-insights` | `quote_items.populate_insights` | ✅ |
|
||||
| List attachments | attachments card | `GET /quotes/[id]/attachments` | `quotes.list_attachments` | ✅ |
|
||||
| Upload attachment | attachments card | `POST /quotes/[id]/attachments/upload` + `/quotes/items/[id]/attachments/upload` | `attachments.upload_for_quote` | ✅ |
|
||||
| Set notes / set is_no_quote | form | `PATCH /quotes/[id]` | `quotes.update` | ✅ |
|
||||
| Lookup (number → id) | form | `GET /quotes/lookup` | `quotes.lookup` | ✅ |
|
||||
| Generate next quote # | form | `GET /quotes/next-number` | `quotes.next_number` | ✅ |
|
||||
| Archive quotes | `/quotes/archive` | `POST /quotes/archive` | ❌ | MCP gap (see §1.1) |
|
||||
|
||||
Result: 28 capabilities, 26 fully covered. **2 MCP gaps** (review-approve, archive).
|
||||
|
||||
### Quote items
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List per quote | `/quotes/[id]` | `GET /quotes/[id]/items` | `quote_items.list` | ✅ |
|
||||
| Get single item | inline | `GET /quote_items/[id]` | `quote_items.get` | ✅ |
|
||||
| Create item | inline "add row" | `POST /quotes/[id]/items` | `quote_items.create` | ✅ (Phase F) |
|
||||
| Update item | inline popover | `PATCH /quote_items/[id]` | `quote_items.update` | ✅ |
|
||||
| Bulk update items | sprint bulk | `POST /quote_items/bulk-update` | `bulk_update_quote_items` | ✅ |
|
||||
| Boolean popover fields (qty/cost/sell) | inline | `PATCH /order-items/[id]/...` (per-field) | 🟡 | Split across many small routes — see §3.4 |
|
||||
| Populate AI insights | insights panel | `POST /quote_items/[id]/populate-insights` | `quote_items.populate_insights` | ✅ |
|
||||
| Get AI intel | insights panel | `GET /quote_items/[id]/intel` | 🟡 | REST yes, MCP gap (see §1.7) |
|
||||
| Item history | inline panel | `GET /quote_items/[id]/item-history` | ❌ | MCP gap |
|
||||
| SKU lookup / match | sprint match picker | `GET /quotes/[id]/items/[itemId]/lookup` + `/match` | 🟡 | REST yes, MCP gap |
|
||||
| Rewrite (sprint rewrite) | sprint | `POST /quotes/items/rewrite` | ❌ | MCP gap (internal AI flow) |
|
||||
|
||||
Result: 11 capabilities, 6 fully covered, **5 partial/missing**.
|
||||
|
||||
### Orders pipeline
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List orders | `/orders` | `GET /orders/list` + `/orders` | `orders.list` | ✅ |
|
||||
| Get single order | `/orders/[id]` | `GET /orders/[id]` | `orders.get` | ✅ |
|
||||
| Create from quote | "Convert" button | `POST /orders/from-quote` | `orders.create_from_quote` | ✅ |
|
||||
| Create walk-in | `/orders/new` | `POST /orders/create` | `orders.create` | ✅ |
|
||||
| Update header | detail page | `PATCH /orders/[id]` | `orders.update` | ✅ (Phase H) |
|
||||
| Set quote link | detail page | `POST /orders/[id]/quote` | `orders.set_quote` | ✅ |
|
||||
| Archive | `/orders/archive` | `POST /orders/archive` | `orders.archive` | ✅ (Phase H) |
|
||||
| Cancel | detail page | `POST /orders/[id]/cancel` | `orders.cancel` | ✅ (Phase H) |
|
||||
| Review-save | detail page | `POST /orders/[id]/review-save` | `orders.review_save` | ✅ (Phase H) |
|
||||
| Mark invoiced (header) | invoicing tab | `POST /orders/[id]/mark_invoiced` | `orders.mark_invoiced` | ✅ (Phase H) |
|
||||
| Mark delivered (header) | invoicing tab | `POST /orders/[id]/mark_delivered` | `orders.mark_delivered` | ✅ (Phase H) |
|
||||
| Add note | detail page | `PATCH /orders/[id]` | `orders.add_note` | ✅ (Phase H, wrapper of update) |
|
||||
| Add attachment | attachments card | `POST /orders/[id]/attachments/upload` | `orders.add_attachment` | ✅ |
|
||||
| List attachments | attachments card | `GET /orders/[id]/matched-attachments` | `orders.list_attachments`, `orders.matched_attachments` | ✅ |
|
||||
| Split order | detail page | `POST /orders/[id]/split` | `orders.split` | ✅ |
|
||||
| Add item (existing order) | inline | `POST /orders/[id]/items` | `order_items.create` | ✅ (Phase H) |
|
||||
| Review data (read) | review screen | `GET /orders/[id]/review` | `orders.review` | ✅ (Phase E) |
|
||||
| Stats (aggregate) | reports | `GET /orders/stats` | `orders.stats` | ✅ (Phase H) |
|
||||
| Daily summary | reports | `GET /orders/daily_summary` | `orders.daily_summary` | ✅ (Phase H) |
|
||||
| Sprint counts | reports | `GET /orders/sprint/counts` | `orders.sprint_counts` | ✅ (Phase E) |
|
||||
| Find by order # | search | `GET /orders/lookup` | `orders.find_by_order_number` | ✅ (Phase E) |
|
||||
| Find by Message-ID | n8n callback | `GET /orders/lookup` | `orders.find_by_email_message_id` | ✅ (Phase E) |
|
||||
| Find duplicates | QA | `GET /orders/duplicates` | `orders.find_duplicates` | ✅ (Phase E) |
|
||||
| Lookup (number → id) | form | `GET /orders/lookup` | ❌ | MCP gap — see §1.2 |
|
||||
|
||||
Result: 24 capabilities, 23 fully covered. **1 MCP gap** (generic lookup).
|
||||
|
||||
### Order items
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List per order | `/orders/[id]` | `GET /order-items?orderId=` | `order_items.list` | ✅ |
|
||||
| Get single item | inline | `GET /order-items/[id]` | `order_items.get` | ✅ |
|
||||
| Update popover fields | inline popover | `PATCH /order-items/[id]` | `order_items.update` | ✅ (Phase A whitelist) |
|
||||
| Per-field shortcuts | inline | `PATCH /order-items/[id]/{qty,sku,cost,status}` | 🟡 | 4 routes for the popover per-field widgets; not wrapped in MCP — see §3.4 |
|
||||
| Update status | workflow button | `PATCH /order-items/[id]/status` | `order_items.update_status` | ✅ |
|
||||
| Confirm collected (driver) | driver flow | `POST /order-items/confirm` | `order_items.confirm_collected` | ✅ |
|
||||
| Split item | inline | `POST /order-items/[id]/split` | ❌ | MCP gap — see §1.3 |
|
||||
| Request margin approval | inline | `POST /order-items/[id]/request-margin-approval` | ❌ | MCP gap |
|
||||
| AI intel (read) | insights panel | `GET /order-items/[id]/intel` | 🟡 | REST yes, MCP gap |
|
||||
| Refresh AI intel | insights panel | `POST /items/[collection]/[id]/intel/refresh` | 🟡 | REST yes, MCP gap |
|
||||
| Backfill intel | cron | `POST /items/backfill-intel` | ❌ | MCP gap (cron) |
|
||||
| Context (price-history snapshot) | context panel | `GET /order-items/[id]/context` | ❌ | MCP gap |
|
||||
|
||||
Result: 12 capabilities, 5 fully covered, **7 partial/missing**.
|
||||
|
||||
### Sprint / item-level queue
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List sprint items | `/sprint` | `GET /sprint` | `sprint.list` | ✅ |
|
||||
| Claim item (30-min TTL) | inline | `POST /sprint/items/[id]/claim` | `sprint.claim` | ✅ |
|
||||
| Item attachments | sprint panel | `GET /sprint/items/[id]/attachments` | `sprint.item_attachments` | ✅ |
|
||||
| Attachment counts | sprint row badge | `GET /sprint/attachment-counts` | `sprint.attachment_counts` | ✅ |
|
||||
| Upload attachment | sprint panel | `POST /sprint/items/[id]/attachments/upload` | `sprint.item_attachments` (POST variant) | 🟡 | MCP exposes the GET path; the POST upload is a different input shape — see §3.5 |
|
||||
|
||||
Result: 5 capabilities, 4 fully covered, **1 partial**.
|
||||
|
||||
### Collection pool & map
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List collection pool | `/collection-pool` | `GET /collection-pool` (`GET /orders/...`) | `collection_pool.list` | ✅ (older route) |
|
||||
| Map items | `/collection-map` | `GET /collection-map/items` | ❌ | MCP gap — see §1.5 |
|
||||
| Assign driver (map) | map click | `POST /collection-map/items/[id]/assign` | ❌ | MCP gap |
|
||||
| Bulk assign per supplier | pool | server action `bulkAssign` | ❌ | MCP gap |
|
||||
|
||||
Result: 4 capabilities, 1 covered, **3 MCP gaps**.
|
||||
|
||||
### Driver (mobile)
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List drivers (users) | settings | `GET /driver` | ❌ | MCP gap — only `drivers.list_stops` exists |
|
||||
| List stops | mobile / driver | `GET /driver/stops` | `drivers.list_stops` | ✅ |
|
||||
| Update stop status | mobile / driver | `PATCH /driver/stops/[id]/status` | `drivers.update_stop_status` | ✅ |
|
||||
| Collection-confirm (driver UI) | `/collection-confirm` | `GET /collection-confirm` | `collection_confirm.list` | ✅ |
|
||||
| Confirm collection | mobile / driver | `POST /collection-confirm/confirm-order-items` | `collection_confirm.confirm_order_items` | ✅ |
|
||||
| Upload invoice (driver) | mobile / driver | `POST /collection-confirm/invoice-upload` | `collection_confirm.upload_invoice` | ✅ |
|
||||
| Confirm stock (warehouse) | warehouse | `POST /collection-confirm/confirm-stock` | ❌ | MCP gap |
|
||||
|
||||
Result: 7 capabilities, 5 covered, **2 MCP gaps** (driver list, confirm-stock).
|
||||
|
||||
### Delivery board (`/deliveries`)
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List per tab (to_load / back_from_driver / checked_in) | `/deliveries` | `GET /deliveries?tab=` | ❌ | MCP gap (see §1.6) |
|
||||
| Load (set status → with_driver) | `/deliveries` | `POST /deliveries/load` | ❌ | MCP gap |
|
||||
| Load DN (delivery-note bound load) | `/deliveries` | `POST /deliveries/load-dn` | ❌ | MCP gap |
|
||||
| Check-in (driver returns) | `/deliveries` | `POST /deliveries/check-in` | ❌ | MCP gap |
|
||||
| Mark signed copy | `/deliveries` | `POST /deliveries/mark-signed-copy` | ❌ | MCP gap |
|
||||
| Find item to load by DN | `/deliveries` | server action | ❌ | MCP gap |
|
||||
|
||||
Result: 6 capabilities, 0 MCP-covered, 6 REST-only. **Whole `/deliveries` MCP domain missing.**
|
||||
|
||||
### Delivery notes
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List delivery notes | `/delivery-notes` | `GET /delivery-notes` | `delivery_notes.list` | ✅ (Phase E) |
|
||||
| Get single | `/delivery-notes/[id]` | `GET /delivery-notes/[id]` | ❌ | MCP gap — see §1.9 |
|
||||
| Create from order | invoicing modal | `POST /delivery-notes` | `delivery_notes.create` | ✅ (Phase E) |
|
||||
| Cancel | detail page | `POST /delivery-notes/[id]/cancel` | `delivery_notes.cancel` | ✅ (Phase E) |
|
||||
| Mark invoiced | invoicing tab | `POST /delivery-notes/[id]/mark-invoiced` | `delivery_notes.mark_invoiced` | ✅ (Phase E) |
|
||||
| Mark complete | detail page | `POST /delivery-notes/[id]/mark-complete` | `delivery_notes.mark_complete` | ✅ (Phase E) |
|
||||
| Regenerate PDF | detail page | `POST /delivery-notes/[id]/regenerate-pdf` | `delivery_notes.regenerate_pdf` | ✅ (Phase E) |
|
||||
| Create quote from DN | detail page | `POST /delivery-notes/[id]/create-quote` | `delivery_notes.create_quote` | ✅ (Phase E) |
|
||||
| Set customer PO | detail page | `POST /delivery-notes/[id]/customer-po` | `delivery_notes.set_customer_po` | ✅ (Phase E) |
|
||||
| Set item quantity | inline | `POST /delivery-notes/items/[id]/quantity` | `delivery_notes.set_item_quantity` | ✅ (Phase E) |
|
||||
| Set order | detail page | `POST /delivery-notes/[id]/order` | `delivery_notes.set_order` | ✅ (Phase E) |
|
||||
| Combine (merge DNs) | admin | `POST /delivery-notes/combine` | `delivery_notes.combine` | ✅ (Phase E) |
|
||||
| Mark signed copy | `/delivery-notes/[id]` | `POST /delivery-notes/[id]/signed-copy` | ❌ | MCP gap |
|
||||
| Get PDF (stream) | preview | ❌ (no stream route) | ❌ | See §1.10 |
|
||||
|
||||
Result: 14 capabilities, 11 covered. **3 MCP gaps** (get-single, signed-copy, PDF stream).
|
||||
|
||||
### Repairs
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List repairs | `/repairs` | `GET /repairs` | ❌ | MCP gap — see §1.8 |
|
||||
| Get single | `/repairs/[id]` | `GET /repairs/[id]` | ❌ | MCP gap |
|
||||
| Create repair | `/repairs` form | `POST /repairs` | ❌ | MCP gap |
|
||||
| Update repair | `/repairs/[id]` | `PATCH /repairs/[id]` | ❌ | MCP gap |
|
||||
| Delete repair | `/repairs/[id]` | `DELETE /repairs/[id]` | ❌ | MCP gap |
|
||||
| List form-data (create modal) | form | `GET /repairs/form-data` | ❌ | Internal helper, no MCP gap |
|
||||
| Upload file | repair detail | `POST /repairs/[id]/files/[field]` | ❌ | MCP gap |
|
||||
| Generate delivery note | repair detail | `POST /repairs/[id]/generate-delivery-note` | ❌ | MCP gap |
|
||||
| Generate repair number | form | ❌ (handled inline) | ❌ | MCP gap |
|
||||
|
||||
Result: 9 capabilities, 0 MCP-covered. **Whole `/repairs` MCP domain missing.**
|
||||
|
||||
### Purchase orders (PO)
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List POs | (sprint/PO view) | `GET /purchase-orders` | `purchase_orders.list` | ✅ |
|
||||
| Get single PO | inline | `GET /purchase-orders/[id]` | `purchase_orders.get` | ✅ |
|
||||
| Create PO | server action | `POST /purchase-orders` | `stock_purchases.send` (different) | 🟡 | The agent uses `stock_purchases` for stock POs; **customer POs** go via server action with no MCP wrapper — see §1.11 |
|
||||
| Send PO email | email modal | `POST /purchase-orders/[id]/send-email` | `purchase_orders.send_email` | ✅ |
|
||||
| Retry email | email modal | `POST /purchase-orders/[id]/send-email` | `purchase_orders.retry_email` | ✅ |
|
||||
| Send outcome (callback) | n8n callback | `POST /purchase-orders/[id]/send-email/outcome` | `purchase_orders.record_send_outcome` | ✅ |
|
||||
| PDF render | preview | `GET /purchase-orders/[id]/pdf` | ❌ | MCP gap (see §1.12) |
|
||||
| Dispatch (operator action) | PO row | `POST /purchase-orders/[id]/dispatch` | 🟡 | REST yes, MCP gap |
|
||||
| Add attachment | detail page | `POST /purchase-orders/[id]/attachments/upload` | `purchase_orders.add_attachment` | ✅ |
|
||||
| Get attachment | preview | `GET /purchase-orders/[id]/attachments/[filename]` | 🟡 | REST yes, MCP gap |
|
||||
| List stale POs | reports | `GET /purchase-orders/stale` | `purchase_orders.stale` | ✅ |
|
||||
| Scan (PB PDF → lines) | AI flow | `POST /purchase-orders/scan` | `purchase_orders.scan` | ✅ (internal) |
|
||||
| Diff (PO vs lines) | AI flow | `POST /purchase-orders/diff` | `purchase_orders.diff` | ✅ (internal) |
|
||||
| Record diff report | AI flow | `POST /purchase-orders/record-diff-report` | `purchase_orders.record_diff_report` | ✅ (internal) |
|
||||
|
||||
Result: 14 capabilities, 9 fully covered, **5 partial/missing**.
|
||||
|
||||
### Stock items + stock purchases
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List stock items | `/stock` | `GET /stock/items` | `stock_items.list` | ✅ |
|
||||
| Get single | `/stock/[id]` | `GET /stock/items/[id]` | `stock_items.get` | ✅ |
|
||||
| Create stock item | `/stock` form | `POST /stock/items/create` | `stock_items.create` | ✅ |
|
||||
| Update stock item | `/stock/[id]` | `PATCH /stock/items/[id]` | `stock_items.update` | ✅ |
|
||||
| Manual adjust | `/stock/[id]` | `POST /stock/items/[id]/adjust` | `stock_items.adjust` | ✅ |
|
||||
| Set cost / selling price / markup (batch) | `/stock` inline edit | `POST /stock/items/set-pricing` | `stock_items.set_pricing` | ✅ |
|
||||
| Fulfill from stock for order | fulfilment flow | `POST /stock/fulfill` | `stock.fulfill` | ✅ |
|
||||
| Surplus book | fulfilment flow | `POST /stock/surplus-book` | `stock.surplus_book` | ✅ |
|
||||
| List stock purchases | `/stock-orders` | `GET /stock-purchases/list` | `stock_purchases.list` | ✅ |
|
||||
| Get single SP | `/stock-orders/[id]` | `GET /stock-purchases` (one-shot read) | `stock_purchases.get` | ✅ |
|
||||
| Create SP | `/stock-orders/new` | `POST /stock-purchases` | `stock_purchases.create` | ✅ |
|
||||
| Send SP email | email modal | `POST /stock-purchases/[id]/send-email` | `stock_purchases.send_email` | ✅ |
|
||||
| Retry email | email modal | `POST /stock-purchases/[id]/send-email` | `stock_purchases.retry_email` | ✅ |
|
||||
| Receive SP (book into stock) | warehouse | `POST /stock-purchases/[id]/receive` | `stock_purchases.receive` | ✅ |
|
||||
| Match & receive from invoice | scan workflow | n8n webhook → `/stock-purchases/match-from-invoice` | `stock_purchases.match_and_receive_from_invoice` | ✅ (Phase H internal) |
|
||||
| Add attachment | detail page | `POST /stock-purchases/[id]/attachments/upload` | `stock_purchases.add_attachment` | ✅ |
|
||||
| Get attachment | preview | `GET /stock-purchases/[id]/attachments/[filename]` | ❌ | MCP gap |
|
||||
| Stock-purchase list PDF | reports | `GET /stock-purchases/list-pdf` | ❌ | MCP gap |
|
||||
| Picking slip PDF | warehouse | `GET /stock-purchases/picking-slip` | ❌ | MCP gap |
|
||||
| SP PDF | detail page | `GET /stock-purchases/[id]/pdf` | ❌ | MCP gap (3 PDF routes un-wrapped) |
|
||||
| Stock-purchase items: list | `/stock-orders/[id]` | `GET /stock-purchase-items` | `stock_purchase_items.list` | ✅ |
|
||||
| Stock-purchase items: update | inline | `PATCH /stock-purchase-items/[id]` | `stock_purchase_items.update` | ✅ |
|
||||
| Stock-purchase items: bulk update | inline | `PATCH /stock-purchase-items/bulk` | `stock_purchase_items.bulk_update` | ✅ |
|
||||
| Stock-purchase items: per-field (qty/sku/cost) | inline | `PATCH /stock-purchase-items/[id]/{qty,sku,cost}` | 🟡 | 3 routes for popover per-field widgets, no MCP — see §3.4 |
|
||||
| Stock-purchase items: assign driver | inline | `POST /order-items/assign-driver` (uses same path) | `stock_purchase_items.assign_driver` | ✅ |
|
||||
| Stock-purchase items: mark collected | warehouse | n/a (via confirm) | `stock_purchase_items.mark_collected` | ✅ |
|
||||
| Next SP number | form | `GET /stock-purchases/next-number` | `stock_purchases.next_number` | ✅ |
|
||||
| Send SP (shorthand) | `POST /stock-purchases/send` | 🟡 | REST yes, MCP gap |
|
||||
| Stocktake PDF | reports | `GET /stock-items/stocktake-pdf` | ❌ | MCP gap |
|
||||
| Reorder list | `/stock-orders/reorder` | (server action) | ❌ | MCP gap |
|
||||
|
||||
Result: 30 capabilities, 21 fully covered, **9 partial/missing**.
|
||||
|
||||
### Stock quick-draft
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Search catalogue | inline | `GET /stock-quick-draft/search` | `stock_quick_draft.search` | ✅ (Phase E) |
|
||||
| Add line to draft | inline | `POST /stock-quick-draft/add-line` | `stock_quick_draft.add_line` | ✅ (Phase E) |
|
||||
| List suppliers | inline | `GET /stock-quick-draft/suppliers` | `stock_quick_draft.suppliers` | ✅ (Phase E) |
|
||||
| List drafts | inline | `GET /stock-quick-draft/drafts` | ❌ | MCP gap |
|
||||
| Edit/delete line | inline | `PATCH /stock-quick-draft/line/[lineId]` | ❌ | MCP gap |
|
||||
| Supplier history | inline | `GET /stock-quick-draft/supplier-history` | ❌ | MCP gap |
|
||||
|
||||
Result: 6 capabilities, 3 covered. **3 MCP gaps.**
|
||||
|
||||
### Pricelist / catalogue
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List raw uploads | `/pricelist` Raw tab | `GET /pricelist/raw` (via `pb-helpers`) | ❌ | MCP gap — see §1.13 |
|
||||
| Delete raw upload | row | `DELETE /pricelist/raw/[id]` | ❌ | MCP gap |
|
||||
| Search catalogue | Catalogue tab | `GET /price-list` (via `pb-helpers`) | ❌ | MCP gap |
|
||||
| Get price-list entry | inline | `GET /price-list/[sku]` | ❌ | MCP gap |
|
||||
| Add items (bulk ingest) | AI flow | `POST /price-list/add-items` | `price_list.add_items` | ✅ |
|
||||
| Create raw upload | upload widget | (server action) | ❌ | MCP gap |
|
||||
| Resync from raw upload | row | (server action) | ❌ | MCP gap |
|
||||
|
||||
Result: 7 capabilities, 1 covered. **6 MCP gaps** — almost the entire catalogue is agent-blind.
|
||||
|
||||
### Companies / Customers / Suppliers (master data)
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List companies | `/companies` | `GET /companies` | `companies.list` | ✅ |
|
||||
| Get single | `/companies/[id]` | `GET /companies/[id]` | `companies.get` | ✅ |
|
||||
| Create | form | `POST /companies` | `companies.create` | ✅ |
|
||||
| Update | form | `PATCH /companies/[id]` | `companies.update` | ✅ |
|
||||
| Delete | confirm | `DELETE /companies/[id]` | `companies.delete` | ✅ |
|
||||
| Find duplicates | QA | `GET /companies/duplicates` (via pb-helpers) | ❌ | MCP gap |
|
||||
| List customers | `/customers` | `GET /customers/list` + `/customers` | `customers.list` | ✅ (Phase G added `rep` filter) |
|
||||
| Get single | `/customers/[id]` | `GET /customers/[id]` | `customers.get` | ✅ |
|
||||
| Create | form | `POST /customers` | `customers.create` | ✅ |
|
||||
| Update | form | `PATCH /customers/[id]` | `customers.update` | ✅ |
|
||||
| Delete | confirm | `DELETE /customers/[id]` | `customers.delete` | ✅ |
|
||||
| Find duplicates | QA | (via pb-helpers) | ❌ | MCP gap |
|
||||
| List suppliers | `/suppliers` | `GET /suppliers` | `suppliers.list` | ✅ |
|
||||
| Get single | `/suppliers/[id]` | `GET /suppliers/[id]` | `suppliers.get` | ✅ |
|
||||
| Create | form | `POST /suppliers` | `suppliers.create` | ✅ |
|
||||
| Update | form | `PATCH /suppliers/[id]` | `suppliers.update` | ✅ |
|
||||
| Delete | confirm | `DELETE /suppliers/[id]` | `suppliers.delete` | ✅ |
|
||||
| Find duplicates | QA | (via pb-helpers) | ❌ | MCP gap |
|
||||
| Geocode (read + write) | supplier row | (none) | ❌ | FEATURE-REQUESTS.md (now) |
|
||||
| Mappable suppliers (for map) | `/supplier-map` | `GET /suppliers/mappable` (via pb-helpers) | ❌ | MCP gap |
|
||||
|
||||
Result: 21 capabilities, 15 covered. **6 MCP gaps** (duplicates for all 3; mappable; geocoding).
|
||||
|
||||
### Categories
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List categories | (admin) | ❌ (server-component read) | ❌ | Both gaps |
|
||||
| CRUD | (admin) | ❌ | ❌ | Whole admin domain missing — see §1.14 |
|
||||
|
||||
Result: 2 capabilities, 0 covered. **Whole categories domain missing.**
|
||||
|
||||
### Dashboard & KPIs
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Quotes dashboard data | `/dashboard` | `GET /dashboard` | ❌ | MCP gap |
|
||||
| Orders dashboard data | `/dashboard` | (combined) | ❌ | MCP gap |
|
||||
| Boss dashboard (today/week/month) | `/m` | `GET /boss-dashboard` | ❌ | MCP gap |
|
||||
| Boss dashboard rep activity | `/m` | `GET /boss-dashboard/rep-activity` | ❌ | MCP gap |
|
||||
| Daily summary (single call) | reports | `GET /orders/daily_summary` | `orders.daily_summary` | ✅ |
|
||||
| Dashboard daily summary | reports | ❌ (no separate route — `dashboard.daily_summary` MCP reads PB directly) | `dashboard.daily_summary` | ✅ (Phase E) |
|
||||
| Health check | n8n | `GET /health` | ❌ | MCP gap |
|
||||
|
||||
Result: 7 capabilities, 2 covered. **5 MCP gaps.**
|
||||
|
||||
### Settings
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Get AI settings | `/settings/ai` | `GET /ai/settings` | `ai_settings.get` | ✅ |
|
||||
| Update AI settings | `/settings/ai` form | `POST /ai/settings` (via Server Action) | `ai_settings.update` | ✅ |
|
||||
| Rerun AI insights (per collection) | `/settings/ai` | `POST /ai-insights/rerun` | `ai_insights.rerun` | ✅ |
|
||||
| List AI insights | `/settings/ai` | `GET /ai-insights` | ❌ | MCP gap |
|
||||
| Theme picker | `/settings` | client-side | n/a | N/A |
|
||||
| Dev identity switcher | `/settings` | client-side | n/a | N/A |
|
||||
| TV session | `/tv/pin`, `/tv/board` | `/api/tv/login`, `/api/tv/logout` | ❌ | MCP gap (separate auth surface) |
|
||||
| User lookup | `/settings` dev-mode | `GET /users/lookup` | `users.lookup` | ✅ |
|
||||
| List users | (admin) | `GET /users` | ❌ | MCP gap |
|
||||
|
||||
Result: 9 capabilities, 4 covered. **5 gaps** (3 MCP, 2 non-MCP surfaces).
|
||||
|
||||
### Buyer tasks
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| List buyer tasks | `/orders/tasks` | `GET /buyer-tasks` | `buyer_tasks.list` | ✅ |
|
||||
| Generate PO | inline | `POST /buyer-tasks/generate-po` | `buyer_tasks.generate_po` | ✅ |
|
||||
| Mark ready | inline | `POST /buyer-tasks/mark-ready` | `buyer_tasks.mark_ready` | ✅ |
|
||||
|
||||
Result: 3 capabilities, 3 covered. **0 gaps.**
|
||||
|
||||
### Files & attachments proxy
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Stream PB file | inline | `GET /files/[collection]/[recordId]/[filename]` | ❌ | MCP gap (see §1.15) |
|
||||
| Fetch as base64 | inline | ❌ (no base64 proxy) | `files.fetch` | ✅ (Phase E) — but only base64 |
|
||||
|
||||
Result: 2 capabilities, 1 covered. **1 MCP gap** (byte-stream equivalent).
|
||||
|
||||
### Invoicing (header)
|
||||
|
||||
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Stock book from invoice | accountant button | `POST /invoicing/stock-book` | ❌ | MCP gap (see §1.16) |
|
||||
| QB invoice (build + send) | invoicing modal | n8n webhook (not in /api/v1) | ❌ | Architectural gap — see §1.17 |
|
||||
|
||||
Result: 2 capabilities, 0 covered via MCP. **2 MCP gaps.**
|
||||
|
||||
---
|
||||
|
||||
## Gap analysis (prioritized)
|
||||
|
||||
Gaps are scored by:
|
||||
|
||||
- **Severity** (now = blocks daily agent/operator work; nice-to-have = polish)
|
||||
- **Effort** (small = ≤1 PR with no schema change; medium = 1-3 PRs; large = schema + migration + docs)
|
||||
- **Block count** (how many downstream agent workflows depend on it)
|
||||
|
||||
### 1. Critical MCP gaps (now, blocks daily agent workflows)
|
||||
|
||||
#### 1.1 `quotes.review_approve` + `quotes.archive` MCP missing
|
||||
|
||||
- **Severity:** now
|
||||
- **Effort:** small (route handlers exist, just thin wrappers)
|
||||
- **REST surface:** `POST /api/v1/quotes/[id]/review-approve`, `POST /api/v1/quotes/archive`
|
||||
- **Agent impact:** the approval queue (`/quotes/approvals`) and archive view are completely agent-invisible. Hermes can list but cannot act on approval decisions.
|
||||
- **Recommended slice:** add two MCP wrappers, no schema changes.
|
||||
|
||||
#### 1.2 `orders.lookup` MCP missing
|
||||
|
||||
- **Severity:** now
|
||||
- **Effort:** small
|
||||
- **REST surface:** `GET /api/v1/orders/lookup?number=...`
|
||||
- **Agent impact:** the agent has `find_by_order_number` (Phase E) but no generic lookup that takes either id or number. Duplication by design.
|
||||
- **Recommended slice:** either alias `find_by_order_number` to `lookup` or add a thin `orders.lookup({id|number})`.
|
||||
|
||||
#### 1.3 `order_items.split` MCP missing
|
||||
|
||||
- **Severity:** now
|
||||
- **Effort:** small
|
||||
- **REST surface:** `POST /api/v1/order-items/[id]/split`
|
||||
- **Agent impact:** splitting an order line is a daily buyer workflow ("customer only wants 5 of the 10 we ordered"). The agent can do everything around the split but not the split itself.
|
||||
- **Recommended slice:** thin wrapper, no schema changes.
|
||||
|
||||
#### 1.4 `quotes.append_reply` routed via the wrong endpoint
|
||||
|
||||
- **Severity:** now (deceptive)
|
||||
- **Effort:** small
|
||||
- **Issue:** MCP tool `quotes.append_reply` works, but the underlying REST is `POST /quotes` with an `action: "append_reply"` discriminator — not the natural `POST /quotes/append-reply`. The MCP wraps it, so Hermes doesn't see this, but anyone hand-calling REST hits a 404.
|
||||
- **Recommended slice:** add `POST /api/v1/quotes/append-reply` as the canonical route, keep the legacy action as a back-compat alias.
|
||||
|
||||
#### 1.5 `collection_map.assign` + bulk assign MCP missing
|
||||
|
||||
- **Severity:** now
|
||||
- **Effort:** small
|
||||
- **REST surface:** `POST /api/v1/collection-map/items/[id]/assign`; bulk-assign is a server action on the pool page
|
||||
- **Agent impact:** driver dispatch from the map view is invisible to the agent. Daily workflow for the office team.
|
||||
- **Recommended slice:** add `collection_map.assign({order_item_id, driver_id})` and `collection_map.bulk_assign({supplier_id, driver_id, order_item_ids[]})`.
|
||||
|
||||
#### 1.6 `/deliveries` MCP domain entirely missing
|
||||
|
||||
- **Severity:** now (whole domain)
|
||||
- **Effort:** medium (5 routes + 5 tools)
|
||||
- **Routes:** `/api/v1/deliveries`, `/load`, `/load-dn`, `/check-in`, `/mark-signed-copy`, plus the lookup helper
|
||||
- **Agent impact:** the operator's `/deliveries` board (to_load → back_from_driver → checked_in) is the daily delivery flow; the agent can see orders but cannot act on the delivery lifecycle.
|
||||
- **Recommended slice:** add `deliveries.list({tab})`, `deliveries.load({item_ids[]})`, `deliveries.load_dn({delivery_note_id})`, `deliveries.check_in({item_ids[], delivery_reference})`, `deliveries.mark_signed_copy({item_ids[]})`. These wrap the existing REST routes.
|
||||
|
||||
#### 1.7 `order_items.intel` + `quote_items.intel` MCP missing
|
||||
|
||||
- **Severity:** now
|
||||
- **Effort:** small (pure read wrappers)
|
||||
- **Routes:** `GET /order-items/[id]/intel`, `GET /quote_items/[id]/intel`, `POST /items/[collection]/[id]/intel/refresh`
|
||||
- **Agent impact:** the agent can't read or refresh AI intel on a specific item, forcing it to scan the full PB collection via the admin SDK (slow + PII risk).
|
||||
- **Recommended slice:** add `order_items.intel({id})`, `quote_items.intel({id})`, `items.intel_refresh({collection, id})`.
|
||||
|
||||
#### 1.8 `repairs.*` MCP domain entirely missing
|
||||
|
||||
- **Severity:** now (whole domain)
|
||||
- **Effort:** medium
|
||||
- **Routes:** `/repairs` GET+POST, `/repairs/[id]` GET+PATCH+DELETE, `/repairs/[id]/files/[field]`, `/repairs/[id]/generate-delivery-note`, plus the form-data helper
|
||||
- **Agent impact:** repair jobs are completely agent-invisible. The owner asked "anything in the app" → this is one of the seven operator-managed domains the agent cannot touch.
|
||||
- **Recommended slice:** add `repairs.list`, `repairs.get`, `repairs.create`, `repairs.update`, `repairs.delete`, `repairs.upload_file`, `repairs.generate_delivery_note`.
|
||||
|
||||
#### 1.9 `delivery_notes.get` MCP missing
|
||||
|
||||
- **Severity:** nice-to-have (most reads go via list)
|
||||
- **Effort:** small
|
||||
- **REST surface:** no `/delivery-notes/[id]` GET route exists; the agent reads the row directly from PB.
|
||||
- **Recommended slice:** add `GET /api/v1/delivery-notes/[id]` + `delivery_notes.get` MCP wrapper. Consistent with `quotes.get` / `orders.get`.
|
||||
|
||||
#### 1.10 `delivery_notes.pdf` MCP + REST missing
|
||||
|
||||
- **Severity:** now (preview breaks for agent-rendered DNs)
|
||||
- **Effort:** small (mirror the quote PDF route pattern)
|
||||
- **Recommended slice:** add `GET /api/v1/delivery-notes/[id]/pdf` + `delivery_notes.get_pdf` MCP tool.
|
||||
|
||||
#### 1.11 Customer PO creation has no MCP wrapper
|
||||
|
||||
- **Severity:** now
|
||||
- **Effort:** small (route exists; missing wrapper)
|
||||
- **Issue:** the `orders.create` tool creates orders, but **creating a customer purchase order** (the supplier-facing doc, distinct from the stock `stock_purchases` flow) is done via a server action on `/orders/[id]` and is not exposed via MCP.
|
||||
- **REST surface:** no dedicated `/api/v1/purchase-orders` POST; the server action lives in the order detail component.
|
||||
- **Recommended slice:** extract the PO creation into `POST /api/v1/purchase-orders` + `purchase_orders.create` MCP tool. Migrate the server action to call it.
|
||||
|
||||
#### 1.12 Three PO PDFs un-wrapped (customer + stock + stocktake)
|
||||
|
||||
- **Severity:** now (agent can't preview PO PDFs)
|
||||
- **Effort:** small (3 routes already exist)
|
||||
- **Routes:** `GET /purchase-orders/[id]/pdf`, `GET /stock-purchases/[id]/pdf`, `GET /stock-items/stocktake-pdf`, `GET /stock-purchases/list-pdf`, `GET /stock-purchases/picking-slip`
|
||||
- **Recommended slice:** add `purchase_orders.get_pdf`, `stock_purchases.get_pdf`, `stock_items.stocktake_pdf`, `stock_purchases.list_pdf`, `stock_purchases.picking_slip` MCP tools.
|
||||
|
||||
#### 1.13 `pricelist` / `raw_price_list` MCP domain entirely missing
|
||||
|
||||
- **Severity:** now (catalogue is the price-discovery backbone)
|
||||
- **Effort:** medium
|
||||
- **Routes needed:** `/pricelist/raw` GET+DELETE, `/pricelist/search`, `/pricelist/[sku]`, plus the upload/resync server actions need REST mirrors
|
||||
- **Agent impact:** the agent can add items to the price list (`price_list.add_items`) but cannot search or read entries. Sprint automation depends on the price list search.
|
||||
- **Recommended slice:** add `pricelist.search({q, sku, brand, category, supplier})`, `pricelist.get({sku})`, `pricelist.raw_list()`, `pricelist.raw_delete({id})`, `pricelist.raw_create(...)`, `pricelist.raw_resync({id})`.
|
||||
|
||||
#### 1.14 `categories` MCP domain entirely missing
|
||||
|
||||
- **Severity:** nice-to-have
|
||||
- **Effort:** small
|
||||
- **Routes needed:** `/categories` GET+POST, `/categories/[id]` GET+PATCH+DELETE
|
||||
- **Agent impact:** the sprint auto-bucketing rule sets (`match_brands`, `match_supplier_tags`, `match_keywords`) live on categories. The agent can read items but cannot inspect or tune the routing rules.
|
||||
- **Recommended slice:** add the 5 MCP wrappers.
|
||||
|
||||
#### 1.15 `files.stream` MCP missing (byte-stream equivalent)
|
||||
|
||||
- **Severity:** nice-to-have
|
||||
- **Effort:** small
|
||||
- **Issue:** `files.fetch` returns base64; large files (10 MB+ pricelist PDFs, signed delivery copies) bloat the MCP payload. Hermes wants byte-stream download.
|
||||
- **Recommended slice:** add a `files.stream({collection, recordId, filename})` that returns `{url, mime, expiresAt}` so the agent can `curl` directly.
|
||||
|
||||
#### 1.16 `invoicing.stock_book` MCP missing
|
||||
|
||||
- **Severity:** nice-to-have
|
||||
- **Effort:** small
|
||||
- **Route:** `POST /api/v1/invoicing/stock-book`
|
||||
- **Agent impact:** accountant's "Book into stock" button is REST-only. The buyer agent can't run the workflow end-to-end.
|
||||
- **Recommended slice:** add `invoicing.stock_book({id, date?})` MCP wrapper.
|
||||
|
||||
#### 1.17 QuickBooks invoice payload has no MCP entry point
|
||||
|
||||
- **Severity:** now (invoicing is a daily operator workflow)
|
||||
- **Effort:** large (architectural — the QB flow runs via n8n webhook, not /api/v1)
|
||||
- **Issue:** the QB invoice is built and POSTed to n8n from the invoicing modal — there's no `/api/v1` route and no MCP tool. The agent can't drive the invoicing end-to-end.
|
||||
- **Recommended slice:** add `/api/v1/invoicing/qb-invoice` (POST) + `invoicing.qb_invoice` MCP. Backend service: `qb-invoice-service.ts` mirroring the structure of `stock-purchase-invoice-matching-service.ts`.
|
||||
|
||||
### 2. MCP gaps per route (full list)
|
||||
|
||||
A few useful one-liner additions (severity: nice-to-have unless flagged otherwise):
|
||||
|
||||
- `purchase_orders.dispatch` — REST-only; MCP wrapper.
|
||||
- `purchase_orders.get_attachment` — REST-only; MCP wrapper (use `files.fetch` as the underlying primitive).
|
||||
- `stock_purchase_items.{qty,sku,cost}` per-field — REST-only; MCP wrappers.
|
||||
- `order_items.{qty,sku,cost,status,context}` per-field — REST-only; MCP wrappers.
|
||||
- `order_items.request_margin_approval` — REST-only; MCP wrapper.
|
||||
- `order_items.split` — see §1.3.
|
||||
- `quote_items.item_history` — REST-only; MCP wrapper.
|
||||
- `quote_items.lookup` / `quote_items.match` — REST-only; MCP wrappers (sprint match picker).
|
||||
- `quotes.items/rewrite` — REST-only; MCP wrapper (internal AI flow, low priority).
|
||||
- `driver.list` — REST-only; MCP wrapper.
|
||||
- `collection_confirm.confirm_stock` — REST-only; MCP wrapper.
|
||||
- `delivery_notes.signed_copy` — REST-only; MCP wrapper.
|
||||
- `delivery_notes.combine` — REST-only; MCP wrapper.
|
||||
- `stock_purchases.{send, get_attachment, list_pdf, picking_slip}` — REST-only; MCP wrappers.
|
||||
- `stock_purchases.drafts.{list, edit_line, supplier_history}` — REST-only; MCP wrappers.
|
||||
- `companies.{find_duplicates, mappable}` — REST-only; MCP wrappers.
|
||||
- `customers.find_duplicates` — REST-only; MCP wrapper.
|
||||
- `suppliers.{find_duplicates, geocode, mappable}` — REST-only; MCP wrappers (geocode already in FEATURE-REQUESTS.md).
|
||||
- `users.list` — REST-only; MCP wrapper.
|
||||
- `ai_insights.list` — REST-only; MCP wrapper.
|
||||
- `health.check` — REST-only; MCP wrapper (cheap operational health probe).
|
||||
- `repair.*` — see §1.8.
|
||||
- `category.*` — see §1.14.
|
||||
- `pricelist.*` / `raw_price_list.*` — see §1.13.
|
||||
- `boss_dashboard.{get, rep_activity}` — REST-only; MCP wrappers.
|
||||
|
||||
### 3. MCP refactor recommendations (the 7,077-line `route.ts`)
|
||||
|
||||
The MCP route handler is the single biggest maintainability bottleneck in the agent layer. The approved `docs/audit/backend-data.md:355-383` calls for splitting each tool into `src/lib/server/mcp-tools/<name>.ts`. Building on that recommendation:
|
||||
|
||||
#### 3.1 Per-tool file split
|
||||
|
||||
```
|
||||
src/lib/server/mcp-tools/
|
||||
orders/
|
||||
list.ts # inputSchema + handler
|
||||
get.ts
|
||||
create.ts
|
||||
...
|
||||
quotes/
|
||||
...
|
||||
```
|
||||
|
||||
Each file exports:
|
||||
|
||||
```ts
|
||||
export const tool: McpTool = {
|
||||
name: "orders.list",
|
||||
description: "...",
|
||||
inputSchema: { ... },
|
||||
};
|
||||
export async function handle(req: Request, input: unknown, ctx: McpContext): Promise<ToolResult> { ... }
|
||||
```
|
||||
|
||||
The `tools` array in `route.ts` reduces to `Object.entries(MCP_TOOLS).flatMap(([name, mod]) => [mod.tool])`; dispatch is `MCP_TOOLS[toolName]?.handle(...)`.
|
||||
|
||||
#### 3.2 Zod schema sharing with REST routes
|
||||
|
||||
Today the MCP layer hand-writes `inputSchema` while REST routes use `zod`. They drift (the `quote_items.create` whitelist drift was caught in Phase F; same risk exists for `quote_items.update` vs `bulk_update_quote_items` per FEATURE-REQUESTS.md).
|
||||
|
||||
Recommend: each MCP tool exports both `inputSchema` (JSON Schema for the MCP wire) AND a `zodSchema` for runtime validation. Use `zod-to-json-schema` to derive the JSON Schema from the zod schema. One source of truth per write operation.
|
||||
|
||||
#### 3.3 Service-layer dispatch table
|
||||
|
||||
Cross-reference: the FEATURE-REQUESTS "Standard error envelope with `hint`/`next_actions`/`allowed_tools`" entry. The 13 write tools migrated in Phase I carry a `TOOL_HINTS` map at `route.ts:266`; the remaining 33 `fetch + if (!res.ok)` blocks need the same migration. Per-tool file split makes this mechanical.
|
||||
|
||||
#### 3.4 Per-field widget route proliferation
|
||||
|
||||
`/api/v1/order-items/[id]/{qty,sku,cost,status,context}` and `/api/v1/stock-purchase-items/[id]/{qty,sku,cost}` exist as 4+3 separate PATCH routes because the operator popover posts one field at a time. This works but is awkward for an agent (which would batch). Recommend either:
|
||||
|
||||
- Document the existing `PATCH /order-items/[id]` (whitelist already exists) as the canonical "batch fields" endpoint for the agent; keep the per-field routes for the popover; OR
|
||||
- Generate the per-field routes from a shared schema (e.g. `const PER_FIELD_ROUTES = { qty: { method: "PATCH", path: "/order-items/[id]/qty" }, ... }`).
|
||||
|
||||
#### 3.5 MCP tool consolidation for sprint attachments
|
||||
|
||||
`sprint.item_attachments` is one tool that takes either a GET or POST shape depending on context. Split into `sprint.get_attachments({id})` + `sprint.upload_attachment({id, file})` for clarity.
|
||||
|
||||
### 4. Architectural observations
|
||||
|
||||
#### 4.1 Customer PO vs stock PO model drift
|
||||
|
||||
The MCP separates `purchase_orders.*` (customer-side) from `stock_purchases.*` (warehouse-side). The duplicate-rest surface is fine for queries but the agent often wants a "PO" abstraction (the source PDF, the supplier reply, the lines, the email status). Consider a `purchase_orders.unified` MCP tool that returns both sides under a discriminated union — or just clearer cross-tool recipes in `docs/agents/workflows.md`.
|
||||
|
||||
#### 4.2 Server Actions ↔ REST surface split
|
||||
|
||||
Only one page (`/settings/ai`) currently uses Next.js Server Actions directly. The AGENTS.md `data-layer-discipline` rule says new endpoints belong in `/api/v1/*`. The pattern is healthy; the existing inventory confirms it's not being abused. No action needed.
|
||||
|
||||
#### 4.3 Auth model split
|
||||
|
||||
Three auth surfaces are in play:
|
||||
|
||||
- `/api/v1/*` — `assertAgentAuth` (bearer `AGENT_API_KEY`) OR `assertBrowserSession` (PB cookie)
|
||||
- `/api/mcp` — JSON-RPC, bearer `AGENT_API_KEY`, CORS `*`
|
||||
- Server Actions — PB cookie session via `(app)` layout
|
||||
|
||||
The audit (`docs/audit/security-perf-prod.md` Major #2) flags the bare `AGENT_API_KEY` as too weak + `/api/mcp` CORS `*`. When those are remediated, the MCP auth boundary should mirror the new contract.
|
||||
|
||||
#### 4.4 Streaming response types
|
||||
|
||||
`files.fetch` returns base64. PDF routes stream bytes. Delivery note PDF, stocktake PDF, picking slip, and PO PDF stream bytes via `NextResponse`. If the MCP layer is to drive document previews end-to-end, it needs a `files.stream` (see §1.15) that wraps the streaming routes.
|
||||
|
||||
#### 4.5 Invoicing flow architecture
|
||||
|
||||
The QuickBooks invoice pipeline runs through n8n, not `/api/v1`. Adding MCP coverage requires both a REST endpoint (`/api/v1/invoicing/qb-invoice`) and a service layer (`qb-invoice-service.ts`) that wraps the n8n webhook contract. This is the only domain where the agent has zero programmatic access to a daily operator workflow. Highest architectural priority.
|
||||
|
||||
## Recommended implementation slices
|
||||
|
||||
Slice these as separate tasks for the team-lead to assign. Each slice is sized to a single PR and ships behind a feature flag if desired.
|
||||
|
||||
| # | Slice | Severity | Effort | Tools added |
|
||||
|---|---|---|---|---|
|
||||
| S1 | Quote approval + archive MCP | now | small | 2 |
|
||||
| S2 | Order lookup MCP + item split MCP | now | small | 2 |
|
||||
| S3 | Collection map + bulk assign MCP | now | small | 2 |
|
||||
| S4 | `/deliveries` MCP domain | now | medium | 5 |
|
||||
| S5 | `order_items.intel` + `quote_items.intel` MCP | now | small | 3 |
|
||||
| S6 | `repairs.*` MCP domain | now | medium | 7 |
|
||||
| S7 | `delivery_notes.{get, pdf, signed_copy}` MCP | now | small | 3 |
|
||||
| S8 | Customer PO creation MCP + 5 PDF MCP wrappers | now | medium | 6 |
|
||||
| S9 | `pricelist.*` + `raw_price_list.*` MCP | now | medium | 7 |
|
||||
| S10 | QuickBooks invoice MCP + service layer | now | large | 1 |
|
||||
| S11 | MCP `route.ts` per-tool file split | major | large | refactor |
|
||||
| S12 | Zod-schema sharing (MCP ↔ REST) | major | medium | refactor |
|
||||
| S13 | Migrate remaining 33 `fetch + if (!res.ok)` blocks to `fetchUpstreamJson` | major | medium | refactor |
|
||||
| S14 | `categories.*` MCP | nice | small | 5 |
|
||||
| S15 | `boss_dashboard.*` MCP + `dashboard.quotes`/`dashboard.orders` | nice | small | 4 |
|
||||
| S16 | `files.stream` MCP | nice | small | 1 |
|
||||
| S17 | Long-tail single-route MCP wrappers (§2 list) | nice | medium | ~30 |
|
||||
| S18 | Per-field widget route cleanup (§3.4) | nice | small | refactor |
|
||||
| S19 | `stock_quick_draft.{drafts, edit_line, supplier_history}` MCP | nice | small | 3 |
|
||||
| S20 | `health.check` MCP + `ai_insights.list` MCP + `users.list` MCP | nice | small | 3 |
|
||||
|
||||
Total new tools if every slice ships: ~85 (S11/S12/S13/S18 are refactors, not additions). Brings the catalogue to ~225 tools.
|
||||
|
||||
## Cross-references to existing FEATURE-REQUESTS.md
|
||||
|
||||
This analysis **does not duplicate** the entries already tracked in `src/app/api/mcp/FEATURE-REQUESTS.md`. Open entries that this doc references but doesn't re-list:
|
||||
|
||||
- "Order items: clarify received" (Phase H partial, still open)
|
||||
- "Order items: detect items missing selling_price" (Phase H partial)
|
||||
- "Stock purchases: paperclip badge" (UI-only, now)
|
||||
- "Drivers: no MCP visibility or assignment" (Phase E partial — list_stops + update_stop_status shipped; `drivers.list` still missing, see §1.16)
|
||||
- "Suppliers: read/update works, geocoding does not" (still open)
|
||||
- "Stock purchases: PDF → stock order ingestion" (still open)
|
||||
- "Stock purchases: paperclip badge on list" (UI-only)
|
||||
- "Standard error envelope with hint / next_actions / allowed_tools" (Phase I partial — 13 of 46 tools migrated; see §3.3)
|
||||
- "Quotes: trim/normalise customer + company names on quotes.create" (still open)
|
||||
- "Quotes: pre-send validation errors vs SMTP failures don't share an envelope" (Phase F partial — `no_pdf` diagnostics shipped)
|
||||
- "Orders: incoming email recipient resolution" (still open)
|
||||
- "Customers: case-insensitive email match" (still open)
|
||||
- "quotes.send_email.attach_auth_letters per-letterhead semantics undocumented" (still open)
|
||||
- "quote_items.create vs quote_items.update whitelist drift" (Phase F.2 partial — still see §3.2)
|
||||
- "Hermes MCP origin config" (doc-only, still open)
|
||||
- "Bulk_update_quote_items schema" (related to §3.2)
|
||||
|
||||
New entries to append (these came out of this audit):
|
||||
|
||||
- **§1.4: Quotes append-reply REST is on the wrong route** — add `POST /api/v1/quotes/append-reply`, keep back-compat alias.
|
||||
- **§1.8: Repairs MCP domain entirely missing** — 7 tools + 1 service layer extension.
|
||||
- **§1.13: Pricelist / raw_price_list MCP domain entirely missing** — 7 tools + REST routes.
|
||||
- **§1.14: Categories MCP domain entirely missing** — 5 tools + REST routes.
|
||||
- **§1.17: QuickBooks invoice MCP + service layer missing** — architectural; needs `qb-invoice-service.ts`.
|
||||
|
||||
## Open questions for the team-lead
|
||||
|
||||
1. **Scope of slice S10 (QuickBooks invoice MCP):** is the goal to mirror the n8n webhook in /api/v1 and call n8n from there, or to call the QB API directly? The current architecture routes through n8n for retries + audit; a direct QB integration would lose those.
|
||||
2. **Scope of slice S11 (MCP route split):** is per-tool the target, or per-domain-folder? Per-tool gives 142 files; per-domain gives ~30 files each with 1-many tools. The approved backend-data.md audit calls for per-tool; per-domain is the practical mid-point.
|
||||
3. **Auth: does the new MCP auth model (currently being designed for the security-perf-prod remediation) require an explicit `allowed_tools` ACL per agent?** If yes, that changes the per-tool file shape (each tool needs an `allowedRoles` manifest).
|
||||
4. **Pricing model for the MCP "anything in the app" promise:** today the MCP is bearer-key-only. If the user wants the agent to drive every operator workflow, should the MCP accept the same PB cookie session as the UI? That would let `/api/mcp` proxy operator context for audit, instead of `AGENT_API_KEY` (which has no operator identity).
|
||||
|
|
@ -1,783 +0,0 @@
|
|||
# Backend / API / Data Layer Audit
|
||||
|
||||
**Scope:** `src/lib/pocketbase.ts`, `src/lib/pb-helpers.ts` (legacy),
|
||||
`src/lib/server/*-service.ts` (current layer), `src/app/api/v1/*` route
|
||||
handlers, `/api/mcp`, and the route/service contract between them.
|
||||
|
||||
**Date:** 2026-08-28
|
||||
**Author:** bob (audit only — no code changes shipped)
|
||||
|
||||
This audit is the data-layer companion to `security-perf-prod.md`
|
||||
(tom, 2026-08-28). It deliberately does not re-cover the auth/secrets
|
||||
surface — see that doc for the critical AGENT_API_KEY / PB admin / CORS
|
||||
issues. Here we focus on:
|
||||
|
||||
- legacy `pb-helpers.ts` vs current `src/lib/server/*.service.ts` drift
|
||||
- PB filter / query foot-guns (N+1, getFirstListItem, IN-vs-OR)
|
||||
- schema-missing fallback pattern and select-field pitfalls
|
||||
- input validation, error envelope, and route-handler boilerplate
|
||||
- multi-write consistency (no transactions, partial-success semantics)
|
||||
- service-layer API inconsistencies (admin PB vs cookie PB)
|
||||
|
||||
---
|
||||
|
||||
## 0. Headline summary
|
||||
|
||||
| Severity | Count | What it is |
|
||||
| -------- | ----- | ---------- |
|
||||
| Critical | 3 | Legacy `pb-helpers.ts` used directly by 32 files; schema-field gaps invisible to the type system; `pb-helpers.ts` PO/quote/order number generators are racy and were deprecated but still callable |
|
||||
| Major | 12 | No schema-validation library; 30+ `getFirstListItem` calls with inconsistent error handling; N+1 patterns in several services; `getAdminPocketBase` everywhere bypasses PB listRules; 7077-line MCP route file; schema-missing fallback duplicated 4× with subtle behavioural drift; cookie-auth PB singleton used by Server Components |
|
||||
| Minor | 10 | Inconsistent error envelopes; ad-hoc PO/quote-status string-literal duplication; search hard-caps hardcoded; legacy client-side duplicate-detection post-filter; no structured logging; service-layer returns vary between throwing and returning partial-success |
|
||||
|
||||
The legacy layer (`pb-helpers.ts`) is the single biggest source of
|
||||
drift. AGENTS.md says "do NOT extend `pb-helpers.ts`", and the service
|
||||
layer is the right place for new endpoints — but 32 callers still hit
|
||||
it directly, and the data shape they consume is subtly different from
|
||||
what the service layer emits. Migrating page-by-page is safe; the
|
||||
hazard is doing it inconsistently.
|
||||
|
||||
---
|
||||
|
||||
## 1. Critical findings
|
||||
|
||||
### 1.1 `pb-helpers.ts` PO/quote/order number generators are racy, still exported
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:1755-1772` (`generateQuoteNumber`)
|
||||
**File:** `src/lib/pb-helpers.ts:1773-1796` (`generateOrderNumber`)
|
||||
**File:** `src/lib/pb-helpers.ts:1799-1864` (`generatePONumber`)
|
||||
|
||||
All three functions use the read-then-write pattern:
|
||||
|
||||
```ts
|
||||
const existing = await pb.collection("orders").getList<Order>(1, 1, {
|
||||
filter: `order_number ~ "${prefix}"`,
|
||||
sort: "-order_number",
|
||||
});
|
||||
let seq = ... + 1; // <- race window
|
||||
return `ORD-${...}-${seq}`;
|
||||
```
|
||||
|
||||
Two simultaneous calls both observe `007` and both write `008`. The
|
||||
PB-side atomic counter collection (`order_counters`, `quote_counters`)
|
||||
already exists and is used by `orders-service.ts:1302-1325` and
|
||||
`quotes-service.ts:879-909`. AGENTS.md explicitly deprecates these
|
||||
helpers, but they're still exported and reachable:
|
||||
|
||||
- `generateQuoteNumber` — `@deprecated` comment at line 1748, but
|
||||
nothing prevents a new caller from importing it.
|
||||
- `generateOrderNumber` — no deprecation marker; no callers found
|
||||
via grep on the codebase, but still public.
|
||||
- `generatePONumber` — inline comment at line 1745 calls it a
|
||||
"client-side mirror" of `src/lib/server/po-number.ts`. Both exist;
|
||||
the client-side one is racy.
|
||||
|
||||
**Recommended fix:** delete `generateQuoteNumber`, `generateOrderNumber`,
|
||||
and `generatePONumber` from `pb-helpers.ts` (or move to a `legacy`
|
||||
sub-export with a build-time ban) and add a CI lint rule that fails
|
||||
if any non-`pb-helpers.ts` file imports them. Server-side callers
|
||||
already use the atomic variants in `orders-service.ts` /
|
||||
`quotes-service.ts` / `po-number.ts`.
|
||||
|
||||
### 1.2 Legacy `pb-helpers.ts` is the data layer for 32 files, drift risk
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:1-1994`
|
||||
**File:** 32 files import from `@/lib/pb-helpers` (verified
|
||||
2026-08-28 via `grep -rl 'from "@/lib/pb-helpers"' src/ | wc -l`)
|
||||
|
||||
AGENTS.md says new code goes through `src/lib/server/*.service.ts`.
|
||||
The legacy helpers are still imported by Server Components,
|
||||
Client Components, and form components directly. The two layers read
|
||||
the same PB collections but emit subtly different shapes:
|
||||
|
||||
| Helper | Service-layer equivalent |
|
||||
| ------ | ------------------------ |
|
||||
| `getCustomer` / `getAllCustomers` (`pb-helpers.ts:32-46`) | `customers-service.ts` |
|
||||
| `getCompanies` / `getCompany` / `getAllCompanies` (`pb-helpers.ts:48-67`) | not yet migrated |
|
||||
| `getSuppliers` / `getSupplier` / `getAllSuppliers` (`pb-helpers.ts:69-87`) | not yet migrated |
|
||||
| `getQuotes` (`pb-helpers.ts:213-247`) | `quotes-service.ts` |
|
||||
| `getOrders` / `getOrder` / `getOrdersWithItems` (`pb-helpers.ts:617-635`, `:729-790`) | `orders-service.ts` |
|
||||
| `createOrderFromQuote` (`pb-helpers.ts:1231...`) | `orders-service.createOrderFromQuote` |
|
||||
| `getDeliveryNotes` (`pb-helpers.ts:1412-1420`) | `delivery-notes-service.ts` |
|
||||
|
||||
The risk is real and visible: `getQuotes` in `pb-helpers.ts:213` has
|
||||
the `deliveryNoteOnly` option for the Quotes page delivery-note tab,
|
||||
while `quotes-service.ts` re-implements the same query with a
|
||||
different filter order. A page that switches from the helper to the
|
||||
service changes the wire payload, which changes the page's render.
|
||||
|
||||
**Recommended fix:** per AGENTS.md, migrate page-by-page. The
|
||||
highest-risk migrations first (orders, quotes) because they emit
|
||||
shapes that already drift from the service. Add a `console.warn`
|
||||
to the legacy helpers that fires once per session if `NODE_ENV !==
|
||||
'test'` and the call comes from a Server Component — the breadcrumb
|
||||
will surface un-migrated callers in the browser console.
|
||||
|
||||
### 1.3 `pocketbase.ts` browser singleton auth pattern doesn't translate to Server Components
|
||||
|
||||
**File:** `src/lib/pocketbase.ts:9-45`
|
||||
|
||||
```ts
|
||||
let pb: PocketBase | null = null;
|
||||
export function getPocketBase(): PocketBase {
|
||||
if (typeof window === "undefined") {
|
||||
// Server-side: create new instance each time
|
||||
return new PocketBase(POCKETBASE_URL);
|
||||
}
|
||||
// Client-side: reuse singleton — load auth from localStorage
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Two problems:
|
||||
|
||||
1. The server-side path always creates an unauthenticated instance.
|
||||
Server Components that import `getPocketBase` make PB calls with
|
||||
the `_superusers` collection bypass or no auth at all, depending
|
||||
on PB rule configuration. `getPocketBase` itself can't impersonate
|
||||
the user — there's no cookie / header to read on the server. See
|
||||
`dev-identity.ts` for the project's solution (a cookie called
|
||||
`wfdash_dev_email` that holds the user's email as the dev
|
||||
placeholder).
|
||||
|
||||
2. The client-side singleton uses `localStorage` for the auth store
|
||||
(`pocketbase.ts:23-30`). This is fine for browser use, but it
|
||||
couples the data layer to the browser. Any attempt to share a
|
||||
helper between Server and Client Components requires a wrapper
|
||||
(the service layer does this via `getAdminPocketBase()`).
|
||||
|
||||
**Recommended fix:** make `getPocketBase` server-side UNSAFE (it
|
||||
should throw if `typeof window === "undefined"`) and force all
|
||||
server-side callers through `getAdminPocketBase`. Document the rule
|
||||
in AGENTS.md. Today, server components using `getCustomer(id)` from
|
||||
`pb-helpers.ts:32` are silently making unauthenticated PB calls —
|
||||
PB's per-collection `listRule`/`viewRule` triages this, but a future
|
||||
rule relaxation will silently expose data.
|
||||
|
||||
---
|
||||
|
||||
## 2. Major findings
|
||||
|
||||
### 2.1 No schema-validation library (no zod / yup / valibot / joi / ajv)
|
||||
|
||||
**File:** every `src/app/api/v1/**/route.ts` that calls `req.json()` (57 routes)
|
||||
|
||||
Input validation is entirely ad-hoc. Each route hand-rolls its
|
||||
required-field checks:
|
||||
|
||||
```ts
|
||||
// customers/route.ts:67-69
|
||||
if (!body?.customer) {
|
||||
return NextResponse.json({ error: "customer is required" }, { status: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
Verification: `grep -i 'zod\|yup\|valibot\|joi\|ajv' package.json`
|
||||
returns 0 hits. The cost is real:
|
||||
|
||||
- **No type safety for body shapes** — TypeScript sees the body as
|
||||
`any` after `await req.json()`. The next line that reads
|
||||
`body.customer` will compile even if `customer` is missing.
|
||||
- **Inconsistent error messages** — some routes say
|
||||
`"item_description is required."`, others say
|
||||
`"customer is required"`, others say
|
||||
`"Invalid JSON body"`. The MCP layer (`mcp/route.ts`) has a
|
||||
bespoke `QUOTE_ITEM_PATCH_FIELDS` allowlist (line 161-176), and
|
||||
the v1 layer has no equivalent on most endpoints.
|
||||
- **No coercion** — string `"123"` and number `123` aren't
|
||||
normalised. Many service-layer functions wrap with `Number(...)`
|
||||
defensively (`pb-helpers.ts:1255-1262`), but a route handler that
|
||||
passes `body.cost_price` straight into a `pb.update(...)` will
|
||||
write a string into a number field.
|
||||
- **No max-size enforcement** — the only size cap is the
|
||||
multipart file limit (25 MB on the `mcp.add_attachment` tool,
|
||||
not on JSON bodies). A 50 MB JSON body is parsed in full before
|
||||
the handler can 413.
|
||||
|
||||
**Recommended fix:** add `zod` to package.json (small, no deps,
|
||||
TS-native), write one `parseBody(req, schema)` helper that lives in
|
||||
`src/lib/server/http-helpers.ts`, and convert routes incrementally.
|
||||
The 7 highest-value routes first (write endpoints that mutate PB):
|
||||
customers, suppliers, companies, orders, order_items, quotes,
|
||||
quote_items. The MCP route already enforces its own per-tool schema
|
||||
via JSON Schema (not zod), so leave it alone.
|
||||
|
||||
### 2.2 `getFirstListItem` throws on miss — 30+ inconsistent handlers
|
||||
|
||||
**File:** 31 call sites in `src/lib/server/` and `src/app/api/v1/`
|
||||
|
||||
PB's `getFirstListItem(filter)` throws a 404 if the filter matches
|
||||
zero rows. Half the call sites defensively catch, half don't. Examples:
|
||||
|
||||
- `orders-service.ts:1404` — `getFirstListItem(`year=${year} && month=${month}`)` is inside a try, catches via fallback (line 1411).
|
||||
- `orders-service.ts:2574` — `getFirstListItem<Order>(`order_number = "${esc}"`)` is inside its own try/catch, returns `null` on 404 (line 2580).
|
||||
- `management-service.ts:247` — `getFirstListItem(`company = "${escaped}"`)` returns the result (or rejects promise); the caller at line 245 wraps it in `.catch(() => null)` — but the path between line 240 and 247 doesn't catch.
|
||||
- `management-service.ts:232-236` — two `.catch(() => null)` chains on `users` lookups, OK.
|
||||
|
||||
A non-defensive call site bubbles a 500 to the route handler, which
|
||||
returns a generic `{error: message}` with the PB 404 stack trace
|
||||
included. This is loud in logs but confusing to the operator — they
|
||||
typed a real order number and got "404 not found" wrapped as a
|
||||
500-class error.
|
||||
|
||||
**Recommended fix:** wrap once at the service boundary with a
|
||||
`firstOrNull<T>(collection, filter)` helper. Every service should
|
||||
call `firstOrNull` and let the route handler decide whether "no rows
|
||||
matched" is a 404, 422, or a `null` field in a successful response.
|
||||
This is the same pattern `findOrderByOrderNumber` uses at
|
||||
`orders-service.ts:2573-2588` — apply it everywhere.
|
||||
|
||||
### 2.3 `getAdminPocketBase` everywhere bypasses PB listRules / field-level perms
|
||||
|
||||
**File:** every service-layer file (43 import sites)
|
||||
|
||||
The admin PB instance has superuser rights — every listRule, viewRule,
|
||||
and modifyRule is bypassed. This means:
|
||||
|
||||
- **Data classification doesn't apply.** A future "sensitive
|
||||
customer" field added to `customers` with `viewRule: ""` (no
|
||||
public read) would still be readable through every v1 route, the
|
||||
MCP route, and the legacy helpers.
|
||||
- **Field-level visibility doesn't apply.** Same shape: a
|
||||
`salary_band` field on `users` with a restrictive viewRule would
|
||||
be returned by every service that expands `users`.
|
||||
- **Audit trail is lost.** Superuser actions log as
|
||||
`_superusers` in PB's audit log, not as the operator who
|
||||
actually made the call. The `getServerIdentity(req)` helper
|
||||
(`dev-identity.ts:42-58`) is used to record the operator on
|
||||
specific fields, but the PB audit row still says `_superusers`.
|
||||
|
||||
This is a known design decision, not a bug — the rationale (server
|
||||
scripts, n8n, AI agents that can't carry the user's JWT) is documented
|
||||
in `management-service.ts:1-25`. The risk is that the rationale
|
||||
becomes invisible as more routes get added.
|
||||
|
||||
**Recommended fix:** add a comment to `pocketbase-admin.ts:62-72`
|
||||
warning that the admin client bypasses ALL PB rules, and require a
|
||||
JSDoc on every new service file that explains why admin is required.
|
||||
For browser-facing reads (`customers/list/route.ts:30`), keep using
|
||||
`assertBrowserSession` + the cookie PB (or migrate to the user-PB
|
||||
instance). The `assertAgentAuth` / `assertBrowserSession` split is
|
||||
correct as designed — the issue is when a *third* auth tier creeps
|
||||
in (e.g. an `assertServiceAuth` for cron jobs) and the proof of
|
||||
which tier a route uses gets buried in the route body.
|
||||
|
||||
### 2.4 N+1 patterns in service-layer loops
|
||||
|
||||
**File:** `src/lib/server/orders-service.ts:2174` (per-item patch loop inside standalone order create)
|
||||
**File:** `src/lib/server/orders-service.ts:2503-2506` (`cancelOrder` per-item `item_status: "cancelled"` update loop)
|
||||
**File:** `src/lib/server/quotes-service.ts` (multiple per-item update loops)
|
||||
**File:** `src/lib/server/buyer-tasks-service.ts:115-167` (per-tab fetch × N tabs)
|
||||
|
||||
Examples:
|
||||
|
||||
```ts
|
||||
// orders-service.ts:2174 (standalone order create flow)
|
||||
for (let i = 0; i < itemsResult.created.length; i++) {
|
||||
const created = itemsResult.created[i];
|
||||
const original = input.items[i];
|
||||
if (!original) continue;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (original.sku !== undefined) patch.sku = original.sku;
|
||||
if (original.unit !== undefined) patch.unit = original.unit;
|
||||
if (original.supplier_id !== undefined) patch.supplier = original.supplier_id || null;
|
||||
if (original.notes !== undefined) patch.notes = original.notes;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
try {
|
||||
await pb.collection(orderItemsCollection).update(created.id, patch);
|
||||
} catch (err) {
|
||||
// ... per-row failure recorded, loop continues
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`createOrderItems` returns N rows; this loop fires N PB round-trips
|
||||
to patch the secondary fields (sku, unit, supplier_id, notes). At
|
||||
the WFDASH fleet scale (≤30 items per order) it's tolerable, but the
|
||||
helper comment immediately above the loop admits this. Better
|
||||
shape: pass the optional secondary fields into `createOrderItems` as
|
||||
a parallel array, so the helper writes them in the same `create`
|
||||
call. PB supports fields on create.
|
||||
|
||||
`cancelOrder` (`orders-service.ts:2503-2506`, the
|
||||
`for (const item of toCancel) { await pb.collection(...).update(item.id, { item_status: "cancelled" }); }`
|
||||
block) is the same shape: one PB write per cancelled item. The
|
||||
surrounding `cancelOrdersBulk` (`orders-service.ts:2523-2535`) is a
|
||||
*separate* loop at the order level — it iterates `orderIds` and
|
||||
calls `cancelOrder` per order; the per-item fan-out lives inside
|
||||
`cancelOrder` itself.
|
||||
|
||||
`buyer-tasks-service.ts:115` opens with
|
||||
`Promise.all`-batched fetches for supplementary data, but the
|
||||
surrounding loops are per-item on the response side.
|
||||
|
||||
**Recommended fix:** before each loop, ask "can I do this with one
|
||||
PB call?" Specific wins:
|
||||
|
||||
- `orders-service.ts:2174` — fold secondary fields into the
|
||||
`createOrderItems` call so we don't need a follow-up patch.
|
||||
- `cancelOrder` — collect all eligible items and use PB's batch
|
||||
create endpoint (or a single `update` with a different verb).
|
||||
PB 0.22+ supports transactional batch via `pb.send()`.
|
||||
- `buyer-tasks-service.ts` — the per-tab fan-out is intentional
|
||||
and correct; the per-item post-processing inside each tab is the
|
||||
N+1 target.
|
||||
|
||||
### 2.5 No transactional writes across collections
|
||||
|
||||
**File:** every service that mutates >1 PB row in one operation
|
||||
|
||||
A representative example: `pb-helpers.ts:1264-1287`
|
||||
(`createOrderFromQuote`) creates the `orders` row, then loops
|
||||
`order_items` creates. If the network drops between the first
|
||||
`order_items.create` and the last, the order exists with N items
|
||||
where N is whatever the network reached. The transaction equivalent
|
||||
would be PB's `pb.send()` with a chain of ops, or a manual
|
||||
compensating-rollback helper.
|
||||
|
||||
The current mitigation is "log loudly and let the operator clean up
|
||||
manually". This is fine at WFDASH fleet scale, but the pattern
|
||||
scales badly — and the `OrderNumberCollisionError` flow at
|
||||
`orders-service.ts:2145-2198` shows the cost of catching a partial-
|
||||
failure state and rolling forward.
|
||||
|
||||
**Recommended fix:** two changes, in priority order:
|
||||
|
||||
1. Document the partial-success semantics in each multi-write
|
||||
service's JSDoc, with an explicit list of which steps are
|
||||
idempotent (safe to re-run) and which aren't. Today this
|
||||
knowledge lives in operator tribal memory.
|
||||
2. For the highest-value multi-writes (order creation, PO creation,
|
||||
delivery note creation, quote split), add a `try/catch` that
|
||||
deletes / rolls-back the partial state on failure. The PB SDK's
|
||||
`pb.send()` batch endpoint supports this natively.
|
||||
|
||||
### 2.6 MCP route handler is a single 7077-line file
|
||||
|
||||
**File:** `src/app/api/mcp/route.ts:1-7077`
|
||||
|
||||
Everything — the CORS helper, JSON-RPC envelope, upstream-fetch
|
||||
helper, schema validation, 50+ tool definitions, 50+ tool
|
||||
handlers — lives in one file. Tool definitions alone occupy
|
||||
lines 270-1500 (~1230 lines of `tools: [{ name: ..., inputSchema: ...}]`).
|
||||
Tool handlers are interleaved with definitions rather than grouped,
|
||||
so changing one tool requires scanning hundreds of lines.
|
||||
|
||||
This is a known single-file pattern (the existing audit covers it),
|
||||
but the data-layer perspective adds:
|
||||
|
||||
- **No way to mock individual tools for tests.** The MCP route has
|
||||
tests at `route.test.ts` and `po-agent-workflow.test.ts`, but
|
||||
both test the route shape end-to-end. Service-layer splits would
|
||||
let each tool be tested in isolation (the underlying services
|
||||
already have unit tests).
|
||||
- **Tool input schemas are written by hand** instead of using the
|
||||
same `zod` schema as the route handlers (see 2.1). The MCP layer
|
||||
could share schemas via `zod-to-json-schema` and the v1 routes.
|
||||
- **Adding a new tool is a 30-minute scroll-and-edit.** A
|
||||
`tools/<name>.ts` per-tool structure would make `git diff` readable
|
||||
and reduce merge conflicts when multiple devs add tools.
|
||||
|
||||
**Recommended fix:** extract each tool's `inputSchema` and handler
|
||||
into `src/lib/server/mcp-tools/<name>.ts`. The `tools` array becomes
|
||||
`Object.entries(MCP_TOOLS).map(...)`. JSON-RPC dispatch stays in
|
||||
`route.ts`. Risk: low — the file is a leaf in the import graph.
|
||||
|
||||
### 2.7 Schema-missing fallback pattern duplicated 4× with subtle drift
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:128-185` (`getQuotes`)
|
||||
**File:** `src/lib/pb-helpers.ts:204-228` (`getQuoteAttachments`)
|
||||
**File:** `src/lib/pb-helpers.ts:230-258` (`getQuoteStatusCounts`)
|
||||
**File:** `src/lib/pb-helpers.ts:188-202` (`getTemplates` / `getFollowUpDueQuotes`)
|
||||
|
||||
All four use the same `is400(err)` + `warnSchemaMissing` pattern:
|
||||
|
||||
```ts
|
||||
function is400(err: unknown): boolean { ... }
|
||||
let warnedSchemaMissing = false;
|
||||
function warnSchemaMissing(what: string) {
|
||||
if (warnedSchemaMissing) return; // <- only warns ONCE for the whole process
|
||||
warnedSchemaMissing = true;
|
||||
console.warn(`[pb-helpers] ... (${what}). Import pb_schema to enable the new filter.`);
|
||||
}
|
||||
```
|
||||
|
||||
Three issues:
|
||||
|
||||
1. **`warnedSchemaMissing` is a module-level boolean**, so the warn
|
||||
fires once per process. After the first fallback, the operator
|
||||
never sees another "field missing" warning even if a different
|
||||
collection is missing a different field. The "what" argument is
|
||||
silently dropped.
|
||||
2. **`is400` only checks `err.status === 400`**, but PB can also
|
||||
return 400 with a different error shape (the new PB versions
|
||||
embed a JSON `data` payload). The check misses anything that
|
||||
isn't a literal `{status: 400}` object.
|
||||
3. **The fallback filters may compile but return wrong results.**
|
||||
`getQuotes` drops the `is_template != true` clause on fallback
|
||||
(line 175), so a quote marked as template silently appears in
|
||||
the New tab. `getQuoteStatusCounts` keeps the per-status queries
|
||||
but loses the templates-tab exclusion (line 263).
|
||||
|
||||
The 2026-07-22 PB schema import project (mentioned in AGENTS.md) is
|
||||
the path that resolves this; until then, the fallbacks should at
|
||||
minimum warn with the `what` argument.
|
||||
|
||||
**Recommended fix:** replace the module-level latch with a `Set<string>`
|
||||
that tracks warned fields, and surface the warning to a UI
|
||||
diagnostic (a banner on the operator dashboard, not a `console.warn`).
|
||||
If a route's fallback returns wrong data, that should surface in
|
||||
QA tests — write tests that assert "templates don't appear in the
|
||||
New tab when `is_template` is missing".
|
||||
|
||||
### 2.8 Service-layer error envelopes vary between routes
|
||||
|
||||
**File:** every `src/app/api/v1/**/route.ts` that returns JSON
|
||||
|
||||
Three observed shapes:
|
||||
|
||||
```ts
|
||||
// Shape A — most routes
|
||||
{ error: "message" }
|
||||
|
||||
// Shape B — quote pdf route
|
||||
{ error: "PDF render failed", detail: "..." }
|
||||
|
||||
// Shape C — quote pdf validation
|
||||
{ error: "items not ready", code: "items_not_ready", details: { itemIds: [...], issues: [...] } }
|
||||
|
||||
// Shape D — MCP route (JSON-RPC)
|
||||
{ jsonrpc: "2.0", id: ..., error: { code: -32010, message: "...", data: {...} } }
|
||||
```
|
||||
|
||||
The route handler picks whichever envelope the original author
|
||||
chose, with no consistent shape across endpoints. The MCP layer's
|
||||
JSON-RPC envelope is correct per spec but uses error codes that
|
||||
don't map to HTTP status (the agent has to learn the mapping from
|
||||
the hint strings in `TOOL_HINTS` at `mcp/route.ts:271-450`).
|
||||
|
||||
The v1 route handlers also return inconsistent HTTP statuses for
|
||||
similar failures:
|
||||
|
||||
- `customers/route.ts:78` returns 400 with `{error: "customer is required"}`.
|
||||
- `customers/[id]/route.ts:48` returns 400 with `{error: "JSON object body required"}`.
|
||||
- `quotes/[id]/pdf/route.ts:165-168` returns 422 with a structured body.
|
||||
- `quote-items/route.ts` (need to read) returns 500 on validation failures (no dedicated check).
|
||||
|
||||
**Recommended fix:** standardise on:
|
||||
|
||||
```ts
|
||||
{ error: { code: "string_enum", message: "human-readable", details?: object } }
|
||||
```
|
||||
|
||||
with a top-level helper `err(code, message, status, details?)`. The
|
||||
MCP layer keeps its JSON-RPC envelope (it's a different protocol)
|
||||
but maps to the same codes internally.
|
||||
|
||||
### 2.9 Search hard-caps silently truncate results
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:1066` (`SEARCH_HARD_CAP = 500`)
|
||||
**File:** `src/lib/server/orders-service.ts:1075` (search caps in service layer, separate value)
|
||||
**File:** `src/lib/pb-helpers.ts:431-453` (status counts — 22 parallel queries)
|
||||
|
||||
`SEARCH_HARD_CAP = 500` caps `searchOrdersWithItems` at 500 results.
|
||||
Past 500, the result is silently truncated — no warning to the
|
||||
operator that there are more matches.
|
||||
|
||||
The status counts (`getQuoteStatusCounts` at `pb-helpers.ts:230`)
|
||||
fires 22 parallel `getList(1, 1, ...)` queries, one per PB status.
|
||||
With no `quotes.status` index on the live PB, each query is a full
|
||||
scan over ~8.5k quotes = ~2.6s wall time per the inline comment.
|
||||
The service-layer replacement (`quotes-service.ts`) collapses this
|
||||
to 1 scan.
|
||||
|
||||
**Recommended fix:** when a search hits the hard cap, return
|
||||
`{ items, truncated: true, totalApprox: "≥ 500" }` so the UI can
|
||||
render "showing 500 of 500+, refine your query". Migrate the status
|
||||
counts through the service layer's collapsed-counts implementation.
|
||||
|
||||
### 2.10 `pb-helpers.ts` duplicate-detection post-filter is fine but verbose
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:1889-1953` (`findNameMatches`, `findCompanyDuplicates`, etc.)
|
||||
|
||||
PB's `~` operator does case-insensitive substring match; for exact
|
||||
case-insensitive equality, the helper does:
|
||||
|
||||
```ts
|
||||
const result = await pb.collection(collectionName).getList<T>(1, limit, {
|
||||
filter: `${field} ~ "${escapeDup(trimmed)}"`,
|
||||
});
|
||||
return result.items.filter((row) => normDup(row[field]) === target);
|
||||
```
|
||||
|
||||
This works at WFDASH scale but has two limitations:
|
||||
|
||||
- `(field) ~ "value"` is still substring, so a search for "Acme"
|
||||
returns "Acme Limited" AND "New Acme Holdings" both. The
|
||||
post-filter narrows to exact, but the wire payload already
|
||||
included the unwanted rows.
|
||||
- The helper calls `pb.collection(...).getList` rather than the
|
||||
service-layer wrapper, so the auth context drifts (cookie PB vs
|
||||
admin PB depending on caller).
|
||||
|
||||
**Recommended fix:** migrate `findCompanyDuplicates` /
|
||||
`findSupplierDuplicates` / `findCustomerDuplicates` into
|
||||
`management-service.ts` so they use admin PB (consistent with the
|
||||
CRUD paths), and use PB's `=` with a `lower()` equivalent. PB 0.26
|
||||
doesn't have `lower()`, so the post-filter is unavoidable, but the
|
||||
narrowed PB `filter` should be the strict substring with `~`, not
|
||||
a substring that includes everything.
|
||||
|
||||
### 2.11 Counter `last_seq+` increments — concurrent-edge case
|
||||
|
||||
**File:** `src/lib/server/orders-service.ts:1413-1426` (`getOrCreateOrderCounter`)
|
||||
**File:** `src/lib/server/quotes-service.ts:930-944` (mirror for quotes)
|
||||
|
||||
```ts
|
||||
try {
|
||||
return await pb.collection(ORDER_COUNTERS_COLLECTION)
|
||||
.getFirstListItem(`year=${year} && month=${month}`);
|
||||
} catch {
|
||||
// First request for this (year, month) — backfill from existing orders,
|
||||
// then create. Without backfill the new code would start at ORD-...-001
|
||||
// and collide with the existing sequence.
|
||||
const backfill = await getMaxOrderSequenceInMonth(pb, year, month);
|
||||
try {
|
||||
return await pb.collection(ORDER_COUNTERS_COLLECTION).create({
|
||||
year, month, last_seq: backfill,
|
||||
});
|
||||
} catch {
|
||||
// Concurrent caller beat us to it — re-fetch.
|
||||
return await pb.collection(ORDER_COUNTERS_COLLECTION)
|
||||
.getFirstListItem(`year=${year} && month=${month}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The third `try` (line 1421-1425) catches the concurrent-beat-us case
|
||||
but doesn't catch the underlying unique-constraint error inside the
|
||||
`.catch`. If the unique constraint fails AND the re-fetch also fails,
|
||||
the original error is swallowed.
|
||||
|
||||
More importantly: `getMaxOrderSequenceInMonth` does a
|
||||
`getFullList({ filter: order_number ~ "..." })`, which scans every
|
||||
order in the month. On a busy month (or for the orders of the
|
||||
month, not just the current one), this is the slow path of an
|
||||
otherwise O(1) counter increment.
|
||||
|
||||
**Recommended fix:** the third try should re-throw the original
|
||||
error if the re-fetch fails. The slow-path backfill can be a one-
|
||||
shot job run by ops, not a per-create step.
|
||||
|
||||
### 2.12 PO number generator `getOrCreate` lookup lacks fallback for race
|
||||
|
||||
**File:** `src/lib/server/po-number.ts:1-...` (mirror of `pb-helpers.ts:1799-1864`)
|
||||
|
||||
The server-side PO number generator lives at `src/lib/server/po-number.ts`
|
||||
(matching the deprecated client-side `generatePONumber` at
|
||||
`pb-helpers.ts:1799-1864`). Per the inline comment at
|
||||
`pb-helpers.ts:1745-1752`, they should mirror field-by-field.
|
||||
|
||||
Two issues:
|
||||
|
||||
1. The client-side helper at `pb-helpers.ts:1799-1864` post-filters
|
||||
the existing POs on the suffix (line 1840-1848). The server-side
|
||||
helper (read separately) presumably uses a more correct index hit.
|
||||
Verify the formats actually match — a divergence here means
|
||||
previews in the email composer don't match the persisted PO
|
||||
number.
|
||||
|
||||
2. Both helpers look up "all POs in the (year, month)" via
|
||||
`getList(1, 500)` and filter client-side. For a busy month this
|
||||
pulls 500 records per PO generation. At the WFDASH fleet scale
|
||||
it's tolerable but wasteful.
|
||||
|
||||
**Recommended fix:** read both side-by-side and assert the formats
|
||||
match byte-for-byte. Add a comment in `po-number.ts` pinning the
|
||||
contract: "client-side preview at `pb-helpers.ts:1799-1864` MUST
|
||||
produce the same string".
|
||||
|
||||
---
|
||||
|
||||
## 3. Minor findings
|
||||
|
||||
### 3.1 `deliveryNoteOnly` option on `getQuotes` is fragile
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:213-247` (`getQuotes` options parameter)
|
||||
|
||||
The `options: { deliveryNoteOnly?: boolean }` shape is the only
|
||||
optional options object on the legacy helper. Adding a second
|
||||
boolean later would require a breaking change to the type. Use a
|
||||
discriminated options object instead — `getQuotes(page, perPage, search, sort, statuses, options)`.
|
||||
|
||||
### 3.2 Cookie PB singleton persists auth across logout
|
||||
|
||||
**File:** `src/lib/pocketbase.ts:23-46`
|
||||
|
||||
When the user clicks logout, the operator's PB token is removed
|
||||
from `localStorage` (presumably — verify in the logout UI), but
|
||||
the singleton `pb` instance in `pocketbase.ts:9` is module-scoped
|
||||
and not cleared. The next `getPocketBase()` returns the same
|
||||
instance with the stale auth.
|
||||
|
||||
**Recommended fix:** add an exported `clearPocketBaseAuth()` that
|
||||
resets the singleton and clears the localStorage entry. Call it
|
||||
from the logout handler.
|
||||
|
||||
### 3.3 `assertBrowserSession` is duplicated inline in `quotes/items/rewrite/route.ts`
|
||||
|
||||
**File:** `src/app/api/v1/quotes/items/rewrite/route.ts:99-122`
|
||||
**File:** `src/lib/server/browser-session.ts:1-30`
|
||||
|
||||
The rewrite route has its own `assertBrowserSession` instead of
|
||||
importing the shared one. The two implementations are identical
|
||||
(`fetch ${pbUrl}/api/collections/users/auth-refresh` with a 5s
|
||||
timeout). Drift risk if one is updated and the other isn't.
|
||||
|
||||
**Recommended fix:** import from `@/lib/server/browser-session`.
|
||||
|
||||
### 3.4 `isPocketBaseId` regex is too lax (downgraded — minor)
|
||||
|
||||
**File:** `src/lib/server/management-service.ts:253-257`
|
||||
|
||||
```ts
|
||||
function isPocketBaseId(s: string): boolean {
|
||||
// PocketBase ids are 15 alphanumeric chars by default. We don't try
|
||||
// to be stricter than that — false positives just fall through to
|
||||
// the name lookup which is harmless.
|
||||
return /^[a-z0-9]{15}$/i.test(s);
|
||||
}
|
||||
```
|
||||
|
||||
PB's default id format is 15 alphanumeric chars, but admins can
|
||||
configure custom id formats. The regex matches any 15-char
|
||||
alphanumeric string (e.g. `"customerCustomer1234"` — not a real PB
|
||||
id). However, the actual call sites (`resolveCompanyRef` /
|
||||
`resolveUserRef` at `management-service.ts:206-237`) wrap the
|
||||
`getOne` in `try/catch` and **explicitly fall through to the name
|
||||
lookup** on any failure (literal comment: `// Fall through to name
|
||||
lookup.` / `// Fall through.`). So a 15-char string that doesn't
|
||||
resolve as an id correctly triggers the name-lookup fallback, and
|
||||
a string that *does* resolve as an id returns the real record.
|
||||
|
||||
The remaining (narrow) risk is a false positive that happens to
|
||||
collide with an unrelated record's real 15-char PB id — i.e. the
|
||||
operator types a company name like `"abc123def456ghi"` and it
|
||||
silently shadows a different real company. Probability is low
|
||||
(15-char alphanumeric is a ~10^27 space at 15 chars), but not
|
||||
zero, and the symptom is silent mis-routing. This is **minor**, not
|
||||
major.
|
||||
|
||||
**Severity:** Minor (downgraded from a prior draft that incorrectly
|
||||
described the fallback as missing — the fallback IS present).
|
||||
|
||||
**Recommended fix:** tighten the regex when PB exposes a stable id
|
||||
shape (e.g. include the `pre_` / custom-prefix configuration), or
|
||||
require a prefix hint on user-supplied input. For the time being,
|
||||
add a unit test that asserts both the fast-path and the fallback
|
||||
paths resolve to the same id when given an ambiguous 15-char input.
|
||||
|
||||
### 3.5 PB file proxy is publicly readable (per code comment)
|
||||
|
||||
**File:** `src/app/api/v1/quotes/[id]/pdf/route.ts:694-697`
|
||||
|
||||
```ts
|
||||
// PB's `/api/files/...` endpoint is publicly readable for these
|
||||
// records (no Authorization header required — the file collection
|
||||
// rule allows it). Stream the bytes through so the iframe can
|
||||
// render without exposing the storage URL directly.
|
||||
```
|
||||
|
||||
The comment admits that PB's per-file rule allows anonymous reads.
|
||||
The PDF route streams the bytes back through Next so the iframe
|
||||
gets the data via the same-origin response, but anyone with the
|
||||
file token can hit `${PB}/api/files/quote_attachments/${id}/${name}`
|
||||
directly. The `security-perf-prod.md` audit covers this.
|
||||
|
||||
**Recommended fix (data-layer specific):** add a PB rule that gates
|
||||
`/api/files/quote_attachments/*` on a valid auth token. Today,
|
||||
the application relies on the file token being unguessable — PB's
|
||||
token format is randomsuffixed so it's hard to enumerate, but the
|
||||
attachment-id IS in the URL.
|
||||
|
||||
### 3.6 Schema-mismatch silently degrades `quote_attachments` direction
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:540-585` (`getQuoteAttachments`)
|
||||
|
||||
The `direction` field on `quote_attachments` was added in a 2026-06-23
|
||||
migration. The fallback at `pb-helpers.ts:570-583` returns the
|
||||
historical "all attachments" view when the field doesn't exist.
|
||||
This means after the migration, an operator who hasn't run it sees
|
||||
all attachments (including outbound and generated), which changes
|
||||
the "Original Email" card on the quote detail page. Document this
|
||||
in the migration guide.
|
||||
|
||||
### 3.7 `csv` cell encoding is naive
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:1914-1929` (`exportToCSV`)
|
||||
|
||||
The CSV encoder only escapes commas and double quotes, not
|
||||
newlines. A description with `\n` (rare in the codebase, but
|
||||
possible from copy/paste from a Word doc) breaks the CSV row.
|
||||
|
||||
**Recommended fix:** also escape `\n` and `\r` (CRLF inside a quoted
|
||||
cell is allowed by RFC 4180, but only if the cell is quoted; the
|
||||
current code only quotes when commas or double quotes are present).
|
||||
|
||||
### 3.8 `console.error` count is 37 across 165+ routes
|
||||
|
||||
**File:** `console.error` and `console.warn` callsites across `src/app/api/v1/**`
|
||||
|
||||
A `grep -c 'console.error\|console.warn\|console.log' src/app/api/v1/**/*.ts` shows 37. Most routes either log nothing or log a single error per failure. With structured logging missing (per the existing audit's 2.8), debugging a customer-reported failure today requires PB logs + Next logs + guessing.
|
||||
|
||||
**Recommended fix:** introduce a `logError(scope, error, context)` helper that adds the route path, request id, and container id to a single JSON line. Wrap every `catch` block with it. Even without a logger backend, the JSON-line format makes production logs grep-able.
|
||||
|
||||
### 3.9 `searchOrdersWithItems` SEARCH_HARD_CAP is in legacy only
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:1066` vs `src/lib/server/orders-service.ts:1075`
|
||||
|
||||
The legacy cap is 500; the service-layer cap is presumably different
|
||||
(verify). Different caps means a page that migrated to the service
|
||||
shows a different count than a page that hasn't.
|
||||
|
||||
### 3.10 `pb-helpers.ts` `getOrderItemStatusTabCounts` is N round-trips
|
||||
|
||||
**File:** `src/lib/pb-helpers.ts:660-693`
|
||||
|
||||
Six `getFullList` calls in parallel (`tabs.map(...)`). Each scans
|
||||
`order_items` filtered by `item_status` — at WFDASH scale this is
|
||||
fast, but each tab also returns the full `order` relation string,
|
||||
even though the function only dedupes to a count. Add `fields: "order"`
|
||||
to narrow the wire payload (this is the same fix the service layer
|
||||
applies).
|
||||
|
||||
---
|
||||
|
||||
## 4. Production-readiness checklist (data-layer specific)
|
||||
|
||||
- [ ] **Schema validation:** add `zod`, write `parseBody(req, schema)` helper, convert the 7 highest-risk write routes.
|
||||
- [ ] **Legacy deprecation:** move `generateQuoteNumber`, `generateOrderNumber`, `generatePONumber` out of `pb-helpers.ts` exports; add a lint rule that bans non-legacy imports.
|
||||
- [ ] **Browser-singleton fix:** `getPocketBase` throws on server-side. Service-layer is the only path.
|
||||
- [ ] **getFirstListItem abstraction:** write `firstOrNull<T>(collection, filter)` and migrate the 31 call sites.
|
||||
- [ ] **Multi-write rollback:** document partial-success semantics on the 4 multi-write services (orders, PO, DN, quote-split).
|
||||
- [ ] **MCP route refactor:** extract each tool into `lib/server/mcp-tools/<name>.ts`.
|
||||
- [ ] **Search cap visibility:** return `truncated: true` from any search that hits a hard cap.
|
||||
- [ ] **Schema-missing warnings:** `Set<string>` per-field, surfaced to a UI diagnostic.
|
||||
- [ ] **Error envelope standardisation:** `{ error: { code, message, details? } }` shape across all v1 routes.
|
||||
- [ ] **`isPocketBaseId` tightening:** verify both id and name lookups for ambiguous inputs.
|
||||
- [ ] **CSV export hardening:** escape `\n` and `\r`.
|
||||
- [ ] **Logging:** structured JSON `logError(scope, error, context)` helper.
|
||||
|
||||
## 5. What I deliberately did **not** flag
|
||||
|
||||
- **Auth surface.** Covered comprehensively in `security-perf-prod.md`
|
||||
§1.1-1.4, §2.1-2.2, §2.11. This audit stays data-focused.
|
||||
- **Frontend / UX issues.** See `frontend-ux.md`.
|
||||
- **PB schema field disuse.** See `unused-pb-fields.md`.
|
||||
- **Test coverage gaps.** The service-layer files have *.test.ts
|
||||
companions for every business-critical module; the gaps are at
|
||||
the route-handler layer (most routes have no tests, e.g.
|
||||
`agent/scan/route.ts`). Coverage audit is a separate task.
|
||||
- **PDF rendering / @react-pdf / SVG data URIs.** The PDF rendering
|
||||
foot-guns (raster corruption at byte 0x80) are covered in the
|
||||
PDF route JSDoc and don't affect the data layer.
|
||||
|
|
@ -1,461 +0,0 @@
|
|||
# Frontend / UI / UX Audit — WFDASH
|
||||
|
||||
**Date:** 2026-08-28
|
||||
**Scope:** `src/app/(app)/**/page.tsx`, form components, client components, navigation, loading/empty/error states, mobile `/m` dashboard, counter shell.
|
||||
**Method:** Walked the app from the user's perspective (entry → login → dashboard → core flows: orders, quotes, customers, suppliers, stock, deliveries, counter). Read every primary page + form + the sidebar/nav + key modals. Did **not** fix anything — this is a categorised findings list.
|
||||
|
||||
## Severity legend
|
||||
|
||||
- **Critical** — broken flows, data loss, security hole, anything that blocks production rollout
|
||||
- **Major** — visible UX failure, confusing default, accessibility blocker, fragile pattern that will bite later
|
||||
- **Minor** — polish, consistency, hardening
|
||||
|
||||
---
|
||||
|
||||
## Executive summary
|
||||
|
||||
The app has a thoughtful information architecture, real-time updates via PocketBase subscriptions, a cohesive brand language (tri-colour stripe, themed WFDASH palette, themed login) and a lot of detailed care (per-row expansion on the orders list, deterministic accent colours for approval cards, etc). The big remaining issues are:
|
||||
|
||||
1. **No `loading.tsx` / `error.tsx` / `not-found.tsx` anywhere in the app** — every route relies on the page's own inline spinner / error catch. A backend blip on a slow link looks identical to a normal load and an empty state is the only fallback for "this route blew up".
|
||||
2. **No global error boundary** — a throw inside any client component crashes the route silently (only the toast or `console.error` surfaces it).
|
||||
3. **Hardcoded "Westcott Fasteners" brand string** in 4+ places (sidebar, dashboard header, login logo comment, server-side email template).
|
||||
4. **Forms don't use react-hook-form / shadcn `Form`** — every form is bare `useState` with hand-rolled `onSubmit` validation. Inline error messages are inconsistent (some use `toast.error`, some show nothing); the Order form does not validate required Company before submit UI; several forms lack `aria-invalid` / `aria-describedby`.
|
||||
5. **Mobile `/m` dashboard** does a hard `document.location.href = "/login"` on 401 (loses state, no retry). It also uses native `<dialog>` with a hand-rolled backdrop click handler that double-fires in some browsers.
|
||||
6. **Counter success screen** says *"on the New tab"* with no link or context — a quote unquote placeholder text.
|
||||
7. **Sidebar `New Order` vs `Order List`** highlight resolution is clever — relies on a "most-specific match" heuristic that's correct but only documented inline; if anyone adds another nested route under `/orders`, the heuristic breaks silently.
|
||||
|
||||
The remainder of this doc walks the app section by section.
|
||||
|
||||
---
|
||||
|
||||
## 1. Auth & shell
|
||||
|
||||
### 1.1 Login screen — `src/app/(auth)/login/page.tsx`
|
||||
|
||||
**Strengths**
|
||||
- The branded login is genuinely nice — tri-colour stripe, brand-correct dot grid mask, three-radial wash behind the card, three tabs (Login / Register / Reset).
|
||||
- Tabs use a scoped `<style>` block so theme overrides can't recolour the login surface. Good intentionality.
|
||||
- Register validates password match (`if (regPassword !== regConfirm) toast.error(...)`) and min-length is enforced by the browser via `minLength={8}`.
|
||||
|
||||
**Findings**
|
||||
|
||||
- **[Minor]** `src/app/(auth)/login/page.tsx:108-111` — register doesn't check the password length on submit. `minLength={8}` is a browser hint but if it's bypassed (autofill, paste), the form goes through. **Fix:** add the same explicit check you have for "passwords do not match".
|
||||
- **[Minor]** `src/app/(auth)/login/page.tsx:86` — `register` parameter order is `(email, password, passwordConfirm, name)`. The callsite has to remember that order. The function is called `useAuth().register` so the destructured position isn't documented. Consider named options object (`register({ name, email, password, passwordConfirm })`).
|
||||
- **[Minor]** `src/app/(auth)/login/page.tsx:62` — `requestPasswordReset` gives no UI feedback after success beyond the toast. The "Send Reset Email" button stays enabled — a fast user could re-submit. **Fix:** keep the button `disabled` while loading (already true) AND reset the email field on success, AND show inline confirmation copy so the screen doesn't read as "nothing happened".
|
||||
|
||||
### 1.2 Auth gate — `src/app/(app)/layout.tsx:18-30` and `src/components/auth-provider.tsx`
|
||||
|
||||
**Findings**
|
||||
|
||||
- **[Major]** `src/app/(app)/layout.tsx:15` — the loading state is a centred 8×8 spinner with no label. An operator who lands on a slow network sees a blank grey page with a small ring spinning; if their PB session took >2s to verify, they don't know if they're being signed in or if the page is broken.
|
||||
**Fix:** add a subtle brand stripe + "Signing you in…" copy so the layout reads as "we're working on it" instead of "broken".
|
||||
- **[Major]** `src/components/auth-provider.tsx:14-23` — `AuthProvider` only consults `pb.authStore.isValid` on mount. It does **not** validate the token against the server (i.e. doesn't refresh from PB to confirm the JWT hasn't expired). So a returning user can have `pb.authStore.isValid === true` (locally) but a 401 from every API call. The app layout will render, the dashboard will fail with a toast, and the user is in limbo. **Fix:** on mount, call `pb.collection("users").authRefresh()` to validate; if it fails, clear `authStore` and redirect.
|
||||
- **[Critical]** `src/app/(app)/layout.tsx:18-30` — the layout redirects to `/login` if `!user && !loading`. **But** there's no guard for the **mobile `/m` route group** or the **counter route group**. Mobile layout (`src/app/(mobile)/m/layout.tsx`) has no auth check at all. Counter layout (`src/app/(counter)/layout.tsx`) also has no auth check. **A user who never signs in can browse `/m` and `/new-order` unauthenticated and the app will silently 401 every fetch.** (Mobile currently hard-redirects on 401 via `document.location.href = "/login"` — see §6.) **Fix:** either put the auth gate in the root layout, or add the same gate to `(mobile)/layout.tsx` and `(counter)/layout.tsx`.
|
||||
|
||||
### 1.3 Logout
|
||||
|
||||
**Findings**
|
||||
|
||||
- **[Minor]** `src/components/sidebar.tsx:355-362` — logout button has no confirmation. One misclick = signed out. Mobile dashboard has the same gap. **Fix:** either add a confirm popover or accept the trade-off and document it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sidebar / navigation
|
||||
|
||||
### 2.1 Sidebar component — `src/components/sidebar.tsx`
|
||||
|
||||
**Strengths**
|
||||
- Sectioning is clear: Management / Quotes / Orders / Logistics / Operations / Stock / Other. The section labels are uppercase tracking-widest which matches the design system.
|
||||
- Active-route resolution (`isActive` helper at `src/components/sidebar.tsx:99-110`) handles the `/orders/new` vs `/orders` highlight collision correctly by demanding "most-specific match within group".
|
||||
- Mobile fallback uses a Sheet drawer with a top bar — this is fine.
|
||||
- Tri-color brand stripe at top + bottom wraps the sidebar so the brand identity is consistent.
|
||||
|
||||
**Findings**
|
||||
|
||||
- **[Major]** `src/components/sidebar.tsx:99-110` — `isActive` only looks at *direct siblings*. If someone later adds `/orders/foo` as a top-level sidebar item while `/orders` and `/orders/new` both already exist, only the longest URL wins; adding `/orders/foo/bar` does *not* automatically lose its highlight to `/orders/foo`. This is documented in the comment block but it's the kind of thing that breaks on the next refactor. **Fix:** write a tiny unit test for `isActive` covering: equal length, nested, ambiguous three-way.
|
||||
- **[Major]** `src/components/sidebar.tsx:438-470` — mobile sheet drawer is built around the same `NavContent` component but the top mobile bar duplicates the brand block (logo + WFDASH). On a narrow phone this eats ~30px at the top of every screen that's duplicated against the sidebar's own brand block in the drawer. **Fix:** either drop the mobile top bar to a plain hamburger + page title, or render the title from `usePathname()` so it's actually useful (e.g. shows "Orders" when on `/orders`).
|
||||
- **[Minor]** `src/components/sidebar.tsx:81-82` — `Order Sprint` uses the `Truck` icon, identical to the `Deliveries` nav item in Operations. The two icons are visually indistinguishable on the small 17×17 size used here. **Fix:** pick distinct icons — `FastForward` or `Zap` for the sprint, or use `Truck` only on the operations side and `Timer` on the orders side.
|
||||
- **[Minor]** `src/components/sidebar.tsx:118` — `Repairs` is parked under "Other" with a comment saying "Move it once it has a clearer home". For a production app, "Other" is itself a smell — it tells operators *you haven't finished the IA*. **Fix:** either rename "Other" to "Tools" (less pejorative) or promote `Repairs` to a top-level section.
|
||||
- **[Minor]** `src/components/sidebar.tsx:335-352` — user block at the bottom of the sidebar shows `user?.name || "User"` and `user?.email`. If `email` is undefined (rare but possible for a brand-new admin record), the second line is empty. Not blocking.
|
||||
- **[Minor]** `src/components/sidebar.tsx:165-170` — `NavSection` defaults to open if any sibling is active, but the active section's label doesn't render in a different weight so a user scanning the sidebar has to actually look at the chevron to know which section is currently open. **Fix:** bold the active section's label.
|
||||
- **[Minor]** `src/components/sidebar.tsx:341-350` — theme toggle button (`Sun` / `Moon`) flips between `theme === "dark" ? "light" : "dark"` but doesn't reflect the third state some themes support (`"system"`). If a user picks "System" via the ThemePicker, the sidebar still shows only two icons. Acceptable; document it.
|
||||
|
||||
### 2.2 Cross-route nav
|
||||
|
||||
- **[Minor]** The "Order Sprint" sidebar entry has the label "Order Sprint" (`src/components/sidebar.tsx:81`) while the page itself says "Order Sprint" (`src/app/(app)/orders/sprint/page.tsx:190`) — matches. But the `Buyer Tasks` entry (`src/components/sidebar.tsx:82`) links to `/orders/tasks` while the page H1 is just "Buyer Tasks" (`src/app/(app)/orders/tasks/page.tsx:6`). Inconsistent sub-title: "Buyer Tasks" vs nav label "Buyer Tasks" — fine, just noting it. Same pattern for "Sprint" (sidebar) vs page title "Sprint" (page). These micro-inconsistencies add up.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dashboard — `src/app/(app)/dashboard/page.tsx`
|
||||
|
||||
**Strengths**
|
||||
- The greeting strip + KPI cards + chart rows are visually distinctive.
|
||||
- Sparklines, deltas, real-time refresh with debounced PocketBase subscription. Good.
|
||||
- `KpiCard` flash animation on value change (`kpi-delta-up`/`kpi-delta-down` classes) is a thoughtful touch.
|
||||
|
||||
**Findings**
|
||||
|
||||
- **[Critical]** `src/app/(app)/dashboard/page.tsx:271` — `Westcott Fasteners` is **hardcoded** as the org name on the dashboard greeting strip. The `useAuth()` hook already returns `user` — there's no equivalent of `useOrg()` because the org isn't a real concept in the data model, but this means **the dashboard greets every WFDASH tenant as "Westcott Fasteners"**. If the app is sold to anyone else, the greeting will lie. **Fix:** derive the org from a `settings` collection, or remove the org label entirely and just show the user name.
|
||||
- **[Critical]** Same hardcoded "Westcott Fasteners" string at:
|
||||
- `src/components/sidebar.tsx:284` (sidebar brand block)
|
||||
- `src/app/(app)/layout.tsx` brand banner comment (this is just code, not user-visible)
|
||||
- `src/app/api/v1/quotes/[id]/review-approve/route.ts:326` (the email subject says "Quote X from Westcott Fasteners" — so this also leaks into customer emails!)
|
||||
- **Fix:** every one of these needs to come from a single config / settings record, not from hardcoded constants.
|
||||
- **[Major]** `src/app/(app)/dashboard/page.tsx:75-105` — error handling on the dashboard fetch only toasts. If the first fetch fails (token expired, PB unreachable), the user sees `DashboardSkeleton` forever (the `if (!data)` branch) because the catch block doesn't clear `loading` until `finally` — but `loading` is set to `true` initially and only flipped to `false` in `finally`. If the fetch throws, `loading` does flip, but `data` stays `null`, so the user is stuck on the skeleton with a toast they may have dismissed. **Fix:** add a dedicated error-state render: "Couldn't load dashboard. [Retry]" with a button.
|
||||
- **[Major]** `src/app/(app)/dashboard/page.tsx:175-200` — the realtime subscription uses `await pb.collection(...).subscribe("*", trigger)` inside an async IIFE (`const subscribe = async () => { ... }`). If the WebSocket fails to connect (e.g. PB down, proxy buffering), `pb.subscribe` resolves successfully but no events ever fire — the dashboard silently stops refreshing and the operator has no signal. **Fix:** wrap in `pb.collection(...).subscribe(...)` and listen for `PB.AuthStore.onChange` or expose a connection state; show a "Realtime disconnected" badge when no events fire for >N seconds.
|
||||
- **[Major]** `src/app/(app)/dashboard/page.tsx:11-22` — `greeting()` runs at module evaluation time in places like the follow-up list (the component uses `formatDate` from `pb-helpers` which is locale-aware — good). But the dashboard's `greeting()` is called on every render and uses `new Date().getHours()`, which is the **client's** local time. A user in a different timezone than the server's "today" sees a stale "Good morning" at 18:00 their time. **Fix:** if the dashboard is meant to reflect the operator's day, document the timezone; otherwise, prefer a constant greeting for the first iteration.
|
||||
- **[Minor]** `src/app/(app)/dashboard/page.tsx:384-388` — "Customer depth" title is a clever metaphor but the operator might not understand it on first read. A label like "Active pipeline by stage" is plainer.
|
||||
- **[Minor]** `src/app/(app)/dashboard/page.tsx:690` — "Follow-ups due" card has an inner Sub-Component (`FollowUpList`) — good. But the `isOverdue` calculation compares `due < new Date(new Date().toDateString())` which rounds the due date to midnight in **server** timezone, then compares to today's local midnight. Off-by-one risk near midnight.
|
||||
- **[Minor]** `src/app/(app)/dashboard/page.tsx:525-540` — the "Order volume" card and "Orders trend" card both render an 8-week bar chart. Two near-identical widgets one above the other — likely intentional (different x-axis scales), but visually redundant. **Fix:** either merge or label clearly ("Order volume (count)" vs "Order volume (split: new vs fulfilled)").
|
||||
- **[Minor]** `src/app/(app)/dashboard/page.tsx:537-540` — the "Live data" badge is unconditional — it says "Live data" even when the realtime subscription has never fired (and we're showing stale data). Same as §3 major — the badge is a lie in the disconnected case.
|
||||
- **[Minor]** `src/app/(app)/dashboard/page.tsx:1` — file imports `Sparkline` from `@/components/charts/sw` — verify the chart directory is actually `charts/` not `charts/sw/`. Worth a quick check at file open time.
|
||||
|
||||
---
|
||||
|
||||
## 4. Orders list — `src/app/(app)/orders/page.tsx`
|
||||
|
||||
**Strengths**
|
||||
- Item-status-driven tab filter is excellent; server-side `tab=` filter keeps pagination honest.
|
||||
- "Where is it" empty-state nudge ("Found in: Processing (4) · Completed (2)") is a genuinely good UX touch.
|
||||
- Per-row expansion, summary segments showing per-tab counts, complete pill, linked-quote pill — all excellent.
|
||||
|
||||
**Findings**
|
||||
|
||||
- **[Major]** `src/app/(app)/orders/page.tsx:560-574` — empty-state copy for "No orders with items in New" doesn't differentiate between "no orders exist at all" and "no orders match the current search". The page does render *both* cases correctly (different copy), but for "no orders in this tab, but other tabs have data" it shows the same generic nudge. **Fix:** add a third empty-state: "Nothing in New. Try: [Suggested tab buttons]". (The "Busy tabs" subline is close but doesn't go the last mile.)
|
||||
- **[Major]** `src/app/(app)/orders/page.tsx:480-520` — when search is empty AND active tab is "new", the page lands and the operator can't see how many *total* orders are in the system. The pagination footer says `New orders · 20 on this page` but not the global count. **Fix:** add the global active-tab count to the footer (e.g. "Showing 1-20 of 4,326 in New").
|
||||
- **[Major]** `src/app/(app)/orders/page.tsx:520-530` — both the "Review" button and the "Open" button render with class `text-warn hover:text-warn hover:bg-warn/10`. They're both warn-coloured. The review button is supposed to be a *review* action — different colour. **Fix:** review = warn (amber), open = primary (or muted ghost).
|
||||
- **[Major]** `src/app/(app)/orders/page.tsx:676-686` — the row header `<div role="button">` swallows Enter/Space correctly, but if the operator double-clicks the header on a row that's also in `showAll` state, the expansion toggles twice rapidly. (Not blocking, but worth `data-testid`.)
|
||||
- **[Major]** `src/app/(app)/orders/page.tsx:680-686` — the `role="button"` div uses `aria-expanded` correctly, but its accessible name is just the concatenated order number / company / customer / contact / rep. Screen readers will read all of them. **Fix:** wrap the visible content in a labelled region or use `aria-label="Toggle details for order ORD-2026-12345"`.
|
||||
- **[Major]** `src/app/(app)/orders/page.tsx:830-850` — the "Show all items" toggle is rendered as a ghost button with no icon distinguishing "expanded" vs "collapsed". When `showAll === true`, the button label says "Hide items not in X" but the chevron still rotates DOWN. **Fix:** flip the chevron or use a clearer label like "Showing all items" / "Showing only X items".
|
||||
- **[Minor]** `src/app/(app)/orders/page.tsx:737-750` — the "Suggested" pill (potential quote match) uses an amber palette while the linked-quote pill uses teal — both are good — but the "Suggested" pill is **not clickable** while the linked-quote pill opens the order. Inconsistent affordance. **Fix:** make the "Suggested" pill a button that opens the order (the comment in the code already explains this is intentional because the suggestion lives on the order detail page — but that justification should be in a tooltip on the pill itself).
|
||||
- **[Minor]** `src/app/(app)/orders/page.tsx:170` — comment says "Delivered + Cancelled orders live on a separate /orders/archive page." That's a real architectural choice; it should be in the page header as a subtitle so the operator doesn't think the tabs are broken. **Fix:** add a small "Archive: Delivered · Cancelled → /orders/archive" link below the tab strip.
|
||||
|
||||
### 4.1 Orders detail — `src/app/(app)/orders/[id]/page.tsx`
|
||||
|
||||
- **[Major]** This is a 4,500+ line client component. It's effectively a "kitchen sink" page. Anything wrong here is invisible because the file is too large to review in one pass. **Fix:** split into `OrderHeaderCard`, `OrderItemsTable`, `OrderQuoteMatchPanel`, `OrderPurchaseOrdersPanel`, `OrderDocumentsPanel`, `OrderTimelinePanel`, `OrderActionsBar`. Each sub-component takes props + callbacks. The current file mixes presentation, side effects, modal state, and business logic in one place.
|
||||
- **[Minor]** The order detail page has 6+ different `console.error` / `console.warn` calls — these should surface as toasts (the user did the action, they need to know it failed) rather than as silent browser console noise.
|
||||
- **[Minor]** `getOrder` / `findMatchingQuotesForOrder` calls happen via `pb-helpers.ts`, the legacy data layer. AGENTS.md is explicit that new code should go through the service layer — but the order detail page is the largest legacy consumer. This isn't a bug, but every time the order detail page is touched, the migration cost is paid.
|
||||
|
||||
### 4.2 Orders archive — `src/app/(app)/orders/archive/page.tsx`
|
||||
|
||||
- **[Minor]** `src/app/(app)/orders/archive/page.tsx:144` — embedded anchor `<a href="/orders" className="underline">orders page</a>` inside a CardContent paragraph is a hardcoded route. If the IA shifts again, this drifts. Use `<Link>` from `next/link`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Quotes
|
||||
|
||||
### 5.1 Quote list — `src/app/(app)/quotes/page.tsx`
|
||||
|
||||
**Strengths**
|
||||
- AbortController for in-flight fetches (`fetchAbortRef`) is correctly implemented.
|
||||
- Tab counts + per-row "aged" badge with `AGE_WARNING_DAYS` is well thought-out.
|
||||
- Search has a 90-day default cutoff (`includeOlder` toggle) — great UX for the operator's "where's the Acme quote" flow.
|
||||
|
||||
**Findings**
|
||||
|
||||
- **[Critical]** No `loading.tsx` for the quotes route — the entire route is one client component with a centred spinner. On slow networks the operator sees a half-rendered header + an empty list + a spinner. The header + tab strip should appear instantly via a server component split.
|
||||
- **[Major]** `src/app/(app)/quotes/page.tsx:55` — `AGE_WARNING_DAYS = 3` is hardcoded. There's no way for an admin to change it without a code change + redeploy. **Fix:** move to a `settings` collection or to an env var.
|
||||
- **[Major]** `src/app/(app)/quotes/page.tsx:280` — `fetchQuotes` does NOT use the `AbortController` when `search.trim()` is empty (the comment says "fires for both branches" but the abort is wired in only on the search branch — see line 233). **Fix:** move the abort setup above the `if (search.trim())` branch.
|
||||
- **[Major]** The quotes page uses `data-table.tsx` for the body, but the tabs are rendered separately above it. When the operator clicks a tab and the new tab is empty, the DataTable shows its own "No results" message which can be the third empty-state in the same screen (no orders → "No quote found" inside the table, the "Found in:" nudge above, and the empty-state card). **Fix:** consolidate.
|
||||
- **[Minor]** `src/app/(app)/quotes/page.tsx` is also 3,500+ lines and mixes presentation, modals (SendQuoteModal, RewriteQuoteModal), search, tabs, real-time. Same "kitchen sink" problem as the orders page.
|
||||
|
||||
### 5.2 Quote detail — `src/app/(app)/quotes/[id]/page.tsx`
|
||||
|
||||
- **[Major]** This is a **6,500+ line file** with the comment `Eye was only used by the removed "Submit for Approval" buttons. If a future "preview/inspect" action needs it, add the import back.` Dead imports should be cleaned before production.
|
||||
- **[Critical]** File-level `dangerouslySetInnerHTML` for the email body (`src/app/(app)/quotes/[id]/page.tsx:748`) — confirmed sanitised upstream, but worth a regression test that includes `<script>`, `<iframe>`, `javascript:` hrefs, and `onerror=` handlers.
|
||||
- **[Major]** The "Send Quote" modal can fail silently. If `send-quote-modal.tsx` errors mid-submit, the toast fires but the modal stays open with a generic spinner — operator doesn't know whether to retry or close. **Fix:** distinguish "still working" vs "failed, retry?".
|
||||
|
||||
---
|
||||
|
||||
## 6. Mobile `/m` dashboard — `src/app/(mobile)/m/dashboard-view.tsx` and `dashboard/page.tsx`
|
||||
|
||||
**Strengths**
|
||||
- Tabbed time-window selector (today/week/month).
|
||||
- Sales rep cards with metric breakdowns.
|
||||
- Native `<dialog>` for the bottom sheet.
|
||||
- Real-time updates with PB realtime subscription.
|
||||
|
||||
**Findings**
|
||||
|
||||
- **[Critical]** `src/app/(mobile)/m/dashboard-view.tsx:78-80` — **hard navigation** to `/login` on 401 via `document.location.href = "/login"`. This wipes the in-memory state of the dashboard (search, tab selection, sheet state) and gives no "your session expired, sign back in" feedback. The user just lands back on the login page with no breadcrumb. **Fix:** show a sonner toast + push to `/login` via router (preserves back button).
|
||||
- **[Critical]** `src/app/(mobile)/m/dashboard-view.tsx:158-180` — `pb.collection("customers").subscribe("*", trigger)` etc. on mount. **If PB disconnects, the subscription silently stops firing and the operator sees stale data with no indication.** This is the dashboard's "Live data" badge lying again. **Fix:** add a `PB.EventSource` connection state listener or expose a "Reconnecting…" badge.
|
||||
- **[Critical]** `src/app/(mobile)/m/page.tsx` + `(mobile)/m/layout.tsx` — **no auth guard**. The layout doesn't redirect unauthenticated users. A user who never signs in can land on `/m` and the client component will fetch `/api/v1/boss-dashboard` which returns 401, then the hard-redirect fires (good), but the redirect happens *after* the layout already rendered. **Fix:** wrap `(mobile)/layout.tsx` with the same auth guard as `(app)/layout.tsx`.
|
||||
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:159-165` — `pb.collection("customers").subscribe("*", trigger)` opens a new EventSource per page-load. If the page is opened twice in two tabs, **two subscriptions stay alive**; when one tab closes, the other tab's subscription isn't affected (good — PB fan-outs server-side) but the count of open EventSources climbs with each open tab and there's no UI for "this dashboard is also open in another tab". Acceptable.
|
||||
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:330-355` — the `<dialog>` backdrop click handler uses `e.target === dialogRef.current` to detect backdrop taps. **In some browsers (Safari iOS in particular), the dialog element fires a click event for the backdrop AND a separate click for the dialog content if the user tapped within the dialog. The check `e.target === dialogRef.current` works, but `onClose` is also wired, which fires when `dialog.close()` is called from outside — so closing on a real X click fires `onClose` (sets sheetRep to null) AND the backdrop click handler (calls `closeSheet` which also sets sheetRep to null).** Result: a redundant state update. Not blocking, but the dialog backdrop semantics are notoriously inconsistent across browsers.
|
||||
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:298-301` — the dialog has `open={sheetRep !== null}` BUT the dialog is also opened imperatively via `dialogRef.current?.showModal()`. Mixing declarative `open` with imperative `showModal()` is a known source of bugs — the `open` attribute may not update the DOM correctly in all browsers. **Fix:** pick one — drop the `open` prop and drive entirely via the ref, or drop the ref and use only `open` + `onOpenChange`.
|
||||
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:78-90` — when `fetchData` is called and the auth token is missing, it doesn't pre-check; it just lets the request fire and 401s. The dashboard then redirects. **Fix:** pre-check `pb.authStore.isValid` and redirect preemptively.
|
||||
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:235` — `dialogRef.current?.close()` is called in `selectWindow`. **But `selectWindow` does not wait for the close animation**, so if the next `fetchData()` triggers a re-render that opens a new dialog, the user sees a flicker. Acceptable, but the comment is silent.
|
||||
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:185-190` — greeting is hardcoded `"morning"` / `"afternoon"` / `"evening"`. Fine, but uses local time without TZ consideration (same as dashboard §3).
|
||||
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:454` — close button is the literal character `×` instead of an `X` icon. Screen readers will read "multiplication sign" or nothing.
|
||||
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:407-420` — sheet list items open in `window.open(path, "_blank")`. There's no keyboard equivalent — a power-user on a Bluetooth keyboard can't open the order in a new tab. Use a normal `<a>` with `target="_blank"`.
|
||||
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:332-333` — driver section's badge logic: `allDone` shows "All done", `noneStarted` shows `${drv.stopsLeft} left` (red), in-progress shows `${drv.stopsLeft} left` (amber). The amber and red badges say the same text. **Fix:** differentiate ("In progress · 3 left" vs "Not started · 3 left").
|
||||
|
||||
---
|
||||
|
||||
## 7. Forms — cross-cutting
|
||||
|
||||
### 7.1 No `Form` / react-hook-form anywhere
|
||||
|
||||
**Findings**
|
||||
|
||||
- **[Major]** Every form in the app uses bare `useState` + `<form onSubmit={handleSubmit}>`. Validation is done in the submit handler with `toast.error(...)` for failures. This means:
|
||||
- **No inline field-level error messages** — the operator sees a toast at the top of the screen, then has to scroll back to the field.
|
||||
- **No `aria-invalid` / `aria-describedby` propagation** — most forms don't even use `<FormMessage>`-style components because they don't exist in the codebase.
|
||||
- **No `react-hook-form` performance benefits** — every keystroke re-renders the entire form.
|
||||
- **No submit-button-level disable based on `formState.isValid`** — buttons stay clickable while the form has errors.
|
||||
- **Fix:** adopt `react-hook-form` + `zod` + the shadcn `Form` wrapper (you already have `@hookform/resolvers` likely — verify) for any net-new forms; migrate the existing ones over time.
|
||||
|
||||
### 7.2 Customer form — `src/components/customer-form.tsx`
|
||||
|
||||
- **[Major]** `src/components/customer-form.tsx:298` — duplicate-warning live effect uses `setDuplicates` from the effect body without a `useState` lint escape, but it doesn't run a stale-result check; if the user types fast, the effect that started second can resolve before the first. **Fix:** add a request-token / AbortController.
|
||||
- **[Major]** `src/components/customer-form.tsx:175-200` — `handleSubmit` re-checks duplicates on submit (good) but does so via `findCustomerDuplicates(...)` which is from `pb-helpers.ts` (the legacy data layer). AGENTS.md says new features should use the service layer.
|
||||
- **[Minor]** The customer form allows `lat`/`lon` to be entered as raw text fields. An operator can paste a malformed coordinate; the form silently saves an invalid number. **Fix:** validate before submit + show inline error.
|
||||
|
||||
### 7.3 Quote form — `src/components/quote-form.tsx`
|
||||
|
||||
- **[Major]** `src/components/quote-form.tsx:226` — fallback quote number generation uses `Math.random()`:
|
||||
```ts
|
||||
quoteNumber = `Q${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(Math.floor(Math.random() * 999) + 1).padStart(3, "0")}`;
|
||||
```
|
||||
With `Math.floor(Math.random() * 999) + 1` you get 1..999 padded to 3 digits. **Collision risk is real** for an operator who generates two quotes in the same month within the same minute. **Fix:** this is a fallback for when the server-side atomic increment endpoint fails — fix the underlying bug (the fallback is masking a race) or use a UUID suffix.
|
||||
- **[Major]** `src/components/quote-form.tsx:224, 242, 266, 306` — `console.error(...)` for every quote-creation failure path. **Operator never sees the toast for these paths** because the catch only logs. **Fix:** toast on every error path.
|
||||
- **[Minor]** `src/components/quote-form.tsx` doesn't reset the form state when the operator clicks Cancel — the close-handler relies on the parent. If the parent forgets to unmount, the next open shows stale data.
|
||||
|
||||
### 7.4 Order form — `src/components/order-form.tsx`
|
||||
|
||||
- **[Major]** `src/components/order-form.tsx:100-105` — `handleSubmit` only validates that `form.company` is set. **No validation of `form.order_number`** (server-generated), **`form.customer` (optional but required in some flows), or `form.notes` (length cap?).** Operator can submit an order with no customer and only a company — that's by design? Document it.
|
||||
- **[Major]** `src/components/order-form.tsx:163-200` — the PopoverTrigger for customer/company uses raw `<PopoverTrigger className="...">` with a hand-rolled Tailwind class string. The shared `<Button variant="outline">` would inherit the shadcn focus ring. The custom class string **does not match** the Button component's focus-visible style — accessibility regression. **Fix:** use a `Select` or the existing `CompanySearch` / `CustomerSearch` (which the supplier form already uses).
|
||||
- **[Major]** `src/components/order-form.tsx:120-127` — loadDropdowns fetches `getAllCustomers()`, `getAllCompanies()`, `getAllSuppliers()` on every open. These are unbounded `getFullList()` calls. **For a tenant with thousands of customers, this is a 5–15s blocking fetch.** The supplier/customer search components already lazy-load. **Fix:** use those instead.
|
||||
- **[Minor]** `src/components/order-form.tsx:30-45` — `loadDropdowns` writes `customers`, `companies`, `suppliers` from inside the effect callback but doesn't reset them on close. Opening the order form, switching customer, closing, opening again — the dropdowns can show stale data.
|
||||
|
||||
### 7.5 Counter form — `src/app/(counter)/new-order/components/new-customer-sheet.tsx`
|
||||
|
||||
- **[Major]** `src/app/(counter)/new-order/components/new-customer-sheet.tsx:60-65` — comment in the source itself: *"the sheet doesn't auto-reset on reopen. If a counter user reopens without saving, stale fields are visible."* This is an acknowledged UX bug shipped to production. **Fix:** either accept it (document it) or fix it.
|
||||
- **[Major]** `src/app/(counter)/new-order/components/new-customer-sheet.tsx` — `<form>` is missing. The new-customer sheet has no `<form onSubmit>` — submit is wired through `onClick` on the "Create" button. Pressing Enter in any of the inputs does nothing.
|
||||
- **[Minor]** `src/app/(counter)/new-order/components/new-customer-sheet.tsx:36-45` — `mode` state cycles between `{ kind: "customer-only", attachToCompanyId?: string }` and `{ kind: "both" }`. The radio buttons that drive this state are presumably elsewhere — verify they exist (the snippet shown ends at line ~60, the rest of the file should be reviewed).
|
||||
|
||||
---
|
||||
|
||||
## 8. Modals, popovers, sheets — cross-cutting
|
||||
|
||||
### 8.1 No focus-trap standardisation
|
||||
|
||||
- **[Major]** Some modals use the shadcn-style `<Dialog>` (which wraps `Dialog as DialogPrimitive` from `@base-ui/react/dialog`), some use `<Sheet>` (same primitive), some use plain `<Popover>`. **None of them are tested for focus trap behaviour.** A keyboard user can Tab *out* of the open modal in some cases. **Fix:** add a Playwright a11y test that opens each modal type and tries to Tab out.
|
||||
- **[Major]** `<SheetContent>` from `src/components/ui/sheet.tsx:63-76` includes a close button (the `X`) inside the dialog, but **no `aria-label`** — screen readers just hear "close". **Fix:** add `aria-label="Close dialog"` (or a sr-only label).
|
||||
|
||||
### 8.2 Modals opened from inside modals
|
||||
|
||||
- **[Major]** Multiple places stack a Modal inside a Modal (e.g. the Order Form `<Sheet>` opens the `<CancelOrderDialog>` which is a `<Dialog>` inside a Sheet). The z-indexes work but focus ordering doesn't always return to the parent trigger when the inner dialog closes. **Fix:** Playwright test for each stacked-modal flow.
|
||||
|
||||
### 8.3 Dialog close-on-submit
|
||||
|
||||
- **[Major]** Several forms (Customer form, Quote form, Order form) call `onSaved()` then `onOpenChange(false)` separately. If the parent has its own onSaved logic that throws, the form stays open with a success toast — operator thinks it saved but it didn't. **Fix:** await onSaved before closing.
|
||||
|
||||
---
|
||||
|
||||
## 9. Empty / loading / error states — global
|
||||
|
||||
### 9.1 No `loading.tsx` anywhere
|
||||
|
||||
- **[Critical]** `find /Volumes/.../src -name "loading.tsx"` returned **zero matches**. Every route is a client component with an inline spinner. On slow networks the operator lands on a blank page that builds itself piece by piece. **Fix:** add `loading.tsx` for each route segment. Even a thin skeleton matching the page's grid is better than the current behaviour.
|
||||
|
||||
### 9.2 No `error.tsx` anywhere
|
||||
|
||||
- **[Critical]** `find /Volumes/.../src -name "error.tsx"` returned **zero matches**. **No Next.js error boundary is registered anywhere in the app tree.** A throw inside any client component crashes the route silently — the operator sees a blank page. **Fix:** add `(app)/error.tsx`, `(mobile)/error.tsx`, `(counter)/error.tsx` with a "Something went wrong. [Reload]" surface.
|
||||
|
||||
### 9.3 No `not-found.tsx` anywhere
|
||||
|
||||
- **[Major]** No `not-found.tsx`. A bad URL (e.g. `/orders/abc-not-a-real-id`) renders Next.js's default "404 - This page could not be found" page, which **doesn't match the brand at all**. **Fix:** add branded 404 for `(app)`, `(mobile)`, `(counter)`, `(auth)` route groups.
|
||||
|
||||
### 9.4 Empty states — quality varies
|
||||
|
||||
- **[Major]** `src/app/(app)/orders/page.tsx:560-590` — empty state is excellent (mentioned above).
|
||||
- **[Minor]** Several pages render an empty `<Card>` with no message inside, leaving a blank card. **Fix:** audit each list page for "no items returned but the response is success" → ensure a friendly empty card.
|
||||
- **[Minor]** `src/components/sidebar.tsx` — empty sidebar group (`Other`) renders nothing, which is fine, but if every group is collapsed (e.g. default), the operator lands on a sidebar of just section labels.
|
||||
|
||||
---
|
||||
|
||||
## 10. Hydration / client/server boundary
|
||||
|
||||
### 10.1 Theme flash — handled correctly
|
||||
|
||||
- **[Strength]** `src/components/theme-bootstrap.tsx` runs as a `<script>` in `<head>` BEFORE hydration, reading `localStorage.getItem('wfdash-theme')` and applying it. This avoids the FOUC. **Pattern is exemplary — copy this for any other persisted client state** (e.g. dev identity, sidebar collapsed state).
|
||||
|
||||
### 10.2 `Math.random()` in render
|
||||
|
||||
- **[Major]** `Math.random()` is called inside render in:
|
||||
- `src/app/(app)/stock-orders/new/page.tsx:93`
|
||||
- `src/app/(app)/orders/new/page.tsx:118`
|
||||
- `src/app/(counter)/new-order/components/items-list.tsx:19`
|
||||
- `src/app/(counter)/new-order/page.tsx:44, 102`
|
||||
- `src/app/(app)/collection-confirm/view-model.ts:308`
|
||||
|
||||
These all appear to be for generating client-only `tempId` strings for list rows. **If the form is server-rendered before hydration, the IDs differ between SSR and CSR** — **hydration mismatch**. The `items-list.tsx` file even acknowledges this: *"The earlier `tempId` (Math.random) bled into the <input id=...> attributes and caused a hydration mismatch on the first render"*. **Fix:** `useId()` from React 18+, or `crypto.randomUUID()` (already used in `quote-form.tsx:113` — good).
|
||||
|
||||
### 10.3 Date / time rendering
|
||||
|
||||
- **[Minor]** `formatDate` is locale-aware (`"en-ZA"`) — good. But `new Date().toDateString()` and `new Date()` inside render (e.g. `DashboardPage` and `MobileDashboard`) **differ between SSR and CSR**. The server renders in its timezone; the client renders in theirs. For components that show "Updated 5m ago", this can show "just now" server-side and "5m ago" client-side after hydration. **Fix:** render time-based copy only on the client (gate behind `mounted` flag).
|
||||
|
||||
### 10.4 Server Components leaking to client
|
||||
|
||||
- **[Major]** `src/app/(app)/stock-orders/page.tsx` is a **server component** that calls `getAdminPocketBase()` and `listStockPurchases(...)` directly. This is correct (the AGENTS.md says migrate data calls to the service layer). But the error path renders the page with `loadError` set — there's no client-side retry button. **Fix:** pair server-rendered pages with a client `<RefreshButton>`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Accessibility (a11y)
|
||||
|
||||
### 11.1 Good patterns observed
|
||||
|
||||
- ✅ `src/components/orders/page.tsx:680-686` — proper `role="button"`, `tabIndex`, keyboard handler, `aria-expanded`.
|
||||
- ✅ `src/app/(app)/quotes/approvals/page.tsx` — `aria-selected` on tabs.
|
||||
- ✅ `src/app/(mobile)/m/dashboard-view.tsx:130-150` — `role="tablist"` + `aria-selected` on time-window tabs.
|
||||
- ✅ `src/components/ui/button.tsx` (assumed from shadcn) — proper focus-visible styles.
|
||||
- ✅ `aria-label` on icon-only buttons (most places — verify by grep).
|
||||
|
||||
### 11.2 Issues
|
||||
|
||||
- **[Major]** `src/components/orders/page.tsx:691-697` — the linked-quote pill is a `<button>` rendered inside a `<div role="button">`. **The accessible name of the outer div includes the linked-quote pill text**, so screen readers read the pill twice (once as part of the row, once as its own button). **Fix:** move the pill to the right of the toggle, not inside it.
|
||||
- **[Major]** `src/components/customer-form.tsx:287-296` — the customer form has no `<label>` elements for its inputs. The `FieldLabel` helper renders a `<Label>` (per Radix's `Label`), but the field below uses `<Input id="...">` and the label `htmlFor` isn't wired up. **Verify:** the `Label` should have `htmlFor={id}` to associate it with the input.
|
||||
- **[Major]** `src/components/order-form.tsx` — same problem. `<Label>Customer</Label>` is not `htmlFor` the PopoverTrigger.
|
||||
- **[Major]** `src/components/ui/sidebar.tsx` mobile drawer has `<SheetTitle className="sr-only">Navigation</SheetTitle>` — good. But the desktop `<aside>` has no `<h1>` / landmark. **Fix:** add `aria-label="Primary navigation"` to the desktop aside.
|
||||
- **[Minor]** Several dialogs use the bare word "Close" as accessible name. The SheetContent's close `<button>` is `<SheetPrimitive.Close>` with no aria-label. **Fix:** add `aria-label="Close"`.
|
||||
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:454` — close button uses literal `×` instead of an icon — accessible name is empty.
|
||||
|
||||
---
|
||||
|
||||
## 12. Other gaps
|
||||
|
||||
### 12.1 Real-time / SSE / WebSocket disconnect UX
|
||||
|
||||
- **[Critical]** No code path in the app surfaces a "Realtime disconnected" badge when PB subscriptions stop firing. The dashboards and orders list all claim to be "live" via badges or copy, but if the WebSocket drops, the operator has no signal. **Fix:** add a single shared realtime-status hook (`useRealtimeStatus`) that watches all PB EventSource connections and renders a "Reconnecting…" pill.
|
||||
|
||||
### 12.2 Counter success screen placeholder text
|
||||
|
||||
- **[Major]** `src/app/(counter)/new-order/components/success-screen.tsx:22` — *"on the New tab"* — this is **literal placeholder copy**. The success screen tells the operator the order is on "the New tab" of an unspecified list. There's no link to that tab. **Fix:** either remove the line or replace with a real sentence ("Find it in Orders → New").
|
||||
|
||||
### 12.3 Counter header
|
||||
|
||||
- **[Minor]** `src/app/(counter)/new-order/components/header.tsx` — not reviewed here. Verify it handles the empty-state (operator has placed zero orders) gracefully.
|
||||
|
||||
### 12.4 `/webview` page
|
||||
|
||||
- **[Minor]** `src/app/(app)/webview/page.tsx:22-30` — the probe uses `method: "HEAD"` with `redirect: "manual"` and a 5s timeout. **Some servers return 405 Method Not Allowed for HEAD.** The probe treats `res.ok || res.status === 302` as "up", which covers most cases, but a server returning 405 is treated as "up" because `res.ok` is false but no other guard rejects it. **Fix:** explicitly treat 405 as "can't tell" rather than up.
|
||||
- **[Minor]** `src/app/(app)/webview/page.tsx:108-115` — the iframe has `referrerPolicy="strict-origin-when-cross-origin"`. Good. But the `allow="clipboard-read; clipboard-write"` is a permission policy hint — verify the embedded origin actually grants these (otherwise the clipboard buttons in the embedded app silently fail). Document this in the code comment.
|
||||
|
||||
### 12.5 Duplicate-orders dialog
|
||||
|
||||
- **[Major]** `src/components/duplicate-orders-dialog.tsx:77-90` — duplicate-detection runs on every dialog open. **The search fetches are unbounded — could be very slow on a tenant with thousands of orders.** **Fix:** add pagination + a "show more" affordance.
|
||||
|
||||
### 12.6 Order sprint / buyer tasks
|
||||
|
||||
- **[Minor]** Not deeply audited — flagged here as needing a separate pass.
|
||||
|
||||
### 12.7 Stock page
|
||||
|
||||
- **[Major]** `src/app/(app)/stock/page.tsx` — per-row "click to navigate to detail" pattern: the entire `<TableRow>` should be a navigation element. Currently it's a click handler — keyboard users can't activate it without a button. **Fix:** wrap the row in `<Link>` or add `<Button>` per row.
|
||||
|
||||
---
|
||||
|
||||
## 13. Documentation drift
|
||||
|
||||
### 13.1 Hardcoded brand strings
|
||||
|
||||
Already covered in §3 (Critical) but the spread is wider than that:
|
||||
|
||||
- `src/components/sidebar.tsx:284` — "Westcott Fasteners"
|
||||
- `src/app/(app)/dashboard/page.tsx:271` — "Westcott Fasteners"
|
||||
- `src/app/api/v1/quotes/[id]/review-approve/route.ts:326` — "from Westcott Fasteners" (email subject)
|
||||
- `src/app/(auth)/login/page.tsx:20-30` (comment only) — references `/public/wfdash-logo.png`
|
||||
|
||||
**Fix:** introduce a single `appName` + `orgName` from a config collection; thread through every consumer.
|
||||
|
||||
### 13.2 Hardcoded SLA thresholds
|
||||
|
||||
- `src/app/(app)/quotes/page.tsx:55` — `AGE_WARNING_DAYS = 3`
|
||||
- `src/lib/server/quotes-service.ts:253` — `COMPLETED_RECENT_WINDOW_DAYS = 7`
|
||||
|
||||
**Fix:** both should be settings.
|
||||
|
||||
### 13.3 Hardcoded `Math.random()` for IDs
|
||||
|
||||
Already covered in §10.2.
|
||||
|
||||
---
|
||||
|
||||
## 14. Recommended fix priority (rough order)
|
||||
|
||||
1. **Production-readiness blockers** (Critical):
|
||||
- Real-time disconnect UX across all PB subscribers (§12.1)
|
||||
- Auth gate on mobile + counter layouts (§1.2)
|
||||
- Hardcoded brand strings (§13.1)
|
||||
- No `loading.tsx` / `error.tsx` / `not-found.tsx` (§9)
|
||||
- Mobile dashboard hard-redirect (§6)
|
||||
- Mobile dashboard `<dialog>` open-state mismatch (§6)
|
||||
|
||||
2. **Major UX failures** (Major):
|
||||
- react-hook-form + zod adoption (§7.1)
|
||||
- Inline error messages everywhere (§7)
|
||||
- Order detail / quote detail page splits (kitchen-sink files §4.1, §5.2)
|
||||
- Stock page keyboard nav (§12.7)
|
||||
- Mobile sheet dialog state (§6)
|
||||
|
||||
3. **Polish / hardening** (Minor):
|
||||
- Sidebar icon duplication (§2.1)
|
||||
- "Repairs" section labelling (§2.1)
|
||||
- Hydration mismatches from `Math.random()` IDs (§10.2)
|
||||
- Stale counter success-screen copy (§12.2)
|
||||
|
||||
---
|
||||
|
||||
## Appendix — files touched during the audit
|
||||
|
||||
```
|
||||
src/app/layout.tsx
|
||||
src/app/page.tsx
|
||||
src/app/offline.tsx
|
||||
src/app/(auth)/layout.tsx
|
||||
src/app/(auth)/login/page.tsx
|
||||
src/app/(mobile)/m/layout.tsx
|
||||
src/app/(mobile)/m/page.tsx
|
||||
src/app/(mobile)/m/dashboard-view.tsx
|
||||
src/app/(app)/layout.tsx
|
||||
src/app/(app)/dashboard/page.tsx
|
||||
src/app/(app)/orders/page.tsx
|
||||
src/app/(app)/orders/archive/page.tsx
|
||||
src/app/(app)/orders/[id]/page.tsx
|
||||
src/app/(app)/quotes/page.tsx
|
||||
src/app/(app)/quotes/[id]/page.tsx
|
||||
src/app/(app)/quotes/approvals/page.tsx
|
||||
src/app/(app)/customers/page.tsx
|
||||
src/app/(app)/customers/[id]/page.tsx
|
||||
src/app/(app)/suppliers/page.tsx
|
||||
src/app/(app)/sprint/page.tsx
|
||||
src/app/(app)/repairs/page.tsx
|
||||
src/app/(app)/pricelist/page.tsx
|
||||
src/app/(app)/invoicing/page.tsx
|
||||
src/app/(app)/deliveries/page.tsx
|
||||
src/app/(app)/collection-confirm/page.tsx
|
||||
src/app/(app)/stock/page.tsx
|
||||
src/app/(app)/stock-orders/page.tsx
|
||||
src/app/(app)/settings/page.tsx
|
||||
src/app/(app)/webview/page.tsx
|
||||
src/app/(counter)/layout.tsx
|
||||
src/app/(counter)/page.tsx
|
||||
src/app/(counter)/new-order/page.tsx
|
||||
src/app/(counter)/new-order/components/success-screen.tsx
|
||||
src/app/(counter)/new-order/components/new-customer-sheet.tsx
|
||||
src/app/(counter)/new-order/components/photo-capture.tsx
|
||||
src/components/sidebar.tsx
|
||||
src/components/auth-provider.tsx
|
||||
src/components/customer-form.tsx
|
||||
src/components/order-form.tsx
|
||||
src/components/quote-form.tsx
|
||||
src/components/duplicate-orders-dialog.tsx
|
||||
src/components/theme-picker.tsx
|
||||
src/components/ui/sheet.tsx
|
||||
src/components/ui/dialog.tsx
|
||||
src/components/ui/input.tsx
|
||||
src/components/ui/textarea.tsx
|
||||
```
|
||||
|
||||
Total: ~40 files. The full audit findings map to roughly 30 unique issues. None are show-stoppers for a beta launch; the Critical ones should land before any production rollout.
|
||||
|
|
@ -1,863 +0,0 @@
|
|||
# Security, Performance & Production-Readiness Audit
|
||||
|
||||
**Scope:** WFDASH Next.js app + PocketBase backend.
|
||||
**Date:** 2026-08-28
|
||||
**Author:** tom (audit only — no code changes shipped)
|
||||
|
||||
This document catalogues the issues that stood out while reading the
|
||||
codebase end-to-end. It is intentionally a "report", not a fix-it-up
|
||||
PR. Each finding has a severity (critical / major / minor), a
|
||||
`file:line` reference where it lives, and a recommended fix.
|
||||
|
||||
Conventions used in the file refs:
|
||||
|
||||
- `src/...` — relative to repo root.
|
||||
- Line numbers reference the current state of the file at audit time.
|
||||
|
||||
---
|
||||
|
||||
## 0. Headline summary
|
||||
|
||||
| Severity | Count | What it is |
|
||||
| -------- | ----- | ---------- |
|
||||
| Critical | 4 | Hardcoded production-looking secrets, missing security headers, very weak `AGENT_API_KEY` default, plaintext `ai_settings.api_key` storage |
|
||||
| Major | 11 | Many `/api/v1/*` routes have no auth, no rate limiting, no request-size limits, unauthenticated file proxy, public CORS `*` on MCP, `localStorage` PB token, no observability, dependency risks, console noise leaking PII, no CSP, no backup plan for `ai_settings` |
|
||||
| Minor | 9 | N+1 patterns, untyped env reads, missing `no-store` on sensitive endpoints, no CI, file type / extension trust, race conditions on shared rows, no HTTP `secure` cookie enforcement in dev, etc. |
|
||||
|
||||
The good news: there is no `dangerouslySetInnerHTML` of user-controlled
|
||||
content without a custom sanitizer (`src/components/quote-email-card.tsx:195-237`),
|
||||
no `eval`/`new Function` anywhere, the file-proxy has a hard whitelist
|
||||
of collections, the TV PIN handler uses a constant-time-ish check with
|
||||
a 250 ms flattening delay (`src/app/api/tv/login/route.ts:34-46`), and
|
||||
the auth helper re-validates the PB token against PB on every call
|
||||
instead of trusting the JWT.
|
||||
|
||||
The bad news is mostly the auth surface, secret handling, and the
|
||||
"we trust the dev identity cookie" model. Those are blocking for
|
||||
production.
|
||||
|
||||
---
|
||||
|
||||
## 1. Critical findings
|
||||
|
||||
### 1.1 Hardcoded production-looking credentials in repo root
|
||||
|
||||
**File:** `.env:5` (PB_ADMIN_PASSWORD=<redacted — see .env, ~10-digit numeric>)
|
||||
**File:** `.env:7` (AGENT_API_KEY=<redacted — see .env, 6-digit numeric>)
|
||||
|
||||
`/Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP/.env` is checked into
|
||||
the working tree on disk and contains what look like real values
|
||||
for the production deployment (literal values redacted here to avoid
|
||||
permanently leaking them into git history when this doc is committed —
|
||||
see the actual `.env` file for the current values):
|
||||
|
||||
```
|
||||
PB_ADMIN_PASSWORD=<redacted — 10-digit numeric string>
|
||||
AGENT_API_KEY=<redacted — 6-digit numeric string>
|
||||
```
|
||||
|
||||
`.gitignore` correctly excludes `.env*` (`.gitignore:28`), so the file
|
||||
is **not** in git history — but a `.env.local` and `.env.local.bak`
|
||||
sitting next to it (`ls -la` output) suggest the same values have been
|
||||
copied into per-developer files for a while. Whoever runs `git status`
|
||||
today won't see them, but a copy-paste into a screenshot, a
|
||||
`tar`/backup, a CI artifact, or a sibling repo will leak them. The
|
||||
length (6 digits for `AGENT_API_KEY`, 10 numeric for
|
||||
`PB_ADMIN_PASSWORD`) makes both trivially brute-forceable against a
|
||||
public deployment.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. Rotate both immediately. Treat them as compromised.
|
||||
2. Move all secrets to a real secret store (Doppler / Vault / Coolify
|
||||
env / a 1Password CLI hook). Do **not** rely on `.env.local` for
|
||||
production.
|
||||
3. Add a `pre-commit` + CI grep that fails on the literal patterns
|
||||
`PB_ADMIN_PASSWORD=`, `AGENT_API_KEY=`, `ANTHROPIC_API_KEY=`,
|
||||
`sk-`, `Bearer\s+\w+` in any committed file.
|
||||
4. Make `AGENT_API_KEY` at least 32 bytes of CSPRNG output
|
||||
(`openssl rand -hex 32`).
|
||||
|
||||
---
|
||||
|
||||
### 1.2 `AGENT_API_KEY` is a single bearer shared with the browser bundle
|
||||
|
||||
**Files:**
|
||||
- `src/lib/server/agent-auth.ts:4-19` (the check)
|
||||
- `src/app/api/mcp/route.ts` (the route)
|
||||
- `next.config.ts:16` (the rest)
|
||||
|
||||
`assertAgentAuth` accepts `Authorization: Bearer <AGENT_API_KEY>` and
|
||||
that single token is used by:
|
||||
|
||||
- The MCP `/api/mcp` route, which calls the REST `/api/v1/*` routes
|
||||
internally.
|
||||
- The agent CLIs that drive the same routes from outside.
|
||||
|
||||
The MCP route is exposed with `Access-Control-Allow-Origin: *`
|
||||
(`src/app/api/mcp/route.ts:78-87` — `response.headers.set("Access-Control-Allow-Origin", origin ?? "*")`).
|
||||
That means **any origin on the public internet** can call
|
||||
`/api/mcp` with that bearer token, and the operator's n8n workflow
|
||||
broker the same key. Combined with finding 1.1 (`AGENT_API_KEY` is a
|
||||
6-digit numeric string — see redacted value),
|
||||
this is the single most damaging exposure in the system.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Restrict `Access-Control-Allow-Origin` to an explicit list (e.g. the
|
||||
Hermes domain and the agent's own origin). Reject any other origin
|
||||
with 403.
|
||||
- Issue **per-integration** keys (n8n key, Hermes key, agent CLI key)
|
||||
and track them separately. Revoke the shared one.
|
||||
- Move MCP and `/api/v1/*` agent routes behind a different auth path
|
||||
(mTLS, signed JWT with short TTL, or at minimum an HMAC request
|
||||
signature that includes timestamp + nonce).
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Plaintext `api_key` in `ai_settings` PB row, with no at-rest encryption
|
||||
|
||||
**File:** `src/lib/server/ai-settings-service.ts:36-39` (the comment
|
||||
that explicitly says "plaintext by design")
|
||||
**File:** `src/lib/server/ai-settings-service.ts:132-133, 235-236`
|
||||
(where the key is read/written)
|
||||
|
||||
`ai_settings.api_key` is stored as a plain string field on the PB
|
||||
`ai_settings` row. The operator explicitly chose visibility over
|
||||
encryption-at-rest in August 2026 (per the file's own comment), but:
|
||||
|
||||
- The PB admin JWT and the in-app UI both read the row through
|
||||
admin PB (`getAdminPocketBase`), which means the key is in PB's
|
||||
on-disk SQLite. Anyone with shell access to the PB container
|
||||
(`pocketbase/data/data.db`) gets every operator's LLM API key.
|
||||
- The `/api/v1/ai/settings` route (`src/app/api/v1/ai/settings/route.ts:43-60`)
|
||||
does NOT include the secret in the response — but it does include
|
||||
`api_key_set: boolean`, so any bearer holder can probe the row to
|
||||
confirm whether a key is configured.
|
||||
- The write path accepts the key in a JSON body over HTTPS only if
|
||||
the operator has Coolify terminating TLS. In dev (HTTP) it goes
|
||||
plaintext.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Encrypt `api_key` at rest using a key derived from
|
||||
`process.env.AI_SETTINGS_ENCRYPTION_KEY` (32-byte CSPRNG, rotated
|
||||
quarterly). Decrypt on read inside the service.
|
||||
- Move the key to a sidecar (1Password CLI, Vault Agent, Coolify
|
||||
secret). The PB row should hold a *reference* (vault path /
|
||||
secret id), not the secret itself.
|
||||
- Audit PB's `data.db` to confirm no prior keys were committed to
|
||||
backup snapshots.
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Zero security headers and no CSP
|
||||
|
||||
**File:** `next.config.ts` (no `headers()` block at all)
|
||||
|
||||
`next.config.ts:1-46` configures `output: "standalone"`,
|
||||
`serverExternalPackages`, `allowedDevOrigins` (which includes
|
||||
`"*"`!), and redirects. It does **not** set any
|
||||
`Content-Security-Policy`, `X-Frame-Options`,
|
||||
`Strict-Transport-Security`, `Referrer-Policy`, or
|
||||
`X-Content-Type-Options`. The only "origin policy" in the file is
|
||||
`allowedDevOrigins: [..., "*"]`, which is the most permissive
|
||||
possible value and is shipped in the production build because there
|
||||
is no env-gated branch.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Add a `headers()` block in `next.config.ts`:
|
||||
|
||||
```ts
|
||||
async headers() {
|
||||
return [{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' <POCKETBASE_URL> <HERMES_URL> <N8N_URL>; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" },
|
||||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" },
|
||||
],
|
||||
}];
|
||||
}
|
||||
```
|
||||
|
||||
Tweak `script-src` to match your actual scripts (you have
|
||||
`next/script` and `dangerouslySetInnerHTML` in two components, so
|
||||
`'unsafe-inline'` is currently needed; replace with nonces when
|
||||
you can).
|
||||
- Move `allowedDevOrigins` out of the production config. Gate it
|
||||
on `process.env.NODE_ENV === "development"` and remove the `*`.
|
||||
- Turn on HSTS at the Coolify/proxy level too (the header is
|
||||
already standard for Coolify + Traefik).
|
||||
|
||||
---
|
||||
|
||||
## 2. Major findings
|
||||
|
||||
### 2.1 Wide attack surface: 102 of 180 API routes have no auth
|
||||
|
||||
**Files:** `src/app/api/v1/orders/[id]/attachments/upload/route.ts`,
|
||||
`src/app/api/v1/agent/scan/route.ts`, `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`,
|
||||
plus every route in the following directories (sample listing):
|
||||
|
||||
- `src/app/api/v1/stock-purchases/**/attachments/**`
|
||||
- `src/app/api/v1/purchase-orders/**/attachments/**`
|
||||
- `src/app/api/v1/quotes/**/attachments/**`
|
||||
- `src/app/api/v1/quotes/[id]/pdf/**` (PDF render endpoints)
|
||||
- `src/app/api/v1/repairs/**` (entire resource — all 6 routes
|
||||
unauthenticated)
|
||||
- `src/app/api/v1/buyer-tasks/**` (PO generation, mark-ready)
|
||||
- `src/app/api/v1/collection-confirm/**` (confirm-stock, invoice-upload)
|
||||
- `src/app/api/v1/sprint/items/**/claim` and `attachments/upload`
|
||||
- `src/app/api/v1/invoicing/**`
|
||||
- `src/app/api/counter/orders/[id]/attachments/route.ts`
|
||||
- `src/app/api/invoicing/delivery-note/route.ts`
|
||||
- `src/app/api/invoicing/customers/route.ts`
|
||||
- `src/app/api/invoicing/delivery-note-pdf/route.ts`
|
||||
- `src/app/api/tv/login/route.ts` (PIN-gated but no rate limiting,
|
||||
see 2.4)
|
||||
|
||||
A spot count via `find src/app/api -name "route.ts" -exec grep -L
|
||||
"assertBrowserSession\|assertAgentAuth" {} \; | wc -l` shows
|
||||
**102 of 180 routes have no `assertAgentAuth` or `assertBrowserSession`
|
||||
call**. They rely on the route being called from the in-app UI, which
|
||||
in turn relies on PocketBase's collection rules being tight.
|
||||
|
||||
Concrete consequences:
|
||||
|
||||
- `POST /api/v1/orders/[id]/attachments/upload` — anyone with the URL
|
||||
can append arbitrary files to any order's `order_attachements` field
|
||||
on PB. PB will accept up to 10 files per order (PB schema is the
|
||||
only gate).
|
||||
- `GET /api/v1/agent/scan` — the entire PO triage report (which
|
||||
orders are stuck, which suppliers owe responses) is world-readable
|
||||
to anyone who can reach the app.
|
||||
- `GET /api/v1/files/[collection]/[recordId]/[filename]` — gated by
|
||||
the `ALLOWED_COLLECTIONS` whitelist, but the whitelist is a
|
||||
reasonable superset of every collection that holds
|
||||
operator-uploaded documents. Anyone can read any operator's PDFs
|
||||
by guessing or scraping the URL shape
|
||||
(`/api/v1/files/orders/<id>/<pdf>`).
|
||||
- `POST /api/v1/repairs/[id]/files/[field]` — no auth, no
|
||||
rate-limit, no size check beyond what PB enforces. The field is
|
||||
validated against `ALLOWED_FIELDS` but nothing else.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Apply a single auth boundary, ideally in middleware. The
|
||||
current `src/middleware.ts:8-20` is a no-op pass-through. The
|
||||
comment correctly explains why (the cookie-validating code uses
|
||||
`node:crypto`, which Edge runtime can't bundle), but the
|
||||
alternative isn't "do nothing" — it's "validate auth in each
|
||||
route handler" and that work hasn't been done for these 102
|
||||
routes.
|
||||
- Pragmatic stopgap: add a global API middleware (Node runtime)
|
||||
that:
|
||||
1. Skips only `/api/v1/health`, `/api/tv/login`,
|
||||
`/api/counter/orders/[id]/attachments` (PWA kiosk).
|
||||
2. For everything else under `/api/v1/*` and `/api/mcp`,
|
||||
require a valid `AGENT_API_KEY` *or* a `assertBrowserSession`
|
||||
pass.
|
||||
3. For `/api/invoicing/*`, `/api/counter/*`, `/api/tv/*` —
|
||||
per-route auth as appropriate.
|
||||
- Long-term: switch to a per-route `_auth` wrapper that the
|
||||
middleware can compose.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 PocketBase auth token kept in `localStorage`
|
||||
|
||||
**File:** `src/lib/pocketbase.ts:18-46`
|
||||
|
||||
The PB SDK's `authStore` is persisted to `localStorage` under
|
||||
`pocketbase_auth` with the raw JWT and the user record. Any XSS
|
||||
on the app would let an attacker read this and impersonate the
|
||||
operator with full PB privileges (the token is the user's PB JWT,
|
||||
so it can do whatever the user can do per PB's collection rules).
|
||||
|
||||
The app has at least two `dangerouslySetInnerHTML` callsites that
|
||||
render either operator-controlled data (the email body) or
|
||||
theme-bootstrap script. The email body sanitizer
|
||||
(`src/components/quote-email-card.tsx:195-237`) is a
|
||||
hand-rolled regex sanitizer — it blocks `javascript:`, strips
|
||||
event handlers, and limits tags. But the comment at line 177
|
||||
explicitly says "This is NOT a general-purpose sanitizer (use
|
||||
DOMPurify for that)." That is a future footgun if anyone adds a
|
||||
new tags-allowing bypass (e.g. `<svg>`-with-`<use>` is currently
|
||||
allowed because `<svg>` is not in the allow-list, but a small
|
||||
regression in the regex could let a `<style>` through if the
|
||||
stripper changes).
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Move PB tokens out of `localStorage` into an `httpOnly;
|
||||
SameSite=Lax; Secure` cookie set by a server endpoint. The
|
||||
browser can keep using PB's SDK by hitting that cookie. The
|
||||
current `assertBrowserSession` already does server-side
|
||||
validation — the gap is the storage location.
|
||||
- Replace the hand-rolled email sanitizer with DOMPurify
|
||||
(`isomorphic-dompurify` for SSR). Add it to the dep list and
|
||||
drop ~70 lines of custom regex code.
|
||||
- Add a strict CSP (see 1.4) so even if XSS happens, it can't
|
||||
call out.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 No rate limiting anywhere
|
||||
|
||||
**Files:** every `/api/**/route.ts`, especially:
|
||||
- `src/app/api/tv/login/route.ts` (6-digit PIN brute-force window)
|
||||
- `src/app/api/v1/quotes/[id]/from-email/route.ts` (PB write loop)
|
||||
- `src/app/api/mcp/route.ts` (admin PB on every tool)
|
||||
- `src/app/api/v1/orders/[id]/attachments/upload/route.ts`
|
||||
(multipart upload)
|
||||
|
||||
The TV PIN is 6 digits, so 1 000 000 combinations. The handler has
|
||||
a 250 ms flattening delay (`src/app/api/tv/login/route.ts:35-38`),
|
||||
which means ~4000 guesses/min from a single IP. No
|
||||
`X-Forwarded-For` trust boundary is enforced, so an attacker
|
||||
spoofing source IPs can multiply that. No per-IP lockout.
|
||||
|
||||
`assertAgentAuth` does not rate-limit at all. A leaked
|
||||
`AGENT_API_KEY` (see 1.1) means a free pass to admin PB.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Add a simple token-bucket rate limiter in `src/middleware.ts`
|
||||
(Edge runtime) keyed on the request IP. ~30 req/min per IP
|
||||
globally, ~5/min on `/api/tv/login`, ~10/min on agent routes.
|
||||
- For the TV PIN, lock out an IP for 60 seconds after 5 wrong
|
||||
attempts in a 5-minute window.
|
||||
- For the MCP and `/api/v1/*` agent routes, rate-limit per
|
||||
`Authorization` token (so a leaked key is at least throttled).
|
||||
- Consider adopting `@upstash/ratelimit` or `next-safe-action`'s
|
||||
rate-limit helper to avoid hand-rolling token buckets.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 File upload routes have no per-route size cap and trust PB's defaults
|
||||
|
||||
**Files:**
|
||||
- `src/app/api/v1/orders/[id]/attachments/upload/route.ts:39-50`
|
||||
(the comment says "default 5 MB but operators usually set 25 MB")
|
||||
- `src/app/api/v1/collection-confirm/invoice-upload/route.ts`
|
||||
- `src/app/api/counter/orders/[id]/attachments/route.ts:14-22`
|
||||
(only enforces a photo cap, but does it via env)
|
||||
- `src/app/api/v1/repairs/[id]/files/[field]/route.ts`
|
||||
|
||||
The order-attachment route says "PB enforces 25 MB" but never
|
||||
re-validates against the runtime value of PB's schema. An
|
||||
attacker can POST a 25 MB binary, PB will store it, the proxy
|
||||
will serve it back, and the operator pays the storage + bandwidth
|
||||
cost.
|
||||
|
||||
More importantly, **MIME type is not validated** at the route.
|
||||
`file.type` is browser-supplied and trivially spoofable in a
|
||||
direct `curl`. PB stores whatever the client claims. A PDF-claimed
|
||||
HTML file uploaded to `order_attachements` is then served back
|
||||
through `/api/v1/files/orders/<id>/<file>` with `Content-Type:
|
||||
text/html` (PB echoes the stored MIME) and rendered in the
|
||||
operator's preview pane. The preview pane uses `<iframe>` /
|
||||
`<img>` / `<object>` depending on type, so a successful upload
|
||||
turns into an XSS on the operator's next visit to the order
|
||||
detail page.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- In each upload route, after `req.formData()`:
|
||||
1. Enforce a per-route cap (e.g. 10 MB for orders, 8 MB for
|
||||
counter photos) using `file.size` BEFORE handing to PB.
|
||||
2. Validate `file.type` against an allow-list per field
|
||||
(`order_attachements`: `image/*`, `application/pdf`; counter:
|
||||
`image/*` only; repairs.photos: `image/*` only;
|
||||
repairs.quote/PO/DN: `application/pdf` only).
|
||||
3. Validate the **filename** is not a path traversal attempt
|
||||
(no `..`, no leading `/`, no null bytes) and has an
|
||||
appropriate extension.
|
||||
- Set `Content-Disposition: attachment; filename="..."` in the
|
||||
proxy (`src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts:152-154`)
|
||||
for HTML and any other "executable" MIME, **even if the
|
||||
operator originally uploaded it as PDF**. The proxy's
|
||||
current `isViewable` heuristic trusts PB's stored content-type,
|
||||
which the attacker controls.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 `next.config.ts` ships `allowedDevOrigins: ["*"]`
|
||||
|
||||
**File:** `next.config.ts:18-20`
|
||||
|
||||
`allowedDevOrigins` is a dev-server feature that lets the HMR
|
||||
client connect from a non-`localhost` origin. Setting it to `*`
|
||||
in a `next.config.ts` that's the same file used for `next build`
|
||||
and `next start` means the production server also allows HMR
|
||||
from any origin. While the HMR socket is dev-only, having `*` in
|
||||
the config is misleading and a sign that the dev/prod split is
|
||||
missing.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```ts
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["pdf-parse", "pdfjs-dist", "@napi-rs/canvas"],
|
||||
...(isDev ? { allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.42", "192.168.1.122", "192.168.1.128"] } : {}),
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.6 `next.config.ts` redirects include the local PIN
|
||||
|
||||
**File:** `next.config.ts:30-44`
|
||||
|
||||
The `/counter → /new-order` and `/tv → /pin` redirects are fine.
|
||||
But the underscore-to-hyphen redirects for `/api/v1/order_items`
|
||||
etc. are a soft commitment: agents (and humans copy-pasting from
|
||||
PB collection names) reach the right URL, but it means the
|
||||
server will accept *both* spellings forever. The redirect is
|
||||
`permanent: true` so it 308s in perpetuity. That's a long-term
|
||||
naming inconsistency baked into the public API.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Keep the redirects, but add an HTTP `Deprecation` or `Sunset`
|
||||
header on the underscore paths so callers can migrate.
|
||||
- Or, change PB collection names to use hyphens (PB allows it)
|
||||
and drop the redirect.
|
||||
|
||||
---
|
||||
|
||||
### 2.7 CORS on `/api/mcp` is `*` (any origin can use the agent key)
|
||||
|
||||
**File:** `src/app/api/mcp/route.ts:78-87`
|
||||
|
||||
```ts
|
||||
function withCors(response: NextResponse, req: NextRequest): NextResponse {
|
||||
const origin = req.headers.get("origin");
|
||||
response.headers.set("Access-Control-Allow-Origin", origin ?? "*");
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
If the request has no `Origin` header (e.g. `curl`, a server-side
|
||||
agent), the response sets `*`. With an `Origin`, the response
|
||||
echoes the origin back. That is functionally `Access-Control-Allow-Origin: *`
|
||||
**and** credentialed CORS at once. The comment on line 68-77 says
|
||||
"Production should narrow the origin allow-list" — it has not been
|
||||
narrowed.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Replace with an allow-list:
|
||||
|
||||
```ts
|
||||
const ALLOWED_ORIGINS = new Set([
|
||||
process.env.NEXT_PUBLIC_SITE_URL,
|
||||
"https://hermes.example.com",
|
||||
]);
|
||||
const origin = req.headers.get("origin");
|
||||
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
||||
response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
response.headers.set("Vary", "Origin");
|
||||
}
|
||||
// No CORS headers = browser will block; CLI/server callers ignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.8 No structured logging / observability
|
||||
|
||||
**Files:** 132 `console.*` callsites across `src/`, no logger
|
||||
abstraction, no Sentry / OpenTelemetry / pino / datadog.
|
||||
|
||||
`grep -rn "console\." src/ --include="*.ts" --include="*.tsx" | wc -l` → 132.
|
||||
The counts include:
|
||||
|
||||
- 13 in `src/app/(app)/orders/[id]/process/page.tsx` (operator PII
|
||||
in the body of the log lines)
|
||||
- 10 in `src/app/api/v1/quotes/[id]/pdf/route.ts`
|
||||
- 7 in `src/app/api/v1/quotes/[id]/submit/route.ts`
|
||||
|
||||
The messages include `console.log` of error bodies that contain
|
||||
the customer's email subject, the supplier name, and the parsed
|
||||
email body (`src/app/(app)/orders/[id]/process/page.tsx:1396-1405`).
|
||||
That is PII leaking into the server's stdout.
|
||||
|
||||
`process.env` is also used directly in many places without a typed
|
||||
loader (`grep -rn "process.env" src/lib/server/` returns 30+
|
||||
results), so a typo in an env var name (`ORDER_ITEMS_COLLECTIONS`
|
||||
vs `ORDER_ITEMS_COLLECTION`) silently falls back to a default and
|
||||
nobody notices.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Add `pino` with a redact list (`req.headers.authorization`,
|
||||
`pb.authStore.token`, `*.email`, `*.password`). Wire it into
|
||||
the route-handler error catch in `src/app/api/v1/*`.
|
||||
- For the Next.js server side, set up `pino-pretty` in dev and
|
||||
ship JSON to stdout in prod (Coolify picks it up).
|
||||
- Add Sentry (or self-hosted GlitchTip) with the same redact
|
||||
config. Wrap the `assertBrowserSession` and `assertAgentAuth`
|
||||
calls so the path / actor / status code are recorded.
|
||||
- Centralise env access in `src/lib/server/env.ts` using
|
||||
`zod` or `t3-env` so typos fail at boot.
|
||||
- Replace `console.log("[send-po] ...", pdfErr)` and similar
|
||||
with `logger.error({ err, ...context }, "send-po failed")`.
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Lucide-react pinned to a very old `1.16.0` fork
|
||||
|
||||
**File:** `package.json:73` — `"lucide-react": "^1.16.0"`
|
||||
**File:** `package-lock.json:8499-8506` — confirmed version
|
||||
|
||||
The current published `lucide-react` package is at version
|
||||
`0.4xx+` (the `0.x` line is the actively-maintained one by the
|
||||
Lucide maintainers). Version `1.16.0` is from a *separate* fork
|
||||
(the original Lucide pre-rebrand, ISC-licensed, last published
|
||||
2018-ish). Peer-dep is `"react": "^16.5.1 || ^17.0.0 || ^18.0.0 ||
|
||||
^19.0.0"`, so it does work with React 19, but it ships the old
|
||||
"Lucide" icon set (outlined style) and is no longer receiving
|
||||
security or icon updates.
|
||||
|
||||
This is a maintenance risk, not a known CVE. If a future
|
||||
advisory lands against the `1.x` line, the project has no
|
||||
upgrade path short of re-mapping every icon import.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Audit the icon list used (`grep -rn "from \"lucide-react\"" src/`).
|
||||
- Migrate to the `0.x` line (`lucide-react@^0.460.0`) — every
|
||||
icon name has been preserved, but you'll get active maintenance
|
||||
+ a much larger icon set.
|
||||
- If migrating is too expensive today, pin a known-good lockfile
|
||||
revision and add a Renovate / Dependabot rule to track upstream.
|
||||
|
||||
---
|
||||
|
||||
### 2.10 PDF rendering libs: `pdfjs-dist` 5.x and `pdf-parse` 2.4.5
|
||||
|
||||
**File:** `package.json:78-79`
|
||||
**File:** `next.config.ts:10-13` (the `serverExternalPackages`
|
||||
escape hatch)
|
||||
|
||||
`pdfjs-dist` 5.x has had multiple CVEs in 2024-2025 (font
|
||||
parser, JBIG2 decoder, arbitrary code execution on crafted
|
||||
PDFs). The escape hatch `serverExternalPackages: ["pdf-parse",
|
||||
"pdfjs-dist", "@napi-rs/canvas"]` is a workaround for webpack's
|
||||
RSC runtime, not a security control — and the comment
|
||||
(`next.config.ts:8-13`) admits the underlying lib has ESM/CJS
|
||||
quirks that suggest an older or untested version may be in use.
|
||||
|
||||
`pdf-parse@2.4.5` is *not* the same package as the
|
||||
`pdf-parse@1.1.1` that npm originally had; it's a separate
|
||||
maintainer with a recent rewrite, and there's no public
|
||||
security audit of 2.x yet.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Pin both to the latest patch of a known-good line. Add
|
||||
`npm audit` to CI.
|
||||
- Prefer `@react-pdf/renderer` (already a dep) for any
|
||||
*generated* PDFs. Only use `pdfjs-dist` for parsing
|
||||
untrusted PDFs, and:
|
||||
1. Strip active content (JS, embedded files) before parse.
|
||||
2. Run parsing in a worker (already done via Next.js's
|
||||
node runtime) so a memory-corruption bug stays in
|
||||
the worker.
|
||||
3. Cap input size (see 2.4) so an attacker can't
|
||||
OOM the worker.
|
||||
|
||||
---
|
||||
|
||||
### 2.11 `dev-identity` cookie is the de-facto auth for many flows
|
||||
|
||||
**File:** `src/lib/dev-identity.ts:46-67`
|
||||
|
||||
`getServerIdentity` reads identity in this order:
|
||||
|
||||
1. `X-User-Email` request header (any caller can set this)
|
||||
2. `wfdash_dev_email` cookie (the UI switcher)
|
||||
3. `DEV_USER_EMAIL` env var
|
||||
4. `DEFAULT_DEV_EMAIL = "david.els1@outlook.com"`
|
||||
|
||||
The header is explicitly listed as a per-request override — but
|
||||
**any HTTP client can set `X-User-Email: <anything>` and be
|
||||
attributed as that operator**. The function is used by routes
|
||||
that need an actor name (audit notes, claim owners), so a
|
||||
malicious caller can spoof audit-trail entries by setting the
|
||||
header.
|
||||
|
||||
The comment block at the top of the file (`dev-identity.ts:1-20`)
|
||||
calls out a real migration plan: "when real PB auth lands,
|
||||
replace `getServerIdentity` with `assertBrowserSession`." Until
|
||||
then, the system is in an explicit "no auth" mode, but the routes
|
||||
that depend on identity don't all gate on it.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Drop the `X-User-Email` header override. Make the cookie the
|
||||
only override. Validate the cookie against a known set of
|
||||
dev emails (or against `pb.collection("users").authRefresh`).
|
||||
- Audit every call site:
|
||||
|
||||
```bash
|
||||
grep -rn "getServerIdentity\|DEV_IDENTITY" src/app/api
|
||||
```
|
||||
|
||||
and ensure each is `assertBrowserSession`-gated before
|
||||
trusting the identity.
|
||||
|
||||
- Set a clear `DEADLINE` (e.g. 30 days) for removing the
|
||||
`DEFAULT_DEV_EMAIL` fallback so this can't quietly ship
|
||||
to prod.
|
||||
|
||||
---
|
||||
|
||||
## 3. Minor findings
|
||||
|
||||
### 3.1 CORS preflight sets `Access-Control-Max-Age: 86400`
|
||||
|
||||
**File:** `src/app/api/mcp/route.ts:85`
|
||||
|
||||
CORS preflight is cached for 24h. If you tighten the CORS
|
||||
allow-list (see 2.7), browsers will keep allowing disallowed
|
||||
origins for 24h. Lower to 600s (10 min) during the transition.
|
||||
|
||||
### 3.2 No request size cap on JSON endpoints
|
||||
|
||||
**File:** all `req.json()` callers in `src/app/api/v1/*/route.ts`
|
||||
|
||||
The default Next.js `req.json()` is bounded by the framework
|
||||
defaults, but no route explicitly sets a max. A 100 MB JSON
|
||||
body on `POST /api/v1/orders/create` would parse, allocate, and
|
||||
only then be rejected as "items must be a non-empty array".
|
||||
|
||||
**Fix:** Add an explicit content-length check or use `req.json({
|
||||
limit: "1mb" })` where supported.
|
||||
|
||||
### 3.3 `assertBrowserSession` doesn't bind to PB user id
|
||||
|
||||
**File:** `src/lib/server/browser-session.ts:30-44`
|
||||
|
||||
The function refreshes the JWT but never extracts the user id.
|
||||
Downstream code that needs to "is this user the one who claimed
|
||||
this row?" can't tell. Add a return value:
|
||||
|
||||
```ts
|
||||
export async function assertBrowserSession(req: NextRequest): Promise<{ id: string; record: PBRecord }> { ... }
|
||||
```
|
||||
|
||||
and propagate the `id` into `getServerIdentity` so the two
|
||||
paths converge.
|
||||
|
||||
### 3.4 PB admin auth token cached in module-level memory indefinitely
|
||||
|
||||
**File:** `src/lib/server/pocketbase-admin.ts:7-15, 78-89`
|
||||
|
||||
`cachedAdminAuth` lives in the Node module scope. In a serverless
|
||||
deploy (Vercel, AWS Lambda) this is fine (per-invocation). In
|
||||
the standalone Next.js server (Coolify), it lives for the
|
||||
process lifetime. If an operator's PB password is rotated, every
|
||||
running instance keeps using the old token until process restart
|
||||
(5-minute drift, per the `ADMIN_TOKEN_REFRESH_BUFFER_MS = 60_000`
|
||||
buffer). The `clearAdminPocketBaseAuthCache` export exists but
|
||||
no caller uses it.
|
||||
|
||||
**Fix:** Call `clearAdminPocketBaseAuthCache` from a
|
||||
`process.on("SIGHUP")` (or a `/api/v1/admin/reload-auth`
|
||||
debug-only endpoint) so a PB password rotation can take effect
|
||||
without a process restart.
|
||||
|
||||
### 3.5 Many `getFullList` calls — N+1 risk
|
||||
|
||||
**File:** `src/lib/server/orders-service.ts:433, 473, 482, 621, 657, 696, 873`
|
||||
|
||||
The service uses `pb.collection(...).getFullList(...)` in many
|
||||
places. PB paginates this internally (default 500/batch), so
|
||||
"full list" really means "iterate until done". For collections
|
||||
in the tens of thousands (`order_items` is approaching that),
|
||||
this is slow under load.
|
||||
|
||||
**Fix:** Replace with paginated reads or filter-then-page
|
||||
where the caller only needs a slice. Add a regression test
|
||||
that asserts total latency for a 5 000-order workspace.
|
||||
|
||||
### 3.6 No CI, no test gating, no pre-commit hooks
|
||||
|
||||
**Files:** `package.json:73-90` has no `lint:fix`, no
|
||||
`format:check`, no `test:ci` script. No `.github/workflows/`,
|
||||
no `.gitlab-ci.yml`. The Dockerfile builds but doesn't run
|
||||
tests.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Add a GitHub Actions workflow that runs `npm run lint`,
|
||||
`npm run test:run`, and `npm audit --audit-level=high`
|
||||
on every PR.
|
||||
- Add Husky + lint-staged to enforce formatting on commit.
|
||||
|
||||
### 3.7 `console.error` messages embed PII (operator names, customer emails)
|
||||
|
||||
**File:** `src/app/(app)/orders/[id]/process/page.tsx:1276, 1285, 1396, 1405`
|
||||
|
||||
Examples (paraphrased — see actual file for exact strings):
|
||||
|
||||
- `console.error("[send-po] PDF fetch threw:", pdfErr)` — `pdfErr`
|
||||
includes the full response body, which includes the order
|
||||
number + customer email.
|
||||
- `console.error("[send-po] PDF generation failed:", errBody)` —
|
||||
ditto.
|
||||
- `console.log(...)` in the same file dumps the customer
|
||||
subject line to stdout.
|
||||
|
||||
**Fix:** Adopt a `logger.error({ err, orderId, userId, route }, ...)`
|
||||
shape (see 2.8) and never log raw request/response bodies.
|
||||
|
||||
### 3.8 TV session cookie is not `secure` in dev
|
||||
|
||||
**File:** `src/lib/server/tv-session.ts:80-83`
|
||||
**File:** `src/app/api/tv/login/route.ts:64-66`
|
||||
|
||||
The cookie sets `secure: req.nextUrl.protocol === "https:"`. In
|
||||
dev (`http://localhost`), the cookie goes out as `Secure: false`
|
||||
which is correct for localhost. But there is no environment
|
||||
check that prevents `secure: false` from shipping to a
|
||||
production Coolify deploy that terminates TLS at a proxy and
|
||||
forwards plain HTTP internally — if `req.nextUrl.protocol` ever
|
||||
reads `http` in that scenario (it should, since Next sees the
|
||||
incoming protocol from `X-Forwarded-Proto` only when explicitly
|
||||
configured), the cookie will be sent over plain HTTP and an
|
||||
attacker on the LAN can steal it.
|
||||
|
||||
**Fix:** Force `secure: process.env.NODE_ENV === "production"`
|
||||
in the cookie options; trust the env, not the request protocol.
|
||||
|
||||
### 3.9 `MCP_ERR_NETWORK` (-32010) and `MCP_ERR_UPSTREAM` (-32011) can leak internal URLs
|
||||
|
||||
**File:** `src/lib/server/mcp-upstream-fetch.ts:55-75`
|
||||
|
||||
The error envelope includes `data.url` — the full upstream URL
|
||||
the MCP route tried to call. In a container deploy that URL is
|
||||
`https://0.0.0.0:3000` (per the `Dockerfile` `HOSTNAME="0.0.0.0"`
|
||||
and the `upstreamOrigin` fix in the MCP route). The comment
|
||||
acknowledges the issue but the fix is partial. An agent that
|
||||
reads this error and uses the URL to call back will hit
|
||||
`0.0.0.0` from outside the container and fail.
|
||||
|
||||
**Fix:** Strip the URL down to origin + path in production.
|
||||
For local-dev, keep the full URL. Use an env flag.
|
||||
|
||||
### 3.10 No CORS on `/api/v1/files/...` proxy
|
||||
|
||||
**File:** `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`
|
||||
|
||||
The file proxy is meant to be loaded by `<iframe src=...>` and
|
||||
`<a target=_blank href=...>`. Both are same-origin in the
|
||||
operator flow, so no CORS is needed today. But if a Hermes
|
||||
frontend tries to embed a PDF preview in an `<iframe>`, the
|
||||
browser will block it because there's no `Access-Control-Allow-Origin`.
|
||||
Either add CORS to the proxy for the Hermes origin, or document
|
||||
that the proxy is same-origin-only.
|
||||
|
||||
### 3.11 `tsconfig` paths used in tests require real `process.env` setup
|
||||
|
||||
**File:** `vitest.config.ts` (not opened but referenced in
|
||||
README)
|
||||
**File:** `src/lib/server/agent-auth.test.ts:6-16` (must set
|
||||
`AGENT_API_KEY` before import)
|
||||
|
||||
Many tests assume `process.env.PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD`
|
||||
are set. If they're missing at import time, tests fail with
|
||||
confusing errors. Add a `test-setup.ts` (referenced in
|
||||
`package.json`) that asserts the env or stubs `getAdminPocketBase`.
|
||||
|
||||
### 3.12 `output: "standalone"` includes dev artifacts in the image
|
||||
|
||||
**File:** `Dockerfile:1-31`
|
||||
|
||||
The Dockerfile uses `node:20-alpine` (3-year-old EOL track —
|
||||
Node 20 is in maintenance until April 2026, EOL October 2026).
|
||||
The `builder` stage copies `.` into the image, including the
|
||||
`docs/`, `scripts/`, `e2e/`, `screenshots`, `.audit-screenshots/`,
|
||||
and PB exports. The `runner` stage only copies `public/`,
|
||||
`.next/standalone/`, `.next/static/`, so the extras don't ship —
|
||||
but the build cache is bigger than it needs to be.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Add `.dockerignore` excluding everything except `src/`,
|
||||
`public/`, `package.json`, `package-lock.json`, `next.config.ts`,
|
||||
`postcss.config.mjs`, `tsconfig.json`, and `next-env.d.ts`.
|
||||
- Bump base image to `node:22-alpine` (current LTS as of
|
||||
2026-08). Pin to a specific minor (e.g. `node:22.11-alpine`)
|
||||
for reproducibility.
|
||||
|
||||
---
|
||||
|
||||
## 4. Production readiness checklist (roll-up)
|
||||
|
||||
| # | Item | Status | Owner |
|
||||
|---|------|--------|-------|
|
||||
| 1 | Rotate `AGENT_API_KEY` to ≥32 bytes CSPRNG | blocking | ops |
|
||||
| 2 | Rotate `PB_ADMIN_PASSWORD` | blocking | ops |
|
||||
| 3 | Move secrets out of `.env` files into a real store | blocking | ops |
|
||||
| 4 | Add CSP, HSTS, X-CTO, X-Frame-Options, Referrer-Policy headers | blocking | dev |
|
||||
| 5 | Add a global API auth middleware; remove `allowedDevOrigins: "*"` from prod config | blocking | dev |
|
||||
| 6 | Restrict CORS on `/api/mcp` to an allow-list | blocking | dev |
|
||||
| 7 | Move PB auth token out of `localStorage` into `httpOnly` cookie | blocker for prod | dev |
|
||||
| 8 | Replace hand-rolled email HTML sanitizer with DOMPurify | major | dev |
|
||||
| 9 | Add per-route upload size cap + MIME validation | major | dev |
|
||||
| 10 | Add rate limiting (TV PIN, agent routes, global) | major | dev |
|
||||
| 11 | Encrypt `ai_settings.api_key` at rest | major | dev |
|
||||
| 12 | Add structured logging + Sentry/GlitchTip; redact PII | major | dev |
|
||||
| 13 | Add CI (lint + test + audit) | major | dev |
|
||||
| 14 | Replace `getFullList` patterns with paginated reads where feasible | perf | dev |
|
||||
| 15 | Drop `X-User-Email` header override in `getServerIdentity` | security | dev |
|
||||
| 16 | Force `Secure` cookie in prod for TV session | minor | dev |
|
||||
| 17 | Migrate `lucide-react` to the `0.x` maintained line | maintenance | dev |
|
||||
| 18 | Bump base Docker image to Node 22 LTS | maintenance | ops |
|
||||
| 19 | Centralise `process.env` access in a typed env module | quality | dev |
|
||||
| 20 | Add CORS to file proxy for Hermes embed | depends on Hermes scope | dev |
|
||||
|
||||
---
|
||||
|
||||
## 5. What I deliberately did **not** flag
|
||||
|
||||
- **`escapeFilterValue` in `src/app/(app)/invoicing/page.tsx:103`** —
|
||||
The filter strings are built from server-resolved PB ids and the
|
||||
comment makes the escaping contract explicit. This is safe.
|
||||
- **The hand-rolled email sanitizer in `src/components/quote-email-card.tsx:195-237`** —
|
||||
Has gaps (no DOMPurify) but is well-tested in scope. The
|
||||
recommendation is to swap it for DOMPurify, not to remove the
|
||||
current check.
|
||||
- **`getServerIdentity` cookie path** — The cookie path is `/`,
|
||||
`SameSite=Lax`, and 1 year max-age. For the dev-identity
|
||||
temporary stand-in this is acceptable; the real fix is to
|
||||
replace it (see 2.11).
|
||||
- **`assertAgentAuth` timing** — String comparison of fixed-length
|
||||
tokens is not constant-time, but the token is large enough that
|
||||
timing attacks are impractical. If you rotate to a 32-byte
|
||||
CSPRNG token, this becomes irrelevant.
|
||||
- **PDF and image generation libs (`jspdf`, `pdf-lib`,
|
||||
`@react-pdf/renderer`)** — These are widely used and have no
|
||||
known critical CVEs at the pinned versions. Re-evaluate at
|
||||
next major bump.
|
||||
- **`xlsx` and `mammoth`** — Used for *operator-supplied* file
|
||||
parsing (price lists, supplier docs). They have a long history
|
||||
of CVEs around embedded XXE / formula injection. They are
|
||||
scoped to trusted-operator input today, but if you ever expose
|
||||
the upload UI to a customer, the same input boundary needs the
|
||||
same treatment as 2.4.
|
||||
|
||||
---
|
||||
|
||||
*End of audit. No code was changed during this task — all
|
||||
recommendations are documented here for the team to triage.*
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
# Collection Confirm AI checks runbook
|
||||
|
||||
Design: `docs/superpowers/specs/2026-10-17-collection-confirm-ai-checks-design.md`.
|
||||
|
||||
## What it does
|
||||
|
||||
On Collection Confirm, drivers upload photos of the supplier's tax invoice
|
||||
or delivery note. The AI (the provider set in Settings, AI) reads each
|
||||
photo, pairs the document lines with our order lines, and shows four ticks
|
||||
per line (SKU, quantity, description, price), plus a POS column and an
|
||||
"AI findings" box when something at document level looks wrong.
|
||||
|
||||
- It runs automatically (n8n clock, every 5 minutes) on files not yet read,
|
||||
and on demand with the **Check with AI** button on a card.
|
||||
- **Check with AI** needs only the global AI switch. The scheduled tick
|
||||
needs both the global AI switch and "Collection checks enabled".
|
||||
- A file is read once (by content hash, stored in `collection_doc_reads`).
|
||||
A failed photo read is retried only by the **Check with AI** button, not
|
||||
by the tick.
|
||||
- Lines with no AI check look exactly as before, with no ticks.
|
||||
- **Add to price list** (was "Add to POS", changed 2026-10-30) is a manual
|
||||
feature (per line, or "Confirm and add to price list" on the card). A
|
||||
customer line goes to the `price_list` catalogue at cost + 80%, never to
|
||||
the POS: only stock goes on the counter, and nothing there may be R0. A
|
||||
line needs a SKU, a cost and a supplier first; the toast reports lines
|
||||
added, failed and still needing a SKU / cost / supplier. The buttons are
|
||||
currently hidden on the card (see `collection-return-to-pool.md`).
|
||||
|
||||
What the AI writes: only `ai_check` on the order/stock line, the document
|
||||
reads and an **empty SKU** when it is sure. It never writes a price. It never overwrites a SKU a person set, never confirms a collection,
|
||||
and never changes status.
|
||||
|
||||
Discounts: the AI reads a line's own "Disc %" column and a discount printed
|
||||
only in the invoice totals, and the Price tick compares our cost with the
|
||||
price **after** discount. It **never changes a cost** (changed 2026-10-30):
|
||||
it once read a 15% discount that wasn't on the invoice (PO202610005TEL) and
|
||||
cut a cost. A cost that differs from the discounted price is a red Price
|
||||
tick, which also keeps the card out of auto-push, so a person settles it.
|
||||
Cost changes the AI made before then still show in purple under the line.
|
||||
On order lines the SKU fill is audited as an ordinary `sku` change; stock
|
||||
purchase lines (`stock_purchase_items`) are not covered by the audit hook,
|
||||
so a SKU fill there is not in the audit log.
|
||||
|
||||
## Going live (manual steps, in this order)
|
||||
|
||||
1. **Migrate PocketBase.** Run against the live PB:
|
||||
`npm run pb:migrate:collection-ai`
|
||||
(creates `collection_doc_reads`, adds `ai_check` to `order_items` and
|
||||
`stock_purchase_items`, and the `collection_ai_*` fields on
|
||||
`ai_settings`).
|
||||
2. **Redeploy the audit hook** `pocketbase/pb_hooks/audit_log_utils.js` to
|
||||
the PB server's `pb_hooks/`. It now ignores `ai_check`; until then the
|
||||
only cost is noisy history (a large JSON diff on every re-run).
|
||||
3. **Deploy** the app. Pushing to main does not auto-deploy; it is a
|
||||
manual deploy that takes 5 to 10 minutes.
|
||||
4. **Activate the n8n workflow** (below). Create it inactive, activate it
|
||||
after steps 1 to 3.
|
||||
5. **Turn it on** in Settings, AI: the global "AI enabled" switch must be
|
||||
on, then switch on "Collection checks enabled" in the Collection checks
|
||||
section and click Save.
|
||||
|
||||
The tick does nothing until both switches are on, so steps 4 and 5 are
|
||||
safe in either order.
|
||||
|
||||
## n8n workflow: "WFDASH Collection check tick"
|
||||
|
||||
n8n only acts as a clock; the app decides whether anything is due.
|
||||
|
||||
| Node | Setting |
|
||||
|---|---|
|
||||
| Name | `WFDASH Collection check tick` |
|
||||
| Trigger | Schedule Trigger, every 5 minutes |
|
||||
| Action | HTTP Request |
|
||||
| Method / URL | `POST` `https://wfdash.apphub.co.za/api/v1/collection-confirm/ai/tick` |
|
||||
| Authentication | The `WFDASH Agent API Key` credential (`Authorization: Bearer <AGENT_API_KEY>`). Do not create a new secret. |
|
||||
| Body | none |
|
||||
| Options | Timeout **120000 ms** (120 s); Never Error (neverError) **on** |
|
||||
| Status | **inactive** until the app is deployed and migrated |
|
||||
|
||||
After deploy, Execute workflow once. Expected responses:
|
||||
`{"ran":false,"reason":"disabled"}` while off, `{"ran":false,"reason":"running"}`
|
||||
while an earlier run is still going, `{"ran":true,"summary":{...}}` when it
|
||||
ran. A 401 means the credential is wrong.
|
||||
|
||||
Each run is bounded: at most 10 files, and it stops starting new files
|
||||
after 90 seconds, so it normally answers inside the 120 s timeout;
|
||||
leftovers carry to the next run. Runs never overlap: a run holds a lease
|
||||
for up to 10 minutes, and if a run dies without writing its summary the
|
||||
next tick after that takes over.
|
||||
|
||||
The last run (files read, cards checked) is shown in Settings, AI,
|
||||
Collection checks, and stored in
|
||||
`ai_settings.collection_ai_last_run_summary`.
|
||||
|
||||
Photo on the wrong PO (added 2026-10-19): when the AI reads a PO number
|
||||
on a photo that isn't the card's PO, and that number is a PO we have, the
|
||||
card says "Photo may be for <PO>" and, when open, "Invoice <no> is for
|
||||
<PO>, not this PO" with a **Move photo to <PO>** button. The move is a
|
||||
person's call — nothing moves on its own. It copies the photo onto the
|
||||
other PO (a collected line of a customer PO, or the stock purchase's one
|
||||
photo slot — refused if that slot is taken), removes it from this line,
|
||||
and deletes the AI's read of it so it is read again where it now belongs
|
||||
(`POST /api/v1/collection-confirm/move-photo`,
|
||||
`src/lib/server/collection-photo-move-service.ts`).
|
||||
|
||||
Delete photo (added 2026-10-19): next to "Add supplier invoice", **Delete
|
||||
photo** removes the photo open in the previewer — for a wrong upload or a
|
||||
duplicate — after an inline "Delete …? Yes, delete" confirm. It takes the
|
||||
file off every order line (or the stock purchase) carrying it and deletes
|
||||
the AI's read of it. The PO PDF can't be deleted there. This can't be
|
||||
undone (`POST /api/v1/collection-confirm/delete-photo`).
|
||||
|
||||
Stock orders (changed 2026-10-19): a driver collecting a stock pickup
|
||||
records the full quantity as received, so collected stock lines used to
|
||||
skip Collection Confirm and sit "sent" until someone clicked Mark received
|
||||
(29 stock orders were stuck). Now a collected stock line stays on
|
||||
Collection Confirm (with its photo and AI check) until its stock order is
|
||||
marked received; **Confirm stock** marks it received once every line is
|
||||
collected — that sends it to the accountant's booking queue. Orders with
|
||||
lines still out with a driver stay open (the toast says how many).
|
||||
Cancelled stock orders never show. Driver photos for stock pickups are
|
||||
kept on the stock purchase (`additional_attachments`, multi-file after
|
||||
`npm run pb:migrate:stock-photos`).
|
||||
|
||||
## Auto-push (added 2026-10-28)
|
||||
|
||||
With **Auto-push verified cards** on (Settings, AI, Collection checks, off by
|
||||
default), the tick confirms a card on its own when the page shows it purple
|
||||
(`cardAiState` "verified": every line checked, no red or amber tick). It runs
|
||||
at the end of every tick over every card on the page, so a card a person
|
||||
checked by hand is covered too. It calls the same functions as the manual
|
||||
buttons (`confirmOrderItemsWithSplits`, `confirmStockRows` +
|
||||
`markStockOrdersReceived`), as actor "AI collection check".
|
||||
|
||||
- Customer lines go to ready for invoicing with the invoice number and date
|
||||
the AI read. A card covered by two invoices is pushed one invoice at a time.
|
||||
- Stock lines go to the accountant's stock booking, but only when every line
|
||||
of the stock order is already collected (no follow-on order is created by
|
||||
the AI).
|
||||
- A card is left for a person when: any line has a problem flag, a photo
|
||||
looks like it belongs to another PO, or a customer line has no invoice
|
||||
number or no `YYYY-MM-DD` date. Grey ticks do not block it (same rule as
|
||||
the purple card).
|
||||
- Every push is appended to `ai_settings.collection_auto_push_log` (kept
|
||||
three days). The page shows today and yesterday at the top: PO, supplier,
|
||||
items, value. Endpoint: `GET /api/v1/collection-confirm/auto-push-log`.
|
||||
- Without the PB fields it never pushes (it could not record the push).
|
||||
|
||||
Going live: `npm run pb:migrate:collection-auto-push`, deploy, then switch it on
|
||||
in Settings, AI and Save.
|
||||
|
||||
## Reading the ticks
|
||||
|
||||
| Colour | Meaning |
|
||||
|---|---|
|
||||
| Green | The document agrees with our line: our SKU equals the document code after normalising (uppercase, letters and digits only; this is also how a SKU the AI filled goes green), quantity equals what was collected and what was ordered, the AI is sure it is the same item, unit price after any discount within 1 cent |
|
||||
| Amber | Close but worth a look: our SKU differs from the document code; collected quantity matches the document but the ordered quantity differs; the AI thinks it is the same item but is only "likely" sure |
|
||||
| Red | Mismatch or missing: no SKU on our line, quantity differs from the document, price differs or we have no cost, or the line is not on the document or the AI says it is a different product (all four red) |
|
||||
| Grey | Can't check, per tick: **SKU** when the document line shows no code; **Qty** when it shows no quantity; **Price** when the document line shows no price (a delivery note that prints prices, like Plumblink's, is compared like an invoice — but the AI only ever corrects a cost from a tax invoice); **all four** when the AI couldn't pair the document (see the AI findings box for the reason; **Check with AI** retries) |
|
||||
|
||||
For stock purchase lines the SKU tick and robot use the line's own SKU
|
||||
(the one a rep or the AI set on the purchase line), not the catalogue SKU.
|
||||
|
||||
The AI findings box also lists the AI's **reading notes** — what it noticed on the photo (a delivery note rather than an invoice, the year missing from the date, a rotated or partly covered page).
|
||||
|
||||
Hover a tick for the values compared (ours vs the document). A robot icon
|
||||
marks a SKU the AI filled. Click the document chip on a card to see the
|
||||
extracted lines next to the photo.
|
||||
|
||||
## Errors
|
||||
|
||||
- **"Collection checks are not set up yet: run `npm run pb:migrate:collection-ai`
|
||||
against PocketBase first"** is the guard. The service refuses to run
|
||||
until `ai_check` exists on the live PB, because PB silently drops
|
||||
unknown fields and results would vanish. Run step 1 above.
|
||||
- **"AI is disabled in settings"** from **Check with AI**: switch on the
|
||||
global AI switch in Settings, AI.
|
||||
|
||||
## Changing the prompt
|
||||
|
||||
Settings, AI, Collection checks prompt. "Reset to default" loads the
|
||||
built-in prompt; empty means the built-in default. Keep it to the
|
||||
`submit_document_read` tool's fields.
|
||||
|
||||
## Stopping it
|
||||
|
||||
Switch off "Collection checks enabled" in Settings, AI and Save, or
|
||||
deactivate the n8n workflow. Existing ticks stay until the card is
|
||||
re-checked or the line is cleared.
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# Return mistaken collections to the pool
|
||||
|
||||
Collection Confirm has a **Return to pool** button on each line and a
|
||||
**Return PO to collection pool** button at the bottom of each PO card.
|
||||
The PO button affects only the collected lines shown on that card, not
|
||||
other lines on the customer order. Both open a confirmation listing the
|
||||
affected items before making any changes.
|
||||
|
||||
The reset clears driver assignment, collection dates, collected and
|
||||
assigned quantities, collection problem flags and the previous AI check.
|
||||
Order quantities, costs, SKUs, photos and notes are retained. Customer
|
||||
lines return to `item_status = collection_pool`; stock lines return to
|
||||
`status = collection_pool` with `quantity_received = 0`. An unbooked stock
|
||||
header marked `received` reopens to `sent` so it leaves the accountant's
|
||||
booking queue until collection is confirmed again.
|
||||
|
||||
Items already invoiced, dispatched, delivered or booked into stock are
|
||||
refused. Refused lines are reported separately if some lines can return.
|
||||
The browser reloads the queue after the operation. Normal confirmation
|
||||
stays available; Add to price list (formerly Add to POS) controls are hidden but their underlying
|
||||
handlers and endpoints remain.
|
||||
|
||||
## Deployment
|
||||
|
||||
Deploy **both** the app and `pocketbase/pb_hooks/collection_return.pb.js`.
|
||||
The hook is listed in `pocketbase/pb_hooks/DEPLOY`; publish the committed
|
||||
hooks folder with `npm run pb:hooks:publish` using the workflow in
|
||||
`scripts/pb-hooks/README.md`. No collection fields or select values need
|
||||
to be added.
|
||||
|
||||
The authenticated capability endpoint
|
||||
`GET /api/wfdash/collection-return-guard` must return `{ "version": 1 }`.
|
||||
Until the hook is deployed the app refuses resets with HTTP 503. It must
|
||||
never fall back to an unguarded update.
|
||||
|
||||
Each return uses a PB batch transaction. The hook rechecks row revisions,
|
||||
stock parent revisions, invoice metadata and stock ledger entries inside
|
||||
that transaction before allowing the writes. It also guards the opposite
|
||||
race: a stale customer confirmation cannot advance a returned/assigned
|
||||
line, and a stock booking cannot insert a receipt after the PO reopens.
|
||||
Existing PB audit hooks record customer field changes; no extra app audit
|
||||
notes are added.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the tests under `src/components/collection-confirm`,
|
||||
`src/app/(app)/collection-confirm`, `src/app/api/v1/collection-confirm`,
|
||||
and `src/lib/server/collection-return*.test.ts`. The hook tests evaluate
|
||||
the real JS file in a mocked PB environment; they do not change live data.
|
||||
After deployment, verify a mistaken collection returns to the pool with
|
||||
zero collected quantity and can be assigned to a driver again.
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
# Handover: driver invoice photos that were never collected
|
||||
|
||||
Run every 30 minutes during the working day (06:00–17:00 SAST).
|
||||
|
||||
> **Not scheduled yet.** No cron task runs this — the 5 live crons are the
|
||||
> MCODe basket, three PocketBase backups and the n8n watchdog. Until one is
|
||||
> created, someone has to run this by hand.
|
||||
|
||||
## The problem
|
||||
|
||||
Drivers photograph the supplier invoice on a PO in the driver app, but
|
||||
sometimes never tap **Collect**. The line then stays waiting, so it
|
||||
never reaches **Collection Confirm** and never gets invoiced. A new app
|
||||
version (auto invoice match + "Collect these items?" prompt) should stop
|
||||
this, but until it is proven, check by hand.
|
||||
|
||||
Sometimes the photo is also on the **wrong PO** (e.g. the Metabo invoice
|
||||
was uploaded on the Mega Lube PO), or is not an invoice at all.
|
||||
|
||||
Two sides to check — they are different collections with different
|
||||
attachment fields:
|
||||
|
||||
| Side | Line lives in | Waiting statuses | Photo lives in |
|
||||
|---|---|---|---|
|
||||
| Customer PO | `order_items` | `collection_pool`, `assigned`, `ready_delivery` | `order_items.order_item_attachements` |
|
||||
| Stock PO | `stock_purchase_items` | `collection_pool`, `assigned_to_driver` | `stock_purchases.additional_attachments` (**on the parent PO**) |
|
||||
|
||||
All three customer waiting statuses count. The "collection pool" tab group
|
||||
is `["collection_pool", "assigned", "ready_delivery"]` and
|
||||
`order_items_confirm_collected` accepts `collection_pool` **or**
|
||||
`assigned` — a line stuck in any of them never reaches Collection Confirm.
|
||||
|
||||
## The check
|
||||
|
||||
### 1. Find the lines
|
||||
|
||||
Customer lines — `order_items_list` with filter:
|
||||
|
||||
```
|
||||
(item_status = "collection_pool" || item_status = "assigned" || item_status = "ready_delivery") && order_item_attachements:length > 0
|
||||
```
|
||||
|
||||
expand `purchase_order,supplier` — the PO number is
|
||||
`expand.purchase_order.po_number`, it is not a field on the line itself.
|
||||
|
||||
Stock lines — `stock_purchase_items_list` with filter:
|
||||
|
||||
```
|
||||
status = "collection_pool" || status = "assigned_to_driver"
|
||||
```
|
||||
|
||||
expand `purchase` — the PO number is `expand.purchase.purchase_number`
|
||||
(looks like `PO26100606STK`, not `PO...MAS`), and the photos are in
|
||||
`expand.purchase.additional_attachments`.
|
||||
|
||||
### 2. Read every photo
|
||||
|
||||
`order_item_attachements` is a **multi-file** field (up to 10), and stock
|
||||
POs can have several pages (`..._p1_`, `..._p2_`). Open **each** filename:
|
||||
|
||||
- customer line → `files_fetch`, collection `order_items`, the line id, the filename
|
||||
- stock line → `files_fetch`, collection `stock_purchases`, the **parent PO** id, the filename
|
||||
|
||||
If a photo cannot be read as an invoice, treat the whole line as
|
||||
unreadable (below).
|
||||
|
||||
### 3. Compare the invoice with the line
|
||||
|
||||
- the PO number printed on the invoice (reference / customer order no.)
|
||||
= the line's PO number
|
||||
- the supplier on the invoice = the line's supplier
|
||||
- the item matches the line's description
|
||||
- the quantity on the invoice = the line's `quantity`
|
||||
- the invoice is dated today or recently
|
||||
|
||||
## What to do
|
||||
|
||||
- **Everything matches** → mark it collected. Do **not** hand-roll this
|
||||
with `order_items_update` + `order_items_update_status`; use the tool
|
||||
that exists for it, because it validates eligibility, refuses to
|
||||
over-collect, stamps the audit note and refuses rows that are already
|
||||
past the pool:
|
||||
- customer line → `order_items_confirm_collected` with
|
||||
`{ item_ids: [...], actor: { id, name }, quantity_collected: { <line id>: <invoice qty> } }`
|
||||
- stock line → `stock_purchase_items_mark_collected` with
|
||||
`{ id: <line id>, quantity_collected: <invoice qty> }`
|
||||
- `quantity_collected` is the qty to **add**, not the new total. Omit it
|
||||
to collect the full remaining quantity.
|
||||
- `actor.id` is a free-form string — use `mavis` / `Mavis` so the
|
||||
`notes` feed shows who did it.
|
||||
- Do every line on that PO that is on the invoice.
|
||||
- **Invoice shows less than ordered** → do NOT mark it. Report it
|
||||
(the driver must collect the short quantity on the item card).
|
||||
- **Photo is for a different PO** → do NOT mark it. Report both PO
|
||||
numbers so a person can move the photo to the right PO.
|
||||
- **Photo is not an invoice** → do NOT mark it. Report it.
|
||||
- **Can't read the photo / not sure** → do NOT mark it. Report it.
|
||||
|
||||
Never cancel, delete, confirm, or change prices. Only ever mark a line
|
||||
collected, and nothing else.
|
||||
|
||||
## Report
|
||||
|
||||
A short list: what you marked collected (supplier, PO, item, qty,
|
||||
invoice number) and what needs a person (and why). If nothing was
|
||||
found, say "nothing new".
|
||||
|
||||
**Do not re-report what is already on the "Waiting on a person" list
|
||||
below** — those lines stay in the waiting status until somebody moves
|
||||
them, so every run would otherwise find them again and repeat the same
|
||||
report 22 times a day. Add anything new to that list.
|
||||
|
||||
## Waiting on a person
|
||||
|
||||
- **NSS M24 × 160 hex bolt ×60, PO202610018MAS** (line
|
||||
`890919xtp0i8zfp`, driver Joachim): the photo on it is NSS invoice
|
||||
5031768 for **PO202610080MAS** (M12 cap screws, already collected and
|
||||
confirmed on 27 Oct). Wrong photo, so nothing proves the M24 bolts
|
||||
were collected. Ask Joachim.
|
||||
- **Mega Lube oil aerosol ×3, PO202610073MAS** (line `ganf24h10lyrzdl`,
|
||||
driver Joachim): no Mega Lube invoice anywhere — the line has no
|
||||
attachment at all, so the filter will not find it. Moved to 27 Oct.
|
||||
Ask Joachim.
|
||||
- **UPL UNBRAKO 1 1/2 × 7 allen caps ×34, PO202609005CEM** (line
|
||||
`47it7cxyu27dmd5`): photo is on the line, but an operator bounced it
|
||||
back to "NOT READY" on 26 Oct, so it is not a forgotten Collect.
|
||||
Do not mark it.
|
||||
- **NSS M6 × 30 CSK ×100, PO26100606STK** (stock line
|
||||
`o9jtrr0wasxxq2g`): the photo on the PO is a picture of a laptop
|
||||
keyboard, not an invoice. Needs a person to re-shoot it.
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
# Manager orders view (`/manager`)
|
||||
|
||||
A hidden, installable PWA for the managers: one link, opened on a
|
||||
phone, showing where every order is — **New / Processing / Completed**.
|
||||
|
||||
It is deliberately not in the sidebar. The route lives in the
|
||||
`(mobile)` route group, outside `(app)`, so `<Sidebar />` never
|
||||
renders on it and there is no nav entry anywhere.
|
||||
|
||||
## Where things live
|
||||
|
||||
| Piece | File |
|
||||
|---|---|
|
||||
| Page (route group) | `src/app/(mobile)/manager/` — `layout.tsx`, `auth-gate.tsx`, `page.tsx`, `manager-orders-view.tsx` |
|
||||
| Read model | `src/lib/server/manager-orders-service.ts` |
|
||||
| API route | `src/app/api/v1/manager-orders/route.ts` |
|
||||
| PWA manifest | `public/manifest-manager.json` |
|
||||
| Service worker | `public/sw-manager.js` |
|
||||
| SW registration | `src/components/service-worker-registration.tsx` (adds `/manager` → `/sw-manager.js`) |
|
||||
| Bucket + settled-status contract | `src/lib/item-status-ui.ts` |
|
||||
| Shared window filter | `settledWindowFilter` in `src/lib/server/orders-service.ts` |
|
||||
|
||||
## The three buckets
|
||||
|
||||
`PIPELINE_GROUP_ITEM_STATUSES` in `src/lib/item-status-ui.ts` is a
|
||||
coarse view of the desk's eight workflow tabs. Both surfaces read the
|
||||
same status lists, so they can never disagree about what a status
|
||||
means.
|
||||
|
||||
| Bucket | Statuses | Orders (live, 2026-10-26) |
|
||||
|---|---|---|
|
||||
| **New** | `pending`, `approval_needed`, `approval_rejected` | 8 |
|
||||
| **Processing** | `processed`, `po_generated`, `collection_pool`, `assigned`, `ready_delivery`, `collected`, `stock_verified`, `picking`, `ready_invoice`, `stock_booked`, `with_driver` | 79 |
|
||||
| **Completed** | the settled set, windowed to 7 / 14 / 30 days | 65 / 112 / 200 |
|
||||
|
||||
An order lands in a bucket when **at least one of its lines** is in
|
||||
that bucket. That is why Processing holds 79 orders for only 206
|
||||
open lines: 45 of those orders have only ONE line left, and 40 have
|
||||
other lines already invoiced or collected. `cancelled` and `removed`
|
||||
are deliberately outside the pipeline (they live on /orders/archive).
|
||||
|
||||
## The Completed window — read before changing it
|
||||
|
||||
There is no single "finished at" column. `SETTLED_STATUSES` +
|
||||
`SETTLED_DATE_FIELDS` in `src/lib/item-status-ui.ts` encode which
|
||||
field dates each settled status:
|
||||
|
||||
| Status | Dated on | Why |
|
||||
|---|---|---|
|
||||
| `delivered` | `delivered_at` | populated on only 2 of 12,957 lines; the rest are legacy rows whose `updated` is the MIGRATION timestamp, so an un-dated delivered row is ARCHIVED, not recent |
|
||||
| `invoiced` | `invoiced_date` | the only properly stamped settlement date in the schema (2,199 lines) |
|
||||
| `completed`, `checked_in`, `delivery_note` | `updated` | no dedicated column exists, so `updated` is the only signal |
|
||||
|
||||
`primary` and `fallback` are mutually exclusive. Giving a status both
|
||||
would resurface archived rows every time an old row was touched.
|
||||
|
||||
Before this change the Completed tab matched **0** orders: 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.
|
||||
|
||||
`settledWindowFilter(days)` is shared with `/orders` (30 days) — only
|
||||
the window length differs, so the two surfaces never disagree.
|
||||
|
||||
## The preview popup is reused, not rebuilt
|
||||
|
||||
The service emits `UniversalHit`s — the same shape the universal
|
||||
search returns — and the page hands them to the existing
|
||||
`SearchPreviewDialog` (`src/app/(app)/search/search-preview.tsx`). So
|
||||
the manager gets the desk's quick-overview verbatim: status chips,
|
||||
stats, invoice refs, delivery-note numbers, the line list and the
|
||||
lifecycle timeline, plus a link out to the full order page.
|
||||
|
||||
**Read-only.** Nothing on the phone can change an order's state.
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
GET /api/v1/manager-orders?group=new|processing|completed
|
||||
&window=5|7|14|30
|
||||
&search=<order no|customer|company|rep>
|
||||
&page=1&perPage=20
|
||||
```
|
||||
|
||||
Auth is the PB browser session in the `Authorization` header — the
|
||||
same `assertBrowserSession` pattern as `/api/v1/boss-dashboard`. The
|
||||
response carries `counts` (distinct orders per bucket), `total`,
|
||||
`totalPages`, the page of `orders` and `viewerName`.
|
||||
|
||||
## Installing it on a phone
|
||||
|
||||
Open the link on the phone → browser menu → **Add to Home Screen**. It
|
||||
installs as a standalone app ("WFDASH — Orders") with its own icon,
|
||||
launched in portrait with no browser chrome. iOS needs the
|
||||
`apple-mobile-web-app-capable` meta, which the layout sets (Next 16
|
||||
only emits the standardised name, which iOS ignores — see the note in
|
||||
`(mobile)/m/layout.tsx`).
|
||||
|
||||
The service worker precaches the shell and never caches `/api/`, so a
|
||||
stale order count can never be shown.
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
# n8n Watchdog
|
||||
|
||||
You are the n8n Watchdog for the WFDASH n8n instance. You run on a schedule. Your job: notice when a workflow is broken, stuck, or silently not running, and report it in plain language. You are READ-ONLY.
|
||||
|
||||
## TOOLS
|
||||
n8n MCP only. No browser, no direct REST.
|
||||
- `search_workflows` — inventory: name, active, triggerCount
|
||||
- `search_executions` — main signal; filter by status, startedAfter, workflowId
|
||||
- `get_execution` — why one execution failed
|
||||
- `get_workflow_details` — trigger config / expected cadence
|
||||
|
||||
## STATE
|
||||
You cannot detect "stuck" from one snapshot — a 4-hour hang looks identical to a 3-second-old run. Keep `n8n-watchdog-state.json`. Load at start, save at end:
|
||||
|
||||
```json
|
||||
{
|
||||
"last_run_utc": "",
|
||||
"baseline": { "<workflowId>": { "name": "", "active": true,
|
||||
"trigger_kind": "schedule|poll|webhook|sub",
|
||||
"expected_interval_minutes": 2, "typical_runtime_seconds": 4,
|
||||
"typical_runs_per_hour": 30 } },
|
||||
"open_incidents": { "<key>": { "first_seen_utc": "", "times_reported": 3, "summary": "" } },
|
||||
"seen_execution_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
No file = RUN 1.
|
||||
|
||||
## RUN 1 — baseline (once)
|
||||
1. `search_workflows(limit:200)`.
|
||||
2. For each active workflow, `get_workflow_details`, read the trigger:
|
||||
- **Schedule/Cron** → record interval as `expected_interval_minutes`.
|
||||
- **IMAP/Email** → polls constantly but only executes when mail arrives. Silence is normal. `null`, kind `poll`.
|
||||
- **Webhook** → fires on call only. Silence normal. `null`.
|
||||
- **Execute Workflow trigger / triggerCount 0** → sub-workflow or error handler. Silence normal. `null`.
|
||||
3. Pull last ~20 executions per active workflow; record median runtime and runs/hour.
|
||||
4. Report the baseline table to David: name, active, trigger kind, expected cadence, typical runtime, anything odd. He does not currently know every workflow's real cadence — this table is the point of Run 1.
|
||||
|
||||
## EVERY RUN
|
||||
Stop early when clear. Most runs = one call + one line. Do not burn a long session confirming all is well.
|
||||
|
||||
**1. Failures**
|
||||
`search_executions(status:["error","crashed"], startedAfter:<last_run_utc>, limit:100)`
|
||||
For each id not in `seen_execution_ids`: `get_execution(includeData:false)`. If you need the cause, call again with `includeData:true, truncateData:2, nodeNames:[failing node only]`. Extract workflow, node, `error.message`, `httpCode`.
|
||||
|
||||
**2. Stuck**
|
||||
`search_executions(status:["running","waiting"], limit:100)`
|
||||
- `running` longer than max(10 min, 10 × typical_runtime_seconds) → HUNG, report.
|
||||
- `waiting` AND `waitTill` >10 min in the past → OVERDUE, report.
|
||||
- `waiting` AND `waitTill` in the future → normal, do NOT report.
|
||||
- Same execution id was running/waiting last run → escalate wording: "stuck for N hours".
|
||||
|
||||
**3. Silent — the check nothing else does**
|
||||
A workflow that stops triggering produces no error and no execution, so error alerts never fire. Causes: expired IMAP creds, a restart that didn't re-register a schedule, someone deactivating it.
|
||||
For each active baseline workflow with non-null `expected_interval_minutes`:
|
||||
`search_executions(workflowId:<id>, limit:1)`
|
||||
Last run more than 3 × `expected_interval_minutes` ago (floor 15 min) → SILENT. Give last-run time and gap length.
|
||||
Also flag any workflow whose `active` flag flipped since baseline.
|
||||
|
||||
**4. Loops**
|
||||
- Storm: executions in the last hour > 3 × `typical_runs_per_hour` → looping.
|
||||
- Internal: running well past normal runtime AND runtime growing between your runs → `get_execution(includeData:true, truncateData:2)`, inspect `runData` for a node with implausibly high `executionIndex` / run count. Name that node.
|
||||
|
||||
**5. Hygiene** (once a day, low priority)
|
||||
Active workflows named TEST/OLD/DRAFT/"do not activate"; duplicate names; active workflows with zero executions ever.
|
||||
|
||||
## HARD RULES
|
||||
1. **READ-ONLY.** Only `search_*` and `get_*`. Never `update_workflow`, `execute_workflow`, `archive_workflow`, publish/unpublish, `restore_workflow_version`, or anything that changes state — not to "fix" something obvious, not to re-run a failed execution. Describe the action, let David decide.
|
||||
2. **REDACT.** `includeData:true` returns raw payloads that on this instance include PocketBase auth JWTs, API keys and customer email content. Replace anything token/JWT/bearer/key-shaped with `[REDACTED]` before it reaches your log or report. Never paste a full node payload. Default to `includeData:false`.
|
||||
3. **Don't repeat.** Incident already in `open_incidents` and unchanged → one line, "still open, Nth run". Full detail only when it worsens.
|
||||
4. **Say when unsure.** A tool call that fails or returns something you don't understand → say so. Never present a guess as a finding.
|
||||
5. **If the n8n MCP itself doesn't respond, THAT is the headline** — n8n is likely down. Report immediately, skip the rest.
|
||||
|
||||
## REPORT
|
||||
Post every run. Nothing wrong = the one-liner, unpadded:
|
||||
|
||||
```
|
||||
n8n watchdog — 14:00 SAST — all clear. 17 active, 42 executions since last
|
||||
check, 0 errors, 0 stuck, 0 silent.
|
||||
```
|
||||
|
||||
Something wrong:
|
||||
|
||||
```
|
||||
n8n watchdog — 14:00 SAST — 2 issues
|
||||
|
||||
[NEW] WFDASH_RAW_PRICELIST — failing since 09:00 (3 runs)
|
||||
Node: "Main Price List" (PocketBase)
|
||||
Error: DNS server returned an error (EAI_AGAIN, pocketbase-...sslip.io)
|
||||
Read: PocketBase host not resolving. Infrastructure, not workflow logic.
|
||||
Suggest: check the container is up and sslip.io DNS resolves.
|
||||
|
||||
[STILL OPEN — 4th run] WFDASH — Quote Intake (RFQ Email Monitor)
|
||||
Silent 3h20m. Expected ~every 1 min (IMAP poll). Last execution 10:41, ok.
|
||||
Read: trigger has stopped firing. IMAP credentials are the usual cause.
|
||||
|
||||
Checked: 17 active workflows, 38 executions, 3 errors, 0 stuck.
|
||||
Next check: 15:00.
|
||||
```
|
||||
|
||||
Lead with what is broken, not what you checked. One entry per incident, tagged `[NEW]` / `[STILL OPEN — Nth run]` / `[RESOLVED]`. Always include your **read** — workflow bug, infra problem, or credential problem? Raw stack traces don't help; interpretation does. Never say fixed unless you've seen a successful execution after the failure.
|
||||
|
||||
## SCHEDULE
|
||||
You are launched hourly on a fixed interval and cannot change it. Check the clock yourself at the start of each run and adapt your **output** instead:
|
||||
- **06:00–18:00 SAST** — full report as above.
|
||||
- **18:00–06:00 SAST** — one line only, unless something is genuinely broken. Do not wake David for "all clear".
|
||||
|
||||
Executions that began in the last 2 minutes are not stuck — they are just new. Ignore them when applying the step-2 thresholds.
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
# AI order assistant runbook
|
||||
|
||||
Design: `docs/superpowers/specs/2026-10-24-ai-order-assistant-design.md`.
|
||||
Plan: `docs/superpowers/plans/2026-10-24-ai-order-assistant.md`.
|
||||
|
||||
## What it does
|
||||
|
||||
An order that came from a quote should point at that quote. Today only an
|
||||
order created *from* a quote carries the link; every other order — a
|
||||
walk-in, an email intake, a counter sale — has to be linked by hand.
|
||||
|
||||
The order assistant works out which quote an order came from. On a
|
||||
schedule it looks at orders that have no quote yet, asks the AI which of
|
||||
the candidate quotes the order came from, and then either:
|
||||
|
||||
- **links** the order to the quote itself (when it is sure), or
|
||||
- **suggests** the quote, and a person accepts it.
|
||||
|
||||
Once the order points at a quote, the assistant can also **fill the
|
||||
order's lines** from that quote — the cost, the supplier, the SKU and the
|
||||
part number — leaving them in **purple** until a person checks them.
|
||||
|
||||
The AI never changes a line's status, never creates a purchase order and
|
||||
never sends anything. It decides which quote an order belongs to, and it
|
||||
may pre-fill a line's prices from that quote.
|
||||
|
||||
## The auto-link rule, in plain words
|
||||
|
||||
The assistant links an order to a quote **only** when all of these are
|
||||
true:
|
||||
|
||||
- The **"Auto-link when sure"** setting is on (it is on by default).
|
||||
- The AI says it is **sure** about the match.
|
||||
- **Every** real order line is paired to a quote line, and each of those
|
||||
pairs is a **sure** match.
|
||||
- **Every** pair points at a line of the **one** chosen quote (not a
|
||||
mixture of two quotes).
|
||||
- The chosen quote belongs to the **same company** as the order.
|
||||
|
||||
If any of those fail, the assistant only writes a **suggestion**. The
|
||||
order page then shows "AI suggests Q…" with the reason, the doubts and the
|
||||
line-by-line pairing, and the existing **Link** button is there to accept
|
||||
it.
|
||||
|
||||
The assistant also refuses to link when PocketBase says no — for example
|
||||
when a line on the order has already moved past `pending`, or when
|
||||
another order already points at that quote. A refusal is **not** an error:
|
||||
the assistant falls back to a suggestion.
|
||||
|
||||
## Filling the lines from the quote
|
||||
|
||||
With **"Fill lines from the quote"** on, the assistant prices the order's
|
||||
lines from the quote they are paired with. This is a copy, not a new
|
||||
price: no AI call is involved, the pairing is already on the lines.
|
||||
|
||||
A line may be filled only when **all** of these are true:
|
||||
|
||||
- It is still `pending`.
|
||||
- It carries **no cost** of its own (`cost_price` is 0).
|
||||
- Its `pricing_source` is not `manual` and not `quote` — a price a person
|
||||
set is never overwritten.
|
||||
- It has no **reviewed** AI fill on it.
|
||||
- It is paired to a quote line (`quote_item` is set) that actually has a
|
||||
cost.
|
||||
|
||||
What the fill writes:
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `cost_price` | The quote line's cost |
|
||||
| `selling_price` | Only when the order line has none of its own |
|
||||
| `supplier`, `sku`, `matched_price_list_id` | Only when the order line has none of its own |
|
||||
| `markup` + `markup_percent` | Written together, from cost and selling |
|
||||
| `pricing_source` | `ai_quote` |
|
||||
| `ai_fill` | The audit record of the fill (see below) |
|
||||
|
||||
Every line is **re-read immediately before** it is written, so a cost a
|
||||
person typed in the meantime wins over the AI's.
|
||||
|
||||
If the customer's price and the quoted price both exist and differ by more
|
||||
than **5%**, the fill records a doubt, e.g. `customer price R16.64, quoted
|
||||
R18.20`. Doubts show on the line's marker.
|
||||
|
||||
### The purple review
|
||||
|
||||
A filled line is **pending review** while all three are true:
|
||||
|
||||
- `ai_fill.status` is `filled`,
|
||||
- `ai_fill.reviewed_at` is empty, and
|
||||
- `pricing_source` is still `ai_quote`.
|
||||
|
||||
While a line is pending it shows up **purple** on the order Process page,
|
||||
with the bot marker next to it. Hover or focus the marker to see the quote
|
||||
it was filled from, the cost, the selling price, the markup, the reason and
|
||||
any doubts.
|
||||
|
||||
The purple ends the moment somebody touches the line:
|
||||
|
||||
- **Saving the line** records the review on `ai_fill` (`reviewed_at`,
|
||||
`reviewed_by`, and whether the prices were accepted unchanged or
|
||||
changed). The pricing source stays `ai_quote`.
|
||||
- The one-click **"use this cost"** copy writes `pricing_source: "quote"`
|
||||
— the operator chose the quote's cost themselves — so the AI's
|
||||
provenance ends too.
|
||||
- A line whose price the operator types by hand becomes `pricing_source:
|
||||
"manual"`.
|
||||
|
||||
### The board badge
|
||||
|
||||
The Orders board shows an **"AI x/y"** badge on an order when the AI
|
||||
filled some of its lines: `x` is how many lines still need a review, `y`
|
||||
is how many the AI filled. The badge is hidden when nothing is pending.
|
||||
|
||||
### `order_items.ai_fill`
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `status` | `filled`, `skipped` or `error` |
|
||||
| `source` | `quote` |
|
||||
| `quote_id`, `quote_number`, `quote_item_id` | Where the numbers came from |
|
||||
| `cost_price`, `selling_price`, `markup` | What was written |
|
||||
| `supplier_id`, `sku` | What was written |
|
||||
| `reason` | Why the line was paired with that quote line |
|
||||
| `doubts` | Anything a person should check (e.g. the 5% price difference) |
|
||||
| `model`, `attempted_at` | Which model, and when |
|
||||
| `reviewed_at`, `reviewed_by` | Set when a person checked the line |
|
||||
| `accepted_unchanged` | True when the person kept both prices |
|
||||
| `supplier_changed` | True when the person changed the supplier |
|
||||
|
||||
## Going live (manual steps, in this order)
|
||||
|
||||
1. **Migrate PocketBase.** Run against the live PB:
|
||||
`npm run pb:migrate:order-assistant`
|
||||
(adds `orders.ai_quote_match`, `order_items.ai_fill` and the
|
||||
`order_assistant_*` fields on `ai_settings`). If the app is deployed
|
||||
before this, PocketBase drops the unknown fields silently and the
|
||||
assistant's result lands nowhere.
|
||||
2. **Publish the PB hook change.** `ai_quote_match` and `ai_fill` were
|
||||
added to `IGNORED_FIELDS` in `pocketbase/pb_hooks/audit_log_utils.js`,
|
||||
so the AI's own writes do not flood the audit log. Publish it the same
|
||||
way the other hook changes are published
|
||||
(`npm run pb:hooks:publish`).
|
||||
3. **Deploy** the app. Pushing to main does not auto-deploy; it is a
|
||||
manual deploy that takes 5 to 10 minutes.
|
||||
4. **Clear the old rule-based suggestions.** Quote matching is AI-only:
|
||||
the rule-based matcher no longer runs (not at intake, not on the order
|
||||
page, not in the sweep). The suggestions it left behind are wrong far
|
||||
too often — on 2026-10-24 there were 2,888 on unlinked orders, one
|
||||
quote suggested to 309 of them. Clear them — **dry run first**:
|
||||
```bash
|
||||
npx tsx scripts/2026-10-24-clear-rule-quote-suggestions.ts
|
||||
npx tsx scripts/2026-10-24-clear-rule-quote-suggestions.ts --apply
|
||||
```
|
||||
It only clears `potential_quote` on orders with no hard link whose
|
||||
`quote_match_method` is not an AI one; links and AI suggestions stay.
|
||||
5. **Activate the n8n clock** (below).
|
||||
6. **Turn it on** in Settings, AI: the global "AI enabled" switch must be
|
||||
on, then switch on **"Order assistant enabled"** in the Order assistant
|
||||
section and click Save. Defaults: every 10 minutes, auto-link on, fill
|
||||
lines on.
|
||||
|
||||
The tick does nothing until both switches are on, so steps 5 and 6 are
|
||||
safe in either order.
|
||||
|
||||
## n8n workflow: "WFDASH Order assistant tick"
|
||||
|
||||
n8n only acts as a clock. The app decides whether a run is due (AI and
|
||||
the assistant on, and at least "Run every" minutes since the last run), so
|
||||
the setting in the app controls the real frequency.
|
||||
|
||||
Spec:
|
||||
|
||||
| Node | Setting |
|
||||
|---|---|
|
||||
| Name | `WFDASH Order assistant tick` |
|
||||
| Trigger | Schedule Trigger, every 5 minutes |
|
||||
| Action | HTTP Request |
|
||||
| Method / URL | `POST` `https://wfdash.apphub.co.za/api/v1/orders/assistant/tick` |
|
||||
| Authentication | Generic Credential Type, Header Auth, **the same credential** the quote assistant tick workflow uses (an `Authorization: Bearer <AGENT_API_KEY>` header). Do not create a new secret. |
|
||||
| Body | none |
|
||||
| Settings tab | Retry On Fail: **off**; Timeout: **120000 ms** (120 s) |
|
||||
| Status | **inactive** until the app is deployed and migrated |
|
||||
|
||||
Click-by-click:
|
||||
|
||||
1. n8n, Workflows, Create workflow. Name it `WFDASH Order assistant tick`.
|
||||
2. Add node, Schedule Trigger. Trigger Interval: Minutes, Minutes Between
|
||||
Triggers: `5`.
|
||||
3. Add node, HTTP Request, connect it after the trigger.
|
||||
- Method: `POST`.
|
||||
- URL: `https://wfdash.apphub.co.za/api/v1/orders/assistant/tick`.
|
||||
- Authentication: Generic Credential Type, Generic Auth Type: Header
|
||||
Auth. Pick the credential used by the quote assistant tick workflow
|
||||
(open that workflow and read its HTTP Request node to see which one).
|
||||
- Options, Add option, Timeout: `120000`.
|
||||
- Settings tab on the node: turn **Retry On Fail** off.
|
||||
4. Save. Leave the workflow **inactive** until the steps above are done.
|
||||
5. After deploy, click Execute workflow once. Expected response while the
|
||||
assistant is off: `{"ran":false,"reason":"disabled"}`. When on and not
|
||||
yet due: `{"ran":false,"reason":"not_due"}`. While an earlier run is
|
||||
still going: `{"ran":false,"reason":"running"}`. When it ran:
|
||||
`{"ran":true,"summary":{...}}`.
|
||||
6. Toggle the workflow Active.
|
||||
|
||||
A 401 means the credential is wrong (it must send the app's
|
||||
`AGENT_API_KEY` as a Bearer token). A tick that takes long is normal when
|
||||
there is work. Each run is bounded: it matches **at most 10 orders** and
|
||||
fills **at most 10 orders**, and it stops starting new orders once 90
|
||||
seconds have passed, so a run normally answers inside the 120 s timeout;
|
||||
leftovers carry to the next run.
|
||||
|
||||
An order the tick has nothing to do with does **not** use up that budget:
|
||||
the run looks through up to 200 unlinked orders to find 10 it can work
|
||||
on, and an order whose lines the fill could not write is skipped rather
|
||||
than counted.
|
||||
|
||||
Runs never overlap. A run stamps its start time before it does any work and
|
||||
writes its summary when it ends. While a start is newer than the last
|
||||
summary and less than 10 minutes old, other ticks answer
|
||||
`{"ran":false,"reason":"running"}`. If a run dies without writing its
|
||||
summary, the next tick after those 10 minutes takes over.
|
||||
|
||||
## Running it by hand
|
||||
|
||||
- **Order page** — the **"Match with AI"** button on the quote match card
|
||||
runs the assistant for that one order and reloads the order. It also
|
||||
fills the lines when the order ends up linked.
|
||||
- **MCP tool** — `orders.assistant_match` with `{"orderId": "..."}`.
|
||||
|
||||
Both force a run past the "already looked at these lines" rule.
|
||||
|
||||
## Reading `ai_quote_match`
|
||||
|
||||
`orders.ai_quote_match` (JSON) is the record of what the assistant decided
|
||||
about an order. Read it in the PB admin.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `status` | `matched` (the order was linked), `suggested`, `no_match` or `error` |
|
||||
| `quote_id`, `quote_number` | The quote the assistant chose |
|
||||
| `confidence` | `high`, `medium` or `low` |
|
||||
| `reason`, `doubts` | The AI's plain-language reasoning and anything to check |
|
||||
| `lines` | The pairing: order line id → quote line id, with `same_item` (`sure` / `likely` / `unsure`) |
|
||||
| `auto_linked` | True only when the assistant itself created the link |
|
||||
| `lines_hash` | Fingerprint of the order lines the run looked at — the same lines are not looked at twice |
|
||||
| `model` | The model that answered |
|
||||
| `run_at` | When the run happened |
|
||||
| `error` | Set when `status` is `error` |
|
||||
|
||||
## Settings (Settings → AI → Order assistant)
|
||||
|
||||
| Setting | Default | Meaning |
|
||||
|---|---|---|
|
||||
| Order assistant enabled | off | The tick does nothing while this is off |
|
||||
| Run every (minutes) | 10 | How often the tick may run |
|
||||
| Auto-link when sure | on | May the assistant link the order itself? |
|
||||
| Fill lines from the quote | on | May the assistant pre-fill the order's lines from the linked quote? |
|
||||
| Prompt | empty | Overrides the built-in prompt when set |
|
||||
|
||||
`orders.ai_quote_match` and `order_items.ai_fill` are in `IGNORED_FIELDS`
|
||||
of the audit hook, so the assistant's own writes do not flood the audit
|
||||
log.
|
||||
|
||||
## Not in this phase
|
||||
|
||||
- The assistant does not price a line from scratch — it only copies the
|
||||
cost, supplier, SKU and part number from the quote line the order line
|
||||
is already paired with.
|
||||
- It never creates a purchase order, never sends an email and never
|
||||
changes a line's status.
|
||||
- There is no POS fallback: the assistant fills only from a quote.
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
# AI quote assistant runbook
|
||||
|
||||
Design: `docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md`.
|
||||
|
||||
## What it does
|
||||
|
||||
On a schedule, the assistant looks at quote lines nobody has priced yet,
|
||||
shortlists matching items from the POS catalogue (`stock_items`), asks the
|
||||
AI (the provider set in Settings, AI) to pick the right one, and writes a
|
||||
**draft** cost, selling price and supplier onto the line. A rep checks the
|
||||
draft and saves as usual.
|
||||
|
||||
The shortlist is **size-aware**. The size is parsed out of the line
|
||||
(`src/lib/item-size.ts`) and the candidates are ranked by it, so
|
||||
"BOLT HEX M16X60 GR8.8 ZP" is offered the M16x60 bolt and not the M22x65
|
||||
that merely shares the words "BOLT 8.8 ZP". The AI then answers **exact**,
|
||||
**closest** or **none** per line:
|
||||
|
||||
- **exact** — the same product and every size attribute the line states
|
||||
matches. The price is written.
|
||||
- **closest** — the nearest item, but something differs. **No price is
|
||||
written**; the item and what differs are recorded in `ai_fill.closest`
|
||||
and the badge reads "Closest: …".
|
||||
- **none** — nothing in the catalogue is close enough. No price.
|
||||
|
||||
- Filled lines show purple with an agent logo on the quote and Process
|
||||
pages. Clicking the logo shows the POS item, price source, reason and
|
||||
doubts. A small warning-coloured dot on the logo means the AI noted
|
||||
doubts to check.
|
||||
- Lines it could not match stay unpriced and get a muted "AI: no match"
|
||||
badge listing the closest POS items.
|
||||
- The Quotes board shows "AI x/y" (unreviewed AI fills / total lines).
|
||||
- The quote and Process pages have a "Let AI fill this quote" button for
|
||||
one quote now. MCP tool: `quotes.assistant_fill`. On the Process page,
|
||||
lines you have typed in but not saved are left as you typed them.
|
||||
|
||||
Only quotes with status **new**, **follow_up** or **claimed** are filled,
|
||||
never templates. Up to 15 lines are sent per quote per run (one AI call);
|
||||
the rest follow on the next run.
|
||||
|
||||
It never sets `is_processed`, never changes quote status, never sends
|
||||
email, and never overwrites a line that already has a price, was edited by
|
||||
a person, or is processed. Prices come only from the POS item. A supplier
|
||||
already on the line is kept; the POS item's supplier is only written when
|
||||
the line has none.
|
||||
|
||||
## Going live (manual steps, in this order)
|
||||
|
||||
1. **Migrate PocketBase.** Run against the live PB:
|
||||
`npm run pb:migrate:quote-assistant`
|
||||
(adds `quote_items.ai_fill` and the `quote_assistant_*` fields on
|
||||
`ai_settings`). If the app is deployed before this, the Quote assistant
|
||||
settings don't persist (the switch, interval, confidence and prompt
|
||||
come back as defaults after Save), so the assistant stays off.
|
||||
2. **Deploy** the app. Pushing to main does not auto-deploy; it is a
|
||||
manual deploy that takes 5 to 10 minutes.
|
||||
3. **Activate the n8n clock** (below).
|
||||
4. **Turn it on** in Settings, AI: the global "AI enabled" switch must be
|
||||
on, then switch on "Quote assistant enabled" in the Quote assistant
|
||||
section and click Save. Defaults: every 10 minutes, minimum confidence
|
||||
Medium, prompt empty (= built-in default).
|
||||
|
||||
The tick does nothing until both switches are on, so steps 3 and 4 are
|
||||
safe in either order.
|
||||
|
||||
## n8n workflow: "WFDASH Quote assistant tick"
|
||||
|
||||
n8n only acts as a clock. The app decides whether a run is due (AI and the
|
||||
assistant on, and at least "Run every" minutes since the last run), so the
|
||||
setting in the app controls the real frequency.
|
||||
|
||||
Spec:
|
||||
|
||||
| Node | Setting |
|
||||
|---|---|
|
||||
| Name | `WFDASH Quote assistant tick` |
|
||||
| Trigger | Schedule Trigger, every 5 minutes |
|
||||
| Action | HTTP Request |
|
||||
| Method / URL | `POST` `https://wfdash.apphub.co.za/api/v1/quotes/assistant/tick` |
|
||||
| Authentication | Generic Credential Type, Header Auth, **the same credential** the existing quotes `process-pending` workflow uses (an `Authorization: Bearer <AGENT_API_KEY>` header). Do not create a new secret. |
|
||||
| Body | none |
|
||||
| Settings tab | Retry On Fail: **off**; Timeout: **120000 ms** (120 s) |
|
||||
| Status | **inactive** until the app is deployed and migrated |
|
||||
|
||||
Click-by-click:
|
||||
|
||||
1. n8n, Workflows, Create workflow. Name it `WFDASH Quote assistant tick`.
|
||||
2. Add node, Schedule Trigger. Trigger Interval: Minutes, Minutes Between
|
||||
Triggers: `5`.
|
||||
3. Add node, HTTP Request, connect it after the trigger.
|
||||
- Method: `POST`.
|
||||
- URL: `https://wfdash.apphub.co.za/api/v1/quotes/assistant/tick`.
|
||||
- Authentication: Generic Credential Type, Generic Auth Type: Header
|
||||
Auth. Pick the credential used by the quotes `process-pending`
|
||||
workflow (open that workflow and read its HTTP Request node to see
|
||||
which one).
|
||||
- Options, Add option, Timeout: `120000`.
|
||||
- Settings tab on the node: turn **Retry On Fail** off.
|
||||
4. Save. Leave the workflow **inactive** until steps 1 and 2 above are done.
|
||||
5. After deploy, click Execute workflow once. Expected response while the
|
||||
assistant is off: `{"ran":false,"reason":"disabled"}`. When on and not
|
||||
yet due: `{"ran":false,"reason":"not_due"}`. While an earlier run is
|
||||
still going: `{"ran":false,"reason":"running"}`. When it ran:
|
||||
`{"ran":true,"summary":{...}}`.
|
||||
6. Toggle the workflow Active.
|
||||
|
||||
A 401 means the credential is wrong (it must send the app's
|
||||
`AGENT_API_KEY` as a Bearer token). A tick that takes long is normal when
|
||||
there is work. Each run is bounded: at most 20 quotes and 60 lines, newest
|
||||
first, 15 lines per quote (one AI call per quote). It also stops starting
|
||||
new quotes once 90 seconds have passed, so a run normally answers inside
|
||||
the 120 s timeout; leftovers carry to the next run.
|
||||
|
||||
Runs never overlap. A run stamps its start time before it does any work and
|
||||
writes its summary when it ends. While a start is newer than the last
|
||||
summary and less than 10 minutes old, other ticks answer
|
||||
`{"ran":false,"reason":"running"}`. If a run dies without writing its
|
||||
summary, the next tick after those 10 minutes takes over.
|
||||
|
||||
## Reading `ai_fill`
|
||||
|
||||
`quote_items.ai_fill` (JSON) is the record of what the AI did to a line.
|
||||
Read it in the PB admin, or through the MCP `quote_items.get`.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `status` | `filled` (an **exact** match — the only status that writes a price), `no_match` (no exact match; `closest` may say what the nearest item was), `no_candidates` (nothing in the POS shortlist, no AI call made) or `error` (the shortlist or AI call failed, the AI answer was cut off, or it skipped the line; retried after 1 hour or by the manual button) |
|
||||
| `stock_item_id`, `stock_item_name`, `stock_item_sku` | The POS item picked. The POS SKU lives here only; it is never copied onto the line's own `sku`. |
|
||||
| `cost_price`, `selling_price`, `markup`, `supplier_id` | What was written to the line, taken from the POS item |
|
||||
| `price_from_default_markup` | Selling price came from the default markup because the POS item had a cost but no selling price |
|
||||
| `confidence` | `high`, `medium` or `low` |
|
||||
| `reason`, `doubts` | The AI's plain-language reasoning and any mismatches to check (grade, size, pack size, etc.) |
|
||||
| `candidates` | Closest POS items looked at (for unmatched lines) |
|
||||
| `closest` | The nearest POS item when the AI could only find a close one — `stock_item_id`, `name`, `sku`, `cost_price`, `selling_price` and `differs`. The line keeps **no** price. |
|
||||
| `description_snapshot` | The line description when tried. An unmatched line is only retried if the description changes. |
|
||||
| `model`, `attempted_at` | Which model, when |
|
||||
| `reviewed_at`, `reviewed_by` | Set when a rep saves the line |
|
||||
| `accepted_unchanged` | On review: cost and selling price still equal what the AI wrote |
|
||||
| `supplier_changed` | On review: the saved supplier differs from the POS item's supplier (the rep changed it, or the line already had another supplier) |
|
||||
|
||||
A line is **pending review** (purple) while
|
||||
`ai_fill.status = "filled"`, no `reviewed_at`, and
|
||||
`pricing_source = "ai_pos"`. The rep's save sets `reviewed_at`; the line
|
||||
keeps `pricing_source = "ai_pos"` and a grey logo. Every AI write is in the
|
||||
audit log under the actor "AI quote assistant", or "AI quote assistant (run
|
||||
by <name>)" for the button (and "(run by MCP)" for the MCP tool). Those
|
||||
writes are never credited to the person who clicked, even though the click
|
||||
carries their login.
|
||||
|
||||
Clone and Save as template copy an unreviewed AI line without its cost,
|
||||
selling price, markup or supplier, so the copy can't carry an unchecked
|
||||
price.
|
||||
|
||||
The last run is shown in Settings, AI, Quote assistant, and stored in
|
||||
`ai_settings.quote_assistant_last_run_summary` (quotes seen, lines filled,
|
||||
no match, skipped, errors).
|
||||
|
||||
## Judging accuracy
|
||||
|
||||
Among reviewed AI fills (`ai_fill.status = "filled"` and `reviewed_at`
|
||||
set), the share with `accepted_unchanged = true` is the headline number:
|
||||
how often a rep saved the AI's price as it was. Also watch
|
||||
`supplier_changed` (supplier was wrong even when the price was right).
|
||||
|
||||
To tally: fetch `quote_items` where `pricing_source = "ai_pos"` (for
|
||||
example with `quote_items.list` or a short script), keep the ones whose
|
||||
`ai_fill.reviewed_at` is set, and compare `accepted_unchanged` true versus
|
||||
false.
|
||||
|
||||
Rough guide: the default minimum confidence is Medium. If reps often
|
||||
change AI prices, raise it to High (only the AI's surest matches are
|
||||
written) or edit the prompt (Settings, AI, Quote assistant prompt; "Reset
|
||||
to default" restores the built-in one). If most reviewed fills are
|
||||
unchanged at High, Medium is worth trying again.
|
||||
|
||||
## Stopping it
|
||||
|
||||
Switch off "Quote assistant enabled" in Settings, AI and Save, or
|
||||
deactivate the n8n workflow. Existing purple lines stay until reviewed.
|
||||
|
|
@ -220,22 +220,14 @@ Calls `attachments.upload_for_quote` via the WFDASH MCP. Reads the binary from `
|
|||
> Workflow:
|
||||
> 1. Read the email body (in your user message). For each attachment, also look at the extracted_text_preview field — that is the first 2KB of the PDF text. If the email body is short or empty, the line items are almost certainly in the PDF.
|
||||
> 2. Extract the buyer/end-customer company name (company_hint). Empty string if unclear.
|
||||
> 3. Extract the customer being quoted FOR (person_hint). **This is NOT the email sender** — when a rep (e.g. Gary, the South Africa field rep) forwards an inbound RFQ or sends a quote request for a customer, the sender is Gary and the actual customer is whoever Gary named in the subject line, the email body, or a signature. Examples:
|
||||
> - Subject `Quote for John Smith at ABC Mining` → person_hint=`John Smith`, company_hint=`ABC Mining`
|
||||
> - Subject `RE: Bearings enquiry — Peter Jones (Acme)` → person_hint=`Peter Jones`, company_hint=`Acme`
|
||||
> - Subject `RFQ from John Smith` (Gary forwarding) → person_hint=`John Smith`
|
||||
> - Subject `Quote request` with body `Please quote for Sarah at TechCo` → person_hint=`Sarah`, company_hint=`TechCo`
|
||||
>
|
||||
> If no specific customer is named and the email IS from the customer themselves (the from_email address is their personal / corporate domain, not a wfsales.co.za rep), set person_hint to empty string — the from_email will resolve correctly. Only set person_hint when the sender is clearly NOT the customer.
|
||||
> 4. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.
|
||||
> 5. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase. Include `person_hint` and `company_hint` in the call.
|
||||
> 6. Capture the quote_id from the tool result.
|
||||
> 7. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.
|
||||
> 8. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=... person_hint=...
|
||||
> 3. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.
|
||||
> 4. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase.
|
||||
> 5. Capture the quote_id from the tool result.
|
||||
> 6. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.
|
||||
> 7. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=...
|
||||
>
|
||||
> Important:
|
||||
> - Empty body / no items is OK — create the quote anyway with an empty extracted_items array.
|
||||
> - Empty person_hint is OK when from_email IS the customer (typical direct RFQ).
|
||||
> - attachments metadata only (no base64) — the binary lives in the workflow's binary store; only upload_attachment_tool knows how to read it.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
# Quote Outbound Send — operator-uploaded attachments fix
|
||||
|
||||
Deployed 2026-09-09 alongside the `quotes.outbound_attachments` /
|
||||
`quotes.internal_attachments` storage split (the migration that
|
||||
moved operator-uploaded files off the legacy `quote_attachments`
|
||||
collection's `direction` enum).
|
||||
|
||||
## What the deployed workflow does now
|
||||
|
||||
The active n8n workflow is `WFDASH — Quote Outbound Send
|
||||
(push-payload + outbound-attachments)` (id `PIEAkPYYYZLGl6Pj`,
|
||||
previously named `…(push-payload)`).
|
||||
|
||||
The chain still ends in `Send Email (SMTP)` with the auto-generated
|
||||
PDF attached — same as before — **plus** a new `Build attachments
|
||||
list` Code node that runs between `HTTP Request` (PDF download) and
|
||||
the SMTP node. It:
|
||||
|
||||
1. Reads `quotes.outbound_attachments` (the PB file field on the
|
||||
parent quote row) and `email_outbound_attachment_ids` (the JSON
|
||||
array of operator-selected tokens persisted by
|
||||
`sendQuoteEmail`).
|
||||
2. Intersects the two so only files the operator explicitly marked
|
||||
for the customer get attached. Defends against drift between the
|
||||
modal snapshot and the send.
|
||||
3. Downloads each token from
|
||||
`https://<PB>/api/files/quotes/<quoteId>/<token>` via
|
||||
`this.helpers.httpRequest`.
|
||||
4. Returns a single item with `attachments: [{name, content (base64),
|
||||
type}, …]` on the JSON — same shape the deployed `WFDASH —
|
||||
Purchase Order Send` workflow consumes.
|
||||
5. Per-download failures are skipped (not raised) so a single bad
|
||||
token doesn't abort the whole send. Operator sees the final count
|
||||
via `attachmentCount`.
|
||||
|
||||
The SMTP node now consumes the array via
|
||||
`options.attachments={{ $json.attachments && $json.attachments.length
|
||||
? $json.attachments : [] }}` — matching the PO Send pattern. The
|
||||
previous `options.fileAttachments=data` (PDF only) is removed.
|
||||
|
||||
## Bonus fix: failure status `send_failed` → `mail_failed`
|
||||
|
||||
The deployed `Update quote - Send Failed` node previously flipped a
|
||||
failed send to status `send_failed`. The dashboard's `retryQuoteEmail`
|
||||
only accepts `mail_failed`, so failed sends were stranded — operator
|
||||
couldn't retry from the UI. The failure path now flips to
|
||||
`mail_failed`. Stale `send_failed` rows (if any pre-existed) can be
|
||||
re-categorised with a one-shot PB update if the operator wants the
|
||||
tab badge to count them.
|
||||
|
||||
## Why the repo file was deleted
|
||||
|
||||
`n8n_workfows/WFDASH — Quote Outbound Send.json` (the pre-existing
|
||||
repo file) was **stale** — it referenced a non-existent MCP tool
|
||||
(`quotes.get_send_payload`), the old row-ID model
|
||||
(`selected_attachment_ids` + `pdf_attachment_id`), `$env.PB_ADMIN_TOKEN`
|
||||
(which the docker-compose blocks via
|
||||
`N8N_BLOCK_ENV_ACCESS_IN_NODE=true`), and field names that had
|
||||
been renamed (`email_message_id`, `email_delivered`, …). It did
|
||||
**not** match the deployed workflow and would have misled anyone
|
||||
debugging the system. Deleted.
|
||||
|
||||
A clean snapshot of the previously-deployed workflow is preserved at
|
||||
`n8n_workfows/WFDASH — Quote Outbound Send (push-payload).baseline.json`
|
||||
for diff/rollback reference. The post-fix deployed workflow is in
|
||||
`n8n_workfows/WFDASH — Quote Outbound Send
|
||||
(with-outbound-attachments).json`.
|
||||
|
||||
## MCP `quotes.send_email` description
|
||||
|
||||
Updated to spell out the new field semantics: "`selectedAttachmentIds`
|
||||
is a list of PB file tokens from `quotes.outbound_attachments`
|
||||
(operator-uploaded customer-facing files — supplier quotes, drawings,
|
||||
NDAs). The PDF is always attached separately from
|
||||
`quote_attachments` kind="pdf"`. Also added a per-property
|
||||
description on the input schema and updated the `docs/api-mcp-layer.md`
|
||||
table to match.
|
||||
|
||||
## Verification
|
||||
|
||||
| Layer | Result |
|
||||
|---|---|
|
||||
| Deployed workflow via n8n REST | PUT returned HTTP 200, `active=true`, node count 24 → 25, new connections verified |
|
||||
| `quotes-service.test.ts` | 117 pass, 4 pre-existing failures (stale-PDF auto-regen — unrelated, baseline fails too) |
|
||||
| `mcp/route.test.ts` | 118 pass, 2 pre-existing failures (unrelated, baseline fails too) |
|
||||
| Code node JS syntax | `node --check` passes |
|
||||
|
||||
## Files touched
|
||||
|
||||
- **Deleted**: `n8n_workfows/WFDASH — Quote Outbound Send.json`
|
||||
- **Added**:
|
||||
- `n8n_workfows/WFDASH — Quote Outbound Send (push-payload).baseline.json`
|
||||
- `n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json`
|
||||
- `docs/quotes-outbound-attachments-deploy.md`
|
||||
- **Modified**:
|
||||
- `src/app/api/mcp/route.ts` (tool description + `selectedAttachmentIds` schema property)
|
||||
- `src/app/api/v1/quotes/[id]/send-email/route.ts` (docstring only)
|
||||
- `docs/api-mcp-layer.md` (table row)
|
||||
- **Deployed**: workflow `PIEAkPYYYZLGl6Pj` in the live n8n instance
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
# Rep Performance Report — Data Source & Provenance
|
||||
|
||||
> **Audience:** anyone who asks where the numbers come from — a manager, an
|
||||
> auditor, a customer-facing colleague.
|
||||
> **Scope:** `/reports/rep-performance` (summary, per-rep detail, team
|
||||
> comparison) and the PDF download.
|
||||
> **Author context:** WFDash is the operator app (Next.js + PocketBase). This doc
|
||||
> describes where every figure on the report originates, so the report can be
|
||||
> defended without reading the code.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
The report is **two separate things stitched together**, and it matters that
|
||||
you can tell them apart:
|
||||
|
||||
| | **A — Activity** | **B — Time log** |
|
||||
|---|---|---|
|
||||
| What it is | Quotes, orders, delivery notes, customers, companies | "Where the time went", visit map, hours per place |
|
||||
| Source | **WFDASH itself** (PocketBase) | **Hardwired vehicle tracker** (GPS unit in the truck) |
|
||||
| Entered by | Reps working in the app; some quotes arrive by email automation | A CSV export from the tracker portal, imported by a manager |
|
||||
| On a phone? | No — server-side database | No — a fixed vehicle unit, not a personal phone |
|
||||
| Pages in the PDF | 1, 3, 4, 5, 6 | 2 (and the map behind it) |
|
||||
|
||||
**The charts anyone is likely to challenge — quotes loaded, orders placed,
|
||||
customers visited, companies — are all Section A, and every one of them is a
|
||||
read-only count of rows that already exist in WFDASH.** Nothing on those pages
|
||||
is typed in by a person for reporting purposes; the report only counts work
|
||||
the reps already did in the normal course of business.
|
||||
|
||||
The only non-WFDASH input is Section B, and it is confined to two clearly
|
||||
labelled pages.
|
||||
|
||||
---
|
||||
|
||||
## 1. Topology — where the data physically comes from
|
||||
|
||||
```
|
||||
WHO ENTERS THE DATA
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
▼ ▼
|
||||
Reps working in the app n8n email intake
|
||||
quotes · orders quotes from RFQ mail
|
||||
│ │
|
||||
│ order_items │
|
||||
│ (cancellation check) │
|
||||
│ │
|
||||
└───────────┬───────────┘
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ delivery_notes │ raised & completed
|
||||
└─────────┬─────────┘
|
||||
▼
|
||||
══════════════════════════════════════════════════════
|
||||
WFDASH → POCKETBASE (system of record)
|
||||
quotes · quotes_items · orders · order_items
|
||||
delivery_notes · customers · companies · users
|
||||
══════════════════════════════════════════════════════
|
||||
│
|
||||
│ read-only counts — nothing is created
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ REP PERFORMANCE │
|
||||
│ REPORT │ (this document)
|
||||
└─────────┬─────────┘
|
||||
▼
|
||||
screen · PDF download
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Section A — Activity metrics (system of record)
|
||||
|
||||
Every row below is a **count of existing WFDASH records** inside the selected
|
||||
date window. The report creates nothing, edits nothing, and nobody enters a
|
||||
"report number" anywhere.
|
||||
|
||||
| Report column | Collection | Field counted | Rule |
|
||||
|---|---|---|---|
|
||||
| **Quotes loaded (QL)** | `quotes` | `created` | Any quote created in the window. |
|
||||
| **Quotes done (QD)** | `quotes` | `created` + `status` | Quote **and** `claimed_by` set **and** status is `emailed`, `approved` or `sent`. |
|
||||
| **Orders placed (OP)** | `orders` | `created` | Order in the window, **minus** any order with at least one `order_items` row marked `cancelled`. |
|
||||
| **Delivery notes (DN)** | `delivery_notes` | `created` | DN in the window, excluding `cancelled`. |
|
||||
| **Orders received (OR)** | `delivery_notes` | `created` + `status` | DN in the window with status `completed`. |
|
||||
| **Customers / Companies** | `customers`, `companies` | — | Derived from the rows above; customers roll up to the company on the customer record. |
|
||||
| **Total** | — | — | Plain sum of the five metrics above. Not weighted, not scored. |
|
||||
|
||||
**Code of record:** `src/lib/server/rep-performance-service.ts` (the counting
|
||||
loop is `loadActivity`). The `QD` status list and the order-rep resolver are
|
||||
imported from `src/lib/server/boss-dashboard-service.ts`, so this report and
|
||||
the boss dashboard cannot drift apart.
|
||||
|
||||
### Date filtering
|
||||
|
||||
Every collection is filtered on the same generated window:
|
||||
|
||||
```
|
||||
created >= "<from> 00:00:00.000Z" && created < "<to+1 day> 00:00:00.000Z"
|
||||
```
|
||||
|
||||
The upper bound is pushed out one day so the last day is **inclusive** — a
|
||||
common off-by-one that would otherwise silently drop activity on the final day
|
||||
of the month.
|
||||
|
||||
---
|
||||
|
||||
## 3. Who gets the credit (attribution)
|
||||
|
||||
This is the part most worth explaining, because "whose number is it?" is the
|
||||
usual challenge.
|
||||
|
||||
| Metric | Credited to |
|
||||
|---|---|
|
||||
| Quotes loaded, Delivery notes, Orders received | The rep who **owns the customer** (`customers.rep`) |
|
||||
| Quotes done | The rep who **claimed the quote** (`quotes.claimed_by`) |
|
||||
| Orders placed | The rep on the **order itself** (`orders.rep`) — *not* the customer's owner |
|
||||
|
||||
Orders deliberately follow the order's own rep field, matching the boss
|
||||
dashboard. A rep who is covering someone else's customers shows their orders
|
||||
under their own name, which is the behaviour the business expects.
|
||||
|
||||
### ⚠️ Known quirk worth knowing about
|
||||
|
||||
`orders.rep` is a **free-text field** that over the years holds *either* a
|
||||
PocketBase user id *or* a person's name. The report resolves both: exact user
|
||||
id first, then a case-insensitive name match. This is not a cosmetic detail —
|
||||
for Tiaan in September 2026 it is the difference between 1 order and 27:
|
||||
|
||||
| How you look it up | Result |
|
||||
|---|---|
|
||||
| `orders.rep = "<user id>"` | 1 |
|
||||
| `orders.rep = "Tiaan"` | 26 |
|
||||
| **Report total** | **27** |
|
||||
|
||||
If someone queries the database themselves and gets a small number, this is
|
||||
why.
|
||||
|
||||
---
|
||||
|
||||
## 4. Section B — The time log (vehicle tracker, not WFDASH)
|
||||
|
||||
Stated plainly: **the "where the time went" page is not sourced from WFDASH.**
|
||||
It is sourced from a GPS tracking unit fitted to the vehicle. It is kept in a
|
||||
separate section of the report for exactly this reason, and it should be
|
||||
presented differently from Section A if it is challenged.
|
||||
|
||||
### 4.1 How it gets in
|
||||
|
||||
1. A manager downloads a **"Travel History" CSV** from the tracker's web portal.
|
||||
2. They upload it on the report page (*Import time log*).
|
||||
3. The file is parsed and written into PocketBase as `rep_trip_legs` — **one
|
||||
row per tracker interval, with no interpretation applied at import time.**
|
||||
|
||||
```
|
||||
══════ SEPARATE PROVENANCE — NOT WFDASH ══════
|
||||
|
||||
┌────────────────────────────────┐ hardwired unit in the truck,
|
||||
│ Vehicle GPS tracker │ logs position every few minutes
|
||||
│ (vehicle registered in │
|
||||
│ users.tracker_vehicle_id) │
|
||||
└────────────────────────────────┘
|
||||
│ manager downloads the CSV from the tracker
|
||||
│ portal, then uploads it on the report page
|
||||
▼
|
||||
┌────────────────────────────────┐ one row per tracker interval,
|
||||
│ rep_trip_legs │ stored in PocketBase as a copy
|
||||
│ (raw, un-interpreted) │
|
||||
└────────────────────────────────┘
|
||||
│ read-time derivation:
|
||||
│ work-hours clip · 250 m clustering ·
|
||||
│ company matching · operator labels
|
||||
▼
|
||||
┌────────────────────────────────┐ PDF page 2 + the visit map
|
||||
│ "Where the time went" │
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
Storing raw and deriving at read time is deliberate: if company coordinates or
|
||||
weekend rules are corrected tomorrow, historical reports improve immediately
|
||||
with **no re-import**.
|
||||
|
||||
Which vehicle belongs to which rep is recorded on the user
|
||||
(`users.tracker_vehicle_id`, e.g. `MW13TCGP`), so a CSV cannot be silently
|
||||
attached to the wrong person.
|
||||
|
||||
Re-importing a file **replaces only the dates that file covers**, so importing
|
||||
October does not destroy September.
|
||||
|
||||
### 4.2 What is derived (and therefore arguable)
|
||||
|
||||
| Derived value | Rule | Why it exists |
|
||||
|---|---|---|
|
||||
| Work hours | 07:30–17:00, local (SAST, UTC+2, no DST) | One global default across the fleet. |
|
||||
| Weekends | **Excluded** | A truck is often out or parked at weekends for reasons unrelated to selling. One flag flips this back on. |
|
||||
| Noise filter | Stops under **5 minutes** dropped | Traffic lights, gate checks, GPS drift. Applied at *period* level, so a place survives or drops whole rather than losing a 4-minute sliver. |
|
||||
| Clustering | Points within **250 m** of each other become one "place" | Keeps neighbouring mine gates apart while absorbing GPS jitter when parked. |
|
||||
| Place name | Matched against **`companies.coordinates` within 750 m** | `customers.coordinates` is deliberately **ignored** — those are residential addresses, and reporting "visited: a customer's house" would be worse than no name. |
|
||||
| Unnamed places | Operator-assigned labels, stored in `place_overrides`, matched by proximity (300 m) | Most company records have no coordinates yet, so an unnamed place is a placeholder the manager can name on the map. |
|
||||
|
||||
**Consequence to be honest about:** a place only carries a company name if the
|
||||
company has usable coordinates **or** someone has mapped it manually. Unmapped
|
||||
places show as a raw location, not as a company. The time log is a strong
|
||||
signal on *how the time was spent*; it is only as good as the mapping for
|
||||
*naming* it.
|
||||
|
||||
---
|
||||
|
||||
## 5. How to verify any number yourself
|
||||
|
||||
The report is read-only, so anyone with admin access can reproduce its figures
|
||||
directly from the database. Worked example — **Tiaan, September 2026**, the
|
||||
exact period in the sample PDF:
|
||||
|
||||
| Report says | Raw PocketBase query | Result |
|
||||
|---|---|---|
|
||||
| Orders placed = 27 | `orders` where `rep` = id **or** `"Tiaan"`, created in Sept | 1 + 26 = **27** ✓ |
|
||||
| Delivery notes = 3 | `delivery_notes` where `customer.rep` = Tiaan, `status != "cancelled"` | **3** ✓ |
|
||||
| Orders received = 2 | same, `status == "completed"` | **2** ✓ |
|
||||
| Time log rows = 692 | `rep_trip_legs` where `rep` = Tiaan | **692** ✓ |
|
||||
|
||||
The same check works in the WFDASH admin UI, or via the PocketBase admin API
|
||||
with a filter such as:
|
||||
|
||||
```
|
||||
customer.rep = "54uj39i4juqkh9x" && created >= "2026-09-01 00:00:00.000Z"
|
||||
&& created < "2026-10-01 00:00:00.000Z" && status != "cancelled"
|
||||
```
|
||||
|
||||
Every metric is a plain count of rows returned by a filter like this one. There
|
||||
is no spreadsheet step, no manual tally, and no intermediate cache to explain.
|
||||
|
||||
---
|
||||
|
||||
## 6. Which reps appear
|
||||
|
||||
The rep list is **not hardcoded**. Someone appears on the report if they own a
|
||||
customer, claimed a quote in the period, or carry the `REP` user type. This
|
||||
means:
|
||||
|
||||
- a new rep shows up without a code deploy;
|
||||
- a rep with no activity in the period drops off naturally;
|
||||
- accounts matching `test` / `qa` / `demo` are filtered out automatically.
|
||||
|
||||
The summary page can additionally hide low-activity reps via the **Min total**
|
||||
control; this is a display filter only and does not change any underlying total.
|
||||
|
||||
---
|
||||
|
||||
## 7. Honest limitations
|
||||
|
||||
Stated up front so nobody is caught out:
|
||||
|
||||
1. **"Orders received" is approximated by completed delivery notes.** The
|
||||
natural source would be `order_items.delivered_at`, but that field is empty
|
||||
on every current record, so a completed DN is used instead. If delivery-note
|
||||
completion is not always entered promptly, this figure runs behind reality.
|
||||
2. **Order cancellation is coarse.** An order with *any* cancelled line is
|
||||
excluded entirely, not just the cancelled portion.
|
||||
3. **The time log's place names depend on manual mapping** (section 4.2).
|
||||
4. **Work hours are a single fleet-wide default**, not per-rep shifts.
|
||||
5. **The report reflects the selected period only.** A quote loaded in August
|
||||
and sent in September counts its *completed* event in September.
|
||||
|
||||
---
|
||||
|
||||
## 8. Where the code lives
|
||||
|
||||
| Concern | File |
|
||||
|---|---|
|
||||
| Counting, attribution, company rollups | `src/lib/server/rep-performance-service.ts` |
|
||||
| Shared status rules (kept in step with boss dashboard) | `src/lib/server/boss-dashboard-service.ts` |
|
||||
| Tracker CSV parsing | `src/lib/server/travel-history-parser.ts` |
|
||||
| Time log import + read-time derivation | `src/lib/server/rep-time-log-service.ts` |
|
||||
| PDF layout (A4 portrait, 6 pages) | `src/lib/pdf/rep-performance-detail-document.tsx` |
|
||||
| API surface | `src/app/api/v1/reports/rep-performance/**` |
|
||||
| Schema for tracker data | `scripts/migrations/2026-09-28-create-rep-trip-legs.ts` |
|
||||
| Schema for operator place labels | `scripts/migrations/2026-09-28-create-place-overrides.ts` |
|
||||
|
||||
Both the counting service and the CSV parser are covered by unit tests
|
||||
(`rep-performance-service.test.ts`, `travel-history-parser.test.ts`), so the
|
||||
rules in this document are enforced by the build rather than by memory.
|
||||
|
||||
---
|
||||
|
||||
## 9. One-paragraph answer
|
||||
|
||||
> "The report counts records in our own WFDASH database. Quotes loaded, orders
|
||||
> placed and delivery notes are rows the reps created in the app while doing
|
||||
> their jobs — we just count them. Customers and companies are read straight
|
||||
> off those rows. The one exception is the 'where the time went' page, which
|
||||
> comes from the GPS unit fitted to the truck, exported as a CSV and imported
|
||||
> manually; it's a separate page for that reason. You can reproduce any number
|
||||
> in section A yourself by filtering the database on the rep and the date
|
||||
> range — the queries are in section 5."
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# WFDASH SOPs
|
||||
|
||||
Quick cheat sheets for each role in the business. Read your role's SOP
|
||||
on your first day. Keep it handy.
|
||||
|
||||
## The four roles
|
||||
|
||||
| File | What they do |
|
||||
|---|---|
|
||||
| [quote-operator.md](./quote-operator.md) | Answer the phone, price items, send quotes, follow up. |
|
||||
| [order-operator.md](./order-operator.md) | Turn accepted quotes into orders. Buy from suppliers. Get goods to the customer. |
|
||||
| [collection-driver.md](./collection-driver.md) | Drive to suppliers, collect the goods, deliver to customers. (Mobile app.) |
|
||||
| [approver-supervisor.md](./approver-supervisor.md) | Sign off quotes, run the weekly review, handle escalations. |
|
||||
|
||||
## How to read your SOP
|
||||
|
||||
Each one has four short sections:
|
||||
|
||||
1. **What you do** — your job, in plain English.
|
||||
2. **Your day** — the actions, in order, with times.
|
||||
3. **How to do the common stuff** — step-by-step for the 3-4 tasks
|
||||
you'll do most often.
|
||||
4. **Don'ts & when to ask** — the 3-4 things you must never do, and
|
||||
the 3-4 situations where you stop and ask the supervisor.
|
||||
|
||||
That's it. There are no bonus points for reading more.
|
||||
|
||||
## When something changes
|
||||
|
||||
If your job changes and the SOP doesn't match, tell the supervisor.
|
||||
They'll fix the SOP. Don't keep doing the old thing because the SOP
|
||||
said so.
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
# Approver / Supervisor — Quickstart
|
||||
|
||||
**One-line job:** keep the team unblocked, the system healthy, and
|
||||
the customers happy.
|
||||
|
||||
You don't do the pricing or the driving. You make sure the people
|
||||
who do have what they need, the system gives them the right signals,
|
||||
and the customers don't fall through the cracks.
|
||||
|
||||
## What you do
|
||||
|
||||
- Sign off quotes that landed in `/quotes/approvals`.
|
||||
- Handle escalations the operators bring to you (discount, cancellation,
|
||||
customer complaint, supplier dispute).
|
||||
- Pull the weekly KPI one-pager, run the Monday standup.
|
||||
- Write the Friday summary.
|
||||
- Set pricing policy when costs / markets shift.
|
||||
|
||||
## Your day
|
||||
|
||||
| Time | What to do |
|
||||
|---|---|
|
||||
| **08:00** | Open `/quotes/approvals`. Clear the queue. Anything > 4 hours old = bug. |
|
||||
| **08:30** | Glance at `/dashboard`. Approval rate, pipeline depth, follow-ups due. Anything red? |
|
||||
| **13:00** | Re-check `/quotes/approvals`. Anything still pending from this morning is now a bug. |
|
||||
| **17:00** | Triage escalations. Every escalation gets a decision or a clear next step. |
|
||||
|
||||
**Monday** (90 min block): pull the weekly KPI one-pager, run the
|
||||
standup (15 min), update pricing policy if needed.
|
||||
|
||||
**Friday** (afternoon): write the 1-page weekly summary, forecast
|
||||
next month's cash collection.
|
||||
|
||||
## How to do the common stuff
|
||||
|
||||
### 1. Approve a quote
|
||||
|
||||
1. Open `/quotes/approvals`. Oldest first.
|
||||
2. For each quote, check:
|
||||
- **Customer + rep** — known account? Standing discount?
|
||||
- **Every line priced** — any zeros? Refuse and send back.
|
||||
- **Markup** — does it match the policy for this category / customer?
|
||||
- **Validity date** — realistic?
|
||||
3. **Approve** (status → `approved`, operator can send) or
|
||||
**Request changes** (operator re-prices) or **Reject** (with
|
||||
a reason — not a silent "no").
|
||||
4. **Log the decision in the quote's notes** if it's non-obvious
|
||||
(e.g. "approved at 18% markup per Aug 2026 customer contract").
|
||||
|
||||
### 2. Handle a "should I give this discount?" escalation
|
||||
|
||||
- **< 5%** — the operator can do it. Tell them to apply and log.
|
||||
- **5-15%** — if strategic account OR one-off (large order, end-of-line),
|
||||
approve. Otherwise tell the operator to push back.
|
||||
- **> 15%** — you (or the owner) call the customer. Don't delegate.
|
||||
|
||||
### 3. Handle a "customer wants to cancel" escalation
|
||||
|
||||
- **No PO sent yet** — easy, cancel the line.
|
||||
- **PO sent, supplier hasn't confirmed** — phone the supplier NOW
|
||||
(most will cancel without charge if pre-confirm).
|
||||
- **PO confirmed, goods in transit / collected** — three options:
|
||||
accept and book to stock, refuse delivery (restocking fee?), or
|
||||
return-to-vendor. You decide based on the customer + the cost.
|
||||
|
||||
### 4. Set / change pricing policy
|
||||
|
||||
1. Identify the scope (category? customer? supplier?).
|
||||
2. Pull the data: current avg markup for that scope, competitor
|
||||
pricing, supplier cost trend.
|
||||
3. Document the change: scope, new range, effective date, why.
|
||||
4. Communicate: post in the team chat, mention Monday standup, pin
|
||||
in `/settings` if there's a policy field.
|
||||
5. Audit after 2 weeks — is the team actually applying it?
|
||||
|
||||
## Don'ts — the 3 things you must never do
|
||||
|
||||
1. **Never approve a quote without reading it.** 30-second reviews
|
||||
miss the wrong SKU, wrong qty, wrong customer. If you don't have
|
||||
time, do it later. Don't rubber-stamp.
|
||||
2. **Never reject an operator's escalation without a reason.** "No"
|
||||
is demoralising. "No, because the markup is below the policy
|
||||
for this category" is a teachable moment.
|
||||
3. **Never push a system-level decision to the operators.** If
|
||||
the data is wrong, the SOP is unclear, or a workflow is
|
||||
broken — that's yours to fix, not theirs to work around.
|
||||
|
||||
## When to escalate to the owner (the 4 most common situations)
|
||||
|
||||
- **Strategic account (top 10% by spend) wants bespoke terms.** Call
|
||||
the owner. Strategic accounts can have bespoke anything; it's
|
||||
a relationship call, not an SOP.
|
||||
- **A supplier wants to renegotiate terms that change > 20% of our
|
||||
spend.** Call the owner. This is a business call, not an
|
||||
operator call.
|
||||
- **A customer escalates a quality issue and asks for the owner /
|
||||
MD by name.** Call the owner. Don't try to be the buffer.
|
||||
- **Anything involving a refund > R50,000, a credit note > R100,000,
|
||||
or a write-off.** Call the owner. Documented, in writing.
|
||||
|
||||
## How you're measured (the 3 numbers that matter)
|
||||
|
||||
1. **Approval turnaround** — target: **< 4 working hours median.**
|
||||
(Quotes don't sit in your queue.)
|
||||
2. **Escalation response time** — target: **< 2 working hours.**
|
||||
(Operators aren't blocked waiting on you.)
|
||||
3. **Team KPI attainment** — target: **> 80% of operator KPIs at
|
||||
target** each week. (Your team is doing their job.)
|
||||
|
||||
Bonus number: **catalogue coverage** — target > 60% of quote items
|
||||
matched. If low, the Pricelist Curator needs to upload more supplier
|
||||
lists. That's your nudge, not theirs.
|
||||
|
||||
## Who to ask
|
||||
|
||||
- **The owner / MD** — for any of the 4 escalation situations above,
|
||||
anything strategic, anything you can't decide alone.
|
||||
- **Other supervisors / industry peers** — for pricing policy sanity
|
||||
checks ("does this markup make sense for JEI?").
|
||||
- **When in doubt, write it down.** If you made a decision, log it
|
||||
in the relevant record. Future you (and the auditors) will
|
||||
thank you.
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
# Collection Driver — Quickstart
|
||||
|
||||
**One-line job:** pick up goods from suppliers, drop them at customers.
|
||||
|
||||
You do this on your **phone** (WFDASHDRIVER-APP) or the office
|
||||
`/collection-confirm` page. The app is the source of truth — what's
|
||||
on the app is what counts.
|
||||
|
||||
## What you do
|
||||
|
||||
Every day, the app has a list of pickups and deliveries. You:
|
||||
|
||||
- Drive to the supplier, collect the goods.
|
||||
- Take a photo + sign at the supplier.
|
||||
- Drop the goods at the customer.
|
||||
- Take a photo + get the customer's signature.
|
||||
- Flag anything weird (short, wrong, damaged) before leaving.
|
||||
|
||||
## Your day
|
||||
|
||||
| Time | What to do |
|
||||
|---|---|
|
||||
| **07:00** | Open the app. Look at today's run — suppliers, in order. |
|
||||
| **07:15** | Phone the first supplier if you haven't worked with them in a while. Confirm they're open. |
|
||||
| **08:00 - 17:00** | Do the pickups + deliveries. Use the app, not memory. |
|
||||
| **12:00** | Lunch + call the office if anything is blocking. |
|
||||
| **17:00** | Sync the app. Mark all today's items with their final status. Nothing in flight overnight. |
|
||||
|
||||
**Before you leave the truck**: every item from today is either
|
||||
`collected`, `delivered`, or marked with a problem and a note. If
|
||||
you don't, the office won't know what happened.
|
||||
|
||||
## How to do the common stuff
|
||||
|
||||
### 1. At the supplier — happy path
|
||||
|
||||
1. Tap the supplier row in the app. Tap **Navigate** (opens Maps).
|
||||
2. At the supplier, tap **Confirm Collection**.
|
||||
3. Scan or tap each line. Enter the qty received.
|
||||
4. **Photograph the supplier's delivery note / invoice.** The app
|
||||
attaches it automatically.
|
||||
5. Tap **Submit**. Items flip to `collected`.
|
||||
|
||||
### 2. At the supplier — short / wrong / damaged
|
||||
|
||||
1. **Don't accept** the bad goods. Talk to the supplier's rep.
|
||||
2. In the app, on the line, tap **Report Problem**.
|
||||
3. Pick: `short` / `wrong_item` / `damaged` / `other`.
|
||||
4. **Take 2+ photos** (whole item + close-up of the issue).
|
||||
5. Get the supplier to **sign the delivery note** with the
|
||||
discrepancy. Photograph the signed note.
|
||||
6. **Add a note** explaining what happened.
|
||||
7. Tap **Submit**. The item stays in `collection_pool` — the office
|
||||
handles the dispute.
|
||||
|
||||
### 3. At the customer — happy path
|
||||
|
||||
1. Tap the order row. Tap **Confirm Delivery**.
|
||||
2. Each line: tap **Delivered** (qty matches).
|
||||
3. **Photograph the goods at the customer's site.**
|
||||
4. **Get the customer's signature** on the app (or on the waybill
|
||||
+ photograph it).
|
||||
5. Tap **Submit**. Items flip to `delivered`.
|
||||
|
||||
### 4. At the customer — partial / refused / not home
|
||||
|
||||
- **Customer wants less than you brought:** mark the line as
|
||||
**Partial**, enter the qty they took, return the rest to the
|
||||
office. Note why.
|
||||
- **Customer refuses (damaged, wrong, "changed my mind"):**
|
||||
photograph everything, get them to sign the refusal note, bring
|
||||
it back. The office decides next.
|
||||
- **Customer not on site:** wait 15 min, try to call, leave a card.
|
||||
Mark `assigned` with a note "not on site" + the time you tried.
|
||||
Bring the goods back.
|
||||
|
||||
## Don'ts — the 3 things you must never do
|
||||
|
||||
1. **Never deliver without a photo + signature.** A "delivered"
|
||||
item with no proof is a dispute magnet. The app needs both.
|
||||
2. **Never submit a wrong / damaged pickup silently.** The
|
||||
supplier will say "we never gave you damaged goods" and you'll
|
||||
be on the hook. Photo + signed note at the supplier, every time.
|
||||
3. **Never leave the app showing items "in flight" overnight.**
|
||||
Mark everything as final before you clock out. If something's
|
||||
stuck, the office can't help you at 7am Monday if the app says
|
||||
you're still on the road.
|
||||
|
||||
## When to ask the office (the 4 most common situations)
|
||||
|
||||
- **Supplier closed / order not ready.** Don't wait. Mark
|
||||
`assigned`, note the time, re-order your run. Call the office
|
||||
so they can reschedule the customer.
|
||||
- **Wrong address.** Don't drive around. Tap **Report Wrong
|
||||
Address** on the row. The office fixes the master data and
|
||||
re-routes you.
|
||||
- **Customer wants a discount to accept.** Don't promise anything.
|
||||
Call the office. They'll decide.
|
||||
- **Truck breakdown / accident.** Safety first. Then call the
|
||||
office. The goods are secondary.
|
||||
|
||||
## How you're measured (the 2 numbers that matter)
|
||||
|
||||
1. **On-time delivery rate** — target: **> 95%** of items delivered
|
||||
by the customer-promised time. (You hit the date.)
|
||||
2. **Photo + signature proof rate** — target: **100%** of collections
|
||||
and deliveries. (You have the evidence if it goes wrong.)
|
||||
|
||||
Bonus number: **error rate** — target < 2% of line items have
|
||||
damage / wrong-item reports. (You get it right first time.)
|
||||
|
||||
## Who to ask
|
||||
|
||||
- **The office phone number** — for anything that needs a decision
|
||||
(refusal, discount, wrong address, breakdown).
|
||||
- **Your supervisor** — for any pattern (same supplier always short,
|
||||
same customer always refusing).
|
||||
- **When in doubt, call.** Don't guess from the cab.
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
# Order Operator — Quickstart
|
||||
|
||||
**One-line job:** turn accepted quotes into delivered goods.
|
||||
|
||||
## What you do
|
||||
|
||||
The customer said yes. You:
|
||||
|
||||
- Create the order from the quote.
|
||||
- Order the goods from the supplier (a Purchase Order).
|
||||
- Track them through collection, packing, and delivery.
|
||||
- Hand off to invoicing once delivered.
|
||||
|
||||
You're the bridge between "they said yes" and "they have it".
|
||||
|
||||
## Your day
|
||||
|
||||
| Time | What to do |
|
||||
|---|---|
|
||||
| **08:00** | Open `/orders?tab=New`. Anything new from yesterday? Move it forward. |
|
||||
| **08:30** | Open `/orders?tab=Processing`. Every line should have a PO. Flag any stuck > 1 day. |
|
||||
| **12:00** | Glance at `/orders?tab=Collected`. Anything stuck in `stock_verified`? Investigate. |
|
||||
| **17:00** | Anything you changed today — sanity check. Don't leave surprises for Monday. |
|
||||
|
||||
**Three times a day**, scan the dashboard. The order-trend chart
|
||||
should show lines moving right (New → Processing → Collection →
|
||||
Collected → Delivered). If anything's piling up in one tab, that's
|
||||
where to focus.
|
||||
|
||||
## How to do the common stuff
|
||||
|
||||
### 1. Create an order from a quote
|
||||
|
||||
1. Open the quote (status `approved` or `emailed`).
|
||||
2. Click **Create Order**. One new order, one row per quote line.
|
||||
3. Order lands in **New** tab with everything `pending`.
|
||||
4. The quote status does **not** change. Leave it alone.
|
||||
|
||||
### 2. Process a `pending` line
|
||||
|
||||
1. Open the order detail, click the line.
|
||||
2. Set `cost_price` (from the quote) and `selling_price` (auto-derived
|
||||
from cost × markup).
|
||||
3. Click **Mark Processed**. Status → `processed`.
|
||||
4. Decide next: in stock? → **Book from Stock**. Not in stock? →
|
||||
**Generate PO** (next step).
|
||||
|
||||
### 3. Generate a Purchase Order (PO)
|
||||
|
||||
1. Click **Generate PO**. One PO per supplier.
|
||||
2. Open the PO. Verify items, quantities, costs, delivery date.
|
||||
3. Click **Send PO**. Same email modal as quotes — check recipient,
|
||||
attach any terms docs, hit send.
|
||||
4. Status → `po_generated`. The order item waits for the supplier to
|
||||
reply / ship.
|
||||
|
||||
### 4. Receive a supplier invoice (when the email or paper arrives)
|
||||
|
||||
1. Find the PO number on the invoice (top right usually — "Your Ref").
|
||||
2. The system will diff the invoice against the PO and tell you if
|
||||
it matches. **If it says "ready to receive"** → click **Confirm
|
||||
Receive**. Done.
|
||||
3. **If it says anything else** (cost mismatch, qty mismatch, etc.) →
|
||||
**stop and ask the supervisor.** Don't force it.
|
||||
|
||||
### 5. Mark delivered (once the goods are with the customer)
|
||||
|
||||
1. Confirm the order has all its items in `delivered`. The header
|
||||
will show a green "Complete" badge.
|
||||
2. Click **Mark Delivered** on the header. (Idempotent — safe to
|
||||
click twice.)
|
||||
3. Then click **Mark Ready to Invoice**. Items flip to `ready_invoice`.
|
||||
The Invoicing Clerk takes it from here.
|
||||
|
||||
## Don'ts — the 3 things you must never do
|
||||
|
||||
1. **Never generate a PO without verifying the cost.** Once sent, it's
|
||||
the contract. Wrong cost = wrong margin.
|
||||
2. **Never "force through" a receive diff that doesn't say
|
||||
`ready_to_receive`.** If the diff says cost mismatch, qty mismatch,
|
||||
supplier mismatch — call the supervisor. Don't bypass.
|
||||
3. **Never mark an order delivered before every line is `delivered`.**
|
||||
Use the per-line `Mark Delivered` if needed. Don't bulk-flip the
|
||||
header to "make it look done".
|
||||
|
||||
## When to ask the supervisor (the 4 most common situations)
|
||||
|
||||
- **Supplier hasn't replied to a PO in 3+ working days.** Phone them
|
||||
first. If no answer after 2 attempts, escalate.
|
||||
- **Customer wants to cancel after the PO is sent.** Depends on
|
||||
whether the supplier has confirmed / shipped. Call the supervisor —
|
||||
there are 3 options and they pick.
|
||||
- **Supplier delivered the wrong / damaged goods.** Refuse at the
|
||||
supplier (if still there) or take photos at receipt. **Don't
|
||||
accept damaged goods silently.** The supervisor decides credit /
|
||||
return / write-off.
|
||||
- **Stock item is short when you try to book.** Either the Stock
|
||||
Controller can re-check the count, or we source from the supplier
|
||||
instead. Phone, don't poke.
|
||||
|
||||
## How you're measured (the 2 numbers that matter)
|
||||
|
||||
1. **Order completion time** — target: **> 80% of orders delivered
|
||||
within 5 working days** of the order being created. (From "yes" to
|
||||
"they have it".)
|
||||
2. **PO stale rate** — target: **< 5% of open POs** without a supplier
|
||||
reply for > 3 working days. (Catch the silent ones early.)
|
||||
|
||||
Bonus number the supervisor watches: **PO cost-mismatch rate** —
|
||||
target < 3% of receives. If yours is higher, your cost entries are
|
||||
probably sloppy.
|
||||
|
||||
## Who to ask
|
||||
|
||||
- **Anything about a specific order, a PO, a customer, a supplier**
|
||||
→ supervisor.
|
||||
- **Anything broken in the system** → supervisor.
|
||||
- **Anything you're not sure about** → supervisor. Better to ask
|
||||
than guess.
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
# Quote Operator — Quickstart
|
||||
|
||||
**One-line job:** turn customer requests into priced quotes, fast.
|
||||
|
||||
## What you do
|
||||
|
||||
A customer wants something. We need to tell them "yes, we can supply
|
||||
it, and here's the price". You:
|
||||
|
||||
- Pick up new quotes that come in by email or phone.
|
||||
- Find the right price (from the catalogue, an AI suggestion, or a
|
||||
supplier list).
|
||||
- Get the quote approved.
|
||||
- Send it to the customer.
|
||||
- Follow up if they don't reply.
|
||||
|
||||
## Your day
|
||||
|
||||
| Time | What to do |
|
||||
|---|---|
|
||||
| **08:30** | Open `/quotes?tab=new`. Anything new from overnight? Pick it up. |
|
||||
| **09:00** | Open `/sprint`. Claim 2-3 items you can price fast. (30-min timer per item.) |
|
||||
| **10:00, 13:00, 16:00** | Same as 09:00. Keep the sprint queue small. |
|
||||
| **12:00** | Lunch. |
|
||||
| **17:00** | Set `follow_up_due` on every quote you sent today (5 working days from now). |
|
||||
|
||||
**Three times a day**, glance at `/dashboard` — is the "Quote Pipeline"
|
||||
doughnut getting stuck in `new` or `claim`? If yes, the queue is
|
||||
backing up. Pick up more.
|
||||
|
||||
## How to do the common stuff
|
||||
|
||||
### 1. Price a line item (the most common task — you'll do this 50× a day)
|
||||
|
||||
1. Open the line.
|
||||
2. Is there a **matched catalogue row** (look for the catalogue panel
|
||||
on the line)? Use its price. Set `pricing_source: "catalogue"`.
|
||||
3. Not matched? Search `/pricelist` by description or SKU. If you
|
||||
find it, copy the price across. Set `pricing_source: "catalogue"`.
|
||||
4. Still nothing? Look at recent supplier lists in `/pricelist`
|
||||
(the "Raw Uploads" tab). If the supplier is one we work with,
|
||||
use their price. Set `pricing_source: "manual"`.
|
||||
5. **Cannot find a price anywhere** → flag the line for the
|
||||
supervisor. Don't guess.
|
||||
|
||||
### 2. Send a quote (once everything is priced)
|
||||
|
||||
1. Click **Submit for Approval**. Status → `approval_pending`.
|
||||
2. Wait for the supervisor. (You'll see it move off the queue.)
|
||||
3. Approved? Click **Send Email**.
|
||||
4. Check the recipient. Add the customer rep to CC if you know them.
|
||||
5. The PDF is already attached. Hit **Send**.
|
||||
6. **Set `follow_up_due`** to today + 5 working days. Don't forget.
|
||||
|
||||
### 3. Follow up on a sent quote
|
||||
|
||||
1. Open the quote. Is there a reply logged? (Green chip on the row.)
|
||||
2. No reply? **Phone the customer first** — faster than email.
|
||||
3. Log the call in the quote's notes.
|
||||
4. Set a new `follow_up_due` date. Try again in 5 working days.
|
||||
|
||||
### 4. Inbound email quote (the common one)
|
||||
|
||||
1. n8n already created the quote in `/quotes?tab=new`. Open it.
|
||||
2. Check: right **customer**? right **company/letterhead**? items
|
||||
look right?
|
||||
3. Items with `pricing_source: "ai"` are pre-priced — verify, save.
|
||||
4. Items with no price landed in `/sprint` — go price them.
|
||||
|
||||
## Don'ts — the 3 things you must never do
|
||||
|
||||
1. **Never send a quote with a line that has `selling_price = 0`.**
|
||||
The system will refuse the send. If you can't price a line, ask
|
||||
the supervisor.
|
||||
2. **Never override an AI price without checking the catalogue.**
|
||||
The AI is right ~85% of the time. If you change it, write a
|
||||
`pricing_notes` line saying why (e.g. "catalogue price used,
|
||||
AI was 12% low").
|
||||
3. **Never leave a sprint claim to expire with no work.** Save
|
||||
what you have (even partial), or release the claim by walking
|
||||
away. Don't ghost your own claims.
|
||||
|
||||
## When to ask the supervisor (the 4 most common situations)
|
||||
|
||||
- **AI price is way off (more than 10% from catalogue).** Use the
|
||||
catalogue price and log the why. Don't agonise.
|
||||
- **Customer wants a discount.** < 5% — apply it, log the reason.
|
||||
5-15% — apply it, flag the approver in the quote notes.
|
||||
> 15% — don't apply; tell the customer it's above your authority.
|
||||
- **Email send failed.** Click **Retry** once. If it fails again,
|
||||
check the address (typo? bounced?). If still failing, escalate.
|
||||
- **Customer is unknown / wrong.** The quote landed on "Unknown
|
||||
Sender". Don't price it. Hand to the supervisor to create the
|
||||
customer first.
|
||||
|
||||
## How you're measured (the 2 numbers that matter)
|
||||
|
||||
1. **Time to first touch on a new quote** — target: **< 2 hours**
|
||||
during business hours. (How fast did you pick it up?)
|
||||
2. **Win rate** — target: **> 30%** of quotes you send become
|
||||
orders. (Out of every 10 quotes you sent, how many got accepted?)
|
||||
|
||||
The supervisor pulls these for you every Monday. If either is red
|
||||
for 2+ weeks, you'll get a 1-on-1.
|
||||
|
||||
## Who to ask
|
||||
|
||||
- **Anything about a specific quote, a customer, a price** → supervisor.
|
||||
- **Anything broken in the system (button missing, error message, weird
|
||||
data)** → supervisor, who'll loop in the dev if needed.
|
||||
- **Anything you're not sure about** → supervisor. Better to ask
|
||||
than guess.
|
||||
|
|
@ -1,708 +0,0 @@
|
|||
# Approve & Send from Pending-Approvals List Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Wire the existing detail-page "Approve & send email" affordance (modal + `review-approve action: "approve"`) onto the Pending Approvals list rows, so the rep can approve and dispatch in one click without drilling into the quote detail.
|
||||
|
||||
**Architecture:** Single-file change to `src/app/(app)/quotes/approvals/page.tsx`. Reuse the existing `SendQuoteModal` component unchanged. Reuse the existing `POST /api/v1/quotes/[id]/review-approve` endpoint unchanged. Replace the list's two-step handlers (`handleApprove` + `handleApprovePartial`) with a single `sendApprove` lifted to page level, callable per row. Reject path stays untouched.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router), TypeScript, React, Vitest + Testing Library (jsdom). Component-level test uses `vi.hoisted` mocks.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Touched in | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/app/(app)/quotes/approvals/page.test.tsx` | Task 1 (create) | Component test for the row-button → modal → submit → toast flow |
|
||||
| `src/app/(app)/quotes/approvals/page.tsx` | Tasks 2, 3, 4 | Lift `sendApprove`; render one `SendQuoteModal` at page level; replace `handleApprove` + `handleApprovePartial`; replace button labels |
|
||||
|
||||
Unchanged (reused as-is):
|
||||
- `src/components/quotes/send-quote-modal.tsx` — modal component
|
||||
- `src/app/api/v1/quotes/[id]/review-approve/route.ts` — endpoint
|
||||
- `src/app/(app)/quotes/approvals/[id]/page.tsx` — detail page (already uses `review-approve`)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add component tests for the new row-button flow (failing)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/(app)/quotes/approvals/page.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the test file with the mock infrastructure**
|
||||
|
||||
Write `src/app/(app)/quotes/approvals/page.test.tsx` with the `vi.hoisted` mocks for PB + `next/navigation` + `SendQuoteModal`. The PB mock provides an empty `getList` (returns two quotes: one full-pending, one partial-pending). `SendQuoteModal` is replaced with a stub that exposes the props the test cares about (so we can drive the `onSend` callback from outside). `useRouter` is stubbed so `router.refresh` and `router.push` are observable spies.
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import ApprovalsPage from "./page";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────
|
||||
// Hoisted so the vi.mock factories can close over them.
|
||||
const {
|
||||
getListMock,
|
||||
refreshMock,
|
||||
pushMock,
|
||||
captureModalProps,
|
||||
triggerSend,
|
||||
} = vi.hoisted(() => ({
|
||||
getListMock: vi.fn(),
|
||||
refreshMock: vi.fn(),
|
||||
pushMock: vi.fn(),
|
||||
captureModalProps: vi.fn(),
|
||||
triggerSend: vi.fn(),
|
||||
}));
|
||||
|
||||
// Stub the PocketBase client so we can drive list contents from the test.
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
collection: (name: string) => {
|
||||
if (name === "quotes") {
|
||||
return { getList: (...args: unknown[]) => getListMock(...args) };
|
||||
}
|
||||
if (name === "quote_items") {
|
||||
return { getFullList: async () => [] };
|
||||
}
|
||||
return { getFullList: async () => [], getList: async () => ({ items: [] }) };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stub next/navigation so we can assert on refresh/push.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: refreshMock, push: pushMock }),
|
||||
}));
|
||||
|
||||
// Replace SendQuoteModal with a thin stub. The test grabs the props the
|
||||
// page passed in via captureModalProps; triggerSend becomes the onSend
|
||||
// prop, so tests can fire the modal submit callback directly.
|
||||
vi.mock("@/components/quotes/send-quote-modal", () => ({
|
||||
SendQuoteModal: (props: {
|
||||
open: boolean;
|
||||
submitLabel?: string;
|
||||
onSend: (p: unknown) => Promise<void>;
|
||||
quote: { id: string };
|
||||
}) => {
|
||||
captureModalProps(props);
|
||||
if (!props.open) return null;
|
||||
return (
|
||||
<div data-testid="send-quote-modal">
|
||||
<span data-testid="modal-submit-label">{props.submitLabel ?? ""}</span>
|
||||
<span data-testid="modal-quote-id">{props.quote.id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerSend({
|
||||
to: "buyer@example.com",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
subject: "Quote Q-26082701",
|
||||
message: "",
|
||||
selectedAttachmentIds: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Submit modal
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// fetch is used by the modal stub's submit handler (and by the rep-email
|
||||
// lookup). The page itself only does `fetch("/api/v1/quotes/[id]/review-approve")`.
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const baseQuote = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "q-full-1",
|
||||
quote_number: "Q-26082701",
|
||||
status: "approval_pending",
|
||||
rfq_number: "RFQ-100",
|
||||
description: "Replacement quote",
|
||||
customer: "cust-1",
|
||||
company: "co-1",
|
||||
created: "2026-08-27T08:00:00Z",
|
||||
notes: "",
|
||||
expand: {
|
||||
customer: { customer: "Acme Co", rep: "", company: "co-1" },
|
||||
company: { company: "Acme Holdings" },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default list: one full-pending, one partial-pending. Tests can
|
||||
// override getListMock.mockResolvedValueOnce(...) to narrow.
|
||||
getListMock.mockResolvedValue({
|
||||
items: [
|
||||
baseQuote(),
|
||||
baseQuote({
|
||||
id: "q-part-1",
|
||||
quote_number: "Q-26082702",
|
||||
status: "partial_approval_pending",
|
||||
}),
|
||||
],
|
||||
});
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ quote: baseQuote(), email: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalsPage — Approve & send from list", () => {
|
||||
it("renders an 'Approve & send' button on a full-pending row", async () => {
|
||||
render(<ApprovalsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole("button", { name: /approve & send/i }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking 'Approve & send' on a full row opens SendQuoteModal with submitLabel 'Approve & send'", async () => {
|
||||
render(<ApprovalsPage />);
|
||||
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
|
||||
fireEvent.click(buttons[0]);
|
||||
await waitFor(() => {
|
||||
expect(captureModalProps).toHaveBeenCalled();
|
||||
const last = captureModalProps.mock.calls.at(-1)![0];
|
||||
expect(last.open).toBe(true);
|
||||
expect(last.quote.id).toBe("q-full-1");
|
||||
expect(last.submitLabel).toBe("Approve & send");
|
||||
});
|
||||
});
|
||||
|
||||
it("submitting the modal posts to review-approve with action 'approve' and email payload", async () => {
|
||||
render(<ApprovalsPage />);
|
||||
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
|
||||
fireEvent.click(buttons[0]);
|
||||
await waitFor(() => expect(captureModalProps).toHaveBeenCalled());
|
||||
// The modal stub's "Submit modal" button fires triggerSend which is
|
||||
// wired up as the page's onSend callback below.
|
||||
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
|
||||
fireEvent.click(submitBtn);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/v1/quotes/q-full-1/review-approve",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: expect.stringContaining('"action":"approve"'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("on 200 success, refreshes the list and toasts 'Approved — email sent...'", async () => {
|
||||
render(<ApprovalsPage />);
|
||||
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
|
||||
fireEvent.click(buttons[0]);
|
||||
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
|
||||
fireEvent.click(submitBtn);
|
||||
await waitFor(() => {
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
// toast.success is from sonner — verify by checking sonner was called.
|
||||
const { toast } = await import("sonner");
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Approved — email sent")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("on 422 invalid_status, refreshes and toasts 'This quote was already actioned — refreshing.'", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: async () => ({ error: "Already approved", code: "invalid_status" }),
|
||||
});
|
||||
render(<ApprovalsPage />);
|
||||
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
|
||||
fireEvent.click(buttons[0]);
|
||||
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
|
||||
fireEvent.click(submitBtn);
|
||||
await waitFor(() => {
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
const { toast } = await import("sonner");
|
||||
await waitFor(() => {
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("already actioned"),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("on 422 mail-failed, toasts 'Saved — but email send failed...'", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: async () => ({
|
||||
error: "SMTP error",
|
||||
email: { failure: { code: "smtp", message: "Connection refused" } },
|
||||
}),
|
||||
});
|
||||
render(<ApprovalsPage />);
|
||||
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
|
||||
fireEvent.click(buttons[0]);
|
||||
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
|
||||
fireEvent.click(submitBtn);
|
||||
const { toast } = await import("sonner");
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Saved — but email send failed")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking 'Approve & send' on a partial row invokes review-approve and toasts the partial message", async () => {
|
||||
render(<ApprovalsPage />);
|
||||
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
|
||||
// The second row is the partial one.
|
||||
fireEvent.click(buttons[1]);
|
||||
await waitFor(() => expect(captureModalProps).toHaveBeenCalled());
|
||||
const last = captureModalProps.mock.calls.at(-1)![0];
|
||||
expect(last.quote.id).toBe("q-part-1");
|
||||
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
|
||||
fireEvent.click(submitBtn);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/v1/quotes/q-part-1/review-approve",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
const { toast } = await import("sonner");
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Partial approved — email sent")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify sonner is mocked**
|
||||
|
||||
The detail page imports `toast` from `sonner`. The test stubs `vi.stubGlobal("fetch")` but not sonner. Sonner's `toast` function uses React internals (creates a portal) which fails in jsdom if you let it render. Check whether the existing test setup mocks sonner globally — if it doesn't, add `vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }) }))` to this file's mock block (before the `describe`). Confirm by reading `src/test-setup.ts` to see whether a global sonner mock is already wired up. If it is, drop the inline `import { toast }` in the tests and assert via the global mock directly.
|
||||
|
||||
- [ ] **Step 3: Run the tests; expect 7 failures**
|
||||
|
||||
Run: `npx vitest run src/app/(app)/quotes/approvals/page.test.tsx`
|
||||
|
||||
Expected: 7 failures — the page still has the old `handleApprove` / `handleApprovePartial` that call `pb.collection.update` / `fetch("/api/v1/quotes/.../partial-approve")` directly, not `SendQuoteModal`. The new tests reference button names and flows that don't exist yet. Failures should mention "Unable to find a button with name /approve & send/i" or similar.
|
||||
|
||||
- [ ] **Step 4: Commit (scaffolding only — tests will be fixed in Tasks 2-4)**
|
||||
|
||||
```bash
|
||||
git add 'src/app/(app)/quotes/approvals/page.test.tsx'
|
||||
git commit -m "test(approvals): scaffold Approve & send from list tests
|
||||
|
||||
Component tests cover the row-button -> SendQuoteModal ->
|
||||
review-approve submit flow including success, invalid_status,
|
||||
and mail-failed error branches. All 7 fail; implementation in
|
||||
follow-up commits."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire full-quote "Approve & send" path (tests 1, 2, 3, 4, 5 pass)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(app)/quotes/approvals/page.tsx` (replace `handleApprove`; add lifted `sendApprove` + page-level modal render; change button label)
|
||||
|
||||
- [ ] **Step 1: Add imports**
|
||||
|
||||
In `src/app/(app)/quotes/approvals/page.tsx`, add the modal import. Existing imports include `useState`, `useEffect`, `useCallback`, `useRouter`, `toast`, `getPocketBase`, `Button`, etc. Add at the top of the import block:
|
||||
|
||||
```ts
|
||||
import { SendQuoteModal } from "@/components/quotes/send-quote-modal";
|
||||
import { isPlaceholderCustomer } from "@/lib/placeholder-customers";
|
||||
import type { QuoteAttachment } from "@/lib/types";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add page-level state for the modal**
|
||||
|
||||
Find the existing state declarations near line 80-94. Add immediately after the `itemsByQuote` declaration:
|
||||
|
||||
```ts
|
||||
// Modal state — which row's "Approve & send" modal is currently open.
|
||||
// `null` means no modal is rendered. The modal stays mounted across
|
||||
// rows so we don't unmount mid-edit; switching rows closes the current
|
||||
// modal and opens the new one via the onOpenChange callback.
|
||||
const [sendModalQuoteId, setSendModalQuoteId] = useState<string | null>(null);
|
||||
// Per-quote outbound attachments cache. Keyed by quote id; only the
|
||||
// modal needs them, so we fetch lazily when the modal first opens.
|
||||
const [outboundAttachments, setOutboundAttachments] = useState<
|
||||
Record<string, QuoteAttachment[]>
|
||||
>({});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the lifted `sendApprove` callback**
|
||||
|
||||
Find the `handleApprove` function (lines 153-168). Replace it with:
|
||||
|
||||
```ts
|
||||
// Lifted from /quotes/approvals/[id]/page.tsx — the modal's onSend
|
||||
// handler. Called when the rep confirms the email composition and
|
||||
// hits the modal's primary button ("Approve & send").
|
||||
//
|
||||
// Diff from the detail page version:
|
||||
// - We use `router.refresh()` instead of `router.push("/quotes/approvals")`
|
||||
// because we're already on the list; refresh re-fetches the page's
|
||||
// server data, which re-renders the list and drops the now-approved
|
||||
// row naturally.
|
||||
// - `itemChanges` is always `[]` — the list page has no inline markup
|
||||
// editor (that's a detail-page affordance). The rep nudges markup
|
||||
// by drilling into the quote, which is the explicit out-of-scope
|
||||
// escape hatch in the spec.
|
||||
const sendApprove = async (
|
||||
quote: Quote,
|
||||
email: {
|
||||
to: string;
|
||||
cc: string;
|
||||
bcc: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
selectedAttachmentIds: string[];
|
||||
}
|
||||
) => {
|
||||
setActionLoading(quote.id);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/quotes/${quote.id}/review-approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "approve", itemChanges: [], email }),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
email?: { failure?: { code: string; message: string } };
|
||||
};
|
||||
if (!res.ok) {
|
||||
// Three branches on the !ok path:
|
||||
// 1) invalid_status — another rep already actioned this quote.
|
||||
// Surface a toast that explains the refresh and refresh the list.
|
||||
// 2) Mail-send failure — the approve part succeeded server-side
|
||||
// but the SMTP send failed. The quote is now in `mail_failed`;
|
||||
// tell the rep to retry from the quote page.
|
||||
// 3) Anything else (placeholder customer, validation, 5xx) —
|
||||
// generic error toast.
|
||||
if (body.code === "invalid_status") {
|
||||
toast("This quote was already actioned — refreshing.", { type: "info" });
|
||||
router.refresh();
|
||||
setSendModalQuoteId(null);
|
||||
return;
|
||||
}
|
||||
if (body.email?.failure) {
|
||||
toast.error(
|
||||
`Saved — but email send failed: ${body.email.failure.message}. Retry from the quote page.`
|
||||
);
|
||||
setSendModalQuoteId(null);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
toast.error(body.error ?? `Approve failed (HTTP ${res.status})`);
|
||||
return;
|
||||
}
|
||||
// 200 path — branch on partial vs full based on the quote we sent.
|
||||
toast.success(
|
||||
quote.status === "partial_approval_pending"
|
||||
? "Partial approved — email sent for the priced items."
|
||||
: "Approved — email sent to the customer."
|
||||
);
|
||||
setSendModalQuoteId(null);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Approve failed");
|
||||
} finally {
|
||||
setActionLoading("");
|
||||
}
|
||||
};
|
||||
|
||||
// Outbound attachments loader — only called when the modal opens for
|
||||
// the first time on a row. The detail page does this fetch inside the
|
||||
// modal mount; we mirror the pattern.
|
||||
const ensureOutboundAttachments = useCallback(async (quoteId: string) => {
|
||||
if (outboundAttachments[quoteId]) return;
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
const items = await pb.collection("quote_attachments").getFullList<QuoteAttachment>({
|
||||
filter: `quote = "${quoteId}" && direction = "outbound"`,
|
||||
sort: "-created",
|
||||
});
|
||||
setOutboundAttachments((prev) => ({ ...prev, [quoteId]: items }));
|
||||
} catch {
|
||||
// Silent — modal will just show zero outbound attachments. The
|
||||
// rep can still send the email with just the PDF.
|
||||
}
|
||||
}, [outboundAttachments]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `handleApprove` with a thin open-modal handler**
|
||||
|
||||
Replace `handleApprove` (the full-quote version) with:
|
||||
|
||||
```ts
|
||||
const openSendModal = (quoteId: string) => {
|
||||
setSendModalQuoteId(quoteId);
|
||||
void ensureOutboundAttachments(quoteId);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update the button to call `openSendModal` and use the new label**
|
||||
|
||||
Find the `isPartial` ternary block at lines 392-423 (the button group with "Approve" and "Approve partial"). Replace the entire `{isPartial ? (...) : (...)}` block with a single button that always reads "Approve & send":
|
||||
|
||||
```tsx
|
||||
{/* Approve & send — same button for both full and partial rows.
|
||||
Opens the same SendQuoteModal the detail page uses, then submits
|
||||
via POST /api/v1/quotes/[id]/review-approve action: "approve".
|
||||
The server handles partials natively (flips through
|
||||
partial_approved → sending → emailed). */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isPartial ? "default" : "default"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openSendModal(quote.id);
|
||||
}}
|
||||
disabled={actionLoading !== ""}
|
||||
>
|
||||
{actionLoading === quote.id && (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Approve & send
|
||||
</Button>
|
||||
```
|
||||
|
||||
Note: `&` because we're inside JSX text content. Use that exact entity — `<Button>Approve & send</Button>` (with `&` literal) works in JSX too if you prefer; both render the same.
|
||||
|
||||
- [ ] **Step 6: Render `SendQuoteModal` at page level**
|
||||
|
||||
Find the closing `</Card>` for each row and the outer `</div>` for the grid. Just BEFORE the `</div>` that closes the grid (the one wrapping `{quotes.map((quote) => { ... })}`), render the modal once, keyed by the open row:
|
||||
|
||||
```tsx
|
||||
{(() => {
|
||||
const openQuote = quotes.find((q) => q.id === sendModalQuoteId);
|
||||
if (!openQuote) return null;
|
||||
const customerIsPlaceholder = isPlaceholderCustomer(
|
||||
openQuote.expand?.customer?.customer ?? ""
|
||||
);
|
||||
return (
|
||||
<SendQuoteModal
|
||||
open={true}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setSendModalQuoteId(null);
|
||||
}}
|
||||
quote={openQuote}
|
||||
outboundAttachments={outboundAttachments[openQuote.id] ?? []}
|
||||
// The detail page passes the rep's display name; on the list we
|
||||
// don't have a per-quote claimer lookup, so the modal just shows
|
||||
// its own default signature.
|
||||
claimerName={null}
|
||||
submitting={actionLoading === openQuote.id}
|
||||
submitLabel="Approve & send"
|
||||
onSend={(email) => sendApprove(openQuote, email)}
|
||||
// No onRetry: a mail-failed retry needs the quote's persisted
|
||||
// composition, which the list page doesn't keep. The rep can
|
||||
// retry from the quote page (the same path the toast points them
|
||||
// at).
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
```
|
||||
|
||||
Place this just before the `{` that closes the `{quotes.length === 0 ? ... : <div className="grid gap-4"> ... </div>}` ternary. The modal renders OUTSIDE the grid wrapper so its portal sits at the document root.
|
||||
|
||||
- [ ] **Step 7: Disable submit when customer is placeholder**
|
||||
|
||||
The detail page already short-circuits the modal's submit button when the customer is a placeholder (no email target). We can't replicate that here without the modal knowing the placeholder flag. Two options:
|
||||
|
||||
Option A (simplest, this commit): trust the server's 422 response. The placeholder customer case will land in the generic error toast — `"Approve failed (HTTP 422)"` with whatever message the server returns. The spec says placeholder customer disables submit, but this is a slight degradation from the detail page's UX. Acceptable because placeholder customers shouldn't reach the approval queue in normal flow.
|
||||
|
||||
Option B: add a `customerIsPlaceholder` prop to `SendQuoteModal` and pass it here. Out of scope for this commit; document as a follow-up.
|
||||
|
||||
For this commit, take Option A. Add a comment in the modal render explaining:
|
||||
|
||||
```tsx
|
||||
{/* Placeholder-customer UX (disable submit) is delegated to the
|
||||
server's 422 response. The detail page passes a customerIsPlaceholder
|
||||
prop to SendQuoteModal; the list page deliberately doesn't bother —
|
||||
placeholder customers shouldn't reach the approval queue in normal
|
||||
flow, and the existing 422 handler shows the failure cleanly. */}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update the file header comment**
|
||||
|
||||
Find the file header doc comment block (lines 18-39). Replace the "Actions" section with:
|
||||
|
||||
```
|
||||
* Actions:
|
||||
* - Approve & send (full): opens SendQuoteModal and submits via
|
||||
* /review-approve action: "approve". The server atomically flips
|
||||
* status and dispatches the email.
|
||||
* - Approve & send (partial): same modal + same endpoint. The
|
||||
* server handles partial_approval_pending → partial_approved →
|
||||
* sending → emailed natively.
|
||||
* - Reject: bounces the quote back to the operator's queue —
|
||||
* flips `status` to `new`, clears `claimed_by`, and appends
|
||||
* the rejection reason (with reviewer + timestamp) to
|
||||
* `quotes.notes`. Works for both full and partial submissions.
|
||||
*/
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run the tests for the full-quote path**
|
||||
|
||||
Run: `npx vitest run src/app/(app)/quotes/approvals/page.test.tsx`
|
||||
|
||||
Expected: Tests 1, 2, 3, 4, 5 pass (render "Approve & send" button; click opens modal with `submitLabel="Approve & send"`; submit posts to review-approve with action: "approve"; 200 refreshes + success toast; 422 invalid_status refreshes + info toast; 422 mail-failed shows the saved-but-failed toast).
|
||||
|
||||
Test 6 (partial row → review-approve + partial-success toast) should now ALSO pass, because the partial path is wired up by reusing `openSendModal` for both rows. Verify.
|
||||
|
||||
- [ ] **Step 10: Run the rest of the suite to confirm no regression**
|
||||
|
||||
Run: `npx vitest run`
|
||||
|
||||
Expected: All other test files pass (no regressions). If anything fails, surface it; do NOT skip or fix unrelated failures.
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add 'src/app/(app)/quotes/approvals/page.tsx'
|
||||
git commit -m "feat(approvals): Approve & send from list rows
|
||||
|
||||
The pending-approvals list now offers the same one-step
|
||||
approve-and-dispatch affordance as the detail page, instead of
|
||||
forcing the rep through a two-step approve-then-drill-in flow.
|
||||
|
||||
- Replace the list's standalone 'Approve' button (direct PB write)
|
||||
and 'Approve partial' button (POST /partial-approve) with a single
|
||||
'Approve & send' button that opens SendQuoteModal.
|
||||
- Lift the modal and sendApprove callback to page level, keyed by
|
||||
row id, callable per row.
|
||||
- Submit posts to POST /api/v1/quotes/[id]/review-approve with
|
||||
action: 'approve' and the modal's email payload. The server
|
||||
atomically approves + dispatches.
|
||||
- Same handler works for partial_approval_pending rows — server
|
||||
flips partial_approved -> sending -> emailed natively.
|
||||
- Toast wording matches the detail page: 'Approved - email sent...'
|
||||
for full, 'Partial approved - email sent...' for partial, with
|
||||
invalid_status and mail-failed branches surfacing their own toasts.
|
||||
- Reject path untouched."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Verify partial-quote toast path (test 6 passes — likely already does)
|
||||
|
||||
**Files:**
|
||||
- Modify: none (no code change expected)
|
||||
|
||||
- [ ] **Step 1: Confirm test 6 already passes from Task 2**
|
||||
|
||||
Run: `npx vitest run src/app/(app)/quotes/approvals/page.test.tsx -t "partial row"`
|
||||
|
||||
Expected: PASS. The partial row uses the same `openSendModal` path as the full row, and the toast message branches on `quote.status === "partial_approval_pending"` inside `sendApprove`.
|
||||
|
||||
If it fails: the most likely cause is the second-row click target (the test uses `buttons[1]`) — confirm the partial row renders an "Approve & send" button by listing all buttons in the DOM in a debug render. The fix is to ensure the partial row IS rendered with the new button (no leftover "Approve partial" copy).
|
||||
|
||||
- [ ] **Step 2: If a fix is needed, edit the page to make test 6 pass, then re-run all 7 tests**
|
||||
|
||||
Most likely culprit: the partial row is missing items so it shows the "(X/Y items)" badge but the button's `disabled` prop may be set incorrectly. Verify by reading the `disabled` predicate:
|
||||
|
||||
```tsx
|
||||
disabled={actionLoading !== ""}
|
||||
```
|
||||
|
||||
This is the existing predicate; it should fire correctly.
|
||||
|
||||
- [ ] **Step 3: If a fix is needed, commit; otherwise mark this task done with no commit**
|
||||
|
||||
```bash
|
||||
git add 'src/app/(app)/quotes/approvals/page.tsx' # only if a fix was applied
|
||||
git commit -m "fix(approvals): ensure partial row renders Approve & send button
|
||||
|
||||
<one-line explanation of what was actually wrong>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Manual smoke test against running PB
|
||||
|
||||
**Files:** none modified
|
||||
|
||||
- [ ] **Step 1: Start the dev server**
|
||||
|
||||
Run: `npm run dev` (or whatever command the project uses — port 4001 per project memory). Wait for the ready signal.
|
||||
|
||||
- [ ] **Step 2: Seed a quote in `approval_pending` status**
|
||||
|
||||
In the PB admin UI, open an existing quote in `new` or `approval_pending`. If none exist, take any quote, fill in prices on its items, and run the existing "submit for approval" workflow to get one into `approval_pending`. Alternatively, manually flip a quote's `status` to `approval_pending` in the PB admin UI for the smoke test.
|
||||
|
||||
- [ ] **Step 3: Navigate to /quotes/approvals and click "Approve & send"**
|
||||
|
||||
Open `http://localhost:4001/quotes/approvals`. Find the seeded quote's row. Click "Approve & send". Confirm:
|
||||
- The `SendQuoteModal` opens
|
||||
- The primary button reads "Approve & send"
|
||||
- The To field is pre-populated with the customer's email
|
||||
- Subject is pre-populated
|
||||
|
||||
Edit the subject/message if needed, click "Approve & send". Confirm:
|
||||
- The modal closes
|
||||
- The row disappears from the list (status flipped to `approved` and `sending` → `emailed`)
|
||||
- The toast reads "Approved — email sent to the customer."
|
||||
- The customer receives the email
|
||||
|
||||
- [ ] **Step 4: Repeat for a partial row**
|
||||
|
||||
Repeat Steps 2-3 with a quote in `partial_approval_pending` status. Confirm:
|
||||
- Toast reads "Partial approved — email sent for the priced items."
|
||||
|
||||
- [ ] **Step 5: Race-test the invalid_status branch**
|
||||
|
||||
In one browser tab, open `/quotes/approvals`. In a second tab, open the same quote via the PB admin and flip its status to `approved`. Back in tab 1, click "Approve & send" on the (now stale) row → submit. Confirm:
|
||||
- Toast reads "This quote was already actioned — refreshing."
|
||||
- The list refreshes and the row is gone.
|
||||
|
||||
- [ ] **Step 6: Mail-failed branch (optional — requires SMTP misconfiguration)**
|
||||
|
||||
If a `mail_failed` scenario is reproducible in your env (e.g. by temporarily pointing the SMTP to a black hole port), confirm:
|
||||
- The approve part succeeds (status flips to `approved` or `partial_approved`)
|
||||
- The toast reads "Saved — but email send failed: <msg>. Retry from the quote page."
|
||||
- The quote's status is `mail_failed` in PB
|
||||
|
||||
If the smoke step is impractical, skip and document.
|
||||
|
||||
- [ ] **Step 7: Report results**
|
||||
|
||||
Tell the user: smoke test pass/fail for each branch; any UI quirks; any deviation from the spec.
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist (run before offering execution)
|
||||
|
||||
- [ ] Every spec requirement is covered by a task: ✅
|
||||
- "Replace list 'Approve' button" → Task 2 Step 5
|
||||
- "Replace list 'Approve partial' button" → Task 2 Step 5 (single button now)
|
||||
- "Open SendQuoteModal unchanged" → Task 2 Step 6
|
||||
- "Lift sendApprove to page level" → Task 2 Step 3
|
||||
- "Per-row modal state" → Task 2 Step 2
|
||||
- "Submit posts to review-approve" → Task 2 Step 3
|
||||
- "Toast wording matches detail page (full)" → Task 2 Step 3
|
||||
- "Toast wording matches detail page (partial)" → Task 2 Step 3
|
||||
- "Toast wording for invalid_status" → Task 2 Step 3
|
||||
- "Toast wording for mail-failed" → Task 2 Step 3
|
||||
- "Reject path unchanged" → Task 2 Step 5 (button untouched)
|
||||
- "6 component tests" → Task 1 (all 7 tests scaffolded; 6 from spec + 1 extra for button presence)
|
||||
- "SendQuoteModal not re-tested" → Reused unchanged, no test added
|
||||
- "review-approve endpoint not changed" → Reused unchanged
|
||||
- [ ] No placeholder/TBD in any task.
|
||||
- [ ] Type consistency: `sendApprove` signature matches the `onSend` prop shape in `SendQuoteModal` Props (the `email` argument matches the `onSend` callback signature line 88-95).
|
||||
- [ ] No new dependencies introduced.
|
||||
- [ ] No PB schema changes (we're only calling existing endpoints).
|
||||
- [ ] No AGENTS.md violations: no `pb-helpers.ts` imports added, no new `select` values needed.
|
||||
|
|
@ -1,374 +0,0 @@
|
|||
# Handoff: outstanding work on `feat/order-quote-auto-linking`
|
||||
|
||||
**Written:** 2026-08-29
|
||||
**Branch:** `feat/order-quote-auto-linking` (branched from `main`)
|
||||
**HEAD at handoff:** `0083199`
|
||||
|
||||
This document is for an implementing agent picking up mid-feature. It states
|
||||
what is genuinely done (verified, not assumed), what is outstanding, and —
|
||||
importantly — **where the original plan documents have gone stale**, because
|
||||
several review cycles changed things the plans still describe the old way.
|
||||
|
||||
Read this file before reading the plans. Where they disagree, this file wins.
|
||||
|
||||
---
|
||||
|
||||
## Two plans are in flight
|
||||
|
||||
| Plan | Status |
|
||||
|---|---|
|
||||
| `docs/superpowers/plans/2026-08-29-order-quote-linking.md` | Tasks 1–6 done, **7–13 outstanding** |
|
||||
| `docs/superpowers/plans/2026-08-29-order-delivery-note-match.md` | **Not started**, all 6 tasks outstanding |
|
||||
|
||||
The delivery-note plan depends on the quote plan's Task 6 (already merged), so
|
||||
they can proceed in parallel from here. Its own pre-flight section explains
|
||||
the dependency.
|
||||
|
||||
---
|
||||
|
||||
## Verified current state
|
||||
|
||||
Confirmed by running the commands, not by trusting prior reports:
|
||||
|
||||
- **13 feature commits** on the branch since the plan landed. Working tree clean.
|
||||
- `src/lib/server/order-quote-link-service.ts` — **481 lines**, exports
|
||||
`QuoteMatchMethod`, `QuoteMatchCandidate`, `SuggestQuoteResult`,
|
||||
`suggestQuoteForOrder`, `PairedLine`, `PairOrderLinesResult`, `pairOrderLines`.
|
||||
- **55 tests passing** across `order-quote-link-service.test.ts` +
|
||||
`order-quote-match.test.ts`.
|
||||
- Full suite: **15 pre-existing failures across 9 files** — all predate this
|
||||
work. Do not fix them, but do confirm you add none. Baseline files:
|
||||
`pocketbase-admin.test.ts` (2), `po-agent-workflow.test.ts` (2), MCP
|
||||
`route.test.ts` (2), `orders/route.test.ts` (2),
|
||||
`quotes/[id]/submit/route.test.ts` (3), `sidebar.test.tsx` (1),
|
||||
`delivery-notes-service.test.ts` (1), `orders-service.test.ts` (1 —
|
||||
`splitOrderForContinuation > moves pending items...`), `invoicing/page.test.tsx` (1).
|
||||
**Diff failing test NAMES, not counts** — this was how a real regression
|
||||
would otherwise have hidden behind a coincidentally-equal count.
|
||||
|
||||
### What works end-to-end today
|
||||
|
||||
An order created through `createOrder` (the n8n email-intake path, via MCP
|
||||
`orders.create` or `POST /api/v1/orders/create`) now automatically gets its
|
||||
source quote detected and written to `potential_quote`, and the existing amber
|
||||
"Suggested" pill appears on `/orders` with no human action. Linking a quote via
|
||||
`setOrderQuote` (REST, MCP, or UI) now also persists confident order-line →
|
||||
quote-line pairs onto `order_items.quote_item`.
|
||||
|
||||
### What does NOT work yet
|
||||
|
||||
- The **historical backlog is untouched** — only orders created from now on are
|
||||
matched. That's Task 7.
|
||||
- **No UI reads the line pairs.** The buyer notes and per-line attachments that
|
||||
motivated this whole feature still don't show. That's Tasks 9–13.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Where the plans are STALE — read before implementing
|
||||
|
||||
Six review cycles changed things the plan text still describes the old way.
|
||||
|
||||
### 1. `SuggestQuoteResult.window` is `"initial"`, not `"90d"`
|
||||
|
||||
The plan's Task 7 sample code and prose refer to a `"90d"` label. Review
|
||||
changed it so retuning `FUZZY_WINDOW_DAYS` can't make the label lie. Actual
|
||||
current shape (`order-quote-link-service.ts:54-67`):
|
||||
|
||||
```ts
|
||||
export interface SuggestQuoteResult {
|
||||
orderId: string;
|
||||
best: QuoteMatchCandidate | null;
|
||||
alternatives: QuoteMatchCandidate[];
|
||||
method: QuoteMatchMethod;
|
||||
window: "exact" | "initial" | "widened" | "none";
|
||||
ageGapDays: number | null;
|
||||
skipped?: "already_linked";
|
||||
}
|
||||
```
|
||||
|
||||
`ageGapDays` **does** exist and is populated — Task 7's histogram can read it
|
||||
directly, no recomputation needed.
|
||||
|
||||
### 2. The test mock harness was refactored
|
||||
|
||||
`order-quote-link-service.test.ts` now has a `makePb(overrides)` that merges
|
||||
per-test overrides over sensible defaults, and the helper is `mockAdminPb`,
|
||||
**not** `useP`. The plan's Task 4/7 test snippets show the old style. Read the
|
||||
current file and follow it.
|
||||
|
||||
### 3. `vitest.config.ts` has no `clearMocks` — this bit us three times
|
||||
|
||||
Mock call history is **not** reset between tests; the global `beforeEach` only
|
||||
calls `clearStore()`. Three separate tests were found passing on history
|
||||
leaked from tests 150 lines earlier, guarding nothing.
|
||||
|
||||
**Always `vi.mocked(fn).mockClear()` at the top of any test asserting
|
||||
`toHaveBeenCalledWith`.** And prove your test isn't vacuous: delete the call
|
||||
site, watch the test fail, restore. Do not skip this — it caught real holes
|
||||
every time it was run.
|
||||
|
||||
Do **not** globally set `clearMocks: true` as a drive-by fix; it changes
|
||||
behaviour across 2,278 tests and needs its own change with its own verification.
|
||||
|
||||
### 4. Quote-item fetches go through a chunked helper
|
||||
|
||||
Do not hand-build `quote = "a" || quote = "b" || ...` OR-chains. PB rejects
|
||||
filters past ~3.5KB (measured: 107 ids fail, 106 pass — see
|
||||
`orders-service.ts:499-505`). Use `fetchQuoteItemsForQuotesAdmin(pb, ids, fields)`
|
||||
from `quotes-service.ts`, which does IN-syntax with an 80-id chunked fallback.
|
||||
It takes an explicit `fields` param — **pass what you need**, because a
|
||||
narrowed default projection silently degrades matching to near-zero
|
||||
confidence with no error.
|
||||
|
||||
### 5. Field projections are load-bearing and must stay tested
|
||||
|
||||
`MATCHER_QUOTE_ITEM_FIELDS`, `PAIR_ORDER_ITEM_FIELDS`, `PAIR_QUOTE_ITEM_FIELDS`
|
||||
exist as named constants because dropping a field from one silently disables a
|
||||
safety guard while the suite stays green. If you add a query, assert its
|
||||
projection in a test.
|
||||
|
||||
### 6. `createOrder`'s `after()` is guarded — don't add a second one
|
||||
|
||||
`after()` **throws synchronously** outside a request scope (Next 16.2.6, error
|
||||
`E468`, `node_modules/next/dist/server/after/after.js`). `createOrder` wraps
|
||||
the `after()` call itself and falls back to fire-and-forget. There is a single
|
||||
`runQuoteMatch` closure — **add new post-intake work inside it**, in its own
|
||||
`try/catch`, rather than calling `after()` again.
|
||||
|
||||
---
|
||||
|
||||
# OUTSTANDING WORK
|
||||
|
||||
## A. Task 6 follow-up fixes (do first — small, and they close a real hole)
|
||||
|
||||
Task 6 is merged (`21bc577`) and passed spec review, but its code review found
|
||||
two issues that were never applied. Both in `src/lib/server/orders-service.ts`
|
||||
and its test file.
|
||||
|
||||
**A1. The fallback path has zero coverage, and one test is vacuous.**
|
||||
|
||||
The test mocks `next/server` so `after` never throws — meaning the `catch` +
|
||||
fire-and-forget fallback (~`orders-service.ts:2238-2249`) is never executed by
|
||||
the suite. Worse, the reviewer deleted the inner `try/catch` from
|
||||
`runQuoteMatch` entirely and **both existing tests still passed**, because the
|
||||
mock's `void cb()` detaches the promise regardless of what production code does.
|
||||
|
||||
Add:
|
||||
|
||||
```ts
|
||||
it("falls back to an inline match when after() is unavailable", async () => {
|
||||
const { after } = await import("next/server");
|
||||
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
|
||||
vi.mocked(suggestQuoteForOrder).mockClear();
|
||||
vi.mocked(after).mockImplementationOnce(() => {
|
||||
throw new Error("`after` was called outside a request scope.");
|
||||
});
|
||||
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
|
||||
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
|
||||
|
||||
const r = await createOrder({
|
||||
customer_id: "cust-x",
|
||||
company_id: "comp-x",
|
||||
items: [{ description: "M16 Bolt", quantity: 50 }],
|
||||
});
|
||||
|
||||
expect(r.order.id).toBeDefined();
|
||||
expect(suggestQuoteForOrder).toHaveBeenCalledWith(r.order.id);
|
||||
});
|
||||
```
|
||||
|
||||
Also add a `console.error` spy assertion to the existing
|
||||
`"never fails order creation when the matcher throws"` test so it verifies the
|
||||
swallow rather than the mock's detachment.
|
||||
|
||||
**A2. The `catch` around `after()` logs nothing.**
|
||||
|
||||
If `after()` becomes unavailable (a Next upgrade, or `waitUntil` missing on the
|
||||
deploy target — a second distinct throw path in `after-context.js` that the bare
|
||||
catch also swallows), every order silently degrades to fire-and-forget with
|
||||
**zero trace anywhere**. Nothing breaks visibly; you just lose the latency
|
||||
guarantee permanently and invisibly.
|
||||
|
||||
```ts
|
||||
} catch (err) {
|
||||
console.error(`[createOrder] after() unavailable, running quote match inline for ${order.id}:`, err);
|
||||
void runQuoteMatch();
|
||||
}
|
||||
```
|
||||
|
||||
**A3 (optional).** The comment explaining the guard names neither the error
|
||||
code nor the Next version, so a future reader hitting an upgrade must redo the
|
||||
source dive. Add `// Next 16.2.6, error code E468 — node_modules/next/dist/server/after/after.js`.
|
||||
Its stated justification also cites a smoke-script caller that doesn't exist —
|
||||
all three real callers are in request scope. Keep the fallback (`createOrder` is
|
||||
an exported service function reachable from multiple layers per this repo's
|
||||
architecture rules), but fix the comment to say that.
|
||||
|
||||
Commit: `fix(orders): cover the after() fallback path and log when it engages`
|
||||
|
||||
---
|
||||
|
||||
## B. Tasks 7–13 of the order↔quote plan
|
||||
|
||||
Full step-by-step detail is in
|
||||
`docs/superpowers/plans/2026-08-29-order-quote-linking.md` — **read it there**,
|
||||
subject to the staleness corrections above. Summary of what remains:
|
||||
|
||||
### Task 7 — Backlog sweep
|
||||
`POST /api/v1/orders/quote-match/sweep` + `sweepUnmatchedOrders` and
|
||||
`bucketAgeGaps` in the link service. Processes orders where
|
||||
`quote_matching_attempted = false && quote = ""` in batches, returns
|
||||
`{ processed, suggested, noMatch, failed, remaining, ageGaps }`.
|
||||
|
||||
The `ageGaps` histogram is not decoration — it's how the 90-day
|
||||
`FUZZY_WINDOW_DAYS` default gets retuned from real data instead of staying an
|
||||
estimate. Read `ageGapDays` straight off each `SuggestQuoteResult`.
|
||||
|
||||
Verify `quote = ""` is genuinely how this codebase queries an empty relation
|
||||
(grep `orders-service.ts`) rather than assuming PB's syntax.
|
||||
|
||||
### Task 8 — MCP tool `orders.quote_match_sweep`
|
||||
Hermes/n8n drops the `AGENT_API_KEY` on direct REST calls, so the sweep must
|
||||
exist as an MCP tool. Follow the `orders.set_quote` pattern in
|
||||
`src/app/api/mcp/route.ts` — tool definition alongside it, handler forwarding
|
||||
to the REST route.
|
||||
|
||||
### ✅ CHECKPOINT — DONE 2026-08-29. Sweep run, window confirmed at 90 days.
|
||||
|
||||
**Outcome: `FUZZY_WINDOW_DAYS` stays at 90. No change needed. Phase 2 is unblocked.**
|
||||
|
||||
Full backlog swept: **5,096 orders processed, 2,483 suggested, 2,613 no-match,
|
||||
0 failed** — `remaining: 0`. Amber pills are now live across the historical
|
||||
backlog (approved by the owner).
|
||||
|
||||
**Read this before you trust the sweep's `ageGaps` output.** The histogram
|
||||
reported `d0_30: 100%`, which is an **artifact, not a finding**:
|
||||
|
||||
- **92% of matched backlog orders have a gap of exactly 0 days.** Their
|
||||
`created` timestamps come from the legacy bulk migration — order and quote
|
||||
were inserted at the same instant — so the gap measures migration timing,
|
||||
not quote-to-order lead time.
|
||||
- `quoteToOrderAgeDays` itself is **correct** (verified: returns `33` for a
|
||||
real 33-day PB-format pair). Do not go looking for a bug in it.
|
||||
- The real signal, from non-migrated orders: gaps run **1–33 days, p50 23,
|
||||
p90 24, max 33. Zero beyond 90 days.** That's what justifies keeping 90 —
|
||||
nearly 3× headroom against the worst observed real gap.
|
||||
|
||||
**Consequence worth knowing: for migrated records the date window constrains
|
||||
nothing.** If an order and every candidate quote share a migration timestamp,
|
||||
all of them fall inside any window. So those ~2,400 backlog suggestions were
|
||||
scored with the window effectively disabled — only company / customer /
|
||||
description / price carried them. Backlog amber pills deserve more operator
|
||||
skepticism than new-order ones, whose timestamps are real and where the window
|
||||
does its job.
|
||||
|
||||
**Two follow-ups, neither blocking Phase 2:**
|
||||
1. Have the sweep report gap-0 matches in their own bucket so this histogram
|
||||
can't mislead the next reader the way it nearly did here.
|
||||
2. Check whether the migration preserved a real business date (there is a
|
||||
`migration_meta` JSON field on `orders`) that the matcher could prefer over
|
||||
`created` for migrated rows.
|
||||
|
||||
**Separate open question — do not conflate it with the window.** Match rate is
|
||||
**48.7%**, well under the owner's ~85% expectation. The misses are not old
|
||||
quotes, so widening the window will not help. Candidates: those orders
|
||||
genuinely had no quote; the `MATCH_CONFIDENCE_THRESHOLD` of 0.4 is too strict;
|
||||
or description drift exceeds what the Jaccard scorer tolerates. Diagnose
|
||||
deliberately, don't tune blind.
|
||||
|
||||
**Operational note for anyone re-running the sweep:** `limit: 200` killed the
|
||||
dev server outright (process gone, not a timeout). `limit: 50` is stable at
|
||||
~14s per batch. The route advertises up to 500 with `maxDuration = 300`; that
|
||||
ceiling is optimistic, because each order fans out into a candidate scan of up
|
||||
to 200 quotes plus their line items, twice when the window widens. Consider
|
||||
lowering the documented max.
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:4001/api/v1/orders/quote-match/sweep \
|
||||
-H "authorization: Bearer $AGENT_API_KEY" \
|
||||
-H 'content-type: application/json' -d '{"limit":50}'
|
||||
```
|
||||
|
||||
(Dev server runs on **port 4001**, not 3000.)
|
||||
|
||||
### Task 9 — Extract `AttachmentList` to `src/components/orders/attachment-list.tsx`
|
||||
Currently local to `process/page.tsx`. Pure move so both order pages can share it.
|
||||
|
||||
### Task 10 — Serve the resolved pairing to the UI
|
||||
Add `linePairs` (keyed by `order_item_id`, carrying `quote_item_id`,
|
||||
`confidence`, `tier`) to `matched-attachments/route.ts`. Additive — the MCP
|
||||
`orders.list_attachments` consumer must keep working. Plus
|
||||
`POST /api/v1/orders/[id]/quote-line` to confirm a doubtful pair.
|
||||
|
||||
### Task 11 — `src/components/orders/quote-line-context.tsx`
|
||||
Shared presentation of a paired quote line: description, buyer note,
|
||||
attachments, plus a "possible match — confirm?" state and an explicit
|
||||
"no matching line" state.
|
||||
|
||||
### Task 12 — Process page uses server pairing
|
||||
**Delete `findMatchedQuoteItem`** (`process/page.tsx:3159`) — the exact/substring
|
||||
guesser that is the actual root cause of vanishing buyer notes. Replace its
|
||||
three call sites with `linePairs` lookups.
|
||||
|
||||
### Task 13 — Order detail table + migrate off `pb-helpers`
|
||||
Row indicators (note icon, paperclip + count) expanding to show quote context.
|
||||
Then migrate the four `pb-helpers` matcher callers still live at
|
||||
`src/app/(app)/orders/[id]/page.tsx:59-62` (`findMatchingQuotesForOrder`,
|
||||
`applyOrderQuoteMatch`, `clearOrderQuoteMatch`, `setOrderQuoteManually`) onto
|
||||
`/api/v1/*`, then delete the stale matcher block from `pb-helpers.ts`.
|
||||
|
||||
**Note the API shape change:** the old `result.best.quote.quote_number` becomes
|
||||
`result.best.quoteNumber`, and `result.best.quote.id` becomes
|
||||
`result.best.quoteId`.
|
||||
|
||||
`AGENTS.md` requires this migration when touching that page — it is not optional
|
||||
cleanup.
|
||||
|
||||
---
|
||||
|
||||
## C. The delivery-note repeat-order feature (all 6 tasks)
|
||||
|
||||
`docs/superpowers/plans/2026-08-29-order-delivery-note-match.md`, spec at
|
||||
`docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md`.
|
||||
|
||||
Detects when a new order's items exactly match a past delivery note's items
|
||||
(same **company** — a different person at the company can place the repeat
|
||||
order), and flags it with a blue pill showing the DN number.
|
||||
|
||||
Two things to know before starting:
|
||||
|
||||
- **It needs a PB schema migration** (Task 1 of that plan) — five new `orders`
|
||||
fields. All are relation/text/number/bool, **not `select`**, so no manual PB
|
||||
admin step is required. Confirm with the user before running any migration
|
||||
against the live database.
|
||||
- **The pill reads a denormalized text field, not an expanded relation.**
|
||||
`orders-service.ts:253` documents that PB issues a separate query per
|
||||
expanded relation, which is why the existing quote pill shows no number.
|
||||
Since this pill must show the DN number, the plan denormalizes
|
||||
`delivery_note_number` onto the order at write time. Do not "simplify" this
|
||||
into an `expand` — it would reintroduce a per-page-load cost the codebase
|
||||
deliberately avoids.
|
||||
|
||||
Its matching is **exact**, not fuzzy — either every line pairs on normalized
|
||||
description and quantity with nothing left over on either side, or nothing is
|
||||
flagged. There is no "possible" tier. That's deliberate and different from the
|
||||
quote matcher.
|
||||
|
||||
---
|
||||
|
||||
## Working agreement that produced good results here
|
||||
|
||||
Every task so far ran: implement → spec-compliance review → code-quality review
|
||||
→ fix findings → re-verify. That caught, among others:
|
||||
|
||||
- a PB filter-length bug that would have broken exactly the high-volume
|
||||
customers the matcher matters most for
|
||||
- a cross-line collision that would have shown two different parts the same
|
||||
estimator's note — the precise failure the feature exists to prevent
|
||||
- three tests that passed while guarding nothing
|
||||
- `after()` throwing outside request scope, which would have made the safety
|
||||
code itself crash order creation
|
||||
|
||||
The pattern that found most of these was **mutation testing**: break the thing
|
||||
deliberately, confirm a test fails, restore. Please keep doing that rather than
|
||||
trusting that a green suite means a guarded one.
|
||||
|
|
@ -1,981 +0,0 @@
|
|||
# Order ↔ Delivery-Note Repeat-Order Detection Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** When a new order's line items exactly match a past delivery note's items (same company), flag it automatically with a visible pill on the orders list showing the delivery note number.
|
||||
|
||||
**Architecture:** A pure exact-match comparator (no I/O) feeds a PB-backed service, `suggestDeliveryNoteForOrder`, which piggybacks on the `after()` hook already built and reviewed for the order↔quote matching feature — no new `after()` call, no new error-guard code to write or review. Two independent `try/catch` blocks so a failure in one matcher can't block the other. The match is written automatically (no human confirmation step) because it's informational only — nothing downstream reads it to drive pricing or processing.
|
||||
|
||||
**Tech Stack:** Next.js 16.2.6 (App Router), TypeScript, PocketBase (via `getAdminPocketBase()`), Vitest, Tailwind + shadcn/ui, lucide-react.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md` — read it first. It records why the pill reads a denormalized text field instead of an expanded relation (a deliberate perf tradeoff already made once in this codebase for the quote-suggestion pill, at `orders-service.ts:253`) — do not "simplify" this by switching to an `expand`.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight — verify before writing code, do not assume
|
||||
|
||||
1. **This plan depends on `docs/superpowers/plans/2026-08-29-order-quote-linking.md` Task 6 being merged first** — specifically the `runQuoteMatch` closure inside `createOrder`'s `after()` block in `src/lib/server/orders-service.ts`. Confirm it exists (`grep -n "async function runQuoteMatch" src/lib/server/orders-service.ts`) before starting Task 4 below. If it isn't there yet, stop and report — do not build a second `after()` call as a workaround.
|
||||
2. **Confirm `getOrdersDashboardData`'s field selection.** Read `src/lib/server/orders-service.ts` around line 253 (the `potential_quote` expand-drop comment) and trace what the function actually returns to `/api/v1/orders/list` — full records or a `fields`-limited projection. The spec assumes full records (per the PB-0.26-drops-expand-when-fields-is-set note in the same comment block) so no allowlist change is needed for the new field to appear. If that assumption is wrong, Task 6 below needs an additional edit — say so in that task's report.
|
||||
3. **Confirm the migration script auth pattern still works.** `scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` is the template for Task 1. Read it in full before writing the new one.
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `src/lib/server/delivery-note-order-match.ts` *(create)* | Pure exact-match comparator. No I/O. |
|
||||
| `scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts` *(create)* | Adds the 5 new `orders` fields. |
|
||||
| `src/lib/server/order-delivery-note-link-service.ts` *(create)* | PB-backed `suggestDeliveryNoteForOrder`. |
|
||||
| `src/lib/server/orders-service.ts` *(modify)* | Add the second matcher call inside the existing `runQuoteMatch` closure. |
|
||||
| `src/app/api/v1/orders/[id]/delivery-note-match/route.ts` *(create)* | On-demand single-order re-check. |
|
||||
| `src/lib/types.ts` *(modify)* | Add the 5 new fields to the `Order` interface. |
|
||||
| `src/app/(app)/orders/page.tsx` *(modify)* | New pill in the existing badge row. |
|
||||
|
||||
## Phase boundaries
|
||||
|
||||
- **Phase 1 — Tasks 1–4.** Backend: schema, matcher, service, intake wiring. Independently testable and mergeable without the UI.
|
||||
- **Phase 2 — Tasks 5–6.** On-demand route + UI pill.
|
||||
|
||||
---
|
||||
|
||||
# PHASE 1 — Backend
|
||||
|
||||
## Task 1: Migration script for the new `orders` fields
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts`
|
||||
|
||||
- [ ] **Step 1: Write the script**
|
||||
|
||||
Model this exactly on `scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` — same env-loading, same auth pattern, same idempotent field-existence check per field, same re-fetch-after-each-write pattern (PB's `collections.update` needs the freshest `fields` array or it clobbers concurrent additions).
|
||||
|
||||
```ts
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Migration: add delivery-note repeat-order detection fields to `orders`.
|
||||
*
|
||||
* Adds five fields:
|
||||
*
|
||||
* 1. `potential_delivery_note` (relation → delivery_notes, single)
|
||||
* The DN whose items exactly matched this order's items.
|
||||
*
|
||||
* 2. `potential_delivery_note_number` (text)
|
||||
* Denormalized copy of the matched DN's `delivery_note_number`.
|
||||
* Written alongside (1) so the orders list can render the number
|
||||
* without a second `expand` — see the comment at
|
||||
* orders-service.ts:253 for why that expand cost is avoided here
|
||||
* (PB issues a separate IN query per expanded relation).
|
||||
* `delivery_note_number` is immutable once assigned, so copying
|
||||
* it carries no staleness risk.
|
||||
*
|
||||
* 3. `delivery_note_match_confidence` (number)
|
||||
* Always 1 when set — the match is exact-or-nothing, not scored.
|
||||
* Kept for shape parity with quote_match_confidence and so a
|
||||
* future fuzzy extension doesn't need a second migration.
|
||||
*
|
||||
* 4. `delivery_note_match_method` (text)
|
||||
* "exact_items" or "no_match". Text, not select, for the same
|
||||
* forward-compatibility reason quote_match_method is text.
|
||||
*
|
||||
* 5. `delivery_note_matching_attempted` (bool)
|
||||
* Audit flag mirroring quote_matching_attempted.
|
||||
*
|
||||
* Idempotent — safe to re-run. Each step detects its own "already
|
||||
* done" state.
|
||||
*
|
||||
* Run with:
|
||||
* npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts
|
||||
*/
|
||||
import PocketBase from "pocketbase";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function loadEnvFile(name: string) {
|
||||
try {
|
||||
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
||||
if (m && !process.env[m[1]]) {
|
||||
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file not present — fine, rely on shell env
|
||||
}
|
||||
}
|
||||
loadEnvFile(".env");
|
||||
loadEnvFile(".env.local");
|
||||
|
||||
async function main() {
|
||||
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
||||
const email = process.env.PB_ADMIN_EMAIL;
|
||||
const password = process.env.PB_ADMIN_PASSWORD;
|
||||
if (!url || !email || !password) {
|
||||
throw new Error(
|
||||
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local)."
|
||||
);
|
||||
}
|
||||
|
||||
const pb = new PocketBase(url);
|
||||
await pb.collection("_superusers").authWithPassword(email, password);
|
||||
console.log(`[order-delivery-note-match] Authenticated against ${url}`);
|
||||
|
||||
const deliveryNotes = await pb.collections.getOne("delivery_notes");
|
||||
|
||||
async function addFieldIfMissing(
|
||||
fieldDef: Record<string, unknown> & { name: string },
|
||||
) {
|
||||
const orders = await pb.collections.getOne("orders");
|
||||
if (orders.fields.some((f: { name: string }) => f.name === fieldDef.name)) {
|
||||
console.log(`[order-delivery-note-match] orders.${fieldDef.name} already present — skipping.`);
|
||||
return;
|
||||
}
|
||||
await pb.collections.update(orders.id, {
|
||||
fields: [...orders.fields, fieldDef],
|
||||
});
|
||||
console.log(`[order-delivery-note-match] Added orders.${fieldDef.name}.`);
|
||||
}
|
||||
|
||||
await addFieldIfMissing({
|
||||
name: "potential_delivery_note",
|
||||
type: "relation",
|
||||
required: false,
|
||||
presentable: false,
|
||||
options: {
|
||||
collectionId: deliveryNotes.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await addFieldIfMissing({
|
||||
name: "potential_delivery_note_number",
|
||||
type: "text",
|
||||
required: false,
|
||||
presentable: false,
|
||||
options: { max: 32 },
|
||||
});
|
||||
|
||||
await addFieldIfMissing({
|
||||
name: "delivery_note_match_confidence",
|
||||
type: "number",
|
||||
required: false,
|
||||
presentable: false,
|
||||
options: { min: 0, max: 1 },
|
||||
});
|
||||
|
||||
await addFieldIfMissing({
|
||||
name: "delivery_note_match_method",
|
||||
type: "text",
|
||||
required: false,
|
||||
presentable: false,
|
||||
options: { max: 32 },
|
||||
});
|
||||
|
||||
await addFieldIfMissing({
|
||||
name: "delivery_note_matching_attempted",
|
||||
type: "bool",
|
||||
required: false,
|
||||
presentable: false,
|
||||
options: {},
|
||||
});
|
||||
|
||||
console.log("[order-delivery-note-match] Migration complete.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[order-delivery-note-match] Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Verify the relation field's `options` shape against a real PB response.**
|
||||
The `relation` field's `options` object (`collectionId`, `cascadeDelete`, `minSelect`, `maxSelect`) is written from memory of PocketBase's schema shape — before running this against production, fetch the *existing* `orders.quote` or `orders.potential_quote` field definition (`pb.collections.getOne("orders")`, inspect `.fields`) and confirm the option keys match exactly. If they differ, use the real shape, not what's written above.
|
||||
|
||||
- [ ] **Step 3: Run it against the actual PB instance** (ask the user for confirmation before running any migration against a real database — this is a schema change, not a reversible local operation):
|
||||
|
||||
```bash
|
||||
npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts
|
||||
```
|
||||
|
||||
Expected: five "Added orders.X" lines, or "already present" on re-run.
|
||||
|
||||
- [ ] **Step 4: Add the npm script**, matching the existing `pb:migrate:*` convention in `package.json`:
|
||||
|
||||
```json
|
||||
"pb:migrate:order-delivery-note-match": "npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts package.json
|
||||
git commit -m "feat(orders): add delivery-note repeat-order match fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Pure exact-match comparator
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/server/delivery-note-order-match.ts`
|
||||
- Test: `src/lib/server/delivery-note-order-match.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeLine, isExactItemMatch } from "@/lib/server/delivery-note-order-match";
|
||||
|
||||
describe("normalizeLine", () => {
|
||||
it("trims, lowercases, and collapses internal whitespace", () => {
|
||||
expect(normalizeLine(" Hex Bolt M20 ")).toBe("hex bolt m20");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExactItemMatch", () => {
|
||||
it("matches when every line pairs 1:1 on normalized description and quantity", () => {
|
||||
const order = [
|
||||
{ description: "Hex Bolt M20", quantity: 50 },
|
||||
{ description: "Hex Nut M20", quantity: 50 },
|
||||
];
|
||||
const dn = [
|
||||
{ description: "hex bolt m20", quantity: 50 },
|
||||
{ description: "HEX NUT M20", quantity: 50 },
|
||||
];
|
||||
expect(isExactItemMatch(order, dn)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match when a quantity differs", () => {
|
||||
const order = [{ description: "Hex Bolt M20", quantity: 50 }];
|
||||
const dn = [{ description: "Hex Bolt M20", quantity: 45 }];
|
||||
expect(isExactItemMatch(order, dn)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match when the order has an extra line the DN doesn't have", () => {
|
||||
const order = [
|
||||
{ description: "Hex Bolt M20", quantity: 50 },
|
||||
{ description: "Washer M20", quantity: 50 },
|
||||
];
|
||||
const dn = [{ description: "Hex Bolt M20", quantity: 50 }];
|
||||
expect(isExactItemMatch(order, dn)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match when the DN has an extra line the order doesn't have", () => {
|
||||
const order = [{ description: "Hex Bolt M20", quantity: 50 }];
|
||||
const dn = [
|
||||
{ description: "Hex Bolt M20", quantity: 50 },
|
||||
{ description: "Washer M20", quantity: 50 },
|
||||
];
|
||||
expect(isExactItemMatch(order, dn)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires matching multiplicity, not just membership, for duplicate lines", () => {
|
||||
// Order has the SAME line twice (two separate 50-unit lines of the
|
||||
// same bolt, e.g. from two different quote lines that priced
|
||||
// differently but described identically). The DN has only one.
|
||||
const order = [
|
||||
{ description: "Hex Bolt M20", quantity: 50 },
|
||||
{ description: "Hex Bolt M20", quantity: 50 },
|
||||
];
|
||||
const dn = [{ description: "Hex Bolt M20", quantity: 50 }];
|
||||
expect(isExactItemMatch(order, dn)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches duplicate lines when both sides have the same multiplicity", () => {
|
||||
const order = [
|
||||
{ description: "Hex Bolt M20", quantity: 50 },
|
||||
{ description: "Hex Bolt M20", quantity: 50 },
|
||||
];
|
||||
const dn = [
|
||||
{ description: "Hex Bolt M20", quantity: 50 },
|
||||
{ description: "Hex Bolt M20", quantity: 50 },
|
||||
];
|
||||
expect(isExactItemMatch(order, dn)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for two empty line sets rather than vacuously true", () => {
|
||||
// An order or DN with zero lines carries no signal — this must
|
||||
// never be treated as "everything matched." If this returned true,
|
||||
// an order with 0 lines could match every DN with 0 lines in the
|
||||
// company, which is meaningless.
|
||||
expect(isExactItemMatch([], [])).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify red.** `npx vitest run src/lib/server/delivery-note-order-match.test.ts`
|
||||
|
||||
- [ ] **Step 3: Implementation**
|
||||
|
||||
```ts
|
||||
/**
|
||||
* delivery-note-order-match — pure exact-match comparator for the
|
||||
* order ↔ delivery-note repeat-order detector.
|
||||
*
|
||||
* Deliberately NOT the quote matcher's tokenize/Jaccard fuzzy scoring
|
||||
* (order-quote-match.ts). This is exact-string equality on a
|
||||
* normalized form, with multiset (bag) semantics — every order line
|
||||
* must pair to exactly one DN line and nothing may be left over on
|
||||
* either side. There is no partial-credit tier here; either the order
|
||||
* is a complete repeat of the DN or it isn't flagged at all.
|
||||
*/
|
||||
|
||||
export interface DeliveryLine {
|
||||
description: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/** Normalize for comparison: trim, lowercase, collapse internal
|
||||
* whitespace runs to a single space. */
|
||||
export function normalizeLine(s: string): string {
|
||||
return (s || "").trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `orderLines` and `dnLines` are equal as multisets keyed on
|
||||
* (normalized description, quantity). Empty input on either side
|
||||
* always returns false — there is no signal to match on, and treating
|
||||
* two empty sets as "matching" would flag every zero-line order
|
||||
* against every zero-line DN.
|
||||
*/
|
||||
export function isExactItemMatch(
|
||||
orderLines: ReadonlyArray<DeliveryLine>,
|
||||
dnLines: ReadonlyArray<DeliveryLine>,
|
||||
): boolean {
|
||||
if (orderLines.length === 0 || dnLines.length === 0) return false;
|
||||
if (orderLines.length !== dnLines.length) return false;
|
||||
|
||||
const key = (l: DeliveryLine) => `${normalizeLine(l.description)} | ||||