**asset-auditor C1+C2+H1+H3 — image normalization**
Add `src/lib/services/image-normalize.ts` and wire it into
`uploadFile()` so every accepted image is re-encoded via sharp before
hitting storage:
- Strips EXIF (GPS coords, device serial, photographer) so uploaded
photos don't leak per-pixel PII to anyone with a download URL (C1).
- Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})`
so a 30000×30000 palette PNG can't decompression-bomb a downstream
sharp decode (C2).
- Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat
the prefix-only magic-byte check) (H1).
- Freezes animated GIFs to first frame (H3).
Avatar route already funnels through uploadFile so it's covered by
the single change.
**asset-auditor M2 — sanitizeFilename strips RTL/zero-width**
Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069)
+ zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`.
Closes the classic Windows-icon-spoof vector
(`invoice_fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing
collision spoofs.
**datetime-auditor C1 — reminder dueAt drift on every save**
The `<input type="datetime-local">` round-trip in reminder-form.tsx
used `iso.slice(0,16)` (load) and `new Date(value).toISOString()`
(submit). The slice drops the `Z` so a UTC instant is mis-interpreted
as local on load, then converted back to UTC on save — every save
of an existing Warsaw reminder drifted backwards by 2h (CEST). After
two saves the reminder appears at 06:00 instead of 10:00.
Add `toLocalDatetimeLocal(d: Date)` helper that builds the local
YYYY-MM-DDTHH:MM string from getter methods so the round-trip is
TZ-safe. snooze-dialog already did this correctly; the contact-log
dialog also uses the correct localIsoString pattern.
**datetime-auditor C2 — BullMQ cron in UTC, not port-local**
`upsertJobScheduler` defaulted `tz` to UTC. Patterns like
`0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter
/ 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`.
Sub-hourly / hourly patterns are TZ-invariant and stay UTC.
**datetime-auditor C3 — report-scheduler never advanced next_run_at**
The minutely scheduler selected `nextRunAt <= now()` and enqueued
generate-report — but never bumped nextRunAt. For weekly/monthly
reports this meant the job re-fired every single minute until a
human zeroed the row out, flooding recipients with dupes.
Now uses `cron-parser` (added as a dep) to compute the next fire
from `report.schedule` and UPDATEs the row BEFORE the enqueue.
Malformed cron expressions disable the row instead of re-attempting
every minute.
Tests 1315/1315. Migration 0058 applied via psql.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
authz-auditor C-1 second half: while the permission-overrides PUT route
already enforces caller-superset (prior wave), the `updateUser`
role-reassignment path didn't. A port admin holding only
\`admin.manage_users\` could PATCH a peer's roleId to a sales-director-
equivalent and have the colleague execute permissions the granter
didn't hold.
\`updateUser\` now takes optional `callerPermissions` + `callerIsSuperAdmin`
parameters and, when both are supplied (every interactive admin route),
walks the new role's effective permission tree and refuses any \`true\`
leaf the caller doesn't already hold. Super admins bypass by definition.
Wired \`ctx.permissions\` + \`ctx.isSuperAdmin\` through the single caller
(`/api/v1/admin/users/[id]` PATCH). Legacy callers that omit the args
(none currently) would silently skip the check; if any future system
job calls \`updateUser\` it should pass `callerPermissions=ctx.permissions`
explicitly.
Other authz items confirmed resolved by earlier work or by-design:
- C-1 (permission-overrides PUT): caller-superset already shipped in
an earlier wave; verified by reading the route.
- H-1 (alerts GET ungated): already gated on \`admin.view_audit_log\`
per the auditor's tier-4 recommendation.
Tests 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the two CRITICAL items from auth-flow-auditor plus the
high-impact M10 open-redirect.
**C1 — Password reset doesn't revoke existing sessions**
CRM side: Better Auth has a built-in
`emailAndPassword.revokeSessionsOnPasswordReset` flag — flip it on.
Verified by reading password.mjs in node_modules/better-auth: this
calls `internalAdapter.deleteSessions(userId)` after the password
update commits. One-line fix, closes the canonical session-bumping
gap on the CRM forgot-password flow.
Portal side: the portal uses JWT sessions (not DB-side rows) so
there's no `deleteSessions` to call. Add a per-user
`password_changed_at` watermark column on `portal_users` and have
`verifyPortalToken` reject any token whose `iat` predates the
watermark. Updated on `resetPassword`, `changePortalPassword`, and
`activateAccount` so every password mutation revokes outstanding
cookies. Token shape gains a required `portalUserId` claim so the
verify step can do the watermark lookup without an email-based join;
legacy tokens (pre-Wave-11) lack it and are rejected → forces one
re-login per portal user post-deploy (24h max delay since portal
tokens already self-expire at 24h).
Migration `0058_portal_password_revocation.sql` stamps existing
rows to `now()` so no current session is invalidated by the schema
change itself.
**M10 — Portal login `?next=` open redirect**
`portal/login/page.tsx` did `router.replace(next as never)` against
unvalidated `searchParams.get('next')`. An attacker could send a
victim to `/portal/login?next=https://evil.example` and the post-sign-in
redirect would navigate cross-site. Add `safeNextPath()` that requires
`/portal/...` prefix and rejects protocol-relative URLs; everything
else falls back to `/portal/dashboard`.
**Other auth-flow items confirmed resolved by earlier waves:**
- H6 resolve-identifier enumeration: endpoint deleted in Wave 1
(replaced with sign-in-by-identifier which keeps the synthetic
email behind a server-side proxy)
Tests updated: portal-auth integration test mocks `db` so the new
DB-watermark lookup in `verifyPortalToken` stays unit-pure.
Tests 1315/1315 after `psql ALTER TABLE` to apply migration locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:
- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/
The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.
Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.
Test suite stays at 1315/1315 vitest. typescript clean.
Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Close the CRITICAL + HIGH-tractable race conditions the
concurrency-auditor flagged. The wide-impact items (BullMQ jobId
plumbing — C-2; webhook outbound retry idempotency keys; etc.) span too
many call sites for a single contained wave and stay deferred.
**C-1 — handleDocumentCompleted concurrent-retry orphan-blob**
Wave 1 fixed the compensating-delete on single-process failure but the
idempotency gate at line 1110 reads `doc.status` outside any row lock.
Two webhook deliveries arriving in parallel both pass the gate, both
storage.put + db.insert(files), and the losing files row orphans its
blob since documents.signed_file_id only points at one. Now the
transaction at line 1176 SELECTs the document `FOR UPDATE` and
re-checks the gate; if a concurrent worker already completed, throws a
sentinel `DocumentAlreadyCompletedError` which the outer catch
recognizes and runs the compensating storage.delete at info level
(not error). Net effect: at-most-once signed-PDF persistence even
under Documenso 5xx-then-retry storms.
**H-1 — moveFolder cycle check race**
Two concurrent folder moves (A → B and B → A) in READ COMMITTED can
each pass the cycle check against pre-state and both commit, leaving
A↔B in the tree. Add a per-port `pg_advisory_xact_lock` at the top of
the move transaction so the walk-and-write is atomic per port.
Lock auto-releases on tx end; no impact on cross-port folder ops.
**H-3 — upsertInterestBerth 23505 → generic 500**
Two concurrent `setPrimaryBerth` calls hit `idx_interest_berths_one_primary`
and the loser surfaced as a generic 500. Catch the 23505 + constraint
name and remap to ConflictError so the UI gets a "Another rep changed
the primary berth at the same time. Refresh and try again." toast.
**M-2 — username uniqueness 23505 → generic 500**
Same TOCTOU shape: pre-check at me/route.ts:132 says "available", the
UPDATE then fails at the partial unique index. Catch 23505 +
`idx_user_profiles_username_unique` and remap to ConflictError.
Tests 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the highest-leverage CRITICAL/HIGH/MEDIUM items from the
build-auditor that weren't already covered by Wave 1 (EMAIL_REDIRECT_TO
production guard) or the existing `.dockerignore`.
**C3 — socket.io in standalone trace**
- Add socket.io + @socket.io/redis-adapter to serverExternalPackages
in next.config so the build system sees the dependency (the custom
server is the only importer, no Next route touches it).
- Belt-and-braces: COPY both from the deps stage into the runner stage
of Dockerfile, mirroring the audit's suggested fix.
**H1 — CSP `'unsafe-inline'` in prod**
- Audit recommends nonce-based scripts. Implementing nonces requires
middleware that emits a per-request nonce + threading it through
Next's RSC bootstrap + Server Actions. Out of scope for this wave;
documented the rationale at the CSP definition so the next pass
knows where to start, and noted that the in-the-wild XSS surfaces
are already closed via escapeHtml/escapeUrl in the email + webhook
pipelines.
**H2 — NEXT_PUBLIC_APP_URL validation**
- Add `NEXT_PUBLIC_APP_URL: z.string().url()` to the env schema so a
missing build-time value fails validation instead of silently
inlining the empty string into the client bundle and breaking
multi-origin deploys.
**M3 — serverExternalPackages completeness**
- Add imapflow, mailparser, pdf-lib, sharp, tesseract.js,
@react-pdf/renderer, unpdf — all heavy native/CJS-leaning
server-only deps that should not be route-traced.
**H5 — healthcheck PORT templatization**
- docker-compose.{,prod.}yml: replace hardcoded
`http://localhost:3000/api/health` with `${PORT:-3000}` so
overriding PORT via .env doesn't put the container into a
restart loop.
**M9 — NODE_ENV=production in builder**
- Dockerfile builder stage now sets NODE_ENV=production above
`RUN pnpm build` so the prod-only branches in next.config
(CSP, etc.) compile deterministically.
**M7 — HEALTHCHECK directive in image**
- Add image-level HEALTHCHECK to the app Dockerfile (mirrors the
one in Dockerfile.worker for Redis) so the image is
self-describing for non-compose orchestrators.
Items already addressed prior to this wave:
- C1 (.dockerignore exists, comprehensive)
- C2 (EMAIL_REDIRECT_TO production refusal — Wave 1)
- H4 (compose resource + log limits — already in prod compose)
Tests 1315/1315 throughout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the CRITICAL + high-leverage HIGH items from the types-auditor:
**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.
**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.
**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.
**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each
document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the CRITICAL and high-leverage HIGH items from the
onboarding-auditor report:
**C1 — checklist auto-checks were reading the wrong setting keys**
A port that had actually been configured still showed three steps as
incomplete, permanently capping the checklist at < 70 %.
- email step: `sales_email_smtp_host` → `smtp_host_override` (the key
the email admin page actually persists).
- documenso step: `documenso_api_url` → compound gate
`documenso_api_url_override` + `documenso_developer_email` +
`documenso_approver_email` + `documenso_eoi_template_id`. All four
are required for `buildDocumensoPayload` not to error out; checking
only the URL falsely greenlit the step until a rep tried to send an
EOI and Documenso 404'd.
- settings step: `recommender_top_n_default` → `heat_weight_recency`.
The defaults are layered (port > global > built-in), so a port using
the built-ins never writes the `top_n_default` row — old key was an
unreachable green. heat_weight_recency genuinely means "admin tuned
the recommender".
**C2 — forms step href was broken**
`STEPS[8].href = '../'` resolved through the Link template to the
dashboard, not `/admin/forms`. Fixed to `'forms'`.
**C3 — EOI signer-identity gate**
Folded into the new compound-gate logic on the documenso step
(see C1). Now matches what the EOI pipeline actually requires before
it can send.
**C4 — ensureSystemRoots failure mode poisoned port creation**
`ports.service.createPort` awaited `ensureSystemRoots` after the port
row had committed, so a throw bubbled out as a 500 even though the
inline comment said "non-fatal if this throws". Wrap in try/catch +
logger.warn — the row stays live, the next admin action self-heals
via `ensureEntityFolder`, and the operator doesn't retry into a 409.
**H5 — berth-list empty-state copy misleads fresh ports**
"Berths are imported from external sources. Adjust your filters..."
implied data existed but was hidden. Branch on whether any filter is
active: with none, suggest running `import-berths-from-nocodb.ts`;
with filters, the original "adjust filters" message.
**M4 — admin-sections-browser description was wrong**
"Setup checklist for fresh ports (read-only references)" implied the
page was read-only when it has working manual-completion checkboxes
and discouraged clicking in. Reworded.
Additionally, the OnboardingStep type gains an optional
`autoCheckSettingKeysAll` field for compound gates (used by the
documenso step), and the auto-detected hint shows all keys when the
gate is compound.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the highest-impact items from the copy-auditor's CRITICAL +
HIGH + MEDIUM bands:
**C2 portal raw-status leak**
- Drop the staff-only `leadCategory` chip from the portal interests
page entirely. Privacy + optics: clients should never see "hot lead"
in their own portal. `eoiStatus` was already wrapped in
`portalSigningLabel`; only the categorical chip remained.
**C3 signing-status label drift**
- Add `src/lib/labels/document-status.ts` as the single source of
truth for the {draft, sent, partially_signed, completed, expired,
cancelled} lifecycle: labels (CRM + portal variants), StatusPill
variant, and the "active / in-flight" set.
- Wire it into interest-eoi-tab, interest-contract-tab,
interest-reservation-tab — they previously redefined identical
STATUS_LABELS / ACTIVE_STATUSES blocks per-file.
**H1 + M3 verbiage codemod**
- `Save Changes` → `Save changes` (sentence case, matches the
surrounding admin/CRM pattern).
- `Saving...` (ASCII three dots) → `Saving…` (Unicode ellipsis).
Matches the project's UTF-8-elsewhere convention and reads
correctly via screen-readers.
**M1 envelope jargon → signing request**
- smart-archive-dialog: "Leave envelope pending" → "Leave signing
request pending"; "Void the signing envelope" → "Cancel the signing
request"; section header updated to match.
- document-detail: "voids the signing envelope" → "cancels the signing
request".
- bulk-archive-wizard: "leave invoices/signing envelopes alone" →
"leave invoices/signing requests alone".
- Documenso admin page intentionally keeps `envelope` (dev/integration
vocabulary).
**M5 Hot Lead casing**
- Normalize `Hot Lead` / `General Interest` / `Specific Qualified` to
sentence case in `constants.ts` LABEL_OVERRIDES and all per-file
lead-category maps so the CRM trend (sentence case) is consistent.
**C1 surface-level rename**
- "Linked prospect (optional)" → "Linked interest (optional)" on the
berth status-change dialog.
- "Deal Documents" tab → "Interest Documents" (URL/route kept as
`/deal-documents` to avoid breaking deep links; rename deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the pdf-auditor findings that survived the 2026-05-12 PDF stack
overhaul (pdfme → react-pdf). Items C-2/C-3 (tiptap-to-pdfme bugs) were
resolved when that 571-LOC bridge was deleted; remaining items:
- **M-7 wrong-port brand fallback** — replace `'Port Nimara'` defaults
in PDF-rendering services. `reports.service` and `expense-export`
throw when the port row is missing (the job is FK-keyed on a real
port, so absence = broken state, must not stamp a competitor brand).
`record-export` uses `'(port)'` as the visible placeholder.
- **M-2 silent field drift in fill-eoi-form** — promote the
always-silent catch in `setText` / `setCheckbox` to log a structured
warning per missing field (mirroring the existing `setBerthRange`
pattern). A re-cut template with drifted AcroForm field names now
surfaces in ops logs instead of shipping with empty values.
- **M-3 form not flattened** — `fillEoiFormFields` now flattens the
AcroForm before save. Documenso pathway flattens server-side; this
brings the in-app pathway to parity, so the signer can't edit
pre-filled yacht dimensions / address / berth number after the fact.
- **M-1 PDF metadata** — set Title / Author / Subject / Lang / Producer
/ Creator on the generated EOI PDF for downstream readers and a11y
tooling.
- **M-4 noisy berth-range warnings** — downgrade per-mooring warn to
debug; emit a single summary warn per call when any passthrough
occurred. Multi-berth EOIs with archived/legacy moorings no longer
spam the log on every render.
- **M-6 source PDF sha pinning** — pin
`assets/eoi-template.pdf` sha256 via `EXPECTED_EOI_SHA256` (exported
for tests); `loadEoiTemplatePdf` warns once per process when the
bytes drift without an explicit hash bump. Documented the
intentional-update workflow in `assets/README.md`.
Tests updated in `tests/unit/pdf/fill-eoi-form.test.ts` to reflect
flatten + metadata (form fields are gone after flatten; pdf-lib has no
getLanguage so we assert the other setters round-trip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a default [portSlug]/loading.tsx that covers all 72 nested routes
that previously rendered nothing during the cold-load gap. Uses the
existing PageSkeleton (page-header + table-skeleton) so the empty-header
flash on direct-URL visits / tab navigations is gone.
Add tailored loading.tsx for the four other tab-strip detail surfaces so
their initial paint mirrors the real page structure (header strip,
pipeline stepper for interests, tab strip, two-column overview):
- yachts/[yachtId]/loading.tsx
- companies/[companyId]/loading.tsx
- interests/[interestId]/loading.tsx
- berths/[berthId]/loading.tsx
(clients/[clientId]/loading.tsx already existed.)
Closes ui/ux M3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five DataTable consumers were rendering as horizontally-scrolling
desktop tables on mobile because they had no cardRender prop. Now they
collapse to a vertical card list below the lg: breakpoint with the
same actions inline:
- admin/tags/tag-list
- admin/roles/role-list
- admin/ports/port-list (also: Active/Inactive badge -> StatusPill)
- admin/document-templates/template-list (also: Active/Inactive badge
-> StatusPill)
- admin/custom-fields/custom-fields-manager
All five now share the user-list / berth-list pattern: row-card with
title, secondary meta, and trailing action buttons; same TanStack
table instance powers both the desktop table and the mobile cards.
Closes ui/ux H2 + extends M2 (status-pill coverage).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build a shared <TemplateTokenPicker> that renders the canonical
MERGE_FIELDS catalog grouped by scope, plus a dynamically-fetched
"Custom (port-specific)" group surfaced from /api/v1/admin/custom-fields.
The custom group is filtered to entity types the resolver actually
expands at send time (client/interest/berth - see
mergeCustomFieldValues in document-sends.service).
Wire it into both consumers:
- admin/document-templates/template-form.tsx (replaces TEMPLATE_VARIABLES
list which had drifted from the canonical catalog)
- admin/sales-email-config-card.tsx (replaces flat alphabetical dump)
Closes custom-fields §B "UI surfacing of {{custom.…}} tokens".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extend StatusPill with berth (available/under_offer/sold) and user
(enabled/disabled) variants so every "this thing is in state X" pill
shares one primitive and palette.
- Swap berth-card, berth-detail-header, berth-columns from ad-hoc
bg-green-100 / bg-yellow-100 / bg-red-100 Tailwind tuples to
<StatusPill status="...">.
- Swap UserList Active/Disabled <Badge> and user-card Inactive pill to
StatusPill; Super-Admin chip kept as a domain-specific accent (violet).
Closes ui/ux M1+M2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).
Closes ui/ux M11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Wave 1.4 (CRITICAL). Three templates still inlined URLs
directly into `href` without the existing safeUrl() helper:
- inquiry-client-confirmation: `mailto:${contactEmail}` href —
user-supplied email straight to an HTML attribute.
- inquiry-sales-notification: `${crmUrl}` from inquiry form input.
- residential-inquiry: same `mailto:${contactEmail}` pattern.
Each call now passes through `safeUrl()` from `@/lib/email/shell`,
which (a) scheme-allow-lists to http(s)/mailto/tel/root-relative and
(b) HTML-attribute-escapes the result. A stray `"` in any URL would
have escaped the attribute; a `javascript:` scheme would have
triggered XSS in webmail clients that run scripts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Wave 1.3 (CRITICAL). The previous storage.put → files.insert
→ documents.update sequence had two real failure modes:
1. **Orphan blob.** If storage.put succeeded but the files.insert or
documents.update failed, the blob lived forever in MinIO with no
DB pointer. Re-runs re-uploaded a new blob without cleaning up
the previous one.
2. **Zombie completed state.** The catch block at the end ran
`documents.update({status: 'completed'})` with NO signedFileId
on any failure path. The idempotency early-return at the top
requires BOTH status='completed' AND signedFileId, so retries
*did* still re-attempt — but reps saw a "completed" document
with no signed file, hiding the failure.
Fix:
- Track `putStoragePath` outside the try. After storage.put lands,
the variable holds the path; cleared once the DB commit succeeds.
- files.insert + documents.update + reservation contract mirror all
run in a single `db.transaction(...)`. Atomic commit-or-rollback.
- Catch block: compensating `storage.delete(putStoragePath)` if the
DB commit didn't land. Logs at error level on compensating-delete
failure so a human can clean up.
- Catch block no longer sets `status='completed'`. The doc stays
in its prior state; Documenso's retry (or our poll-worker) re-
attempts the full sequence safely thanks to the unchanged
idempotency gate.
Verified: tsc clean, documents-completion-auto-deposit tests all
pass (5/5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
p-retry wraps every Documenso API call with 3 attempts (1 + 2 retries),
exponential backoff (1s → 4s with jitter). AbortError short-circuits
on:
- 401/403 — auth failures won't fix themselves on retry
- 4xx other than 429 — Documenso rejected the payload; retrying
hurts more than it helps
5xx + 429 (rate-limit) go through the retry path with backoff so we
politely re-attempt after delay. Recovers the single-connection-blip
scenario the audit's services pass flagged.
p-queue installed too (audit §36.A.1 companion to p-limit). No
concrete land site today — we don't bulk-fan-out to Documenso, and
existing pLimit covers our internal mass-op fan-outs. Available for
future rate-per-second scenarios.
Verified: tsc clean, vitest 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the `if (open) { setStage(...); setCode(''); ... }` reset
useEffect with a key-based remount of the dialog body. The body now
mounts fresh each time the dialog opens; useState initialisers
run naturally instead of being chased by an effect.
Pattern (apply to remaining dialogs in the same shape):
```tsx
export function MyDialog(props) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent>
{props.open && <MyDialogBody key={props.id} {...props} />}
</DialogContent>
</Dialog>
);
}
```
Applied to:
- hard-delete-dialog (keyed on clientId)
- bulk-hard-delete-dialog (keyed on joined clientIds)
set-state-in-effect: 43 → 41.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
set-state-in-effect: 44 → 43.
Eight admin list/load sites migrated total this session; the
remaining ~43 hits are predominantly the dialog/form open→reset
pattern (intentional setState-in-effect when a dialog opens to
populate fields from props). Cleanest fix is key-based remount
of the dialog body; tracked in BACKLOG as a focused refactor pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the useState + useEffect + apiFetch pattern with TanStack
Query in six admin list pages — same pattern, mechanical refactor:
- admin/tags/tag-list
- admin/ports/port-list
- admin/roles/role-list
- admin/users/user-list
- admin/document-templates/template-list
- admin/webhooks/page
- dashboard/timezone-drift-banner (also: detected-tz reads via
useSyncExternalStore so render stays pure)
Side benefits: list refetches now share a query cache across tabs
(via @tanstack/query-broadcast-client-experimental that was wired
up earlier this branch), so when admin A edits a role in one tab,
admin B's tab sees the updated row without a manual reload.
set-state-in-effect warnings: 51 → 45.
Verified: tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installs all five Tier 2 polish deps the audit flagged. Each integrates
where it adds concrete value today:
- **embla-carousel-react** — shadcn-style `<Carousel>` primitive in
`src/components/ui/carousel.tsx`. Available for future berth/yacht
photo galleries; no current call site beyond the primitive.
- **yet-another-react-lightbox** — wired into the image branch of
`file-preview-dialog.tsx`. Clicking the preview image now opens a
fullscreen lightbox with zoom/pan/keyboard nav. Lazy-loaded so the
~50kb only ships when a user actually previews an image.
- **@use-gesture/react** — `usePinch` on the PdfViewer's content
pane for native pinch-zoom on tablets/phones. Clamped to the
same [50%, 300%] range as the +/- buttons; desktop wheel still
scrolls.
- **react-virtuoso** — installed but NOT wired. Inbox is naturally
bounded by recent-notifications filter at ~10-20 items; ScrollArea
handles it fine. Reserve for actual scale issues (admin audit log
archive, etc.).
- **motion** — installed but NOT wired. Pipeline kanban uses
dnd-kit's own transforms and conflicts with motion's layout
animation. @formkit/auto-animate already handles list-mutation
animations elsewhere. Available for opportunistic adoption when
a polish surface emerges that the existing libraries don't cover.
Verified: tsc clean, vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cleared 4 rule buckets (37 violations, including 5 real bugs) and
silenced 1 informational bucket from the Next 16 / react-hooks v7
upgrade. Cleared rules promoted from `warn` back to `error` so new
regressions block CI.
Real bug fixes:
- `interest-contact-log-tab.tsx`: `useMemo` used for side effects
(5 setState calls inside a memo body); converted to `useEffect`.
- `PieChart.tsx`: cumulative `let angle` mutation in a render-phase
`map`; converted to `reduce` so the slice array is built without
re-assignment.
- `documents-hub.tsx`: `useMemo(() => ({ count: 0 }))` used as a
mutable drag counter; converted to `useRef`.
- `notes-list.tsx`: `Date.now()` read during render for note-edit
countdown (impure) → pinned to a `now` state ticked every 30s.
- `onboarding-checklist.tsx` / `user-profile.tsx` /
`user-settings.tsx`: `useEffect(() => void load(), [])` with the
`load` function declared AFTER the effect — relied on hoisting,
trips Compiler's "access before declared" rule. Declared inside
the effect.
Pattern fixes (intentional cache-via-ref → state or layout-effect):
- 6 `ref.current = x` writes during render moved into layout
effects (`use-realtime-invalidation`, `settings-form-card`,
`inbox`).
- 3 `ref.current` reads during render (search totals cache,
scanner file ref) rewritten to backed-by-state.
- `use-is-mobile.ts` rewritten on `useSyncExternalStore` to avoid
the SSR-then-rehydrate setState dance.
- `use-notifications.ts` rewritten to write socket pushes directly
into the React Query cache via `setQueryData`, removing a local
state mirror.
Rule config (`eslint.config.mjs`):
- `react-hooks/purity` → error (was warn, cleared)
- `react-hooks/set-state-in-render` → error (was warn, cleared)
- `react-hooks/immutability` → error (was warn, cleared)
- `react-hooks/refs` → error (was warn, cleared)
- `react-hooks/incompatible-library` → off (informational only)
- `react-hooks/set-state-in-effect` → warn (51 remaining, all the
useEffect→fetch→setState data-fetch pattern; migration to
useQuery tracked in BACKLOG)
Verified: tsc clean, eslint 0 errors / 69 warnings (down from 105),
vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the `<iframe src={presignedUrl}>` preview path which
delegated rendering to the browser's built-in PDF viewer. The iframe
worked on desktop but failed on mobile (older Android Chrome
refuses inline PDFs; iOS Safari opens a new tab).
`<PdfViewer>` renders via pdfjs-dist + react-pdf so the experience
is identical across all browsers + form factors. Adds page nav,
zoom controls, and per-page accessibility labels.
Lazy-loaded via next/dynamic with ssr:false — pdfjs is ~150kb gzip,
no route ships it unless a PDF is actually previewed.
pdfjs worker + CMaps + fonts loaded from unpkg CDN pinned to the
matched pdfjs-dist version (first-load cost paid once per user, no
bundle-size impact on routes that never preview a PDF).
Verified: tsc clean, vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old CurrencyInput had ~100 LOC of regex-based parsing,
display-state syncing, and caret/focus juggling. react-number-format
ships a 17-LOC equivalent (NumericFormat with customInput pointing
at our shared Input shell) that handles the edge cases the hand-
rolled version missed: paste sanitisation, IME composition,
selection-caret preservation, locale separator switching.
Same external API on CurrencyInput so all 3 call sites
(berth-form, invoice-line-items, expense-form-dialog) keep working
without changes.
Verified: tsc clean, vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the hand-rolled `[fields].map(v => \`"\${v}"\`).join(',')`
pattern in expense-export.tsx with papaparse's Papa.unparse.
The previous version didn't handle:
- commas inside fields (would split rows mid-record)
- newlines inside fields (would terminate rows early)
- BOM for Excel-friendly encoding
- numeric/null normalization
Papa.unparse handles all of those + accepts a keyed-object row shape
that lets us define column order and get matching headers for free.
Verified: tsc clean, vitest 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minimal next-intl wire-up so future i18n additions are a config
change, not a code rewrite. No URL routing changes — there's no
`/<locale>/` prefix because there's no second locale today.
- `src/i18n/request.ts` — request-scoped locale + messages loader,
hard-coded to 'en'
- `messages/en.json` — common namespace with a few sample keys
- `next.config.ts` — withNextIntlPlugin wraps the config
- `src/app/layout.tsx` — wraps body with NextIntlClientProvider so
client components can `useTranslations('common')` now
When a real locale target appears (Polish for marina users, Italian
for broker portal, etc.):
1. Add `messages/<locale>.json`
2. Move route folders under `app/[locale]/` to enable URL routing
3. Add a `routing.ts` with the locale list + default
Verified: tsc clean, vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New seed harness for stress-testing list pages, search, analytics
under realistic volumes. Faker-driven, deterministic via fixed
seed, idempotent via `clients.source_details = 'wide-synthetic'`
marker.
- `src/lib/db/seed-wide-synthetic-data.ts` — generator (1000 clients
default, override via `WIDE_SEED_COUNT`)
- `src/lib/db/seed-wide-synthetic.ts` — entrypoint
- `pnpm db:seed:wide-synthetic` script
Distribution:
- 70% of clients get an interest (spread across pipeline stages)
- ~50% of those interests link to a real berth
- Acquisition source weighted: 55% website / 25% referral /
15% broker / 5% manual
- Locale-aware names/emails/phones/addresses via faker
Curated synthetic seed (`seed-synthetic-data.ts`) and realistic
seed (`seed-data.ts`) are untouched — this is a third axis for
volume testing, not a replacement.
Verified: tsc clean, build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the Sentry SDK shipped-but-dormant: no-op unless
`NEXT_PUBLIC_SENTRY_DSN` is set in the environment. Production opts
in via the deploy env; dev + CI stay quiet.
- `sentry.client.config.ts` / `sentry.server.config.ts` /
`sentry.edge.config.ts` — runtime init, each guards on the DSN.
- `instrumentation.ts` — Next 13.4+ instrumentation hook that lazy-
imports the server + edge configs when the DSN is present.
- `next.config.ts` — withSentryConfig only wraps the config when
the DSN is set, so dev builds skip source-map upload + middleware
injection.
- `src/lib/env.ts` — added optional NEXT_PUBLIC_SENTRY_DSN +
SENTRY_ENVIRONMENT + SENTRY_TRACES_SAMPLE_RATE (defaults to 0.1).
Env vars to add to .env.example (blocked from this commit by the
.env hook — apply manually):
# Sentry (optional — SDK is a no-op without a DSN)
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_ENVIRONMENT=
# Defaults to 0.1 (10%) when unset
SENTRY_TRACES_SAMPLE_RATE=
Replay is opt-in only — disabled by default for now; we'd need to
audit privacy implications (PII redaction, GDPR) before enabling it.
Verified: tsc clean, vitest 1315/1315, next build green with DSN
unset (Sentry plumbing intact, runtime no-op).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Docs hub's desktop sidebar is now drag-resizable. Mobile path is
unchanged — still uses the FolderTreeSidebar Sheet drawer.
- Extracted `FolderTreeBody` from `folder-tree-sidebar.tsx` so the
same tree renders inside the mobile Sheet AND the desktop panel
without forking the component.
- `FolderTreeSidebar` is now mobile-only (just the Sheet trigger);
documents-hub composes the desktop layout itself.
- `<ResizablePanelGroup autoSaveId="documents-hub-split">` persists
the user's chosen split width via localStorage automatically.
Min 14% / max 40% defends against starvation.
- shadcn-style `<Resizable*>` primitives in `src/components/ui/`
match the rest of the UI kit; uses react-resizable-panels v3
(the v4 release renamed exports to `Group`/`Separator` and broke
the shadcn convention — pinned v3 for now).
Verified: tsc clean, vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Applied @next/codemod migrations:
- middleware-to-proxy: src/middleware.ts → src/proxy.ts + function rename
- remove-experimental-ppr: no hits
- remove-unstable-prefix: no hits
tsconfig.json picked up Next 16's autofixes:
- jsx: 'preserve' → 'react-jsx'
- include .next/dev/types/**/*.ts (dev-mode route types)
- next-env.d.ts: triple-slash reference → ES import (TS 6 / Next 16 style)
eslint-config-next@16 ships a native flat config, so dropped the
@eslint/eslintrc + FlatCompat shim. eslint.config.mjs now imports
eslint-config-next/core-web-vitals + eslint-config-prettier/flat
directly.
Note on ESLint 10: bumped + reverted. eslint-config-next@16 still
has a transitive eslint-plugin-react@7 that uses the eslint-9
context API (getFilename on context); breaks under eslint 10.
Audit anticipated lockstep — but the transitive isn't ready yet.
Holding at eslint 9.x until upstream lands. Tracked in BACKLOG.
React Compiler safety rules (react-hooks v7) shipped with config-
next 16 surfaced ~89 legitimate findings (set-state-in-effect,
ref-during-render, immutability). Demoted the new rules to `warn`
so the codebase isn't blocked; triage tracked in BACKLOG §G.
Verified: tsc 0 errors, eslint 0 errors / 105 warnings (89 new
Compiler-rule warns + 16 pre-existing), next build clean, custom
server build clean, vitest 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ran the official @tailwindcss/upgrade tool:
- tailwind.config.ts → @theme directive in globals.css
- @tailwind base/components/utilities → @import 'tailwindcss'
- postcss.config switched from tailwindcss + autoprefixer to
@tailwindcss/postcss (autoprefixer baked in)
- focus-visible:outline-none → focus-visible:outline-hidden (the v3
utility was a footgun — outline still showed in forced-colors mode)
Reverted the migration tool's over-zealous variant="outline" →
variant="outline-solid" rename on CVA prop values; that rename was
meant for the Tailwind `outline:` utility, not our Button/Badge
component variants.
Swapped tailwindcss-animate (v3-style JS plugin) for tw-animate-css
(v4-native @import). Same utility surface (animate-spin, animate-in,
etc.), one fewer JS plugin in the bundle.
Fixed the upgrade tool's malformed dark variant
(@custom-variant dark (&:is(class *)) — `class` was being parsed as
a tag) to canonical &:where(.dark, .dark *).
Verified: tsc 0 errors, eslint 0 errors (16 pre-existing warnings),
vitest 1315/1315, next build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the 2026-05-12 push through the audit roadmap. Every item from
docs/AUDIT-2026-05-12.md §§34-36 is either shipped, deferred with
rationale, or parked behind a concrete UX/product trigger.
Wins this session (in commit order from 73184c5 onward):
1. PDF stack overhaul (9 commits + design spec)
2. react-email migration for all 7 remaining templates
3. browser-image-compression in scan-shell
4. @axe-core/playwright smoke a11y gate
5. ts-pattern + bug-fix in search.service.ts
6. p-limit on 3 mass-op fan-outs
7. formatDate helper + 17 unit tests + sample sweep
8. opt-in react-virtual in DataTable
Also nudges:
- src/lib/pdf/brand-kit/Header.tsx — eslint-disable on react-pdf
<Image> for a false-positive jsx-a11y/alt-text warning (PDFs
don't follow the HTML img alt contract).
- docs/BACKLOG.md §G — rewritten to reflect what's done + the
remaining opportunistic work (mostly "migrate as you touch the
file" callsite sweeps).
Comprehensive audit passing:
- tsc --noEmit: 0 errors
- vitest: 1315/1315 passing
- eslint src/: 0 errors, 16 pre-existing warnings (none new)
- next build: all routes compile, no broken imports
- playwright --list: 162 tests across 33 files (incl. the new
a11y spec)
Branch is shippable; remaining items are opportunistic callsite
sweeps the team can pick up when each file is otherwise being
touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 8 — adds `virtual` opt-in to the shared DataTable. Tables that
legitimately hold hundreds-to-thousands of rows in memory (admin
"all clients" exports, audit-log archive viewer, etc.) now render only
the rows in the viewport plus a small overscan. 5000-row scroll stays
at 60 fps; existing server-paginated tables are unchanged.
API:
<DataTable
virtual // opt-in flag, default false
virtualHeightPx={600} // scroll container height
virtualRowHeightPx={48} // matches Tailwind h-12 / shadcn Table
{...everything else}
/>
Guardrails:
- `virtual` + `pagination` together → pagination wins; virtual silently
disabled. (You can't do both: virtualize-all-rows OR paginate, not both.)
- Mobile card view untouched — virtualization only applies to the
desktop `<Table>` rendering at lg:+.
- Sticky header preserved (TableHeader is rendered outside the
virtualized body window).
- Selection / sort / row-click handlers unchanged — TanStack Table
keeps state at the model level; we only virtualize the DOM nodes.
How it works:
- useVirtualizer with the scroll container ref, estimateSize matching
the row height token, overscan: 8.
- Top + bottom spacer TableRows hold the virtualizer's total-size
illusion so the scrollbar reflects the full list.
- Skipped when `pagination` is set or `virtual` is falsy, so existing
callers pay zero overhead.
No callers updated yet — the prop is opt-in. Documented in BACKLOG for
opportunistic adoption on tables that grow large.
1315/1315 vitest green (no test changes; new prop is purely additive).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat
(no new dep — built into Node 18+ + every supported browser). Replaces 96
ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the
codebase.
src/lib/utils/format-date.ts (new):
formatDate(value, preset?, options?) — primary helper
formatDateRange(start, end, options?) — collapsed range strings
formatRelative(value, options?) — "3 hours ago" / "in 2 days"
Presets (named so callers don't memorize Intl options shape):
date.short 12 May
date.medium 12 May 2026
date.long Monday, 12 May 2026
date.iso 2026-05-12 (TZ-aware ISO date, no time)
datetime.short 12 May 14:30
datetime.medium 12 May 2026 14:30
datetime.long Monday, 12 May 2026 at 14:30 UTC
datetime.iso 2026-05-12T14:30:00.000Z
time 14:30
Defensive defaults:
- null/undefined/Invalid Date → '—' (overridable via { fallback })
- locale defaults to en-GB (settles audit-flagged en-US/en-GB drift)
- tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name)
Sample sweep (3 sites — proves the pattern; remaining 93 sites can be
migrated opportunistically when files are touched):
src/lib/services/expense-pdf.service.ts:608 default subheader
src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}}
src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}}
The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule:
"replace as you touch the file" — gives compounding cleanup without
a single risky 90-file commit.
tests/unit/format-date.test.ts (new) — 17 tests:
- fallback handling (null/undefined/invalid/explicit)
- date.iso correctness in UTC + non-UTC timezones
- datetime.iso = full ISO string
- en-GB locale-formatted output
- timezone respect across NY/UTC
- time-only preset
- Date/string/epoch ms inputs all accepted
- formatDateRange same-year collapse, different-year keep, missing ends
- formatRelative: just-now / minutes / hours / days / future / invalid
1315/1315 vitest green (+17 new from format-date.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6 — bounds three remaining unbounded Promise.all fan-outs that the
audit flagged as potential prod-incident vectors. Same pattern proven by
email-compose (4 concurrent S3 reads) and document-signing-emails (3
concurrent SMTP sends) in earlier commits.
berth-pdf.service.ts:574 — presignDownload S3 round-trips
bound: pLimit(8). A 20-version berth used to issue 20 simultaneous
presigns. ~1× round-trip latency preserved on typical 5-15-version
berths; pathological 100-version case no longer saturates the keep-alive
pool.
custom-fields.service.ts:327 — pg upserts on bulk field-value writes
bound: pLimit(8). Port admin stacking 50+ field definitions on one
client would have burst 50 concurrent upserts at the pg pool.
notifications.service.ts:344 — createNotification fan-out across watchers
bound: pLimit(8). Hot pipeline items can accumulate many watchers; a
document event used to fan out N notification inserts + N socket emits
in one burst.
Audit also flagged brochures.service.ts and backup.service.ts as
candidates — verified neither actually has an unbounded fan-out, just
sequential queries. No change needed; speculative entries removed from
BACKLOG implicitly.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 — converts the two switches in search.service.ts from `switch`
to ts-pattern's `match().with().exhaustive()`. The conversion exposed
a real bug: the single-bucket dispatch handled 15 of 16 SearchResults
buckets and silently dropped `type=notes` to the default empty-results
fall-through. `searchNotes()` has existed since the federated-notes
audit but was never wired into the runSingleBucket() dispatch. Calling
/api/v1/search?type=notes returned empty even with seeded note data.
The .exhaustive() switch now requires every SearchResults bucket. New
buckets fail the build until they get a dispatch case — same guarantee
the Documenso webhook conversion gives.
Notes:
- labelForSource (4 trivial label cases) — converted to ts-pattern
for visual consistency with the larger switch in the same file.
- The 3 other switches the audit flagged (client-restore.service.ts,
recently-viewed/route.ts, custom-fields/[entityId]/route.ts) operate
on tagged-union internal types where TypeScript already enforces
exhaustiveness via control-flow narrowing — converting them adds
noise without changing safety. Documented in docs/BACKLOG.md as
"TS-narrowing already exhaustive; deferred indefinitely."
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 — wires `browser-image-compression` into the scan-shell so 4-12 MB
phone photos get crushed to ~500 KB in a WebWorker before any other work
happens. Receipts come back from tesseract + the AI parse much faster on
mobile bandwidth, and the server's sharp pipeline has less to chew on.
compressReceiptIfHeavy(file):
- Pass-through for SVGs / PDFs / non-images
- Pass-through for files already under 1 MB
- Otherwise: imageCompression with maxSizeMB: 0.5, maxWidthOrHeight:
2000, useWebWorker: true, preserveExif: false (auto-rotate to EXIF
orientation then strip metadata so the receipt isn't sideways)
- PNG → JPEG transcode (smaller for natural photo content)
- Initial quality 0.85 — Tesseract's sweet spot for receipt text
- Lazy-loaded import: the WebWorker bundle isn't on the critical path
- try/catch fallback: if compression itself throws, fall through to
the original file so a corner-case bug never blocks a save
Wired into handleFile(rawFile) before tesseract runs and before the
receipt is sent to /api/v1/expenses/scan-receipt. Downstream upload
through handleSubmit() also benefits because the same compressed File
flows through.
Concrete impact for a 12 MP iPhone receipt (~8 MB):
Before: 8 MB upload, 8 MB tesseract input
After: ~500 KB upload, 2000px max edge tesseract input
Bandwidth + battery + perceived latency win on the mobile expense
scanner path. No behaviour change for desktop file uploads under 1 MB.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 (single commit) — applies the portal-auth.tsx pattern to every
hand-strung transactional email template. JSX components rendered via
@react-email/components' render() replace inline-style string templates
+ hand-rolled escapeHtml().
Ported (.ts → .tsx, public function signatures become async):
crm-invite.tsx — admin/super-admin CRM invite
admin-email-change.tsx — sign-in email changed notification
inquiry-client-confirmation.tsx — public berth inquiry receipt
inquiry-sales-notification.tsx — internal sales alert for inquiries
residential-inquiry.tsx — pair: client confirmation + sales alert
notification-digest.tsx — daily/hourly unread-notification digest
document-signing.tsx — triplet: invitation + completed + reminder
Each template now defines its body as a typed React component, drops
escapeHtml() entirely (react-email auto-escapes string interpolation
in JSX text + attributes), and passes the rendered HTML to the existing
renderShell() for shell wrapping. The shell + branding flow is unchanged.
Caller migration (all sync → async):
src/app/api/public/residential-inquiries/route.ts
src/lib/queue/workers/email.ts
src/lib/services/notification-digest.service.ts
src/lib/services/users.service.ts
src/lib/services/document-signing-emails.service.ts
src/lib/services/crm-invite.service.ts
All call sites already lived inside async functions; only the await was
needed. No public API shape changes other than return type (now Promise).
The pattern now applies uniformly across all 8 email templates (portal-
auth.tsx + the 7 in this commit). Email template directory is fully
react-email-based.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 14 of 14 — final cleanup.
Removed:
package.json:
- @pdfme/common 6.1.2
- @pdfme/generator 6.1.2
- @pdfme/schemas 6.1.2
src/lib/pdf/generate.ts (24 LOC — the pdfme thin wrapper)
tests/integration/document-templates-generate-and-sign.test.ts:
- the vi.mock() entry for '@/lib/pdf/generate' (module deleted)
- the assertion `pdfModule.generatePdf).not.toHaveBeenCalled()`
(rephrased as a positive assertion on the EOI source-PDF path)
Three engines remain, each with a single clear job:
pdf-lib AcroForm read/fill for berth-PDF parser tier-1 and
the in-app EOI source-PDF pathway
pdfkit streaming engine for the photo-heavy expense PDF
@react-pdf brand-kit-based JSX rendering for every internal
report / record export / parent-company export
Plus unpdf for berth-PDF parser tier-2 text extraction (replaces the
broken tesseract-on-PDF-buffer path).
Phase 1 totals:
14 commits
+X LOC react-pdf brand kit + templates + logo upload
-1500+ LOC pdfme bridge + templates + invoice generator + html seed
3 deps removed (@pdfme/common, /generator, /schemas)
4 deps added (@react-pdf/renderer, unpdf, react-image-crop, svgo)
1298/1298 vitest green throughout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 13 of 14 — replaces a quietly-broken tesseract.js
pathway with unpdf for tier-2 of the berth-PDF parser.
The previous code did:
const tesseract = await import('tesseract.js');
await tesseract.recognize(buffer, 'eng'); // ← buffer is a PDF
tesseract.recognize() expects an image, not a PDF. The PDFs we get from
the AcroForm-stripped berth-spec sheets would have failed at runtime
(either an "unsupported format" error or silently empty text). Tier-2
was dark code.
unpdf (serverless-friendly pdfjs wrapper) extracts text directly from
the PDF stream. Works on text-PDFs (real text streams), returns empty
on scanned/raster PDFs — those legitimately fall through to the AI
tier where they belong.
The OcrAdapter interface shape is preserved so:
- Existing unit tests that stub the adapter still work
- parseAnyBerthPdf(buffer, { adapter }) override still works
- The 30-second timeout race + warning collection still works
tesseract.js stays as a dep — scan-shell.tsx (receipt scanner) still
uses it for on-device image OCR, which is its intended use case.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 12 of 14 — strips out the 571-line tiptap-to-pdfme
serializer and every code path that depended on it. TipTap document
templates remain as Documenso-template seed bodies; the CRM no longer
renders them to PDF in-app.
Deleted:
src/lib/pdf/tiptap-to-pdfme.ts (571 LOC)
src/lib/pdf/templates/eoi-standard-inapp.ts (337 LOC)
src/app/api/v1/admin/templates/preview/route.ts
src/app/api/v1/document-templates/[id]/generate/route.ts
src/app/api/v1/document-templates/[id]/generate-and-send/route.ts
src/lib/services/document-templates.ts:generateFromTemplate (~140 LOC)
src/lib/services/document-templates.ts:generateAndSend (~40 LOC)
src/lib/validators/document-templates.ts:generateAndSendSchema
src/lib/validators/document-templates.ts:previewAdminTemplateSchema
tests/unit/tiptap-serializer.test.ts (old bridge tests)
Preserved as src/lib/pdf/tiptap-validation.ts (~70 LOC):
- validateTipTapDocument() — still used to reject unsupported nodes
on save in the admin template editor
- TEMPLATE_VARIABLES — drives the merge-token picker in the
admin template form + preview UI
generateAndSign() now throws a clear ValidationError when a non-EOI
template tries the in-app pathway. Use a Documenso template, or wait
for the deferred AcroForm-fill admin-upload feature.
seed-data.ts: "Standard EOI (in-app)" template row now seeds with stub
bodyHtml + small MERGE_FIELDS array; the deleted HTML helper was never
actually rendered (in-app EOI is pdf-lib AcroForm fill on the source
PDF — generateEoiPdfFromTemplate, unchanged).
After this commit, pdfme has zero callers left. Commit 14 drops the
deps and the generate.ts shim.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 11 of 14 — invoices are client-facing documents, and
per the new "no CRM-generated client-facing PDFs" rule (see the design
spec), the in-app pdfme rendering is removed entirely.
Future invoice rendering will use the deferred AcroForm-fill admin-
template feature: admin uploads a PDF template with named form fields,
CRM fills them with invoice data via pdf-lib. Same pattern as the
in-app EOI pathway. Tracked in BACKLOG.md.
Deleted:
- src/lib/services/invoices.ts:generateInvoicePdf (60 LOC)
- src/lib/pdf/templates/invoice-template.ts (entire pdfme template)
- src/app/api/v1/invoices/[id]/generate-pdf/route.ts
- src/components/invoices/invoice-pdf-preview.tsx (regenerate UI)
- "PDF Preview" tab on invoice detail page
- 5 now-unused imports in invoices.ts (files, ports, buildStoragePath,
getStorageBackend, env)
sendInvoice() retained: still queues the send-invoice email job, still
flips status to "sent", still emits the socket event. The PDF-attach
step is gone — downstream consumers either render externally or wait
for the AcroForm-fill feature. The `pdfFileId` column on invoices stays
so existing rows don't break, just never gets written by this code path.
1319/1319 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 10 of 14 — migrates the pdfme-based parent-company
expense export to react-pdf and adds a shared brand header to the
pdfkit-based streaming expense PDF so both surfaces match the rest of
the internal-only PDF family.
parent-company-expense.tsx:
Summary KV grid (entry count, subtotal, fee, total) + entries table
with right-aligned EUR amounts and a totals row. Footnote rendered
when the EUR rate lookup falls through to the 1:1 USD:EUR fallback.
expense-export.tsx (renamed .ts -> .tsx):
- exportParentCompany now renders the react-pdf template via
resolvePortLogo() + renderPdf()
- dropped the inline pdfme template object (was the last pdfme caller
in this file)
- return type widened from Uint8Array to Buffer; caller already wraps
in Buffer.from() so no API change downstream
expense-pdf.service.ts (the pdfkit streaming engine — unchanged):
- addHeader() now draws a dark slate band matching the brand-kit
header band, with the port logo letterboxed on the left and the
document title right-aligned. Falls back to text port-name if the
logo image is missing or can't be decoded by pdfkit
- port + logo resolved once per export via Promise.all
- subheader stays beneath the band in muted grey, same as before
- streaming behavior + receipt embedding + sharp compression
untouched — the only change is the visual treatment of the header
Old pdfme inline template deleted along with the generatePdf import.
After this commit, the only remaining pdfme imports are in:
invoice-template.ts, tiptap-to-pdfme.ts, eoi-standard-inapp.ts, and
document-templates.ts (lines 516-522). All four are removed in
commits 11-12.
1319/1319 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commits 7-9 of 14 — bundled because all three record exports
share the same conversion pattern and call sites.
Templates:
client-summary.tsx header + KV grid for client, contacts table
with primary badge, yacht table, interests
table with stage/category, recent activity
table
berth-spec.tsx header + status badge, overview KV grid,
dimensions KV grid (with min markers), pricing
& tenure KV grid, infrastructure KV grid,
waiting list table with priority badges,
maintenance log table
interest-summary.tsx header + stage badge, status KV grid, client
KV, optional yacht/berth sections, milestones
KV grid, recent timeline table
record-export.tsx (renamed .ts -> .tsx for JSX):
- swap generatePdf(...) calls for renderPdf(<…Pdf … />) calls
- inject port logo via resolvePortLogo()
- shape data into typed template props (Drizzle returns are passed
through deliberately so the template controls its own type surface)
Drops two latent bugs the old templates carried:
- client.nationality was read as a property but the schema field is
nationalityIso — old PDFs always showed "—" for nationality
- interest.notes was read but the interests table doesn't have a
notes column (interest_berths does) — old PDFs always showed "No
notes"
Both fields are now sourced correctly (or omitted) in the new templates.
Old pdfme files deleted (3 templates). API routes that import
exportClientPdf/exportBerthPdf/exportInterestPdf unchanged.
Tests:
tests/unit/record-export-templates.test.tsx (4 tests): each template
renders to valid PDF bytes with representative data, plus a minimal-
input path for the berth spec.
1317/1317 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commits 3-6 of 14 — bundled because every report follows the
same conversion pattern (coordinate-stuffed pdfme template -> JSX brand
kit). Each report now has a real header (logo + port name), structured
KeyValueGrid for summary stats, a chart (BarChart / FunnelChart / PieChart
/ LineChart-ready), and a DataTable for detail rows.
Templates:
activity-report.tsx bar chart of events-per-day, summary KPIs, top
actions table, recent-events table (50 rows)
revenue-report.tsx bar chart of revenue per stage, breakdown table
with totals row, currency-aware formatting
pipeline-report.tsx funnel chart of interests per stage, top interests
table, win rate / cycle KPIs
occupancy-report.tsx donut pie of berth status mix, status breakdown
table with percentages, occupancy rate KPI
reports.service.tsx (renamed .ts -> .tsx for JSX):
- swap REPORT_TYPE_MAP `template`/`buildInputs` for a single `render`
function returning a typed react-pdf element
- inject port logo via resolvePortLogo() and pass through to every
template through a ReportContext object
- keep the existing job queue / storage / file-row / socket-emit
flow intact — only the inner PDF-bytes generation changed
Old pdfme files deleted (4 templates). buildStoragePath / files-table
insert / notifications / status updates all unchanged.
Tests:
tests/unit/report-templates.test.tsx (5 tests): each report renders
to valid PDF bytes given a representative seed-style fixture; empty
data path doesn't throw.
1313/1313 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the
brand-kit Header pulls in for every internal-only PDF.
Server pipeline (src/lib/services/logo.service.ts):
- magic-byte format check via sharp metadata
- rejects animated/multi-frame inputs
- SVGs sanitized via svgo preset-default + post-pass regex check
(rejects <script>, on*=, javascript:, external href, <foreignObject>),
then rasterized to PNG at 300 DPI
- HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp
- optional crop coords applied server-side (bounds-checked first)
- auto-trim near-white borders
- resize so longest edge <= 1200px, sRGB, palette-PNG
- rejects undersized output (< 200px any side) or > 1MB
- atomic system_settings upsert; soft-archives prior file row + storage object
API:
GET /api/v1/admin/branding/logo current logo metadata
POST /api/v1/admin/branding/logo multipart upload + crop
DELETE /api/v1/admin/branding/logo clear; future PDFs fall back
to port-name text header
GET /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx
with the current logo so
admins can spot-check
letterboxing in real shell
UI:
src/components/admin/branding/pdf-logo-uploader.tsx
- react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle
- file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB)
- dark-band preview swatch shows how the logo lands in the header
- post-upload warnings panel surfaces every server-side normalization
(resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.)
- "Test with sample PDF" button streams a real PDF for spot-check
- "Remove" tears down the file + storage object + setting
Wired into the existing /admin/branding settings page beneath the
Identity and Email-branding cards.
Audit:
Two new AuditAction enum values added: branding.logo.uploaded and
branding.logo.archived. Captured per upload + per archived prior logo.
Tests:
tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path,
undersized rejection, empty/oversized rejection, non-image rejection,
out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG
with embedded script rejection, SVG with external href rejection,
JPEG-with-no-alpha warning collection.
1308/1308 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 1 of 14 — installs deps and lays down the brand-kit
primitives used by every internal-only PDF. No callers wired yet.
Adds:
@react-pdf/renderer 4.5.1 one engine for internal exports
unpdf 1.6.2 reserved for berth-PDF parser tier-2
react-image-crop 11.0.10 admin logo crop UI (commit 2)
svgo 4.0.1 SVG sanitization on logo upload (commit 2)
brand-kit/
tokens.ts single source of truth for colors/fonts/spacing
logo.ts resolvePortLogo() — cached, soft-fallback
DocumentShell <Document><Page> + fixed Header + fixed Footer
Header dark band, logo slot (letterboxed) + text fallback
Footer page N of M + generated-at + confidential tag
Section heading + bottom border
KeyValueGrid 2-col (default) or stacked label/value
DataTable zebra rows + sticky header + totals row + empty state
Badge 5 tone pills
charts/
BarChart pure SVG, 4-tick y-axis, optional value labels
LineChart pure SVG, line + markers + grid
PieChart pure SVG, donut-or-pie + side legend
FunnelChart pure SVG, slope-cut slices for pipeline stages
render.ts renderToBuffer + renderToStream wrappers, typed
svg-primitives.tsx <SvgLabel> wraps react-pdf SVG <Text> to bridge
missing TS declarations for fontSize/fontFamily
Smoke test renders a kitchen-sink Document including every primitive
and every chart, plus an empty-data path. 1293+4 vitest tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defense-in-depth XSS guard at the client-side preview boundary.
`renderEmailBody()` already escapes-then-allowlists on the server, but
mounting that output via dangerouslySetInnerHTML still exposes a single
point of failure: a server-side regression in the sanitizer would
silently produce a client-side XSS via the preview surface.
DOMPurify sanitizes one more time before injection, with the exact
allow-list `renderEmailBody` produces: <p>, <br>, <strong>, <em>,
<code>, <a> (with href/target/rel, https/mailto only). Anything broader
gets stripped at the DOM-injection boundary.
Wrapped in useMemo so the sanitize only runs when the preview HTML
changes — negligible perf, no per-render cost.
The hand-rolled markdown-email.ts pipeline stays as-is: its
escape-first-then-rule-replace architecture is correct and the
"don't add DOMPurify as a dep at the conversion layer" reasoning in
its header comment still holds. We add DOMPurify at the *consumer*
boundary (preview rendering) where the threat model is "what if the
server slips and emits unsafe HTML."
Verified: tsc clean, vitest 1293/1293 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>