- Updated the search input handling to improve responsiveness by integrating a debounced search mechanism, reducing latency from 550 ms to approximately 300 ms. - Added keyboard shortcut support for focusing the search box, enhancing accessibility and usability for frequent users. - Improved the search placeholder to reflect all searchable fields, providing clearer guidance to operators. - Introduced a clear-search button to streamline user interactions, allowing quick resets of the search input. - Enhanced the display of search results with a live result count badge, providing immediate feedback on search outcomes. These changes collectively enhance the search experience for operators, making it more intuitive and efficient.
783 lines
36 KiB
Markdown
783 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.
|