23 KiB
WFDASH QA Health Check
Date: 2026-07-10
Branch: minimax_ui_rev1
HEAD: 47b01bd feat: add picking slips, bulk print tracking, supplier map
Auditor: Coder (senior QA pass against Ultimate Debugger and QA Engineer P0–P3 framework)
🔍 Debugging Report
Summary
WFDASH is a high-effort, feature-rich Next.js 16 / React 19 / PocketBase CRM with very strong conventions (test scaffold, route guard helpers, sanitizers, async params). But three concrete production-blocking issues surface in type-check and tests: the quote-number formatter returns Q202607001 instead of the documented Q-2026-07-001 shape, the order-number generator uses now month for its prefix even when the matching sequence comes from a prior month, and the invoice-details type test fails TypeScript excess-property checking. Combined with a custom regex HTML sanitizer that the author themselves flags as "not a general-purpose sanitizer", the codebase is in "ship-with-known-bugs" territory rather than "in order". Fix the three logic bugs and migrate the sanitizer to DOMPurify before considering the next release.
Verdict: Needs fixes — 3 P1 functional bugs in production code paths + 1 P1 sanitizer risk + manageable secondary issues.
Issues Identified (by severity, descending)
[P1] — Quote number formatter produces wrong shape
- Location:
src/lib/server/quotes-service.ts:156-158 - Severity: P1 — every new quote, customer-facing, breaks the documented contract.
- Reproduction (unit test, fails today):
Three of the four failure messages are the same root cause (variants ofnpm run test:run FAIL src/lib/server/quotes-service.test.ts > generateQuoteNumberServer > starts the month at 001 expected 'Q202607001' to be 'Q-2026-07-001'increments on each call,concurrent calls no race,createQuoteFromEmail creates a new quote, customer, company, items, and attachments). - Root cause:
Missing the two dashes that every other surface in the codebase assumes.function formatQuoteNumber(year, month, seq) { return `Q${year}${String(month).padStart(2, "0")}${String(seq).padStart(3, "0")}`; }AGENT_INSTRUCTIONS.mdline 153-156 anddocs/SHIPPED.mdline 161 both documentQ-YYYY-MM-NNN. The n8n v5 side indexes against this regex (src/lib/server/quotes-service.test.ts:367:/^Q-\d{4}-\d{2}-001$/). - Recommended fix:
return `Q-${year}-${String(month).padStart(2, "0")}-${String(seq).padStart(3, "0")}`; - Verification:
npx vitest run src/lib/server/quotes-service.test.tsshows all four previously-failing tests now green. Runscripts/smoke/counter.tsandcounter-concurrent.tsagainst dev PB to confirm no other downstream consumer relied on the wrong shape.
[P1] — Order-number generator picks wrong month on rollover
- Location:
src/lib/server/orders-service.ts:41-55 - Severity: P1 — silently resets the sequence at every month boundary; also racy by construction.
- Reproduction (unit test, fails today):
npm run test:run FAIL src/lib/server/orders-service.test.ts:228:57 expected 'ORD-2026-07-008' to be 'ORD-2026-06-008' - Root cause:
prefix =is built fromnew Date()even when the matched sequence comes from a different month. Two failure modes follow:- The unit test pins the contract: when the latest matching order sits in June and we're now in July, the next order should still be
ORD-2026-06-008— i.e. the prefix is derived from the matched order, not wall-clock time. Today the implementation breaks that. - Even with a fixed prefix, the generator reads-then-writes without atomic increment — two concurrent
createOrderFromQuotecalls can both observeORD-...-007and both writeORD-...-008. (quotes-service.tsgets atomicity right viaquote_counters+last_seq+— the orders-side has no equivalent.)
- The unit test pins the contract: when the latest matching order sits in June and we're now in July, the next order should still be
- Recommended fix: (a) Derive
year/monthfrom the matched order's prefix, fall back tonowwhen the table is empty, (b) introduce anorder_counterscollection (mirroringquote_counters) for the cross-month rollover, or (c) accept the restart-at-001 behaviour but pin a test that documents it as intentional. The current "usenowalways" path is the worst of the three options. - Verification:
npx vitest run src/lib/server/orders-service.test.ts; then a manualcreateOrderFromQuoteafter midnight on 2026-06-30/2026-07-01 to confirm what the operator actually sees.
[P1] — TypeScript excess-property error in collection-confirm test
- Location:
src/app/(app)/collection-confirm/view-model.test.ts:278(vssrc/app/(app)/collection-confirm/view-model.ts:210) - Severity: P1 —
npx tsc --noEmitfails; CI cannot pass. - Reproduction:
src/app/(app)/collection-confirm/view-model.test.ts(278,63): error TS2353: Object literal may only specify known properties, and 'setBy' does not exist in type 'Pick<InvoiceDetails, "number" | "date">'. - Root cause:
buildConfirmNoteis declaredPick<InvoiceDetails, "date" | "number">; the test passes{ date, number, setBy, setByName, setAt }. Two fixes are defensible — pick the one that matches intent. - Recommended fix (pick one):
- Drop the extra fields from the test:
buildConfirmNote({ date: "2026-07-08", number: "INV-2" }) - Or change
buildConfirmNoteto accept the full type if those fields are needed later:export function buildConfirmNote(invoice: InvoiceDetails): string { … }
- Drop the extra fields from the test:
- Verification:
npx tsc --noEmitshows zero errors.
[P1] — Custom regex-based HTML sanitizer is bypassable
- Location:
src/components/quote-email-card.tsx:166-233(sanitizeHtml) - Severity: P1 — defence-in-depth layer only; author documents this openly but it is invoked with
dangerouslySetInnerHTMLon remote email bodies from arbitrary senders. - Reproduction (conceptual, not exploited live): a hostile email body containing
javascript:alert(1)inside an<a href="…">tag, or attribute injection via mismatched quotes (the regex on line 211 accepts'and unquoted values, which can smuggle payloads past the per-tag whitelist). - Root cause: Regex substitution cannot reliably parse HTML. The author already flags this at line 171-174 ("NOT a general-purpose sanitizer (use DOMPurify for that)"). It survives today because it sees friendly RFQ traffic, but the email pipeline is the canonical XSS ingress for a CRM and the cost of getting it wrong is high.
- Recommended fix: add
dompurify+isomorphic-dompurify, replacesanitizeHtmlbody with:import DOMPurify from "isomorphic-dompurify"; return DOMPurify.sanitize(input, { ALLOWED_TAGS: [...ALLOWED_TAGS], ALLOWED_ATTR: [...ALLOWED_ATTRS.a, ...ALLOWED_ATTRS.img, ...ALLOWED_ATTRS.th, ...ALLOWED_ATTRS.td, ...ALLOWED_ATTRS.table], ALLOW_DATA_ATTR: false, }); - Verification: add a vitest with two payloads —
javascript:href,javascript:alert(1)href, both should be neutralised.
[P1] — scripts/setup-pocketbase.ts is destructive with no backup guard
- Location:
scripts/setup-pocketbase.ts:50-63 - Severity: P1 — operator footgun. Hard-coded admin credentials (line 4-5)
david.els1@outlook.com / 1234567890and the script unconditionallyDELETEs 12+ collections againsthttp://127.0.0.1:8090. - Reproduction: run
npm run setupagainst any PB instance that already has data — every listed collection is wiped. - Root cause: the comment at line 48-49 acknowledges the destructive intent ("delete all existing (including stale ones from other projects)") but there is no confirmation prompt, no backup step, no
dryRunflag. - Recommended fix:
- Move the hardcoded creds to env reads (
PB_ADMIN_EMAIL,PB_ADMIN_PASSWORD) with safe fallback only inNODE_ENV !== "production". - Add a
--confirm-wipeflag and refuse to run without it. - Add a
takeSnapshot()call that writespb-exports/pb_schema-pre-setup-{ts}.jsonfirst.
- Move the hardcoded creds to env reads (
- Verification: run the script against a sandbox PB with non-empty data and confirm: (a) it refuses without the flag, (b) with the flag it produces a snapshot.
[P2] — 50 ESLint errors, dominated by React 19 hook violations
- Location: see table below
- Severity: P2 — the build succeeds but CI lint gates will fail, and the new
react-hooks/set-state-in-effectrule is real: it flags code that triggers cascading renders. - Reproduction:
npm run lint. - Counts:
Rule Count react-hooks/set-state-in-effect27 @typescript-eslint/no-explicit-any17 jsx-a11y/alt-text(false positives on@react-pdf/rendererImage)12 react/display-name1 @typescript-eslint/no-require-imports1 Cannot access variable before it is declared1 react/no-unescaped-entities1 Total 50 errors / 86 warnings - Top offender files:
src/components/order-form.tsx(initNewOrder + 4 sub-effects → cascading renders when the form loads)src/components/quote-pdf-viewer.tsx:47(sync reset on close path)src/components/supplier-map.tsx:150(fetch-on-mount in effect)src/components/ui/quote-search.tsx:157(fetch on query change)src/app/(app)/collection-map/page.tsx:437,441,504(4 instances)src/app/(app)/quotes/[id]/page.tsx:859,867(2 instances)src/lib/pdf/purchase-order-document.tsx:122andquote-document.tsx:120— false-positive alt warnings on@react-pdf<Image>(PDF renderer, not HTML)
- Recommended fix:
- Where sync state writes happen in cleanup, move them to
useLayoutEffect(mounted → unmount) or compute from props instead. - Add
// eslint-disable-next-line jsx-a11y/alt-texton the two PDF<Image>lines or scope the rule to.tsxfiles matching**/page.tsxonly. - Replace
anyannotations withunknown+ narrow — orasfor genuinely-opaque third-party shapes.
- Where sync state writes happen in cleanup, move them to
- Verification:
npm run lintshows 0 errors, 0 warnings (or warnings only).
[P2] — 6 Vitest failures in production code paths
- Location: see counts
- Severity: P2 — same root causes as P1 #1, #2 (quote/order numbering) and P1 #3 (collection-confirm test). Plus one UI-only test.
- Reproduction:
npx vitest run.Test Files 3 failed | 52 passed (55) Tests 6 failed | 483 passed (489) - Failures:
File Test Root cause src/lib/server/quotes-service.test.tsgenerateQuoteNumberServer > starts the month at 001See P1 #1 src/lib/server/quotes-service.test.tsgenerateQuoteNumberServer > increments on each callSee P1 #1 src/lib/server/quotes-service.test.tsgenerateQuoteNumberServer > produces unique numbers under concurrent calls (no race)See P1 #1 (and possibly also a race) src/lib/server/quotes-service.test.tscreateQuoteFromEmail > creates a new quote, customer, company, items, and attachmentsSee P1 #1 src/lib/server/orders-service.test.tscreateOrderFromQuote > increments the sequence using the latest matching order_numberSee P1 #2 src/app/(app)/collection-pool/page.test.tsxCollectionPoolPage sort control > defaults to operational sort and reorders rows when nearby suppliers is selectedUI: rendered DOM shows rows [Alpha, Charlie]not[Alpha, Bravo, Charlie]— likely the "nearby" sort colocates Bravo/Charlie by shared coords in the test fixture - Recommended fix: the first 5 fall out of the P1 fixes. For the sort test, inspect
src/app/(app)/collection-pool/page.test.tsxfixture and thenearbycomparator atsrc/app/(app)/collection-pool/page.tsx(search fornearby). - Verification:
npx vitest runreports 0 failures.
[P2] — All pages are "use client"; nothing is server-rendered
- Location: every file under
src/app/(app)/**/page.tsx(~24 files),src/app/layout.tsxwrapsAuthProvider(client), andsrc/app/(app)/layout.tsx:1is"use client". - Severity: P2 — performance + SEO + first-paint hit. Dashboard, list pages, PDF view all hydrate fresh in the browser.
- Recommended fix: the (app)/layout auth check should move to a server component that reads the PB auth cookie via
await cookies()(per Next 16 docs,01-app/.../cookies.md), letting list pages render server-side. The<Sidebar>and provider wrappers stay client-side. - Verification: Lighthouse mobile run on
/dashboardand/quotesshows improved LCP and a smaller initial JS payload.
[P2] — og-image.png and og-square.png are too large for social cards
- Location:
public/og-image.png(732K),public/og-square.png(1.1M),public/twitter-image.png(772K). - Severity: P2 — page weight + slow social-share unfurls.
og-imageis referenced on everymetadata.openGraph.imagesso it's effectively eagerly fetched by social previews. - Recommended fix: re-export at ≤ 200K each as JPEG/WebP using the existing
letterheadassets. Next/Imagepriorityis not used here; add apublic/og-square.{webp,jpg}and reference inlayout.tsx:111-119,123. - Verification:
du -sh public/og-*≤ 200K and a manual Slack/Twitter card preview.
[P2] — E2E coverage is narrow vs page surface
- Location:
e2e/(only 5 specs:_helpers,agent-api,auth,health,management). - Severity: P2 — 22+ pages have no E2E coverage. Recent feature work on
invoicing,collection-confirm,collection-map(date filter + unassign),collection-pool(nearby sort),sprint(categories + claims),purchase-orders(PDF tokens + additional attachments), andcompanies-coordinatesis entirely uncovered at the E2E layer. - Recommended fix: add at minimum specs for
invoicing,purchase-orders/{id}/pdf,collection-pool(sort + Mark Ready dialog),sprint/items/{id}/claim. The unit coverage is strong (483/489) but it tests PB services and isolated components, not composed flows. - Verification:
npx playwright test --listshows the new specs and they pass locally.
[P2] — Aggressive PB filter string interpolation
- Location:
src/lib/pb-helpers.ts(multiple sites: lines 437, 577, 947, 1066, 1436, 1439). - Severity: P2 (the dominant flows use it correctly, but the surface is large).
- Reproduction (conceptual): in
await pb.collection("orders").getList(1, 50, { filter:order_number ~ "${search}"}), asearchcontaining"is escaped (\") and newlines/backslashes are not. The resulting filter is well-formed today because every call site uses IDs or short alphanumeric tokens, but the pattern does not survive a refactor. - Root cause:
pb-helpers.ts:1436-1439shows two calls in a row:
NB: PB string literals also treatclauses.push(`category = "${filters.category.replace(/"/g, '\\"')}"`); clauses.push(`brand = "${filters.brand.replace(/"/g, '\\"')}"`);\specially; the escape omits that. The PB native SDK offerspb.filter(expr, params)-style helpers in 0.21+ but the project uses raw strings. - Recommended fix: introduce one internal
pbFilter(field, value, op)helper insrc/lib/pb-helpers.tsthat handles quote/backslash escaping once and test it. - Verification: add a unit test passing
'; --and\\as inputs; the resulting filter string either succeeds (escaped) or throws the expected PB error envelope.
[P2] — lucide-react dependency version looks unusual
- Location:
package.json:58declares"lucide-react": "^1.16.0". - Severity: P2 — verified on npm that lucide-react actually publishes 1.x via the maintainers (1.24.0 is current); package-lock confirms 1.16.0 is the installed version. Not a real bug, but flag for a fresh install — if the upstream version diverges from the lockfile's lockSpec pattern, imports break.
- Recommended fix: in CI,
npm ci(strict lockfile) so an upstream version drift never surprises a build. Addengines.npmif not present. - Verification:
npm cion a fresh checkout succeeds.
[P2] — Destructive "wipe" in scripts/setup-pocketbase.ts
(See also P1 #6 — that's the same script; the duplication is intentional here to call out the operator UX angle separately for triaging.) Already covered.
[P3] — Loose PNG/screenshot files in the repo root
- Location: repo root:
Screenshot 2026-07-09 at 01.12.32.png(80K),bulk_modal.png(177K),invoicing-*.png× 6 (~800K total),page-overview.png(101K),single_modal.png(183K). - Severity: P3 — repo bloat, accidentally-committed investigation screenshots. Not referenced by code.
- Recommended fix: move to
docs/_screenshots/or.gitignorepatterns likeScreenshot *.png,bulk_modal.png,single_modal.png,invoicing*.png,page-overview.png. - Verification:
git ls-files | grep -E '\.(png|jpg)$' | grep -v public/returns nothing.
[P3] — Stray large JPG inside scripts/
- Location:
scripts/mrbxnim4-telegram-cloud-photo-size-4-6016860144018328845-y.jpg(52K) - Severity: P3 — looks like a Telegram-saved reference photo that escaped review.
- Recommended fix: move to
docs/_references/or delete.
[P3] — Loose ESM debug scripts at scripts/dev/
- Location:
scripts/dev/debug-*.{ts,tsx}(12 files). - Severity: P3 — pulled into ESLint scope (driving the "50 errors" count up). Code in them is incidental.
- Recommended fix: move to
scripts/dev/_archive/or addscripts/dev/**toeslint.config.mjsglobalIgnores. Either is fine — naming should signal "operator-only, not shipped". - Verification:
npm run lintcount drops; nothing in production import-path references them.
[P3] — tsconfig.json excludes scripts/** but ESLint scans them
- Location:
tsconfig.json:42vseslint.config.mjs:9-15. - Severity: P3 — symptom, not cause. Many of the 50 lint errors live in scripts that aren't type-checked. Either fix the scripts or exempt them.
- Recommended fix: add
scripts/**to the ESLintglobalIgnores.
[P3] — Modal escape-on-backdrop-click behaviour
- Location: Base UI
Dialog(insrc/components/ui/dialog.tsx) doesn't ship escape-on-backdrop-click out of the box on this version's setting; custom escape handler inquote-pdf-viewer.tsx:90-97only. - Severity: P3 — minor UX inconsistency. Verify by clicking outside the dialog or pressing
Esc. - Recommended fix: confirm with a manual smoke pass and standardise on the Base UI default.
[P3] — ThemeBootstrapScript uses dangerouslySetInnerHTML
- Location:
src/components/theme-bootstrap.tsx(script withdangerouslySetInnerHTML={{__html: code}}) - Severity: P3 — acceptable because
codeis a literal authored string in the same file (no remote content), but flag for future readers.
What's Working Well ✅
- Test scaffolding is healthy. 483/489 unit tests pass; failures are concentrated in 3 files and tied to the P1 findings (not flaky code).
- Next.js 16 async params are used consistently in every
[id]/route.tsand[id]/page.tsx. - shadcn/ui base-nova + Base UI primitives are used consistently across all 14 base components (
button,dialog,dropdown-menu,input,popover,scroll-area,select,separator,sheet,switch,tabs,avatar,badge,checkbox). Zero Radix insrc/components/ui/. - Heavy API routes declare
runtime = "nodejs"+dynamic = "force-dynamic"correctly (/api/v1/quotes/[id]/pdf,/purchase-orders/[id]/dispatch,/purchase-orders/[id]/send-email, etc.). - PB filter fallback pattern for the legacy vs template schema is in place (
pb-helpers.ts:228-260, "Try the new filter first; fall back to legacy"). - File proxy at
/api/v1/files/[collection]/[recordId]/[filename]has an explicit whitelist, inline-vs-attachment disposition, and cache headers (no-cache). - Theme bootstrap script prevents FOUC via
suppressHydrationWarning+ pre-paint script. .gitignorecorrectly excludes.env*,.mcp.json,test-results/,.next/,tsbuildinfo.- No
TODO/FIXME/XXX/HACKmarkers anywhere undersrc/orscripts/. - PWA scaffold present (
/manifest.json,service-worker-registration,/sw.js). - Standalone Next.js output in
next.config.ts:4— matches the Dockerfile multi-stage build. allowedDevOriginsincludes192.168.1.122,192.168.1.128— operator explicitly accommodated LAN phone testing; correct.
Recommendations
- Adopt a
react-dom/test-utilslint baseline forreact-hooks/set-state-in-effect. The 27set-state-in-effectviolations are a real React 19 smell. Convert the cleanest 3-5 first (order-form.tsx,quote-search.tsx,supplier-map.tsx'sfetchSuppliers()) to auseEffect(() => { let cancelled = false; ... }, [])pattern with derived state viauseStateinitialiser; lint baseline should land at zero (or scoped-down per directory). - Move auth gating server-side. Replace the
(app)/layout.tsxclient redirect (src/app/(app)/layout.tsx:12-16) with a server component that reads the PB auth cookie viaawait cookies(). This unlocks server-side data fetching for the 24use clientpages — biggest single perf win available. - Atomic counter for orders (mirroring
quote_counters). Even if the month-rollover logic is intentionally fixed to usenow-month only, two simultaneouscreateOrderFromQuotecalls still race. Addscripts/migrations/2026-07-10-create-order-counters.ts. - DOMPurify swap-in for the email sanitizer. Use
isomorphic-dompurify; same ALLOWED_TAGS/ALLOWED_ATTRS. Add 2 vitest cases coveringjavascript:and:payloads. - Convert tests under
src/lib/server/quotes-service.test.tsandorders-service.test.tsto a CI gate. Add alint:fixscript that auto-applies the 29 fixable warnings and gates the build. - Add E2E coverage for invoicing and PO PDF dispatch flows. The new PDF-token + attachment work has unit coverage but no composed-flow coverage; one Playwright spec per flow closes the gap.
Next Steps
Tackle in this order:
- Fix
formatQuoteNumber— 1 line, unblocks 4 tests + downstream n8n.- Fix
buildConfirmNotetest (or type) — 1 line, unblockstsc --noEmit.- Fix
createOrderFromQuotemonth derivation — clarify intent with the team (extend latest vs. reset-at-month-boundary), pin a test, ship.- Move auth gating server-side — biggest perf win, broad but mechanical.
- DOMPurify — small, immediate XSS risk reduction.
- Lint zero — schedule a 1-2 hour pass to land
react-hooks/set-state-in-effect+no-explicit-anycleanups.- E2E gap-fill for invoicing + PO dispatch.
Want me to:
- Generate the fixes for the top 3 P1s?
- Open follow-up issues for the lint + e2e gaps?
- Run a security audit pass (XSS, CSRF, rate limiting)?