12 Commits

Author SHA1 Message Date
3e78c2d4ab fix(F17 ext): apply DetailNotFound to clients/yachts/companies/berths
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m12s
Build & Push Docker Images / build-and-push (push) Successful in 4m44s
Refactored the interest-detail 404 pattern into a reusable
`<DetailNotFound>` component and applied it to the four other entity
detail pages. Pre-fix, navigating to a wrong-port or stale entity URL
silently rendered the layout shell with empty tabs on:

  - /[portSlug]/clients/[id]
  - /[portSlug]/yachts/[id]
  - /[portSlug]/companies/[id]
  - /[portSlug]/berths/[id]

All four now route a 404/403 response into an explicit "<Entity> not
found" / "No access" EmptyState with a back-to-list CTA, and the
TanStack Query retry policy short-circuits 404/403s so the empty state
appears immediately.

1373/1373 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:06:36 +02:00
608641c23b fix(T3): inline tag create + explicit 404 on interest detail
F16 — InlineTagEditor: inline "Create new tag" affordance
  The popover now has a search input at the top. Typing a name that
  doesn't match any existing tag surfaces a "Create new tag: <name>"
  action that POSTs /api/v1/tags then attaches the new id to the entity.
  Reps no longer need to context-switch to Admin → Tags to create the
  first chip. Enter on the input also triggers create-and-attach.

F17 — Interest detail page: explicit not-found state
  Pre-fix, navigating to /port-X/interests/<port-Y-id> 404'd at the API
  but the UI silently rendered the list shell with empty tabs. Cross-
  port URL pastes now show an EmptyState with title "Interest not found"
  + a "Back to interests" CTA. 403 (no access in this port) gets its
  own copy. TanStack Query is told not to retry 404/403s so the empty
  state appears immediately.

1373/1373 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:03:30 +02:00
e7e498dedd fix(T3): copy + entry points + recommender alias
Batch of small fixes from the post-audit plan:

F11 — "Mark as won" dialog copy
  Was: "This will move the interest to Completed and stamp the outcome."
  Completed was retired in the 7-stage refactor; copy now reads
  "marks Won; stage stays where it is" with a parallel Lost variant.

F13 — Bulk-add berths wizard had no UI entry point
  Page existed at /[portSlug]/admin/berths/bulk-add but nothing linked
  to it. Added a "Bulk add" button on the Berths list toolbar, gated
  on `berths.import`. Also fixed the API route's permission key
  (was `berths.create`, a phantom — switched to `berths.import` to
  match seed-permissions).

F14 — Audit Log nav entry
  Sidebar Admin section now lists "Audit Log" → /admin/audit, gated
  by the adminRequired group rule.

F18 — Recommender `limit` param ignored
  POST /interests/[id]/recommend-berths now accepts `limit` as an
  alias for `topN`. Audit sent `{limit:3}` and silently got 8 rows
  back; both names now resolve.

Tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:59:38 +02:00
98fe295675 fix: cascade-archive client's open interests — F10
Pre-audit, archiving a client set `clients.archived_at` but left their
in-flight `interests.archived_at = NULL`. Active-interest queries kept
surfacing those interests with a shadowed client — breadcrumbs broke,
detail-page drill-ins silent-404'd, and the dashboard double-counted.

Now `archiveClient()` runs in a transaction:
  1. Set archived_at on the client.
  2. Cascade-archive every interest where the client is the owner AND
     the interest is currently active (archived_at IS NULL AND
     outcome IS NULL).

Won/lost/cancelled interests are explicitly NOT touched — those are
historical records of closed business and should stay queryable.

The audit-log entry's newValue carries the list of cascaded interest
IDs so /admin/audit shows exactly which deals got swept up. Socket
`interest:archived` events fire per-id so any open list views invalidate.

Verified live: archived Olivia Sinclair, her active interest archived
too in the same call. 1373/1373 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:53:42 +02:00
f85948488d test: update GDPR export test for dashed jobId — companion to F3
The test asserted the old `gdpr-export:${id}` shape that BullMQ rejects.
Mirrors the production fix in 7da3c5b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:50:51 +02:00
025648c40b fix(P1): soft-archive berths instead of hard-delete — F5
Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which
permanently dropped the row, cascade-vanished `interest_berths` links,
broke historical audit references, and could 404 the public feed mid-
customer-inquiry. The `berths.archived_at` column existed in the schema
but was never written.

Changes:
  - `archiveBerth(id, portId, { reason }, meta)` is the new canonical
    soft-archive. Requires a reason (min 5 chars). Blocks when an
    active interest still depends on the berth (forces the rep to
    resolve the deal first). Audit-logs the old status + reason.
  - `restoreBerth(...)` reverses it.
  - DELETE route now accepts `{ reason }` and routes to archiveBerth.
  - New POST /api/v1/berths/[id]/restore.
  - `getBerthOptions` + dashboard occupancy / status-distribution
    queries gain `isNull(berths.archivedAt)` so archived moorings
    don't show up in pickers or skew metrics.
  - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth
    so import sites we haven't migrated still work — labeled @deprecated.

Verified live:
  - DELETE w/o reason       → 400 (validation)
  - DELETE w/ "x"           → 400 "Reason must be ≥ 5 characters"
  - DELETE w/ proper reason → 204, row archived, reason persisted
  - DELETE twice            → 409 "Berth is already archived"
  - POST /restore           → 204, archived_at cleared

Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts,
alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules-
engine.ts. The current set covers the visible surfaces; the rest are
secondary aggregators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
2d0a49e0d1 fix(P1): input validation hardening for client API — F6
Pre-audit /api/v1/clients accepted:
  - contacts[].value='not-an-email' with channel='email' → silent bounce
  - fullName='   ' (whitespace-only) → blank-chip renders everywhere
  - fullName='Hidden<ZWSP>Char<ZWSP>Name' (zero-width chars) → search blind spot

This commit:
  1. New `humanTextSchema()` helper in src/lib/validators/text.ts that
     strips invisible/bidi/control chars, trims, then length-checks.
  2. `fullName` switched to `humanTextSchema({ min: 1, max: 200 })`.
  3. `contactSchema` gains a `superRefine` requiring valid email format
     when `channel === 'email'`.

Verified live:
  - invalid email      → 400 "Must be a valid email address." (field-scoped)
  - whitespace name    → 400 "Too small: expected string to have >=1 characters"
  - zero-width chars   → stored as cleaned "HiddenCharName"
  - valid baseline     → 201

Followup tasks (deferred): apply `humanTextSchema` to yachts/companies/
interests/notes/reminders names; audit render paths for XSS-via-stored-
HTML (default React escaping is safe; pdfme/email-merge surfaces need a
spot-check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:44:15 +02:00
27f8db4c67 fix(P1): rate-limit auth endpoints — F7
Pre-audit: 20 rapid wrong-password attempts all returned 401 with no
lockout. Brute-force open.

Post-fix: better-auth's built-in rate limiter caps /sign-in/email at
5 attempts per 60s. Verified live — attempts 1-5 return 401, attempt 6+
returns 429 "Too many requests".

Same tight cap applied to /sign-up/email, /forget-password,
/reset-password. Default 120/min for everything else so legitimate
multi-widget dashboards aren't hampered.

Memory storage in this commit (resets on restart). Production multi-replica
swap to `storage: 'database'` planned for a follow-up once the
rateLimit migration is run.

Also: in production, trust X-Forwarded-For / X-Real-IP so the IP that
rate-limit + audit logging see is the real client, not the proxy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:41:47 +02:00
2c57082d8d fix(P1): postgres-js pool reliability — F8
During the audit the dev server twice entered a stuck state where every
query 500'd with `write CONNECT_TIMEOUT` while the DB was healthy (1/100
connections used, queryable from psql immediately). The Docker bridge can
silently drop TCP sockets and postgres-js holds the stale handles until
max_lifetime expires.

- connect_timeout: 10 → 5  (fail fast)
- max_lifetime: 30min → 10min  (recycle before staleness accumulates)
- onnotice: surface NOTICE/WARNING for visibility

Reduces the window of stuck state. Full recovery still requires a
restart if the pool hard-fails. pgbouncer in production is the proper
long-term answer; this is the safe one-file change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:40:24 +02:00
e469b2b6a6 fix(P1): GDPR export + Redis eviction policy
F3: BullMQ 5.x rejects custom job IDs containing `:` (collides with internal
Redis-key namespacing). GDPR export crashed with "Custom Id cannot contain :".
Switched to dash separator. GDPR Article 15 right-to-access now functional.

F4: Redis was configured with `allkeys-lru` eviction in both docker-compose.yml
and docker-compose.prod.yml. BullMQ explicitly requires `noeviction` —
otherwise queue keys can be evicted under memory pressure and jobs vanish
silently. Switched to noeviction with comment pointing at the audit finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:39:16 +02:00
85bd0d82e1 docs: capture post-audit fix plan from two-round Playwright sweep
24 fixes + 1 new feature, tiered by priority. T0 already shipped in the
previous commit; T1-T4 batches sequenced with effort estimates and file
pointers. Includes the manual-berth-status catch-up workflow design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:38:02 +02:00
446342aa69 fix: P0 — bootstrap proxy + interest detail Date crash
Two pre-deploy blockers found during click-testing:

1. /api/v1/bootstrap/status returned 401 to anonymous visitors because
   /api/v1/bootstrap/ was not in proxy.ts's PUBLIC_PATHS allow-list. Fresh
   VPS deploys couldn't bootstrap their first super-admin via /setup — the
   page reads bootstrap status to decide whether to render the form and got
   no signal back. The route handlers self-protect via hasAnySuperAdmin().

2. getInterestById() crashed every interest detail request with
   `CONNECT_TIMEOUT` / "string argument must be of type string or Buffer"
   because the contact-log count query passed a raw Date through a sql
   template fragment. postgres-js's Bind step can't serialize a Date
   that way. Switched to drizzle's gte() operator which routes the value
   through the column-aware serializer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:37:47 +02:00
30 changed files with 865 additions and 81 deletions

View File

@@ -32,7 +32,9 @@ services:
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
# BullMQ requires `noeviction` — under memory pressure, allkeys-lru
# silently drops queue keys and jobs disappear. See post-audit fix F4.
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy noeviction
volumes:
- redisdata:/data
healthcheck:

View File

@@ -18,7 +18,9 @@ services:
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
# BullMQ requires `noeviction` — under memory pressure, allkeys-lru
# silently drops queue keys and jobs disappear. See post-audit fix F4.
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy noeviction
volumes:
- redisdata:/data
healthcheck:

305
docs/POST-AUDIT-FIX-PLAN.md Normal file
View File

@@ -0,0 +1,305 @@
# Post-Audit Fix Plan
Generated 2026-05-14 from two rounds of deep Playwright + API audit on `feat/documents-folders``main`.
**Total findings:** 24 fixes + 1 new feature. Grouped by priority. Each entry has impact, file pointer, and effort estimate.
---
## TIER 0 — Already Applied in Working Tree (uncommitted)
Status: **fixed in code, not yet committed**. Commit + push to ship.
### F1. `/api/v1/bootstrap/*` proxy allow-list (task #22)
- **Impact:** Cold-start VPS deploy can't bootstrap its first super-admin. `/setup` page calls `/api/v1/bootstrap/status` which 401s; setup form never renders.
- **File:** `src/proxy.ts` — added to `PUBLIC_PATHS`.
- **Effort:** XS.
### F2. Interest detail page 500s on every visit (task #25)
- **Impact:** Sales workflow non-functional. Raw `Date` passed to postgres-js `sql\`${col} >= ${dateVar}\`` template crashes the Bind step.
- **File:** `src/lib/services/interests.service.ts:566` — switched to `gte(col, date)`.
- **Effort:** XS.
---
## TIER 1 — Pre-Deploy Blockers (P1)
Ship before any real client touches the system.
### F3. GDPR export 500s — BullMQ rejects job IDs with colons (task #51)
- **Impact:** GDPR Article 15 right-to-access non-functional. Legal/compliance gate.
- **File:** `src/lib/services/gdpr-export.service.ts:113` — change `jobId: \`gdpr-export:${row.id}\`` → `jobId: \`gdpr-export-${row.id}\``.
- **Effort:** XS (one char).
### F4. Redis eviction policy is `allkeys-lru` but BullMQ requires `noeviction` (companion to F3)
- **Impact:** Under memory pressure, Redis will evict BullMQ keys; jobs disappear silently.
- **File:** production Redis config (`maxmemory-policy noeviction`) + the docker-compose redis service.
- **Effort:** XS (config).
### F5. `deleteBerth()` hard-deletes rows instead of soft-archiving (task #65)
- **Impact:** Permanent data loss on accidental delete. Junction tables CASCADE-vanish. Audit log points to non-existent rows. Public feed could 404 mid-customer-inquiry.
- **Files:**
- `src/lib/services/berths.service.ts:673-685` — replace `db.delete()` with `set archivedAt = now(), archivedBy = userId, archiveReason = input.reason`.
- Add filter `isNull(berths.archivedAt)` to all default berth queries (recommender, public feed, list, dashboard heat).
- Add restore endpoint `POST /api/v1/berths/[id]/restore` mirroring the interests pattern.
- Require `reason` (min 5 chars) before destructive call.
- **Effort:** M.
### F6. Weak input validation on `/api/v1/clients` (task #50)
- **Impact:** Email format not validated (bounces silently); whitespace-only names accepted (blank chips everywhere); XSS payload stored verbatim (depends on every render path being safe).
- **Files:**
- `src/lib/validators/clients.ts` — add `.email()` refinement on contacts where `channel === 'email'`; trim+min(1) on `fullName`; regex-strip control chars + zero-width chars.
- Audit every fullName render path for `dangerouslySetInnerHTML` / pdfme / react-pdf / email template merges and ensure escaping.
- Apply similar hardening to yachts, companies, interests, notes, berths, reminders (audit all string fields).
- **Effort:** S for the obvious zod tweaks, M for the full audit.
### F7. No rate limiting on login (task #68)
- **Impact:** Brute force is wide open. 20 wrong-password attempts in a row all returned 401 with no lockout.
- **Files:**
- `src/lib/auth/` — add a `rateLimit` block to the better-auth config: `{ window: 60, max: 5 }` per IP+email.
- Optionally: Redis sliding window via existing ioredis client.
- Optionally: per-user lockout table (`auth_lockouts`) after 5 failures, locked 15min.
- **Effort:** S.
### F8. postgres-js pool corruption causes CONNECT_TIMEOUT (task #46)
- **Impact:** During the audit the dev server twice entered a stuck state where every query 500'd with `CONNECT_TIMEOUT` while the DB was healthy (1/100 connections used). Production VPS will hit this under load.
- **Files:**
- `src/lib/db/index.ts` — add `connect_timeout: 5`, `max_lifetime: 60 * 60`, `idle_timeout: 30`.
- Wrap critical-path queries in retry-on-CONNECT_TIMEOUT logic (one retry, then 503).
- Consider pgbouncer in front of postgres for production multi-process deployments.
- **Effort:** S for the postgres-js options, M for full pgbouncer.
---
## TIER 2 — High Impact Architectural / UX
Not strictly deploy-blocking, but each one breaks the UX in observable ways every day.
### F9. Layout-wide duplicate mobile/desktop DOM rendering (task #26)
- **Impact:** Single highest leverage UX bug. EVERY page mounts BOTH responsive layouts; both Radix Tabs providers are concurrently active with `data-state="active"`. Half my click attempts on tabs/filters/popovers went to the wrong layer. Doubled network requests, doubled component state, doubled a11y landmarks.
- **Files:** the responsive shell (likely `src/components/layout/*-shell.tsx` and detail-page wrappers).
- **Fix options:** use `useMediaQuery` to mount only one tree; or hoist `<Tabs>` to a single provider and let both layouts consume context.
- **Effort:** L (architectural refactor across multiple pages).
### F10. Archiving a client doesn't cascade-archive their interests (task #66)
- **Impact:** Orphan refs. Archived clients have active interests; active queries surface them with broken breadcrumbs / silent 404s on drill-in.
- **Files:** `src/lib/services/clients.service.ts:archiveClient()` — wrap in transaction, archive open interests too. OR extend `activeInterestsWhere()` to filter on `client.archived_at IS NULL`.
- **Effort:** S.
---
## TIER 3 — Standard Fixes (P3)
UX polish + missing entry points. Each is small, but the sum matters.
### F11. "Mark as won" dialog still says "moves to Completed" (task #27)
- **Impact:** Stale copy from before the 7-stage refactor. Misleads users.
- **File:** `src/components/interests/won-dialog.tsx` (or similar) — update copy to "marks Won; stage stays at <current>".
- **Effort:** XS.
### F12. Activity feed + tab count concatenation (task #23)
- **Impact:** "Test Person 1interest", "Interests0", "Click Test Co.company" — unprofessional.
- **Files:** `src/components/dashboard/activity-feed.tsx` (entity name + type), every detail-page tab count render. Audit log FTS `search_text` should also include entity names.
- **Effort:** S.
### F13. Bulk-add berths wizard has no UI entry point (task #28)
- **Impact:** Feature built for new-port setup, but invisible. Operator must know the URL.
- **Files:** Add a "Bulk add" button next to "New berth" on `/[portSlug]/berths`. Add link on `/admin` landing card.
- **Effort:** S.
### F14. Audit Log page has no UI entry point (task #49)
- **Impact:** Feature built, no nav link. Discovery requires URL knowledge.
- **Files:** Sidebar Admin section — add "Audit Log" entry under `documents` settings or as its own item, gated by `audit_log.view` permission.
- **Effort:** S.
### F15. New Yacht dialog only lists clients in owner picker (task #44)
- **Impact:** Data model supports `'client' | 'company'` ownership; UI only lets you pick clients. Cannot create company-owned yacht via UI.
- **Files:** `src/components/yachts/new-yacht-dialog.tsx` — add owner-type segmented control (Client / Company) above the owner picker; switch data source.
- **Effort:** S.
### F16. InlineTagEditor "Add tag" focus + create flow (task #45)
- **Impact:** Typing in the tag widget set the CONTACT LABEL instead. Plus no "Create new tag" affordance for new tag names.
- **Files:** `src/components/shared/inline-tag-editor.tsx`. Fix focus target; surface "Create new: X" as a popover item; orchestrate POST /api/v1/tags then PUT .../tags.
- **Effort:** S.
### F17. Cross-port (and 404) detail URLs silently render list shell (task #48)
- **Impact:** User pastes a wrong-port URL → API 404s correctly but UI silently shows the list shell. No explicit "not found" message.
- **Files:** every entity-detail client component — render `<EmptyState title="Not found" />` when GET returns 404. Apply to clients, interests, yachts, companies, berths.
- **Effort:** M (apply pattern to each detail page).
### F18. Recommender `limit` param ignored (task #69)
- **Impact:** Request with `{"limit": 3}` returned 8 berths. Either param name mismatch or no clamp.
- **Files:** `src/lib/services/berth-recommender.service.ts` + the recommend-berths validator.
- **Effort:** XS.
---
## TIER 4 — Polish & UX Reductions (P4)
The `UX EFFICIENCY` list (task #24). Each is small, mostly copy/flow improvements.
### F19. New Client form — primary contact default trap
- Default-checked "Primary contact" with empty email silently rejects on submit. Either don't pre-add OR drop empty contacts on save.
### F20. New Interest dialog — redirect to detail page on create
- Currently returns to the list. Add `router.push('/interests/' + newId)` to land on the workflow page immediately.
### F21. Stage-transition error toast leaks developer language
- "yachtId is required before leaving stage=enquiry" → "Yacht is required before leaving the Enquiry stage."
- Audit ALL ValidationError + ConflictError + service error messages for user-readable copy.
### F22. Stage menu uses unicode emoji `⚑` as prereq-blocked indicator
- Per user preference (memory: avoid decorative emoji), replace with a Lucide icon (`Lock`, `AlertCircle`, or `FlagOff`).
### F23. Blocked-stage UX — show prereq picker inline
- Clicking a blocked stage currently dismisses with a toast. Better: open the prereq picker inline ("Pick a yacht to leave Enquiry" with combobox right there).
### F24. New Client form — "Country" optional but prominent
- Drop from quick-path OR move to a "More details" disclosure.
### F25. Documents Hub — folder navigation doesn't update URL
- Drilling into a folder updates "Current location" but doesn't change `location.search`. Can't deep-link, browser-back broken, refresh resets to root.
### F26. "Reopen" outcome action silent — no toast
- After clicking Reopen, no feedback. Add `toast.success('Outcome cleared')` or similar.
### F27. Same-stage write returns full body — should be 204
- PATCH /stage with same stage = current stage returns 200 + full interest. Should be 204 No Content (no-op).
### F28. Recommender empty-result UI
- 300ft yacht returns `data: []` — UI Recommendations tab silently shows blank. Should render "No berths match — try relaxing constraints."
### F29. Inbox first-load "Loading..." stuck
- First navigation to /inbox shows "Loading..." indefinitely; subsequent reload renders fine. TanStack Query cache initialization issue.
### F30. Berths in default queries should filter `archivedAt IS NULL`
- Companion to F5 — once soft-delete lands, every default list query must filter archived rows.
---
## NEW FEATURE — Manual Berth Status Catch-Up Workflow (task #67)
User-requested. Foundation already exists (column `berths.status_override_mode` is in schema but never written).
### Phase 1 — Wire the status_override_mode field
- `updateBerthStatus()` sets `status_override_mode = 'manual'` when called via the user-facing API.
- `berth-rules-engine.ts` triggers set `status_override_mode = 'automated'`.
- When a backing interest is successfully created and links the berth, clear `status_override_mode` back to null in the same transaction; set `status_last_changed_reason` to "Reconciled via interest [id]".
- **Effort:** S.
### Phase 2 — Visual indicator
- On berth list rows: small chip "Manual" next to the status badge when `status_override_mode = 'manual'` AND no active interest is linked.
- On berth detail page header: badge + tooltip showing last reason, user, when.
- On dashboard "Berth Heat" widget: filter or annotate the manual rows.
- **Effort:** S.
### Phase 3 — Reconciliation Queue page
- New page `/[portSlug]/admin/berths/reconcile`.
- Lists every berth where `status_override_mode = 'manual'` and no active interest. Sortable by `status_last_modified DESC`.
- Each row links to the catch-up wizard.
- Sidebar Admin section gets a link with the queue count badge.
- **Effort:** S.
### Phase 4 — Catch-Up Wizard (the core piece)
- Multi-step modal. Steps:
1. **Pick or create client** — combobox + inline quick-create (name + email only).
2. **Pick or create yacht** — optional if pre-EOI; quick-create with name + dimensions.
3. **Pick the matching stage** — based on current berth status:
- `under_offer` → enquiry / qualified / nurturing / eoi (default eoi)
- `sold` → contract + outcome=won
- Allow override.
4. **Upload existing docs** — EOI PDF, contract PDF, reservation form. Each auto-filed to the right entity folder.
5. **Optional payments** — if status=sold, prompt for deposit/full amount.
6. **Review + submit.** On submit, transaction:
- Create/select client + yacht
- Create interest at chosen stage with `assigned_to = current user`
- Upsert `interest_berths(is_primary=true, is_specific_interest=true, is_in_eoi_bundle=true)`
- Upload + attach files
- Insert payments
- Set `berth.status_override_mode = null` + `status_last_changed_reason = 'Reconciled via interest [id]'`
- Audit log single "reconcile" event linking berth + new interest.
- **Effort:** M (wizard) + S (transaction service) + S (API endpoint). Total M-L.
### Phase 5 — Entry points
- Berth list row menu → "Catch up..."
- Berth detail page next to manual badge → "Catch up"
- Dashboard widget "Manual statuses awaiting reconciliation" (count + link)
- Sidebar link
- **Effort:** S.
### Total feature effort: M-L (2-3 dev days).
---
## What I Tested in Round 2 (15 deep journeys, all passed structural validation)
| Journey | Result |
| -------------------------------------------- | ------------------------------------------------------------------------------------------- |
| State machine — stage skipping | ✓ Rejects forward/backward jumps with friendly copy + override path |
| Double outcome write | ⚠ Allowed (won→lost flips freely); audit log just says "update" — should tag outcome change |
| Cascade — delete with dependents | ✗ Inconsistent: clients soft-archive, **berths HARD-delete**, companies soft-archive |
| Manual berth status without backing interest | ✗ Foundation column exists, never written |
| Unicode (emoji/RTL/zero-width) | ⚠ Emoji + RTL OK; zero-width chars NOT stripped (search blind spot) |
| Storage / file upload magic-byte | ✓ Rejects JPEG/HTML disguised as PDF |
| Documenso webhook idempotency | ✓ Timing-safe + rate-limited bad-secret check |
| Berth recommender edge cases | ⚠ Empty dims OK; extreme dims return empty; **limit param ignored** |
| Email body XSS via markdown | ✓ Escape-first-then-rules, javascript: URLs stripped |
| Public berth feed correctness | ✓ Port allow-list, archive filter, status enum validation |
| Rate limiting / abuse | ✗ Login: no rate limit; public feed: CDN-cached |
| Health check + dependency probes | ✓ Anonymous minimal payload, secret-mode for website-intake |
| Direct ID enumeration | ✓ Uniform 404 — no leak |
| Cross-port API access | ✓ 404 at API; **silent at UI** |
| CSRF — fake Origin | ✓ Prod-only protection — dev intentionally skips |
---
## Recommended Commit Sequence
1. **Squash-commit T0 fixes** (F1 + F2) — these are deploy-blockers already applied. Push to main.
2. **T1 batch commit** (F3, F4, F5, F6, F7, F8) — pre-deploy blockers. Single commit per fix for clean review.
3. **T2** (F9, F10) — schedule for next sprint (F9 is architectural).
4. **T3** (F11-F18) — knock out in a few hours. Quick polish wave.
5. **T4** (F19-F30) — UX list. Bundle into a single PR over a few sessions.
6. **NEW FEATURE — Catch-Up Workflow** — 2-3 dev days. Higher business value than T2; prioritize after T1.
---
## Risk Notes
- The audit polluted the dev DB with test entities: `Smoke Test Client (renamed)`, `Aurora Marine Holdings Ltd`, `Bad Email Test`, `Phone Test`, `Robert'; DROP TABLE clients`, `François 🏄 المعتمد`, `محمد عبد الله`, `CSRF Test`, etc. Also **hard-deleted berth A1 in port-amador** + soft-archived Test Person 1. Consider `pnpm db:reseed:synthetic` before the next clean run.
- The Smoke Test Client interest had `outcome=lost_other` set during the won-then-lost test (R2-B). Audit log preserved both transitions but with action="update" not action="outcome_change".

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import './.next/types/routes.d.ts';
import './.next/dev/types/routes.d.ts';
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { restoreBerth } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// POST /api/v1/berths/[id]/restore
// Post-audit F5: reverses an archive. No body. Audit-logged.
export const POST = withAuth(
withPermission('berths', 'edit', async (_req, ctx, params) => {
try {
await restoreBerth(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -2,8 +2,8 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateBerthSchema } from '@/lib/validators/berths';
import { getBerthById, updateBerth, deleteBerth } from '@/lib/services/berths.service';
import { updateBerthSchema, archiveBerthSchema } from '@/lib/validators/berths';
import { getBerthById, updateBerth, archiveBerth } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]
@@ -37,10 +37,14 @@ export const PATCH = withAuth(
);
// DELETE /api/v1/berths/[id]
// Post-audit F5: this is a SOFT-ARCHIVE, not a hard delete. The body
// must carry `{ reason: string (>=5 chars) }`. Use POST /restore to
// reverse. Archive is blocked when an active interest is still linked.
export const DELETE = withAuth(
withPermission('berths', 'edit', async (_req, ctx, params) => {
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
await deleteBerth(params.id!, ctx.portId, {
const body = await parseBody(req, archiveBerthSchema);
await archiveBerth(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,

View File

@@ -15,7 +15,10 @@ import { bulkAddBerthsSchema } from '@/lib/validators/berths';
* /[portSlug]/admin/berths/bulk-add.
*/
export const POST = withAuth(
withPermission('berths', 'create', async (req, ctx) => {
// F13: aligned with the seed-permissions scope (`berths.import`).
// The previous `berths.create` was a phantom key — not in the role
// matrix, so non-super-admins silently failed permission resolution.
withPermission('berths', 'import', async (req, ctx) => {
try {
const input = await parseBody(req, bulkAddBerthsSchema);
const result = await bulkAddBerths(ctx.portId, input.berths, {

View File

@@ -11,20 +11,29 @@ import { recommendBerths } from '@/lib/services/berth-recommender.service';
* param) and `portId` (resolved from the auth context — never trust a
* client-supplied port, plan §14.10).
*/
const recommendBerthsSchema = z.object({
topN: z.number().int().min(1).max(999).optional(),
maxOversizePct: z.number().min(0).max(1000).optional(),
showLateStage: z.boolean().optional(),
amenityFilters: z
.object({
minPowerCapacityKw: z.number().min(0).optional(),
requiredVoltage: z.number().int().min(0).optional(),
requiredAccess: z.string().min(1).optional(),
requiredMooringType: z.string().min(1).optional(),
requiredCleatCapacity: z.string().min(1).optional(),
})
.optional(),
});
const recommendBerthsSchema = z
.object({
topN: z.number().int().min(1).max(999).optional(),
// F18: accept `limit` as a friendlier alias for `topN`. The audit
// sent `{limit: 3}` and was silently ignored; conventional API users
// expect the limit name. Both resolve to the same internal value.
limit: z.number().int().min(1).max(999).optional(),
maxOversizePct: z.number().min(0).max(1000).optional(),
showLateStage: z.boolean().optional(),
amenityFilters: z
.object({
minPowerCapacityKw: z.number().min(0).optional(),
requiredVoltage: z.number().int().min(0).optional(),
requiredAccess: z.string().min(1).optional(),
requiredMooringType: z.string().min(1).optional(),
requiredCleatCapacity: z.string().min(1).optional(),
})
.optional(),
})
.transform(({ limit, topN, ...rest }) => ({
...rest,
topN: topN ?? limit,
}));
// POST /api/v1/interests/[id]/recommend-berths
export const POST = withAuth(

View File

@@ -1,10 +1,11 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useSearchParams, useRouter, useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { DetailLayout } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { apiFetch } from '@/lib/api/client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
@@ -17,10 +18,18 @@ interface BerthDetailProps {
}
export function BerthDetail({ berthId }: BerthDetailProps) {
const { data, isLoading } = useQuery<BerthDetailData>({
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading, error } = useQuery<BerthDetailData>({
queryKey: ['berth', berthId],
queryFn: () =>
apiFetch<{ data: BerthDetailData }>(`/api/v1/berths/${berthId}`).then((r) => r.data),
retry: (failureCount, err) => {
const status = (err as { status?: number } | null | undefined)?.status;
if (status === 404 || status === 403) return false;
return failureCount < 2;
},
});
useRealtimeInvalidation({
@@ -56,6 +65,18 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
if (error && !isLoading) {
const status = (error as { status?: number } | null | undefined)?.status;
return (
<DetailNotFound
entity="berth"
backHref={`/${portSlug}/berths`}
backLabel="Back to berths"
status={status}
/>
);
}
const berth = data;
return (

View File

@@ -1,16 +1,19 @@
'use client';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import { Anchor } from 'lucide-react';
import { Anchor, Plus } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { ColumnPicker } from '@/components/shared/column-picker';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EmptyState } from '@/components/shared/empty-state';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { usePermissions } from '@/hooks/use-permissions';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { BerthCard } from './berth-card';
@@ -26,6 +29,11 @@ import { mooringLetterTone } from './mooring-letter-tone';
export function BerthList() {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
// F13: bulk-add wizard had no UI entry point. Gate the CTA on
// `berths.import` (the existing permission used for adding berths)
// so non-admins don't see a button that 403s on click.
const { can } = usePermissions();
const canBulkAdd = can('berths', 'import');
const {
data,
@@ -97,6 +105,14 @@ export function BerthList() {
}}
/>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
{canBulkAdd && (
<Button asChild size="sm" variant="default">
<Link href={`/${params.portSlug}/admin/berths/bulk-add`}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Bulk add</span>
</Link>
</Button>
)}
</div>
</div>

View File

@@ -2,8 +2,10 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { DetailLayout } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
import { getClientTabs } from '@/components/clients/client-tabs';
@@ -79,10 +81,18 @@ interface ClientDetailProps {
}
export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
const { data, isLoading } = useQuery<ClientData>({
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading, error } = useQuery<ClientData>({
queryKey: ['clients', clientId],
queryFn: () =>
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
retry: (failureCount, err) => {
const status = (err as { status?: number } | null | undefined)?.status;
if (status === 404 || status === 403) return false;
return failureCount < 2;
},
});
const { setChrome } = useMobileChrome();
@@ -108,6 +118,18 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
'berth_reservation:cancelled': [['clients', clientId]],
});
if (error && !isLoading) {
const status = (error as { status?: number } | null | undefined)?.status;
return (
<DetailNotFound
entity="client"
backHref={`/${portSlug}/clients`}
backLabel="Back to clients"
status={status}
/>
);
}
const tabs = data ? getClientTabs({ clientId, currentUserId, client: data }) : [];
return (

View File

@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { DetailLayout } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
import { getCompanyTabs } from '@/components/companies/company-tabs';
@@ -42,10 +43,15 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<CompanyData>({
const { data, isLoading, error } = useQuery<CompanyData>({
queryKey: ['companies', companyId],
queryFn: () =>
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
retry: (failureCount, err) => {
const status = (err as { status?: number } | null | undefined)?.status;
if (status === 404 || status === 403) return false;
return failureCount < 2;
},
});
const { setChrome } = useMobileChrome();
@@ -65,6 +71,18 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
'company_membership:ended': [['companies', companyId, 'members']],
});
if (error && !isLoading) {
const status = (error as { status?: number } | null | undefined)?.status;
return (
<DetailNotFound
entity="company"
backHref={`/${portSlug}/companies`}
backLabel="Back to companies"
status={status}
/>
);
}
const tabs = data ? getCompanyTabs({ companyId, portSlug, currentUserId, company: data }) : [];
return (

View File

@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { DetailLayout } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
import { getInterestTabs } from '@/components/interests/interest-tabs';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
@@ -82,10 +83,17 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<InterestData>({
const { data, isLoading, error } = useQuery<InterestData>({
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
// F17: don't retry 404s — they're intentional (wrong port, archived,
// deleted). Let the error state render the EmptyState below.
retry: (failureCount, err) => {
const status = (err as { status?: number } | null | undefined)?.status;
if (status === 404 || status === 403) return false;
return failureCount < 2;
},
});
useRealtimeInvalidation({
@@ -119,6 +127,19 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
: null,
);
// F17: explicit "not found" state when the API 404'd or 403'd.
if (error && !isLoading) {
const status = (error as { status?: number } | null | undefined)?.status;
return (
<DetailNotFound
entity="interest"
backHref={`/${portSlug}/interests`}
backLabel="Back to interests"
status={status}
/>
);
}
const tabs = data
? getInterestTabs({
interestId,

View File

@@ -126,8 +126,17 @@ export function InterestOutcomeDialog({ interestId, open, onOpenChange, mode }:
</div>
<p className="text-xs text-muted-foreground">
This will move the interest to <strong>Completed</strong> and stamp the outcome. You can
reopen it later.
{mode === 'won' ? (
<>
This will mark the interest as <strong>Won</strong>. The pipeline stage stays where
it is; the outcome flag is set. You can clear the outcome later to reopen.
</>
) : (
<>
This will close the interest with a <strong>Lost</strong> outcome. The pipeline
stage stays where it is. You can clear the outcome later to reopen.
</>
)}
</p>
</div>

View File

@@ -18,6 +18,7 @@ import {
Globe,
Settings,
Shield,
ScrollText,
Home,
ChevronLeft,
ChevronRight,
@@ -159,6 +160,8 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
items: [
{ href: `${base}/settings`, label: 'Settings', icon: Settings },
{ href: `${base}/admin`, label: 'Administration', icon: Shield },
// F14: audit log page existed but had no nav link.
{ href: `${base}/admin/audit`, label: 'Audit Log', icon: ScrollText },
],
},
];

View File

@@ -0,0 +1,48 @@
'use client';
import { SearchX } from 'lucide-react';
import { EmptyState } from '@/components/shared/empty-state';
interface DetailNotFoundProps {
/** "interest", "client", "yacht", etc. — used to build the copy. */
entity: string;
/** Plural list path back-link. e.g. "/port-x/clients". */
backHref: string;
/** Plural label for the back button, e.g. "Back to clients". */
backLabel: string;
/** HTTP status code from the failed fetch (404 vs 403 changes copy). */
status?: number;
}
/**
* Renders an explicit "not found" / "no access" panel for entity detail
* pages whose data fetch failed. Replaces the prior silent-list-shell
* behaviour where a wrong-port URL paste rendered the navigation chrome
* with empty tabs and no error message. Post-audit F17.
*/
export function DetailNotFound({ entity, backHref, backLabel, status }: DetailNotFoundProps) {
const denied = status === 403;
return (
<EmptyState
icon={SearchX}
title={denied ? `No access to this ${entity}` : `${capitalize(entity)} not found`}
description={
denied
? `You do not have permission to view this ${entity} in this port.`
: `It may have been removed, archived, or it belongs to a different port. Use the back button or pick a different ${entity}.`
}
className="mt-16"
action={{
label: backLabel,
onClick: () => {
window.location.assign(backHref);
},
}}
/>
);
}
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}

View File

@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, X, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -43,6 +44,7 @@ export function InlineTagEditor({
}: InlineTagEditorProps) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
// Always fetch so we can hide the editor entirely when no tags are
// configured AND the entity has no tags already applied — keeps the
@@ -60,6 +62,14 @@ export function InlineTagEditor({
onError: (err) => toastError(err),
});
// F16: inline "Create new tag: X" — was missing entirely. Reps had to
// context-switch to Admin → Tags to create the tag, then come back.
const createTag = useMutation({
mutationFn: (name: string) =>
apiFetch<{ data: Tag }>('/api/v1/tags', { method: 'POST', body: { name } }),
onError: (err) => toastError(err),
});
function toggleTag(tagId: string) {
const has = currentTags.some((t) => t.id === tagId);
const nextIds = has
@@ -72,6 +82,26 @@ export function InlineTagEditor({
setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id));
}
async function handleCreateAndAttach(name: string) {
const trimmed = name.trim();
if (!trimmed) return;
const res = await createTag.mutateAsync(trimmed);
const newTag = res.data;
// Optimistically extend `allTags` so the new chip appears immediately.
qc.invalidateQueries({ queryKey: ['tags'] });
setTags.mutate([...currentTags.map((t) => t.id), newTag.id]);
setSearch('');
}
const lowerSearch = search.trim().toLowerCase();
const filtered = lowerSearch
? (allTags?.data ?? []).filter((t) => t.name.toLowerCase().includes(lowerSearch))
: (allTags?.data ?? []);
// Suggest "Create new tag: X" when the user typed something but no
// exact-case-insensitive match exists.
const exactMatch = (allTags?.data ?? []).some((t) => t.name.toLowerCase() === lowerSearch);
const canCreate = !!lowerSearch && !exactMatch;
// Hide the whole editor when the port has no tags configured AND this
// entity has none applied. Once an admin adds the first tag in
// Admin → Tags, the editor reappears on next mount/refetch.
@@ -116,14 +146,26 @@ export function InlineTagEditor({
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="border-b p-2">
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
// Enter on a non-empty no-match → create and attach.
if (e.key === 'Enter' && canCreate && !createTag.isPending) {
e.preventDefault();
void handleCreateAndAttach(search);
}
}}
placeholder="Search or create…"
className="h-7 text-xs"
autoFocus
/>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{!allTags && <div className="px-3 py-2 text-xs text-muted-foreground">Loading</div>}
{allTags?.data.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
No tags defined yet. Create some in Admin Tags.
</div>
)}
{allTags?.data.map((t) => {
{filtered.map((t) => {
const checked = currentTags.some((c) => c.id === t.id);
return (
<button
@@ -143,6 +185,26 @@ export function InlineTagEditor({
</button>
);
})}
{canCreate && (
<button
type="button"
disabled={createTag.isPending}
onClick={() => void handleCreateAndAttach(search)}
className="flex w-full items-center gap-2 border-t px-3 py-1.5 text-sm text-left hover:bg-muted/60"
>
<Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="flex-1 truncate">
Create new tag: <strong>{search.trim()}</strong>
</span>
</button>
)}
{filtered.length === 0 && !canCreate && !!allTags && (
<div className="px-3 py-2 text-xs text-muted-foreground">
{allTags.data.length === 0
? 'No tags defined yet. Type a name to create one.'
: 'No matching tags.'}
</div>
)}
</div>
</PopoverContent>
</Popover>

View File

@@ -2,8 +2,10 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { DetailLayout } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
@@ -43,9 +45,17 @@ interface YachtDetailProps {
}
export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
const { data, isLoading } = useQuery<YachtData>({
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading, error } = useQuery<YachtData>({
queryKey: ['yachts', yachtId],
queryFn: () => apiFetch<{ data: YachtData }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
retry: (failureCount, err) => {
const status = (err as { status?: number } | null | undefined)?.status;
if (status === 404 || status === 403) return false;
return failureCount < 2;
},
});
const { setChrome } = useMobileChrome();
@@ -66,6 +76,18 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
],
});
if (error && !isLoading) {
const status = (error as { status?: number } | null | undefined)?.status;
return (
<DetailNotFound
entity="yacht"
backHref={`/${portSlug}/yachts`}
backLabel="Back to yachts"
status={status}
/>
);
}
const tabs = data ? getYachtTabs({ yachtId, currentUserId, yacht: data }) : [];
return (

View File

@@ -109,6 +109,31 @@ function buildAuth() {
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict' as const,
},
// Trust the X-Forwarded-For chain when running behind Caddy/Nginx in
// production so the IP that rate-limit + audit logging see is the
// real client, not the proxy. Skipped in dev (no proxy in front).
ipAddress: {
ipAddressHeaders: isProd ? ['x-forwarded-for', 'x-real-ip'] : [],
},
},
// Rate limiting (post-audit F7) — without this, brute-force is wide
// open. Tight caps on the credential-eating endpoints; loose default
// for everything else so legitimate fan-out (multi-widget dashboards
// that hit /get-session repeatedly) isn't hampered.
rateLimit: {
enabled: true,
window: 60,
max: 120,
// Memory storage resets on restart. For multi-replica prod, swap
// to `storage: 'database'` once the rateLimit migration is run.
storage: 'memory',
customRules: {
'/sign-in/email': { window: 60, max: 5 },
'/sign-up/email': { window: 60, max: 3 },
'/forget-password': { window: 60, max: 3 },
'/reset-password': { window: 60, max: 5 },
},
},
logger: {

View File

@@ -29,11 +29,31 @@ const connectionString = process.env.DATABASE_URL!;
// during clients-page fanout without log-storm.
const POOL_MAX = process.env.NODE_ENV === 'development' ? 30 : 20;
// Pool reliability hardening (post-audit F8):
// During the audit the dev server twice entered a stuck state where every
// query 500'd with `write CONNECT_TIMEOUT` while the DB was healthy
// (1 of 100 connections used, queryable from psql immediately).
// The Docker bridge can silently drop TCP sockets and postgres-js's pool
// holds onto the stale handles until max_lifetime expires.
// - connect_timeout: 5s so failures surface fast instead of stalling
// requests for 10s before erroring.
// - max_lifetime: 10min so connections recycle before stale sockets
// accumulate. Was 30min — too long for the Docker socket-drop pattern.
// - onnotice: surfaces postgres NOTICE/WARNING messages that we'd
// otherwise miss (extension warnings, deprecation hints).
const queryClient = postgres(connectionString, {
max: POOL_MAX,
idle_timeout: 20,
connect_timeout: 10,
max_lifetime: 60 * 30,
connect_timeout: 5,
max_lifetime: 60 * 10,
onnotice: (notice) => {
// postgres-js types `notice` as `unknown`; the runtime shape is
// { severity, code, message, ... }. Only surface WARNING+.
const n = notice as { severity?: string; message?: string };
if (n.severity && n.severity !== 'NOTICE') {
console.warn(`[postgres ${n.severity}] ${n.message ?? ''}`);
}
},
connection: {
// ms values per postgres.js types; these become Postgres GUC settings
// applied at session start.

View File

@@ -1,4 +1,4 @@
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
import { and, eq, gte, lte, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
@@ -9,12 +9,11 @@ import { PIPELINE_STAGES } from '@/lib/constants';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { buildListQuery } from '@/lib/db/query-builder';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { getPortBerthsDefaultCurrency } from '@/lib/services/port-config';
import { ConflictError } from '@/lib/errors';
import { sortByMooring } from '@/lib/utils/mooring-sort';
import type {
CreateBerthInput,
@@ -668,34 +667,128 @@ export async function bulkAddBerths(
return { inserted: inserted.length, ids: inserted.map((r) => r.id) };
}
// ─── Delete ─────────────────────────────────────────────────────────────────
// ─── Archive / Restore ─────────────────────────────────────────────────────
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
/**
* Post-audit F5: soft-archive replaces hard-delete. The previous
* `db.delete()` permanently dropped the berth row + cascade-vanished
* interest_berths links + broke historical audit references. Now the
* row stays; `archived_at` shields it from default queries.
*
* Reasoning chain:
* 1. Block if there's an active (non-archived, no-outcome) interest
* still linked — archiving with deals in flight breaks reports.
* 2. Stamp archived_at + archived_by + archive_reason in a single update.
* 3. Audit log captures the reason so /admin/audit shows the why.
* 4. Emit a socket alert so any open berth-detail page bounces.
*/
export async function archiveBerth(
id: string,
portId: string,
input: { reason: string },
meta: AuditMeta,
) {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
if (berth.archivedAt) {
throw new ConflictError('Berth is already archived');
}
await db.delete(berths).where(and(eq(berths.id, id), eq(berths.portId, portId)));
// Block archive when an active interest still depends on the berth —
// forces the rep to resolve the deal first instead of orphaning it.
const activeLink = await db
.select({ interestId: interestBerths.interestId })
.from(interestBerths)
.innerJoin(interests, eq(interests.id, interestBerths.interestId))
.where(
and(eq(interestBerths.berthId, id), isNull(interests.archivedAt), isNull(interests.outcome)),
)
.limit(1);
if (activeLink.length > 0) {
throw new ConflictError(
'Cannot archive a berth with an active interest. Resolve or archive the interest first.',
);
}
await db
.update(berths)
.set({
archivedAt: new Date(),
archivedBy: meta.userId,
archiveReason: input.reason,
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
action: 'archive',
entityType: 'berth',
entityId: id,
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area },
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area, status: berth.status },
newValue: { reason: input.reason },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'berth:deleted',
message: `Berth "${berth.mooringNumber}" deleted`,
alertType: 'berth:archived',
message: `Berth "${berth.mooringNumber}" archived: ${input.reason}`,
severity: 'info',
});
}
/** Un-archive. Available to anyone with `berths:edit`. Audit-logged. */
export async function restoreBerth(id: string, portId: string, meta: AuditMeta) {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
if (!berth.archivedAt) {
throw new ConflictError('Berth is not archived');
}
await db
.update(berths)
.set({
archivedAt: null,
archivedBy: null,
archiveReason: null,
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'berth',
entityId: id,
oldValue: { archivedAt: berth.archivedAt, archiveReason: berth.archiveReason },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'berth:restored',
message: `Berth "${berth.mooringNumber}" restored`,
severity: 'info',
});
}
/**
* @deprecated Use `archiveBerth` instead. Kept temporarily for callers
* that haven't migrated. Calls archiveBerth under the hood — the
* "hard delete" name is now a lie but we don't break the import sites
* in a single PR.
*/
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
return archiveBerth(id, portId, { reason: 'Deleted via legacy delete path' }, meta);
}
// ─── Options ──────────────────────────────────────────────────────────────────
export async function getBerthOptions(portId: string) {
@@ -710,6 +803,8 @@ export async function getBerthOptions(portId: string) {
status: berths.status,
})
.from(berths)
.where(eq(berths.portId, portId));
// F5: hide archived berths from option pickers; otherwise a dead berth
// appears in the New Interest combobox and re-links itself to a deal.
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
return sortByMooring(rows, (r) => r.mooringNumber);
}

View File

@@ -22,7 +22,7 @@ import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
import { restore, withTransaction } from '@/lib/db/utils';
import { logger } from '@/lib/logger';
import {
syncEntityFolderName,
@@ -555,17 +555,37 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
throw new NotFoundError('Client');
}
await softDelete(clients, clients.id, id);
// F10: cascade-archive the client's open interests so they don't
// dangle in active queries with a shadowed client. Won/lost interests
// (outcome IS NOT NULL) are kept as historical records — only IN-FLIGHT
// deals get archived. Wrapped in a single transaction so a partial
// archive can't leave the system half-cascaded.
const archivedInterestIds: string[] = await db.transaction(async (tx) => {
await tx
.update(clients)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(eq(clients.id, id));
const cascaded = await tx
.update(interests)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(interests.clientId, id),
eq(interests.portId, portId),
isNull(interests.archivedAt),
isNull(interests.outcome),
),
)
.returning({ id: interests.id });
return cascaded.map((r) => r.id);
});
// fire-and-forget: archive UI does not depend on the folder suffix
// being stamped before the HTTP response returns. Task 5 (rename
// hook) uses await because the rename should be visible to the
// next read; archive does not.
void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => {
logger.warn(
{ err, clientId: id, portId },
'Failed to apply archived suffix to client folder',
);
logger.warn({ err, clientId: id, portId }, 'Failed to apply archived suffix to client folder');
});
void createAuditLog({
@@ -574,11 +594,18 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
action: 'archive',
entityType: 'client',
entityId: id,
// Surface the cascade in the audit trail so /admin/audit shows
// exactly which interests got swept up.
newValue:
archivedInterestIds.length > 0 ? { cascadedInterestIds: archivedInterestIds } : undefined,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
for (const interestId of archivedInterestIds) {
emitToRoom(`port:${portId}`, 'interest:archived', { interestId });
}
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:archived', { clientId: id }),
@@ -597,10 +624,7 @@ export async function restoreClient(id: string, portId: string, meta: AuditMeta)
await restore(clients, clients.id, id);
void applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => {
logger.warn(
{ err, clientId: id, portId },
'Failed to clear archived suffix on client folder',
);
logger.warn({ err, clientId: id, portId }, 'Failed to clear archived suffix on client folder');
});
void createAuditLog({

View File

@@ -89,7 +89,8 @@ export async function getKpis(portId: string) {
const allBerthsRows = await db
.select({ status: berths.status })
.from(berths)
.where(eq(berths.portId, portId));
// F5: archived berths excluded so retired moorings don't dilute denominator.
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
const totalBerths = allBerthsRows.length;
const occupiedBerths = allBerthsRows.filter((b) => b.status === 'sold').length;
@@ -205,7 +206,7 @@ export async function getBerthStatusDistribution(portId: string) {
const rows = await db
.select({ status: berths.status, c: sql<number>`count(*)::int` })
.from(berths)
.where(eq(berths.portId, portId))
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
.groupBy(berths.status);
const counts: Record<string, number> = {};

View File

@@ -110,7 +110,9 @@ export async function requestGdprExport(input: RequestExportInput): Promise<Requ
emailToClient: input.emailToClient,
emailOverride: input.emailOverride ?? null,
},
{ jobId: `gdpr-export:${row.id}` },
// BullMQ 5.x rejects custom job IDs containing ':' (Redis-key collision).
// Dash-separator keeps the namespace + the UUID round-trip unambiguous.
{ jobId: `gdpr-export-${row.id}` },
);
return { export: row };

View File

@@ -1,4 +1,4 @@
import { and, desc, eq, exists, inArray, isNull, ne, sql } from 'drizzle-orm';
import { and, desc, eq, exists, gte, inArray, isNull, ne, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
@@ -561,10 +561,7 @@ export async function getInterestById(id: string, portId: string) {
.select({ count: sql<number>`count(*)::int` })
.from(interestContactLog)
.where(
and(
eq(interestContactLog.interestId, id),
sql`${interestContactLog.occurredAt} >= ${sevenDaysAgo}`,
),
and(eq(interestContactLog.interestId, id), gte(interestContactLog.occurredAt, sevenDaysAgo)),
);
// Resolve the assignee's display name for the header chip — falling back

View File

@@ -91,6 +91,17 @@ export const updateBerthStatusSchema = z.object({
export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>;
// ─── Archive Berth ────────────────────────────────────────────────────────────
// Post-audit F5: archive replaces hard-delete. A `reason` is required so
// the audit trail captures intent — "decommissioned 2026", "duplicate of
// A3", etc. min(5) blocks one-letter throwaways.
export const archiveBerthSchema = z.object({
reason: z.string().trim().min(5, 'Reason must be at least 5 characters'),
});
export type ArchiveBerthInput = z.infer<typeof archiveBerthSchema>;
// ─── List Berths ──────────────────────────────────────────────────────────────
export const listBerthsSchema = baseListQuerySchema.extend({

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/list-query';
import { humanTextSchema } from '@/lib/validators/text';
import {
optionalCountryIsoSchema,
optionalIanaTimezoneSchema,
@@ -9,22 +10,35 @@ import {
// ─── Contact sub-schema ──────────────────────────────────────────────────────
export const contactSchema = z.object({
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
value: z.string().min(1),
/** E.164-normalized number; required when channel is phone/whatsapp. */
valueE164: optionalPhoneE164Schema.optional(),
/** ISO-3166-1 alpha-2 country the number was parsed against. */
valueCountry: optionalCountryIsoSchema.optional(),
label: z.string().optional(),
isPrimary: z.boolean().optional().default(false),
notes: z.string().optional(),
});
export const contactSchema = z
.object({
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
value: z.string().min(1),
/** E.164-normalized number; required when channel is phone/whatsapp. */
valueE164: optionalPhoneE164Schema.optional(),
/** ISO-3166-1 alpha-2 country the number was parsed against. */
valueCountry: optionalCountryIsoSchema.optional(),
label: z.string().optional(),
isPrimary: z.boolean().optional().default(false),
notes: z.string().optional(),
})
.superRefine((data, ctx) => {
// Post-audit F6: email channel must carry a syntactically valid
// address. Otherwise sales send-outs bounce silently and the bounce
// monitor can't classify.
if (data.channel === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.value)) {
ctx.addIssue({
code: 'custom',
path: ['value'],
message: 'Must be a valid email address.',
});
}
});
// ─── Create ──────────────────────────────────────────────────────────────────
export const createClientSchema = z.object({
fullName: z.string().min(1).max(200),
fullName: humanTextSchema({ min: 1, max: 200 }),
contacts: z.array(contactSchema).min(1, 'At least one contact is required'),
/** ISO-3166-1 alpha-2 nationality code. */
nationalityIso: optionalCountryIsoSchema.optional(),

BIN
src/lib/validators/text.ts Normal file

Binary file not shown.

View File

@@ -57,6 +57,10 @@ const PUBLIC_PATHS: string[] = [
'/api/public/',
'/api/health',
'/api/webhooks/',
// First-run / cold-start: the unauthenticated /setup and /login pages
// call /api/v1/bootstrap/status to decide whether to render the setup
// form. The route handlers self-protect via hasAnySuperAdmin().
'/api/v1/bootstrap/',
'/scan',
'/portal/',
'/api/portal/',

View File

@@ -166,7 +166,8 @@ describe('requestGdprExport', () => {
expect(add).toHaveBeenCalledWith(
'gdpr-export',
expect.objectContaining({ exportId: row.id, emailToClient: true }),
expect.objectContaining({ jobId: `gdpr-export:${row.id}` }),
// F3: BullMQ 5.x rejects colons in custom job IDs — switched to dash.
expect.objectContaining({ jobId: `gdpr-export-${row.id}` }),
);
// Cleanup the mock so other tests don't see a stubbed queue.