From e56cf01fdfb53255d2a6fbba8969073a7785a3d8 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 22:27:53 +0200 Subject: [PATCH] docs(spec): repairs app for the phone, and Giellie in repairs mode --- .../specs/2026-11-05-repairs-app-design.md | 811 ++++++++++++++++++ 1 file changed, 811 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-05-repairs-app-design.md diff --git a/docs/superpowers/specs/2026-11-05-repairs-app-design.md b/docs/superpowers/specs/2026-11-05-repairs-app-design.md new file mode 100644 index 0000000..56fcf30 --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-repairs-app-design.md @@ -0,0 +1,811 @@ +# Repairs app: phone-first capture and tracking, plus Giellie in repairs mode + +Date: 2026-11-05 +Status: design, approved — pending written review + +## Why + +The person who used to handle repairs is leaving. The job does not go away +with him: customers keep calling in with broken machines — a welding +machine that will not strike, a compressor that will not build pressure — +and someone has to book the machine in, get it to the right supplier, get +a quote out, quote the customer, and hand the job over to the buyer. + +Today that job runs on a desktop `DataTable` in `/repairs` with a dialog +form. It works for the office. It does not work for someone standing next +to a customer's machine with a phone in one hand. + +The operator described the job exactly as it actually runs: + +1. The customer reports a broken machine. +2. We collect the machine. +3. The repairs person creates the **repair request form**: the machine's + serial number, photos of the machine, whose machine it is, what the + customer says is wrong, and what the supplier needs to look at. +4. The driver takes the form and the machine to the supplier. +5. The supplier keeps a copy, **signs one and hands it back**. +6. The supplier sends a quote. We upload it against the repair. +7. The repair now holds the machine, the photos, the repair request and + the supplier quote. +8. We do a quote for the customer. +9. The customer sends the order. That order **matches the repair**. +10. The buyer can see where the machine is, and either sends them the + order to go ahead with the repair, or rejects it and collects the + machine back unrepaired. + +Two things follow from that description. + +**The capture step is the product.** Steps 3 and 6 are what the person +holding the phone actually does. The rest of the chain is office work that +already exists and mostly works. "That's why we need to build him +something" — the something is a phone app that captures a machine +booking-in properly, and then tracks the job from there. + +**The agent cannot help at all yet.** `src/app/api/mcp/route.ts` has +**zero** repair tools. `grep repairs` across the 7,462-line MCP route +returns nothing. This is a known, logged gap — `FEATURE-REQUESTS.md` +line 213, "Repairs: entire MCP domain missing", surfaced 2026-08-28. So +"give him full knowledge of repairs via Giellie" is not a re-architecture; +it is about eight MCP tools over service functions that already exist. + +### What the code has today, against that flow + +| Step | State | +|---|---| +| Book in a machine | `repairs` collection, `repairs-service.ts`, six `/api/v1/repairs/*` routes, office page + detail page | +| Photos of the machine | `repairs.photos`, multi-file, max 10 | +| Serial number, whose machine | `repairs.serial_no`, `repairs.customer`, `repairs.company` | +| What the customer says is wrong | `repairs.fault_description` — one field, currently doing both jobs | +| What the supplier must look at | **missing** | +| Repair request PDF the driver carries | `generateRepairRequest` → `repairs.repair_request` | +| The supplier's signed copy coming back | **missing** — we only keep our outgoing PDF | +| Supplier quote uploaded | `repairs.quote`, auto-flips status to `quoted` | +| Customer quote from the repair | `createCustomerQuoteFromRepair` → `repairs.customer_quote` | +| Order from that quote | `orders.create_from_quote` (MCP) | +| Order matched to the repair | **half-built and dormant** — `repairs.order` exists and nothing writes it; `orders.is_repair_order` exists and nothing writes or shows it | +| Buyer sees where the machine is | **missing** — no linkage to render | +| Giellie knows about repairs | **missing** — zero MCP tools | + +### What is wrong with the repair request PDF + +`src/lib/pdf/repair-request-document.tsx` renders a Repair No / Date / +Received row, a prominent supplier block, and a four-row item details +table: Item Description, Serial Number, **Problem Description**, Status. +It then asks the supplier for a numbered list (assessment, parts, labour, +timeline, warranty). + +Three things it must do that it does not do: + +- **No customer block.** The supplier cannot see whose machine it is. +- **One problem row.** The customer's words and our instruction to the + supplier are the same cell. +- **No signature block.** Step 5 is the supplier signing a copy and + handing it back. There is nowhere to sign. + +And no photos, though the operator lists photos as part of the form. + +### What is wrong with the status labels + +The repair status vocabulary is copied **three** times, with three +different sets of words: + +| Status | `repairs/page.tsx` | `repairs/[id]/page.tsx` | `repairs-service.ts` (PDF) | +|---|---|---|---| +| `new` | New | New | Booked in | +| `assessing` | Sent to supplier | Sent to supplier | Sent for Strip & Quote | +| `quoted` | Quote received | Quote received | Quote Received | +| `approved` | Approved | Approved | Approved | +| `in_progress` | In progress | In progress | In Progress | +| `completed` | Completed | Completed | Completed | + +A phone app with a status filter row cannot have three vocabularies. This +spec consolidates them into one shared module. + +## What success looks like + +- The new person installs one app on his phone and only ever sees + repairs. He books a machine in with photos, a serial number, the + customer's words and what the supplier needs to look at — in about a + minute, one thumb, no training. +- He can see the status of every repair without opening anything, and the + counts on the filter row add up to the number of repairs. +- Every repair carries the full paper trail: photos, the repair request + the driver carried, the supplier's signed copy, the supplier quote, the + customer quote and the order. +- When the customer's order arrives, the buyer opens the order and sees + the machine's repair status in place, without asking anyone. +- Giellie can drive the whole repair chain by chat, and in repairs mode + it will not touch a quote, an order or an email that has nothing to do + with repairs. + +## Goals + +1. **A standalone phone app at `/repairs-app`** — own manifest, own + service worker, own layout, out of the office sidebar, exactly like + `/rep` and `/manager`. A status board, a booking-in form, a repair + detail screen, and a Giellie tab. +2. **One shared status vocabulary** for every surface — board, detail, + PDF, agent. +3. **Four new PocketBase fields** to carry what the flow needs and the + code cannot express today. +4. **The order ↔ repair match**, written in both directions and visible + to the buyer. +5. **The repair request PDF rebuilt** for the real round trip: customer + block, split fault fields, embedded photos, signature block. +6. **Eight MCP repair tools**, plus a repairs mode for Giellie that is + enforced on the server. + +## Non-goals + +- **No offline capture.** The operator confirmed this person always has + signal. No outbox, no queue, no local-first conflict handling. The + service worker is a shell cache so the app opens instantly and Android + offers "Install app"; it does not make the app work without a network. +- **No machine location field.** The operator confirmed that "where the + machine is" means *the job status*, not a physical location tracker. +- **No repairer / assigned-to field.** One operator for now. +- **No role gate.** The operator chose open to any signed-in user, the + same as `/rep` today. See "Later, not now". +- **No new quote or order pipeline.** The customer quote and the order + keep running through the existing screens. This spec links them to the + repair; it does not redesign them. +- **No driver-app work.** See "Later, not now". + +## Anti-goals: it fails if… + +- The person still has to open the office dashboard, scroll a + `DataTable` sideways, or learn where a menu is. +- The filter counts do not add up to the total, or a status is conveyed by + colour alone. +- A booking-in can be saved without photos, without a serial, or without + the customer — those are the three things the form exists to capture. +- The agent can change a repair's status by any route other than the + guarded one. +- The agent in repairs mode can send a customer email without asking. +- Money (cost, selling price, margin) appears on the board or on the + booking-in form. + +## How the job actually runs + +This is the reference the rest of the spec is written against. + +``` +Customer reports a broken machine + | + v +We collect the machine ------------------------------+ + | | + v | +Repairs person books it in: | + . photos of the machine | + . serial number | + . whose machine it is (customer) | + . what the customer says is wrong | + . what the supplier must look at | + | | + v | +Repair request PDF is generated and PRINTED | + | | + v | +Driver carries form + machine to supplier | + | | + v | +Supplier keeps a copy, SIGNS ONE, hands it back <-----+ + | | + +--> signed_request (uploaded) | + | | + v | +Supplier sends quote --> quote (uploaded) --> status = quoted + | | + v | +Customer quote from the repair --> customer_quote | + | | + v | +Customer sends the order --> orders.repair set --------+ + | + v +Buyer sees the repair status on the order: + approve -> supplier works -> completed + reject -> cancelled, machine collected back +``` + +## How it works + +### 1. PocketBase schema — four new fields + +All four are **new fields on existing collections**. None of them adds a +value to an existing `select`, so none of them trips the AGENTS.md +"select fields are not self-service" gotcha — the operator creates each +field in the PB admin and the code follows. + +| Collection | Field | Type | Why | +|---|---|---|---| +| `repairs` | `supplier_scope` | text | What the supplier must look at. Separate from the customer's reported fault because two different people say different things on the same form. | +| `repairs` | `signed_request` | file, multi, max 10, 8MB | The supplier's signed copy coming back in step 5. | +| `orders` | `repair` | relation to `repairs`, single | The order to repair match. Makes the link queryable in both directions without walking order to quote to repair. | +| `giellie_chats` | `mode` | text | Chooses the Giellie persona and tool allowlist. Empty = general. | + +**Why `fault_description` is kept and not renamed.** The operator's flow +names two things: what the customer says is wrong, and what the supplier +must look at. The obvious move is two new fields, +`customer_fault` plus `supplier_scope`. But `fault_description` already +holds the customer's report for every existing repair, and renaming it +means a data migration, a broken `Repair` type, a broken PDF prop and a +broken office form for no gain the operator can see — the labels on the +form and the PDF carry the meaning, not the column name. So: + +- `fault_description` keeps its name and means **what the customer says + is wrong**. Existing rows keep their meaning. +- `supplier_scope` is new and means **what the supplier must look at**. +- The booking-in form and the PDF label them "Reported fault" and "What + the supplier must look at", so the split is visible to every human who + touches it. + +Where an existing row's `fault_description` was written as an instruction +to the supplier rather than a customer report, the PDF will show it under +"Reported fault". That is a historical inaccuracy on old rows only, and +it is not worth a migration. + +**`orders.is_repair_order` stays and gets filled in.** It already exists +on `orders`, it is already a documented filter on `orders.list` in the +MCP, and the n8n AI-insights prompt already reads it. It is simply never +set to `true`. Whenever `orders.repair` is set, `is_repair_order` is set +to `true` in the same write, so the flag means what it has always claimed +to mean. + +### 2. One shared status vocabulary + +New module `src/lib/repair-status.ts`, the single source for every +repairs surface: + +```ts +export const REPAIR_STATUS = [ + "new", "assessing", "quoted", "approved", "in_progress", "completed", "cancelled", +] as const; + +export type RepairStatus = (typeof REPAIR_STATUS)[number]; + +export const REPAIR_STATUS_LABEL: Record; +export const REPAIR_STATUS_VARIANT: Record; +export const REPAIR_TRANSITIONS: Record; +export type RepairAction = + | "generate_repair_request" + | "upload_supplier_quote" + | "create_customer_quote" + | "start_work" + | "mark_completed"; + +export function nextStepFor(status: RepairStatus): { label: string; action: RepairAction } | null; +``` + +The labels, chosen to match the operator's own words for the job: + +| Status | Label shown everywhere | What it means on the floor | +|---|---|---| +| `new` | Booked in | Machine collected; repair request being prepared | +| `assessing` | With supplier | Request printed, machine with the supplier, being assessed | +| `quoted` | Quote in | Supplier quote received and uploaded | +| `approved` | Approved | Customer approved; supplier authorised to work | +| `in_progress` | On the bench | Supplier is doing the work | +| `completed` | Done | Machine back with the customer | +| `cancelled` | Cancelled | Customer rejected; machine collected back unrepaired | + +The transition map moves out of `repairs-service.ts` into this module and +the service imports it — the guard stays enforced in one place, and the +phone app's "next step" button cannot drift from what the server allows. + +The three label copies in `repairs/page.tsx`, `repairs/[id]/page.tsx` +and `repairs-service.ts` are deleted and replaced by imports. + +### 3. The service layer + +`src/lib/server/repairs-service.ts`: + +- `CreateRepairInput` and `UpdateRepairInput` gain `supplier_scope`. + `updateRepair` writes it when supplied. +- `RepairFileField` gains `"signed_request"`. `uploadRepairFile` already + implements the correct append pattern — re-PATCH with the old filenames + plus the new one — so the signed copy is added, never substituted. This + matters: AGENTS.md records a driver's photo being deleted on 2026-10-19 + by a bare-field PATCH that replaced the whole list. +- `generateRepairRequest` passes the customer and `supplier_scope` to + the PDF renderer. +- New `linkRepairToOrder(orderId, repairId)` writes the link in both + directions in one go: `orders.repair`, `orders.is_repair_order = true`, + and `repairs.order`. `repairs.order` already exists on the collection + and has never been written; the repair detail screen needs it to show + the linked order. +- New `getRepairStatusCounts()` returns `{ total, byStatus }` by running + one count query per status. PocketBase returns `totalItems` for a + filtered list, so each is a single cheap `getList(perPage: 1)`. + +`src/lib/types.ts`: the `Repair` interface gains `supplier_scope?` and +`signed_request?`, and `expand` gains `signed_request?: string[]`. The +`Order` interface gains `repair?: string` and `expand.repair?: Repair`. + +### 4. The phone app + +Route group `(repairs-app)`, mirroring `(rep)`: + +``` +src/app/(repairs-app)/repairs-app/layout.tsx metadata, viewport, safe-area +src/app/(repairs-app)/repairs-app/page.tsx status board +src/app/(repairs-app)/repairs-app/book-in/page.tsx booking-in form +src/app/(repairs-app)/repairs-app/[id]/page.tsx repair detail +src/app/(repairs-app)/repairs-app/chat/page.tsx Giellie in repairs mode +public/manifest-repairs-app.json +public/sw-repairs-app.js +``` + +**Layout.** Same shape as `(rep)/rep/layout.tsx`: its own `metadata` with +`manifest: "/manifest-repairs-app.json"`, the +`"apple-mobile-web-app-capable": "yes"` override (iOS only honours the +apple-prefixed name, and without it the home-screen icon opens as a +Safari bookmark rather than a standalone app), `viewportFit: "cover"`, +safe-area padding top and bottom, and `MobileAuthGate` from +`src/app/(mobile)/m/auth-gate.tsx`. + +**Manifest.** `public/manifest-repairs-app.json`, same shape as +`public/manifest-rep.json`: `display: "standalone"`, +`orientation: "portrait"`, `start_url: "/repairs-app"`, +`scope: "/repairs-app"`, the two Android icons. Display name +**"WFDASH — Repairs"**, short name **"Repairs"**. + +**Service worker.** `public/sw-repairs-app.js` — a shell cache, not an +offline app. Precaches the shell, serves the cached shell when the +network fails, and ignores `/api/` entirely so live data is never served +stale. Registered by adding `/repairs-app` to `pickSwPath()` in +`src/components/service-worker-registration.tsx`. + +**Bottom tab bar.** Two tabs — **Repairs** and **Ask Giellie** — fixed to +the bottom of the viewport, above the safe-area inset. The tab bar is the +only navigation in the app. There is no sidebar and no office chrome. + +#### 4a. The status board — `/repairs-app` + +The "easily see the status of each repair" screen. + +A filter row of chips, each with a count, wrapping on a narrow phone: + +``` +All 12 . Booked in 3 . With supplier 2 . Quote in 4 . +Approved 1 . On the bench 1 . Done 1 . Cancelled 0 +``` + +The counts come from `GET /api/v1/repairs/status-counts` and **must +reconcile**: the seven status counts add up to `All`. Every status shows +all the time, with a zero when it has none, so the row is never a +shifting target. The row wraps onto two or three lines on a narrow +phone — it is the first thing on the screen and it is allowed to take +the space it needs. + +Each repair is a card, minimum 64px tall, tappable: + +``` ++-------------------------------------+ +| R-0007 [ On the bench ] | status as TEXT + colour +| Hydraulic press, 40T | +| Borer Plant . S/N HP-2211 | +| 3 photos . Supplier: Bolt & Nut | ++-------------------------------------+ +``` + +Rules for this surface, all of them deliberate: + +- **Status is text.** Colour is a second signal, never the only one. +- **No money.** No cost, no selling price, no margin. Supplier cost and + customer price live on the detail screen and on the documents. +- **No hiding.** Every repair is listed. Filters narrow the list, they do + not hide repairs behind a count. The "All" number is always the honest + total. +- **Counts are always visible**, so a tab total never has to be inferred. + +Above the list: a search box (the existing `search` param covers repair +number, item, fault, notes and serial). Below the list, pinned above the +tab bar: a full-width **"Book in a machine"** button, within thumb reach. + +Data: `GET /api/v1/repairs?page=&perPage=&search=&status=` — the route +already exists and already supports all four. + +#### 4b. Book in — `/repairs-app/book-in` + +Seven fields, in this order, each with a real label: + +1. **Whose machine?** — customer search (`CustomerSearch` already + exists). Required. +2. **Machine** — what it is. Required. +3. **Serial number** — required. It is the number the supplier quotes + against and the number the customer identifies the machine by. +4. **Reported fault** — what the customer says is wrong, in the + customer's words. Required. +5. **What the supplier must look at** — our instruction to the supplier. +6. **Supplier** — optional search (`SupplierSearch` already exists). Can + be set later; the repair request cannot be generated without one, and + the app says so rather than failing. +7. **Photos** — a **Take photo** button using + `` so the + camera opens directly, plus a strip of thumbnails. Up to 10, matching + the field's `maxSelect`. **At least one photo is required** — the + photos are the record of the machine's condition when it was booked + in, and a repair without them cannot be defended later. + +**Required:** customer, machine, serial number, reported fault, and at +least one photo. **Optional:** what the supplier must look at, and the +supplier. + +A pinned **"Book in"** button. On success it navigates straight to the new +repair's detail screen and shows the repair number. + +The existing page already enforces the MIME and size rules +(JPEG/PNG/WEBP/HEIC, 8MB); the phone form reuses the same constants +rather than inventing new ones. + +#### 4c. Repair detail — `/repairs-app/[id]` + +Header: repair number, and the status chip (text + colour). + +**The next-step card**, at the top, holds one full-width 56px button — +the single action most likely to be next for the current status: + +| Status | The button says | What it does | +|---|---|---| +| Booked in | **Send to supplier** | Generates the repair request PDF and flips the status to With supplier | +| With supplier | **Upload supplier quote** | Opens the file picker for `quote` | +| Quote in | **Make customer quote** | Opens the customer-quote form | +| Approved | **Start work** | Flips to On the bench | +| On the bench | **Mark completed** | Flips to Done | +| Done / Cancelled | *(none)* | Terminal — no button, no dead control | + +The label comes from `nextStepFor(status)` in the shared module, so the +button and the server's transition guard can never disagree. + +Below it: + +- **Photos** — thumbnail strip, tap to enlarge, plus a **Take photo** + button. +- **Documents** — Repair Request (open/download), **Signed copy**, + Supplier quote, Supplier PO, Customer quote. Each row shows the file + name and a download action. Files are served through + `/api/v1/files/{collection}/{recordId}/{filename}` — never + `/api/files/`, which 404s in this app. +- **Customer quote and order** — a card showing the linked customer quote + number and the linked order number, each linking out. +- **Notes** — editable, saved on change. +- **Status timeline** — read-only, the seven steps with the current one + marked. + +### 5. The repair request PDF + +`src/lib/pdf/repair-request-document.tsx` and +`renderRepairRequestPdf` in `src/lib/pdf/render.tsx` change shape. + +**New props:** `customerName`, `companyName`, `customerContact`, +`supplierScope`, `photos` (array of `{ dataUri, caption? }`), +`showSignatureBlock`. + +**What the page gains:** + +- A **customer block** — "Whose machine": customer name, company, contact + name and phone if known. Placed under the supplier block, because the + supplier reads the recipient first and the owner second. +- The item details table's **"Problem Description" row is split** into + **"Reported fault"** (customer's words) and **"What the supplier must + look at"**. +- **Photos**, in a grid under the item details. The operator lists photos + as part of the form, and a signed record of the machine's condition is + the point of taking them at booking-in. +- A **supplier acceptance block** at the bottom: "Received and accepted + by", with a name line, a signature line and a date line. This is the + copy the supplier signs and hands back in step 5. + +**What stays:** the Repair No / Date / Received row, the prominent +supplier block, and the numbered list the supplier is asked to answer +(assessment, parts, labour, timeline, warranty). + +**Photo embedding.** `@react-pdf/renderer` renders an `Image` from a +data URI. The renderer fetches the photo bytes from PocketBase through +the admin client and embeds them as base64 data URIs. + +Caps, so one 10-photo repair cannot produce a 40MB PDF: + +- At most **6 photos** on the PDF, even if more were uploaded. The + booking-in keeps all 10; the PDF shows the first 6. +- Each is downscaled to **1,200px on the long edge** and re-encoded as + JPEG at quality 80 before embedding. +- If a photo cannot be fetched or decoded, it is **skipped**, not fatal. + A missing photo must never stop the driver from getting the form. +- If no photo can be embedded, the photo section is omitted entirely + rather than left as an empty frame. + +**The status row** uses the shared `REPAIR_STATUS_LABEL`, so the PDF and +the phone app say the same word for the same state. + +### 6. The order to repair match + +**Writing it.** When a customer quote created from a repair becomes an +order, the order must know about the repair. The chain is +`repairs.customer_quote` to quote to order, and the quote that comes out +of a repair carries `customer_quote` set on the repair row. When +`orders.create_from_quote` runs against such a quote, the order service +resolves the repair by reverse lookup on `repairs.customer_quote` and +writes `orders.repair` plus `orders.is_repair_order = true`, and stamps +`repairs.order` so the repair can find its order. + +Reverse lookup on `customer_quote` is a single PB filter +(`customer_quote = ""`). `createCustomerQuoteFromRepair` +already guarantees at most one repair per quote — it refuses to overwrite +an existing link unless `replace: true` is passed — so the lookup is +unambiguous. + +**Reading it.** The order page (`src/app/(app)/orders/[id]/page.tsx`) +gains a **Repair card**, shown when `orders.repair` is set: + +- The repair number, linking to `/repairs/[id]`. +- The repair's status, using the shared label — so the buyer sees "With + supplier", not a raw status string. +- Whose machine it is (customer) and the serial number. +- A one-line summary of the reported fault. +- The repair request and the customer quote as documents. + +This is the operator's step 10: *the buyer can see where the machine is*. +The repair status **is** "where the machine is" — the machine is booked +in, with the supplier, quoted, approved, on the bench, or done. + +**The approve / reject decision.** The buyer's two paths are the existing +order flows: approve the order and the supplier is authorised to work; +reject the order and the machine is collected back unrepaired. The spec +does not build a new approval surface. What it does is make the repair +visible on the order so the decision is informed, and make +`repairs.cancelled` the honest terminal state for a rejected job. + +### 7. Giellie in repairs mode + +#### 7a. Eight MCP tools + +Added to `src/app/api/mcp/route.ts`, each wrapping the service layer. +The route is 7,462 lines with 148 `name:` entries today; these land in +the same style as every other tool there. + +| Tool | Class | Arguments | Wraps | +|---|---|---|---| +| `repairs.list` | read | `page`, `perPage`, `search`, `status` | `listRepairs` | +| `repairs.get` | read | `id` | `getRepair` | +| `repairs.create` | write | `customer`, `company`, `order`, `supplier`, `item_description`, `fault_description`, `supplier_scope`, `serial_no`, `notes` | `createRepair` | +| `repairs.update` | write | `id`, `patch` (no `status`) | `updateRepair` | +| `repairs.advance_status` | write | `id`, `to` | `updateRepair` plus transition guard, returns `nextStepFor(to)` | +| `repairs.upload_file` | write | `id`, `field`, `filename`, `content_type`, `base64` | `uploadRepairFile` | +| `repairs.generate_repair_request` | write | `id` | `generateRepairRequest` | +| `repairs.create_customer_quote` | write | `id`, `items[]`, `quote_notes?`, `validity_days?`, `description?`, `replace?` | `createCustomerQuoteFromRepair` | + +**Status only moves through `repairs.advance_status`.** `repairs.update` +rejects `status` in its patch. This keeps one guarded door rather than +two, and it means the agent cannot bypass `assertTransition` by writing +the field directly. + +**There is no `repairs.delete`.** The tool policy already denies anything +ending in `.delete`, and Giellie's persona says it can never delete +anything. A repair is cancelled, not removed. + +`repairs.upload_file` accepts `photos`, `quote`, `purchase_order`, +`repair_request` and `signed_request`. It appends; it never replaces. + +#### 7b. Policy entries + +In `src/lib/server/giellie-tool-policy.ts`: + +- `repairs.list` and `repairs.get` are added to `READ_TOOLS`. +- The six write tools fall through to the default `write` class, which + pauses for approval and is auto-approved only when the chat is trusted. +- **No repair tool is `confirm` class.** None of them sends an email. + `quotes.send_email`, `purchase_orders.send_email` and friends stay + `confirm` and always ask, in repairs mode as everywhere else. + +#### 7c. The mode + +`giellie_chats.mode` is a text field on the chat row. Empty means the +general office agent. + +**The mode is read from the stored row, never from the request.** This is +the whole point: if the client could send `mode=repairs` — or omit it — +the scope would be a suggestion rather than a boundary. So: + +1. `createChat(userId, mode)` writes the mode onto the new row. + `POST /api/v1/giellie/chats` accepts an optional `mode` of + `"repairs"`; anything else is rejected, not silently coerced. +2. `giellie-service.ts` `prepare()` loads the chat row and derives the + persona and the tool allowlist from its `mode`. +3. `listChats` filters by mode, so the repairs tab shows only repairs + chats and the office tab shows only general chats. The two lists never + mix. + +**The allowlist in repairs mode** is the eight repair tools plus the +lookup and creation calls the flow actually needs: + +- Read: `customers.list`, `customers.get`, `suppliers.list`, + `suppliers.get`, `quotes.list`, `quotes.get`, `quotes.lookup`, + `orders.get`, `orders.list` +- Write: the six repair write tools, plus `orders.create_from_quote` + +Everything else — stock, the POS, invoices, sprint, buyer tasks, the +other quote tools — is not offered to the repairs agent at all. The +allowlist is applied when the tool list is built for the model, so a +tool that is not in the list cannot be called, not merely discouraged. + +#### 7d. The repairs persona + +`buildSystemPrompt` in `src/lib/server/giellie-system-prompt.ts` gains +a `mode` argument and returns the general prompt or the repairs prompt. +The repairs prompt is short, because the person reading it is not +technical: + +> You are the repairs assistant for Westcott Fasteners. You help the +> person who handles repairs: book machines in, photograph them, get them +> to the right supplier, track the job, quote the customer and hand the +> work to the buyer. +> +> - Speak in plain, short South African English. One question at a time. +> Never send a form at someone. +> - Before any write, say which repair you are changing and name its +> repair number, then do it. +> - To book a machine in you need: whose machine it is, what it is, what +> is wrong, and photos. Ask for what is missing, one thing at a time. +> - "What the supplier must look at" is our instruction to the supplier. +> It is not the same as what the customer said is wrong. If the person +> gives you one, ask whether the other is different. +> - When a supplier quote arrives, upload it against the repair. That +> moves the repair to "Quote in". +> - A supplier may hand back a signed copy of the repair request. Upload +> it too. +> - To quote the customer, ask for the line items and their prices +> before you create anything. +> - Never send an email to a customer or a supplier without asking. + +The last line is the general prompt's rule restated for the reader who +does not know it. The general prompt already says emails, cancellations +and contact-detail changes always ask. + +#### 7e. The chat tab + +`/repairs-app/chat` renders the existing chat components — `ChatList`, +`MessageList`, `ApprovalCard`, `Composer`, and the `useGielie` hook +— in a phone-sized shell. It is the same code the office uses, with the +repairs persona behind it. + +The "Trust this chat" switch is kept: with it on, ordinary changes are +auto-approved and the person is not tapping approve on every photo +upload. Emails, cancellations and contact-detail changes still always +ask, whatever the switch says. The switch label stays as it is today. + +### 8. Files + +**New** + +- `src/app/(repairs-app)/repairs-app/layout.tsx` +- `src/app/(repairs-app)/repairs-app/page.tsx` +- `src/app/(repairs-app)/repairs-app/book-in/page.tsx` +- `src/app/(repairs-app)/repairs-app/[id]/page.tsx` +- `src/app/(repairs-app)/repairs-app/chat/page.tsx` +- `src/lib/repair-status.ts` — the shared status vocabulary +- `src/app/api/v1/repairs/status-counts/route.ts` +- `public/manifest-repairs-app.json` +- `public/sw-repairs-app.js` + +**Changed** + +- `src/lib/server/repairs-service.ts` — `supplier_scope`, `signed_request`, `linkRepairToOrder`, `getRepairStatusCounts`, shared transition map, customer + scope + photos into the PDF +- `src/lib/types.ts` — `Repair`, `Order` +- `src/lib/pdf/repair-request-document.tsx` — customer block, split fault rows, photos, signature block +- `src/lib/pdf/render.tsx` — `renderRepairRequestPdf` props +- `src/app/api/mcp/route.ts` — eight repair tools +- `src/lib/server/giellie-tool-policy.ts` — two read tools +- `src/lib/server/giellie-system-prompt.ts` — `mode` argument, repairs prompt +- `src/lib/server/giellie-service.ts` — `prepare()` derives persona + allowlist from the stored mode +- `src/lib/server/giellie-chat-store.ts` — `mode` on the row, `createChat(userId, mode)`, `listChats` filters by mode +- `src/app/api/v1/giellie/chats/route.ts` — accepts `mode` on create +- `src/components/service-worker-registration.tsx` — `/repairs-app` to `/sw-repairs-app.js` +- `src/app/(app)/repairs/page.tsx`, `src/app/(app)/repairs/[id]/page.tsx` — shared labels +- `src/app/(app)/orders/[id]/page.tsx` — the repair card + +## Build order + +The phases are sequenced so each one leaves the app in a working state and +each one can be verified before the next starts. + +1. **Service layer and schema.** The shared status module, the four PB + fields, `supplier_scope`, `signed_request`, `linkRepairToOrder`, + `getRepairStatusCounts`, and the eight MCP tools with their policy + entries. After this, Giellie can drive repairs by chat from the office + desktop, even though the phone app does not exist yet. +2. **The phone app.** The route group, manifest, service worker, status + board, booking-in form and repair detail. This is what the new person + installs, and it is the reason for the whole build. +3. **The supplier round trip and the hand-off.** The repair request PDF + with the customer block, split fault rows, photos and signature block; + the signed copy upload; the order link and the repair card on the order + page. +4. **Giellie in repairs mode.** The `mode` field, the repairs persona, + the server-side allowlist, and the chat tab inside the phone app. + +Phases 1 and 2 do not depend on each other and could be built in the +other order, but designing the chat against real tool names produces a +better chat, so 1 goes first. + +## Data + +No migration. `fault_description` is untouched and keeps its meaning. The +four new fields start empty. Every existing `giellie_chats` row has no +`mode`, which means the general agent, which is what all of them are +today. + +## Manual steps on the live system + +Four PB admin steps. Do them before the code that writes the field ships +— a missing field makes the write fail rather than degrade. + +1. `repairs` add field **`supplier_scope`**, type **text**. +2. `repairs` add field **`signed_request`**, type **file**, max select + **10**, max size **8MB**, allowed mime types: **application/pdf** plus + the same image set as `photos` (JPEG, PNG, WEBP, HEIC). The signed + copy is usually a photograph of the paper form, but it may be a PDF + the supplier emails back. +3. `orders` add field **`repair`**, type **relation**, collection + **repairs**, max select **1**. +4. `giellie_chats` add field **`mode`**, type **text**. + +None of these adds a value to an existing `select`, so the AGENTS.md +select-field gotcha does not apply. If a relation field needs the repairs +collection id, the PB admin UI picks it from the dropdown. + +After that: the new person needs a `users` record and the app installed +on his phone ("Add to Home Screen" on iOS, "Install app" on Android). + +## Decisions + +Confirmed with the operator on 2026-11-05: + +1. **A separate `/repairs-app` PWA**, not a repairs mode inside the + existing app. The office's `/repairs` page keeps its path; the phone + app gets its own. It is not in the sidebar, like `/rep`, `/m`, + `/manager` and `/counter`. +2. **Full lifecycle.** The repairs person can take a job all the way to + a customer quote and an order. The hand-off to quotes and orders is + part of the job, not a separate team's. +3. **Always has signal.** No offline queue. The service worker is a shell + cache only. +4. **One operator.** The app is built for the single person who handles + repairs. No assignment, no multi-tenant filtering. +5. **Open to any signed-in user.** Same as `/rep` today. No role gate. +6. **Job status is "where the machine is".** No physical location field. +7. **Print the repair request PDF** and the driver carries it. +8. **No deletes.** Cancelling is how a repair is undone. + +## Left to the builder + +- The exact colour tokens for the status chips, reusing the theme's + existing badge variants rather than introducing new ones. +- Whether the booking-in form's photos are uploaded on submit as a batch + or one at a time as they are taken. One at a time is more robust on a + slow connection but means a partial save if the phone drops mid-form. +- The wording of the empty states ("No repairs match this filter"). +- Whether the repair detail screen's photo strip is a horizontal scroll + or a 3-column grid, depending on how it reads on a small screen. + +## Later, not now + +These are real and wanted, and deliberately not in this spec. + +- **The driver leg.** The operator's plan: the repair request goes into + the **collection pool** and gets assigned to a driver, so the form + reaches the driver on his phone instead of on paper. The driver app is + a separate frontend outside this repository, so this needs work there + and a new endpoint on `/api/v1/driver/*`. The PDF is printed in the + meantime. +- **A repairer field.** One operator now. If a second person takes over + repairs, `repairs.assigned_to` and a "my repairs" filter follow. +- **A role gate.** `/repairs-app` is open to any signed-in user today. + `user_type` already has a `REP` value that gates rep visibility, so + adding a `REPAIRS` value and checking it is a small change when it is + wanted. +- **Machine location.** The operator was asked whether the buyer needs + physical location tracking and said job status is enough. Revisit if + machines are ever at more than one place at a time.