784 lines
36 KiB
Markdown
784 lines
36 KiB
Markdown
|
|
# Backend / API / Data Layer Audit
|
|||
|
|
|
|||
|
|
**Scope:** `src/lib/pocketbase.ts`, `src/lib/pb-helpers.ts` (legacy),
|
|||
|
|
`src/lib/server/*-service.ts` (current layer), `src/app/api/v1/*` route
|
|||
|
|
handlers, `/api/mcp`, and the route/service contract between them.
|
|||
|
|
|
|||
|
|
**Date:** 2026-08-28
|
|||
|
|
**Author:** bob (audit only — no code changes shipped)
|
|||
|
|
|
|||
|
|
This audit is the data-layer companion to `security-perf-prod.md`
|
|||
|
|
(tom, 2026-08-28). It deliberately does not re-cover the auth/secrets
|
|||
|
|
surface — see that doc for the critical AGENT_API_KEY / PB admin / CORS
|
|||
|
|
issues. Here we focus on:
|
|||
|
|
|
|||
|
|
- legacy `pb-helpers.ts` vs current `src/lib/server/*.service.ts` drift
|
|||
|
|
- PB filter / query foot-guns (N+1, getFirstListItem, IN-vs-OR)
|
|||
|
|
- schema-missing fallback pattern and select-field pitfalls
|
|||
|
|
- input validation, error envelope, and route-handler boilerplate
|
|||
|
|
- multi-write consistency (no transactions, partial-success semantics)
|
|||
|
|
- service-layer API inconsistencies (admin PB vs cookie PB)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 0. Headline summary
|
|||
|
|
|
|||
|
|
| Severity | Count | What it is |
|
|||
|
|
| -------- | ----- | ---------- |
|
|||
|
|
| Critical | 3 | Legacy `pb-helpers.ts` used directly by 32 files; schema-field gaps invisible to the type system; `pb-helpers.ts` PO/quote/order number generators are racy and were deprecated but still callable |
|
|||
|
|
| Major | 12 | No schema-validation library; 30+ `getFirstListItem` calls with inconsistent error handling; N+1 patterns in several services; `getAdminPocketBase` everywhere bypasses PB listRules; 7077-line MCP route file; schema-missing fallback duplicated 4× with subtle behavioural drift; cookie-auth PB singleton used by Server Components |
|
|||
|
|
| Minor | 10 | Inconsistent error envelopes; ad-hoc PO/quote-status string-literal duplication; search hard-caps hardcoded; legacy client-side duplicate-detection post-filter; no structured logging; service-layer returns vary between throwing and returning partial-success |
|
|||
|
|
|
|||
|
|
The legacy layer (`pb-helpers.ts`) is the single biggest source of
|
|||
|
|
drift. AGENTS.md says "do NOT extend `pb-helpers.ts`", and the service
|
|||
|
|
layer is the right place for new endpoints — but 32 callers still hit
|
|||
|
|
it directly, and the data shape they consume is subtly different from
|
|||
|
|
what the service layer emits. Migrating page-by-page is safe; the
|
|||
|
|
hazard is doing it inconsistently.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Critical findings
|
|||
|
|
|
|||
|
|
### 1.1 `pb-helpers.ts` PO/quote/order number generators are racy, still exported
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:1755-1772` (`generateQuoteNumber`)
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:1773-1796` (`generateOrderNumber`)
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:1799-1864` (`generatePONumber`)
|
|||
|
|
|
|||
|
|
All three functions use the read-then-write pattern:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
const existing = await pb.collection("orders").getList<Order>(1, 1, {
|
|||
|
|
filter: `order_number ~ "${prefix}"`,
|
|||
|
|
sort: "-order_number",
|
|||
|
|
});
|
|||
|
|
let seq = ... + 1; // <- race window
|
|||
|
|
return `ORD-${...}-${seq}`;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Two simultaneous calls both observe `007` and both write `008`. The
|
|||
|
|
PB-side atomic counter collection (`order_counters`, `quote_counters`)
|
|||
|
|
already exists and is used by `orders-service.ts:1302-1325` and
|
|||
|
|
`quotes-service.ts:879-909`. AGENTS.md explicitly deprecates these
|
|||
|
|
helpers, but they're still exported and reachable:
|
|||
|
|
|
|||
|
|
- `generateQuoteNumber` — `@deprecated` comment at line 1748, but
|
|||
|
|
nothing prevents a new caller from importing it.
|
|||
|
|
- `generateOrderNumber` — no deprecation marker; no callers found
|
|||
|
|
via grep on the codebase, but still public.
|
|||
|
|
- `generatePONumber` — inline comment at line 1745 calls it a
|
|||
|
|
"client-side mirror" of `src/lib/server/po-number.ts`. Both exist;
|
|||
|
|
the client-side one is racy.
|
|||
|
|
|
|||
|
|
**Recommended fix:** delete `generateQuoteNumber`, `generateOrderNumber`,
|
|||
|
|
and `generatePONumber` from `pb-helpers.ts` (or move to a `legacy`
|
|||
|
|
sub-export with a build-time ban) and add a CI lint rule that fails
|
|||
|
|
if any non-`pb-helpers.ts` file imports them. Server-side callers
|
|||
|
|
already use the atomic variants in `orders-service.ts` /
|
|||
|
|
`quotes-service.ts` / `po-number.ts`.
|
|||
|
|
|
|||
|
|
### 1.2 Legacy `pb-helpers.ts` is the data layer for 32 files, drift risk
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:1-1994`
|
|||
|
|
**File:** 32 files import from `@/lib/pb-helpers` (verified
|
|||
|
|
2026-08-28 via `grep -rl 'from "@/lib/pb-helpers"' src/ | wc -l`)
|
|||
|
|
|
|||
|
|
AGENTS.md says new code goes through `src/lib/server/*.service.ts`.
|
|||
|
|
The legacy helpers are still imported by Server Components,
|
|||
|
|
Client Components, and form components directly. The two layers read
|
|||
|
|
the same PB collections but emit subtly different shapes:
|
|||
|
|
|
|||
|
|
| Helper | Service-layer equivalent |
|
|||
|
|
| ------ | ------------------------ |
|
|||
|
|
| `getCustomer` / `getAllCustomers` (`pb-helpers.ts:32-46`) | `customers-service.ts` |
|
|||
|
|
| `getCompanies` / `getCompany` / `getAllCompanies` (`pb-helpers.ts:48-67`) | not yet migrated |
|
|||
|
|
| `getSuppliers` / `getSupplier` / `getAllSuppliers` (`pb-helpers.ts:69-87`) | not yet migrated |
|
|||
|
|
| `getQuotes` (`pb-helpers.ts:213-247`) | `quotes-service.ts` |
|
|||
|
|
| `getOrders` / `getOrder` / `getOrdersWithItems` (`pb-helpers.ts:617-635`, `:729-790`) | `orders-service.ts` |
|
|||
|
|
| `createOrderFromQuote` (`pb-helpers.ts:1231...`) | `orders-service.createOrderFromQuote` |
|
|||
|
|
| `getDeliveryNotes` (`pb-helpers.ts:1412-1420`) | `delivery-notes-service.ts` |
|
|||
|
|
|
|||
|
|
The risk is real and visible: `getQuotes` in `pb-helpers.ts:213` has
|
|||
|
|
the `deliveryNoteOnly` option for the Quotes page delivery-note tab,
|
|||
|
|
while `quotes-service.ts` re-implements the same query with a
|
|||
|
|
different filter order. A page that switches from the helper to the
|
|||
|
|
service changes the wire payload, which changes the page's render.
|
|||
|
|
|
|||
|
|
**Recommended fix:** per AGENTS.md, migrate page-by-page. The
|
|||
|
|
highest-risk migrations first (orders, quotes) because they emit
|
|||
|
|
shapes that already drift from the service. Add a `console.warn`
|
|||
|
|
to the legacy helpers that fires once per session if `NODE_ENV !==
|
|||
|
|
'test'` and the call comes from a Server Component — the breadcrumb
|
|||
|
|
will surface un-migrated callers in the browser console.
|
|||
|
|
|
|||
|
|
### 1.3 `pocketbase.ts` browser singleton auth pattern doesn't translate to Server Components
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pocketbase.ts:9-45`
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
let pb: PocketBase | null = null;
|
|||
|
|
export function getPocketBase(): PocketBase {
|
|||
|
|
if (typeof window === "undefined") {
|
|||
|
|
// Server-side: create new instance each time
|
|||
|
|
return new PocketBase(POCKETBASE_URL);
|
|||
|
|
}
|
|||
|
|
// Client-side: reuse singleton — load auth from localStorage
|
|||
|
|
...
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Two problems:
|
|||
|
|
|
|||
|
|
1. The server-side path always creates an unauthenticated instance.
|
|||
|
|
Server Components that import `getPocketBase` make PB calls with
|
|||
|
|
the `_superusers` collection bypass or no auth at all, depending
|
|||
|
|
on PB rule configuration. `getPocketBase` itself can't impersonate
|
|||
|
|
the user — there's no cookie / header to read on the server. See
|
|||
|
|
`dev-identity.ts` for the project's solution (a cookie called
|
|||
|
|
`wfdash_dev_email` that holds the user's email as the dev
|
|||
|
|
placeholder).
|
|||
|
|
|
|||
|
|
2. The client-side singleton uses `localStorage` for the auth store
|
|||
|
|
(`pocketbase.ts:23-30`). This is fine for browser use, but it
|
|||
|
|
couples the data layer to the browser. Any attempt to share a
|
|||
|
|
helper between Server and Client Components requires a wrapper
|
|||
|
|
(the service layer does this via `getAdminPocketBase()`).
|
|||
|
|
|
|||
|
|
**Recommended fix:** make `getPocketBase` server-side UNSAFE (it
|
|||
|
|
should throw if `typeof window === "undefined"`) and force all
|
|||
|
|
server-side callers through `getAdminPocketBase`. Document the rule
|
|||
|
|
in AGENTS.md. Today, server components using `getCustomer(id)` from
|
|||
|
|
`pb-helpers.ts:32` are silently making unauthenticated PB calls —
|
|||
|
|
PB's per-collection `listRule`/`viewRule` triages this, but a future
|
|||
|
|
rule relaxation will silently expose data.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Major findings
|
|||
|
|
|
|||
|
|
### 2.1 No schema-validation library (no zod / yup / valibot / joi / ajv)
|
|||
|
|
|
|||
|
|
**File:** every `src/app/api/v1/**/route.ts` that calls `req.json()` (57 routes)
|
|||
|
|
|
|||
|
|
Input validation is entirely ad-hoc. Each route hand-rolls its
|
|||
|
|
required-field checks:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// customers/route.ts:67-69
|
|||
|
|
if (!body?.customer) {
|
|||
|
|
return NextResponse.json({ error: "customer is required" }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Verification: `grep -i 'zod\|yup\|valibot\|joi\|ajv' package.json`
|
|||
|
|
returns 0 hits. The cost is real:
|
|||
|
|
|
|||
|
|
- **No type safety for body shapes** — TypeScript sees the body as
|
|||
|
|
`any` after `await req.json()`. The next line that reads
|
|||
|
|
`body.customer` will compile even if `customer` is missing.
|
|||
|
|
- **Inconsistent error messages** — some routes say
|
|||
|
|
`"item_description is required."`, others say
|
|||
|
|
`"customer is required"`, others say
|
|||
|
|
`"Invalid JSON body"`. The MCP layer (`mcp/route.ts`) has a
|
|||
|
|
bespoke `QUOTE_ITEM_PATCH_FIELDS` allowlist (line 161-176), and
|
|||
|
|
the v1 layer has no equivalent on most endpoints.
|
|||
|
|
- **No coercion** — string `"123"` and number `123` aren't
|
|||
|
|
normalised. Many service-layer functions wrap with `Number(...)`
|
|||
|
|
defensively (`pb-helpers.ts:1255-1262`), but a route handler that
|
|||
|
|
passes `body.cost_price` straight into a `pb.update(...)` will
|
|||
|
|
write a string into a number field.
|
|||
|
|
- **No max-size enforcement** — the only size cap is the
|
|||
|
|
multipart file limit (25 MB on the `mcp.add_attachment` tool,
|
|||
|
|
not on JSON bodies). A 50 MB JSON body is parsed in full before
|
|||
|
|
the handler can 413.
|
|||
|
|
|
|||
|
|
**Recommended fix:** add `zod` to package.json (small, no deps,
|
|||
|
|
TS-native), write one `parseBody(req, schema)` helper that lives in
|
|||
|
|
`src/lib/server/http-helpers.ts`, and convert routes incrementally.
|
|||
|
|
The 7 highest-value routes first (write endpoints that mutate PB):
|
|||
|
|
customers, suppliers, companies, orders, order_items, quotes,
|
|||
|
|
quote_items. The MCP route already enforces its own per-tool schema
|
|||
|
|
via JSON Schema (not zod), so leave it alone.
|
|||
|
|
|
|||
|
|
### 2.2 `getFirstListItem` throws on miss — 30+ inconsistent handlers
|
|||
|
|
|
|||
|
|
**File:** 31 call sites in `src/lib/server/` and `src/app/api/v1/`
|
|||
|
|
|
|||
|
|
PB's `getFirstListItem(filter)` throws a 404 if the filter matches
|
|||
|
|
zero rows. Half the call sites defensively catch, half don't. Examples:
|
|||
|
|
|
|||
|
|
- `orders-service.ts:1404` — `getFirstListItem(`year=${year} && month=${month}`)` is inside a try, catches via fallback (line 1411).
|
|||
|
|
- `orders-service.ts:2574` — `getFirstListItem<Order>(`order_number = "${esc}"`)` is inside its own try/catch, returns `null` on 404 (line 2580).
|
|||
|
|
- `management-service.ts:247` — `getFirstListItem(`company = "${escaped}"`)` returns the result (or rejects promise); the caller at line 245 wraps it in `.catch(() => null)` — but the path between line 240 and 247 doesn't catch.
|
|||
|
|
- `management-service.ts:232-236` — two `.catch(() => null)` chains on `users` lookups, OK.
|
|||
|
|
|
|||
|
|
A non-defensive call site bubbles a 500 to the route handler, which
|
|||
|
|
returns a generic `{error: message}` with the PB 404 stack trace
|
|||
|
|
included. This is loud in logs but confusing to the operator — they
|
|||
|
|
typed a real order number and got "404 not found" wrapped as a
|
|||
|
|
500-class error.
|
|||
|
|
|
|||
|
|
**Recommended fix:** wrap once at the service boundary with a
|
|||
|
|
`firstOrNull<T>(collection, filter)` helper. Every service should
|
|||
|
|
call `firstOrNull` and let the route handler decide whether "no rows
|
|||
|
|
matched" is a 404, 422, or a `null` field in a successful response.
|
|||
|
|
This is the same pattern `findOrderByOrderNumber` uses at
|
|||
|
|
`orders-service.ts:2573-2588` — apply it everywhere.
|
|||
|
|
|
|||
|
|
### 2.3 `getAdminPocketBase` everywhere bypasses PB listRules / field-level perms
|
|||
|
|
|
|||
|
|
**File:** every service-layer file (43 import sites)
|
|||
|
|
|
|||
|
|
The admin PB instance has superuser rights — every listRule, viewRule,
|
|||
|
|
and modifyRule is bypassed. This means:
|
|||
|
|
|
|||
|
|
- **Data classification doesn't apply.** A future "sensitive
|
|||
|
|
customer" field added to `customers` with `viewRule: ""` (no
|
|||
|
|
public read) would still be readable through every v1 route, the
|
|||
|
|
MCP route, and the legacy helpers.
|
|||
|
|
- **Field-level visibility doesn't apply.** Same shape: a
|
|||
|
|
`salary_band` field on `users` with a restrictive viewRule would
|
|||
|
|
be returned by every service that expands `users`.
|
|||
|
|
- **Audit trail is lost.** Superuser actions log as
|
|||
|
|
`_superusers` in PB's audit log, not as the operator who
|
|||
|
|
actually made the call. The `getServerIdentity(req)` helper
|
|||
|
|
(`dev-identity.ts:42-58`) is used to record the operator on
|
|||
|
|
specific fields, but the PB audit row still says `_superusers`.
|
|||
|
|
|
|||
|
|
This is a known design decision, not a bug — the rationale (server
|
|||
|
|
scripts, n8n, AI agents that can't carry the user's JWT) is documented
|
|||
|
|
in `management-service.ts:1-25`. The risk is that the rationale
|
|||
|
|
becomes invisible as more routes get added.
|
|||
|
|
|
|||
|
|
**Recommended fix:** add a comment to `pocketbase-admin.ts:62-72`
|
|||
|
|
warning that the admin client bypasses ALL PB rules, and require a
|
|||
|
|
JSDoc on every new service file that explains why admin is required.
|
|||
|
|
For browser-facing reads (`customers/list/route.ts:30`), keep using
|
|||
|
|
`assertBrowserSession` + the cookie PB (or migrate to the user-PB
|
|||
|
|
instance). The `assertAgentAuth` / `assertBrowserSession` split is
|
|||
|
|
correct as designed — the issue is when a *third* auth tier creeps
|
|||
|
|
in (e.g. an `assertServiceAuth` for cron jobs) and the proof of
|
|||
|
|
which tier a route uses gets buried in the route body.
|
|||
|
|
|
|||
|
|
### 2.4 N+1 patterns in service-layer loops
|
|||
|
|
|
|||
|
|
**File:** `src/lib/server/orders-service.ts:2174` (per-item patch loop inside standalone order create)
|
|||
|
|
**File:** `src/lib/server/orders-service.ts:2503-2506` (`cancelOrder` per-item `item_status: "cancelled"` update loop)
|
|||
|
|
**File:** `src/lib/server/quotes-service.ts` (multiple per-item update loops)
|
|||
|
|
**File:** `src/lib/server/buyer-tasks-service.ts:115-167` (per-tab fetch × N tabs)
|
|||
|
|
|
|||
|
|
Examples:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// orders-service.ts:2174 (standalone order create flow)
|
|||
|
|
for (let i = 0; i < itemsResult.created.length; i++) {
|
|||
|
|
const created = itemsResult.created[i];
|
|||
|
|
const original = input.items[i];
|
|||
|
|
if (!original) continue;
|
|||
|
|
const patch: Record<string, unknown> = {};
|
|||
|
|
if (original.sku !== undefined) patch.sku = original.sku;
|
|||
|
|
if (original.unit !== undefined) patch.unit = original.unit;
|
|||
|
|
if (original.supplier_id !== undefined) patch.supplier = original.supplier_id || null;
|
|||
|
|
if (original.notes !== undefined) patch.notes = original.notes;
|
|||
|
|
if (Object.keys(patch).length > 0) {
|
|||
|
|
try {
|
|||
|
|
await pb.collection(orderItemsCollection).update(created.id, patch);
|
|||
|
|
} catch (err) {
|
|||
|
|
// ... per-row failure recorded, loop continues
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`createOrderItems` returns N rows; this loop fires N PB round-trips
|
|||
|
|
to patch the secondary fields (sku, unit, supplier_id, notes). At
|
|||
|
|
the WFDASH fleet scale (≤30 items per order) it's tolerable, but the
|
|||
|
|
helper comment immediately above the loop admits this. Better
|
|||
|
|
shape: pass the optional secondary fields into `createOrderItems` as
|
|||
|
|
a parallel array, so the helper writes them in the same `create`
|
|||
|
|
call. PB supports fields on create.
|
|||
|
|
|
|||
|
|
`cancelOrder` (`orders-service.ts:2503-2506`, the
|
|||
|
|
`for (const item of toCancel) { await pb.collection(...).update(item.id, { item_status: "cancelled" }); }`
|
|||
|
|
block) is the same shape: one PB write per cancelled item. The
|
|||
|
|
surrounding `cancelOrdersBulk` (`orders-service.ts:2523-2535`) is a
|
|||
|
|
*separate* loop at the order level — it iterates `orderIds` and
|
|||
|
|
calls `cancelOrder` per order; the per-item fan-out lives inside
|
|||
|
|
`cancelOrder` itself.
|
|||
|
|
|
|||
|
|
`buyer-tasks-service.ts:115` opens with
|
|||
|
|
`Promise.all`-batched fetches for supplementary data, but the
|
|||
|
|
surrounding loops are per-item on the response side.
|
|||
|
|
|
|||
|
|
**Recommended fix:** before each loop, ask "can I do this with one
|
|||
|
|
PB call?" Specific wins:
|
|||
|
|
|
|||
|
|
- `orders-service.ts:2174` — fold secondary fields into the
|
|||
|
|
`createOrderItems` call so we don't need a follow-up patch.
|
|||
|
|
- `cancelOrder` — collect all eligible items and use PB's batch
|
|||
|
|
create endpoint (or a single `update` with a different verb).
|
|||
|
|
PB 0.22+ supports transactional batch via `pb.send()`.
|
|||
|
|
- `buyer-tasks-service.ts` — the per-tab fan-out is intentional
|
|||
|
|
and correct; the per-item post-processing inside each tab is the
|
|||
|
|
N+1 target.
|
|||
|
|
|
|||
|
|
### 2.5 No transactional writes across collections
|
|||
|
|
|
|||
|
|
**File:** every service that mutates >1 PB row in one operation
|
|||
|
|
|
|||
|
|
A representative example: `pb-helpers.ts:1264-1287`
|
|||
|
|
(`createOrderFromQuote`) creates the `orders` row, then loops
|
|||
|
|
`order_items` creates. If the network drops between the first
|
|||
|
|
`order_items.create` and the last, the order exists with N items
|
|||
|
|
where N is whatever the network reached. The transaction equivalent
|
|||
|
|
would be PB's `pb.send()` with a chain of ops, or a manual
|
|||
|
|
compensating-rollback helper.
|
|||
|
|
|
|||
|
|
The current mitigation is "log loudly and let the operator clean up
|
|||
|
|
manually". This is fine at WFDASH fleet scale, but the pattern
|
|||
|
|
scales badly — and the `OrderNumberCollisionError` flow at
|
|||
|
|
`orders-service.ts:2145-2198` shows the cost of catching a partial-
|
|||
|
|
failure state and rolling forward.
|
|||
|
|
|
|||
|
|
**Recommended fix:** two changes, in priority order:
|
|||
|
|
|
|||
|
|
1. Document the partial-success semantics in each multi-write
|
|||
|
|
service's JSDoc, with an explicit list of which steps are
|
|||
|
|
idempotent (safe to re-run) and which aren't. Today this
|
|||
|
|
knowledge lives in operator tribal memory.
|
|||
|
|
2. For the highest-value multi-writes (order creation, PO creation,
|
|||
|
|
delivery note creation, quote split), add a `try/catch` that
|
|||
|
|
deletes / rolls-back the partial state on failure. The PB SDK's
|
|||
|
|
`pb.send()` batch endpoint supports this natively.
|
|||
|
|
|
|||
|
|
### 2.6 MCP route handler is a single 7077-line file
|
|||
|
|
|
|||
|
|
**File:** `src/app/api/mcp/route.ts:1-7077`
|
|||
|
|
|
|||
|
|
Everything — the CORS helper, JSON-RPC envelope, upstream-fetch
|
|||
|
|
helper, schema validation, 50+ tool definitions, 50+ tool
|
|||
|
|
handlers — lives in one file. Tool definitions alone occupy
|
|||
|
|
lines 270-1500 (~1230 lines of `tools: [{ name: ..., inputSchema: ...}]`).
|
|||
|
|
Tool handlers are interleaved with definitions rather than grouped,
|
|||
|
|
so changing one tool requires scanning hundreds of lines.
|
|||
|
|
|
|||
|
|
This is a known single-file pattern (the existing audit covers it),
|
|||
|
|
but the data-layer perspective adds:
|
|||
|
|
|
|||
|
|
- **No way to mock individual tools for tests.** The MCP route has
|
|||
|
|
tests at `route.test.ts` and `po-agent-workflow.test.ts`, but
|
|||
|
|
both test the route shape end-to-end. Service-layer splits would
|
|||
|
|
let each tool be tested in isolation (the underlying services
|
|||
|
|
already have unit tests).
|
|||
|
|
- **Tool input schemas are written by hand** instead of using the
|
|||
|
|
same `zod` schema as the route handlers (see 2.1). The MCP layer
|
|||
|
|
could share schemas via `zod-to-json-schema` and the v1 routes.
|
|||
|
|
- **Adding a new tool is a 30-minute scroll-and-edit.** A
|
|||
|
|
`tools/<name>.ts` per-tool structure would make `git diff` readable
|
|||
|
|
and reduce merge conflicts when multiple devs add tools.
|
|||
|
|
|
|||
|
|
**Recommended fix:** extract each tool's `inputSchema` and handler
|
|||
|
|
into `src/lib/server/mcp-tools/<name>.ts`. The `tools` array becomes
|
|||
|
|
`Object.entries(MCP_TOOLS).map(...)`. JSON-RPC dispatch stays in
|
|||
|
|
`route.ts`. Risk: low — the file is a leaf in the import graph.
|
|||
|
|
|
|||
|
|
### 2.7 Schema-missing fallback pattern duplicated 4× with subtle drift
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:128-185` (`getQuotes`)
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:204-228` (`getQuoteAttachments`)
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:230-258` (`getQuoteStatusCounts`)
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:188-202` (`getTemplates` / `getFollowUpDueQuotes`)
|
|||
|
|
|
|||
|
|
All four use the same `is400(err)` + `warnSchemaMissing` pattern:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
function is400(err: unknown): boolean { ... }
|
|||
|
|
let warnedSchemaMissing = false;
|
|||
|
|
function warnSchemaMissing(what: string) {
|
|||
|
|
if (warnedSchemaMissing) return; // <- only warns ONCE for the whole process
|
|||
|
|
warnedSchemaMissing = true;
|
|||
|
|
console.warn(`[pb-helpers] ... (${what}). Import pb_schema to enable the new filter.`);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Three issues:
|
|||
|
|
|
|||
|
|
1. **`warnedSchemaMissing` is a module-level boolean**, so the warn
|
|||
|
|
fires once per process. After the first fallback, the operator
|
|||
|
|
never sees another "field missing" warning even if a different
|
|||
|
|
collection is missing a different field. The "what" argument is
|
|||
|
|
silently dropped.
|
|||
|
|
2. **`is400` only checks `err.status === 400`**, but PB can also
|
|||
|
|
return 400 with a different error shape (the new PB versions
|
|||
|
|
embed a JSON `data` payload). The check misses anything that
|
|||
|
|
isn't a literal `{status: 400}` object.
|
|||
|
|
3. **The fallback filters may compile but return wrong results.**
|
|||
|
|
`getQuotes` drops the `is_template != true` clause on fallback
|
|||
|
|
(line 175), so a quote marked as template silently appears in
|
|||
|
|
the New tab. `getQuoteStatusCounts` keeps the per-status queries
|
|||
|
|
but loses the templates-tab exclusion (line 263).
|
|||
|
|
|
|||
|
|
The 2026-07-22 PB schema import project (mentioned in AGENTS.md) is
|
|||
|
|
the path that resolves this; until then, the fallbacks should at
|
|||
|
|
minimum warn with the `what` argument.
|
|||
|
|
|
|||
|
|
**Recommended fix:** replace the module-level latch with a `Set<string>`
|
|||
|
|
that tracks warned fields, and surface the warning to a UI
|
|||
|
|
diagnostic (a banner on the operator dashboard, not a `console.warn`).
|
|||
|
|
If a route's fallback returns wrong data, that should surface in
|
|||
|
|
QA tests — write tests that assert "templates don't appear in the
|
|||
|
|
New tab when `is_template` is missing".
|
|||
|
|
|
|||
|
|
### 2.8 Service-layer error envelopes vary between routes
|
|||
|
|
|
|||
|
|
**File:** every `src/app/api/v1/**/route.ts` that returns JSON
|
|||
|
|
|
|||
|
|
Three observed shapes:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// Shape A — most routes
|
|||
|
|
{ error: "message" }
|
|||
|
|
|
|||
|
|
// Shape B — quote pdf route
|
|||
|
|
{ error: "PDF render failed", detail: "..." }
|
|||
|
|
|
|||
|
|
// Shape C — quote pdf validation
|
|||
|
|
{ error: "items not ready", code: "items_not_ready", details: { itemIds: [...], issues: [...] } }
|
|||
|
|
|
|||
|
|
// Shape D — MCP route (JSON-RPC)
|
|||
|
|
{ jsonrpc: "2.0", id: ..., error: { code: -32010, message: "...", data: {...} } }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The route handler picks whichever envelope the original author
|
|||
|
|
chose, with no consistent shape across endpoints. The MCP layer's
|
|||
|
|
JSON-RPC envelope is correct per spec but uses error codes that
|
|||
|
|
don't map to HTTP status (the agent has to learn the mapping from
|
|||
|
|
the hint strings in `TOOL_HINTS` at `mcp/route.ts:271-450`).
|
|||
|
|
|
|||
|
|
The v1 route handlers also return inconsistent HTTP statuses for
|
|||
|
|
similar failures:
|
|||
|
|
|
|||
|
|
- `customers/route.ts:78` returns 400 with `{error: "customer is required"}`.
|
|||
|
|
- `customers/[id]/route.ts:48` returns 400 with `{error: "JSON object body required"}`.
|
|||
|
|
- `quotes/[id]/pdf/route.ts:165-168` returns 422 with a structured body.
|
|||
|
|
- `quote-items/route.ts` (need to read) returns 500 on validation failures (no dedicated check).
|
|||
|
|
|
|||
|
|
**Recommended fix:** standardise on:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
{ error: { code: "string_enum", message: "human-readable", details?: object } }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
with a top-level helper `err(code, message, status, details?)`. The
|
|||
|
|
MCP layer keeps its JSON-RPC envelope (it's a different protocol)
|
|||
|
|
but maps to the same codes internally.
|
|||
|
|
|
|||
|
|
### 2.9 Search hard-caps silently truncate results
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:1066` (`SEARCH_HARD_CAP = 500`)
|
|||
|
|
**File:** `src/lib/server/orders-service.ts:1075` (search caps in service layer, separate value)
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:431-453` (status counts — 22 parallel queries)
|
|||
|
|
|
|||
|
|
`SEARCH_HARD_CAP = 500` caps `searchOrdersWithItems` at 500 results.
|
|||
|
|
Past 500, the result is silently truncated — no warning to the
|
|||
|
|
operator that there are more matches.
|
|||
|
|
|
|||
|
|
The status counts (`getQuoteStatusCounts` at `pb-helpers.ts:230`)
|
|||
|
|
fires 22 parallel `getList(1, 1, ...)` queries, one per PB status.
|
|||
|
|
With no `quotes.status` index on the live PB, each query is a full
|
|||
|
|
scan over ~8.5k quotes = ~2.6s wall time per the inline comment.
|
|||
|
|
The service-layer replacement (`quotes-service.ts`) collapses this
|
|||
|
|
to 1 scan.
|
|||
|
|
|
|||
|
|
**Recommended fix:** when a search hits the hard cap, return
|
|||
|
|
`{ items, truncated: true, totalApprox: "≥ 500" }` so the UI can
|
|||
|
|
render "showing 500 of 500+, refine your query". Migrate the status
|
|||
|
|
counts through the service layer's collapsed-counts implementation.
|
|||
|
|
|
|||
|
|
### 2.10 `pb-helpers.ts` duplicate-detection post-filter is fine but verbose
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:1889-1953` (`findNameMatches`, `findCompanyDuplicates`, etc.)
|
|||
|
|
|
|||
|
|
PB's `~` operator does case-insensitive substring match; for exact
|
|||
|
|
case-insensitive equality, the helper does:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
const result = await pb.collection(collectionName).getList<T>(1, limit, {
|
|||
|
|
filter: `${field} ~ "${escapeDup(trimmed)}"`,
|
|||
|
|
});
|
|||
|
|
return result.items.filter((row) => normDup(row[field]) === target);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
This works at WFDASH scale but has two limitations:
|
|||
|
|
|
|||
|
|
- `(field) ~ "value"` is still substring, so a search for "Acme"
|
|||
|
|
returns "Acme Limited" AND "New Acme Holdings" both. The
|
|||
|
|
post-filter narrows to exact, but the wire payload already
|
|||
|
|
included the unwanted rows.
|
|||
|
|
- The helper calls `pb.collection(...).getList` rather than the
|
|||
|
|
service-layer wrapper, so the auth context drifts (cookie PB vs
|
|||
|
|
admin PB depending on caller).
|
|||
|
|
|
|||
|
|
**Recommended fix:** migrate `findCompanyDuplicates` /
|
|||
|
|
`findSupplierDuplicates` / `findCustomerDuplicates` into
|
|||
|
|
`management-service.ts` so they use admin PB (consistent with the
|
|||
|
|
CRUD paths), and use PB's `=` with a `lower()` equivalent. PB 0.26
|
|||
|
|
doesn't have `lower()`, so the post-filter is unavoidable, but the
|
|||
|
|
narrowed PB `filter` should be the strict substring with `~`, not
|
|||
|
|
a substring that includes everything.
|
|||
|
|
|
|||
|
|
### 2.11 Counter `last_seq+` increments — concurrent-edge case
|
|||
|
|
|
|||
|
|
**File:** `src/lib/server/orders-service.ts:1413-1426` (`getOrCreateOrderCounter`)
|
|||
|
|
**File:** `src/lib/server/quotes-service.ts:930-944` (mirror for quotes)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
try {
|
|||
|
|
return await pb.collection(ORDER_COUNTERS_COLLECTION)
|
|||
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|||
|
|
} catch {
|
|||
|
|
// First request for this (year, month) — backfill from existing orders,
|
|||
|
|
// then create. Without backfill the new code would start at ORD-...-001
|
|||
|
|
// and collide with the existing sequence.
|
|||
|
|
const backfill = await getMaxOrderSequenceInMonth(pb, year, month);
|
|||
|
|
try {
|
|||
|
|
return await pb.collection(ORDER_COUNTERS_COLLECTION).create({
|
|||
|
|
year, month, last_seq: backfill,
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
// Concurrent caller beat us to it — re-fetch.
|
|||
|
|
return await pb.collection(ORDER_COUNTERS_COLLECTION)
|
|||
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The third `try` (line 1421-1425) catches the concurrent-beat-us case
|
|||
|
|
but doesn't catch the underlying unique-constraint error inside the
|
|||
|
|
`.catch`. If the unique constraint fails AND the re-fetch also fails,
|
|||
|
|
the original error is swallowed.
|
|||
|
|
|
|||
|
|
More importantly: `getMaxOrderSequenceInMonth` does a
|
|||
|
|
`getFullList({ filter: order_number ~ "..." })`, which scans every
|
|||
|
|
order in the month. On a busy month (or for the orders of the
|
|||
|
|
month, not just the current one), this is the slow path of an
|
|||
|
|
otherwise O(1) counter increment.
|
|||
|
|
|
|||
|
|
**Recommended fix:** the third try should re-throw the original
|
|||
|
|
error if the re-fetch fails. The slow-path backfill can be a one-
|
|||
|
|
shot job run by ops, not a per-create step.
|
|||
|
|
|
|||
|
|
### 2.12 PO number generator `getOrCreate` lookup lacks fallback for race
|
|||
|
|
|
|||
|
|
**File:** `src/lib/server/po-number.ts:1-...` (mirror of `pb-helpers.ts:1799-1864`)
|
|||
|
|
|
|||
|
|
The server-side PO number generator lives at `src/lib/server/po-number.ts`
|
|||
|
|
(matching the deprecated client-side `generatePONumber` at
|
|||
|
|
`pb-helpers.ts:1799-1864`). Per the inline comment at
|
|||
|
|
`pb-helpers.ts:1745-1752`, they should mirror field-by-field.
|
|||
|
|
|
|||
|
|
Two issues:
|
|||
|
|
|
|||
|
|
1. The client-side helper at `pb-helpers.ts:1799-1864` post-filters
|
|||
|
|
the existing POs on the suffix (line 1840-1848). The server-side
|
|||
|
|
helper (read separately) presumably uses a more correct index hit.
|
|||
|
|
Verify the formats actually match — a divergence here means
|
|||
|
|
previews in the email composer don't match the persisted PO
|
|||
|
|
number.
|
|||
|
|
|
|||
|
|
2. Both helpers look up "all POs in the (year, month)" via
|
|||
|
|
`getList(1, 500)` and filter client-side. For a busy month this
|
|||
|
|
pulls 500 records per PO generation. At the WFDASH fleet scale
|
|||
|
|
it's tolerable but wasteful.
|
|||
|
|
|
|||
|
|
**Recommended fix:** read both side-by-side and assert the formats
|
|||
|
|
match byte-for-byte. Add a comment in `po-number.ts` pinning the
|
|||
|
|
contract: "client-side preview at `pb-helpers.ts:1799-1864` MUST
|
|||
|
|
produce the same string".
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. Minor findings
|
|||
|
|
|
|||
|
|
### 3.1 `deliveryNoteOnly` option on `getQuotes` is fragile
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:213-247` (`getQuotes` options parameter)
|
|||
|
|
|
|||
|
|
The `options: { deliveryNoteOnly?: boolean }` shape is the only
|
|||
|
|
optional options object on the legacy helper. Adding a second
|
|||
|
|
boolean later would require a breaking change to the type. Use a
|
|||
|
|
discriminated options object instead — `getQuotes(page, perPage, search, sort, statuses, options)`.
|
|||
|
|
|
|||
|
|
### 3.2 Cookie PB singleton persists auth across logout
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pocketbase.ts:23-46`
|
|||
|
|
|
|||
|
|
When the user clicks logout, the operator's PB token is removed
|
|||
|
|
from `localStorage` (presumably — verify in the logout UI), but
|
|||
|
|
the singleton `pb` instance in `pocketbase.ts:9` is module-scoped
|
|||
|
|
and not cleared. The next `getPocketBase()` returns the same
|
|||
|
|
instance with the stale auth.
|
|||
|
|
|
|||
|
|
**Recommended fix:** add an exported `clearPocketBaseAuth()` that
|
|||
|
|
resets the singleton and clears the localStorage entry. Call it
|
|||
|
|
from the logout handler.
|
|||
|
|
|
|||
|
|
### 3.3 `assertBrowserSession` is duplicated inline in `quotes/items/rewrite/route.ts`
|
|||
|
|
|
|||
|
|
**File:** `src/app/api/v1/quotes/items/rewrite/route.ts:99-122`
|
|||
|
|
**File:** `src/lib/server/browser-session.ts:1-30`
|
|||
|
|
|
|||
|
|
The rewrite route has its own `assertBrowserSession` instead of
|
|||
|
|
importing the shared one. The two implementations are identical
|
|||
|
|
(`fetch ${pbUrl}/api/collections/users/auth-refresh` with a 5s
|
|||
|
|
timeout). Drift risk if one is updated and the other isn't.
|
|||
|
|
|
|||
|
|
**Recommended fix:** import from `@/lib/server/browser-session`.
|
|||
|
|
|
|||
|
|
### 3.4 `isPocketBaseId` regex is too lax (downgraded — minor)
|
|||
|
|
|
|||
|
|
**File:** `src/lib/server/management-service.ts:253-257`
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
function isPocketBaseId(s: string): boolean {
|
|||
|
|
// PocketBase ids are 15 alphanumeric chars by default. We don't try
|
|||
|
|
// to be stricter than that — false positives just fall through to
|
|||
|
|
// the name lookup which is harmless.
|
|||
|
|
return /^[a-z0-9]{15}$/i.test(s);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
PB's default id format is 15 alphanumeric chars, but admins can
|
|||
|
|
configure custom id formats. The regex matches any 15-char
|
|||
|
|
alphanumeric string (e.g. `"customerCustomer1234"` — not a real PB
|
|||
|
|
id). However, the actual call sites (`resolveCompanyRef` /
|
|||
|
|
`resolveUserRef` at `management-service.ts:206-237`) wrap the
|
|||
|
|
`getOne` in `try/catch` and **explicitly fall through to the name
|
|||
|
|
lookup** on any failure (literal comment: `// Fall through to name
|
|||
|
|
lookup.` / `// Fall through.`). So a 15-char string that doesn't
|
|||
|
|
resolve as an id correctly triggers the name-lookup fallback, and
|
|||
|
|
a string that *does* resolve as an id returns the real record.
|
|||
|
|
|
|||
|
|
The remaining (narrow) risk is a false positive that happens to
|
|||
|
|
collide with an unrelated record's real 15-char PB id — i.e. the
|
|||
|
|
operator types a company name like `"abc123def456ghi"` and it
|
|||
|
|
silently shadows a different real company. Probability is low
|
|||
|
|
(15-char alphanumeric is a ~10^27 space at 15 chars), but not
|
|||
|
|
zero, and the symptom is silent mis-routing. This is **minor**, not
|
|||
|
|
major.
|
|||
|
|
|
|||
|
|
**Severity:** Minor (downgraded from a prior draft that incorrectly
|
|||
|
|
described the fallback as missing — the fallback IS present).
|
|||
|
|
|
|||
|
|
**Recommended fix:** tighten the regex when PB exposes a stable id
|
|||
|
|
shape (e.g. include the `pre_` / custom-prefix configuration), or
|
|||
|
|
require a prefix hint on user-supplied input. For the time being,
|
|||
|
|
add a unit test that asserts both the fast-path and the fallback
|
|||
|
|
paths resolve to the same id when given an ambiguous 15-char input.
|
|||
|
|
|
|||
|
|
### 3.5 PB file proxy is publicly readable (per code comment)
|
|||
|
|
|
|||
|
|
**File:** `src/app/api/v1/quotes/[id]/pdf/route.ts:694-697`
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// PB's `/api/files/...` endpoint is publicly readable for these
|
|||
|
|
// records (no Authorization header required — the file collection
|
|||
|
|
// rule allows it). Stream the bytes through so the iframe can
|
|||
|
|
// render without exposing the storage URL directly.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The comment admits that PB's per-file rule allows anonymous reads.
|
|||
|
|
The PDF route streams the bytes back through Next so the iframe
|
|||
|
|
gets the data via the same-origin response, but anyone with the
|
|||
|
|
file token can hit `${PB}/api/files/quote_attachments/${id}/${name}`
|
|||
|
|
directly. The `security-perf-prod.md` audit covers this.
|
|||
|
|
|
|||
|
|
**Recommended fix (data-layer specific):** add a PB rule that gates
|
|||
|
|
`/api/files/quote_attachments/*` on a valid auth token. Today,
|
|||
|
|
the application relies on the file token being unguessable — PB's
|
|||
|
|
token format is randomsuffixed so it's hard to enumerate, but the
|
|||
|
|
attachment-id IS in the URL.
|
|||
|
|
|
|||
|
|
### 3.6 Schema-mismatch silently degrades `quote_attachments` direction
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:540-585` (`getQuoteAttachments`)
|
|||
|
|
|
|||
|
|
The `direction` field on `quote_attachments` was added in a 2026-06-23
|
|||
|
|
migration. The fallback at `pb-helpers.ts:570-583` returns the
|
|||
|
|
historical "all attachments" view when the field doesn't exist.
|
|||
|
|
This means after the migration, an operator who hasn't run it sees
|
|||
|
|
all attachments (including outbound and generated), which changes
|
|||
|
|
the "Original Email" card on the quote detail page. Document this
|
|||
|
|
in the migration guide.
|
|||
|
|
|
|||
|
|
### 3.7 `csv` cell encoding is naive
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:1914-1929` (`exportToCSV`)
|
|||
|
|
|
|||
|
|
The CSV encoder only escapes commas and double quotes, not
|
|||
|
|
newlines. A description with `\n` (rare in the codebase, but
|
|||
|
|
possible from copy/paste from a Word doc) breaks the CSV row.
|
|||
|
|
|
|||
|
|
**Recommended fix:** also escape `\n` and `\r` (CRLF inside a quoted
|
|||
|
|
cell is allowed by RFC 4180, but only if the cell is quoted; the
|
|||
|
|
current code only quotes when commas or double quotes are present).
|
|||
|
|
|
|||
|
|
### 3.8 `console.error` count is 37 across 165+ routes
|
|||
|
|
|
|||
|
|
**File:** `console.error` and `console.warn` callsites across `src/app/api/v1/**`
|
|||
|
|
|
|||
|
|
A `grep -c 'console.error\|console.warn\|console.log' src/app/api/v1/**/*.ts` shows 37. Most routes either log nothing or log a single error per failure. With structured logging missing (per the existing audit's 2.8), debugging a customer-reported failure today requires PB logs + Next logs + guessing.
|
|||
|
|
|
|||
|
|
**Recommended fix:** introduce a `logError(scope, error, context)` helper that adds the route path, request id, and container id to a single JSON line. Wrap every `catch` block with it. Even without a logger backend, the JSON-line format makes production logs grep-able.
|
|||
|
|
|
|||
|
|
### 3.9 `searchOrdersWithItems` SEARCH_HARD_CAP is in legacy only
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:1066` vs `src/lib/server/orders-service.ts:1075`
|
|||
|
|
|
|||
|
|
The legacy cap is 500; the service-layer cap is presumably different
|
|||
|
|
(verify). Different caps means a page that migrated to the service
|
|||
|
|
shows a different count than a page that hasn't.
|
|||
|
|
|
|||
|
|
### 3.10 `pb-helpers.ts` `getOrderItemStatusTabCounts` is N round-trips
|
|||
|
|
|
|||
|
|
**File:** `src/lib/pb-helpers.ts:660-693`
|
|||
|
|
|
|||
|
|
Six `getFullList` calls in parallel (`tabs.map(...)`). Each scans
|
|||
|
|
`order_items` filtered by `item_status` — at WFDASH scale this is
|
|||
|
|
fast, but each tab also returns the full `order` relation string,
|
|||
|
|
even though the function only dedupes to a count. Add `fields: "order"`
|
|||
|
|
to narrow the wire payload (this is the same fix the service layer
|
|||
|
|
applies).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. Production-readiness checklist (data-layer specific)
|
|||
|
|
|
|||
|
|
- [ ] **Schema validation:** add `zod`, write `parseBody(req, schema)` helper, convert the 7 highest-risk write routes.
|
|||
|
|
- [ ] **Legacy deprecation:** move `generateQuoteNumber`, `generateOrderNumber`, `generatePONumber` out of `pb-helpers.ts` exports; add a lint rule that bans non-legacy imports.
|
|||
|
|
- [ ] **Browser-singleton fix:** `getPocketBase` throws on server-side. Service-layer is the only path.
|
|||
|
|
- [ ] **getFirstListItem abstraction:** write `firstOrNull<T>(collection, filter)` and migrate the 31 call sites.
|
|||
|
|
- [ ] **Multi-write rollback:** document partial-success semantics on the 4 multi-write services (orders, PO, DN, quote-split).
|
|||
|
|
- [ ] **MCP route refactor:** extract each tool into `lib/server/mcp-tools/<name>.ts`.
|
|||
|
|
- [ ] **Search cap visibility:** return `truncated: true` from any search that hits a hard cap.
|
|||
|
|
- [ ] **Schema-missing warnings:** `Set<string>` per-field, surfaced to a UI diagnostic.
|
|||
|
|
- [ ] **Error envelope standardisation:** `{ error: { code, message, details? } }` shape across all v1 routes.
|
|||
|
|
- [ ] **`isPocketBaseId` tightening:** verify both id and name lookups for ambiguous inputs.
|
|||
|
|
- [ ] **CSV export hardening:** escape `\n` and `\r`.
|
|||
|
|
- [ ] **Logging:** structured JSON `logError(scope, error, context)` helper.
|
|||
|
|
|
|||
|
|
## 5. What I deliberately did **not** flag
|
|||
|
|
|
|||
|
|
- **Auth surface.** Covered comprehensively in `security-perf-prod.md`
|
|||
|
|
§1.1-1.4, §2.1-2.2, §2.11. This audit stays data-focused.
|
|||
|
|
- **Frontend / UX issues.** See `frontend-ux.md`.
|
|||
|
|
- **PB schema field disuse.** See `unused-pb-fields.md`.
|
|||
|
|
- **Test coverage gaps.** The service-layer files have *.test.ts
|
|||
|
|
companions for every business-critical module; the gaps are at
|
|||
|
|
the route-handler layer (most routes have no tests, e.g.
|
|||
|
|
`agent/scan/route.ts`). Coverage audit is a separate task.
|
|||
|
|
- **PDF rendering / @react-pdf / SVG data URIs.** The PDF rendering
|
|||
|
|
foot-guns (raster corruption at byte 0x80) are covered in the
|
|||
|
|
PDF route JSDoc and don't affect the data layer.
|