docs(spec): repairs app for the phone, and Giellie in repairs mode

This commit is contained in:
Dave 2026-11-05 22:27:53 +02:00
parent 9254eecc9a
commit e56cf01fdf

View file

@ -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<RepairStatus, string>;
export const REPAIR_STATUS_VARIANT: Record<RepairStatus, "outline" | "secondary" | "default" | "destructive">;
export const REPAIR_TRANSITIONS: Record<RepairStatus, RepairStatus[]>;
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
`<input type="file" accept="image/*" capture="environment">` 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 = "<quote id>"`). `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.