From fb5f0211e91dfb1d15c2f2c980e16bebf25d256b Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 22:46:48 +0200 Subject: [PATCH] docs(plan): phase 1 of the repairs app --- .../plans/2026-11-05-repairs-app-phase-1.md | 1780 +++++++++++++++++ .../specs/2026-11-05-repairs-app-design.md | 5 +- 2 files changed, 1783 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-05-repairs-app-phase-1.md diff --git a/docs/superpowers/plans/2026-11-05-repairs-app-phase-1.md b/docs/superpowers/plans/2026-11-05-repairs-app-phase-1.md new file mode 100644 index 0000000..a1c88b4 --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-repairs-app-phase-1.md @@ -0,0 +1,1780 @@ +# Repairs app — Phase 1 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:** Give Giellie full knowledge of repairs — one shared status vocabulary, a service layer that can hold what the repair flow actually needs, and eight MCP tools — so the phone app in Phase 2 sits on a stable backend. + +**Architecture:** A new pure module `src/lib/repair-status.ts` owns the status vocabulary (labels, badge variants, allowed transitions, the "next step" for each status, and the transition guard). `repairs-service.ts` imports the guard from it and gains `supplier_scope`, `signed_request`, `linkRepairToOrder` and `getRepairStatusCounts`. Eight MCP tools wrap that service so the agent can drive repairs by chat. Nothing user-facing ships in this phase — no page, no PWA. + +**Tech Stack:** Next.js (App Router, `runtime = "nodejs"` route handlers), TypeScript, PocketBase through the admin client (`getAdminPocketBase`), Vitest 4. + +**Spec:** `docs/superpowers/specs/2026-11-05-repairs-app-design.md` + +## Global Constraints + +- Work on a new branch `feature/repairs-phase-1` branched from `main`. Do not build on `feature/giellie-stock-quantities` — that branch carries unrelated in-flight work. +- Every PocketBase write goes through `getAdminPocketBase()` in `src/lib/server/pocketbase-admin.ts`. Never the browser client. +- File fields are **append-only**. A PocketBase PATCH under a bare multi-file field name replaces the whole list. Always re-PATCH the old filenames plus the new one — `uploadRepairFile` already does this and must keep doing it. +- File URLs served to the client go through `/api/v1/files/{collection}/{recordId}/{filename}`. Never `/api/files/`. +- New business logic goes in `src/lib/server/*-service.ts` behind a thin `/api/v1/*` route. Do not extend `src/lib/pb-helpers.ts`. +- `repairs.status` is a PocketBase `select` with fixed values: `new, assessing, quoted, approved, in_progress, completed, cancelled`. Never write a value outside that set. This phase adds **no** new select value. +- PocketBase select fields are not self-service. This phase adds no new select value, so the AGENTS.md select-field gotcha does not apply. +- The audit trail is written by a PB hook (`pocketbase/pb_hooks/audit_log.pb.js`), not by app code. Do not hand-write audit notes. +- Tests: `npx vitest run `. Typecheck: `npx tsc --noEmit` — the baseline is clean, so any error is yours. +- Commit per task with the exact message given. Stage by explicit path — never `git add -A`. + +## Prerequisite — manual PocketBase steps (operator) + +Four new fields on existing collections. Do them **before Task 2**; 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 **8 MB**, mime types **application/pdf, image/jpeg, image/png, image/webp, image/heic**. +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`. + +Verify from the repo root: + +```bash +set -a; . ./.env.local; set +a +curl -s -u "$PB_ADMIN_EMAIL:$PB_ADMIN_PASSWORD" \ + "$NEXT_PUBLIC_POCKETBASE_URL/api/collections/repairs" \ + | python3 -c "import json,sys; print(sorted(f['name'] for f in json.load(sys.stdin)['fields']))" +``` + +Expected: the list contains `signed_request` and `supplier_scope`. Repeat for `orders` (expect `repair`) and `giellie_chats` (expect `mode`). + +## Review Focus + +- A repair's status may only ever change through the guard in `src/lib/repair-status.ts`. The MCP `repairs.update` tool must **reject** a `status` key rather than silently dropping it. (Task 2, Task 4) +- The next step the phone app offers must always be a legal transition. `nextStepFor` returns the target status so the invariant is testable. (Task 1) +- `uploadRepairFile` currently returns the record from **before** the auto status bump — uploading a supplier quote returns a repair that still reads `new`. The Phase 1 MCP tool would report that stale status to the agent. Fix it. (Task 2) +- `linkRepairToOrder` writes the link in **both** directions: `orders.repair` + `orders.is_repair_order`, and `repairs.order`. (Task 2) +- Nothing in this phase may send an email. No repair MCP tool is `confirm` class; `quotes.send_email` and `purchase_orders.send_email` stay `confirm` and always ask. (Task 4) +- No money on any repair surface. The `repairs.list` tool returns repair rows, which carry no prices. (Task 4) +- **MCP errors come back as HTTP 500 with a JSON-RPC error envelope**, not a 400 with plain text. The `POST /api/mcp` catch block turns any untagged `Error` into `{ jsonrpc, id: null, error: { code: -32000, message } }` with status 500 — that is how every one of the existing 147 tools reports a bad argument. Assert on `body.error.message`, not on `res.text()`. (Task 4) + +--- + +### Task 1: One shared status vocabulary + +**Files:** +- Create: `src/lib/repair-status.ts` +- Test: `src/lib/repair-status.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces (imported by every later task): + - `REPAIR_STATUS: readonly RepairStatus[]` — seven statuses in lifecycle order + - `type RepairStatus = "new" | "assessing" | "quoted" | "approved" | "in_progress" | "completed" | "cancelled"` + - `REPAIR_STATUS_LABEL: Record` + - `REPAIR_STATUS_VARIANT: Record` + - `REPAIR_TRANSITIONS: Record` + - `type RepairAction = "generate_repair_request" | "upload_supplier_quote" | "create_customer_quote" | "start_work" | "mark_completed"` + - `interface RepairNextStep { label: string; action: RepairAction; to: RepairStatus }` + - `nextStepFor(status: RepairStatus): RepairNextStep | null` + - `assertRepairTransition(from: RepairStatus, to: RepairStatus): void` — throws on an illegal move + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/repair-status.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + REPAIR_STATUS, + REPAIR_STATUS_LABEL, + REPAIR_STATUS_VARIANT, + REPAIR_TRANSITIONS, + nextStepFor, + assertRepairTransition, +} from "@/lib/repair-status"; + +describe("repair status vocabulary", () => { + it("lists the seven statuses in lifecycle order", () => { + expect([...REPAIR_STATUS]).toEqual([ + "new", + "assessing", + "quoted", + "approved", + "in_progress", + "completed", + "cancelled", + ]); + }); + + it("gives every status a label and a badge variant", () => { + for (const status of REPAIR_STATUS) { + expect(REPAIR_STATUS_LABEL[status]).toBeTruthy(); + expect(REPAIR_STATUS_VARIANT[status]).toBeTruthy(); + } + }); + + it("allows exactly the transitions the service allowed before", () => { + expect(REPAIR_TRANSITIONS.new).toEqual(["assessing", "quoted", "cancelled"]); + expect(REPAIR_TRANSITIONS.assessing).toEqual(["quoted", "cancelled"]); + expect(REPAIR_TRANSITIONS.quoted).toEqual([ + "approved", + "in_progress", + "cancelled", + ]); + expect(REPAIR_TRANSITIONS.approved).toEqual(["in_progress", "cancelled"]); + expect(REPAIR_TRANSITIONS.in_progress).toEqual(["completed", "cancelled"]); + expect(REPAIR_TRANSITIONS.completed).toEqual([]); + expect(REPAIR_TRANSITIONS.cancelled).toEqual([]); + }); + + it("names the next step for every live status", () => { + expect(nextStepFor("new")).toEqual({ + label: "Send to supplier", + action: "generate_repair_request", + to: "assessing", + }); + expect(nextStepFor("assessing")?.action).toBe("upload_supplier_quote"); + expect(nextStepFor("quoted")?.action).toBe("create_customer_quote"); + expect(nextStepFor("approved")?.action).toBe("start_work"); + expect(nextStepFor("in_progress")?.action).toBe("mark_completed"); + }); + + it("offers no next step once the job is finished", () => { + expect(nextStepFor("completed")).toBeNull(); + expect(nextStepFor("cancelled")).toBeNull(); + }); + + it("never offers a next step that is not a legal transition", () => { + for (const status of REPAIR_STATUS) { + const step = nextStepFor(status); + if (!step) continue; + expect(REPAIR_TRANSITIONS[status]).toContain(step.to); + } + }); + + it("rejects an illegal transition with a message naming the legal ones", () => { + expect(() => assertRepairTransition("completed", "new")).toThrow( + /Cannot transition repair from "completed" to "new"/, + ); + expect(() => assertRepairTransition("new", "completed")).toThrow( + /allowed: assessing, quoted, cancelled/, + ); + expect(() => assertRepairTransition("new", "assessing")).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/repair-status.test.ts` + +Expected: FAIL — `Failed to resolve import "@/lib/repair-status"`. + +- [ ] **Step 3: Write the module** + +Create `src/lib/repair-status.ts`: + +```ts +/** + * The repair status vocabulary — ONE source for every repairs surface + * (office list, office detail, phone app, the repair request PDF, and + * the agent's tools). + * + * Before this module the vocabulary was copied in three places with + * three different sets of words: `repairs/page.tsx`, + * `repairs/[id]/page.tsx` and the PDF map in `repairs-service.ts`. A + * phone app with a status filter row cannot have three vocabularies. + * + * Pure module — no server imports, safe for client components. + */ + +/** The seven repair statuses, in lifecycle order. */ +export const REPAIR_STATUS = [ + "new", + "assessing", + "quoted", + "approved", + "in_progress", + "completed", + "cancelled", +] as const; + +export type RepairStatus = (typeof REPAIR_STATUS)[number]; + +/** + * What each status is called, everywhere. The words are the operator's: + * what happens on the floor, not what the workflow calls it. + */ +export const REPAIR_STATUS_LABEL: Record = { + new: "Booked in", + assessing: "With supplier", + quoted: "Quote in", + approved: "Approved", + in_progress: "On the bench", + completed: "Done", + cancelled: "Cancelled", +}; + +/** Badge variant per status. Colour is a SECOND signal — the label always shows too. */ +export const REPAIR_STATUS_VARIANT: Record< + RepairStatus, + "outline" | "secondary" | "default" | "destructive" +> = { + new: "outline", + assessing: "secondary", + quoted: "secondary", + approved: "default", + in_progress: "secondary", + completed: "default", + cancelled: "destructive", +}; + +/** + * Allowed manual transitions. The guard below is the ONLY thing that + * decides whether a status may change — the service, the REST routes and + * the MCP tools all call it. + */ +export const REPAIR_TRANSITIONS: Record = { + new: ["assessing", "quoted", "cancelled"], + assessing: ["quoted", "cancelled"], + quoted: ["approved", "in_progress", "cancelled"], + approved: ["in_progress", "cancelled"], + in_progress: ["completed", "cancelled"], + completed: [], + cancelled: [], +}; + +/** The single action that moves a repair forward from a given status. */ +export type RepairAction = + | "generate_repair_request" + | "upload_supplier_quote" + | "create_customer_quote" + | "start_work" + | "mark_completed"; + +export interface RepairNextStep { + /** Button / sentence label, e.g. "Send to supplier". */ + label: string; + /** Stable action id for the UI and the agent. */ + action: RepairAction; + /** + * The status this step leads to. For `create_customer_quote` the + * button opens a form — the status flips to `approved` when the + * customer's order lands, not when the form opens. + */ + to: RepairStatus; +} + +const NEXT_STEP: Record = { + new: { + label: "Send to supplier", + action: "generate_repair_request", + to: "assessing", + }, + assessing: { + label: "Upload supplier quote", + action: "upload_supplier_quote", + to: "quoted", + }, + quoted: { + label: "Make customer quote", + action: "create_customer_quote", + to: "approved", + }, + approved: { + label: "Start work", + action: "start_work", + to: "in_progress", + }, + in_progress: { + label: "Mark completed", + action: "mark_completed", + to: "completed", + }, + completed: null, + cancelled: null, +}; + +/** + * The next step for a repair, or null when the job is finished. + * `to` is always a legal transition out of `status`. + */ +export function nextStepFor(status: RepairStatus): RepairNextStep | null { + return NEXT_STEP[status] ?? null; +} + +/** + * Throws unless `to` is a legal transition out of `from`. The message + * names the legal targets so the caller can show it to the operator. + */ +export function assertRepairTransition( + from: RepairStatus, + to: RepairStatus, +): void { + const allowed = REPAIR_TRANSITIONS[from] ?? []; + if (allowed.includes(to)) return; + const list = allowed.length > 0 ? allowed.join(", ") : "none"; + throw new Error( + `Cannot transition repair from "${from}" to "${to}" (allowed: ${list}).`, + ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/repair-status.test.ts` + +Expected: PASS — 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/repair-status.ts src/lib/repair-status.test.ts +git commit -m "feat(repairs): one shared status vocabulary" +``` + +--- + +### Task 2: The service layer holds what the flow needs + +**Files:** +- Modify: `src/lib/types.ts` — `Repair` gains `supplier_scope?` and `signed_request?`; `Order` gains `repair?` and `expand.repair?` +- Modify: `src/lib/server/repairs-service.ts` — import the guard, add the two fields, add two functions +- Test: `src/lib/server/repairs-service.test.ts` + +**Interfaces:** +- Consumes: `REPAIR_STATUS`, `REPAIR_TRANSITIONS`, `assertRepairTransition`, `RepairStatus` from `@/lib/repair-status` (Task 1). +- Produces: + - `CreateRepairInput.supplier_scope?: string | null` + - `UpdateRepairInput.supplier_scope?: string | null` + - `type RepairFileField = "photos" | "quote" | "purchase_order" | "repair_request" | "signed_request"` + - `REPAIR_FILE_FIELDS: readonly RepairFileField[]` — the allowed list, for the MCP tool's validation + - `interface RepairStatusCounts { total: number; byStatus: Record }` + - `getRepairStatusCounts(): Promise` + - `linkRepairToOrder(orderId: string, repairId: string): Promise<{ order: Order; repair: Repair }>` + +- [ ] **Step 1: Extend the test file's PocketBase mock** + +In `src/lib/server/repairs-service.test.ts`, replace the `CollectionMock` interface and the `makePb` helper with these. The mock gains an `orders` collection and a `getList` so the new functions can be exercised; the existing eight tests keep working unchanged. + +```ts +interface CollectionMock { + getOne?: ReturnType; + update?: ReturnType; + getList?: ReturnType; + create?: ReturnType; +} + +const ORDERS = "orders"; + +function makePb( + repair: typeof baseRepair & Record, + order?: Record, + listImpl?: (filter: string) => number, +) { + const records: Record> = { + [REPAIRS]: repair, + }; + if (order) records[ORDERS] = order; + + const notFound = () => { + const err = new Error("not found") as Error & { status?: number }; + err.status = 404; + return err; + }; + + const find = (id: string) => { + for (const rec of Object.values(records)) { + if (rec.id === id) return rec; + } + return null; + }; + + const getOne = vi.fn().mockImplementation(async (id: string) => { + const rec = find(id); + if (!rec) throw notFound(); + return rec; + }); + + const update = vi + .fn() + .mockImplementation(async (id: string, patch: Record) => { + const rec = find(id); + if (!rec) throw notFound(); + return { ...rec, id, ...patch }; + }); + + const getList = vi.fn().mockImplementation( + async ( + _page: number, + _perPage: number, + opts?: { filter?: string }, + ) => { + const totalItems = listImpl ? listImpl(String(opts?.filter ?? "")) : 0; + return { + items: [], + totalItems, + totalPages: totalItems > 0 ? 1 : 0, + page: 1, + perPage: 1, + }; + }, + ); + + return { + autoCancellation: vi.fn(), + collection: (name: string) => { + if (!records[name]) { + throw new Error(`unexpected collection: ${name}`); + } + return { getOne, update, getList } satisfies CollectionMock; + }, + _getOne: getOne, + _update: update, + _getList: getList, + }; +} +``` + +- [ ] **Step 2: Write the failing tests** + +Append to `src/lib/server/repairs-service.test.ts`. Add these imports: + +```ts +import { + createRepair, + updateRepair, + uploadRepairFile, + getRepairStatusCounts, + linkRepairToOrder, +} from "@/lib/server/repairs-service"; +``` + +Then append these tests: + +```ts +describe("supplier_scope and signed_request", () => { + beforeEach(() => vi.clearAllMocks()); + + it("writes supplier_scope on create", async () => { + const create = vi.fn().mockResolvedValue({ ...baseRepair, id: "rep-new" }); + const pb = makePb(baseRepair); + const originalCollection = pb.collection; + (pb as unknown as { collection: unknown }).collection = (name: string) => { + const c = originalCollection(name) as Record; + return name === REPAIRS ? { ...c, create } : c; + }; + mockAdminPb(pb); + + await createRepair({ + item_description: "Welding machine", + fault_description: "Will not strike", + supplier_scope: "Strip and quote the strike mechanism", + }); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + supplier_scope: "Strip and quote the strike mechanism", + }), + ); + }); + + it("writes supplier_scope on update", async () => { + const pb = makePb(baseRepair); + mockAdminPb(pb); + + await updateRepair("rep1", { supplier_scope: "Check the transformer" }); + + expect(pb._update).toHaveBeenCalledWith( + "rep1", + expect.objectContaining({ supplier_scope: "Check the transformer" }), + ); + }); + + it("appends a signed_request file without dropping the existing ones", async () => { + const pb = makePb({ + ...baseRepair, + signed_request: ["signed-copy-1.pdf"], + }); + mockAdminPb(pb); + + const blob = new Blob([new Uint8Array([1, 2, 3])], { + type: "application/pdf", + }); + await uploadRepairFile("rep1", "signed_request", blob, "signed-copy-2.pdf"); + + const form = pb._update.mock.calls[0][1] as FormData; + expect(form.getAll("signed_request")).toHaveLength(2); + expect(form.getAll("signed_request")[0]).toBe("signed-copy-1.pdf"); + }); + + it("returns the bumped status after a supplier quote is uploaded", async () => { + const pb = makePb({ ...baseRepair, status: "assessing" }); + mockAdminPb(pb); + + const blob = new Blob([new Uint8Array([1, 2, 3])], { + type: "application/pdf", + }); + const updated = await uploadRepairFile( + "rep1", + "quote", + blob, + "supplier-quote.pdf", + ); + + expect(updated.status).toBe("quoted"); + }); +}); + +describe("getRepairStatusCounts", () => { + beforeEach(() => vi.clearAllMocks()); + + it("counts every status and totals them", async () => { + const counts: Record = { + 'status = "new"': 3, + 'status = "assessing"': 1, + 'status = "quoted"': 2, + 'status = "approved"': 0, + 'status = "in_progress"': 1, + 'status = "completed"': 4, + 'status = "cancelled"': 1, + }; + const pb = makePb(baseRepair, undefined, (filter) => counts[filter] ?? 0); + mockAdminPb(pb); + + const result = await getRepairStatusCounts(); + + expect(result.total).toBe(12); + expect(result.byStatus).toEqual({ + new: 3, + assessing: 1, + quoted: 2, + approved: 0, + in_progress: 1, + completed: 4, + cancelled: 1, + }); + }); +}); + +describe("linkRepairToOrder", () => { + beforeEach(() => vi.clearAllMocks()); + + it("writes the link in both directions", async () => { + const pb = makePb(baseRepair, { + id: "ord1", + order_number: "O-2026-11-001", + is_repair_order: false, + }); + mockAdminPb(pb); + + const result = await linkRepairToOrder("ord1", "rep1"); + + expect(pb._update).toHaveBeenCalledWith("ord1", { + repair: "rep1", + is_repair_order: true, + }); + expect(pb._update).toHaveBeenCalledWith("rep1", { order: "ord1" }); + expect(result.order.repair).toBe("rep1"); + expect(result.order.is_repair_order).toBe(true); + expect(result.repair.order).toBe("ord1"); + }); + + it("throws when the order does not exist", async () => { + const pb = makePb(baseRepair); + mockAdminPb(pb); + + await expect(linkRepairToOrder("nope", "rep1")).rejects.toThrow( + /not found/, + ); + }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npx vitest run src/lib/server/repairs-service.test.ts` + +Expected: FAIL — the import of `getRepairStatusCounts` / `linkRepairToOrder` fails, and `supplier_scope` is not written. + +- [ ] **Step 4: Wire the types** + +In `src/lib/types.ts`, in the `Repair` interface add after `serial_no?: string;`: + +```ts + /** + * What the supplier must look at. Separate from `fault_description` + * (what the customer says is wrong) because two different people say + * different things on the same repair request form. + */ + supplier_scope?: string; + /** + * The supplier's signed copy of the repair request, handed back after + * they receive the machine. Multi-file field. + */ + signed_request?: string[]; +``` + +`signed_request` is a **file** field, not a relation, so it does NOT go in +the `expand` block — PocketBase only expands relations. The top-level +`signed_request?: string[]` above is the whole of it. + +In the `Order` interface (starts at line 973), add next to `is_repair_order: boolean;`: + +```ts + /** + * The repair this order came from. Set when the order was created from + * a customer quote that a repair produced, so the buyer can see the + * machine's repair status without asking anyone. + */ + repair?: string; +``` + +and in the `Order` interface's `expand` block add: + +```ts + repair?: Repair; +``` + +- [ ] **Step 5: Rewire the service to the shared vocabulary** + +In `src/lib/server/repairs-service.ts`: + +1. Delete the local `export type RepairStatus = ...` block, the `ALLOWED_TRANSITIONS` constant, and the `rejectTransition` / `assertTransition` functions. +2. Add to the imports: + +```ts +import { + REPAIR_STATUS, + REPAIR_TRANSITIONS, + assertRepairTransition, + type RepairStatus, +} from "@/lib/repair-status"; +``` + +3. Replace every call to `assertTransition(...)` with `assertRepairTransition(...)`. There are two: one in `updateRepair` and one in `generateRepairRequest`. +4. Re-export `RepairStatus` from the service so existing importers keep working: + +```ts +export type { RepairStatus } from "@/lib/repair-status"; +``` + +5. `CreateRepairInput` gains `supplier_scope?: string | null;` and `UpdateRepairInput` gains `supplier_scope?: string | null;`. +6. In `createRepair`, add to the `create` object: + +```ts + supplier_scope: input.supplier_scope ?? "", +``` + +7. In `updateRepair`, add: + +```ts + if (input.supplier_scope !== undefined) + patch.supplier_scope = input.supplier_scope ?? ""; +``` + +- [ ] **Step 6: Add the signed copy file field, the counts and the order link** + +In `src/lib/server/repairs-service.ts` replace the `RepairFileField` type and add the field list: + +```ts +export type RepairFileField = + | "photos" + | "quote" + | "purchase_order" + | "repair_request" + | "signed_request"; + +/** The fields a file may be uploaded to. Used to validate the MCP tool's `field` argument. */ +export const REPAIR_FILE_FIELDS: readonly RepairFileField[] = [ + "photos", + "quote", + "purchase_order", + "repair_request", + "signed_request", +]; +``` + +Fix the stale return in `uploadRepairFile` — it currently returns the record from **before** the status bump: + +```ts +export async function uploadRepairFile( + repairId: string, + field: RepairFileField, + file: File | Blob, + filename: string, +): Promise { + const pb = await getAdminPocketBase(); + const existing = await pb.collection(REPAIRS).getOne(repairId); + const previousFiles = Array.isArray(existing[field]) + ? (existing[field] as string[]) + : []; + + const form = new FormData(); + for (const f of previousFiles) form.append(field, f); + form.append(field, file, filename); + + let updated = await pb.collection(REPAIRS).update(repairId, form); + + // Auto-bump status: uploading a quote moves new/assessing → quoted. + if ( + field === "quote" && + (existing.status === "new" || existing.status === "assessing") + ) { + assertRepairTransition(existing.status, "quoted"); + updated = await pb.collection(REPAIRS).update(repairId, { + status: "quoted", + }); + } + + return updated; +} +``` + +Then add the two new functions. Put them after `removeRepairFile`: + +```ts +/* ============================================================================ + * Status counts — one cheap count query per status + * ========================================================================== */ + +export interface RepairStatusCounts { + /** Sum of every status count. */ + total: number; + /** Count per status. Every status is present, even at zero. */ + byStatus: Record; +} + +/** + * Count repairs per status. PocketBase returns `totalItems` for a + * filtered list, so each status is one `getList(perPage: 1)` — cheap, + * and the seven counts add up to the total the board shows. + */ +export async function getRepairStatusCounts(): Promise { + const pb = await getAdminPocketBase(); + const byStatus = {} as Record; + let total = 0; + + for (const status of REPAIR_STATUS) { + const result = await pb.collection(REPAIRS).getList(1, 1, { + filter: `status = "${status}"`, + }); + byStatus[status] = result.totalItems; + total += result.totalItems; + } + + return { total, byStatus }; +} + +/* ============================================================================ + * Order ↔ repair link + * ========================================================================== */ + +/** + * Link an order to the repair it came from, in BOTH directions. + * + * `orders.repair` points at the repair (so the buyer can see the machine's + * status from the order) and `repairs.order` points back (so the repair can + * show the order). `orders.is_repair_order` is set in the same write — it + * already exists on the collection and is already a documented filter on + * `orders.list` in the MCP, but nothing ever set it. + */ +export async function linkRepairToOrder( + orderId: string, + repairId: string, +): Promise<{ order: Order; repair: Repair }> { + const pb = await getAdminPocketBase(); + await pb.collection(ORDERS).getOne(orderId); + const repair = await pb.collection(REPAIRS).getOne(repairId); + + const order = await pb.collection(ORDERS).update(orderId, { + repair: repairId, + is_repair_order: true, + }); + const patchedRepair = await pb.collection(REPAIRS).update(repairId, { + order: orderId, + }); + + return { order, repair: patchedRepair }; +} +``` + +Add the collection constant next to the existing ones at the top of the file: + +```ts +const ORDERS = "orders"; +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `npx vitest run src/lib/server/repairs-service.test.ts src/lib/repair-status.test.ts` + +Expected: PASS — the eight original tests plus the new ones. + +- [ ] **Step 8: Typecheck** + +Run: `npx tsc --noEmit` + +Expected: 0 errors. The `RepairStatus` re-export must keep `src/app/(app)/repairs/page.tsx` and `src/app/api/v1/repairs/[id]/route.ts` compiling. + +- [ ] **Step 9: Commit** + +```bash +git add src/lib/types.ts src/lib/server/repairs-service.ts src/lib/server/repairs-service.test.ts +git commit -m "feat(repairs): supplier scope, signed copy, status counts, order link" +``` + +--- + +### Task 3: The REST surface + +**Files:** +- Modify: `src/app/api/v1/repairs/route.ts` — `POST` forwards `supplier_scope` +- Modify: `src/app/api/v1/repairs/[id]/files/[field]/route.ts` — `ALLOWED_FIELDS` gains `signed_request` +- Create: `src/app/api/v1/repairs/status-counts/route.ts` +- Create: `src/app/api/v1/repairs/repair-routes.test.ts` + +**Interfaces:** +- Consumes: `getRepairStatusCounts`, `createRepair`, `uploadRepairFile`, `REPAIR_FILE_FIELDS` from `@/lib/server/repairs-service` (Task 2). +- Produces: `GET /api/v1/repairs/status-counts` → `{ total: number, byStatus: Record }` + +- [ ] **Step 1: Write the failing test** + +Create `src/app/api/v1/repairs/repair-routes.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { + createRepair, + uploadRepairFile, + getRepairStatusCounts, +} from "@/lib/server/repairs-service"; +import { POST as createRoute } from "./route"; +import { POST as filesRoute } from "./[id]/files/[field]/route"; +import { GET as statusCountsRoute } from "./status-counts/route"; + +vi.mock("@/lib/server/repairs-service", async () => { + const actual = await vi.importActual< + typeof import("@/lib/server/repairs-service") + >("@/lib/server/repairs-service"); + return { + ...actual, + createRepair: vi.fn(), + uploadRepairFile: vi.fn(), + getRepairStatusCounts: vi.fn(), + }; +}); + +function req(url: string, init: RequestInit = {}) { + return new NextRequest(url, init); +} + +describe("repairs REST surface", () => { + beforeEach(() => vi.clearAllMocks()); + + it("forwards supplier_scope when a repair is created", async () => { + vi.mocked(createRepair).mockResolvedValue({ + id: "rep1", + repair_number: "R-0001", + } as never); + + const res = await createRoute( + req("http://localhost/api/v1/repairs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + item_description: "Welding machine", + fault_description: "Will not strike", + supplier_scope: "Strip and quote the strike mechanism", + }), + }), + ); + + expect(res.status).toBe(201); + expect(createRepair).toHaveBeenCalledWith( + expect.objectContaining({ + supplier_scope: "Strip and quote the strike mechanism", + }), + ); + }); + + it("accepts signed_request as a file field", async () => { + vi.mocked(uploadRepairFile).mockResolvedValue({ id: "rep1" } as never); + + const form = new FormData(); + form.append( + "signed_request", + new Blob([new Uint8Array([1, 2, 3])], { type: "application/pdf" }), + "signed.pdf", + ); + + const res = await filesRoute( + req("http://localhost/api/v1/repairs/rep1/files/signed_request", { + method: "POST", + body: form, + }), + { params: Promise.resolve({ id: "rep1", field: "signed_request" }) }, + ); + + expect(res.status).toBe(200); + expect(uploadRepairFile).toHaveBeenCalledWith( + "rep1", + "signed_request", + expect.anything(), + "signed.pdf", + ); + }); + + it("returns the status counts", async () => { + vi.mocked(getRepairStatusCounts).mockResolvedValue({ + total: 12, + byStatus: { + new: 3, + assessing: 1, + quoted: 2, + approved: 0, + in_progress: 1, + completed: 4, + cancelled: 1, + }, + }); + + const res = await statusCountsRoute( + req("http://localhost/api/v1/repairs/status-counts"), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.total).toBe(12); + expect(body.byStatus.cancelled).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/app/api/v1/repairs/repair-routes.test.ts` + +Expected: FAIL — `status-counts/route` does not exist, and the create route does not forward `supplier_scope`. + +- [ ] **Step 3: Forward supplier_scope on create** + +In `src/app/api/v1/repairs/route.ts`, in the `POST` handler's `createRepair({ ... })` call add: + +```ts + supplier_scope: body.supplier_scope ?? null, +``` + +and extend the doc comment's body list with +`- supplier_scope: optional — what the supplier must look at`. + +- [ ] **Step 4: Accept the signed copy** + +In `src/app/api/v1/repairs/[id]/files/[field]/route.ts`, add `"signed_request"` to `ALLOWED_FIELDS`: + +```ts +const ALLOWED_FIELDS: RepairFileField[] = [ + "photos", + "quote", + "purchase_order", + "repair_request", + "signed_request", +]; +``` + +- [ ] **Step 5: Add the status-counts route** + +Create `src/app/api/v1/repairs/status-counts/route.ts`: + +```ts +import { NextResponse } from "next/server"; +import { getRepairStatusCounts } from "@/lib/server/repairs-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/v1/repairs/status-counts + * + * One count per repair status, plus the total. Every status is present + * even at zero, so the phone app's filter row never shifts shape and the + * seven counts always add up to the total. + * + * Returns: 200 — { total: number, byStatus: Record } + */ +export async function GET() { + try { + const counts = await getRepairStatusCounts(); + return NextResponse.json(counts); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to count repairs by status"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `npx vitest run src/app/api/v1/repairs/repair-routes.test.ts` + +Expected: PASS — 3 tests. + +- [ ] **Step 7: Typecheck** + +Run: `npx tsc --noEmit` + +Expected: 0 errors. + +- [ ] **Step 8: Commit** + +```bash +git add src/app/api/v1/repairs/route.ts "src/app/api/v1/repairs/[id]/files/[field]/route.ts" src/app/api/v1/repairs/status-counts/route.ts src/app/api/v1/repairs/repair-routes.test.ts +git commit -m "feat(repairs): expose supplier scope, signed copies and status counts" +``` + +--- + +### Task 4: Eight MCP repair tools + +**Files:** +- Modify: `src/app/api/mcp/route.ts` — eight tool definitions in the `tools` array, eight handlers in the `tools/call` dispatch +- Modify: `src/lib/server/giellie-tool-policy.ts` — two read tools added to `READ_TOOLS` +- Test: `src/app/api/mcp/route.test.ts` +- Test: `src/lib/server/giellie-tool-policy.test.ts` + +**Interfaces:** +- Consumes: `REPAIR_STATUS`, `nextStepFor`, `type RepairStatus` from `@/lib/repair-status`; `listRepairs`, `getRepair`, `createRepair`, `updateRepair`, `uploadRepairFile`, `generateRepairRequest`, `createCustomerQuoteFromRepair`, `REPAIR_FILE_FIELDS`, `type RepairFileField`, `type UpdateRepairInput` from `@/lib/server/repairs-service`. +- Produces: eight MCP tools — `repairs.list`, `repairs.get`, `repairs.create`, `repairs.update`, `repairs.advance_status`, `repairs.upload_file`, `repairs.generate_repair_request`, `repairs.create_customer_quote`. + +- [ ] **Step 1: Write the failing MCP tests** + +In `src/app/api/mcp/route.test.ts`, add this mock alongside the existing mocks at the top of the file: + +```ts +vi.mock("@/lib/server/repairs-service", () => ({ + listRepairs: vi.fn(), + getRepair: vi.fn(), + createRepair: vi.fn(), + updateRepair: vi.fn(), + uploadRepairFile: vi.fn(), + generateRepairRequest: vi.fn(), + createCustomerQuoteFromRepair: vi.fn(), + REPAIR_FILE_FIELDS: [ + "photos", + "quote", + "purchase_order", + "repair_request", + "signed_request", + ], +})); +``` + +and these imports: + +```ts +import { + listRepairs, + getRepair, + createRepair, + updateRepair, + uploadRepairFile, + generateRepairRequest, + createCustomerQuoteFromRepair, +} from "@/lib/server/repairs-service"; +``` + +Then append these tests: + +```ts +describe("repairs MCP tools", () => { + beforeEach(() => { + process.env.AGENT_API_KEY = "t"; + vi.clearAllMocks(); + }); + + it("lists every repair tool in tools/list", async () => { + const res = await POST(mcpRequest({ method: "tools/list", id: 1 })); + const body = await res.json(); + const names = body.result.tools.map((t: { name: string }) => t.name); + for (const tool of [ + "repairs.list", + "repairs.get", + "repairs.create", + "repairs.update", + "repairs.advance_status", + "repairs.upload_file", + "repairs.generate_repair_request", + "repairs.create_customer_quote", + ]) { + expect(names).toContain(tool); + } + }); + + it("repairs.list passes the status filter through", async () => { + vi.mocked(listRepairs).mockResolvedValue({ + items: [], + totalItems: 0, + totalPages: 0, + page: 1, + perPage: 20, + }); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 2, + params: { + name: "repairs.list", + arguments: { status: "assessing", page: 1, perPage: 20 }, + }, + }), + ); + + expect(listRepairs).toHaveBeenCalledWith( + expect.objectContaining({ status: "assessing" }), + ); + }); + + it("repairs.list rejects a status outside the fixed set", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 3, + params: { name: "repairs.list", arguments: { status: "wat" } }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/Invalid status/); + }); + + it("repairs.update refuses to patch status", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 4, + params: { + name: "repairs.update", + arguments: { id: "rep1", patch: { status: "quoted" } }, + }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/repairs.advance_status/); + }); + + it("repairs.update refuses a field outside the whitelist", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 5, + params: { + name: "repairs.update", + arguments: { id: "rep1", patch: { repair_number: "HACK" } }, + }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/cannot patch "repair_number"/); + }); + + it("repairs.advance_status returns the next step", async () => { + vi.mocked(updateRepair).mockResolvedValue({ + id: "rep1", + status: "assessing", + } as never); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 6, + params: { + name: "repairs.advance_status", + arguments: { id: "rep1", to: "assessing" }, + }, + }), + ); + + const body = await res.json(); + const payload = JSON.parse(body.result.content[0].text); + expect(payload.next_step.action).toBe("upload_supplier_quote"); + expect(payload.next_step.to).toBe("quoted"); + }); + + it("repairs.upload_file rejects an unknown file field", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 7, + params: { + name: "repairs.upload_file", + arguments: { + id: "rep1", + field: "secrets", + filename: "x.pdf", + base64: "AAAA", + }, + }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/Invalid file field/); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/app/api/mcp/route.test.ts` + +Expected: FAIL — the repair tools are not in the catalog. + +- [ ] **Step 3: Add the tool definitions** + +In `src/app/api/mcp/route.ts`, inside the `const tools = [` array, add eight entries: + +```ts + { + name: "repairs.list", + description: + "List repair jobs. `status` filters to one status (new, assessing, quoted, approved, in_progress, completed, cancelled). `search` is a case-insensitive substring match on repair_number, item_description, fault_description, notes and serial_no.", + inputSchema: { + type: "object", + properties: { + page: { type: "number", description: "Page (1-based). Default 1." }, + perPage: { type: "number", description: "Items per page. Default 20." }, + search: { type: "string", description: "Substring match on repair number, machine, fault, notes or serial." }, + status: { type: "string", description: "One of: new, assessing, quoted, approved, in_progress, completed, cancelled." }, + }, + }, + }, + { + name: "repairs.get", + description: + "One repair job by id, with customer, company, order, supplier and customer quote expanded. Use this to see the photos, the repair request, the signed copy, the supplier quote and the customer quote on a job.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "The repair id." }, + }, + required: ["id"], + }, + }, + { + name: "repairs.create", + description: + "Book a machine in as a repair job. Needs what the machine is and what is wrong; everything else is optional. `fault_description` is what the CUSTOMER says is wrong; `supplier_scope` is what the SUPPLIER must look at — they are different things and both matter on the repair request form.", + inputSchema: { + type: "object", + properties: { + customer: { type: "string", description: "Customer relation id — whose machine it is." }, + company: { type: "string", description: "Company relation id." }, + order: { type: "string", description: "Order relation id, if the machine came from an order." }, + supplier: { type: "string", description: "Supplier relation id — the service centre the machine goes to." }, + item_description: { type: "string", description: "What the machine is. Required." }, + fault_description: { type: "string", description: "What the customer says is wrong. Required." }, + supplier_scope: { type: "string", description: "What the supplier must look at or do." }, + serial_no: { type: "string", description: "The machine's serial number." }, + notes: { type: "string", description: "Free-text note." }, + }, + required: ["item_description", "fault_description"], + }, + }, + { + name: "repairs.update", + description: + "Change the details of a repair job — notes, the machine, the serial number, the customer, the supplier, or what the supplier must look at. It CANNOT change the status: use repairs.advance_status for that.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "The repair id." }, + patch: { type: "object", description: "Fields to change. Status is not allowed here." }, + }, + required: ["id", "patch"], + }, + }, + { + name: "repairs.advance_status", + description: + "Move a repair job to the next status. Allowed moves: new → assessing, quoted or cancelled; assessing → quoted or cancelled; quoted → approved, in_progress or cancelled; approved → in_progress or cancelled; in_progress → completed or cancelled. Returns the repair and the next step available.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "The repair id." }, + to: { type: "string", description: "The new status." }, + }, + required: ["id", "to"], + }, + }, + { + name: "repairs.upload_file", + description: + "Attach a file to a repair job. `field` is one of: photos (the machine as received), quote (the supplier's quote), purchase_order (our PO to the supplier), repair_request (the request the driver carries), signed_request (the supplier's signed copy handed back). The file is ADDED to the field; existing files are kept.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "The repair id." }, + field: { type: "string", description: "photos | quote | purchase_order | repair_request | signed_request" }, + filename: { type: "string", description: "The file name to store." }, + content_type: { type: "string", description: "MIME type, e.g. application/pdf or image/jpeg." }, + base64: { type: "string", description: "The raw file bytes, base64 encoded (no data: URL prefix)." }, + }, + required: ["id", "field", "filename", "base64"], + }, + }, + { + name: "repairs.generate_repair_request", + description: + "Generate the Repair Request PDF for a repair job and send the machine to the supplier's assessment. The repair must have a supplier set. This also moves the job from 'Booked in' to 'With supplier'. The PDF is the document the driver carries with the machine; the supplier signs a copy and hands it back, which is uploaded with repairs.upload_file as signed_request.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "The repair id." }, + }, + required: ["id"], + }, + }, + { + name: "repairs.create_customer_quote", + description: + "Quote the customer for a repair job. The quote carries the repair's customer, company and serial number, and the repair links back to the quote. The items and their prices are required — ask the operator for them before calling this.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "The repair id." }, + items: { + type: "array", + description: "The quote lines.", + items: { + type: "object", + properties: { + description: { type: "string" }, + quantity: { type: "number" }, + unit: { type: "string" }, + cost_price: { type: "number" }, + markup: { type: "number" }, + selling_price: { type: "number" }, + notes: { type: "string" }, + }, + required: ["description"], + }, + }, + quote_notes: { type: "string", description: "Note shown on the customer quote." }, + validity_days: { type: "number", description: "Quote validity in days. Default 30." }, + description: { type: "string", description: "Subject override for the quote." }, + replace: { type: "boolean", description: "Replace an existing customer quote link. Default false." }, + }, + required: ["id", "items"], + }, + }, +``` + +- [ ] **Step 4: Add the dispatch handlers** + +In `src/app/api/mcp/route.ts`, inside the `if (body?.method === "tools/call")` block, add these eight handlers. Put them at the **top** of the dispatch, right after `const args = body?.params?.arguments || {};`, so the repair block reads as one unit. + +```ts + if (toolName === "repairs.list") { + const statusArg = typeof args.status === "string" ? args.status : ""; + if (statusArg && !REPAIR_STATUS.includes(statusArg as RepairStatus)) { + throw new Error( + `Invalid status "${statusArg}". Allowed: ${REPAIR_STATUS.join(", ")}.`, + ); + } + const result = await listRepairs({ + page: Number(args.page || 1), + perPage: Number(args.perPage || 20), + search: typeof args.search === "string" ? args.search : "", + status: statusArg as RepairStatus | "", + }); + return jsonRpcOk(body.id, result); + } + + if (toolName === "repairs.get") { + const id = String(args.id || "").trim(); + if (!id) throw new Error("Missing id for repairs.get."); + const repair = await getRepair(id); + return jsonRpcOk(body.id, repair); + } + + if (toolName === "repairs.create") { + const itemDescription = String(args.item_description || "").trim(); + const faultDescription = String(args.fault_description || "").trim(); + if (!itemDescription) { + throw new Error("Missing item_description for repairs.create."); + } + if (!faultDescription) { + throw new Error("Missing fault_description for repairs.create."); + } + const repair = await createRepair({ + customer: String(args.customer || "").trim() || null, + company: String(args.company || "").trim() || null, + order: String(args.order || "").trim() || null, + supplier: String(args.supplier || "").trim() || null, + item_description: itemDescription, + fault_description: faultDescription, + supplier_scope: String(args.supplier_scope || "").trim() || null, + serial_no: String(args.serial_no || "").trim() || null, + notes: String(args.notes || "").trim() || null, + }); + return jsonRpcOk(body.id, repair); + } + + if (toolName === "repairs.update") { + const id = String(args.id || "").trim(); + if (!id) throw new Error("Missing id for repairs.update."); + const patch = ( + args.patch && typeof args.patch === "object" ? args.patch : {} + ) as Record; + if ("status" in patch) { + throw new Error( + "repairs.update cannot change status. Use repairs.advance_status.", + ); + } + const allowed = [ + "notes", + "item_description", + "fault_description", + "supplier_scope", + "serial_no", + "customer", + "company", + "order", + "supplier", + ]; + const clean: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (!allowed.includes(key)) { + throw new Error( + `repairs.update cannot patch "${key}". Allowed: ${allowed.join(", ")}.`, + ); + } + clean[key] = value; + } + if (Object.keys(clean).length === 0) { + throw new Error("repairs.update needs at least one field to patch."); + } + const repair = await updateRepair(id, clean as UpdateRepairInput); + return jsonRpcOk(body.id, repair); + } + + if (toolName === "repairs.advance_status") { + const id = String(args.id || "").trim(); + const to = String(args.to || "").trim(); + if (!id) throw new Error("Missing id for repairs.advance_status."); + if (!REPAIR_STATUS.includes(to as RepairStatus)) { + throw new Error( + `Invalid status "${to}". Allowed: ${REPAIR_STATUS.join(", ")}.`, + ); + } + const repair = await updateRepair(id, { status: to as RepairStatus }); + return jsonRpcOk(body.id, { + repair, + next_step: nextStepFor(repair.status), + }); + } + + if (toolName === "repairs.upload_file") { + const id = String(args.id || "").trim(); + const field = String(args.field || "").trim(); + const filename = String(args.filename || "").trim(); + if (!id) throw new Error("Missing id for repairs.upload_file."); + if (!REPAIR_FILE_FIELDS.includes(field as RepairFileField)) { + throw new Error( + `Invalid file field "${field}". Allowed: ${REPAIR_FILE_FIELDS.join(", ")}.`, + ); + } + if (!filename) { + throw new Error("Missing filename for repairs.upload_file."); + } + if (!args.base64) { + throw new Error("Missing base64 for repairs.upload_file."); + } + const bytes = Buffer.from(String(args.base64), "base64"); + const blob = new Blob([bytes], { + type: + typeof args.content_type === "string" + ? args.content_type + : "application/octet-stream", + }); + const repair = await uploadRepairFile( + id, + field as RepairFileField, + blob, + filename, + ); + return jsonRpcOk(body.id, repair); + } + + if (toolName === "repairs.generate_repair_request") { + const id = String(args.id || "").trim(); + if (!id) { + throw new Error("Missing id for repairs.generate_repair_request."); + } + const result = await generateRepairRequest(id); + return jsonRpcOk(body.id, result); + } + + if (toolName === "repairs.create_customer_quote") { + const id = String(args.id || "").trim(); + if (!id) { + throw new Error("Missing id for repairs.create_customer_quote."); + } + const rawItems = Array.isArray(args.items) ? args.items : []; + if (rawItems.length === 0) { + throw new Error( + "repairs.create_customer_quote needs at least one line item.", + ); + } + const result = await createCustomerQuoteFromRepair(id, { + items: rawItems.map((it: Record) => ({ + description: String(it.description || "").trim(), + quantity: + typeof it.quantity === "number" ? it.quantity : undefined, + unit: typeof it.unit === "string" ? it.unit : undefined, + cost_price: + typeof it.cost_price === "number" ? it.cost_price : undefined, + markup: typeof it.markup === "number" ? it.markup : undefined, + selling_price: + typeof it.selling_price === "number" + ? it.selling_price + : undefined, + notes: typeof it.notes === "string" ? it.notes : undefined, + })), + quote_notes: + typeof args.quote_notes === "string" ? args.quote_notes : undefined, + validity_days: + typeof args.validity_days === "number" + ? args.validity_days + : undefined, + description: + typeof args.description === "string" ? args.description : undefined, + replace: args.replace === true, + }); + return jsonRpcOk(body.id, result); + } +``` + +Add the imports at the top of `src/app/api/mcp/route.ts`: + +```ts +import { + listRepairs, + getRepair, + createRepair, + updateRepair, + uploadRepairFile, + generateRepairRequest, + createCustomerQuoteFromRepair, + REPAIR_FILE_FIELDS, + type RepairFileField, + type UpdateRepairInput, +} from "@/lib/server/repairs-service"; +import { + REPAIR_STATUS, + nextStepFor, + type RepairStatus, +} from "@/lib/repair-status"; +``` + +- [ ] **Step 5: Run the MCP tests to verify they pass** + +Run: `npx vitest run src/app/api/mcp/route.test.ts` + +Expected: PASS — the existing tests plus the six new ones. + +- [ ] **Step 6: Classify the two read tools** + +In `src/lib/server/giellie-tool-policy.ts`, add to `READ_TOOLS`: + +```ts + "repairs.list", + "repairs.get", +``` + +Add a test to `src/lib/server/giellie-tool-policy.test.ts`: + +```ts + it("classifies the repair tools", () => { + expect(classifyTool("repairs.list")).toBe("read"); + expect(classifyTool("repairs.get")).toBe("read"); + expect(classifyTool("repairs.create")).toBe("write"); + expect(classifyTool("repairs.update")).toBe("write"); + expect(classifyTool("repairs.advance_status")).toBe("write"); + expect(classifyTool("repairs.upload_file")).toBe("write"); + expect(classifyTool("repairs.generate_repair_request")).toBe("write"); + expect(classifyTool("repairs.create_customer_quote")).toBe("write"); + }); +``` + +Read the top of `src/lib/server/giellie-tool-policy.test.ts` first and use whatever +helper name it already imports — if it is not `classifyTool`, match the file. + +- [ ] **Step 7: Run the policy test** + +Run: `npx vitest run src/lib/server/giellie-tool-policy.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Typecheck** + +Run: `npx tsc --noEmit` + +Expected: 0 errors. + +- [ ] **Step 9: Commit** + +```bash +git add src/app/api/mcp/route.ts src/app/api/mcp/route.test.ts src/lib/server/giellie-tool-policy.ts src/lib/server/giellie-tool-policy.test.ts +git commit -m "feat(mcp): eight repair tools for the agent" +``` + +--- + +### Task 5: The office pages use the shared vocabulary + +**Files:** +- Modify: `src/app/(app)/repairs/page.tsx` +- Modify: `src/app/(app)/repairs/[id]/page.tsx` + +**Interfaces:** +- Consumes: `REPAIR_STATUS_LABEL`, `REPAIR_STATUS_VARIANT` from `@/lib/repair-status` (Task 1). +- Produces: nothing new — the office pages gain a `supplier_scope` input and stop carrying their own label maps. + +- [ ] **Step 1: Replace the local label maps** + +In `src/app/(app)/repairs/page.tsx` delete the local `statusColors` and +`statusLabels` constants and import: + +```ts +import { + REPAIR_STATUS_LABEL, + REPAIR_STATUS_VARIANT, +} from "@/lib/repair-status"; +``` + +Replace the two usages: + +```tsx + + {REPAIR_STATUS_LABEL[item.status] || item.status} + +``` + +Do the same in `src/app/(app)/repairs/[id]/page.tsx` — it has its own +`statusColors` and `statusLabels` constants and three usages (the header +badge, the status timeline, and one more). Replace each with the shared +constants. The `statusLabels[s]` usage inside the timeline becomes +`REPAIR_STATUS_LABEL[s]`. + +- [ ] **Step 2: Add the supplier scope input to the create form** + +In `src/app/(app)/repairs/page.tsx`: + +Add to the `form` state object: + +```ts + supplier_scope: "", +``` + +Add to the `closeDialog` reset object (the one that returns every field to +`""`): + +```ts + supplier_scope: "", +``` + +Add to the `createRes` body: + +```ts + supplier_scope: form.supplier_scope.trim(), +``` + +Add a labelled textarea after the fault description field, matching the +existing field's markup: + +```tsx +
+ +