Match the gate to the actual EOI's structure (Section 2 vs Section 3) so
the rep can generate the document the moment they have what they need —
and not before.
Required (Section 2 — top paragraph):
- Client name
- Client primary email
- Client primary address
Optional (Section 3 — left blank when absent):
- Linked yacht (name, dimensions)
- Linked berth (mooring number)
Previously the dialog blocked generation unless yacht AND berth were both
linked, which was overzealous — early-stage EOIs are routinely sent before
a specific berth is pinned down.
- eoi-context.ts: yacht and berth are now nullable in the returned
context. The hard ValidationError is now driven by the EOI's Section
2 fields (name/email/address) rather than yacht/berth presence. The
owner block falls back to the interest's client when no yacht is
linked, so signing parties remain resolvable.
- documenso-payload.ts + fill-eoi-form.ts: Section 3 form values
render as empty strings when yacht or berth are absent, so the
rendered PDF leaves those template inputs blank.
- document-templates.ts: yacht.* and berth.* tokens fall back to
empty strings; the legacy-fallback catch handler also recognises
the new "missing required client details" error.
- interests.service.ts: getInterestById now also returns
`clientPrimaryEmail` and `clientHasAddress` so the Documents tab
can compute the EOI prerequisites checklist client-side without an
extra fetch.
- eoi-generate-dialog.tsx: prereqs split into two groups visually —
Required (with red ✗ when missing) and Optional (with grey – when
absent). The Generate button only requires the Required block to
pass. A small amber banner surfaces when Required is incomplete so
the rep knows where to add the missing data.
Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/
berth" tests with parity coverage for the new behaviour ("builds a
valid context when yacht/berth missing", "throws when client email/
address missing"). Adds a payload test for the empty-Section-3 case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comprehensive audit findings rolled up into one pass.
Bugs:
- dialog.tsx — sm-breakpoint centering classes (sm:left-[50%] /
sm:top-[50%]) were being silently stripped by tailwind-merge because
the base inset-0 + sm:inset-auto pair counted as a conflict. Replaced
with explicit per-side utilities (top-0 right-0 bottom-0 left-0 +
sm:right-auto sm:bottom-auto). Every Dialog instance now centers
correctly on desktop. (Affected 16 dialog consumers.)
- interest-documents-tab.tsx — useQuery shared the queryKey
['interests', interestId] with the parent InterestDetail's query but
returned a different shape ({ data: ... } envelope vs unwrapped).
They clobbered each other's cache on tab mount, degenerating the
parent header to "Unknown Client" / "Open" briefly. Unified the
queryFn shape so the cache stays consistent.
- interest-tabs.tsx — milestone steps now derive done-state from
PIPELINE_STAGES.indexOf(currentStage) >= step.advanceStage_idx as
well as from the date stamp. Stage truth > date truth. Seeded /
imported interests that arrived past `open` without per-step dates
now correctly show their milestone steps as checked.
- interest-detail.tsx — wires useMobileChrome so the mobile topbar
shows the client name instead of the interest UUID.
- interest-documents-tab.tsx — empty state restructured to a centered
"No documents yet — Generate EOI" CTA card instead of a small
primary button floating in the corner.
- timeline/route.ts — synthesizes a "Created at <stage>" event when
no audit-log rows exist for the interest, so the Activity tab
isn't empty for seeded interests.
- lead-source-chart.tsx — pie radii switched from fixed 90px/50px
to "70%"/"40%" so the pie scales with the container instead of
being clipped at narrow widths; reserved 40px for the legend.
Visual / clarity:
- interest-detail-header.tsx — Won/Lost rendered as branded text
buttons on desktop ("Mark won", "Close as lost") and icon-only on
mobile via `hidden sm:inline`. Edit/Archive stay icon-only. Reopen
promoted to a labeled button when the interest is closed. Added
"Last contact Xd ago" to the meta row.
- detail-header-strip.tsx — py-4 → py-3 (tighter strip).
- interest-tabs.tsx — milestone cards: the next pending milestone
gets a brand-blue ring + "NEXT" pill so the user can see at a
glance which lifecycle to act on. Its primary action gets the
filled button variant.
- interest-tabs.tsx — Deposit milestone: invoice flow promoted to
primary CTA ("Create deposit invoice"), manual stage advance
demoted to a small text link ("Mark received manually"). Reflects
the actual recommended path now that recordPayment auto-advances
on payment.
- inline-editable-field.tsx — pencil affordance shown faintly
(opacity-20) at rest so users discover that fields are editable
without having to hover-test every label. Lifts to opacity-60 on
hover.
- constants.ts — STAGE_SHORT_LABELS map for cramped contexts;
pipeline-chart.tsx + pipeline-funnel-chart.tsx use them on mobile
via useIsMobile, so the rotated 9-stage axis isn't a wall of
overlap on a 393px screen.
- client-pipeline-summary.tsx — StageStepper rebuilt as a single
segmented progress bar instead of 9 micro-dots + connectors that
rendered inconsistently at tight widths. Each stage is an equal
slice that lights up as the interest reaches it; tooltips on hover
give the full stage name. Also dropped a pre-existing dead `br`
variable.
- dashboard empty states — Lead Source, Revenue Breakdown, Pipeline
Funnel, and Recent Activity now have helpful descriptions explaining
what populates them, instead of bare "No interests in range".
- use-paginated-query.ts — reuses `&` when the endpoint already has
`?`, so callers like the documents hub don't generate
`…?tab=eoi_queue&signatureOnly=true?page=1&limit=25` (which the API
rejected as 400). Caught while testing the now-removed EOI route
but applies broadly.
tsc clean. vitest 832/832 pass. eslint 0 errors (down from 1
pre-existing) on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trimmed two surfaces that didn't earn their nav weight:
- The /[port]/documents/eoi route added in the previous commit was
redundant with the per-interest EOI status milestones already on
the interest detail and the existing eoi_queue tab inside the
Documents hub. Removed the route + the "EOI queue" sidebar entry.
- The Alerts sidebar entry was promoting a mostly-empty page that
duplicated the dashboard alert rail. Dropped the entry; the
/[port]/alerts route stays accessible via the dashboard rail's
"View all" link and the topbar bell, which is enough for the
audit-trail use case.
While testing the EOI tab, found and fixed a real bug: usePaginatedQuery
was producing malformed URLs like `…?tab=eoi_queue&signatureOnly=true?page=1&limit=25`
(two `?` separators) when the endpoint string already carried query
params. The API rejected those with 400, so the EOI tab in the
documents hub was silently broken. The hook now uses `&` when the
endpoint already contains a `?`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Per-port EOI signer config
- New `eoi_signers` system_settings key (JSON: { developer, approver },
each `{ name, email }`). Settings UI exposes it under Admin → Settings.
- getPortEoiSigners(portId) reads the setting with a typed validator;
falls back to the legacy David Mizrahi / Abbie May defaults if the
row is missing or malformed (so older ports keep working until an
admin saves a value).
- Both EOI generation pathways now read from the helper instead of
hardcoded constants:
* documenso-template path (generateAndSignViaDocumensoTemplate)
* in-app PDF-fill path (generateAndSignViaInApp)
2. Timeline upgrades
The interest detail Activity tab now distinguishes the new automation
events that arrived with sessions 1+2:
- Stage auto-advances (userId='system') get a small "Auto" pill and
carry their reason into the description (e.g. "Stage advanced to
EOI Signed (auto-advanced — EOI signed via Documenso)").
- outcome_set events show "Marked as Won" / "Marked as Lost — went
to another marina" with optional reason; trophy/X icons.
- outcome_cleared events show "Reopened to {stage}" with a refresh
icon.
- Document events humanized: "Document 'X' fully signed" instead
of "Document X: completed".
- Stage labels run through stageLabel() so the timeline shows the
human label, not the enum key.
- Timestamps switched to relative-time with full-date tooltip.
- "by system" is rendered plainly (no longer the literal user-id).
tsc clean. vitest 832/832 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent strengthenings of the sales spine that the prior coherence
sweep made it possible to do cleanly.
1. EOI queue page
- Sidebar entry under Documents → "EOI queue".
- Route /[port]/documents/eoi renders DocumentsHub with the existing
eoi_queue tab pre-selected (filters in-flight EOIs only).
- .gitignore: tightened root-only `eoi/` ignore so the documents/eoi
route is no longer silently excluded.
2. Invoice ↔ deposit link
- invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind
('general' | 'deposit'). Indexed on (port_id, interest_id).
- createInvoiceSchema requires interestId when kind === 'deposit';
the service validates the linked interest belongs to the same port
before insert.
- recordPayment auto-advances pipelineStage to deposit_10pct (via
advanceStageIfBehind) when a paid invoice is kind=deposit and has
an interestId. No-op if the interest is already further along.
- "Create deposit invoice" link added to the Deposit milestone on the
interest detail. Links to /invoices/new?interestId=…&kind=deposit;
the form prefills the billing entity from the linked interest's
client and shows a context banner.
3. Won / lost terminal outcomes
- interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified'
| 'lost_no_response' | 'cancelled') + outcomeReason text +
outcomeAt timestamp. Indexed on (port_id, outcome).
- setInterestOutcome / clearInterestOutcome services + POST/DELETE
/api/v1/interests/:id/outcome endpoints (gated by change_stage
permission). Setting an outcome moves the interest to `completed`
in the same write; clearing reopens to `in_communication` (or a
caller-specified stage).
- Mark Won / Mark Lost icon buttons on the interest detail header,
plus an outcome badge that replaces the stage pill once a terminal
outcome is set, plus a Reopen button.
- Funnel + dashboard math updated to exclude lost/cancelled outcomes
from active calculations (KPIs.activeInterests, pipelineValueUsd,
getPipelineCounts, computePipelineFunnel, getRevenueForecast).
The funnel now also returns a `lost` summary so callers can
surface leakage without polluting conversion percentages.
Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB
manually via psql because drizzle-kit push hits a pre-existing zod
parsing issue on the companies index. Dev server may need a restart
to flush prepared-statement caches.
tsc clean. vitest 832/832 pass. ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
react-grab lets you point at any DOM element on the page and press
Cmd+C to copy the file name, React component, and HTML source — then
paste straight into a coding agent (Claude Code, Cursor, etc.) for
much higher-fidelity context.
Wiring (auto-detected by `npx grab@latest init --force`): a Next
<Script> tag in src/app/layout.tsx that loads the bundle from unpkg
in development only. Production builds skip the script entirely so
no extra weight ships to end users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Topbar (mobile-topbar.tsx):
- Bumped to 56px to match standard mobile-app proportions.
- Deep-navy gradient surface (#1e2844 -> #171f35) with white type —
matches the desktop sidebar identity, gives the app a premium
finish instead of generic white-with-text.
- Brand "PN" wordmark mark on the left when no back affordance is
needed (rounded brand-blue square, inset highlight + drop shadow).
- Soft glow shadow underneath for elevation depth instead of a hard
bottom border.
- White-on-navy back arrow with active-state translucent fill.
PageHeader (page-header.tsx):
- On mobile, the gradient hero strip + duplicate title + description
block now collapses entirely — the topbar already shows the title,
so duplicating it in the body wasted a third of the viewport.
- The actions slot remains rendered as a flush right-aligned row so
primary buttons (date-range pickers, "+ New X") stay accessible.
- Desktop rendering is untouched.
Mobile shell (mobile-layout.tsx):
- Top buffer 16px below the topbar so content doesn't ride flush.
- Bottom buffer 32px above the tab bar so the last card breathes.
CSS (globals.css):
- Hide the react-query-devtools floating button below lg: — it was
overlapping the bottom-tab bar's "More" affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- branded-auth-shell: split the background image into a separate
fixed-positioned layer behind the layout. Previously the bg was on
a min-h-screen container and iOS Safari left visible whitespace at
the top/bottom when the URL bar showed/hid (the container's height
didn't match the visual viewport). Now the bg pins to the actual
visible viewport via `fixed inset-0`. min-h-[100dvh] also added
so the layout layer matches.
- auth client: derive baseURL from window.location.origin instead of
NEXT_PUBLIC_APP_URL. Same dev build now works whether opened on
localhost (Mac) or the LAN IP (iPhone on Wi-Fi).
- auth server: dynamic trustedOrigins function that allows
localhost / 127.x / 192.168.x / 10.x in dev (function form
inspects the incoming request's Origin). Production stays locked
to NEXT_PUBLIC_APP_URL.
- new dev helper: scripts/dev-set-password.ts to set a user's
better-auth password directly (bypasses the email-reset flow);
used to bootstrap matt@letsbe.solutions for mobile testing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mobile topbar already shows the entity name pushed via
useMobileChrome, so the gradient detail-header strip was rendering it
a second time. Hides the inline h1 below sm: while keeping the source
/ email / phone meta and action buttons visible — the strip's
practical content (actions + meta) stays where users need it, and the
title responsibility moves cleanly to the topbar.
Affects: clients, yachts, companies, berths detail headers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- new invoice: push "New Invoice" to mobile topbar, hide the
redundant inline back+title row on mobile.
- scan receipt: dedicated "Take photo" primary button on mobile
(uses input capture="environment" to open the camera directly)
plus "Choose from library" secondary. Drop-zone framing kept on
desktop. Push "Scan Receipt" title to mobile topbar.
Both forms now take their entity title from the topbar and free up
real-estate at the top for actual content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Action buttons in entity detail headers (Invite/GDPR/Archive on
clients, similar sets elsewhere) overflowed off-screen at 393px
because the actions row was flex without flex-wrap. Adds flex-wrap
so buttons drop to a second/third row instead of clipping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detail pages (clients, yachts, companies, berths, invoices, expenses)
now push their entity name + a back-button toggle to the mobile
topbar via useMobileChrome, replacing the URL UUID fallback that was
rendering before.
Supporting changes:
- useMobileChrome() no longer throws when called outside the
MobileLayoutProvider — desktop-tree consumers get a no-op
setChrome so callers don't have to branch on shell type.
- setChrome is now stable across renders (useCallback) so callers'
useEffect dependency arrays don't infinite-loop.
- DetailPageShell now also pushes its entityName + cleans up on
unmount, and hides its desktop-only sticky header on mobile so it
doesn't double up with the topbar (no current callers, prep for
Phase 4 migration).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Project has experimental.typedRoutes enabled; passing template-literal
URLs through the Link href prop requires the wider Route type. Cast
at the Link boundary inside ListCard so callers can keep the simpler
string-typed href API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.
- ReminderCard: Bell icon, related-entity subtitle (User/Anchor/
FileText icon by entity type), due-date meta with
past-due flag, accent bar (rose=past-due,
amber=pending, slate=snoozed, emerald=done).
Snooze/Complete/Edit/Delete in actions menu.
- AuditLogCard: Action icon (Plus/Pencil/Trash2/Eye), entity
title, "{verb} by {actor}" subtitle, timestamp
meta, optional changed-field chip line. Accent
bar by action (created=emerald, updated=blue,
deleted=rose). Immutable, no actions menu.
- UserCard: Initials avatar, displayName/email, role meta
(Shield icon), last-login distance, "Inactive"
pill when deactivated. Accent bar (violet=
super_admin, slate=inactive, none=active).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender. Desktop view
(lg+) is unchanged.
- YachtCard: Ship icon, owner subtitle (User/Building2 icon by
ownerType), dimensions in meters preferred, hull #,
status pill. No accent bar (status is free-text).
- CompanyCard: Building2 icon, legalName subtitle, country (MapPin)
+ tax id (Hash) meta, member/yacht count line.
- BerthCard: Anchor icon, area subtitle (MapPin), dimensions
meta, status pill. Status-encoded accent bar
(emerald=available, amber=under_offer, slate=sold).
- InvoiceCard: FileText icon, client subtitle, due date (Calendar)
meta, prominent currency-formatted amount. Status
accent bar (emerald=paid, orange=overdue, ...).
- ExpenseCard: Receipt icon, category subtitle, expense date meta,
prominent amount, payment-status pill, "Possible
duplicate" pill when duplicateOf is set. Accent bar
by paymentStatus, overridden to amber when flagged
as duplicate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional cardRender prop to <DataTable> that switches the layout
to a vertical card list below lg: while keeping the same TanStack
table instance powering both views (pagination, sort, selection).
New shared shell:
- <ListCard> rounded card with optional left status accent bar,
whole-card link to detail page, top-right actions
slot, and tactile hover/active states.
- <ListCardAvatar> 40px brand-tinted circle (initials or domain icon).
- <ListCardMeta> inline icon + muted text segment.
- deriveInitials() shared helper that ignores numeric tokens (so
"Recovery Test 1777" -> "RT", not "R1").
Clients and interests pages now render mobile cards via cardRender
using this shell; desktop view (lg+) is unchanged. Interests cards
encode pipeline stage as a left-edge accent strip whose saturation
deepens with pipeline progression (open -> completed). Berths display
with an Anchor icon; null-berth interests fall back to a Compass +
"General interest" italic label. Hot leads get a discreet "Hot" pill.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plain h1 + p replaced with the mobile-aware PageHeader primitive so
the reports landing matches dashboard/settings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CardHeader/CardContent/CardFooter were uniformly p-6 (24px), which on
top of the mobile shell's 16px outer padding pushed form content 40px
inward — making cards feel content-shifted on a 393px viewport. Drops
to p-4 (16px) below sm and keeps p-6 from sm+ so desktop is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Content cards/lists were rendering edge-to-edge on mobile because the
mobile shell's <main> had no horizontal padding (only safe-area top/
bottom). Adds px-4 to match the breathing room desktop gets from p-6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8 API route files were exporting handler functions directly from route.ts,
which Next.js 15 rejects with "$NAME is not a valid Route export field".
Per CLAUDE.md convention, service-tested handler functions live in sibling
handlers.ts files and route.ts only re-exports the GET/POST/etc. wrapped
in withAuth(withPermission(...)).
Discovered during the mobile-foundation Task 24 build validation; the route
files predate this branch but the build was never re-run on data-model.
Files:
- berth-reservations/[id], companies/autocomplete, companies/[id]/members
+ nested mid/set-primary, yachts/autocomplete, yachts/[id]/transfer,
yachts/[id]/ownership-history
- Integration tests updated to import from handlers.ts (companies,
memberships, reservations, yachts-detail)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-6 findings — both MEDIUM cross-tenant FK injection.
- interests.service: createInterest/updateInterest/linkBerth accepted
clientId/berthId/yachtId from the request body without verifying the
referenced row belongs to the caller's port. getInterestById joins
clients/berths/yachtTags on these FKs without a port filter, so a
port-A caller could splice a foreign-port id and surface that
tenant's clientName, mooringNumber, or yacht ownership on read.
New assertInterestFksInPort helper guards all three surfaces.
- clients.service.createRelationship: accepted clientBId from the
body without a port check; the relationship list endpoint joins
clients without filtering by port, so the foreign client's name
+ email would render in the relationships tab. Now verifies
clientBId belongs to portId and rejects self-relationships.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. HIGH — reminders.create/updateReminder accepted clientId/interestId/
berthId from the body and persisted them with no port check; getReminder
then hydrated the row via Drizzle relations (no port filter on the
join), so a port-A user with reminders:create could exfiltrate any
port-B client/interest/berth row by guessing its UUID. New
assertReminderFksInPort gates create + update.
2. HIGH — listRecommendations(interestId, _portId) discarded portId
entirely; the route GET /api/v1/interests/[id]/recommendations
forwarded the URL id straight through. A port-A user with
interests:view could read any other tenant's recommended berths
(mooring numbers, dimensions, status). Service now verifies the
interest belongs to portId and joins berths filtered by port.
3. HIGH — Berth waiting list. The PATCH route did not pre-check that
the berth belonged to ctx.portId — a port-A user with
manage_waiting_list could reorder a port-B berth's queue. Separately,
updateWaitingList accepted arbitrary entries[].clientId and inserted
them without verifying tenancy, polluting the table with foreign-port
FKs. Both gaps closed.
4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths)
accepted any tagId and inserted into the join table. The tags table
is per-port but the join only carries a single-column FK. The
downstream getById join `tags ON join.tag_id = tags.id` has no port
filter, so a foreign tag's name + color render in the requesting port.
Helper now batch-validates tagIds belong to portId before insert.
5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission
gate (any role, including viewer, could write) and didn't validate
that the URL entityId pointed at a port-scoped entity of the field
definition's entityType. Route now uses
withPermission('clients','view'/'edit',…); service validates the
entityId per resolved entityType (client/interest/berth/yacht/company)
against portId.
Test mocks updated to cover the new entity-port-scope check.
818 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three findings from a fourth-pass review:
1. MEDIUM — webhook URL SSRF. The validator only enforced HTTPS+URL
parse; it accepted private/loopback/link-local/.internal hosts. The
delivery worker fetched arbitrary URLs and persisted up to 1KB of
response body into webhook_deliveries.response_body, which is then
surfaced via the deliveries listing endpoint — a port admin could
register a webhook to an internal HTTPS endpoint, hit the test
endpoint to force immediate dispatch, and read the response back.
Validator now rejects RFC-1918/loopback/link-local/CGNAT/ULA IPs
(v4 + v6) and .internal/.local/.localhost/.lan/.intranet/.corp
suffixes; the worker re-resolves the hostname at dispatch time and
blocks before fetch (DNS rebinding defense). 21-case unit test
covers the matrix.
2. MEDIUM — POST /api/v1/email/accounts/[id]/sync had no owner check.
Any user with email:view could enqueue an inbox-sync job for any
accountId, which the worker would honour using the foreign user's
decrypted IMAP credentials and advance the account's lastSyncAt
(data-loss risk on the legitimate owner's next sync). Route now
asserts account.userId === ctx.userId before enqueueing, matching
the toggle/disconnect endpoints.
3. MEDIUM — addDocumentWatcher (and the wizard / upload watcher
inserts) didn't validate the watcher's userId belonged to the
document's port. notifyDocumentEvent then emitted a real-time
socket toast + email containing the document title to the foreign
user. New assertWatchersInPort helper verifies each candidate has
a userPortRoles row for the port (super-admin bypass).
818 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. HIGH — Socket.IO accepted client-supplied `auth.portId` in the
handshake without verifying the user actually held a role in that
port, then unconditionally joined the socket to `port:${portId}`.
The `join:entity` handler also skipped authorization. This let any
authenticated CRM user receive realtime events from any other
tenant: invoice numbers + totals + client names, document signer
emails, registration events with full client name + berth, file
uploads, etc. Auth middleware now resolves the user's
userPortRoles (or isSuperAdmin) before honouring portId, and
join:entity verifies the entity's port matches a port the user
has access to. Pre-existing pre-branch issue but fixed here given
the explicit "all data is extremely sensitive" directive.
2. MEDIUM — listCrmInvites issued a global SELECT with no port
scope. The crm_user_invites table has no portId column (invites
mint global better-auth users, then port roles are assigned
later). The previous gating on per-port admin.manage_users let
any director enumerate every other tenant's pending invitee
emails + isSuperAdmin flags — a phishing target list and a
super-admin onboarding timing oracle. Restrict GET (list),
DELETE (revoke), and POST resend to ctx.isSuperAdmin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin
(manage_settings) mutate any other tenant's port row by passing the
foreign id in the path. Now non-super-admins must target their own
ctx.portId; listPorts and createPort are super-admin only.
2. HIGH — Invoice create/update accepted arbitrary expenseIds and
linked them into invoice_expenses with no port check; the GET
response then re-emitted those foreign expense rows via the
linkedExpenses join. assertExpensesInPort now validates each id
belongs to the caller's portId before insert; getInvoiceById's
join filters by expenses.portId as defense-in-depth.
3. HIGH — Document creation paths (createDocument, createFromWizard,
createFromUpload) persisted user-supplied clientId/interestId/
companyId/yachtId/reservationId without verifying those FKs were
in-port. sendForSigning then loaded the foreign client/interest by
id alone and pushed their PII into the Documenso payload. New
assertSubjectFksInPort helper rejects out-of-port FKs at create
time; sendForSigning's interest+client lookups now also filter by
portId.
4. MEDIUM — calculateInterestScore read its redis cache before
verifying portId, and the cache key was interestId-only — a
foreign-port caller could observe a cached score breakdown.
Cache key now includes portId, and the port-scope DB lookup runs
before any cache.get.
5. MEDIUM — AI email-draft job results were retrievable by anyone who
could guess the BullMQ jobId (default sequential integers). Job
ids are now random UUIDs, requestEmailDraft validates interestId/
clientId belong to ctx.portId before enqueueing, the worker's
client lookup is port-scoped, and getEmailDraftResult requires
the caller to match the original requester's userId+portId before
returning the drafted subject/body.
The interest-scoring unit test that asserted "DB is bypassed on cache
hit" is updated to reflect the new (security-correct) ordering.
Two new regression test files cover the email-draft binding (5 tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three findings from the branch security review:
1. HIGH — Privilege escalation via super-admin invite. POST
/api/v1/admin/invitations was gated only by manage_users (held by the
port-scoped director role). The body schema accepted isSuperAdmin
from the request, createCrmInvite persisted it verbatim, and
consumeCrmInvite copied it into userProfiles.isSuperAdmin — granting
the new account cross-tenant access. Now the route rejects
isSuperAdmin=true unless ctx.isSuperAdmin, and createCrmInvite
requires invitedBy.isSuperAdmin as defense-in-depth.
2. HIGH — Receipt-image exfiltration via OCR settings. The route
/api/v1/admin/ocr-settings (and the sibling /test) were wrapped only
in withAuth — any port role including viewer could PUT a swapped
provider apiKey + flip aiEnabled, redirecting every subsequent
receipt scan to attacker infrastructure. Both are now wrapped in
withPermission('admin','manage_settings',…) matching the sibling
admin routes (ai-budget, settings).
3. MEDIUM — Cross-tenant alert IDOR. dismissAlert / acknowledgeAlert
issued UPDATE … WHERE id=? with no portId predicate. Any
authenticated user with a foreign alert UUID could mutate it. Both
service functions now require portId and add it to the WHERE; the
route handlers pass ctx.portId.
The dev-trigger-crm-invite script passes a synthetic super-admin caller
identity since it runs out-of-band.
The two public-form tests randomize their IP prefix per run so a fresh
test process doesn't collide with leftover redis sliding-window entries
from a prior run (publicForm limiter pexpires after 1h).
Two new regression test files cover the fixes (6 tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously /api/health did deep dependency probes (postgres + redis +
minio) and 503'd on any failure. That's readiness behavior, not
liveness — a transient Redis/MinIO blip would tell the orchestrator to
restart the pod when it should only be dropped from the load balancer.
Make /api/health a thin liveness check (returns 200 unconditionally if
the process is responding) and move the deep checks to a new
/api/ready endpoint with the canonical Kubernetes-style 200/503
contract. Docker-compose healthchecks keep pointing at /api/health,
which is now more conservative (no false-positive container restarts).
Documenso/SMTP are intentionally not probed in /api/ready: each tenant
configures its own credentials and a tenant misconfiguration shouldn't
deadline the entire shared CRM.
Also tighten the gdpr-bundle-builder casts: replace the scattered
`as unknown as Record<string, unknown>` double-casts with a small
`toJsonRow<T>()` helper that does the widen narrow→wide in one place
with one cast hop instead of two.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Article-15 bundles are JSON+HTML only (no receipts/contracts), so even
heavy clients land at <1 MB. Anything larger almost certainly indicates
an unbounded relation we forgot to cap. Fail the worker job before
uploading rather than push a runaway blob to MinIO + email the client a
download link of mystery size.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The same `interface AuditMeta { userId; portId; ipAddress; userAgent }`
was duplicated in 26 service files. Move the canonical definition into
`@/lib/audit` next to the related types and update every service to
import it. `ServiceAuditMeta` (the alias used in invoices.ts and
expenses.ts) collapses into the same name.
Tag CRUD across clients/companies/yachts/interests/berths followed an
identical wipe-then-rewrite recipe with two latent issues: the delete
and insert weren't wrapped in a transaction (a partial failure left
the entity with zero tags) and the audit-log payload shape diverged
(`newValue: { tagIds }` for clients/yachts/companies but
`metadata: { type: 'tags_updated', tagIds }` for interests/berths).
Extract `setEntityTags` in `entity-tags.helper.ts` that performs the
delete+insert inside a single transaction, normalizes the audit payload
to `newValue: { tagIds }`, and dispatches the per-entity socket event
through a switch so `ServerToClientEvents` typing stays intact.
The five `setXTags(...)` service functions now do parent-row tenant
verification and delegate the join-table work + side effects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>