Three audit-pass-#3 mobile findings, all in shared primitives so the
fix lands everywhere those primitives are used.
- Input defaults inputMode='decimal' when type='number' and the caller
hasn't overridden. Currency/dimension/price fields across invoices,
expenses, berth specs etc. now show iOS's numeric pad instead of full
QWERTY. Caller can still pass inputMode='numeric' for integer-only
fields.
- DialogContent: padding tightens to p-4 on mobile and restores p-6
at sm+ — the previous fixed p-6 ate ~48px of horizontal width on a
390px iPhone, crushing form-field space. Also adds a max-h-[100dvh]
+ overflow-y-auto so long modal forms scroll inside the dialog
instead of pushing the close button off-screen.
- MoreSheet (mobile bottom-tab "More" drawer): grid-cols-3 cells now
enforce min-h-[88px] so each Apple-HIG-sized 44pt touch target gets
reliable hit area. Icon size bumped from 6 to 7 for visual weight at
the larger cell.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Berth detail page was the last entity using read-only SpecRow widgets
while clients/yachts/companies all use the click-to-edit
InlineEditableField pattern. Marina staffers couldn't update
length/width/draft/price etc without exporting and re-importing.
- New EditableSpec wrapper preserves the SpecRow look + null-hiding
behaviour but defers the value to InlineEditableField with a per-
field PATCH callback.
- useBerthPatch hook hits PATCH /api/v1/berths/{id} (already shipped)
and invalidates the React Query cache for both the list and the
individual berth.
- Numeric helper handles the schema's NUMERIC-as-string convention:
empty input → null, non-numeric → throws, valid → coerced to number.
- 12 fields now editable: lengthFt, widthFt, draftFt, waterDepth,
mooringType, sidePontoon, bowFacing, access, powerCapacity, voltage,
cleatType, cleatCapacity, bollardType, bollardCapacity, price.
- Tags card uses InlineTagEditor instead of read-only badges, matching
the yacht/client pattern. The /api/v1/berths/[id]/tags endpoint was
already in place.
- Dropped the formatDim/formatPower/formatVoltage/price helpers that
inlined the metric column or currency suffix; the editable layout
shows ft/kW/V suffixes inline with the field labels instead. The
metric column is editable separately if needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /api/v1/companies/bulk endpoint shipped in the previous bulk
batch but the UI side was deferred. Mirrors the client-list /
yacht-list pattern: Add tag, Remove tag, Archive bulk actions with
a single TagPicker dialog for tag operations and a window.confirm
for the destructive archive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Grab-bag of UX gaps from audit-pass-#2 + #3. Each one is a small,
focused fix; bundled because they touch different surfaces.
- Popover: collisionPadding={16} + responsive
w-[min(calc(100vw-2rem),18rem)] so popovers stop clipping past the
viewport on iPhone 12 portrait.
- public/manifest.json (was missing) + manifest reference in
layout.tsx — PWA installability now works; icons (192/512/512-
maskable) were already present.
- Admin webhooks page: 4 silent `// ignore` catches in load/delete/
toggle/regenerate replaced with toast.error / toast.success. Users
no longer see a stale list with no feedback when an op fails.
- Portal document-download button: blocking alert() → toast.error().
- src/app/(dashboard)/error.tsx: branded error boundary with retry +
back-to-dashboard, replacing Next.js's default uncaught-error UI.
- GDPR export modal: refetchInterval was a flat 5s while the modal was
open. Switched to a function that only polls (every 15s) when a job
is actually pending/building; settled exports stop polling entirely.
- client-yachts-tab empty state gains a CTA wired to the existing
Add-yacht dialog, instead of just saying "No yachts".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Until now the only bulk action anywhere was Archive on the interests
list — implemented as parallel fan-out with no per-row failure
reporting. The bulk BullMQ worker was a TODO stub with no producers.
- bulk-helpers.runBulk wraps a per-row loop and returns
{results, summary} for the caller. Page-size capped at 100.
- New endpoints: /api/v1/{interests,clients,yachts,companies}/bulk
with a Zod discriminated union over the action. Interests support
change_stage + add_tag + remove_tag + archive; clients/yachts/companies
support archive + add_tag + remove_tag. Each action is permission-gated
individually (delete vs edit vs change_stage).
- interest-list, client-list, yacht-list expose the new actions in the
bulk-action toolbar with dialogs for stage / tag selection. Failure
summaries surface via window.confirm.
- bulkWorker stub gets a docblock explaining the v1 sync-only choice
and what the queue is reserved for (CSV imports, port-wide migrations,
bulk emails to >100 recipients).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the bulk of audit-pass-#1 admin gaps in one batch.
New admin pages:
- /admin/inquiries reads website_submissions with filter chips for
berth/residence/contact + payload viewer per row.
- /admin/sends reads document_sends with sent/failed filter chips and
expandable body markdown; failures surface errorReason and any
fallback-to-link reason from the SMTP retry.
- /admin/email-templates lets per-port admins override the subject of
each transactional template (8 templates catalogued in
template-catalog.ts). Body editing is a follow-on; portal_activation
+ portal_reset are wired to honor the override via loadSubjectOverride.
- /admin/reports replaces the "Coming in Layer 3" placeholder with a
KPI dashboard: 4 KPI tiles, pipeline funnel bars, berth occupancy
donut-bars, conversion %, refresh every 60s.
- backup/import/onboarding admin pages replace placeholders with
actionable guidance: backup posture + planned features, available CLI
imports + planned UI, ordered onboarding checklist linking to admin
pages.
Existing pages widened:
- settings-manager exposes the 9 berth-recommender tunables that were
previously code-only (recommender_*, heat_weight_*, fallthrough_*,
tier_ladder_hide_late_stage).
- role-form covers all 19 RolePermissions schema groups; previously
missing yachts/companies/memberships/reservations + missing
documents.edit + files.edit checkboxes. snake_case residential
labels replaced with friendly text.
portal-auth.service.ts now also writes audit_log rows for portal
invite, resend, activate, password-reset request, and reset (closes one
more audit-pass-#2 gap while we were touching the file).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Until now only the global /admin/audit page surfaced audit_logs. Each
entity detail page either lacked the Activity tab entirely or rendered
"Activity log coming soon" text.
- entity-activity.service.loadEntityActivity wraps searchAuditLogs
with actor-email resolution; reused by all 5 endpoints.
- New endpoints: /api/v1/{clients,yachts,companies,berths,interests}/[id]/activity,
each gated on the per-entity .view permission and tenant-checked
against ctx.portId.
- EntityActivityFeed renders a timeline with action verb ("Updated",
"Archived"), actor name, relative time, and field old→new diff.
- client-tabs, yacht-tabs, company-tabs, berth-tabs now mount the feed
on their Activity tab. Interest already has the richer
InterestTimeline component.
- yacht-tabs YachtInterestsTab also gets a friendlier empty state with
guidance copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The user-menu's Profile link previously 404'd, and CRM users had no way
to change their password from inside the app.
- /api/v1/me/password POST wraps better-auth changePassword, surfaces a
friendlier "Current password is incorrect" on the typical failure
mode, and writes an audit_log row with metadata.revokedOtherSessions.
- /{port}/settings/profile renders display name + email + change-password
card with current/new/confirm fields and a 'Sign out other devices'
toggle.
End-to-end verified: wrong current pw → 400 with mapped message;
correct → 200 + audit row; revert → 200.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final audit polish — closes the remaining LOW + MED items the previous
tiers didn't reach:
* Validation hardening: me.preferences uses .strict() + 8KB cap
instead of unbounded .passthrough(); files.uploadFile gains
magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan
endpoint enforces 10MB cap + magic-byte check on receipt images;
port logoUrl + me.avatarUrl reject javascript:/data: schemes via
a shared httpUrl refinement.
* Permission gates: document-sends/{brochure,berth-pdf} now require
email.send (was withAuth-only); document-sends/{preview,list} on
email.view; ai/email-draft on email.send; documents/[id]/send
uses send_for_signing (was create); expenses/export/parent-company
flips from hard isSuperAdmin to expenses.export for parity;
admin/users/options gated on reminders.assign_others (was withAuth).
* Envelope hygiene: auth/set-password switches the third {message}
variant to errorResponse + {data: {email}}; ai/email-draft wraps
jobId in {data: {jobId}}.
* UI polish: reports-list.handleDownload surfaces failures via
toastError (was console-only).
* Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles +
packageManager field in package.json; Dockerfile.worker re-orders
user creation BEFORE pnpm install so node_modules / .cache dirs
are worker-owned (fixes tesseract.js + sharp EACCES at first PDF
parse); add Redis-ping HEALTHCHECK to the worker container.
* Public health endpoint: returns full env+appUrl payload only when
the caller presents X-Intake-Secret, otherwise a minimal {status}
so generic uptime monitors still work but anonymous internet
doesn't get deployment fingerprints.
* Per-port Documenso webhook secret: new system_settings key
+ listDocumensoWebhookSecrets() helper. The webhook receiver
iterates every configured per-port secret with timing-safe
comparison + falls back to env, then forwards the resolved portId
into handleDocumentExpired so two ports sharing a documensoId
cannot cross-mutate.
Deferred (handled in dedicated follow-up PRs):
* Tier 5.1 — direct service tests for portal-auth / users /
email-accounts / document-sends / sales-email-config. MED, large
test-writing scope.
* The {ok: true} → {data: null} envelope migration across
alerts/expenses/admin-ocr-settings/storage routes. Mechanical but
needs coordinated client + test updates.
* CSP-nonce migration (drop unsafe-inline) — needs middleware-level
nonce generation that the Next 15 router has to thread through.
* Idempotency-Key header on Documenso createDocument. Requires
schema column on documents to persist the key; deferred so it
doesn't bundle a migration into this commit.
* The 16 better-auth user_id FKs — separate dedicated migration
with care (some columns are NOT NULL today and cascade decisions
matter).
* PermissionGate / Skeleton / EmptyState wraps across 5 admin lists
(auditor-H §§36–37) and the residential-clients filter bar.
Test status: 1175/1175 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43
+ HIGH §9 (Documenso secrets follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:
* 50 route files swept (61 sites): manual NextResponse.json({error,
status: 4xx|5xx}) early-returns replaced by typed throws +
errorResponse(err) at the catch.
- Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
helper from src/lib/api/helpers.ts so denials hit the audit log.
- Path-param + body validation 400s become ValidationError throws.
- 404s become NotFoundError or CodedError('NOT_FOUND') for AI
feature-flag paths.
- 11 manual 5xx returns now re-throw so error_events captures the
request-id (the admin error inspector becomes usable from real
incidents).
- website-analytics 200-with-error anti-pattern flipped to 409 +
UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
- 11 sites intentionally preserved: storage/[token] anti-enumeration
token-failure paths, webhook-secret 401, "Unknown port" 400 in
public intake.
* 7 admin forms (roles, users, ports, webhooks, custom-fields,
document-templates, tags) gain a formatErrorBanner() helper from
src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
banner — the rep can copy the request id when reporting a failed
save. Banners get whitespace-pre-line so newlines render.
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:
* 38 client components / 56 toast.error sites converted to
toastError(err) so the new admin error inspector becomes usable from
user-reported issues — every failed inline-edit, save, send, archive,
upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
the existing AppError subclasses. Adds new error codes:
DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
(client-merge "already been merged", expense/interest "couldn't find
that …", documenso "signing service didn't respond").
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the trips/events design discussion: instead of building a full
events domain (table + CRUD UI + calendar) for the 6–12 yacht shows
a year, ship the cheap version that covers the actual asks.
Expenses — `tripLabel` free-text:
- New `expenses.trip_label` text column (migration 0039) + index for
filter / autocomplete lookup.
- Validator: createExpenseShape + listExpensesSchema +
exportExpensePdfSchema.filter all accept tripLabel.
- Service: createExpense + updateExpense persist; listExpenses filters;
new `listTripLabels(portId, search?)` returns distinct values
ordered by most-recent expenseDate so the autocomplete surfaces
recently-used labels first.
- New `GET /api/v1/expenses/trip-labels` endpoint (gated by
expenses.view) backs the autocomplete.
- Form dialog: native `<datalist>` powered by the autocomplete query
so reps don't end up with "Palm Beach 2026" / "palm-beach 2026"
fragmented across two PDF sections.
- Expense list: new "Trip" column (badge) + free-text filter.
- Detail page: trip label rendered alongside Category / Payer.
- PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the
export. Untagged rows fall under "(no trip)".
- Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026".
Interests — event tagging via existing tag system:
- Reps can tag interests with an event tag (e.g. "Palm Beach 2026")
via the existing InlineTagEditor on the detail page; tags are
port-scoped and reusable.
- Interest list now has a TagPicker filter rendered next to the
FilterBar so reps can sort prospects by event attended ("show me
every lead from Palm Beach"). Hidden 'relation'-typed
FilterDefinition for tagIds wires URL round-trip + saved-views
capture without rendering inside the FilterBar.
- FilterBar deserializer now handles `relation` types as comma-joined
arrays on URL load.
Why a free-text trip label and not a trips table:
- 6–12 events/year doesn't justify a domain. The CRUD UI cost would
be most of the engineering, and reps already have the events on
their personal calendars.
- If usage proves demand for per-event ROI dashboards or richer
attribution, promote to a real `trips` table later. Migration
path: trip_label → tripId is a backfill+swap.
Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied
in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037
had `idx_br_interest` colliding with the existing
`berth_recommendations.idx_br_interest`; renamed to
`idx_brr_interest` / `idx_brr_contract_file`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Working through the audit-v2 deferred backlog. Each round was tested
(typecheck + 1168/1168 vitest) before moving on.
Round 1 — DB performance + AI cost visibility:
- Add missing FK indexes Postgres doesn't auto-create on
berth_reservations.{interest_id, contract_file_id},
documents.{file_id, signed_file_id}, document_events.signer_id,
document_templates.source_file_id, form_submissions.{form_template_id,
client_id}, document_sends.{brochure_id, brochure_version_id,
sent_by_user_id}. Without these, RESTRICT-checks on parent delete +
reverse-lookups walk the child tables fully. Migration 0037.
- AI worker now writes one ai_usage_ledger row per OpenAI call so admins
can audit spend per port/user/feature and future per-port budgets have
history to read from. Failure to write is logged-not-thrown so the
user-facing email draft is unaffected.
Round 2 — Boot-time + transport hardening:
- S3 backend verifies the bucket exists at startup (or auto-creates
when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now
surfaces with a clear boot error instead of a vague Minio error
inside the first user-facing request.
- Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx
+ network errors, fail-fast on 4xx. Stops one transient flake from
leaving a document with a partial field set.
- FilesystemBackend logs a structured warn-once at boot when the dev
HMAC fallback is in effect, so two processes started with different
BETTER_AUTH_SECRET values are observable (random 401s on file
downloads otherwise).
- Logger redact paths extended to cover *.headers.{authorization,
cookie}, *.config.headers.authorization, encrypted-credential blobs
(secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso
X-Documenso-Secret header, and 2-level nested forms.
Round 3 — UI feedback + permission gates:
- Storage admin migrate dialog: success toast with row count + error
toast on both dryRun and migrate mutations.
- Invoice detail Send + Record-payment buttons wrapped in
PermissionGate (invoices.send / invoices.record_payment); both
mutations now toast on success/error.
- Admin user list Edit button wrapped in PermissionGate(admin.manage_users).
- Scan-receipt page surfaces an amber warning when OCR fails so reps
know they can fill the form manually instead of staring at a stalled
spinner; the editable form now also opens on scanMutation.isError
/ uploadedFile, not only on success.
- Email threads list now renders skeleton rows during load + shared
EmptyState for the empty case (was a single "Loading…" line).
Round 4 — Service / route correctness:
- documentSends.sent_by_user_id was a free-text NOT NULL column with no
FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row
survives a user being hard-deleted. Migration 0038 with a defensive
null-out for any orphan ids before attaching the constraint.
- Saved-views route: documented why withAuth alone is correct (the
service strictly filters by (portId, userId) — owner-only by design).
- Public-interests audit log: replaced "userId: null as unknown as
string" cast with userId: null; AuditLogParams already accepts null
for system-generated events.
- EOI in-app PDF fill: extracted setBerthRange() that, when the
AcroForm field is missing AND the context has a non-empty range
string, logs a structured warn so the deployment gap (live Documenso
template needs the field) is observable instead of silently dropping
the multi-berth range.
Test status: 1168/1168 vitest. tsc clean. Two new migrations
(0037/0038) need pnpm db:push (or migration apply) on the dev DB.
Deferred-doc updated with the remaining open items (bigger refactors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-domain audit (security, routes, DB, integrations, UI/UX) ran after
the cf37d09 merge. Critical + high-impact items landed here; deferred
medium/low items indexed in docs/audit-final-deferred.md (now organised
into a "Audit-final v2" section).
Security:
- Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived
download URL minted by `presignDownload` for an emailed brochure can no
longer be replayed against the proxy PUT to overwrite the original
storage object. `verifyProxyToken` requires `expectedOp` and rejects
mismatches; legacy tokens missing `op` fail-closed. Regression tests
added.
- Markdown email merge values are now markdown-escaped (`[`, `]`, `(`,
`)`, `*`, `_`, `\`, backticks, braces) before substitution into the
rep-authored body. A malicious value like `[click here](https://evil)`
stored in `client.fullName` no longer survives `escapeHtml` to render
as a real `<a href>` in the outbound email. Phishing-via-merge-field
closed; regression tests added.
- Middleware now performs an Origin/Referer check on
POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of
better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes
exempt as they don't carry the session cookie.
Routes:
- Template management routes were calling `withPermission('documents',
'manage', ...)` — but `documents` doesn't have a `manage` action. The
registry has `document_templates.manage`. Every non-superadmin was
getting 403'd on the seven template endpoints. Fixed across the
/admin/templates surface.
- Custom-fields permission resource is hardcoded to `clients` regardless
of which entity (yacht/company/etc.) the values belong to. Documented
as deferred (requires per-entity routes).
DB:
- documentSends: every parent FK (client_id, interest_id, berth_id,
brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the
audit trail outlasts hard-deletes. The denormalized columns
(recipient_email, document_kind, body_markdown, from_address) were
added precisely for this. Migration 0035.
- Polymorphic discriminators on yachts.current_owner_type and
invoices.billing_entity_type now have CHECK constraints — typos like
`'clients'` vs `'client'` were silently inserting unreachable rows
before. Migration 0036.
Integrations:
- Email attachment resolution (`src/lib/email/index.ts`) was importing
MinIO directly instead of `getStorageBackend()`. Filesystem-backend
deployments would have broken every email-with-attachment send. Now
routes through the pluggable abstraction per CLAUDE.md.
- Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit
`readStatus` or send lowercase, so an event that was the SIGNAL of an
open was being silently dropped. Now treats any recipient on a
DOCUMENT_OPENED event as opened.
UI/UX:
- Expense detail used to render `receiptFileIds` as opaque UUID badges —
reps couldn't view the receipt they uploaded. Now renders an image
thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for
PDFs. Closed the "where's my receipt?" loop in the expense flow.
- Expense detail Edit + Archive buttons now `<PermissionGate>` and the
archive mutation surfaces success/error toasts instead of silent 403s.
- Brochures admin: setDefault/archive/create mutations now have onError
toasts (only onSuccess existed before).
- Removed broken bulk-upload link in scan/page (route doesn't exist;
used a raw `<a>` triggering a full reload to a 404).
Test status: 1168/1168 vitest passing. tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final audit pass on feat/berth-recommender (3 parallel Opus agents)
caught 5 critical and ~12 high-severity findings. All addressed in-branch;
medium/low items deferred to docs/audit-final-deferred.md.
Critical:
- Add filesystem-backend PUT handler at /api/storage/[token] so
presigned uploads stop 405-ing in filesystem mode (every browser-driven
berth-PDF + brochure upload was broken). Same token-verify + replay
protection as GET, plus magic-byte gate when c=application/pdf.
- Forward req.signal into streamExpensePdf so an aborted 1000-receipt
export no longer keeps grinding for minutes.
- Strengthen Content-Disposition filename sanitization: \s matches CR/LF
which would let documentName forge headers; restrict to [\w. -]+ and
add filename* RFC 5987 fallback.
- Lock public berths feed behind an explicit slug allowlist instead of
?portSlug= enumeration.
- Reject cross-port interest_berths upserts (defense-in-depth on top of
the recommender SQL port filter).
High:
- Recommender: width-only feasibility now caps length via L/W ratio so a
200ft berth doesn't surface for a 30ft beam request; total_interest_count
filters out junction rows whose interest is in another port.
- Mooring normalization follow-up migration (0034) catches un-hyphenated
padded forms (A01) the original 0024 WHERE missed.
- Send-out rate limit moved AFTER validation and scoped per-(port, user)
so typos don't burn a slot and a multi-port rep can't be DoS'd by
another tenant.
- Default-brochure path now blocks an archived row from sneaking through
the partial unique index.
- NocoDB import --update-snapshot honoured under --dry-run so reps can
refresh the seed JSON without committing DB writes.
- PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when
expenseIds are passed (was bypassed); flag rate-unavailable rows with
an amber footer instead of silently treating them as 1:1; skip the
USD->EUR chain when source already matches target.
- expense-form-dialog: revokeObjectURL captures the URL in the closure
instead of revoking the still-displayed one; reset upload state on
close.
- scan/page: handleClearReceipt resets in-flight scan/upload mutations;
Save disabled while upload pending.
- updateExpense re-asserts receipt-or-acknowledgement at the merged
row so PATCH can't slip past the create-time refine.
Plus the in-progress receipt upload UI for the expense form dialog
(receipt picker + "I have no receipt" checkbox + warning banner) and
a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration.
Includes the canonical plan doc (referenced in CLAUDE.md), the handoff
prompt, and a deferred-findings index for follow-up issues.
1163/1163 vitest passing. Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two reviewer agents did a second-pass deep audit of the 21-commit
refactor. Eight findings; four fixed here (one was deferred with a
schema comment, three were 🟡 nice-to-haves left for follow-up).
Integration regressions (🟠 high):
- Outbound webhook `interest.berth_linked` now fires from the new
junction-add handler. Was emitting a socket-only event, leaving
external integrations silent post-refactor.
- Two new webhook events `interest.berth_unlinked` and
`interest.berth_link_updated` added to WEBHOOK_EVENTS +
INTERNAL_TO_WEBHOOK_MAP. PATCH and DELETE handlers now dispatch them
alongside the existing socket emits — lifecycle parity restored.
- BerthInterestPulse adds useRealtimeInvalidation for berth-link
events. The query key was berth-scoped while the linked-berths
dialog invalidates interest-scoped keys (no prefix match), so the
pulse went stale. Bridges via the realtime hook now.
Recommender semantic fix (🟠 medium-high):
- aggregates CTE: active_interest_count now filters on
`ib.is_specific_interest = true`, matching the public-map "Under
Offer" derivation. EOI-bundle-only links no longer demote a berth
to Tier C for other reps. Smoke test confirms previously-all-Tier-C
results now correctly classify as Tier A.
- Same CTE: `total_interest_count` uses COUNT(ib.berth_id) instead of
COUNT(*) so a berth with no junction rows reports 0 (not 1 from
the LEFT JOIN's NULL-right-side row). Prevents heat over-counting.
Data integrity (🟠):
- AcroForm tier rejects negative numerics in coerceFieldValue (was
letting through `length_ft="-50"` which would poison the
recommender feasibility filter on apply).
- FilesystemBackend.resolveHmacSecret throws in production when
storage_proxy_hmac_secret_encrypted is null. Dev still derives from
BETTER_AUTH_SECRET for ergonomics; prod must explicitly configure.
- Documented the circular FK between berths.current_pdf_version_id
and berth_pdf_versions.id. Drizzle's `.references()` can't express
the cycle so the schema column is plain text + a comment; the FK
is authoritatively maintained by migration 0030.
Tests still 1163/1163. tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements plan §5.5: a per-interest "Linked berths" panel mounted above the
recommender on the interest detail Overview tab. Each junction row exposes
the role-flag controls reps need to manage the M:M `interest_berths` link
without the legacy single-berth flow.
UI (`src/components/interests/linked-berths-list.tsx`)
* Rows ordered with primary first; mooring number links to /berths/[id], with
area + a status pill (available/under_offer/sold) and a "Primary" chip.
* "Specifically pitching" Switch (writes `is_specific_interest`) with the
consequence text from §1: "This berth will appear as under interest on the
public map" / "This berth is hidden from the public map".
* "Mark in EOI bundle" Switch (writes `is_in_eoi_bundle`).
* "Set as primary" button when the row isn't primary - the existing
`upsertInterestBerth` helper demotes the prior primary in the same tx.
* "Bypass EOI for this berth" with reason textarea, ONLY rendered when the
parent interest's `eoiStatus === 'signed'`. Writes the bypass triple
(`eoi_bypass_reason`, `eoi_bypassed_by` = caller, `eoi_bypassed_at` = now);
also supports clearing.
* Remove-from-interest action gated by a confirmation dialog.
API (`src/app/api/v1/interests/[id]/berths/...`)
* `GET /` - list endpoint returning `listBerthsForInterest` plus the parent
interest's `eoiStatus` in `meta.eoiStatus` so the UI can decide whether to
show the bypass control.
* `PATCH /[berthId]` - partial update of the junction row's flags + bypass
fields. Server-side guard: rejects bypass writes when `eoiStatus !==
'signed'` (defence in depth - never trust the UI to gate this).
* `DELETE /[berthId]` - calls `removeInterestBerth`.
* The existing POST stays unchanged. All routes wrapped with
`withAuth(withPermission('interests', view|edit, ...))`. portId from ctx;
cross-port reads/writes return 404 for enumeration prevention (§14.10).
Service changes (`src/lib/services/interest-berths.service.ts`)
* `upsertInterestBerth` now accepts `eoiBypassReason` (tri-state: omit = no
change, non-empty = record, null = clear) and `eoiBypassedBy`. The bypass
triple moves as a unit, with `eoi_bypassed_at` stamped server-side.
* `listBerthsForInterest` now returns berth detail (area, status, dimensions)
alongside the junction row, typed as `InterestBerthWithDetails`.
Socket: added `interest:berthLinkUpdated` event for live UI refreshes.
Tests: 18 new integration tests in `tests/integration/api/interest-berths.test.ts`
covering happy paths, primary-demotion in same tx, bypass write/clear, the
"requires signed EOI" guard, cross-port 404s, missing-link 404s, empty-body
400, and viewer 403 through the permission gate.
Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7,
§5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for
per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP
config + body templates, and the supporting admin UI.
Migration: 0031_brochures_and_document_sends.sql
Schema additions:
- brochures (port-wide, with isDefault marker + archive)
- brochure_versions (versioned uploads, storageKey per §4.7a)
- document_sends (audit log of every rep-initiated send; failures
captured with failedAt + errorReason). berthPdfVersionId is a plain
text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions
so the two phases stay independent.
§14.7 critical mitigations:
- Body XSS: rep-authored markdown goes through renderEmailBody()
(HTML-escape first, then a tight allowlist of bold/italic/code/link
rules). https:// + mailto: only — javascript:/data: URLs stripped.
Tested against script/img/iframe/svg/onerror polyglots.
- Recipient typo: strict email regex + two-step confirm modal that
shows the exact recipient before send.
- Unresolved merge fields: pre-send dry-run /preview endpoint blocks
submission until findUnresolvedTokens() returns empty.
- SMTP failure: every transport rejection writes a document_sends row
with failedAt + errorReason; UI surfaces the message.
- Hourly per-user rate limit: 50 sends/user/hour via existing
checkRateLimit().
- Size threshold fallback (§11.1): files above
email_attach_threshold_mb (default 15) ship as a 24h signed-URL
download link in the body instead of an attachment. Storage stream
flows directly to nodemailer to avoid buffering 20MB+.
§14.10 critical mitigation:
- SMTP/IMAP passwords encrypted at rest via the existing
EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/
sales-config GET endpoint never returns the decrypted value — only
a *PassIsSet boolean. PATCH treats empty string as "leave unchanged"
and explicit null as "clear", so the masked-placeholder UI round-
trips without forcing re-entry on every save.
system_settings keys (per-port unless noted):
- sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted}
- sales_imap_{host,port,user,pass_encrypted}
- sales_auth_method (default app_password)
- noreply_from_address
- email_template_send_berth_pdf_body, email_template_send_brochure_body
- brochure_max_upload_mb (default 50)
- email_attach_threshold_mb (default 15)
UI surfaces (per §5.7, §5.8, §5.9):
- <SendDocumentDialog> shared 2-step compose+confirm flow.
- <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton>
wrappers per detail page.
- /[portSlug]/admin/brochures: list, upload (direct-to-storage
presigned PUT for the 20MB+ files per §11.1), default toggle,
archive.
- /[portSlug]/admin/email extended with <SalesEmailConfigCard>:
SMTP + IMAP creds, body templates, threshold/max settings.
Storage: every upload + download goes through getStorageBackend() —
no direct minio imports, per Phase 6a contract.
Tests: 1145 vitest passing (+ 50 new in
markdown-email-sanitization.test.ts, document-sends-validators.test.ts,
sales-email-config-validators.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6b of the berth-recommender refactor (see
docs/berth-recommender-and-pdf-plan.md §3.2, §3.3, §4.7b, §11.1, §14.6).
Builds on the Phase 6a pluggable storage backend (commit 83693dd) — every
file write goes through `getStorageBackend()`; no direct minio imports.
Schema (migration 0030_berth_pdf_versions):
- new table `berth_pdf_versions` with monotonic `version_number` per
berth, `storage_key` (renamed convention from §4.7a), sha256, size,
`download_url_expires_at` cache slot for §11.1 signed-URL throttling,
and `parse_results` jsonb for the audit trail.
- new column `berths.current_pdf_version_id` (deferred from Phase 0)
with FK to `berth_pdf_versions(id)` ON DELETE SET NULL.
- relations + types exported from `schema/berths.ts`.
3-tier reverse parser (`lib/services/berth-pdf-parser.ts`):
1. AcroForm via pdf-lib — pulls named fields (`length_ft`,
`mooring_number`, etc.) at confidence 1. Sample PDF has 0 such
fields, so this is defensive coverage for future templates.
2. OCR via Tesseract.js — positional/regex heuristics keyed off the
§9.2 layout (Length/Width/Water Depth as `<imperial> / <metric>`,
`WEEK HIGH / LOW`, `CONFIRMED THROUGH UNTIL <date>`, etc.). Returns
per-field confidence + global mean; flags imperial-vs-metric drift
>1% in `warnings`.
3. AI fallback — gated via `getResolvedOcrConfig()` (existing
openai/claude provider). Surfaced from the diff dialog only when
`shouldOfferAiTier()` returns true (mean OCR confidence below
0.55 threshold), so OPENAI_API_KEY isn't burned on every upload.
Service layer (`lib/services/berth-pdf.service.ts`):
- `uploadBerthPdf()` — magic-byte check, size cap, version-number
bump + current pointer in one transaction.
- `reconcilePdfWithBerth()` — auto-applies fields where CRM is null;
flags conflicts when CRM and PDF disagree; tolerates ±1% on numeric
columns; warns on mooring-number-in-PDF mismatch (§14.6).
- `applyParseResults()` — hard allowlist of writable columns;
stamps `appliedFields` onto `parse_results` for audit.
- `rollbackToVersion()` — pointer flip only, never re-parses (§14.6).
- `listBerthPdfVersions()` — version list with 15-min signed URLs.
- `getMaxUploadMb()` — port-override → global → default 15 lookup
on `system_settings.berth_pdf_max_upload_mb`.
§14.6 critical mitigations:
- Magic-byte check (`%PDF-`) on every upload; mismatch deletes the
storage object and rejects the request.
- Size cap from `system_settings.berth_pdf_max_upload_mb` (default
15 MB); enforced in the upload-url presign AND server-side.
- 0-byte uploads rejected.
- Mooring-number mismatch surfaces as a `warnings[]` entry on the
reconcile result so the rep sees it in the diff dialog.
- Imperial vs metric ±1% tolerance in both the parser warnings and
the reconcile equality check.
- Path traversal already blocked at the storage layer (Phase 6a).
API + UI:
- `POST /api/v1/berths/[id]/pdf-upload-url` — presigned URL (S3) or
HMAC-signed proxy URL (filesystem) sized to the per-port cap.
- `POST /api/v1/berths/[id]/pdf-versions` — verifies the upload via
`backend.head()`, writes the row, bumps `current_pdf_version_id`.
- `GET /api/v1/berths/[id]/pdf-versions` — version list + signed URLs.
- `POST /api/v1/berths/[id]/pdf-versions/[versionId]/rollback`.
- `POST /api/v1/berths/[id]/pdf-versions/parse-results/apply` —
rep-confirmed diff payload.
- New "Documents" tab on the berth detail page (`berth-tabs.tsx`)
with current-PDF panel, version history, Replace PDF button, and
`<PdfReconcileDialog>` for the auto-applied + conflicts UX.
System settings:
- `berth_pdf_max_upload_mb` (default 15) — caps presigned-upload size
+ server-side validation. Resolved port-override → global → default.
Tests:
- `tests/unit/services/berth-pdf-parser.test.ts` — magic bytes,
feet-inches, human dates, full §9.2-shaped OCR text → 18 fields,
drift warning, AI-tier gate.
- `tests/unit/services/berth-pdf-acroform.test.ts` — synthetic
pdf-lib AcroForm round-trip.
- `tests/integration/berth-pdf-versions.test.ts` — upload, version-
number bump, magic-byte rejection, reconcile auto-applied vs
conflicts vs ±1% tolerance, mooring-number warning,
applyParseResults allowlist enforcement, rollback semantics.
Acceptance: `pnpm exec tsc --noEmit` clean, `pnpm exec vitest run`
green at 1103/1103.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6a from docs/berth-recommender-and-pdf-plan.md §4.7a + §14.9a. Lays
the storage groundwork for Phase 6b/7 file-bearing schemas (per-berth PDFs,
brochures) without touching those domains yet.
New files:
- src/lib/storage/index.ts StorageBackend interface + per-process
factory keyed on system_settings.
- src/lib/storage/s3.ts S3-compatible backend (MinIO/AWS/B2/R2/
Wasabi/Tigris) wrapping the existing minio
JS client. Includes a healthCheck() used
by the admin "Test connection" button.
- src/lib/storage/filesystem.ts Local filesystem backend with all §14.9a
mitigations baked in.
- src/lib/storage/migrate.ts Shared migration core — pg_advisory_lock,
per-row resumable progress markers,
sha256 round-trip verification, atomic
storage_backend flip on success.
- scripts/migrate-storage.ts Thin CLI shim around runMigration().
- src/app/api/storage/[token]/route.ts
Filesystem proxy GET. Verifies HMAC,
enforces single-use replay protection
via Redis SET NX, streams via NextResponse
ReadableStream with explicit Content-Type
+ Content-Disposition. Node runtime only.
- src/app/api/v1/admin/storage/route.ts
GET status + POST connection test.
- src/app/api/v1/admin/storage/migrate/route.ts
Super-admin-only POST that runs the
exact same runMigration() as the CLI.
- src/app/(dashboard)/[portSlug]/admin/storage/page.tsx
Super-admin admin UI (current backend,
capacity stats, switch button with
dry-run, test connection, backup hint).
- src/components/admin/storage-admin-panel.tsx
Client component for the page above.
§14.9a critical mitigations implemented:
- Path-traversal: storage keys validated against ^[a-zA-Z0-9/_.-]+$;
`..`, `.`, `//`, leading `/`, and overlength keys rejected.
- Realpath: storage root realpath'd at create time, every per-key
resolution checked against the realpath'd prefix.
- Storage root created (or chmod'd) to 0o700.
- Multi-node refusal: FilesystemBackend.create() throws when
MULTI_NODE_DEPLOYMENT=true.
- HMAC token: sha256-HMAC over the (key, expiry, nonce, filename,
content-type) payload. Verified with timingSafeEqual; bad sig,
expired, or invalid-key payloads all return 403.
- Single-use replay: token body cached in Redis SET NX EX 1800s.
- sha256 round-trip: copyAndVerify() re-fetches from the target after
put() and aborts the migration on any mismatch.
- Free-disk pre-flight: when migrating to filesystem, sums byte counts
via source.head() and aborts if free space < total * 1.2.
- pg_advisory_lock(0xc7000a01) prevents concurrent migrations.
- Resumable: per-row progress markers in _storage_migration_progress.
system_settings keys read by the factory (jsonb, no schema change):
storage_backend, storage_s3_endpoint, storage_s3_region,
storage_s3_bucket, storage_s3_access_key,
storage_s3_secret_key_encrypted, storage_s3_force_path_style,
storage_filesystem_root, storage_proxy_hmac_secret_encrypted.
Defaults: storage_backend=`s3`, storage_filesystem_root=`./storage`
(./storage added to .gitignore).
Tests added (34 tests, all green):
- tests/unit/storage/filesystem-backend.test.ts — key validation
allow/reject matrix, realpath escape, 0o700 perms, multi-node
refusal, HMAC token sign/verify/tamper/expire/invalid-key.
- tests/unit/storage/copy-and-verify.test.ts — sha256 mismatch on
round-trip aborts the migration.
- tests/integration/storage/proxy-route.test.ts — happy path, wrong
HMAC secret, expired token, replay rejection.
Phase 6a ships zero file-bearing tables — TABLES_WITH_STORAGE_KEYS is
intentionally empty. berth_pdf_versions and brochure_versions land in
Phase 6b and join the list there. Existing s3_key columns: only
gdpr_export_jobs.storage_key, already named correctly — no rename needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the recommender inputs added in Phase 2a (interests
.desired_length_ft / desired_width_ft / desired_draft_ft) on the
two interfaces reps actually use:
- /interests list: new "Berth size desired" column rendered as a
compact "60×18×6 ft" string. Cells with no dimensions show "-";
partial dimensions render "?" for the missing axis (recommender
treats null as "no constraint").
- New/Edit Interest form: three optional length/width/draft inputs
with explanatory subhead. Empty submissions collapse to undefined
so the API doesn't see "" -> numeric coercion errors.
- createInterestSchema gains the three optional desired-dim fields
with a shared transform that coerces strings/numbers to a positive
2-decimal numeric string for the postgres `numeric` column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire interests.yachtId -> yachts.name into the listInterests post-fetch
enrichment so the redesigned columns (Client · Yacht · Berth · Stage ·
EOI status · Source · Last activity) render the linked yacht.
- Add yachtId/yachtName to InterestRow.
- listInterests: fourth parallel join for yachts.name, Map merged
alongside the existing client/berth/tag/notes joins.
- interest-columns: add Yacht column (with link to /yachts/[id] when
the yacht has an id); replace Category with EOI status (badge
driven by interests.eoi_status); drop default-view Tags.
The "Berth size desired" column called out in §5.2 is deferred to
Phase 2 since the underlying desired_*_ft columns don't exist yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire primary email + primary phone into the /clients list service so
the redesigned columns (Name · Email · Phone · Country · Source ·
Latest stage · Created) actually have data. Picks the row marked
is_primary=true; falls back to most-recent created_at when the flag
is unset.
- 0026 schema migration: unique partial index
idx_cc_one_primary_per_channel on (client_id, channel) WHERE
is_primary=true. Prevents the §14.2 "multiple primaries" ambiguity.
- 0027 data migration: backfill clients.nationality_iso from the
primary phone's value_country. 218 -> 36 missing on dev. Idempotent.
- listClients: add a fifth parallel query for client_contacts; build
primaryEmailMap / primaryPhoneMap in-memory from the pre-sorted
result.
- client-columns: drop Yachts/Companies/Tags from the default view
per §5.1; add Email/Phone/Country/Latest-stage columns; rename
"Nationality" -> "Country" since phone country is a proxy (§14.2).
- client-card: prefer email, fall back to phone, for the line under
the name; replaces the old `contacts.find(isPrimary)` lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).
Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
port switcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /invoices/upload-receipts as the dedicated explainer for the
mobile scanner PWA: install instructions for iOS/Android, direct
deep-link button, and a walkthrough of the scan -> verify -> save
flow. Sidebar entry replaces the old "Scan receipt" tab so the
desktop side picks up the install steps before sending users to
the mobile-only surface.
Scanner layout moves PWA manifest + apple-* meta tags from inline
JSX into Next.js's metadata/viewport exports so the App Router
doesn't try to render a second <head>, fixing a hydration error
that surfaced as two console warnings on the scan page.
Scanner shell gains a centered Port Nimara logo header so the
standalone PWA looks branded when launched from the home screen
without the dashboard chrome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DateRangePicker grows a "Custom range" mode (From/To inputs capped
at today, mutually-bounded so From <= To). dashboard-shell threads
the range through to /api/v1/analytics, which validates calendar
dates via ISO round-trip and enforces a 365-day cap as a backstop
against the occupancy timeline N+1.
KpiCards now gates its query on currentPortId so the early
unhydrated-store fetch can't cache a zeroed/error response and
display "-" until staleTime expires.
MyRemindersRail drops xl:h-full so the rail no longer stretches
past its grid row and overlaps ActivityFeed below.
useRealtimeInvalidation switches to partial-prefix queryKeys so a
realtime mutation invalidates every cached range bucket at once
instead of just the one currently visible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the topbar's separate AlertBell + NotificationBell with a
single Inbox popover that tabs between alerts and notifications.
NotificationBell keeps a popover-gate so it doesn't fire its list
fetch when Inbox is mounted alongside it.
Extracts the user dropdown into <UserMenu> and moves the port
switcher + role label + theme toggle into the sidebar footer so
the topbar can reclaim space for breadcrumbs and command search.
Adds dedicated Insights / Receipts nav sections in the sidebar
(scaffolds the website-analytics + upload-receipts entry points).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /[portSlug]/website-analytics dashboard page (pageviews, top
pages, top referrers) and a per-port admin config UI for the
Umami URL / website-ID / API token. Settings live in system_settings
keyed per-port so a future second port has its own Umami account.
Adds a website glance tile to the main dashboard, a server-side
test-credentials endpoint, and a stable cache key for the active-
visitor poll so React Query doesn't fragment the cache per range.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the residential-clients and residential-interests pages rendered
plain HTML <table>s with 5–6 columns directly. At 390px viewport the
header columns clipped at the right edge — "Sour..." for the clients
page, no header for the interests page either.
Adds a parallel mobile card list:
- <table> stays inside `hidden lg:block` (unchanged at lg+)
- new card list inside `lg:hidden` mirrors the row data:
- Clients: name + status pill on top, then email · phone ·
residence · source as a wrap-friendly meta row.
- Interests: stage label as headline, updated-at on the right,
preferences (line-clamp-2) and notes (line-clamp-1) below,
source small at the bottom.
- Each card is a Link to the detail page (matching the row click
target on desktop).
- Empty + loading states render as a centered card on mobile.
This is the same `hidden lg:block` / `lg:hidden` pattern used for the
main /clients and /interests pages. Doesn't refactor to the full
DataView primitive (would mean rebuilding the residential data layer
on TanStack Table) — keeps the change tightly scoped to the visible
output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inquiry Settings + Business Rules cards used a flex-row layout that
crushed the label column into a narrow vertical stripe at 390px ("Inquiry
/ Contact / Email" wrapping one word per line) while the input took the
right side.
Stack label + helper text above the input on phone widths; restore the
side-by-side row from sm up. Same pattern as the other detail-edit rows
that were fixed in pass-2/pass-3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five small fixes from the third audit pass on previously-unchecked surfaces:
Yacht detail header (mobile):
- Stack the action cluster (Edit / Transfer / Archive) below the title
block on phone widths. Previously the three buttons crowded the right
side enough to truncate the status pill to "A..." and force the owner
name to wrap to two lines. Same fix that landed for berth / client /
company headers.
Company detail header (mobile):
- Same mobile stacking fix; legal-name + Tax-ID metadata no longer
wraps awkwardly.
Company detail Incorporation Date (all viewports):
- Strip the time portion of the ISO timestamp before passing to the
inline editor. Previously rendered the raw "2019-03-14T00:00:00.000Z"
Postgres-serialized form. Now reads "2019-03-14" and round-trips
through the YYYY-MM-DD inline editor cleanly.
Reminders list filter row:
- Allow flex-wrap on the My/All tabs + status filter + priority filter
cluster. At 390px, the priority filter dropdown was being pushed off
the right edge of the screen.
Client detail tab counts:
- Add interestCount + noteCount to getClientById response, surface as
badges on the Interests + Notes tabs. Brings them into parity with
Yachts/Companies/Reservations/Addresses which already showed counts;
Files + Activity are still stubs and don't get a count yet.
Verification: 0 tsc errors, 926/926 vitest passing, lint clean.
Out of scope (deferred):
- Residential clients / interests pages still render plain HTML tables
on phone widths (header columns clip at the right edge). Needs the
DataView card-on-mobile treatment that the main /clients and
/interests pages already have. Substantial separate work.
- Phone contacts in the legacy seed have value set but valueE164 NULL,
so InlinePhoneField shows "—" even though metadata is technically
populated. Fix is a one-time backfill via libphonenumber-js, not a
UI change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small but high-leverage fixes from the second audit pass on main:
Admin index (src/app/(dashboard)/[portSlug]/admin/page.tsx):
- Grouped 21 sections into 7 categories: Access, Configuration, Content,
Data Quality, Operations, Tenancy, Integrations. Each group has a
one-line description so first-time admins can orient themselves
without reading every card.
- Added the missing Duplicates entry (links to /admin/duplicates from
the dedup-migration work) under Data Quality.
More sheet (mobile bottom-drawer nav):
- "Email" -> "Inbox". The page that opens is an email-inbox surface
(Inbox + Accounts tabs), not a generic email composer. The previous
label was ambiguous.
Interest detail header (Won / Lost outcome buttons):
- Added title="Mark as won" / "Close as lost" so the icon-only buttons
on mobile have a tooltip on long-press / desktop hover.
- Tightened mobile padding (px-2 vs px-2.5) so the full-text desktop
labels still fit on sm+ without re-introducing a regression where a
visible mobile "Won"/"Lost" inline label crowded the right cluster
enough to push Email/Call/WhatsApp action chips into a vertical
stack.
Verification: 0 tsc errors, 926/926 vitest passing, lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mounts a dev-only client component that syncs the react-grab debug
toolbar's pinned edge / collapsed state across viewport changes (so
the toolbar doesn't drift off-screen when resizing or rotating).
Render is gated by NODE_ENV === 'development' in src/app/layout.tsx;
production builds tree-shake the import out via process.env replacement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old order : Dashboard / Clients / Yachts / Berths / More
New order : Dashboard / Berths / Clients / Documents / More
Reasoning (also captured as in-file comments above each tab list):
- Yachts is asset-record traffic — rarely browsed standalone, almost
always reached from inside an interest or client. Pulled out of the
bottom row, kept available in the More sheet.
- Documents (signature tracking / EOI queue) earns a slot at the
bottom because reps chase signers as a daily activity.
- Interests is intentionally NOT in the bottom row: having both
Clients and Interests as peer tabs created a Clients-vs-Interests
confusion for sales reps. The new per-client Interests tab + the
bottom-sheet drawer (see ClientInterestsTab) cover the day-to-day
deal review without needing a dedicated bottom-nav peer.
- Clients moves to the center: it's the primary mental anchor for
"find this person", with everything else (yachts, companies,
interests) reached as a tab on the client detail page.
More-sheet reorder mirrors the new priority: Interests / Yachts /
Companies first (most-likely overflow targets), then financial
(Invoices, Expenses), then Email / Alerts / Reports / Reminders /
Settings / Admin. Documents removed from the More sheet (now in
the bottom row).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Alerts rail and Reminders rail were using h-full unconditionally, which
worked at xl: where the dashboard grid pairs them with a sibling chart
column, but produced weirdly stretched empty cards in the single-column
mobile stack (no fixed-height context to fill).
alert-rail / my-reminders-rail: h-full -> xl:h-full
KPI tiles + skeleton rendered the same desktop padding (p-5) and font sizes
on phone, leaving the value cramped against a wide white frame. Tighter
mobile defaults that scale up at sm:
KPITile p-3 sm:p-5, label text-[10px] sm:text-xs,
value mt-1 text-lg sm:mt-2 sm:text-2xl, value truncates
KpiTileSkeleton: matching p-3 sm:p-5 + smaller skeleton bars on mobile
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InlinePhoneField now lays the country picker + number on top, with Save +
Cancel buttons on a second line — the previous single-line cluster was
cramped at every viewport size and broke entirely below ~480px.
A new onEditingChange callback notifies the parent when the field enters
edit mode, so contact rows can react. ContactsEditor uses it to "dilate"
the row visually: lift out of the muted baseline with a soft primary
ring + slightly brighter surface + bumped padding. Single visual signal
replaces the need for any "now editing" label, and the dilation also
hides the noisy chip cluster (label / star / trash) that would otherwise
fight the editor for space.
Mobile improvements applied at the same time:
- Each row stacks value editor on top, action cluster below at <sm
- Action cluster ("Add tag" + Make-primary star + trash) uses
justify-end on the new row so it doesn't collide with the picker
- Trash icon stays opacity-0/group-hover on desktop but is always
visible on touch (no hover state on touch) — sm:opacity-0 +
sm:group-hover:opacity-100 instead of the prior unconditional fade
- NewContactForm wraps onto multiple lines below sm (basis-full on
the value field) so the channel picker, value, label, and buttons
each get usable width
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a user clicks an inline-edit affordance for country / timezone /
subdivision, the field flipped to its combobox trigger but the popover
didn't open — they had to click again. And if they dismissed the popover
without picking, the field stayed in edit mode showing a "Select country…"
trigger they couldn't get out of.
Combobox primitives (country / timezone / subdivision) now accept:
- defaultOpen — open on first render
- onOpenChange — fired on every open/close transition
InlineCountryField / InlineTimezoneField / and the country + subdivision
fields inside addresses-editor pass defaultOpen=true and use onOpenChange
to auto-exit edit mode when the popover closes without a selection. A
pickedRef gate prevents the close-handler from racing the commit() exit
when the user does pick a value.
Bonus: addresses-editor now renders a flag emoji next to the country name
in the read-only state (regional-indicator pair from the ISO code).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes interests from a stub tab to a first-class surface on the client
detail page, and surfaces pipeline activity in two more places:
UI:
- New ClientInterestsTab (475 lines) — table of every active interest
for the client with stage-stepper visualization, lead category, source,
last-activity timestamp, and a drawer-on-tap row preview.
- New OverviewTab pipeline-summary panel above the existing 2-column
layout, rendering ClientPipelineSummary (already on this branch) in
its panel variant. Reps see the live pipeline at a glance without
leaving Overview.
- Removes "Preferred Language" inline field from the Overview tab and
the create form — unused, and the field added noise without driving
any downstream behavior.
- Tab order: Overview / Interests / Yachts / Companies / ... (Interests
moves up from the back of the list, where it was a stub anyway).
Data:
- listClients now returns interestCount + latestInterest{stage, mooring}
per row, joined from interests + berths in two parallel queries.
ClientRow type updated to surface them; Client list views can now
render "3 interests · last on D-02 (EOI Signed)" without a per-row
fetch.
- Contact rows in client detail now expose valueE164 + valueCountry to
the UI (already returned by the API; just wasn't typed through the
detail-page contract).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the inline "Source · email · phone" text strip with three primary
action chips and a smaller meta line:
Mail / Call / WhatsApp action buttons surface the most-used outbound
contacts on a single tap. WhatsApp deep-link strips the leading + from
the canonical E.164 number (or falls back to digit-only of the value).
Meta line now reads "Country · Added MMM d, yyyy" using nationalityIso
resolved through getCountryName(); date-fns formats createdAt.
Portal Invite + GDPR Export buttons remain available but only render
on sm+; on mobile they're reachable through the More sheet.
Archive / Restore is now a small icon button in the top-right corner
rather than a labeled button competing with the primary action chips.
Destructive intent stays out of the main action flow; hover swaps to
destructive color for archive (and stays neutral for restore).
The previous source/preferred-contact-method/preferred-language/timezone
fields no longer render in the header — they live on the Overview tab via
the inline editor pattern (see client-tabs.tsx).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five mobile-UX issues caught in the 2026-05-03 audit, fixed in one pass:
1. SpecRow on berth detail clipped at right edge on phone widths.
"Length 49.21 ft / 15 r" (the "m" cut off). Mobile-first stack:
label on top, value full-width below; flex row only from sm up.
2. ResponsiveTabs collapsed to a Select on phone widths, which read like
a generic dropdown and obscured the existence of peer tabs. Replaced
with a horizontally-scrollable strip that auto-scrolls the active
trigger into view (so the user sees neighbors and gets a discovery
cue that more exists beyond the edge). Removes the phone-only Select
and unifies the tab UI across viewport sizes.
3. Documents page tab strip ("All / EOI queue / Awaiting them / ...")
overflowed the 390px viewport because the wrapper was a fixed flex
row. Same horizontal-scroll fix as (2); inherits because Documents
uses ResponsiveTabs.
4. Berth detail header: "Change Status" + "Edit" buttons crowded the
area subtitle on mobile, causing "North Pier" to wrap to two lines
("North" / "Pier"). Stacked vertically on phone widths; from sm up
the buttons sit beside the title block as before.
5. Empty contact rows on client detail rendered a stale "Add tag · star"
metadata strip even when the contact value was unset, which cluttered
the row and offered no useful action. The metadata block now only
shows when contact.value is non-empty; the trash icon stays visible
so users can clean up the empty placeholder.
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Defers:
- Mobile More sheet last-row alignment / "Email" label specificity
- Admin index grouping (Access / System / Configuration / Content)
- Interest detail header icon labels (trophy/X discoverability)
- Pipeline funnel x-axis label abbreviations
- Reminders rail width allocation on dashboard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Berth detail (src/components/berths/berth-tabs.tsx):
- Numeric display polish, exposed by the new NocoDB-sourced seed:
- Power capacity now renders with kW unit (e.g. "330 kW")
- Voltage now renders with V unit (e.g. "480 V")
- All metric/imperial values rounded to <= 2 decimals
(was: "62.999112 m" -> now: "62.99 m")
- Nominal Boat Size shows full ft + m pair (was: ft only)
Seed ports (src/lib/db/seed.ts):
- Drop Marina Azzurra and Harbor Royale; install now seeds only:
- Port Nimara (the real install)
- Port Amador (secondary, for multi-tenant isolation tests / Panama
scaffolding)
- Existing dev DBs are not touched; this only affects fresh `pnpm db:seed`
runs. Users wanting to migrate should drop existing rows in the obsolete
ports manually before re-seeding.
Verification:
- lint clean, tsc unchanged from baseline (36 pre-existing errors), 858/858
vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.
Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
(NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
strings convert cleanly
Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type
Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
access
- power capacity / voltage become numeric inputs (with kW / V hints)
Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the live dedup pipeline on top of the P1 library + P3 migration
script. The new `client/interest` model now actively prevents duplicate
client records at creation time and gives admins a queue to triage
the borderline pairs the at-create check missed.
Three layers, per design §7:
Layer 1 — At-create suggestion
==============================
`GET /api/v1/clients/match-candidates`
Accepts free-text email / phone / name from the in-flight client
form, normalizes them via the dedup library, and returns scored
matches against the port's live client pool. Filters out
low-confidence noise (the background scoring queue picks those up
separately). Strict port scoping; never leaks across tenants.
`<DedupSuggestionPanel>` (`src/components/clients/dedup-suggestion-panel.tsx`)
Debounced React Query hook. Renders nothing for short inputs or
no useful match. On a high-confidence match it interrupts visually
with an amber-tinted card and a "Use this client" primary button.
Medium confidence falls back to a softer "possible match — check
before creating" treatment.
`<ClientForm>`
Renders the panel above the form (create path only — skipped on
edit). New `onUseExistingClient` callback fires when the user
picks the existing client; the form closes and the parent decides
what to do (typically: navigate to that client's detail page or
open the create-interest dialog pre-filled).
Layer 2 — Merge service
=======================
`mergeClients` (`src/lib/services/client-merge.service.ts`)
The atomic merge primitive that everything else calls. Single
transaction. Per §6 of the design:
- Locks both rows (FOR UPDATE) so concurrent merges of the same
loser fail with a clear error rather than racing.
- Snapshots the full loser state (contacts / addresses / notes /
tags / interest+reservation IDs / relationship rows) into the
`client_merge_log.merge_details` JSONB column for the eventual
undo flow.
- Reattaches every loser-side row to the winner: interests,
reservations, contacts (skipping duplicates by `(channel, value)`),
addresses, notes, tags (deduped), relationships.
- Optional `fieldChoices` — per-scalar overrides letting the user
keep the loser's value for fullName / nationality / preferences /
timezone / source.
- Marks the loser archived with `mergedIntoClientId` set (a redirect
pointer for stragglers; never hard-deleted within the undo window).
- Resolves any matching `client_merge_candidates` row to status='merged'.
- Writes audit log entry.
Schema additions:
- `clients.merged_into_client_id` (nullable text, indexed) — the
redirect pointer set on archive.
Tests: 6 cases against a real DB — happy path moves rows + writes log;
self-merge / cross-port / already-merged refused; duplicate-contact
deduped on reattach; fieldChoices copies loser values to winner.
Layer 3 — Admin review queue
============================
`GET /api/v1/admin/duplicates`
Pending merge candidates (status='pending') for the current port,
with both client summaries hydrated for side-by-side rendering.
Skips pairs where one side is already archived/merged.
`POST /api/v1/admin/duplicates/[id]/merge`
Confirms a candidate. Body picks the winner; the other side
becomes the loser. Calls into `mergeClients` — the only path that
writes `client_merge_log`.
`POST /api/v1/admin/duplicates/[id]/dismiss`
Marks the candidate dismissed. Future scoring runs skip the same
pair until a score change recreates the row.
`<DuplicatesReviewQueue>` (`/admin/duplicates`)
Side-by-side card UI for each pending pair. Click a card to pick
the winner; the other side is automatically the loser. Toolbar:
"Merge into selected" + "Dismiss". No per-field merge editor in
this PR — that's a future polish; the simple "pick the better row"
flow handles ~80% of cases.
Test coverage
=============
11 new integration tests (76 added in this branch total):
- 6 mergeClients (atomicity, refusal cases, contact dedup,
fieldChoices)
- 5 match-candidates API (shape, port scoping, confidence tiers,
Pattern F false-positive guard)
Full vitest: 926/926 passing (was 858 before the dedup branch).
Lint: clean. tsc: clean for new files (only pre-existing errors in
unrelated `tests/integration/` files remain, same as before this PR).
Out of scope, deferred
======================
- Background scoring cron that populates `client_merge_candidates`
(the queue is empty until this lands; manual seeding works for
now via the at-create flow).
- Side-by-side per-field merge editor with checkboxes (the simple
"pick the winner" UX shipped here covers ~80% of real cases).
- Admin settings UI for tuning the dedup thresholds. Defaults from
the design (90 / 50) are baked in for now.
- `unmergeClients` (the snapshot is captured in client_merge_log;
the undo endpoint just hasn't been wired yet).
These are all natural follow-up PRs that don't block shipping the
runtime UX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Documents hub signer status now renders via a label map (`Pending`,
`Signed`, `Declined`, …) instead of the raw lowercase enum value.
- Invoice detail formats `dueDate` and `paymentDate` as `MMM d, yyyy`
via `date-fns` instead of leaking raw `2025-03-14` ISO strings, and
swaps the "Payment Method" free-text input for a `Select` of labelled
options (`Bank transfer`, `Credit card`, …) so we never store
`bank_transfer` from a hand-typed field again.
- Interest tabs `MilestoneSection` status badge uses a `humanizeStatus`
helper so values like `waiting_for_signatures` show as
`Waiting For Signatures` (correctly title-cased) instead of being a
lower-snake-case fragment inside an ALL-CAPS pill.
- `OUTCOME_BADGE` in the interest header now has a fall-through that
renders any unknown outcome as a closed-state badge, preventing a
closed interest from looking open just because its enum was added
upstream without a matching label entry.
- Interest timeline route joins the `user` table and returns
`userName` alongside `userId`; the client renders the resolved name
instead of a 36-char UUID. Falls back to `'a teammate'` if the user
row was deleted.
- Invoice "New / Step 3 — Review" replaces the truncated UUID display
with a server-resolved client/company name via a small `useQuery`,
so users can confirm they picked the right billing entity before
submitting.
- New `loading.tsx` for client detail renders a header / tab strip /
card skeleton during the server-component / initial-query window
that previously flashed empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- End-reservation: API handler existed but had no UI surface. Adds an
"End reservation" button + date dialog on the reservation detail page,
visible only when status is `active`.
- New port-scoped `GET /api/v1/berth-reservations` list endpoint and
`[portSlug]/berth-reservations` page so users can see all reservations
across all berths from one place (was 404).
- Berths "Edit" menu pushed `/berths/{id}?edit=true` but the detail page
never read the param — it now auto-opens the edit sheet on mount and
strips `edit` from the URL.
- Reservation detail no longer shows raw 8-char UUIDs for Berth / Yacht
/ Client; reuses the lazy-fetching link components from the list view.
- Yacht "Interests" and "Reservations" tabs replaced their "Coming soon"
stubs with real lists fetched from the existing service routes.
- Dashboard "Pipeline Value" KPI used `select(berthId, price)` and
summed per active interest, so a berth with three open interests was
counted three times. Switched to `selectDistinct(berthId, price)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Wire the `DOCUMENT_EXPIRED` webhook event to `handleDocumentExpired`.
Previously the handler existed but was never called, leaving expired
EOIs stuck in `sent` / `partially_signed` forever.
- `sendForSigning` now resolves real port-configured signer emails via
`getPortEoiSigners(portId)` instead of fabricating
`developer@{slug}.com` / `sales@{slug}.com`. The Documenso-template
pathway was already using these; the upload-PDF pathway now matches.
- `handleRecipientSigned` logs a warning when the email match returns
zero rows so a misconfigured signer isn't a silent no-op.
- `handleDocumentCompleted` skips berth-rule re-evaluation when the
interest is already at or past `eoi_signed`, preventing a double-fire
when `DOCUMENT_SIGNED` and `DOCUMENT_COMPLETED` arrive close together.
- EOI generate dialog now invalidates by predicate (any queryKey
starting with `'documents'`) so the Documents tab and hub counts
refresh after generation, instead of missing because the actual
query key shape didn't match the targeted invalidation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>