214 Commits

Author SHA1 Message Date
Matt Ciaccio
21868ee5fc feat(berths,seed): polish detail display + prune ports to Port Nimara + Amador
Berth detail (src/components/berths/berth-tabs.tsx):
- Numeric display polish, exposed by the new NocoDB-sourced seed:
  - Power capacity now renders with kW unit (e.g. "330 kW")
  - Voltage now renders with V unit (e.g. "480 V")
  - All metric/imperial values rounded to <= 2 decimals
    (was: "62.999112 m" -> now: "62.99 m")
  - Nominal Boat Size shows full ft + m pair (was: ft only)

Seed ports (src/lib/db/seed.ts):
- Drop Marina Azzurra and Harbor Royale; install now seeds only:
  - Port Nimara  (the real install)
  - Port Amador  (secondary, for multi-tenant isolation tests / Panama
                  scaffolding)
- Existing dev DBs are not touched; this only affects fresh `pnpm db:seed`
  runs. Users wanting to migrate should drop existing rows in the obsolete
  ports manually before re-seeding.

Verification:
- lint clean, tsc unchanged from baseline (36 pre-existing errors), 858/858
  vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:59:36 +02:00
Matt Ciaccio
c7ab816c99 feat(seed): replace 12 hand-rolled berths with 117-row NocoDB snapshot
The old seed only had 12 berths with made-up area names ("North Pier",
"Central Basin", etc.) and placeholder dimensions. Devs now get the real
117 berths exported from the legacy NocoDB Berths table — every editable
column populated with real production values.

What's in the snapshot (src/lib/db/seed-data/berths.json):
- 117 berths total (61 available / 45 under_offer / 11 sold)
- Areas A through E (matches NocoDB single-select)
- All numeric fields filled: length / width / draft (ft + m), water depth,
  nominal boat size, power capacity (kW), voltage (V)
- All NocoDB single-selects filled where present: side pontoon,
  mooring type, cleat/bollard type+capacity, access
- Bow facing, status_override_mode, berth_approved carried forward as-is
- Status normalized to lowercase snake_case ("Under Offer" -> "under_offer")
- Mooring numbers reformatted A1 -> A-01 to keep the existing "Letter-NN"
  convention used elsewhere in the codebase

Pre-sorted to preserve seed semantics:
  idx 0..4   -> 5 available  (small)   -- "open" / "details_sent" interests
  idx 5..9   -> 5 under_offer (medium) -- "eoi_signed" / "deposit" / "contract"
  idx 10..11 -> 2 sold (large)         -- "completed" interests
This means existing interest/reservation seeds that index berthRows[0..11]
keep their semantic alignment without code changes.

End-to-end verified by clearing Marina Azzurra and re-seeding:
  Port "Marina Azzurra" -- 117 berths, 8 clients, 3 companies, 12 yachts,
                           15 interests, 8 reservations

Future devs running `pnpm db:seed` on a fresh DB will now get realistic
berth data automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:41:12 +02:00
Matt Ciaccio
e40b6c3d99 feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.

Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
  (NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
  forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
  strings convert cleanly

Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
  four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type

Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
  side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
  area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
  access
- power capacity / voltage become numeric inputs (with kW / V hints)

Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
  ("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set

Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
  on feat/mobile-foundation, none introduced)
- lint clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
Matt Ciaccio
e2398099c4 test(audit-fixes): cover the new permission and webhook surfaces
Adds integration coverage for the routes / handlers shipped in the
preceding audit-fix commits, plus refactors two route files to expose
inner handlers from a sibling `handlers.ts` (the pattern used elsewhere
in `src/app/api/v1`) so tests can call them without the
`withAuth(withPermission(…))` wrapper.

New tests (18 cases across 4 files):
- `tests/integration/portal-auth.test.ts` (6) — verifyPortalToken
  rejects tokens missing `aud: 'portal'` or `iss: 'pn-crm'`, with the
  wrong audience (CRM-session-replay shape) or wrong issuer, plus a
  round-trip happy path. Locks in the portal-vs-CRM token isolation.
- `tests/integration/api/saved-views-ownership.test.ts` (6) — patch
  and delete handlers return 403 for a different user, 404 for an
  unknown id or cross-port id, and 200 for the owner. Ownership is
  enforced at the route layer regardless of the service's internal
  filtering.
- `tests/integration/api/berth-reservations-list.test.ts` (3) — the
  new global list returns rows for the current port only and honors
  pagination params. A reservation in a different port never leaks.
- `tests/integration/documents-expired-webhook.test.ts` (3) —
  handleDocumentExpired flips the document to `expired`, also flips
  the linked interest's `eoiStatus`, writes a `documentEvents` row,
  and is a no-op (not a throw) when the documensoId is unknown.

Refactors:
- `src/app/api/v1/saved-views/[id]/route.ts` extracts `patchHandler` /
  `deleteHandler` (and the shared `assertViewOwner`) into
  `handlers.ts`. The route file is now a 4-line `withAuth(handler)`
  wrapper.
- `src/app/api/v1/berth-reservations/route.ts` extracts `listHandler`
  similarly. Tests import directly from `handlers.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:17:08 +02:00
Matt Ciaccio
d364b09885 fix(realtime): keep socket through reconnects, stop re-subscribe storm
Two correctness bugs in the real-time stack — both silent failures, both
session-wide once they trigger.

(1) `SocketProvider` was setting the React context to null on every
`disconnect` event. socket.io's built-in reconnection re-establishes the
underlying transport and replays handlers, but the React tree had
already lost its reference to the socket — so every `useSocket()`
consumer saw null until a session/port change forced a remount. Effect:
after the first transient drop (laptop sleep, wifi blip, server
restart), realtime invalidation and toasts went dead session-wide with
no user-visible signal.

Fix: keep the socket reference stable for the lifetime of the
session+port, and surface a separate `isConnected` boolean for any UI
that wants to render an offline indicator. Exposed as a new
`useIsSocketConnected()` hook; `useSocket()` signature is unchanged.

(2) `useRealtimeInvalidation` captured `eventMap` as a useEffect
dependency. Every caller passes a fresh `{ ... }` object literal on each
render, so the effect re-ran every render → `socket.off`/`socket.on`
storm on pages with many subscribed events.

Fix: extract the subscription logic into a pure helper
(`realtime-invalidation-core.ts`, JSX-free for vitest). The hook now
keeps the latest map in a ref and only re-subscribes when the SET of
event names changes (joined-keys signature, not object identity). The
handler reads `ref.current` at fire time, so callers still see fresh
queryKey lists without re-binding.

Helper is unit-tested with a stub socket: registration count,
fire-time map lookup, cleanup deregistration, missing-event safety.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:11:52 +02:00
Matt Ciaccio
57a099acc4 fix(ui): humanize enum labels, format dates, resolve actor names, loading skeleton
- Documents hub signer status now renders via a label map (`Pending`,
  `Signed`, `Declined`, …) instead of the raw lowercase enum value.
- Invoice detail formats `dueDate` and `paymentDate` as `MMM d, yyyy`
  via `date-fns` instead of leaking raw `2025-03-14` ISO strings, and
  swaps the "Payment Method" free-text input for a `Select` of labelled
  options (`Bank transfer`, `Credit card`, …) so we never store
  `bank_transfer` from a hand-typed field again.
- Interest tabs `MilestoneSection` status badge uses a `humanizeStatus`
  helper so values like `waiting_for_signatures` show as
  `Waiting For Signatures` (correctly title-cased) instead of being a
  lower-snake-case fragment inside an ALL-CAPS pill.
- `OUTCOME_BADGE` in the interest header now has a fall-through that
  renders any unknown outcome as a closed-state badge, preventing a
  closed interest from looking open just because its enum was added
  upstream without a matching label entry.
- Interest timeline route joins the `user` table and returns
  `userName` alongside `userId`; the client renders the resolved name
  instead of a 36-char UUID. Falls back to `'a teammate'` if the user
  row was deleted.
- Invoice "New / Step 3 — Review" replaces the truncated UUID display
  with a server-resolved client/company name via a small `useQuery`,
  so users can confirm they picked the right billing entity before
  submitting.
- New `loading.tsx` for client detail renders a header / tab strip /
  card skeleton during the server-component / initial-query window
  that previously flashed empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:35 +02:00
Matt Ciaccio
a391934b73 feat(marina): end-reservation UI + global list, yacht tabs, dashboard distinct count
- End-reservation: API handler existed but had no UI surface. Adds an
  "End reservation" button + date dialog on the reservation detail page,
  visible only when status is `active`.
- New port-scoped `GET /api/v1/berth-reservations` list endpoint and
  `[portSlug]/berth-reservations` page so users can see all reservations
  across all berths from one place (was 404).
- Berths "Edit" menu pushed `/berths/{id}?edit=true` but the detail page
  never read the param — it now auto-opens the edit sheet on mount and
  strips `edit` from the URL.
- Reservation detail no longer shows raw 8-char UUIDs for Berth / Yacht
  / Client; reuses the lazy-fetching link components from the list view.
- Yacht "Interests" and "Reservations" tabs replaced their "Coming soon"
  stubs with real lists fetched from the existing service routes.
- Dashboard "Pipeline Value" KPI used `select(berthId, price)` and
  summed per active interest, so a berth with three open interests was
  counted three times. Switched to `selectDistinct(berthId, price)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:15 +02:00
Matt Ciaccio
e3e0e69c04 fix(documenso): expired event, real signer emails, query invalidation, double-fire
- Wire the `DOCUMENT_EXPIRED` webhook event to `handleDocumentExpired`.
  Previously the handler existed but was never called, leaving expired
  EOIs stuck in `sent` / `partially_signed` forever.
- `sendForSigning` now resolves real port-configured signer emails via
  `getPortEoiSigners(portId)` instead of fabricating
  `developer@{slug}.com` / `sales@{slug}.com`. The Documenso-template
  pathway was already using these; the upload-PDF pathway now matches.
- `handleRecipientSigned` logs a warning when the email match returns
  zero rows so a misconfigured signer isn't a silent no-op.
- `handleDocumentCompleted` skips berth-rule re-evaluation when the
  interest is already at or past `eoi_signed`, preventing a double-fire
  when `DOCUMENT_SIGNED` and `DOCUMENT_COMPLETED` arrive close together.
- EOI generate dialog now invalidates by predicate (any queryKey
  starting with `'documents'`) so the Documents tab and hub counts
  refresh after generation, instead of missing because the actual
  query key shape didn't match the targeted invalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:00:58 +02:00
Matt Ciaccio
6af2ac9680 fix(auth): harden admin gate, X-Port-Id, portal JWT, saved-views
- Add server-side `<admin>/layout.tsx` that redirects non-super-admins to
  `/[portSlug]/dashboard`. Closes the gap where any authed user could
  guess the URL and reach Users / Roles / Audit Log / Backup.
- `withAuth` super-admin branch now 404s when the requested portId does
  not match a real port row, preventing a compromised super-admin
  session from operating against a fabricated portId.
- Portal JWTs now carry `aud: 'portal'` + `iss: 'pn-crm'` claims and
  `verifyPortalToken` requires both, so a portal token can no longer be
  replayed against the CRM session path or vice versa. In-flight tokens
  (≤24h) will be invalidated once on deploy.
- `saved-views/[id]` PATCH and DELETE now do an explicit ownership
  check before the service call, returning 403 instead of relying on
  the service's internal userId filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:00:42 +02:00
Matt Ciaccio
a767652d74 feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB
Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6,
#7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real
assignee column on interests, defer until ownership model lands) and #12
(keyboard shortcuts — explicit user call).

  Interest list (the rep's main triage surface):

    - Last activity column replaces Created (sortable by
      dateLastContact). Postgres NULLs-last on DESC means
      never-contacted leads sort to the bottom — exactly the right
      triage default.
    - Comment-icon next to client name when notesCount > 0, with a
      tooltip showing the count. Cheap, glanceable signal that the
      lead has correspondence to peek at.
    - Urgency badges under stage when criteria fire: "Silent Nd"
      for mid-funnel interests with no contact in 7+ days,
      "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd"
      for eoi_signed interests with no deposit after 21 days.
      Pure derived — no extra fetch, computed from the dates the
      row already returns.
    - Bulk select checkbox column with bulk-archive (existing
      DataTable.bulkActions API; just wired with a confirm-dialog
      and a Promise.all fan-out).
    - Mobile FAB (+) for new interest, anchored above the bottom-tab
      bar with safe-area inset awareness.

    All four signals mirrored on the mobile InterestCard (comment
    icon, urgency badges, last-activity footer).

  Interest detail:

    - Reminder bell badge in the header showing pending/snoozed
      reminder count linked to the interest. Surfaced via
      getInterestById's new `activeReminderCount`.
    - "Latest note" teaser on the Overview tab — truncated 3-line
      preview of the most recent threaded note + relative time +
      "View all" link to the Notes tab. Saves a click for the
      common "what was discussed last?" peek.
    - Color-block swatches in InlineStagePicker dropdown (rounded-sm
      mini-bars in the stage's progressive saturation color, replacing
      the previous tiny dots). Reads as a visual scan instead of a
      list.

  Dashboard:

    - MyRemindersRail on the right sidebar above the existing
      AlertRail. Shows pending+snoozed reminders for the current
      user (overdue first), each with priority pill, relative due
      time, and click-through to the linked interest/client/berth.

  Berth detail:

    - BerthInterestPulse card at the top of the Overview tab,
      replacing the old "buried in tab" pattern. Shows up to 5
      active interests with avatar, stage pill, urgency badges, and
      last-activity. Mirrors the old Nuxt CRM's beloved "Interested
      Parties" panel but with the new triage signals.

  Realtime toasts:

    - New <RealtimeToasts /> mounted inside SocketProvider in the
      dashboard layout. Subscribes to interest:stageChanged,
      document:completed, document:signer:signed, and
      interest:outcomeSet — fires sonner toasts so reps watching any
      page learn about pipeline events without refreshing.

  Service layer:

    - listInterests: notesCount per row (left join + count + groupBy).
    - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164
      (for the Email/Call/WhatsApp buttons added last commit; phone
      pieces were missing), notesCount, recentNote, activeReminderCount.
    - sortColumn switch handles 'dateLastContact' explicitly; default
      stays 'updatedAt'.

tsc clean. vitest 835/835 pass. ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
Matt Ciaccio
c824b2df12 feat(interests): Email / Call / WhatsApp deep-links on interest header
The interest detail is the rep's workbench — but until now, calling or
emailing the lead meant navigating away to the client page first. Surface
the same Email / Call / WhatsApp affordances that already live on the
client header right where the work is happening.

  - getInterestById: extended to also resolve the linked client's primary
    phone (display value + canonical E.164 form for wa.me).
    `clientPrimaryEmail` is the same column we surfaced earlier for the
    EOI prereq checklist; this commit just adds the phone columns
    alongside it.

  - InterestDetailHeader: new contact-actions row tucked under the meta
    line. Each button is asChild over a real <a href> so middle-click,
    Cmd-click, and screen-readers behave correctly. Renders only the
    buttons whose underlying contact channel is present (Email-only when
    no phone is on file, etc.). The whole row is hidden when the client
    has no contacts at all.

  - WhatsApp number prefers the E.164 form; falls back to digits-stripped
    display value when the canonical form is missing.

tsc clean. vitest 835/835 pass. ESLint clean on every file touched.

Closes audit recommendation #1 (top-of-list — biggest sales-workflow
win per click saved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:33:13 +02:00
Matt Ciaccio
d197f8b321 feat(eoi): align prerequisites with EOI document structure
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>
2026-05-02 03:11:14 +02:00
Matt Ciaccio
76a7387dcc fix(ux): batch UX audit fixes across spine pages
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>
2026-05-02 01:24:15 +02:00
Matt Ciaccio
868b1f40c0 fix(nav): drop dedicated EOI route + alerts sidebar entry, fix paginated-URL bug
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>
2026-05-02 00:30:27 +02:00
Matt Ciaccio
dbbd03fd22 feat(sales): admin-configurable EOI signers + richer timeline events
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>
2026-05-02 00:19:55 +02:00
Matt Ciaccio
ba5fb6db5e feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes
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>
2026-05-02 00:01:33 +02:00
Matt Ciaccio
886119cbde refactor(sales): consolidate pipeline stages + wire EOI auto-advance
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>
2026-05-01 23:33:53 +02:00
Matt Ciaccio
0d357731ad chore(dev): install and wire react-grab
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>
2026-05-01 16:37:40 +02:00
Matt Ciaccio
a75d4f5d69 feat(mobile): redesign topbar + collapse cumbersome page-header on mobile
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>
2026-05-01 16:34:28 +02:00
Matt Ciaccio
0fb7920db5 fix(auth/mobile): support LAN-IP access in dev + edge-to-edge auth bg
- 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>
2026-05-01 16:21:59 +02:00
Matt Ciaccio
16ad61ce15 fix(mobile): hide duplicate detail-header title on mobile
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>
2026-05-01 16:09:32 +02:00
Matt Ciaccio
d080bc52fa feat(mobile): touch up new-invoice + scan-receipt forms
- 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>
2026-05-01 16:06:09 +02:00
Matt Ciaccio
a653c8e039 fix(mobile): wrap detail-header actions on narrow viewports
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>
2026-05-01 15:48:51 +02:00
Matt Ciaccio
7e8110b2ff feat(mobile): show entity name in mobile topbar on detail pages
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>
2026-05-01 15:46:32 +02:00
Matt Ciaccio
9eadaf035e fix(mobile): widen ListCard href type to Route
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>
2026-05-01 15:40:52 +02:00
Matt Ciaccio
bcea28cd71 feat(mobile): mobile cards for reminders, audit log, users
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>
2026-05-01 15:39:06 +02:00
Matt Ciaccio
722491a9dd feat(mobile): mobile cards for yachts, companies, berths, invoices, expenses
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>
2026-05-01 15:34:04 +02:00
Matt Ciaccio
6009ccb7de feat(mobile): mobile card view for clients + interests lists
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>
2026-05-01 15:27:53 +02:00
Matt Ciaccio
71da6e8fdc feat(mobile): swap admin page headers to PageHeader
Mechanical sweep replacing the plain h1+p header markup with the
mobile-aware PageHeader primitive across 12 admin pages: index,
backup, branding, documenso, email, import, invitations, monitoring,
onboarding, reminders, reports, webhooks. Webhooks "Add Webhook"
button preserved via the actions slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:57:52 +02:00
Matt Ciaccio
c405124bc3 feat(mobile): swap reports header to PageHeader
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>
2026-05-01 12:55:11 +02:00
Matt Ciaccio
53cbee1d3d fix(mobile): tighten Card padding on mobile (p-4 sm:p-6)
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>
2026-05-01 12:55:09 +02:00
Matt Ciaccio
ac7f1db62c fix(mobile): add horizontal padding to mobile shell main
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>
2026-05-01 12:45:20 +02:00
Matt Ciaccio
5d44f3cfa4 fix(test): raise mobile-audit timeout to 30min for 4-viewport runs
Task 24 audit run hit the 10-minute test.setTimeout ceiling after capturing
2 of 4 viewport passes (iphone-se complete, iphone-16 complete-ish, 16-pro
partial, pro-max not started). 4 viewports × ~45 routes × slowMo: 200 needs
more headroom than 600s gave. 30min is comfortable headroom; the per-test
project timeout is matched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:15:26 +02:00
Matt Ciaccio
d0540dca55 fix(build): extract route.ts handlers to handlers.ts (CLAUDE.md convention)
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>
2026-04-29 15:14:40 +02:00
Matt Ciaccio
0e9c24e222 test(visual): add mobile shell snapshot baselines (dashboard + more sheet)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:47:26 +02:00
Matt Ciaccio
3aba2181dc feat(test): extract anchor iPhone device descriptors to shared fixture
Move the four iPhone viewport descriptors (SE, 15/16, 16/17 Pro, Pro Max)
into tests/e2e/fixtures/devices.ts so the upcoming visual spec (Task 23)
can share the same anchors. The mobile-audit spec now spreads each
descriptor and adds a slug `name` plus a human `label` for the run header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:31:51 +02:00
Matt Ciaccio
6237ad1567 feat(mobile): add FilterChips primitive (horizontal chip row with Add-filter trigger) 2026-04-29 14:28:33 +02:00
Matt Ciaccio
34916d855e feat(mobile): add DataView (TanStack table on lg+, card list below) with cardRender callback 2026-04-29 14:27:17 +02:00
Matt Ciaccio
41ae8a328f feat(mobile): add DetailPageShell with sticky header + mobile sticky-action shelf 2026-04-29 14:25:45 +02:00
Matt Ciaccio
1ff3160eac feat(mobile): add ActionRow with horizontal-scroll-snap on mobile, wrap on desktop 2026-04-29 14:24:43 +02:00
Matt Ciaccio
5698d742d3 feat(mobile): make PageHeader mobile-aware (stack below sm, hide description when actions present) 2026-04-29 14:23:40 +02:00
Matt Ciaccio
e6ce265be0 fix(mobile): drop positive display rule that overrode desktop shell's flex layout 2026-04-29 14:20:11 +02:00
Matt Ciaccio
19bc2f2a54 feat(mobile): mount MobileLayout alongside desktop shell, remove legacy sidebar mobile-drawer 2026-04-29 14:18:28 +02:00
Matt Ciaccio
b0a11f1785 feat(mobile): add MobileLayout shell composing topbar + content + bottom tabs + more sheet 2026-04-29 14:16:30 +02:00
Matt Ciaccio
3cbf2444fe feat(mobile): add MoreSheet (3-column grid of long-tail nav items in a bottom drawer) 2026-04-29 14:15:25 +02:00
Matt Ciaccio
0330be1312 feat(mobile): add Drawer (vaul wrapper) for native-feel bottom sheets 2026-04-29 14:14:18 +02:00
Matt Ciaccio
210360738d feat(mobile): add MobileBottomTabs with 5 fixed tabs (Dashboard/Clients/Yachts/Berths/More) 2026-04-29 14:13:09 +02:00
Matt Ciaccio
4df04e1a58 feat(mobile): add MobileTopbar with title, back-button, and primary-action slots 2026-04-29 14:12:15 +02:00
Matt Ciaccio
0c3baf04c5 feat(mobile): add MobileLayoutProvider context + useMobileChrome hook 2026-04-29 14:11:27 +02:00
Matt Ciaccio
79667b24da chore(pwa): add placeholder icons (icon-192/512/512-maskable, apple-touch-icon) 2026-04-29 14:10:14 +02:00
Matt Ciaccio
c4fdb29bbe feat(mobile): render Dialog full-screen below sm, centered modal at sm+ 2026-04-29 14:08:14 +02:00
Matt Ciaccio
38527d71fc feat(mobile): bump touch-target heights on Button/Input/Textarea, keep 16px to prevent iOS zoom 2026-04-29 14:06:59 +02:00
Matt Ciaccio
3fbfba6598 chore(deps): add vaul for native-feel bottom sheets 2026-04-29 14:05:10 +02:00
Matt Ciaccio
e3a835675b feat(mobile): add useIsMobile() hook backed by matchMedia (visual-test-only) 2026-04-29 14:04:02 +02:00
Matt Ciaccio
1b085f81ed feat(mobile): add CSS rules to switch shells based on data-form-factor + viewport 2026-04-29 14:00:49 +02:00
Matt Ciaccio
9f786fbcf3 feat(mobile): set data-form-factor body attr from User-Agent in root layout 2026-04-29 13:59:03 +02:00
Matt Ciaccio
906127a292 feat(mobile): add safe-area spacing utilities (pt-safe-top, pb-safe-bottom, etc.) 2026-04-29 13:56:53 +02:00
Matt Ciaccio
737b43589b feat(mobile): add viewport meta, theme-color, and PWA metadata to root layout 2026-04-29 13:55:37 +02:00
Matt Ciaccio
fbb1f1f366 scaffold(mobile): branch setup — audit harness, spec, plan, gitignore + client-portal cleanup
Pre-execution baseline for the mobile foundation PR:

- Mobile audit harness (tests/e2e/audit/mobile.spec.ts + mobile-audit Playwright project) — visits every page at four anchor iPhone viewports (375/393/402/440), screenshots full-page to .audit/mobile/, generates index.md
- Design spec (docs/superpowers/specs/2026-04-29-mobile-optimization-design.md) — adaptive shell + responsive content; full active-iPhone-range coverage; foundation + per-page migration phases
- Implementation plan (docs/superpowers/plans/2026-04-29-mobile-foundation.md) — 24 TDD tasks for the foundation PR
- .gitignore: ignore /client-portal/ (legacy nested Nuxt repo) and /.audit/ (regenerable screenshots)
- Remove phantom client-portal gitlink (mode 160000 with no .gitmodules)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:49:38 +02:00
Matt Ciaccio
ba89b61b3f fix(security): port-scope clientId/berthId/yachtId on interests + clientRelationships
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m17s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
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>
2026-04-29 04:14:09 +02:00
Matt Ciaccio
4eea19a85b sec: lock down 5 cross-tenant FK gaps from fifth-pass review
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>
2026-04-29 03:28:31 +02:00
Matt Ciaccio
47a1a51832 sec: webhook SSRF guard, IMAP-sync owner check, watcher port membership
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>
2026-04-29 03:15:39 +02:00
Matt Ciaccio
9a5479c2c7 sec: lock down socket.io room subscription + crm-invite cross-tenant ops
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>
2026-04-29 03:00:55 +02:00
Matt Ciaccio
e06fb9545b sec: lock down 5 cross-tenant IDORs uncovered in second-pass review
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>
2026-04-29 02:48:43 +02:00
Matt Ciaccio
4c5334d471 sec: gate super-admin invite minting, OCR settings, and alert mutations
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>
2026-04-29 02:27:01 +02:00
Matt Ciaccio
61e40b5e76 chore(ops): split /api/health (liveness) from /api/ready (readiness)
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>
2026-04-29 02:03:10 +02:00
Matt Ciaccio
7f9d90ad05 fix(gdpr): cap export-bundle size at 50MB before upload
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>
2026-04-29 02:00:16 +02:00
Matt Ciaccio
5d29bfc153 refactor(services): centralize AuditMeta + transactional setEntityTags helper
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>
2026-04-29 01:58:42 +02:00
Matt Ciaccio
43f68ca093 chore(hardening): maintenance jobs, defense-in-depth, redis-backed public rate limit
- maintenance worker now expires GDPR export bundles (db row + MinIO object)
  on the gdpr_exports.expires_at boundary, plus 90-day retention sweep on
  ai_usage_ledger; both jobs scheduled daily.
- portId scoping added to listClientRelationships and listClientExports
  (defense-in-depth — parent-resource gates already prevent cross-tenant
  reads, but service layer should enforce on its own).
- SELECT FOR UPDATE on parent client/company row inside add/update address
  transactions to serialize concurrent isPrimary toggles.
- public /interests + /residential-inquiries endpoints swap their
  in-memory ipHits maps for the redis sliding-window limiter via the
  new rateLimiters.publicForm config (5/hr/IP), so the cap survives
  restarts and is shared across worker processes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:52:41 +02:00
Matt Ciaccio
d9557edfc5 docs(spec): GWS inbox-triage exploratory design (not approved for build)
Surveys what it actually takes to ship the AI inbox-triage feature
gated on Google Workspace integration. Walks through three deployment
models with their real costs:

- Model A (Marketplace OAuth app): 4-6 months calendar, $15k-$75k for
  the required CASA security assessment, recurring re-verification
- Model B (per-customer Internal OAuth app): ~5 weeks engineering, $0
  Google-side, scoped to one workspace per customer
- Model C (forward-to-CRM mailbox): ~1 week, receive-only, no reply
  drafts possible

Recommends Model B for the current customer profile, with B → A
promotion only if 3+ customers ask unprompted.

Documents what's already scaffolded (email_accounts/threads/messages
tables, syncInbox stub, BullMQ email queue, ai_usage_ledger, per-port
aiEnabled flag, withRateLimit('ai')) vs what's new (OAuth flow, Pub/
Sub push receiver, gws_user_tokens + email_triage tables, /inbox UI).

End-to-end flow, schema additions, AI cost interaction with the
Phase 3b token budgets, 5-phase build plan (G1-G5), and 5 open
decisions to resolve before scheduling the build. Explicitly out of
scope: M365, sentiment analysis, smart-drafts, cross-staff triage
queue.

No code changes — this is a design doc to drive a go/no-go decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:18:15 +02:00
Matt Ciaccio
6eb0d3dc92 docs(ops): backup/restore + email deliverability runbooks
Two new runbooks under docs/runbooks/ plus the automation scripts the
backup runbook references. Both are written so an operator who has only
the off-site backup credentials and the runbook can recover the system
unaided.

Backup/restore (Phase 4a):
- docs/runbooks/backup-and-restore.md — covers what gets backed up
  (Postgres / MinIO / .env+ENCRYPTION_KEY), schedule (hourly DB +
  hourly MinIO mirror, 7-day hourly + 30-day daily retention),
  cold-restore procedure with row-count verification, weekly drill
- scripts/backup/pg-backup.sh — pg_dump → gzip → optional GPG → mc
  upload, fails loud
- scripts/backup/minio-mirror.sh — incremental mc mirror, no --remove
  flag so accidental deletes on the live bucket can't cascade
- scripts/backup/restore.sh — interactive prod restore + --drill mode
  that runs against a sandbox DB and diffs row counts

Email deliverability (Phase 4b):
- docs/runbooks/email-deliverability.md — what the CRM sends, DNS
  records (SPF/DKIM/DMARC/MX), per-port override implications,
  diagnosis flow ("didn't arrive" → 4-step checklist starting with
  EMAIL_REDIRECT_TO), provider migration plan, realapi suite as the
  end-to-end probe

Tests still 778/778 vitest, tsc/lint clean — these phases are docs +
shell scripts, no code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:10:30 +02:00
Matt Ciaccio
a3305a94f3 feat(gdpr): staff-triggered client-data export bundle (Article 15)
Adds a full GDPR Article 15 (right of access) workflow. Staff trigger
an export from the client detail; a BullMQ worker assembles every row
keyed to that client (profile, contacts, addresses, notes, tags,
yachts, company memberships, interests, reservations, invoices,
documents, last 500 audit events) into JSON + a self-contained HTML
report, ZIPs them, uploads to MinIO, and optionally emails the client
a 7-day signed download link.

- New table gdpr_exports tracks lifecycle (pending → building → ready
  → sent / failed) with a 30-day cleanup target
- Bundle builder (gdpr-bundle-builder.ts) — pure read-side, tenant-
  scoped, with HTML escaping to block injection from rogue field values
- Worker hook in export queue dispatches on job name 'gdpr-export'
- New audit actions: 'request_gdpr_export', 'send_gdpr_export'
- API: POST/GET /api/v1/clients/:id/gdpr-export (admin-gated, exports
  rate-limit, Article-15 audit on POST); GET /:exportId returns a
  fresh signed URL
- UI: <GdprExportButton> dialog on client detail header — admin-only,
  shows recent exports, supports email-to-client + override recipient,
  polls every 5s while open
- Validation: refuses email-to-client when no primary email + no
  override (rather than silently dropping the send)

Tests: 778/778 vitest (was 771) — +7 covering builder happy path,
HTML escaping, tenant isolation, empty client, request-flow validation,
and audit / queue interaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:06:31 +02:00
Matt Ciaccio
9dfa04094b feat(rate-limit): per-user limiters for OCR, AI, and exports
Adds three named rate limiters to the existing Redis sliding-window
catalog and a withRateLimit wrapper that composes inside withAuth.
Wires the OCR limiter into the receipt-scan endpoint so a runaway
client can't burn through the AI budget in a tight loop.

- ocr: 10/min/user
- ai: 60/min/user (reserved for future server-side AI surfaces)
- exports: 30/hour/user (reserved for GDPR bundle, PDF, CSV exports)

429 responses include X-RateLimit-* headers and a Retry-After hint.

Tests: 771/771 vitest (was 766) — +5 rate-limit tests covering catalog
shape, sliding window, cross-prefix isolation, cross-user isolation,
and resetAt timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:56:01 +02:00
Matt Ciaccio
e7d23b254c feat(ai): per-port token budgets + usage ledger for AI features
Adds a token-denominated guardrail in front of every server-side AI call
so a misconfigured port can't run up an unbounded bill. Soft caps surface
a banner; hard caps refuse new requests until the period rolls over.
Usage flows into a feature-typed ledger so future AI surfaces (summary,
embeddings, reply-draft) can drop in without schema changes.

- New table ai_usage_ledger (port, user, feature, provider, model,
  input/output/total tokens, request id) with two indexes for rollup
- New service ai-budget.service.ts: getAiBudget/setAiBudget,
  checkBudget (pre-flight gate), recordAiUsage, currentPeriodTokens,
  periodBreakdown — all token-based, period boundaries in UTC
- runOcr now returns provider usage so the route can record the actual
  spend instead of estimating
- Scan-receipt route gates on checkBudget before invoking AI; returns
  source: manual / reason: budget-exceeded when blocked, surfaces
  softCapWarning on the success path
- Admin UI: new AiBudgetCard on the OCR settings page — shows current
  spend, per-feature breakdown, soft/hard cap inputs, period selector
- Permission: admin.manage_settings on both routes

Tests: 766/766 vitest (was 756) — +10 budget tests covering enforce/
disabled/cap-exceed/estimate-exceed/soft-warn/period boundaries/
cross-port isolation/silent ledger failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:53:09 +02:00
Matt Ciaccio
2cf1bd9754 feat(ocr): Tesseract.js as default scanner, AI as opt-in per port
The mobile receipt scanner now runs Tesseract.js in-browser by default —
on-device, free, and image bytes never leave the device. AI providers
(OpenAI / Claude) become a per-port opt-in for higher accuracy on
hard-to-read receipts.

- Lazy-load Tesseract WASM in src/lib/ocr/tesseract-client.ts (5 MB
  bundle dynamic-imports on first scan, not in main chunk)
- Heuristic parser src/lib/ocr/parse-receipt-text.ts extracts vendor,
  date, amount, currency, and line items from raw OCR text
- New port-scoped aiEnabled flag on OcrConfig (defaults false). Resolved
  flag never inherits from the global row — each port admin opts in
  independently
- Scan endpoint short-circuits to manual-mode when aiEnabled=false so
  the AI provider is never invoked unless the admin has flipped the
  switch
- Scan UI runs Tesseract first, then asks the server whether AI is
  enabled — uses the AI result only when its confidence beats Tesseract;
  network failures degrade gracefully to the local parse
- Admin OCR-settings form gains the per-port aiEnabled checkbox

Tests: 756/756 vitest (was 747) — +7 parser unit tests, +2 aiEnabled
config tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:46:29 +02:00
Matt Ciaccio
46937bbcb9 feat(addresses): full CRUD UI for client + company multi-address
Client and company detail pages each gain an Addresses tab with click-to-edit
fields wired to the existing CountryCombobox/SubdivisionCombobox primitives.
Adds a primary toggle that demotes the previous primary inside one transaction
so the partial unique index never trips.

- New service helpers: list/add/update/remove ClientAddress + CompanyAddress
- New routes: /api/v1/clients/[id]/addresses[/addressId], same under companies/
- New shared component: <AddressesEditor> reused by both detail surfaces
- Integration tests cover happy path, primary demotion, and tenant scoping

Tests: 747/747 vitest (was 741, +6 address tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:38:43 +02:00
Matt Ciaccio
27cdbcc695 chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).

Migration 0016 drops:
  - clients.nationality
  - companies.incorporation_country
  - client_addresses.{state_province, country}
  - company_addresses.{state_province, country}

Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.

Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.

Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.

Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').

Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
Matt Ciaccio
31fa3d08ec chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.

PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
     phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
     and company.{incorporationCountryIso, incorporationSubdivisionIso}.
     Server-side parsePhone() fallback for legacy raw phone strings.

PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
     'audit.suspicious_login') were registered but evaluators returned [].
     Both required schema/instrumentation that hadn't landed. Removed from
     the registry; comments record the dependencies needed to revive them.
     Effective rule count: 8 active.

PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
     integration test files; webhook-delivery uses vi.hoisted for the
     queue-add ref. Vitest no longer warns about non-top-level mocks.
     Deflaked the 'short value' assertion in security-encryption.test.ts
     by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.

PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
     now filter by isNull(archivedAt). Berths use status (no archivedAt).

PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
     walks every src/app/api/v1/**/route.ts and reports handlers without a
     withPermission() wrapper. Initial run found 33 violations.
     - Allow-listed 17 with explicit reasons (self-data, admin, alerts,
       search, currency, ai, custom-fields — some marked TODO).
     - Wrapped 7 routes with concrete permissions: clients/options
       (clients:view), berths/options (berths:view), dashboard/*
       (reports:view_dashboard), analytics (reports:view_analytics).
     Audit report at docs/runbooks/permission-audit.md. Script exits
     non-zero on any unallow-listed violation so it can become a CI gate.

Vitest: 741 -> 741 (no new tests; existing suite covers the changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
Matt Ciaccio
16d98d630e feat(i18n): country/phone/timezone/subdivision primitives + form wiring
Cross-cutting i18n polish for forms across the marina + residential + company
domains. Introduces a single source of truth for country/phone/timezone/
subdivision data and replaces every nationality-as-free-text and timezone-
as-string Input with a dedicated combobox.

PR1  Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames
     for localized labels, detectDefaultCountry() with navigator-region
     fallback to US, CountryCombobox with regional-indicator flag glyphs +
     compact mode for inline use.
PR2  Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType /
     callingCodeFor), PhoneInput with flag dropdown + national-format
     AsYouType + paste-detect that flips the country dropdown for pasted
     international strings.
PR3  Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/
     ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"),
     TimezoneCombobox with Suggested/All grouping driven by countryHint.
PR4  Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2
     codes for every country), per-country cache, SubdivisionCombobox with
     "Pick a country first" / "No regions available" empty states.
PR5  Schema deltas (migration 0015) — clients.nationality_iso, clientContacts
     {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso},
     residentialClients {phone_e164, phone_country, nationality_iso, timezone,
     place_of_residence_country_iso, subdivision_iso}, companies {incorporation_
     country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso,
     subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used
     by every entity validator + route handler.
PR6  ClientForm + ClientDetail — CountryCombobox replaces nationality Input,
     TimezoneCombobox replaces timezone Input (driven by nationalityIso hint),
     PhoneInput conditionally rendered for phone/whatsapp contacts. Inline
     editors (InlineCountryField / InlineTimezoneField / InlinePhoneField)
     for the detail-page overview rows + ContactsEditor.
PR7  Residential client form + detail — phone -> PhoneInput, nationality/
     timezone/place-of-residence-country/subdivision rows in both create
     sheet and inline-editable detail view. Subdivision wipes when country
     flips since codes are country-scoped.
PR8  Company form + detail — incorporation country -> CountryCombobox,
     incorporation region -> SubdivisionCombobox in both modes.
PR9  Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry
     and i18n fields from newer website builds, server-side parsePhone()
     fallback for legacy raw-international submissions. Old Nuxt builds
     keep working unchanged.

Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for
the public phone-normalization path (3 tests), 1 smoke spec asserting the
combobox triggers render in all three create sheets.

Test totals: vitest 713 -> 741 (+28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:13:08 +02:00
Matt Ciaccio
f52d21df83 feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.

PR4  Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
     date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5  Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
     right-rail, three-tab page (active/dismissed/resolved), socket-driven
     invalidation. Bell lazy-loads list on popover open to keep cold pages
     fast in non-dashboard routes.
PR6  EOI queue tab on documents hub — filters to in-flight EOIs, count
     surfaces in tab label.
PR7  Interests-by-berth tab on berth detail — replaces the stub.
PR8  Expense duplicate detection — BullMQ job runs scan on create, yellow
     banner on detail w/ Merge / Not-a-duplicate, transactional merge
     consolidates receipts and archives the source.
PR9  Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
     its own (scanner) group with no dashboard chrome, dynamic per-port
     manifest, OpenAI + Claude provider abstraction, admin OCR settings
     page (port-level + super-admin global default w/ opt-in fallback),
     test-connection endpoint, manual-entry fallback when no key is
     configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
     existing GIN index, cursor pagination, filters for entity/action/user
     /date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
     real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
     socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
     cleanly without their gate envs so CI stays green.

Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
Matt Ciaccio
2fa70f4582 merge: PR3 — analytics snapshot service + refresh job (Phase B)
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m1s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:48 +02:00
Matt Ciaccio
01b201e1a2 feat(analytics): real computations + 15-min snapshot refresh job
PR3 of Phase B. Replaces the no-op stubs in analytics.service.ts with
working drizzle queries and adds the recurring BullMQ job that warms
the cache.

Computations:
- computePipelineFunnel: groups interests by pipeline_stage filtered by
  port + range + not archived; emits 8-row stages array with conversion
  pct relative to 'open' as the funnel top.
- computeOccupancyTimeline: per day in range, counts berths covered by
  an active reservation (start_date ≤ day, end_date IS NULL OR ≥ day);
  emits {date, occupied, total, occupancyPct}.
- computeRevenueBreakdown: sums invoices.total grouped by status +
  currency; filters out archived rows.
- computeLeadSourceAttribution: counts interests by source descending;
  null source bucketed as 'unspecified'.

Public API (getPipelineFunnel, getOccupancyTimeline, etc.) reads
analytics_snapshots first; falls back to compute + writeSnapshot. TTL
15 minutes (matches the cron interval).

Cron:
- queue/scheduler.ts registers 'analytics-refresh' on maintenance with
  pattern '*/15 * * * *'.
- queue/workers/maintenance.ts dispatches to refreshSnapshotsForPort
  for every port; per-port try/catch so one bad port doesn't kill the
  sweep.

Tests: tests/integration/analytics-service.test.ts (9 cases). Pipeline
funnel math (incl. zero state), occupancy timeline shape/percentages
with seeded reservations, revenue grouped by status + currency, lead
source attribution incl. null bucketing, cache hit (mutate snapshot
directly → next read returns mutated value), refreshSnapshotsForPort
warms every metric×range combo.

Vitest 690/690 (+9). tsc + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:46 +02:00
Matt Ciaccio
94f049c8b8 merge: PR2 — alert rules engine + cron + socket (Phase B)
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m3s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:57 +02:00
Matt Ciaccio
df495133b7 feat(alerts): rule engine, recurring evaluator, socket fanout
PR2 of Phase B. Wires the alert framework end-to-end:

- alert-rules.ts: 10 rule evaluators implemented as pure async fns over
  the existing schema. reservation.no_agreement, interest.stale,
  document.signer_overdue, berth.under_offer_stalled, expense.duplicate,
  expense.unscanned, interest.high_value_silent, eoi.unsigned_long,
  audit.suspicious_login fire against real conditions.
  document.expiring_soon stays inert until the documents schema gets an
  expires_at column. audit.suspicious_login also stays inert until the
  auth layer logs 'login.failed' rows (TODO noted in the rule body).

- alert-engine.ts: runAlertEngine() walks every port × every rule and
  calls reconcileAlertsForPort. Errors per (port, rule) are collected
  in the summary, not thrown — one bad evaluator can't stop the sweep.

- alerts.service.ts: reconcileAlertsForPort now emits 'alert:created'
  socket events on insert and 'alert:resolved' on auto-resolve;
  dismissAlert emits 'alert:dismissed'. All scoped to port:{portId}
  rooms.

- socket/events.ts: adds the three Server→Client alert event types.

- queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance
  queue with cron */5 * * * * (every 5 min, per spec risk register).

- queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to
  runAlertEngine; logs sweep summary.

Tests:
- tests/integration/alerts-engine.test.ts (6 cases): seeds reservation
  → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds
  stale interest → fires; hot lead silent → critical; engine summary
  shape on no-data port. Socket emit module is vi.mocked.

Vitest 681/681 (was 675; +6). tsc clean. Lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
Matt Ciaccio
639025ebf9 merge: PR1 — Phase B schema + service skeletons (Phase B)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:43:03 +02:00
Matt Ciaccio
e77d55ac50 feat(insights): Phase B schema + service skeletons
PR1 of Phase B per docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md.
Lays the foundation that PRs 2-10 will fill in with behaviour.

Schema (migration 0014):
- alerts table with rule-engine fields (rule_id, severity, link,
  entity_type/id, fingerprint, fired/dismissed/acknowledged/resolved
  timestamps, jsonb metadata). Partial-unique fingerprint index keeps
  one open row per (port, rule, entity); separate indexes power
  severity-filtered and time-ordered queries.
- analytics_snapshots (port_id, metric_id) -> jsonb cache + computedAt
  for the 15-min recurring refresh.
- expenses: duplicate_of self-FK, dedup_scanned_at, ocr_status/raw/
  confidence; partial index on (port, vendor, amount, date) where
  duplicate_of IS NULL drives the dedup heuristic.
- audit_logs.search_text: GENERATED ALWAYS tsvector over
  action+entity_type+entity_id+user_id, GIN-indexed (drizzle can't
  model GENERATED ALWAYS in TS yet, so the migration appends manual
  ALTER + the GIN index).

Service skeletons in src/lib/services/:
- alerts.service.ts: fingerprintFor, reconcileAlertsForPort (upsert +
  auto-resolve), dismiss, acknowledge, listAlertsForPort.
- alert-rules.ts: RULE_REGISTRY of 10 rule evaluators (currently no-op);
  PR2 fills in the bodies.
- analytics.service.ts: readSnapshot/writeSnapshot with 15-min TTL +
  no-op compute* stubs for the four chart series; PR3 fills behavior.
- expense-dedup.service.ts: scanForDuplicates + markBestDuplicate
  using the partial dedup index. PR8 wires the BullMQ trigger.
- expense-ocr.service.ts: OcrResult/OcrLineItem types + ocrReceipt
  stub. PR9 wires Claude Vision (Haiku 4.5 + ephemeral system-prompt
  cache).
- audit-search.service.ts: tsvector @@ plainto_tsquery + cursor
  pagination on (createdAt, id). PR10 wires the admin UI.

tsc clean, lint clean, vitest 675/675 (one unrelated AES random-output
flake passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:43:01 +02:00
Matt Ciaccio
f1ed2a5f87 docs(spec): Phase B — insights, alerts, and operational awareness
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m4s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Full execution plan for the next phase. Closes the seven priority gaps
the 2026-04-28 Nuxt→Next audit surfaced (analytics, alerts,
interests-by-berth, expense dedup, EOI queue, OCR, audit log read view).

Scope:
- Analytics dashboard with KPI tiles, pipeline funnel, occupancy
  timeline, revenue breakdown, lead-source attribution; cached via
  `analytics_snapshots` recurring job.
- Alert framework: 10-rule v1 catalog, rule engine evaluates on cron,
  fingerprint dedupes, auto-resolves when condition clears, surfaces
  in dashboard right rail + dedicated /alerts page.
- Interests-by-berth tab on berth detail.
- Expense duplicate detection (vendor + amount + date ±3d) with
  merge action.
- OCR for expense receipts via Claude Vision (Haiku 4.5 + ephemeral
  system-prompt cache).
- Audit log admin read view with tsvector search + cursor pagination.
- EOI queue: saved-view tab on the documents hub.

11 PRs, ~10-13 dev days, calendar 2.5-3 weeks. Critical path
graphed. Risk register includes alert false-positive mitigation,
OCR cost ceiling via Haiku + cache, and audit-log scale.

Four open questions for the user in the spec footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:00:01 +02:00
Matt Ciaccio
4036c16f39 test(infra): vitest globalSetup teardown purges test-port-* leaks
Integration tests use makePort() which writes ports with slug 'test-port-{rand}'
and never cleans up. Result: 17,564 leaked rows in dev that slowed every page
load fetching the port-switcher list (and was contributing to smoke flakes).

Adds tests/global-setup.ts with a teardown() that DELETEs every 'test-port-%'
row plus its dependent rows across 30+ tables in one CTE. Wires it into
vitest.config.ts via globalSetup. Adds closeDb() helper so the teardown can
end the postgres-js pool cleanly (kills the 'Tests closed but Vite server
won't exit' warning).

Also lands docs/superpowers/specs/2026-04-28-country-phone-timezone-design.md
— full-scope agenda for the country dropdown / E.164 phone input /
country-driven timezone autofill work, ~7 dev days across 10 PRs. Per
user request: 'let's do this full-fledged if we're gonna do it'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:28:15 +02:00
Matt Ciaccio
5f9bbb97bd fix(sidebar): replace floating circular collapse button with blended row
User feedback: the circular toggle floating off the sidebar's right
edge looked tacked-on. Replaced with a flush full-width row above the
user footer (right-aligned 'Collapse <' chip when expanded; centered
chevron when collapsed). Same nav-item hover treatment so it merges
visually with the sidebar palette. The <aside> no longer needs to
host an overhanging button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:00:20 +02:00
Matt Ciaccio
4911083d0f fix(visual): KPITile data-testid + restore residential interest casing
Post-PR10c follow-ups discovered during smoke triage:
- KPITile gets data-testid="kpi-tile" so the dashboard smoke spec's
  '[data-testid*="kpi"]' selector matches (test 10-dashboard:27 expected
  >=4 kpi cards; the old Card-based render was matched by the
  '[class*="card"]' branch and didn't need a testid).
- Residential interest detail eyebrow text reverted from "Residential
  Interest" to "Residential interest" (lowercase i). The visual is
  identical because the wrapper has the `uppercase` class; the smoke
  spec at 26-residential:140 looks for the literal lowercase string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:56:32 +02:00
Matt Ciaccio
3a7fef59b0 fix(visual): dark-mode-safe borders + sidebar relative + ring-background
Code-review follow-up to PR10b-e:
- DetailHeaderStrip + KPITile: border-slate-200 → border-border so dark
  mode doesn't paint a bright halo around the gradient strip.
- Topbar avatar: ring-white → ring-background so the 2px ring tracks
  the surface (matches the sidebar footer pattern).
- KpiTileSkeleton stripe: bg-slate-100 → bg-muted for parity with
  shadcn skeleton tokens in dark mode.
- Sidebar <aside>: add `relative` so the absolute-positioned
  collapse-toggle button anchors to the sidebar itself rather than
  the nearest positioned ancestor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:24:14 +02:00
Matt Ciaccio
c081334020 merge: PR10e — visual polish (mobile responsive sweep) (Phase A)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:22 +02:00
Matt Ciaccio
2d1b50745a style(mobile): responsive tabs + table overflow + hub flex-wrap (Phase A)
Adds <ResponsiveTabs> primitive that swaps the TabsList for a native Select on
phone-sized viewports (<640px). DetailLayout now routes its tab strip through it,
so every tabbed detail page gets the collapse for free. DataTable wraps the
Table in overflow-x-auto so wide column sets scroll horizontally instead of
breaking the layout under 768px. Documents-hub row swaps the fixed
grid-cols-[auto_1fr_auto_auto_auto_auto] for flex-wrap below sm: so signers /
status / dates stack instead of clipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:21 +02:00
Matt Ciaccio
40ae860a88 merge: PR10d — visual polish (sidebar/topbar) (Phase A)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:12 +02:00
Matt Ciaccio
c7ca7c1f96 style(layout): sidebar stripe + topbar gradient + bell spring + search ring
Sidebar active items: 4px brand left-edge stripe (rounded-r-full) replacing the
border-l-2 + bg shift; section header smaller-caps + brand-200 colour; user-footer
avatar gets shadow-sm + ring-2 ring-white/30.

Topbar '+ New' uses bg-gradient-brand with shadow-sm + scale-1.02 hover. User
avatar trigger gets shadow-sm + ring-2 ring-white. Notification badge gets
gradient-brand fill + ring-2 ring-background + animate-badge-pop spring keyframe
(retriggers on count change via key={unreadCount}). Command search gets shadow-xs
inset + brand focus ring (ring-4 ring-brand/15).

Adds badge-pop keyframes to tailwind config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:11 +02:00
Matt Ciaccio
22b019a27e merge: PR10c — visual polish (dashboard) (Phase A)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:00 +02:00
Matt Ciaccio
a3424b80d5 style(dashboard): KPITile primitive + gradient PageHeader + tile skeletons
Replaces flat Card-based KPI rendering with KPITile (gradient-brand-soft + accent
stripe). Adds polished gradient PageHeader to DashboardShell with eyebrow, KPI
sub-line, description. Tile-shaped skeletons replace the four CardSkeletons during
KPI load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:09:59 +02:00
Matt Ciaccio
5bcdfefde3 merge: PR10b — visual polish (detail pages) (Phase A)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:09:54 +02:00
Matt Ciaccio
22f944fde2 style(detail): apply gradient header strip to client/interest/yacht/company/berth/residential/invoice details
Adds shared <DetailHeaderStrip> wrapper (rounded-xl + gradient-brand-soft + shadow-xs)
and applies it to every legacy domain detail header. Residential client/interest and
invoice detail get an inline gradient strip with eyebrow ('Residential Client',
'Residential Interest', 'Invoice'). Residential bodies normalized to lg:grid-cols-[2fr_1fr]
per spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:09:47 +02:00
Matt Ciaccio
cda44e721b fix(layout): hoist TooltipProvider to wrap full sidebar tree
The collapsed-state user-footer renders a Tooltip that was outside the
TooltipProvider — the provider only wrapped the nav. Once the sidebar
toggled to collapsed, the footer Tooltip threw "Tooltip must be used
within TooltipProvider", surfacing as console errors in exhaustive
click-through tests.

Move TooltipProvider up one level so every Tooltip in the sidebar tree
(nav items + user footer) is covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 05:08:01 +02:00
Matt Ciaccio
0406778c44 fix(api): kill currentPortId persist race + dedupe admin/ports stampede
The dashboard and residential interest smoke tests were intermittently
failing with the page rendering empty/skeleton state. Root causes:

1. ui-store persisted currentPortId/Slug, but those are URL-derived state.
   After login lands on /<first-port-by-name>/dashboard, localStorage holds
   that port. Hard-navigating to /port-nimara/... rehydrated the store with
   the stale id, and useQuery fired with the wrong port before
   PortProvider's URL-sync useEffect could correct it. Drop both fields
   from partialize — PortProvider re-derives them from the route every
   navigation.

2. apiFetch's slug-to-port fallback fired N parallel /api/v1/admin/ports
   calls when N components mounted simultaneously with an empty store.
   Dedupe in-flight lookups so a stampede collapses into one round-trip.

Also tightened four flaky smoke tests that depended on a fixed 3s wait or
non-waiting isVisible({timeout}) — replaced with expect(...).toBeVisible
or expect.poll so they handle dev-mode JIT cold-start delays cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 04:38:57 +02:00
Matt Ciaccio
259cd7b8bb merge: PR11 — realapi spec scaffolds (Phase A) 2026-04-28 02:53:55 +02:00
Matt Ciaccio
e42b8fde84 test(realapi): Phase A integration spec scaffolds
Adds four realapi specs that gate cleanly on env: smtp-system-send
(roundtrip + attachment bytes), email-attachments-roundtrip
(cross-port 403), documenso-cancel (in-flight cancel → status flip),
minio-file-lifecycle (upload/list/download/delete byte-equal).
Specs skip without DOCUMENSO_API_*/SMTP/IMAP/MINIO env so they run as
no-ops when those services aren't reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:53:49 +02:00
Matt Ciaccio
f354f4adab merge: PR10 — visual polish (lists) (Phase A) 2026-04-28 02:52:26 +02:00
Matt Ciaccio
38cd36a616 style(lists): apply gradient PageHeader to client/interest/yacht/company/berth lists
Pulls the polished gradient hero strip into the five primary list
surfaces. PR10b-e (detail polish, dashboard/admin polish, email +
notifications polish, mobile responsive sweep) deferred to a follow-up
release per spec risk register since visual baseline regen needs hands-
on iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:52:17 +02:00
Matt Ciaccio
77b6ef5026 merge: PR9 — reminder framework polish (Phase A) 2026-04-28 02:50:06 +02:00
Matt Ciaccio
978df1c4d7 feat(reminders): cadence-aware framework with auto/manual modes
isReminderDue now keys off doc.remindersDisabled and the effective
cadence (per-doc override → template default), dropping the implicit
interests.reminderEnabled gate so non-EOI docs auto-remind correctly.
sendReminderIfAllowed gains an options bag — auto:true keeps the 9-16
window + cadence cooldown for the cron, auto:false bypasses both for
manual UI sends. signerId targets a specific pending signer (must be
next in sequential mode). 7 unit tests cover the cadence math.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:50:00 +02:00
Matt Ciaccio
df0b408b7a merge: PR8 — email attachments + system/user senderType (Phase A) 2026-04-28 02:48:17 +02:00
Matt Ciaccio
1151768159 feat(email): system/user senderType + attachments
Composer validator now takes senderType (system|user) and an
attachments[] array, and the service dispatches across two paths:
the system path uses lib/email/index.ts with port-config noreply
identity and logs signed_doc_emailed when an attachment matches a
document's signed PDF; the user path stays on the existing personal-
account flow but is gated by the new email.allowPersonalAccountSends
toggle and the attachment fileIds are persisted on email_messages.
sendEmail in lib/email accepts attachments and resolves them from
MinIO with cross-port enforcement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:48:11 +02:00
Matt Ciaccio
9e69c13202 merge: PR7 — reservation detail + agreement (Phase A) 2026-04-28 02:45:11 +02:00
Matt Ciaccio
6212c118e5 feat(reservations): detail page with agreement flow + contract mirror
Adds /berth-reservations/[id] with state-aware agreement card (none /
in-flight / completed) and the Generate-agreement entry point that
opens the wizard prefilled. handleDocumentCompleted now mirrors a
signed reservation_agreement onto berth_reservations.contractFileId
so the portal can resolve contracts without joining through documents.
Reservation merge tokens (startDate/endDate/tenureType/termSummary/
signedDate) added to the catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:45:05 +02:00
Matt Ciaccio
6795db9aa8 merge: PR6 — create-document wizard MVP (Phase A) 2026-04-28 02:43:05 +02:00
Matt Ciaccio
d8f0cdd7d2 feat(documents): create-document wizard MVP + service dispatch
Implements createFromWizard and createFromUpload service paths covering
the documenso-template, in-app, and upload pathways. Persists subject
FK, signers, watchers, and the per-document reminder controls
(remindersDisabled / reminderCadenceOverride) introduced in PR1. New
POST /api/v1/documents/wizard route and a functional /documents/new UI
with type/source/template/signers/reminders sections. Drag-handle
reorder, watcher autocomplete picker, and PDF preview defer to the
PR10 polish sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:43:00 +02:00
Matt Ciaccio
2dc53842c0 merge: PR5 — document detail page (Phase A) 2026-04-28 02:39:52 +02:00
Matt Ciaccio
aa15807063 feat(documents): detail page with signers, watchers, activity, actions
Replaces the PR4 stub at /documents/[id] with the full Phase A detail
view: gradient header strip, status-aware action bar (Cancel /
Download / Email signatories), per-signer remind + copy-link, watcher
list with remove, and activity timeline. Adds the supporting endpoints
(cancel, compose-completion-email, watchers GET/POST/DELETE) and
listDocumentWatchers / addDocumentWatcher / removeDocumentWatcher
service helpers. The document GET now serves the aggregator shape
when ?detail=true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:39:46 +02:00
Matt Ciaccio
2a3fae4d6a merge: PR4 — documents hub page (Phase A) 2026-04-28 02:35:43 +02:00
Matt Ciaccio
da7262f18f feat(documents): hub page with tabs, filters, and live counts
Replaces /documents with the Phase A hub: tabs (All/Awaiting them/
Awaiting me/Completed/Expired) backed by per-tab counts via a new
hub-counts endpoint, signature-only chip, type filter, expandable
signer rows, and real-time invalidation across the eight document
socket events. listDocuments grew tab/watcher/signatureOnly/sent-window
filters; the legacy file browser moved to /documents/files where the
sidebar already linked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:35:36 +02:00
Matt Ciaccio
398d6322f1 merge: PR3 — visual primitives + tokens (Phase A) 2026-04-28 02:25:14 +02:00
Matt Ciaccio
deafc5ef38 feat(ui): visual polish primitives + token additions (Phase A)
Adds the design tokens the polish PRs (10a-e) will draw from:
shadow-xs/sm/md/lg/glow, radius scale tuned to spec, gradient utilities,
spring/smooth eases, and fast/base/slow durations. Introduces
StatusPill, KPITile, and EmptyState primitives plus a polished
PageHeader variant ('gradient') with optional eyebrow + KPI sub-line —
existing PageHeader callers stay on the plain variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:25:08 +02:00
Matt Ciaccio
9b87b14c99 merge: PR2 — Documenso v1/v2 abstraction (Phase A) 2026-04-28 02:22:11 +02:00
Matt Ciaccio
da44e8ecbe feat(documenso): version-aware field placement + void abstractions
Adds DOCUMENSO_API_VERSION env (default v1) plus per-port override.
Introduces placeFields, placeDefaultSignatureFields, and voidDocument
that hide v1 (per-field POST, pixel coords) vs v2 (bulk POST, percent +
fieldMeta) differences. cancelDocument now voids in Documenso first and
treats transient void failures as recoverable so the CRM stays the
system of record. 16 unit specs cover dispatch, layout math, idempotent
404, and v1 pixel conversion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:22:04 +02:00
Matt Ciaccio
af2db06244 merge: PR1 — data model + service skeletons (Phase A) 2026-04-28 02:12:14 +02:00
Matt Ciaccio
0eff6050ae feat(documents): Phase A schema + service skeletons
Adds Phase A data model deltas to documents/templates and the new
document_watchers table. Introduces createFromWizard/createFromUpload
stubs, getDocumentDetail aggregator, cancelDocument flow, signed-doc
email composer, reservation agreement context, and notifyDocumentEvent
fan-out. Validator update accepts new template formats with html-only
bodyHtml requirement. EOI cadence backfilled to 1 day to preserve
current effective behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:12:05 +02:00
Matt Ciaccio
d8ac62f6f4 docs(spec): documents hub + reservation agreements + visual polish (Phase A)
Captures the brainstorm output covering:
- Documents hub at /[port]/documents replacing existing list
- Document detail page with vertical signers panel, watchers, timeline
- Generalised create-document wizard (HTML / PDF AcroForm / PDF overlay /
  Documenso-rendered + ad-hoc PDF upload)
- Reservation agreements as a doc type with new CRM-side detail page
- Email composer attachments + System-vs-User From selector (admin-gated)
- Reminder framework polish (per-template cadence, per-doc override, per-doc
  disable, per-signer manual reminders); drops interests.reminderEnabled gating
- Documenso v1.13.1/v2.x version-aware abstraction for field placement + void
- System-wide visual polish (token additions, primitive components, sweep)
- Test plan including click-everything sweep + expanded realapi round-trip
- Build sequence: 11 PRs, ~3.5 weeks critical path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:51:41 +02:00
Matt Ciaccio
dd138547fb test(e2e): fix admin-nav locator + add residential interest API coverage
- 21-role-based-ui: tighten the Settings link locator. The previous
  `getByRole('link', { name: /settings/i }).first().or(getByText(/.../) .first())`
  chain hit a strict-mode violation once the sidebar Admin section became
  default-expanded — both the section header text node and the Settings
  link matched. Match the link directly with exact: true.
- 26-residential: extend smoke with two API-driven specs covering the
  residential interest pipeline — create+list and detail-page render —
  using preferences-string stamp + heading match for assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:19:51 +02:00
Matt Ciaccio
1791dd7319 fix(ui): resolve yacht owner names server-side, real user in topbar
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m1s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Yachts list page rendered each row's Current Owner via OwnerLink, which
fired its own /api/v1/clients/{id} or /companies/{id} fetch — N+1 round-
trips per page load (12+ for the harbor-royale fixture). Worse, until
those fetches resolved each cell showed "Client c68da7..." style raw IDs.

Fix: listYachts now resolves the polymorphic currentOwnerName in two
batched in-array queries after the page query (mirrors the listClients
yachtCount/companyCount pattern), and OwnerLink accepts an optional
preloadedName prop that suppresses the per-row fetch when supplied.

Topbar: show real user name + avatar initial from session/profile, and
expand the My-Account dropdown header to include the user's email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:54:04 +02:00
Matt Ciaccio
0ccc66833d fix(ui): admin settings loading-loop, real user name, expanded admin nav
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m0s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
SettingsFormCard
- Parent components pass `FIELDS.slice(...)` inline, so the prop reference
  changes on every render. The fetch callback's useCallback re-created
  itself, useEffect re-fired, and loading flicker meant the form never
  rendered. Capture fields in a ref so the callback is stable.

Sidebar
- Show real user name + avatar initial from session/profile, replacing
  the hardcoded "User Name" / "U" placeholder.
- Default the admin-section to expanded so its items are reachable on
  first page load (was collapsed behind a chevron).

Dashboard layout
- Pass {name, email} from the session/profile through to <Sidebar />.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:44:04 +02:00
Matt Ciaccio
4877b97f27 feat(admin): per-port email/Documenso/branding/reminder settings + invitations
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m1s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Centralizes everything operators need to configure into the admin panel,
each setting per-port with env fallback.

New admin pages
- /admin              landing page linking to every admin section as a card
- /admin/email        FROM name+address, reply-to, signature/footer HTML,
                      optional SMTP host/port/user/pass override
- /admin/documenso    API URL+key override, EOI Documenso template ID,
                      default EOI pathway (documenso-template vs inapp),
                      "Test connection" button
- /admin/branding     logo URL, primary color, app name, email
                      header/footer HTML
- /admin/reminders    port-level defaults for new interests +
                      port-wide daily-digest delivery window
- /admin/invitations  send / list / resend / revoke CRM invitations

Per-user reminder digest
- /notifications/preferences gains a Reminder digest card:
  immediate / daily / weekly / off, with HH:MM, day-of-week,
  IANA timezone fields. Stored in user_profiles.preferences.reminders.

Plumbing
- port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig,
  getPortBrandingConfig, getPortReminderConfig) — settings → env fallback.
- sendEmail accepts optional portId; resolves From/SMTP from settings
  when supplied.
- documensoFetch + downloadSignedPdf accept optional portId; each public
  function takes it through. checkDocumensoHealth() backs the test button.
- crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite
  with audit-log entries (revoke_invite, resend_invite added to AuditAction).
- AdminLandingPage card grid + shared SettingsFormCard component to remove
  per-page form boilerplate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
Matt Ciaccio
f2c57c513e feat(queue): implement form-expiry-check maintenance job
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m0s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Marks pending form_submissions whose expires_at has passed
as 'expired'. Logs the count of rows transitioned each run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:58:14 +02:00
Matt Ciaccio
999622fd08 feat(companies): show member + yacht counts on list page
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 59s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
listCompanies returns memberCount (active companyMemberships)
and yachtCount (yachts where currentOwnerType=company), each
fetched as a parallel grouped count after the main page query.
Two new badge columns in company-columns render them between
the tax-id and status columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:57:13 +02:00
Matt Ciaccio
e8d61c91c4 feat(platform): residential module + admin UI + reliability fixes
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Residential platform
- New schema: residentialClients, residentialInterests (separate from
  marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint

Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)

Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
  handlers.ts files (Next.js 15 route.ts only allows specific exports)

Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
  (apiFetch already JSON.stringifies its body; passing a stringified
  body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
  to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
  Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md

Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
Matt Ciaccio
fac8021156 docs: reflect testing infra + Documenso/portal auth conventions in CLAUDE.md
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 59s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
- Quick reference: add commands for every Playwright project + dev tsx helpers
- Conventions: document the Documenso webhook auth pattern (X-Documenso-Secret
  plaintext, not HMAC), the v1.13/2.x response shape normalization layer,
  the email template module location + responsive table layout, and the
  PortalAuthShell pattern that unifies the in-app and email branding
- Environment: document EMAIL_REDIRECT_TO and IMAP_* dev/test-only vars
- New Testing section enumerating the five Playwright projects (setup,
  smoke, exhaustive, destructive, realapi, visual) and what each covers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:48:18 +02:00
Matt Ciaccio
ea8181d108 test(visual): regression baselines for stable list/landing pages
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m7s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
New `visual` project covers six low-volatility screens — portal login,
dashboard, and the four core lists (clients/yachts/berths/invoices) —
with full-page screenshots that diff to a 2% pixel-ratio tolerance.
Animations and the cursor caret are disabled inline so transient
rendering doesn't trigger flaky diffs.

Detail screens (yacht detail, EOI dialog, invoice form steps) are
intentionally deferred until we have stable per-id fixtures so
snapshots don't drift with seed data.

Regenerate with: pnpm exec playwright test --project=visual --update-snapshots

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:42:40 +02:00
Matt Ciaccio
65b241805e test(portal): IMAP full-lifecycle activation E2E + dev probe helper
New realapi spec walks the entire portal activation loop over real
network: invite via the admin endpoint → wait for the activation email
to land in the IMAP mailbox → extract the token from the body link →
activate the portal user via the public API → sign in with the new
password.

The match logic deliberately doesn't filter on the TO header — the
combination of EMAIL_REDIRECT_TO rewriting and +addressing made TO
matching brittle. Instead we discriminate by sender (noreply@…),
subject keyword, and body link pattern, which is unique enough to find
exactly the email this test triggered.

Companion script scripts/dev-imap-probe.ts dumps the most recent ~10
messages with from/to/subject/date — useful for debugging when an IMAP
match goes wrong.

Skips when IMAP_HOST / IMAP_USER / IMAP_PASS are absent so the suite
stays portable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:40:28 +02:00
Matt Ciaccio
4a859245b7 test(documenso): real-API E2E spec + 2.x response normalization
The documenso-template pathway was returning 201 with documensoId=null
because Documenso 2.x renamed `id` → `documentId` and recipient `id` →
`recipientId` in its API responses. Our DocumensoDocument interface
still expected the legacy v1.13 shape, so destructuring silently yielded
undefined and the documents row got NULL'd.

- Add normalizeDocument() in documenso-client that reads either field
  name and surfaces the legacy `id` form downstream consumers expect
- Apply normalization at every callsite that returns DocumensoDocument
  (createDocument, generateDocumentFromTemplate, sendDocument, getDocument)
- New realapi Playwright project (opt-in: --project=realapi) targeting
  tests/e2e/realapi/, with 2-min timeout for real-network calls
- New spec: documenso-real-api.spec.ts seeds client/yacht/berth/interest
  via the v1 API, fires generate-and-sign through the documenso-template
  pathway, asserts the response carries a documensoId, then GETs the
  document directly from Documenso to confirm it exists with PENDING
  status and recipients populated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:25:06 +02:00
Matt Ciaccio
4441f1177f feat(portal): branded auth pages + legacy email styling + dev redirect override
- New PortalAuthShell component: blurred Port Nimara overhead background +
  circular logo + white rounded card, used by /portal/login,
  /portal/activate, /portal/reset-password
- New email/templates/portal-auth.ts: table-based, responsive (max-width
  600px / width 100%), matching the existing legacy inquiry templates;
  replaces the inline templates that lived in portal-auth.service
- EMAIL_REDIRECT_TO env override: when set, sendEmail routes every
  outbound message to that address regardless of recipient and tags the
  subject with "[redirected from <original>]". Dev/test safety net only;
  unset in production
- Portal password minimum length 12 → 9 (service + both API routes +
  client-side form)
- Dev helper script scripts/dev-trigger-portal-invite.ts: seeds a portal
  user against the first port-nimara client and uses EMAIL_REDIRECT_TO
  as the stored email so the tester can sign in with the address that
  received the activation mail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:04:21 +02:00
Matt Ciaccio
c4085265ff fix(documenso): align webhook receiver with Documenso v1.13 + 2.x protocol
Documenso authenticates outbound webhooks via the X-Documenso-Secret
header carrying the plaintext secret (no HMAC). The previous receiver
verified an HMAC against a non-existent x-documenso-signature header
and switched on parsed.type, neither of which Documenso emits — so
every real delivery was being silently rejected.

- Read X-Documenso-Secret, compare timing-safe to env secret
- Switch on parsed.event with uppercase normalization for both v1.13
  (DOCUMENT_SIGNED) and 2.x (lowercase-dotted UI labels) wire formats
- Alias DOCUMENT_RECIPIENT_COMPLETED to DOCUMENT_SIGNED (same
  semantics across versions)
- Handle DOCUMENT_OPENED / DOCUMENT_REJECTED / DOCUMENT_CANCELLED in
  addition to the existing DOCUMENT_SIGNED + DOCUMENT_COMPLETED paths
- Bypass session middleware for /api/webhooks/* (signature is the auth)

Verified end-to-end against signatures.letsbe.solutions: real
DOCUMENT_RECIPIENT_COMPLETED + DOCUMENT_COMPLETED deliveries now pass
secret verification, dispatch correctly, and the handler updates
state (or warns gracefully when the documensoId is unknown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:46:48 +02:00
Matt Ciaccio
475b051e29 feat(portal): replace magic-link with email/password + admin-initiated activation
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m0s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
The client portal no longer uses passwordless / magic-link sign-in. Each
client now has a `portal_users` row with a scrypt-hashed password,
created by an admin from the client detail page; the admin's invite
mails an activation link that the client uses to set their own password.
Forgot-password is wired through the same token mechanism.

Schema (migration `0009_outgoing_rumiko_fujikawa.sql`):

- `portal_users` — one per client account, separate from the CRM
  `users` table (better-auth) so the auth realms stay isolated. Email
  is globally unique, password is null until activation.
- `portal_auth_tokens` — single-use activation / reset tokens. Stores
  only the SHA-256 hash so a DB compromise never leaks live tokens.

Services:

- `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps;
  uses node:crypto), token mint+hash helpers.
- `src/lib/services/portal-auth.service.ts` — createPortalUser,
  resendActivation, activateAccount, signIn (timing-safe),
  requestPasswordReset, resetPassword. Auth failures throw the new
  UnauthorizedError (401); enumeration-safe behaviour everywhere.

Routes:

- POST /api/portal/auth/sign-in — sets the existing portal JWT cookie.
- POST /api/portal/auth/forgot-password — always 200.
- POST /api/portal/auth/reset-password — token + new password.
- POST /api/portal/auth/activate — token + initial password.
- POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`).
- Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link).

UI:

- /portal/login — replaced email-only magic-link form with email +
  password + "forgot password" link.
- /portal/forgot-password, /portal/reset-password, /portal/activate — new.
- New shared `PasswordSetForm` component used by activate + reset.
- New `PortalInviteButton` rendered on the client detail header.

Email send:

- `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are
  set (gmail app-password or marina-server creds, configured via env).
- `SMTP_FROM` env var lets the sender address be overridden without
  pinning it to `noreply@${SMTP_HOST}`.

Tests:

- Smoke spec 17 (client-portal) updated to the new flow: 7/7 green.
- Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to
  match the post-refactor client + invoice forms (drop companyName,
  use OwnerPicker + billingEmail).
- Vitest 652/652 still green; type-check clean.

Drops the dead `requestMagicLink` from portal.service.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
Matt Ciaccio
4da8ed3ae4 docs: reflect data-model refactor in CLAUDE.md + DB schema overview
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
PR 15 (docs): the numbered spec files mostly described the new model
already at the conceptual level, but two needed concrete updates:

- 07-DATABASE-SCHEMA.md: schema overview now lists the new Yacht /
  Company / Reservation domains alongside the existing ones, names the
  partial unique indexes (idx_yoh_active, idx_br_active) that enforce
  exclusivity, and notes that yacht/company details are no longer
  stored on `clients`.
- CLAUDE.md: the Conventions section now points future contributors at
  the new schema files, the polymorphic ownership pattern, the
  EoiContext/dual-path EOI flow, and the merge-token allow-list. Adds
  a pointer to the husky `.env*` block so it doesn't trip people up.
  References the new field-mapping doc and `assets/README.md`.

Task 15.3 (Tier 4 golden-image PDF regression) is deferred — those
tests need committed reference PDFs that come out of a real, manually
verified EOI render. Best landed once the actual `assets/eoi-template.pdf`
is in place; tracking as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:14:34 +02:00
Matt Ciaccio
4c67b9dbd4 test(e2e): exhaustive click-through suite + destructive narrow tests
PR 14: adds a tier-3.5 Playwright pass that opens every refactored page,
clicks every visible button/link/role=button, and asserts no console
errors, no app-side network 4xx/5xx, and no click-time exceptions.

Helper:
- tests/helpers/click-everything.ts — shared `clickEverythingOnPage`
  with default skips for destructive selectors (archive, delete,
  transfer, sign-out), auto-closing of dialogs, and return-to-start
  after navigation.

Exhaustive specs (tests/e2e/exhaustive/):
- 01-yachts: list + detail + transfer dialog
- 02-companies: list + detail + add-membership dialog
- 03-reservations: berth list + detail reservations tab + reserve
  dialog
- 04-client-detail: list + detail walking every tab
- 05-eoi-generate: generate dialog opens with Documenso option
- 06-invoice-form: new-invoice dialog billing-entity toggle
- 07-berths: list + detail walking every tab
- 08-portal: client portal yachts / memberships / reservations
- 09-navigation: every primary nav target loads cleanly

Destructive specs (tests/e2e/destructive/):
- 01-yacht-archive: create-via-API → archive via UI → assert removed.
  Skips with a clear message when the global setup does not seed an
  owner client (avoids brittle failures while the full destructive
  fixture lands).

Playwright config: testDir hoisted to ./tests/e2e; new `exhaustive` and
`destructive` projects share the existing setup project. New scripts
test:e2e / test:e2e:smoke / test:e2e:exhaustive / test:e2e:destructive
in package.json drive each project independently.

CI integration deferred — no .github/workflows/* exists in this repo
yet, so the PR 14 task to wire a separate CI job is N/A. The new
projects will pick up automatically when a workflow lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:06:10 +02:00
Matt Ciaccio
0ed401d083 refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.

Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.

Caller cleanup (zero behavioral change to remaining flows):

- Drops the legacy `generateEoi` flow entirely (route, service function,
  pdfme template, validator schema). The dual-path generate-and-sign
  service from PR 11 has fully replaced it; the route was no longer
  wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
  removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
  `yachts` via `interest.yachtId` instead of the dropped
  `client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
  lookup (direct + active company memberships); interest-summary fetches
  yacht via `interest.yachtId`. Both PDF templates updated to read
  yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
  `search-result-item`, `use-search` hook, `types/domain.ts`,
  `search.service` — drop the companyName badge / sub-label / typed
  field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
  prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
  yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.

Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
Matt Ciaccio
456d399ee2 refactor(templates): merge-field allow-list rejects unknown tokens
Extracts the MERGE_FIELDS catalog out of the document-templates service
into src/lib/templates/merge-fields.ts so the Zod validator can import
it without circular deps. createTemplateSchema now refines mergeFields
against VALID_MERGE_TOKENS — unknown tokens (including the deprecated
`{{client.yachtName}}` / `{{client.companyName}}` family) are rejected
at template creation time with a message naming the offenders.

Adds the missing `eoi` value to templateType enum so seeded EOI rows
round-trip through the validator. Drops the historical "Removed (PR 11):"
comment from the catalog (per project convention against `// removed`
markers).

6 new validator unit tests; 652/652 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:48:06 +02:00
Matt Ciaccio
f4ec51002c feat(eoi): template-aware generate-EOI dialog
The EOI dialog now lists "Documenso Standard EOI" (default) plus any
seeded in-app EOI templates and routes the submit to the dual-path
generate-and-sign endpoint with the correct pathway:

  - "documenso-template" sentinel id → pathway: documenso-template
  - any other template id → pathway: inapp

Signers are derived server-side from EoiContext for both pathways when
the template type is EOI (interest's client + hardcoded developer +
approver), so the dialog doesn't collect them. Non-EOI templates still
require explicit signers.

Drops the legacy `client.yachtLengthFt` prerequisite check (yacht is now
a first-class entity) and replaces it with hasYacht based on
interest.yachtId. Tests updated; 646/646 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:42:08 +02:00
Matt Ciaccio
2ff24a7132 feat(eoi): in-app pathway fills the same source PDF as Documenso
When the in-app pathway is used for EOI templates, we now load the same
source PDF that the Documenso template uploads and fill its AcroForm
fields with values from EoiContext via pdf-lib. Field names mirror the
Documenso template's formValues keys exactly (Name, Email, Address,
Yacht Name, Length, Width, Draft, Berth Number + Lease_10 / Purchase
checkboxes), so both pathways produce equivalent legal documents — only
the renderer differs.

The form is left interactive (not flattened) so a recipient can still
adjust values before signing. Non-EOI templates (welcome letters,
acknowledgments, etc.) keep using the existing HTML→pdfme path.

Adds:
- pdf-lib direct dep
- src/lib/pdf/fill-eoi-form.ts — load + fill helpers, EOI_TEMPLATE_PDF_PATH
  env override
- assets/ + README documenting the expected source PDF
- next.config outputFileTracingIncludes so the asset is bundled in the
  standalone build

Tests: 8 new (4 fill-form unit + 2 source-PDF route + 2 fallback);
645/645 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:38:02 +02:00
Matt Ciaccio
f8255cedb8 feat(eoi): dual-path generateAndSign (inapp + documenso-template)
generateAndSign now accepts a `pathway` parameter:

- `inapp` (existing): resolve in-app template -> pdfme -> MinIO -> Documenso
  createDocument + sendDocument.
- `documenso-template` (new): build EOI context from interestId, assemble
  the Documenso template payload, and call Documenso's
  /api/v1/templates/{id}/generate-document. Documenso owns the PDF; we
  still record a documents row for tracking.

Adds generateDocumentFromTemplate helper to the Documenso client and new
env vars (DOCUMENSO_TEMPLATE_ID_EOI + client/developer/approval recipient
IDs) with defaults matching the legacy flow. Covered by 6 new integration
tests (637/637 green).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:43:41 +02:00
Matt Ciaccio
13d07e3906 feat(templates): merge-field resolver supports yacht/company/owner scopes
Task 11.4. Extends resolveTemplate to use buildEoiContext when interestId
is provided, populating the new yacht.*, company.*, owner.* token scopes
from the shared EOI context. Legacy non-EOI templates still resolve via
direct client/berth/port lookups. Deprecated client.yachtName /
client.companyName / client.yacht*Ft tokens are removed from the catalog;
PR 12 will drop the backing columns. berth.mooringNumber is relaxed to
required:false so welcome-letter-style templates without a berth context
no longer trip the required-merge-field check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:20:53 +02:00
Matt Ciaccio
7ef7b9bb5f feat(eoi): seed Standard EOI in-app template per port
Adds a new per-port document_templates row of type 'eoi' containing an
HTML EOI / Letter of Intent body with {{section.field}} merge tokens
that mirror the EoiContext shape. Enables the in-app pdfme PDF path as
an alternative to the Documenso template flow.

- New getStandardEoiTemplateHtml() returns the Letter-sized HTML body
  with Applicant / Yacht / Owner / Berth / Interest / Signatures blocks
- STANDARD_EOI_MERGE_FIELDS exported for resolveTemplate wiring (11.4)
- seed-data.ts inserts one document_templates row per port inside the
  existing withTransaction block, between ownership transfers and
  interests, using SEED_USER_ID for audit consistency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:13:51 +02:00
Matt Ciaccio
7200c31486 feat(eoi): add Documenso template payload builder 2026-04-24 16:09:27 +02:00
Matt Ciaccio
db74c9394b docs(eoi): document Documenso template field name mapping
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:06:39 +02:00
Matt Ciaccio
d133d6d656 feat(ui): wire OwnerPicker into invoice billing-entity field 2026-04-24 16:04:07 +02:00
Matt Ciaccio
9d7decfc5b feat(invoices): polymorphic billing entity with snapshot clientName
Wires the billingEntityType/billingEntityId columns (added in PR 1) through
the invoice validator and service. Clients can now be billed as either a
client or a company; clientName becomes a snapshot derived from the entity
at create time.

- createInvoiceSchema: replace clientName with billingEntity {type,id}
- listInvoicesSchema: add billingEntityType/billingEntityId filters
- createInvoice: resolveBillingEntity helper (tenant-scoped; tx-aware)
  falls back to entity primary email/address when not supplied
- listInvoices: honor new billing-entity filters
- updateInvoice: unchanged — billing entity is fixed after create
- invoice wizard step 1: temporary billing-entity id input (Task 10.2
  replaces this with a proper picker)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:02:00 +02:00
Matt Ciaccio
c685c9fada feat(recommendations): read yacht dimensions from yachts table
Switch recommendations engine to read yacht dimensions (lengthFt, widthFt,
draftFt) from the yachts table via interest.yachtId instead of from the
deprecated client fields. Cross-tenant safety is maintained by scoping the
yacht lookup to the same portId. Falls back gracefully to null dimensions
when interest.yachtId is null or yacht is not found.

- Modified: src/lib/services/recommendations.ts — replaced client.yacht*Ft
  fields with yacht table lookups via interest.yachtId
- Created: tests/integration/recommendations-yacht-dims.test.ts — 4 tests
  covering happy path, null-yacht fallback, cross-tenant safety, and
  dimension-based scoring

All 594 tests passing, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:51:17 +02:00
Matt Ciaccio
71d7daf1ae feat(search): index yachts and companies alongside clients
Extend the global search service to include yacht and company results
using ILIKE matching on name, hull number, registration, legal name,
and tax ID. Results are tenant-scoped and exclude archived rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:47:54 +02:00
Matt Ciaccio
1fd05a886d feat(public-interest): atomic client+yacht+company+interest trio
Restructures the public interest endpoint to create the yacht as a
first-class row (owned by the new client, or by a newly upserted
company when a company block is provided) and writes the yacht_id
onto the new interest. All writes now run inside a single
transaction instead of the previous unwrapped sequence.

The public validator gains structured `yacht` (required) and
`company` (optional) sub-objects; legacy flat fields remain in the
schema for backward compatibility but are silently ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:42:45 +02:00
Matt Ciaccio
bcf4c1f797 feat(interests): add yacht-picker to interest form 2026-04-24 15:36:27 +02:00
Matt Ciaccio
f9cb8003b5 feat(interests): wire yachtId, enforce ownership + stage-gate
- Add yachtId (optional) to createInterestSchema + listInterestsSchema
  (updateInterestSchema inherits it via partial() automatically).
- Add assertYachtBelongsToClient helper that accepts direct client
  ownership OR company-represented clients with an active membership
  in the owning company.
- createInterest + updateInterest validate yacht ownership whenever
  yachtId is supplied/changed.
- changeInterestStage rejects moving out of stage=open with yachtId
  null (ValidationError).
- listInterests filter supports yachtId.
- Integration tests cover all 7 paths; validator test for yachtId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:34:44 +02:00
Matt Ciaccio
3b0421aa81 fix(tests): use dynamic imports in portal.test.ts to avoid env validation 2026-04-24 14:48:40 +02:00
Matt Ciaccio
a14dc8143c feat(portal): surface yachts, memberships, reservations for portal users
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:43:12 +02:00
Matt Ciaccio
b75834ab7e refactor(clients): rebuild detail tabs + columns for new data model
- ClientData in client-detail.tsx now reflects the stripped shape from
  Task 8.2 (drop companyName/isProxy/proxy*/yacht*/berthSizeDesired) and
  gains yachts / companies / activeReservations arrays.
- client-tabs.tsx: Overview trimmed (personal, contacts, source, tags);
  three new count-badged tabs (Yachts, Companies, Reservations).
- New client-yachts-tab.tsx renders owned yachts + Add yacht CTA (TODO:
  YachtForm preset-owner wiring for v2).
- New client-companies-tab.tsx renders memberships with Primary badge and
  since-date; management still lives on the company detail page.
- New client-reservations-tab.tsx maps activeReservations into ReservationRow
  shape and delegates to <ReservationList showBerth />.
- client-columns.tsx drops companyName column (TODO: add Yachts count +
  Primary company once list endpoint joins those).
- client-filters.tsx drops isProxy filter.
- Wire realtime invalidations for yacht:ownership_transferred,
  company_membership:added/ended, and berth_reservation:*.
2026-04-24 14:36:34 +02:00
Matt Ciaccio
4c171848fc refactor(clients): strip deprecated fields + extend getClientById with yachts/companies/reservations
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:31:14 +02:00
Matt Ciaccio
a6d6647bb2 refactor(clients): strip yacht/company/proxy sections from client form 2026-04-24 14:27:47 +02:00
Matt Ciaccio
367fc9800e refactor(clients): strip yacht/company/proxy fields from validator
Remove deprecated companyName, isProxy, proxyType, actualOwnerName, yacht
dimensions, and berthSizeDesired fields from createClientSchema and the
isProxy filter from listClientsSchema. First step of PR 8; cascading TS
errors in clients.service.ts and client-form.tsx are addressed in 8.2/8.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:25:10 +02:00
Matt Ciaccio
ddcffe9f6f feat(ui): add reservations tab to berth detail 2026-04-24 14:22:06 +02:00
Matt Ciaccio
3c5267f5e9 feat(ui): berth-reserve dialog with create-and-activate flow 2026-04-24 14:20:08 +02:00
Matt Ciaccio
2111bb8b60 feat(ui): add reservation-list table component 2026-04-24 14:18:11 +02:00
Matt Ciaccio
64d7b5c765 feat(ui): company list page with columns, filters, and sidebar entry 2026-04-24 14:05:24 +02:00
Matt Ciaccio
4e448dd06e feat(ui): add-membership dialog for company members 2026-04-24 14:02:47 +02:00
Matt Ciaccio
29a7fc8857 feat(ui): add shared client-picker autocomplete 2026-04-24 14:02:00 +02:00
Matt Ciaccio
5d76a8a1cf feat(ui): company detail page with header, tabs, members, owned yachts 2026-04-24 13:59:21 +02:00
Matt Ciaccio
d6743ed52c feat(ui): add company-form for create/edit with 409 handling
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:53:35 +02:00
Matt Ciaccio
ba86b7a897 feat(ui): add company-picker autocomplete component
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:52:52 +02:00
Matt Ciaccio
4f56c2bdfd feat(ui): add Yachts entry to sidebar navigation 2026-04-24 13:48:37 +02:00
Matt Ciaccio
508518b6c8 feat(ui): yacht transfer dialog with atomic ownership change
Replaces the Task 5.3 stub with a real YachtTransferDialog backed by
OwnerPicker, a date input, reason select, and notes textarea. Submits to
POST /api/v1/yachts/{id}/transfer, invalidates yacht + ownership-history
queries on success, and surfaces API errors (same-owner 400, cross-tenant
404, no-permission 403) as form-level messages. Transfer button is now
gated by PermissionGate resource="yachts" action="transfer".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:47:26 +02:00
Matt Ciaccio
f64a52b995 feat(ui): yacht list page with columns and filters 2026-04-24 13:44:15 +02:00
Matt Ciaccio
76d2348873 feat(ui): yacht detail page with header, tabs, ownership history
Implements Task 5.3: server page passes yachtId to a client YachtDetail,
which fetches via TanStack Query and renders the shared DetailLayout with
Overview / Ownership History / Interests / Reservations / Notes / Tags
tabs. Header shows name, dimensions, polymorphic owner link, status badge,
and Edit / Transfer / Archive actions. Transfer is a stub dialog pending
Task 5.5; Notes tab is a placeholder because NotesList does not yet support
entityType='yachts'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:40:41 +02:00
Matt Ciaccio
a604223c17 feat(ui): add yacht-form for create/edit
Sheet-based react-hook-form + zod component for yacht CRUD.
CREATE mode uses OwnerPicker to set the yacht's owner (required
by createYachtSchema). EDIT mode hides the picker and shows a
notice directing users to the Transfer button, matching the
service-layer guard that blocks owner mutation via PATCH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:34:55 +02:00
Matt Ciaccio
d4f58abb9c feat(ui): add owner-picker and yacht-picker components
Task 5.1 of the data-model refactor. Adds:
- OwnerPicker: polymorphic combobox that toggles between client and
  company autocomplete via a type switch inside the popover. Uses
  /api/v1/clients/options (search=) and /api/v1/companies/autocomplete
  (q=).
- YachtPicker: yacht autocomplete against /api/v1/yachts/autocomplete
  with optional ownerFilter prop to scope to a given client/company.

Both components use TanStack Query with debounced (300ms) input via the
existing use-debounce hook, and apiFetch which attaches X-Port-Id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:32:28 +02:00
Matt Ciaccio
727e323288 feat(seed): rewrite seed for multi-cardinality refactor
Split seed into orchestrator (seed.ts) + per-port fixture builder
(seed-data.ts). Creates three ports (Port Nimara, Marina Azzurra,
Harbor Royale) and seeds each with a realistic multi-cardinality
dataset: 12 berths (5 available / 5 reserved / 2 sold), 8 clients
with contacts and primary addresses, 3 companies (2 active / 1
dissolved) with billing addresses, memberships exercising dual-
company ownership and ended state, 12 yachts (7 client-owned /
5 company-owned) plus matching open ownership-history rows, 3
completed ownership transfers per port (client <-> company), 15
interests spanning all pipeline stages, and 8 reservations (5
active on distinct berths / 2 ended / 1 cancelled). Seed wraps
per-port work in withTransaction and is idempotent: re-running
detects existing company rows and skips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:26:37 +02:00
Matt Ciaccio
7abbdd4913 feat(factories): add makeMembership, makeReservation, makeOwnershipTransfer 2026-04-24 13:19:54 +02:00
Matt Ciaccio
94f8b76a03 feat(events): register yacht, company, membership, reservation webhook events 2026-04-24 12:56:47 +02:00
Matt Ciaccio
a78f653f5a feat(api): berth reservations (create pending + lifecycle PATCH)
Add Task 3.6 routes:

- POST /api/v1/berths/:id/reservations — creates a pending reservation;
  the URL berthId is authoritative and any body-supplied berthId is
  ignored.
- GET /api/v1/berths/:id/reservations — list filtered by URL berthId.
- GET /api/v1/berth-reservations/:id — fetch scoped to tenant.
- PATCH /api/v1/berth-reservations/:id — action-based dispatch
  (activate | end | cancel) via a discriminated union. Because the
  required permission depends on the action, PATCH is wrapped with
  withAuth only and calls requirePermission inside the handler.
- DELETE /api/v1/berth-reservations/:id — alias for cancel (204).

Cross-tenant berths return 404 on both POST and GET via an explicit
pre-check.

Tests cover happy paths, invalid transitions, 404/400/403 cases, the
URL-vs-body berthId precedence, and per-action permission gating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:55:12 +02:00
Matt Ciaccio
aca45fb1b2 feat(api): company memberships (add/update/end/set-primary)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:49:10 +02:00
Matt Ciaccio
183ff1ff9e feat(api): company list/create/detail/patch/archive/autocomplete 2026-04-24 12:45:10 +02:00
Matt Ciaccio
90463269ce feat(api): yacht detail, patch, archive, transfer, history, autocomplete 2026-04-24 12:40:51 +02:00
Matt Ciaccio
a5036c6358 feat(api): GET/POST /api/v1/yachts
Add yacht list + create routes, export RouteHandler type and inner
handlers so tests can invoke them directly with a mock AuthContext.
New tests/helpers/route-tester.ts provides makeMockCtx/makeMockRequest
reusable by subsequent Task 3.x routes.
2026-04-24 12:35:25 +02:00
Matt Ciaccio
f743169354 feat(permissions): add yacht, company, membership, reservation keys 2026-04-24 12:30:06 +02:00
Matt Ciaccio
b053a6388e feat(eoi): shared context builder + tests 2026-04-24 12:20:40 +02:00
Matt Ciaccio
b1133c4e87 feat(reservations): service + validators + exclusivity tests
Adds the berth_reservations service covering the full lifecycle
(pending -> active -> ended/cancelled) with tenant scoping, DB-enforced
exclusivity on the idx_br_active partial unique index, and
client-or-company-member cross-checks for yacht ownership.

- validators: createPending / activate / end / cancel / list schemas
- service: createPending, activate, endReservation, cancel, getById,
  listReservations — with narrow 23505/idx_br_active catch that
  re-queries the conflicting active reservation
- socket events: berth_reservation:{created,activated,ended,cancelled}
- tests: unit (lifecycle, tenant, membership cross-check),
  integration (concurrent-activate ConflictError + re-activate after end)
2026-04-24 12:15:22 +02:00
Matt Ciaccio
15a79e7990 feat(company-memberships): service + validators + tests
Adds company-membership service with six operations (add, update, end,
setPrimary, listByCompany, listByClient), the corresponding Zod
validators, three socket events, and a unit-test suite covering the
portId-scoping rules, the unique_cm_exact conflict path, and the atomic
setPrimary transaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:07:58 +02:00
Matt Ciaccio
037f2544e8 feat(companies): service + validators + unit tests 2026-04-24 12:02:08 +02:00
Matt Ciaccio
7c408cf975 feat(yachts): list + owner-scoped list + autocomplete
Adds `listYachts`, `listYachtsForOwner`, and `autocomplete` to the
yacht service so UIs can page/filter yachts per port, look up all
yachts tied to a given client/company, and power search-as-you-type.

`listYachts` delegates to the shared port-scoped `buildListQuery`,
supporting search over name/hullNumber/registration plus ownerType,
ownerId and status filters; `autocomplete` caps at 10 results and is
tenant-scoped; `listYachtsForOwner` returns all yachts whose current
owner matches, newest first. Extends `makeYacht` factory to accept
flat `name`, `status`, `hullNumber`, `registration` overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:03:36 +02:00
Matt Ciaccio
8a5cd1ef0e feat(yachts): atomic transferOwnership with partial-unique guard 2026-04-23 23:58:20 +02:00
Matt Ciaccio
d0ab4b8102 feat(yachts): updateYacht + archiveYacht 2026-04-23 23:52:24 +02:00
Matt Ciaccio
aaf4847fc2 refactor(yachts): use withTransaction helper per project convention 2026-04-23 23:47:12 +02:00
Matt Ciaccio
feacb8c7ac fix(yachts): run owner existence check inside transaction 2026-04-23 23:46:03 +02:00
Matt Ciaccio
2f2ad4452f feat(yachts): createYacht + getYachtById services with tests 2026-04-23 23:40:56 +02:00
Matt Ciaccio
27d438929b refactor(yachts): rename schema + consolidate tests per project conventions 2026-04-23 23:35:30 +02:00
Matt Ciaccio
899e588a0c feat(yachts): add zod validators + tests 2026-04-23 23:31:29 +02:00
Matt Ciaccio
7a6e95c87a test(schema): verify partial unique indexes and case-insensitive company uniqueness
Adds integration test covering:
- idx_yoh_active: only one active ownership row per yacht
- idx_br_active: only one active reservation per berth (non-active rows
  are ignored by the partial index)
- Case-insensitive company name uniqueness within a port, with same-name
  companies allowed across different ports

Extends tests/helpers/factories.ts with async DB-inserting factories for
ports, clients, berths, yachts (+ ownership history row) and companies.
The new factories use the app's `db` handle so FK and partial unique
indexes are enforced by Postgres. The in-memory data helpers used by
unit tests (makeAuditMeta, makeCreateClientInput, permission helpers)
are preserved.
2026-04-23 18:06:37 +02:00
Matt Ciaccio
077ba5bf6b feat(schema): wire yacht, company, reservation relations in Drizzle 2026-04-23 18:02:22 +02:00
Matt Ciaccio
14dac2f3e1 feat(documents): add yachtId/companyId to files and documents 2026-04-23 18:00:12 +02:00
Matt Ciaccio
117cfae52e feat(invoices): add billingEntityType/Id for polymorphic billing 2026-04-23 17:58:52 +02:00
Matt Ciaccio
d43298a74e feat(schema): add yachtId to interests and berth_waiting_list 2026-04-23 17:57:29 +02:00
Matt Ciaccio
88a87afa77 feat(reservations): add berth_reservations schema with partial unique exclusivity 2026-04-23 17:55:53 +02:00
Matt Ciaccio
299e893e2b feat(companies): add companies, memberships, addresses, notes, tags schema 2026-04-23 17:54:02 +02:00
Matt Ciaccio
51523e6768 feat(yachts): add yachts, ownership history, notes, tags schema 2026-04-23 17:51:19 +02:00
Matt Ciaccio
11969c0d8a docs(plan): add data-model refactor implementation plan (Spec 1)
15-PR sequenced plan covering schema migration, services, API,
seeder, UI, EOI dual-path, exhaustive click-through tests,
documentation updates, and final merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:17:02 +02:00
Matt Ciaccio
1c0a16fd59 docs(spec): add data-model refactor design (Spec 1 of 3)
Introduces yachts and companies as first-class entities with memberships,
ownership history, berth reservations, and dual-path EOI templates.
Explicit non-goals (importer, merge endpoint) carved out as Specs 2 and 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:04:41 +02:00
Matt Ciaccio
b6996f9a31 test(e2e): repair 26 Playwright smoke-test failures
Failures were mostly stale selectors, not product regressions:

- .or() traps matching the topbar "+ New" button → use specific names
  (Add Webhook, New Field, New Template)
- broad /create|add|new/ patterns → same fix
- [role="dialog"] overlay matched before content → getByRole('dialog').last()
- locator('input') picked hidden Radix Select inputs → getByPlaceholder /
  getByRole('combobox', { name })
- 11-global-search rewritten for the inline topbar search (the cmdk
  CommandDialog the old tests targeted was replaced)
- missing .first() causing strict-mode failures on notifications heading,
  version history text, nav links
- dashboard landing test: no h1 exists, target KPI text instead
- activity-feed: items aren't anchors; match action badge text
- monitoring data-leak check scoped to <main> (sidebar has Email/Documents)
- admin API without port context returns 400 (not 403) for non-admins —
  accept 400 as a valid "blocked" status in the sales-agent test

Also dropped dead imports and unused locals surfaced by lint-staged.

Full suite: 124 passed (11.2m).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:24:52 +02:00
Matt Ciaccio
46bd8aaef1 fix: allow /portal and /api/portal paths without CRM session
The portal has its own JWT-based auth (withPortalAuth). The CRM
middleware was redirecting /portal/login and /api/portal/auth/request
to /login, breaking the magic-link flow for unauthenticated clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:22:53 +02:00
Matt Ciaccio
b5d8e1ecb8 docs: update PROGRESS.md with 2026-03-26 → 2026-04-22 changelog
Adds a 'Since 2026-03-26' section summarizing the admin/reminders
expansion, multi-address clients, full inquiry notifications feature,
and Next.js 15 build fixes. Updates the Layer 3 reminders entry to
reflect full CRUD + background processors. Marks Priority 1 push-to-
Gitea as done and splits out CI verification as its own checkbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:37:43 +02:00
Matt Ciaccio
ed40662b99 chore: gitignore docker-compose.override.yml and .remember/
The override file is a local-only port remap for when the default
dev postgres port is already bound by another project. .remember/ is
skill-maintained session-state storage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:36:11 +02:00
Matt Ciaccio
9d815c4dcc fix: wrap useSearchParams pages in Suspense for prerender
Next.js 15 static prerender bails out when useSearchParams is used
outside a Suspense boundary. Extract the hook-using component into
an inner child and wrap it in Suspense at the page root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:06:39 +02:00
Matt Ciaccio
b9b3f942a6 chore: add .gitattributes to normalize line endings to LF
Prevents cross-platform CRLF/LF churn between Windows and macOS checkouts.
Windows-only scripts (bat/cmd/ps1) pinned to CRLF; shell scripts pinned to LF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:02:46 +02:00
687 changed files with 247129 additions and 5936 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}

30
.gitattributes vendored Normal file
View File

@@ -0,0 +1,30 @@
# Normalize line endings on commit; check out LF on every OS.
* text=auto eol=lf
# Binary files — never touch line endings.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.pdf binary
*.zip binary
*.gz binary
*.tar binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.eot binary
*.mp4 binary
*.mov binary
*.wasm binary
# Shell scripts must stay LF regardless.
*.sh text eol=lf
# Windows batch / PowerShell must stay CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf

17
.gitignore vendored
View File

@@ -17,3 +17,20 @@ playwright-report/
nginx/certs/ nginx/certs/
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.playwright-mcp/ .playwright-mcp/
docker-compose.override.yml
.remember/
.DS_Store
# Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match.
/eoi/
# Brainstorming companion mockup files
.superpowers/
# Ad-hoc screenshots / scratch artifacts at repo root
/*.png
# Legacy Nuxt portal — kept on disk for reference, not tracked here
/client-portal/
# Mobile audit screenshots — generated locally, regenerable
/.audit/

View File

@@ -20,16 +20,42 @@
### Client Domain ### Client Domain
- `clients` — Anchor records for people/entities - `clients` — Anchor records for people/entities. Yacht and company details
are no longer stored here — see the Yacht and Company domains.
- `client_contacts` — Multi-channel contact entries per client - `client_contacts` — Multi-channel contact entries per client
- `client_addresses` — Physical addresses per client (primary + others)
- `client_relationships` — Relationships between clients (referrals, broker, family) - `client_relationships` — Relationships between clients (referrals, broker, family)
- `client_notes` — Timestamped notes on clients - `client_notes` — Timestamped notes on clients
- `client_tags` — Tags assigned to clients - `client_tags` — Tags assigned to clients
- `client_merge_log` — Audit trail of client merges - `client_merge_log` — Audit trail of client merges
### Yacht Domain
- `yachts` — First-class yacht records. Polymorphic ownership via
`current_owner_type` (`'client' | 'company'`) + `current_owner_id`.
- `yacht_ownership_history` — Append-only log of every transfer; partial
unique index `idx_yoh_active` enforces a single active owner per yacht.
- `yacht_notes`, `yacht_tags` — Notes / tags on yachts.
### Company Domain
- `companies` — Legal entities that may own yachts or be billed.
- `company_addresses` — Addresses per company.
- `company_memberships` — Active client ↔ company links with role
(director / shareholder / beneficial_owner / authorised_signatory),
start/end dates.
### Reservation Domain
- `berth_reservations` — Concrete client + yacht + berth holds with
start/end dates and status. Partial unique index `idx_br_active`
enforces one active reservation per berth.
### Interest Domain ### Interest Domain
- `interests` — Per-berth pipeline records, each belonging to a client (milestone dates are inline columns) - `interests` — Per-berth pipeline records. Each row references a
`client_id`, `yacht_id` (the yacht in scope for the inquiry), and
optional `berth_id`. Milestone dates are inline columns.
- `interest_notes` — Timestamped notes on interests - `interest_notes` — Timestamped notes on interests
- `interest_tags` — Tags assigned to interests - `interest_tags` — Tags assigned to interests

View File

@@ -13,6 +13,19 @@ pnpm db:generate # Generate Drizzle migrations
pnpm db:push # Push schema to DB pnpm db:push # Push schema to DB
pnpm db:studio # Drizzle Studio GUI pnpm db:studio # Drizzle Studio GUI
pnpm db:seed # Seed database (tsx src/lib/db/seed.ts) pnpm db:seed # Seed database (tsx src/lib/db/seed.ts)
# Tests
pnpm exec vitest run # Unit + integration (~3s)
pnpm exec playwright test --project=smoke # Click-through smoke (~10min)
pnpm exec playwright test --project=exhaustive # Full UI exhaustive
pnpm exec playwright test --project=destructive # Archive/delete flows
pnpm exec playwright test --project=realapi # Real Documenso/IMAP (opt-in)
pnpm exec playwright test --project=visual # Pixel-diff baselines
pnpm exec playwright test --project=visual --update-snapshots # Regenerate baselines
# Dev helpers
pnpm tsx scripts/dev-trigger-portal-invite.ts # Send a portal activation email
pnpm tsx scripts/dev-imap-probe.ts # Dump recent IMAP inbox messages
``` ```
## Tech stack ## Tech stack
@@ -70,15 +83,47 @@ src/
- **Formatting:** Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width. - **Formatting:** Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width.
- **Lint:** ESLint flat config extending `next/core-web-vitals`, `next/typescript`, `prettier`. Unused vars prefixed with `_` are allowed. - **Lint:** ESLint flat config extending `next/core-web-vitals`, `next/typescript`, `prettier`. Unused vars prefixed with `_` are allowed.
- **Imports:** Use `@/*` path alias (maps to `src/*`). - **Imports:** Use `@/*` path alias (maps to `src/*`).
- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`. - **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`. Yacht / company / reservation domains live in `components/yachts`, `components/companies`, `components/reservations` respectively.
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`. - **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`. Domain files include `clients.ts`, `yachts.ts`, `companies.ts`, `reservations.ts`, `interests.ts`, `berths.ts`, `documents.ts`, `invoices.ts`, etc.
- **Polymorphic ownership:** Yachts and invoice billing-entities use `<entity>_type` + `<entity>_id` column pairs (`'client' | 'company'`). Resolve owner identity through `src/lib/services/yachts.service.ts` / `eoi-context.ts` rather than reading the columns ad hoc — those services apply the type discriminator.
- **EOI generation:** Two pathways share the same `EoiContext` (`src/lib/services/eoi-context.ts`). Documenso pathway calls the template-generate endpoint via `documenso-payload.ts`; in-app pathway fills the same source PDF (`assets/eoi-template.pdf`) via `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm). Routed through `generateAndSign(...)` in `src/lib/services/document-templates.ts` with a `pathway` parameter.
- **Merge fields:** Token catalog lives in `src/lib/templates/merge-fields.ts`; the `createTemplateSchema` validator uses `VALID_MERGE_TOKENS` as an allow-list, so unknown tokens are rejected at template creation time.
- **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat.
- **Documenso API responses:** 2.x renamed `id``documentId` and recipient `id``recipientId`; v1.13 still uses `id`. `src/lib/services/documenso-client.ts` runs every response through `normalizeDocument()` which reads either field name and surfaces the legacy `id` form to downstream consumers.
- **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later).
- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
- **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape.
- **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware.
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled. - **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. - **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed.
## Schema migrations during dev
When you run a `db:push` or apply a migration via `psql` against a running dev server, **restart the dev server afterwards**. Drizzle/postgres.js keeps connection-level prepared statements that can hold stale column lists; a stale pool causes `column X does not exist` errors on pages that touch the migrated table even though the column is present in the DB. Symptom: pages return 500 with `errorMissingColumn`/`42703` after a successful migration. Fix: kill `next dev` and restart it.
## Environment ## Environment
Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build). Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build).
Optional dev/test-only env vars (not in `.env.example`):
- `EMAIL_REDIRECT_TO=<address>` — when set, every outbound email is rerouted to this address regardless of the requested recipient and the subject is prefixed with `[redirected from <original>]`. Dev safety net so seeded fake-client emails don't escape; **must be unset in production**.
- `IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` / `IMAP_PASS` — read by `tests/e2e/realapi/portal-imap-activation.spec.ts` to fetch the activation email from a real mailbox during the IMAP round-trip test. The spec skips when any are missing.
## Testing
Five Playwright projects, defined in `playwright.config.ts`:
- `setup` — global setup (seeds users, port, berths, system settings).
- `smoke` — fast click-through over every major flow. Run on every change (~10 min, 125 specs).
- `exhaustive` — deeper UI coverage that takes longer.
- `destructive` — archive/delete/cancel paths against throwaway entities.
- `realapi` — opt-in suite that hits real external services (Documenso send-side + IMAP round-trip). Requires `DOCUMENSO_API_*`, `SMTP_*`, `IMAP_*` env. Cloudflared tunnel needs to be running so Documenso can call the local webhook receiver.
- `visual` — pixel-diff baselines for stable list/landing pages. Snapshots committed under `tests/e2e/visual/snapshots.spec.ts-snapshots/`. Regenerate with `--update-snapshots` after intentional UI changes.
Vitest covers unit + integration with mocked external services (`tests/unit/`, `tests/integration/`).
## Docker ## Docker
- `Dockerfile` - Production multi-stage build (deps -> build -> runner) - `Dockerfile` - Production multi-stage build (deps -> build -> runner)
@@ -89,3 +134,11 @@ Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full s
## Architecture docs ## Architecture docs
Numbered spec files in repo root (`01-CONSOLIDATED-SYSTEM-SPEC.md` through `15-DESIGN-TOKENS.md`) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence. Numbered spec files in repo root (`01-CONSOLIDATED-SYSTEM-SPEC.md` through `15-DESIGN-TOKENS.md`) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence.
Domain-specific references:
- `docs/eoi-documenso-field-mapping.md` — canonical mapping from `EoiContext`
paths to the Documenso template's `formValues` keys, with the matching
AcroForm field names used by the in-app pathway.
- `assets/README.md` — what the in-app EOI source PDF must contain and how
to override its path in dev/test.

View File

@@ -1,12 +1,22 @@
# Port Nimara CRM - Project Progress # Port Nimara CRM - Project Progress
**Last updated:** 2026-03-26 **Last updated:** 2026-04-22
**Repo:** https://code.letsbe.solutions/letsbe/pn-new-crm **Repo:** https://code.letsbe.solutions/letsbe/pn-new-crm
**Domain:** pn.letsbe.solutions **Domain:** pn.letsbe.solutions
**Stack:** Next.js 15 + TypeScript + Tailwind + Drizzle ORM + PostgreSQL + Redis + BullMQ + MinIO + Socket.io **Stack:** Next.js 15 + TypeScript + Tailwind + Drizzle ORM + PostgreSQL + Redis + BullMQ + MinIO + Socket.io
--- ---
## Since 2026-03-26
- **Admin surface expanded** — full admin users + roles management, admin ports + system settings management, user settings, expanded audit log, and berth CRUD completions.
- **Reminders system** — promoted from "pages only" to full CRUD with background processors.
- **Multi-address clients** — new `client_addresses` table with a partial unique index enforcing one primary address per client.
- **Inquiry notifications feature (end-to-end)** — public interest form now fires: (a) confirmation email to the inquiring client, (b) in-app notifications to CRM users with `interests.view`, (c) optional email to configured sales recipients. Public schema expanded with first/last name split, address block, and berth mooring lookup. `sendEmail` gained a plain-text fallback. Admin settings UI exposes `inquiry_contact_email` and `inquiry_notification_recipients`. Plan: `docs/superpowers/plans/2026-04-14-inquiry-notifications.md`.
- **Build/infra cleanup** — Next.js 15 static-prerender bugs fixed (Suspense boundaries around `useSearchParams` on `/portal/verify` and `/set-password`), `.gitattributes` added to enforce LF in the index across Windows/macOS checkouts, Docker production build fixes, CI trimmed to build+push (deploy job removed).
---
## What's Been Built (Layers 0-4 Complete) ## What's Been Built (Layers 0-4 Complete)
### Layer 0: Foundation (DONE) ### Layer 0: Foundation (DONE)
@@ -80,8 +90,10 @@
- API: `/api/v1/notifications/...` (CRUD, preferences, read-all, unread-count) - API: `/api/v1/notifications/...` (CRUD, preferences, read-all, unread-count)
- Service: `notifications.service.ts` - Service: `notifications.service.ts`
- Components: `src/components/notifications/` - Components: `src/components/notifications/`
- [x] **Reminders** - Reminder pages - [x] **Reminders** - Full CRUD with background processors (dispatcher, reminder workers)
- Pages: `/reminders` - Pages: `/reminders`
- API: `/api/v1/reminders/...` (CRUD, my, overdue, upcoming, complete, dismiss, snooze)
- Service: `reminders.service.ts`
- [x] **Search** - Global search (inline in topbar), saved views - [x] **Search** - Global search (inline in topbar), saved views
- API: `/api/v1/search/...`, `/api/v1/saved-views/...` - API: `/api/v1/search/...`, `/api/v1/saved-views/...`
- Service: `search.service.ts`, `saved-views.service.ts` - Service: `search.service.ts`, `saved-views.service.ts`
@@ -178,11 +190,12 @@
### Priority 1: Deployment & Go-Live ### Priority 1: Deployment & Go-Live
- [ ] Push to Gitea and verify CI/CD pipeline builds - [x] Push to Gitea (origin/main at `9d815c4` as of 2026-04-22)
- [ ] Verify CI/CD pipeline builds the latest image and pushes to the Gitea container registry
- [ ] Set up server: install Docker, nginx, configure DNS for `pn.letsbe.solutions` - [ ] Set up server: install Docker, nginx, configure DNS for `pn.letsbe.solutions`
- [ ] Run `certbot --nginx -d pn.letsbe.solutions` for SSL - [ ] Run `certbot --nginx -d pn.letsbe.solutions` for SSL
- [ ] Configure production `.env` on server - [ ] Configure production `.env` on server
- [ ] Run database migrations (`pnpm db:push`) - [ ] Run database migrations (`drizzle-kit migrate` against prod DB — `0000` + `0001` need to apply)
- [ ] Run seed data (`pnpm db:seed`) - [ ] Run seed data (`pnpm db:seed`)
- [ ] Verify all services start and health check passes - [ ] Verify all services start and health check passes

48
assets/README.md Normal file
View File

@@ -0,0 +1,48 @@
# `assets/`
Server-side runtime assets bundled by Next.js (via `outputFileTracingIncludes`
in `next.config.ts`). These files are read with `fs.readFile` from
`process.cwd()` at runtime, so they are NOT served as public URLs — use
`public/` for that.
## `eoi-template.pdf`
The source PDF used by the in-app EOI generation pathway
(`src/lib/pdf/fill-eoi-form.ts`). It must be the **same** PDF that the
Documenso EOI template uploads, so both pathways produce equivalent
documents.
The PDF must contain AcroForm fields with these exact names (mirroring the
Documenso template's `formValues` keys — see
`docs/eoi-documenso-field-mapping.md`):
| Field name | Type | Filled with |
| -------------- | -------- | ----------------------------------------------------- |
| `Name` | Text | `EoiContext.client.fullName` |
| `Email` | Text | `EoiContext.client.primaryEmail` |
| `Address` | Text | `street, city, country` |
| `Yacht Name` | Text | `EoiContext.yacht.name` |
| `Length` | Text | `EoiContext.yacht.lengthFt` |
| `Width` | Text | `EoiContext.yacht.widthFt` |
| `Draft` | Text | `EoiContext.yacht.draftFt` |
| `Berth Number` | Text | `EoiContext.berth.mooringNumber` |
| `Lease_10` | Checkbox | always `false` (legacy default — Purchase, not Lease) |
| `Purchase` | Checkbox | always `true` |
Form fields stay interactive after generation (not flattened), so the
recipient can still tweak values before signing if the in-app pathway is
followed by a Documenso send.
### Override path
In dev/test, set `EOI_TEMPLATE_PDF_PATH=/abs/path/to/your/template.pdf` to
point at a different file (e.g. a fixture).
### How to extract this PDF
The legacy flow uploads this PDF to Documenso template ID 8. To get the
exact bytes:
1. In Documenso, open the EOI template.
2. Download the source PDF.
3. Drop it here as `eoi-template.pdf`.

BIN
assets/eoi-template.pdf Normal file

Binary file not shown.

Submodule client-portal deleted from e2d31815cf

View File

@@ -0,0 +1,76 @@
# Documenso EOI Template — Field Mapping
**Purpose:** This doc is the canonical reference for mapping the Documenso EOI template's `formValues` keys to the new data model's `EoiContext` shape. It drives `buildDocumensoPayload()` (Task 11.2), the in-app Standard EOI HTML tokens (Task 11.3), and the Spec 2 importer's yacht/company hydration.
## Source
The legacy field list comes from `client-portal/server/api/eoi/generate-quick-eoi.ts`, specifically the POST body sent to `POST /api/v1/templates/{templateId}/generate-document` (Documenso template 8). The relevant lines in that file are around the `createDocumentPayload.formValues` object.
## Documenso template `formValues` keys
Documenso template IDs and recipient IDs are configured via env vars:
- `NUXT_DOCUMENSO_TEMPLATE_ID` (default: `8`)
- `NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID` (default: `192`) — signing order 1
- `NUXT_DOCUMENSO_DEVELOPER_RECIPIENT_ID` (default: `193`) — signing order 2
- `NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID` (default: `194`) — APPROVER, signing order 3
The template exposes eight text fields (`formValues` keys) and two boolean checkboxes.
## Field mapping
| Documenso key | Type | Legacy source | New `EoiContext` path | Notes |
| -------------- | ------- | --------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). |
| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. |
| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. |
| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. |
| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Send as string. Documenso doesn't enforce numeric format. |
| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. |
| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". |
| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | One berth per reservation. Multi-berth case was multi-interest in legacy. |
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). |
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. |
## Document `meta` fields (non-`formValues`)
| Documenso key | Type | Legacy source | New source |
| ------------------------- | ---- | ---------------------------------------- | ----------------------------------------------------------------- |
| `meta.message` | text | `Dear ${interest['Full Name']}...` | `Dear ${context.client.fullName}, ...port name interpolated` |
| `meta.subject` | text | `"Your LOI is ready to be signed"` | Same — constant. |
| `meta.redirectUrl` | text | `"https://portnimara.com"` | `context.port.redirectUrl` if per-port; otherwise global app URL. |
| `meta.distributionMethod` | text | `"NONE"` | Same — constant. We use manual send flow (Documenso webhook). |
| `title` | text | `` `${interest['Full Name']}-EOI-NDA` `` | `` `${context.client.fullName}-EOI-NDA` `` |
| `externalId` | text | `` `loi-${interestId}` `` | Same. |
## Recipients (non-`formValues`)
| Recipient | Role | Name | Email | Signing order |
| ------------------- | -------- | ------------------------- | ----------------------------- | ------------- |
| Client (signer) | SIGNER | `context.client.fullName` | `context.client.primaryEmail` | 1 |
| Developer (signer) | SIGNER | `"David Mizrahi"` | `"dm@portnimara.com"` | 2 |
| Approval (approver) | APPROVER | `"Abbie May"` | `"sales@portnimara.com"` | 3 |
The Developer and Approval recipients are currently hardcoded in the legacy flow. In the new system these should eventually come from port-level settings (e.g., `ports.settings.eoi.developerName` + email). For Task 11.2, keep them hardcoded as the legacy system does — tracking as TODO: "Replace hardcoded Developer/Approval recipients with port-level configuration."
## Company-owned yacht handling
The legacy flow has no concept of company ownership — the signer is always the interest's client. In the new system:
- If `context.yacht.ownerType === 'client'`: behavior unchanged.
- If `context.yacht.ownerType === 'company'`: the interest's point-of-contact client still signs (they're the representative of the yacht's owning company), but an extra block should appear in the message body: `"On behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner)."`. This isn't a separate Documenso field — it's woven into `meta.message`.
Tracking this in the mapping doc rather than as a hard TODO because company-owned EOIs were rare in the legacy system and need product input before committing to the final wording.
## Deprecated fields (no longer sourced from `clients`)
The legacy system read these fields from the client row. They are now sourced elsewhere:
| Legacy source | New source |
| ------------------------- | --------------------------------------------------- |
| `client.yachtName` | `yachts.name` via `interest.yachtId` |
| `client.yachtLengthFt` | `yachts.lengthFt` via `interest.yachtId` |
| `client.yachtWidthFt` | `yachts.widthFt` via `interest.yachtId` |
| `client.yachtDraftFt` | `yachts.draftFt` via `interest.yachtId` |
| `client.companyName` | `companies.name` via polymorphic owner resolution |
| `client.berthSizeDesired` | Removed. Berth is picked via reservation, not text. |

View File

@@ -0,0 +1,199 @@
# Backup and restore runbook
This runbook documents what gets backed up, how often, where it lands, and
the exact commands to restore the system from a cold start. The goal is
that any operator who has the off-site backup credentials can bring the
CRM back up on a clean host without help.
## Scope of a "full backup"
The CRM has three stateful surfaces. All three must be captured for a
restore to be useful.
| Surface | Holds | Risk if missing |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| **PostgreSQL** (`port_nimara_crm`) | Every relational record: clients, yachts, companies, interests, reservations, invoices, audit log, GDPR exports, AI usage ledger, Documenso webhook receipts, etc. | Total data loss — site is unrecoverable. |
| **MinIO bucket** (`MINIO_BUCKET`, default `crm-files`) | Receipts, signed contracts, EOI PDFs, GDPR export ZIPs, document attachments. | Files reachable by row references in Postgres become 404s. |
| **`.env` + secrets** | DB password, MinIO keys, Documenso webhook secret, SMTP creds, encryption key (`ENCRYPTION_KEY`). | OCR API keys re-resolve from `system_settings` (encrypted at rest), but **without the original `ENCRYPTION_KEY` they're unreadable**. |
The Redis instance is not backed up. It only holds queue state, rate-limit
counters, and Socket.IO presence — all reconstructable. Stop the workers
during a restore so the queue starts clean.
## Backup schedule
Defaults are tuned for a single-port deployment with O(10k) clients. Bump
on the producing side as scale demands.
| Job | Frequency | Retention | Where |
| ---------------------------------- | -------------------- | ----------------------------- | -------------------------------------------------------------------- |
| `pg_dump` (custom format, gzipped) | Hourly | 7 days hourly + 30 days daily | `${BACKUP_BUCKET}/pg/<host>/<UTC date>/<hour>.dump.gz` |
| MinIO mirror | Hourly (incremental) | 30 days versions | `${BACKUP_BUCKET}/minio/` |
| `.env` snapshot (encrypted) | On change (manual) | Forever | Password manager / secrets vault — **never the same bucket as data** |
The hourly cadence is the right answer for this workload — invoices and
contracts cluster around business hours, and an hour of lost work is the
worst-case data loss window most clients will tolerate. Promote to 15-min
WAL streaming if a customer demands tighter RPO.
## Required environment variables
The scripts below read these. Store them in a CI secret store, not the
host's bash profile.
```
# Source (the running CRM database)
DATABASE_URL=postgresql://crm:<pw>@<host>:<port>/port_nimara_crm
# MinIO (source bucket — the live one)
MINIO_ENDPOINT=minio.letsbe.solutions
MINIO_PORT=443
MINIO_USE_SSL=true
MINIO_ACCESS_KEY=<live key>
MINIO_SECRET_KEY=<live secret>
MINIO_BUCKET=crm-files
# Backup destination (a *separate* MinIO/S3 endpoint or a different bucket
# with no IAM overlap with the live keys)
BACKUP_S3_ENDPOINT=https://s3.eu-west-1.amazonaws.com
BACKUP_S3_REGION=eu-west-1
BACKUP_S3_BUCKET=portnimara-backups-prod
BACKUP_S3_ACCESS_KEY=<dedicated read+write key for this bucket only>
BACKUP_S3_SECRET_KEY=<...>
# Optional: encrypts dumps at rest with a passphrase. Cuts a wider blast
# radius if the backup bucket itself is compromised.
BACKUP_GPG_RECIPIENT=ops@portnimara.com
```
## Provisioning the backup destination
1. Create a dedicated S3-compatible bucket in a **different account** from
the live infra. AWS S3, Backblaze B2, or a separately-credentialed
MinIO instance all work.
2. Apply object-lock or versioning so an attacker who steals the backup
write key still can't permanently delete history.
3. Generate IAM credentials scoped to `s3:PutObject`, `s3:GetObject`,
`s3:ListBucket` on this bucket only. Inject them as
`BACKUP_S3_*` above. Do not reuse the live `MINIO_*` keys.
4. Set a 90-day lifecycle rule that transitions objects older than 30
days to cold storage and deletes them at 90 days. Past 90 days it's
cheaper to restart from a snapshot taken outside the system.
## The scripts
Three scripts in `scripts/backup/`:
- `pg-backup.sh` — runs `pg_dump`, gzips, optionally GPG-encrypts, uploads
- `minio-mirror.sh``mc mirror` of the live bucket → backup bucket
- `restore.sh` — interactive restore (DB + MinIO) given a snapshot path
Make them executable and wire them into cron / GitHub Actions / your
scheduler of choice. Sample crontab on the worker host:
```cron
# Hourly DB dump at minute 7
7 * * * * /opt/pncrm/scripts/backup/pg-backup.sh >> /var/log/pncrm-backup.log 2>&1
# Hourly MinIO mirror at minute 17 (offset so the two don't fight for I/O)
17 * * * * /opt/pncrm/scripts/backup/minio-mirror.sh >> /var/log/pncrm-backup.log 2>&1
# Weekly restore drill (smoke-test to a throwaway DB on Sunday at 03:00)
0 3 * * 0 /opt/pncrm/scripts/backup/restore.sh --drill >> /var/log/pncrm-restore-drill.log 2>&1
```
## Restoring from cold
These steps have been rehearsed against the dev environment; expect them
to take 1530 minutes for a typical port. **The drill (last cron line
above) ensures the runbook stays correct — if the drill fails, the
real restore will too.**
### 0. Stop everything that writes
```bash
docker compose -f docker-compose.prod.yml stop web worker scheduler
# Leave postgres + minio + redis up; we'll point them at restored data.
```
### 1. Restore PostgreSQL
```bash
# Find the dump you want. Prefer the most recent successful hour.
mc ls "$BACKUP_S3_BUCKET/pg/$(hostname)/" | tail
SNAPSHOT="2026-04-28/14.dump.gz"
# Pull it.
mc cp "$BACKUP_S3_BUCKET/pg/$(hostname)/$SNAPSHOT" /tmp/
# Decrypt if BACKUP_GPG_RECIPIENT was set on the producer side.
gpg --decrypt /tmp/14.dump.gz.gpg > /tmp/14.dump.gz
# Drop & recreate the database. The 'restrict' FK from gdpr_exports.requested_by
# to user means we restore in the right order — pg_restore handles this.
psql "$DATABASE_URL" -c 'DROP DATABASE IF EXISTS port_nimara_crm WITH (FORCE);'
psql "$DATABASE_URL" -c 'CREATE DATABASE port_nimara_crm;'
gunzip -c /tmp/14.dump.gz | pg_restore --no-owner --no-privileges \
--dbname "$DATABASE_URL"
```
### 2. Restore MinIO
```bash
# Sync the backup bucket back over the live one. --overwrite handles
# files that were modified between snapshots.
mc mirror --overwrite \
"$BACKUP_S3_BUCKET/minio/" \
"live/$MINIO_BUCKET/"
```
### 3. Restore secrets
The `.env` file is **not** in object storage. Pull it from the password
manager / secrets vault. Verify `ENCRYPTION_KEY` matches the value used
when the database was last running — if it doesn't, rows in
`system_settings` (OCR API keys, etc.) decrypt to garbage and the OCR
"Test connection" button will return an opaque error. There is no
recovery path; the keys must be re-entered through the admin UI.
### 4. Bring services back up
```bash
docker compose -f docker-compose.prod.yml up -d
# Watch the worker logs; expect a flurry of socket reconnections, then quiet.
docker compose -f docker-compose.prod.yml logs -f worker
```
### 5. Verify
Tail through the smoke checklist, in order:
1. **DB up**`psql "$DATABASE_URL" -c 'SELECT count(*) FROM clients;'`
matches the producer-side count from the snapshot's hour.
2. **MinIO up** — open any client with attachments in the CRM, click a
receipt thumbnail; verify the signed URL serves the file.
3. **Documenso webhooks** — re-trigger one in the Documenso admin and
confirm `audit_logs` records the receipt.
4. **Email** — send a portal invite to a real address.
5. **Realtime** — open two browser windows, edit a client in one, watch
the other update via Socket.IO.
6. **AI usage ledger**`SELECT count(*) FROM ai_usage_ledger;`
non-empty if AI was being used. Old rows survive but the budget gates
reset alongside the period boundary at month rollover.
## Drill schedule
The weekly drill (cron line above) runs `restore.sh --drill` against a
throwaway database and a sandbox MinIO bucket. It must produce zero diff
between the restored row counts and the live row counts (modulo the
hour-or-so the drill takes to run).
Failure modes the drill catches before they bite production:
- New tables added without inclusion in `pg_dump`'s `--schema=public` (we
use the default, which captures everything in `public` — but a future
developer adding a `tenant_X` schema will silently lose it).
- MinIO bucket-policy changes that block the backup-side `s3:GetObject`
on certain prefixes.
- GPG passphrase rotation that wasn't propagated to the restore host.
- A `pg_restore` version skew with the producer-side `pg_dump`.

View File

@@ -0,0 +1,186 @@
# Email deliverability runbook
The CRM sends transactional email through three different surfaces. Each
has a different failure mode when it lands in spam. This runbook covers
how to diagnose, fix, and verify each path.
## What email the CRM sends
| Surface | Trigger | Template | Default `from` |
| ----------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------- |
| Portal activation / password-reset | Admin invites a client to the portal | `src/lib/email/templates/portal-auth.ts` | per-port `email_settings.from_address` or `SMTP_FROM` |
| Inquiry confirmation + sales notification | Public website POSTs to `/api/public/interests` or `/api/public/residential-inquiries` | `inquiry-client-confirmation.ts`, `inquiry-sales-notification.ts` | same |
| GDPR export ready | Staff requests an export with `emailToClient=true` | inline in `gdpr-export.service.ts` | same |
| Documenso reminders | Cadence job fires for an unsigned signer | `documenso/reminders/*` | same |
Documenso _itself_ sends signing requests with its own `from` address —
those don't flow through this codebase. SPF/DKIM for the Documenso
sender is the Documenso operator's problem, not yours.
## DNS records
For every domain that appears in a `from:` header you must publish:
### 1. SPF
A single TXT record at the apex authorizing whichever provider is
sending. Multiple SPF records on the same name **break SPF entirely**
combine into one.
```
v=spf1 include:_spf.google.com include:amazonses.com -all
```
The `-all` (hardfail) is correct for transactional mail. Switch to `~all`
(softfail) only as a temporary diagnostic when migrating providers.
### 2. DKIM
Each provider publishes its own selector. Common shapes:
- Google Workspace: `google._domainkey` → 2048-bit RSA pubkey (rotate every 12 months).
- Amazon SES: `xxxx._domainkey`, `yyyy._domainkey`, `zzzz._domainkey` (three CNAMEs SES gives you).
- Postmark / Resend / Mailgun: one CNAME per selector.
Verify alignment — the `d=` value in the DKIM signature must match the
`From:` domain (relaxed alignment is fine, strict is overkill).
### 3. DMARC
Start at `p=none` while you build deliverability data, then upgrade.
```
_dmarc 14400 IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@portnimara.com; ruf=mailto:dmarc@portnimara.com; fo=1; adkim=r; aspf=r; pct=100"
```
`rua` (aggregate reports) is the diagnostic feed — set it before the
first send so the first weekly report has data.
### 4. MX (only if you also receive)
The CRM's IMAP probe (`scripts/dev-imap-probe.ts`) and the inbound thread
sync rely on a real mailbox. Whoever runs that mailbox publishes the MX
records — typically Google Workspace or a dedicated provider. Don't add
an MX pointing at the CRM host; it doesn't accept SMTP IN.
## Per-port overrides
Each port can override `from_address`, `from_name`, and SMTP creds via
the admin email-settings page. When set, `getPortEmailConfig()` returns
those values and `sendEmail()` uses them in preference to the global
`SMTP_*` env. **The override domain still needs SPF / DKIM / DMARC** on
its own DNS — without them, every send from that port lands in spam.
When a customer reports "our portal invite didn't arrive":
1. Pull the port's email settings from the admin UI. Check `from_address`.
2. Run `dig TXT <from-domain>` and `dig TXT _dmarc.<from-domain>`.
Confirm SPF includes the SMTP provider's domain and DMARC exists.
3. Send a probe through `mail-tester.com`: paste the address into a
test send, click the score breakdown.
4. Score < 8/10 → fix whatever's flagged before doing anything else in
this runbook.
## Diagnosing a "didn't arrive" report
Order matters — go top-down, stop when one of these is the answer.
### Step 1: Was the send attempted?
```bash
# Tail the worker logs for the recipient address.
docker compose logs worker | grep '<recipient>'
```
You'll see one of three patterns:
- **Nothing**: The job didn't run. Check that BullMQ actually queued it.
`redis-cli LLEN bull:email:waiting` — if non-zero, the worker is dead.
`docker compose logs scheduler | tail` to see why.
- **`Email sent`** with a message-id: The provider accepted it. Move to
Step 2.
- **`SendError`**: Provider rejected. The error string says why
(auth, rate limit, blocked recipient).
### Step 2: Is `EMAIL_REDIRECT_TO` set?
In dev/test we set `EMAIL_REDIRECT_TO=ops@portnimara.com` so seeded fake
clients don't get real email. **It must be unset in production.**
```bash
# On the production host:
docker exec pncrm-web printenv EMAIL_REDIRECT_TO
# Should print nothing.
```
If it's set, every email is going to the redirect target with the
original recipient prefixed in the subject — the customer never sees it.
### Step 3: Did it land but get filtered?
Ask the recipient to check:
- Spam / Junk folder
- Gmail "Promotions" tab
- Outlook "Other" folder (vs Focused)
- The Quarantine console if they're on M365 with anti-spam enabled
If found in a spam folder: the email arrived; the recipient's filter
classified it. SPF/DKIM/DMARC alignment is suspect — re-run the
mail-tester probe from above.
### Step 4: Was the recipient on a suppression list?
Some providers (SES, Postmark) maintain a suppression list — once a
domain bounces from an address, future sends are dropped silently.
```bash
# SES example:
aws ses list-suppressed-destinations --region eu-west-1
```
If the recipient is suppressed, remove them and ask them to retry. The
CRM doesn't track suppression locally; that's the provider's job.
## When migrating SMTP providers
1. Add the new provider's DKIM CNAMEs alongside the old ones.
2. Add the new provider's `include:` to the existing SPF record.
3. Wait 48 hours for DNS to propagate and DMARC reports to confirm both
providers align.
4. Switch `SMTP_*` env to the new provider on a single staging host.
5. Send through the staging host for a week. Watch DMARC reports.
6. Cut production over.
7. Wait two weeks before removing the old provider's DNS — undelivered
bounce reports keep arriving for a while.
## Testing a deliverability fix
There's no automated test for "did this email reach the inbox" — that's a
property of the recipient's filter, which we don't control. The closest
proxy is the realapi suite:
```bash
pnpm exec playwright test --project=realapi
```
It runs `tests/e2e/realapi/portal-imap-activation.spec.ts` which sends a
real portal-invite email through SMTP, then polls the configured IMAP
mailbox for the activation link. If it appears within 30 seconds, the
SMTP→DKIM→DMARC chain is alive end-to-end. If the test times out, work
backwards through this runbook.
The realapi suite needs `SMTP_*` and `IMAP_*` env vars — see the
"Optional dev/test-only env vars" block in `CLAUDE.md`.
## Bounce handling
The CRM doesn't currently process bounces. If you start seeing volume:
- Set up the provider's webhook (SES → SNS → Lambda; Postmark → webhook
URL) to POST bounce events to a new `/api/webhooks/email-bounce` route.
- Persist the bounced address into a `email_suppressions` table.
- Have `sendEmail()` consult that table before each send.
That work isn't in scope yet; this runbook just flags it as the next
deliverability gap.

View File

@@ -0,0 +1,56 @@
# Permission Matrix Audit
Scanned 182 route files under `src/app/api/v1/`.
**No violations.** Every internal v1 handler is permission-gated.
**Allow-listed:** 46 handler(s) intentionally skip `withPermission`.
| File | Method | Reason |
| ---------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------- |
| `src/app/api/v1/admin/alerts/run-engine/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/connections/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/errors/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/health/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/ocr-settings/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/ocr-settings/route.ts` | PUT | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/ocr-settings/test/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts` | DELETE | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/[queueName]/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/users/options/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/ai/email-draft/[jobId]/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/ai/email-draft/route.ts` | POST | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/ai/interest-score/bulk/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/ai/interest-score/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/alerts/[id]/acknowledge/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/[id]/dismiss/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/count/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/berth-reservations/[id]/route.ts` | PATCH | TODO: PATCH should map to reservations:edit (not currently in catalog). |
| `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. |
| `src/app/api/v1/currency/rates/refresh/route.ts` | POST | TODO: gate with admin:manage_settings — currently allow-listed. |
| `src/app/api/v1/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. |
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | GET | TODO: needs custom_fields:\* permission. PUT path internally validated. |
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | PUT | TODO: needs custom_fields:\* permission. PUT path internally validated. |
| `src/app/api/v1/expenses/export/parent-company/route.ts` | POST | Internally gated by isSuperAdmin inside the handler. |
| `src/app/api/v1/me/route.ts` | GET | Self-endpoint — auth is sufficient. |
| `src/app/api/v1/me/route.ts` | PATCH | Self-endpoint — auth is sufficient. |
| `src/app/api/v1/notifications/[notificationId]/route.ts` | PATCH | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/preferences/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/preferences/route.ts` | PUT | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/read-all/route.ts` | POST | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/unread-count/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/saved-views/[id]/route.ts` | PATCH | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/saved-views/[id]/route.ts` | DELETE | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/saved-views/route.ts` | GET | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/saved-views/route.ts` | POST | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/search/recent/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
| `src/app/api/v1/search/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
| `src/app/api/v1/settings/feature-flag/route.ts` | GET | Public read of feature-flag bool — no PII; auth is sufficient. |
| `src/app/api/v1/tags/options/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
| `src/app/api/v1/tags/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
| `src/app/api/v1/users/me/preferences/route.ts` | GET | User-self preferences — caller is the resource owner. |
| `src/app/api/v1/users/me/preferences/route.ts` | PATCH | User-self preferences — caller is the resource owner. |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,663 @@
# Data-Model Refactor: Yachts and Companies as First-Class Entities
**Status:** Draft — awaiting final review
**Date:** 2026-04-23
**Spec position:** 1 of 3 (Spec 2 = NocoDB+MinIO importer; Spec 3 = client merge endpoint)
## Overview
This spec delivers a refactor of the core client / yacht / company data model to support real-world ownership relationships that the current schema cannot express.
The current `clients` table holds yacht dimensions and company name as columns directly on the person row. This enforces a one-person = one-yacht = one-company assumption that breaks the moment:
- A client owns multiple yachts (a common marina scenario)
- A person is a broker or director of multiple companies
- A yacht is legally owned by a shell company (common for tax / liability reasons) rather than by the human on the dock
- A yacht changes hands between owners and the marina needs chain-of-title
The refactor pulls yacht and company data into their own first-class tables, adds join tables for person↔company memberships, and introduces a proper `berth_reservations` table for exclusive-reservation lifecycle tracking.
This spec also fixes two existing schema gaps that surface during the refactor:
- `berths.status` tracks the state of a berth but there is no table recording which client/yacht exclusively reserves a berth
- `invoices.clientName` is a text field with no FK — there's no first-class link between invoices and billing entities
## Scope boundaries
### In scope (this spec)
- New `yachts`, `yacht_ownership_history`, `yacht_notes`, `yacht_tags` tables
- New `companies`, `company_memberships`, `company_addresses`, `company_notes`, `company_tags` tables
- New `berth_reservations` table with partial-unique-index exclusivity enforcement
- Updates to `interests`, `berth_waiting_list`, `invoices`, `files`, `documents` to add FKs to the new entities
- Removal of yacht, company, and proxy columns from `clients`
- New services, API routes, permissions, and socket/webhook events
- New UI pages for yachts, companies, and berth reservations; modifications to client, interest, berth, invoice forms
- Dual-path EOI generation (Documenso + in-app PDF template) with a shared payload builder
- Comprehensive test coverage: unit, integration, E2E, exhaustive click-through, template regression
- Seeder with realistic multi-cardinality dummy data
### Explicitly out of scope
- **Importing NocoDB records and MinIO documents** → Spec 2
- **Client merge endpoint** → Spec 3
- Yacht survey / class-cert document categorization
- Company hierarchy (holding → subsidiary)
- Line-item-level yacht references on invoices
- Auto-renewal flow for berth reservations
- Per-yacht row-level permissions
- Portal branding per company
## Decisions and rationale
| Topic | Decision | Why |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Yacht scope | Full entity: own page, documents, ownership history, yacht-keyed interests / reservations / invoices | Marina domain cares about yachts as first-class objects (dimensions for berth fit, registration for port entry, ownership for liability) |
| Company scope | Full entity: memberships join, company-owned yachts, company billing | Yachts are frequently owned by shell companies for tax/liability reasons — the human on the dock is a director or broker. Lightweight/medium models can't route invoices to the correct legal entity |
| Ownership history | Dedicated `yacht_ownership_history` table + denormalized current-owner columns on `yachts` | Ownership change is exactly the kind of event that needs queryable history (chain of title, insurance, broker commission attribution). Denormalized current-owner keeps common reads fast |
| Proxy fields on clients (`isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`) | Drop all four | Every real proxy scenario is expressible through `company_memberships` roles or `client_relationships`. Keeping the old fields creates two sources of truth and drift risk |
| Berth exclusive reservation | New `berth_reservations` table with partial unique index `WHERE status = 'active'` | Current schema tracks berth state via `berths.status` but does not record which client/yacht holds the reservation. Partial unique index enforces exclusivity at the DB level |
| Invoice billing entity | `billingEntityType` (`'client' \| 'company'`) + `billingEntityId`; `clientName` retained as an immutable snapshot | Companies become first-class payers. `clientName` as text is preserved on the invoice as a snapshot so invoices never retroactively rename themselves |
| Data state | Green-field with dummy seeder; real data arrives via Spec 2 | No production data lives in this Postgres DB yet. NocoDB holds the real records until Spec 2 imports them |
| Delivery | One cohesive spec covering both yacht + company refactor | Splitting doubles the migration/UI/test churn for no architectural gain; both sets of changes overlap heavily |
| EOI template strategy | Support both Documenso-template path and in-app PDF template path, both fully functional from day one | Handoff risk: client must not come back claiming "EOIs don't work." If Documenso breaks or is replaced, in-app path is the fallback. Both consume the same payload builder for data consistency |
| EOI UI picker | Dropdown at generation time (user picks Documenso or in-app explicitly) | Explicit beats automatic fallback for handoff — misconfiguration is visible, not silently masked |
| Testing | Unit, integration, full E2E scenarios, exhaustive Playwright click-through, template regression (including visual diff) | Explicit "test thoroughly" direction plus the handoff concern justify going heavier than normal on integration + E2E tiers |
## Schema design
### New tables
```
yachts
id text PK
portId text NOT NULL FK → ports.id
name text NOT NULL
hullNumber text
registration text
flag text
yearBuilt integer
builder text
model text
hullMaterial text
lengthFt numeric
widthFt numeric
draftFt numeric
lengthM numeric
widthM numeric
draftM numeric
currentOwnerType text NOT NULL -- 'client' | 'company'
currentOwnerId text NOT NULL
status text NOT NULL DEFAULT 'active' -- 'active' | 'retired' | 'sold_away'
notes text
archivedAt timestamptz
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_yachts_port on (portId)
idx_yachts_current_owner on (portId, currentOwnerType, currentOwnerId)
idx_yachts_name on (portId, name)
yacht_ownership_history
id text PK
yachtId text NOT NULL FK → yachts.id ON DELETE CASCADE
ownerType text NOT NULL -- 'client' | 'company'
ownerId text NOT NULL
startDate date NOT NULL
endDate date -- NULL = currently active
transferReason text -- 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other'
transferNotes text
createdBy text NOT NULL
createdAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_yoh_yacht on (yachtId)
idx_yoh_active (partial) on (yachtId) WHERE endDate IS NULL
yacht_notes -- mirrors client_notes shape
id, yachtId (FK CASCADE), authorId, content, mentions text[], isLocked, createdAt, updatedAt
yacht_tags
yachtId, tagId composite PK; tagId references system.tags.id
companies
id text PK
portId text NOT NULL FK → ports.id
name text NOT NULL
legalName text
taxId text
registrationNumber text
incorporationCountry text
incorporationDate date
status text NOT NULL DEFAULT 'active' -- 'active' | 'dissolved'
billingEmail text
notes text
archivedAt timestamptz
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_companies_port on (portId)
idx_companies_name_unique UNIQUE on (portId, lower(name)) -- case-insensitive
idx_companies_taxid on (portId, taxId) WHERE taxId IS NOT NULL
company_memberships
id text PK
companyId text NOT NULL FK → companies.id ON DELETE CASCADE
clientId text NOT NULL FK → clients.id ON DELETE CASCADE
role text NOT NULL -- 'director' | 'officer' | 'broker' | 'representative' | 'legal_counsel' | 'employee' | 'shareholder' | 'other'
roleDetail text -- free-text qualifier: "Managing Director", "Exclusive Broker"
startDate date NOT NULL
endDate date -- NULL = active
isPrimary boolean NOT NULL DEFAULT false
notes text
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_cm_company on (companyId)
idx_cm_client on (clientId)
idx_cm_active (partial) on (companyId, clientId) WHERE endDate IS NULL
unique_cm_exact UNIQUE on (companyId, clientId, role, startDate)
company_addresses -- mirrors client_addresses shape with companyId FK
company_notes -- mirrors client_notes shape with companyId FK
company_tags
companyId, tagId composite PK
berth_reservations
id text PK
berthId text NOT NULL FK → berths.id
portId text NOT NULL FK → ports.id
clientId text NOT NULL FK → clients.id -- contract holder
yachtId text NOT NULL FK → yachts.id -- which yacht occupies the slip
interestId text FK → interests.id -- nullable link back to originating interest
status text NOT NULL -- 'pending' | 'active' | 'ended' | 'cancelled'
startDate date NOT NULL
endDate date -- NULL = open-ended
tenureType text NOT NULL DEFAULT 'permanent' -- 'permanent' | 'fixed_term' | 'seasonal'
contractFileId text FK → files.id
createdBy text NOT NULL
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_br_berth on (berthId)
idx_br_client on (clientId)
idx_br_yacht on (yachtId)
idx_br_active (partial) UNIQUE on (berthId) WHERE status = 'active'
```
### Modified tables
```
clients
DROP COLUMN yachtName, yachtLengthFt, yachtWidthFt, yachtDraftFt,
yachtLengthM, yachtWidthM, yachtDraftM, berthSizeDesired
DROP COLUMN companyName
DROP COLUMN isProxy, proxyType, actualOwnerName, relationshipNotes
(retains: fullName, nationality, preferredContactMethod, preferredLanguage,
timezone, source, sourceDetails, archivedAt, createdAt, updatedAt)
interests
ADD COLUMN yachtId text FK → yachts.id -- nullable initially; enforced non-null before pipeline_stage leaves 'open'
ADD INDEX idx_interests_yacht on (yachtId)
berth_waiting_list
ADD COLUMN yachtId text FK → yachts.id
invoices
ADD COLUMN billingEntityType text NOT NULL -- 'client' | 'company'
ADD COLUMN billingEntityId text NOT NULL
(clientName column kept as immutable snapshot — must never auto-update)
ADD INDEX idx_invoices_billing_entity on (portId, billingEntityType, billingEntityId)
files
ADD COLUMN yachtId text FK → yachts.id -- nullable
ADD COLUMN companyId text FK → companies.id -- nullable
(existing clientId stays nullable; a file links to one of: client, yacht, or company)
documents
ADD COLUMN yachtId text FK → yachts.id -- nullable
ADD COLUMN companyId text FK → companies.id -- nullable
```
### DB-level invariants
| # | Invariant | Enforced by |
| --- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | One active ownership row per yacht | Partial unique index on `yacht_ownership_history(yachtId) WHERE endDate IS NULL` |
| 2 | One active reservation per berth | Partial unique index on `berth_reservations(berthId) WHERE status = 'active'` |
| 3 | Yacht always has a current owner | Both `currentOwnerType` and `currentOwnerId` NOT NULL; ownership row inserted atomically with yacht creation inside service transaction |
| 4 | Company names unique per port (case-insensitive) | Unique index on `(portId, lower(name))` |
| 5 | Exact-duplicate memberships blocked | Unique index on `(companyId, clientId, role, startDate)` |
### Service-layer invariants (not DB-enforceable due to polymorphic columns)
| # | Invariant | Enforced by |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| 6 | `yacht.currentOwnerType='client'``currentOwnerId` references an existing row in `clients`; same for `'company'``companies` | Zod validator + service-layer lookup before insert/update |
| 7 | `yacht_ownership_history.ownerType/ownerId` consistent with the corresponding entity table | Same as #6 |
| 8 | `invoices.billingEntityType` + `billingEntityId` consistent with entity table | Same as #6 |
| 9 | `files.clientId`, `files.yachtId`, `files.companyId` — exactly one of the three must be non-null if the file is entity-scoped | Service-layer validation on insert/update |
### Drizzle relations (`relations.ts`)
All new tables wire into the relations map. Notable additions:
- `clientsRelations`: `companyMemberships` (many), `ownedYachts` (many, via polymorphic query), `berthReservations` (many)
- `yachtsRelations`: `port` (one), `ownershipHistory` (many), `notes` (many), `tags` (many), `interests` (many), `reservations` (many), `documents` (many)
- `companiesRelations`: `port` (one), `memberships` (many), `addresses` (many), `notes` (many), `tags` (many), `documents` (many)
- `berthReservationsRelations`: `berth`, `port`, `client`, `yacht`, `interest`, `contractFile`
## Service layer and API
### New services (`src/lib/services/`)
| File | Key functions |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `yachts.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `transferOwnership(yachtId, newOwnerType, newOwnerId, effectiveDate, reason, notes)` — atomic: closes current history row, opens new row, updates denormalized `currentOwner*` columns |
| `companies.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `upsertByName(portId, name)` (case-insensitive, for autocomplete) |
| `company-memberships.service.ts` | `addMembership`, `endMembership(id, endDate)`, `updateMembership`, `listByCompany`, `listByClient`, `setPrimary` |
| `berth-reservations.service.ts` | `createPending`, `activate(id)` (gates on partial unique index), `end(id, endDate)`, `cancel(id)`, `listByBerth`, `listByClient`, `listByYacht` |
### Modified services
| File | Change |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `clients.service.ts` | Strip yacht/company/proxy field handling from create/update paths |
| `interests.service.ts` | Accept `yachtId`; validate yacht is owned by the interest's client OR by a company the client actively represents. Promote-to-stage helpers require `yachtId` non-null before leaving `'open'` |
| `berths.service.ts` | Read reservation state via `berth_reservations` instead of deriving from `berths.status`. Reservation state changes also update `berths.status` via trigger-in-service-layer |
| `invoices.service.ts` | Accept `billingEntityType` + `billingEntityId`; snapshot the entity's current display name into `clientName` at creation (immutable afterward) |
| `search.service.ts` | Extend to yachts and companies; include yacht name, hull number, registration in search index; include company name, legal name, taxId |
| `recommendations.ts` (berth matcher) | Pull yacht dimensions from `yachts` table via `interest.yachtId` instead of from `clients.yacht*` |
| `document-templates.ts` | Update `MERGE_FIELDS` catalog: deprecate `{{client.yachtName}}`, `{{client.companyName}}` and old yacht dimension tokens; add `{{yacht.*}}`, `{{company.*}}`, `{{owner.*}}` scopes. Update `resolveTemplate()` to resolve new scopes |
| `portal.service.ts` | Portal user dashboards surface their yachts (owned + represented via memberships), their active memberships, and their active berth reservations |
### New REST endpoints
```
# Yachts
GET /api/v1/yachts
POST /api/v1/yachts
GET /api/v1/yachts/:id
PATCH /api/v1/yachts/:id
DELETE /api/v1/yachts/:id — archive (soft delete)
POST /api/v1/yachts/:id/transfer — ownership transfer
GET /api/v1/yachts/:id/ownership-history
GET /api/v1/yachts/autocomplete?q=…
# Companies
GET /api/v1/companies
POST /api/v1/companies
GET /api/v1/companies/:id
PATCH /api/v1/companies/:id
DELETE /api/v1/companies/:id — archive
GET /api/v1/companies/autocomplete?q=…
# Company memberships
GET /api/v1/companies/:id/members
POST /api/v1/companies/:id/members
PATCH /api/v1/companies/:id/members/:mid
DELETE /api/v1/companies/:id/members/:mid — sets endDate
# Berth reservations
GET /api/v1/berths/:id/reservations
POST /api/v1/berths/:id/reservations — create pending
PATCH /api/v1/berth-reservations/:id — state transitions
```
### Modified endpoints
- `GET /api/v1/clients/:id` — response now includes nested `yachts` (owned + represented), `companies` (via active memberships), `activeReservations`
- `POST /api/v1/clients` — no longer accepts yacht/company/proxy fields
- `POST /api/v1/interests` — requires `yachtId`
- `POST /api/v1/invoices` — requires `billingEntityType` + `billingEntityId`
- `POST /api/public/interests` — creates new `client` + `yacht` + optional `company` + `membership` + `interest` in one transaction, all marked `source: 'public_submission'`. No dedup against existing records (anonymous trust boundary).
### Permissions (new keys)
```
yachts:view
yachts:write
yachts:transfer — higher-stakes operation, separate from :write
yachts:delete — archive permission
companies:view
companies:write
companies:delete
memberships:write — covers both directions of company_memberships
reservations:view
reservations:write
```
Existing role updates:
- `admin` — all new keys
- `team_lead``yachts:view`, `yachts:write`, `companies:view`, `companies:write`, `memberships:write`, `reservations:view`; NOT `yachts:transfer` or `reservations:write`
- `front_desk` — all `:view` keys
### Socket / webhook events (new)
```
yacht.created
yacht.updated
yacht.ownership_transferred
yacht.archived
company.created
company.updated
company.archived
company_membership.added
company_membership.ended
berth_reservation.created
berth_reservation.activated
berth_reservation.ended
berth_reservation.cancelled
```
Webhook event map in `src/lib/services/webhooks.ts` gains the same list.
## EOI template strategy (dual-path)
Both paths fully supported from day one. Required to mitigate handoff risk — if Documenso breaks or is replaced, the in-app path is the fallback.
### Shared payload builder
```ts
// src/lib/services/eoi-context.ts
export async function buildEoiContext(interestId: string): Promise<EoiContext>
type EoiContext = {
client: { fullName; nationality; primaryEmail; primaryPhone; address; }
yacht: { name; lengthFt; widthFt; draftFt; hullNumber; flag; yearBuilt; } // via interest.yachtId
company: { name; legalName; taxId; billingAddress } | null // if yacht owner is a company
owner: { type: 'client' | 'company'; name; } // polymorphic current owner
berth: { mooringNumber; area; lengthFt; price; priceCurrency; tenureType; }
interest: { stage; leadCategory; dateFirstContact; notes; }
port: { name; defaultCurrency; legalEntity; }
date: { today; year }
}
```
Both paths consume this. Guarantees the two rendering engines see the same data and stay in sync as schema evolves.
### Path A — Documenso template
- Documenso hosts the template, referenced by ID via env var `DOCUMENSO_TEMPLATE_ID` (matches the old system's `NUXT_DOCUMENSO_TEMPLATE_ID` pattern — a single global template ID; per-port templates are a future extension if needed)
- Payload builder flattens `EoiContext` into Documenso's field-name format, POSTs to `/api/v1/templates/{id}/generate-document`
- Signing flow unchanged: Documenso emails signers, webhook updates status in our DB
- Mitigation for "Documenso's template expects specific field names": one-time audit mapping every field name expected by `templateId=8` (from the old system) to a source in the new schema
### Path B — In-app PDF template
- Seed a "Standard EOI" HTML template into `document_templates` table on first boot. Template references tokens: `{{client.fullName}}`, `{{yacht.name}}`, `{{yacht.lengthFt}}`, `{{company.name}}`, `{{berth.mooringNumber}}`, `{{interest.dateFirstContact}}`, etc.
- `resolveTemplate()` substitutes tokens from `EoiContext`
- `pdfme` renders the resolved HTML to PDF
- **Signing**: generated PDF is uploaded to Documenso via existing `documensoCreate` + `documensoSend` — Documenso supports signing ad-hoc PDFs (not just its own templates). Signing experience identical to Path A from the signer's perspective.
- **Fallback**: if Documenso is unavailable, the PDF can be emailed to the signer via `nodemailer` as a manual fallback (flag in UI, not auto-fallback)
### UI picker
Generate-EOI dialog adds a Template dropdown:
```
Template: [ Documenso — Standard EOI v ]
[ Documenso — Standard EOI ]
[ In-app — Standard EOI ]
[ In-app — (any custom template user authored) ]
```
Explicit picker chosen over automatic fallback: misconfiguration is visible, not silently masked — important for handoff.
## UI impact
### New pages
| Route | Purpose |
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
| `/[portSlug]/yachts` | List view: name, dimensions, current owner, status. Filters by owner type, size, status |
| `/[portSlug]/yachts/[yachtId]` | Detail — Tabs: Overview, Ownership History, Interests, Reservations, Documents, Notes, Tags |
| `/[portSlug]/companies` | List view: name, legal name, # members, # owned yachts |
| `/[portSlug]/companies/[companyId]` | Detail — Tabs: Overview, Members, Owned Yachts, Addresses, Documents, Notes, Tags |
### Modified pages
| Page | Change |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `client-form` | Remove yacht / companyName / proxy fields. Becomes a clean "person" form. Yacht and company associations managed from detail page, not here |
| `client-detail` | Add tabs: Yachts (owned + represented), Companies (active memberships), Reservations |
| `client-columns` | Replace yacht/company text columns with "# yachts" and "Primary company" (from active memberships marked `isPrimary`) |
| `interest-form` | New required field: yacht picker, constrained to client's yachts (with inline "Add new yacht" option) |
| `interest-detail` | Display yacht prominently; berth recommendations match against yacht dimensions |
| `berth-detail` | New tab: Reservations. Shows active reservation + history. "Reserve this berth" button opens reservation dialog |
| `invoice-form` | New billing-entity picker (client or company toggle + autocomplete); `clientName` snapshot populates automatically |
| `eoi-generate-dialog` | New template-picker dropdown (per dual-path strategy) |
| Global search | Extended to yachts and companies |
| Sidebar | Adds "Yachts" and "Companies" entries. Reservations lives inside the Berths page |
| `/api/public/interest` form (new interest submission) | Captures yacht + company sub-forms; creates new trio on submission |
### Portal pages
- Dashboard: shows owned + represented yachts, active memberships, active reservations
- New "My Yachts" tab — read-only yacht detail scoped to ones user owns or represents
- New "My Reservations" tab
- Authenticated interest submissions create yacht row linked to the portal user (not anonymous)
### New components (`src/components/`)
```
yachts/
yacht-form.tsx
yacht-detail.tsx
yacht-detail-header.tsx
yacht-tabs.tsx
yacht-columns.tsx
yacht-picker.tsx
yacht-ownership-history.tsx
yacht-transfer-dialog.tsx
companies/
company-form.tsx
company-detail.tsx
company-detail-header.tsx
company-tabs.tsx
company-columns.tsx
company-picker.tsx
company-members-tab.tsx
company-owned-yachts-tab.tsx
add-membership-dialog.tsx
reservations/
reservation-form.tsx
reservation-list.tsx
berth-reserve-dialog.tsx
shared/
owner-picker.tsx — polymorphic client|company autocomplete
billing-entity-picker.tsx
```
All follow existing `shadcn/ui` + CVA + react-hook-form + zod pattern.
### Seeder (`src/lib/db/seed.ts`) — rewrite
Produces realistic multi-cardinality fixtures:
- 3 companies (two with multiple members, one dissolved with an `endDate` on all memberships)
- 8 clients (some personal-only, some with company memberships, at least one representing multiple companies)
- 12 yachts (mix of client-owned and company-owned; 2-3 with ownership-transfer history)
- Interests linking clients ↔ yachts ↔ berths with realistic pipeline-stage distribution
- A handful of active berth reservations + a few ended/cancelled ones
- Rich contact / address / membership / ownership-history data covering every test scenario
Seeder shares factory helpers with tests (`tests/helpers/factories.ts`).
## Testing strategy
### Coverage targets (CI-enforced)
| Tier | Target |
| ------------- | ------------------- |
| Service layer | ≥ 90% line coverage |
| Validators | 100% line coverage |
| API routes | ≥ 85% line coverage |
| Overall | ≥ 85% line coverage |
Hard rules: no skipped tests on `main`; no PR merge without green CI on all tiers.
### Tier 1 — Unit tests (Vitest)
- Every new service function: happy path, each validation failure, each precondition failure, tenant-scoping
- Merge-field resolver: every new token resolves correctly across each context shape
- Validators: every zod schema tested for pass + fail on each field
### Tier 2 — Integration tests (Vitest + Postgres via docker-compose test DB)
- Migration up/down correctness
- Partial unique indexes (`berth_reservations(berthId) WHERE status='active'`, `yacht_ownership_history(yachtId) WHERE endDate IS NULL`) reject duplicate inserts
- FK cascades: deleting a client cascades contacts/addresses; yacht-with-this-owner is BLOCKED from being lost
- Atomic `transferOwnership`: concurrent retries result in consistent state
- Polymorphic integrity checks: `yacht.currentOwnerType='client'` with a companyId is rejected by service-layer validation
- Company name case-insensitive uniqueness
- Every new API route: auth → permission → service → DB → response shape
### Tier 3 — E2E scenario tests (Playwright)
Full-lifecycle flows:
1. Create client → add yacht → create interest → generate EOI (Documenso path) → PDF in MinIO
2. Same, in-app template path → verify PDF content contains expected yacht name
3. Create company → add two clients as members → create yacht owned by company → generate invoice billed to company
4. Yacht transfer: client-owned → company-owned; verify history + denormalized column + UI
5. Reserve berth: create → verify visible → attempt duplicate reservation → blocked
6. Public interest form → admin sees new client+yacht+company+interest trio
7. (Spec 3 stub): merge flow tested end-to-end in Spec 3
Multi-cardinality flows (the core justification for this refactor):
8. One client with 3 yachts, 3 interests, 3 different berths — all representable
9. One person as broker for 2 companies, each owning 1 yacht — memberships + owned yachts visible from client detail
Portal flows:
10. Portal user views "my yachts" — sees only owned/represented
11. Portal user submits interest — new yacht linked to their identity
### Tier 3.5 — Exhaustive Playwright click-through suite
Location: `tests/e2e/exhaustive/`. Separate CI job (15-20 min, runs in parallel with other tiers, blocks merge if failing).
Spec files: `yachts`, `companies`, `reservations`, `client-detail-refactored`, `eoi-generate`, `invoice-form`, `berths-with-reservations`, `portal`, `navigation`.
Per-page logic:
1. Navigate to page
2. Enumerate every interactive element (`button`, `a`, `[role="button"]`, `[data-testid]`, form inputs)
3. Click/fill each; post-click: assert no console errors, no 4xx/5xx network responses, UI returns to stable state
4. Coverage assertion: elements clicked ≥ total elements on page (minus declared destructive-action allowlist)
Helper: `tests/helpers/click-everything.ts` exports `clickEverythingOnPage(page, opts)`.
Destructive actions allowlist (tested separately with create-then-destroy isolation):
```
yachts.delete, yachts.archive, yachts.transferOwnership
companies.delete, companies.archive
companyMemberships.end
berthReservations.cancel, berthReservations.end
invoices.delete
```
Acceptance criteria for Spec 1 completion:
- Every new or changed page has 100% coverage in the exhaustive suite (minus allowlist)
- Every allowlist entry has its own narrow destructive test
- Zero console errors across the full suite
- Zero unexpected 4xx/5xx responses
### Tier 4 — EOI template regression
- **Documenso payload snapshot test**: mock Documenso API; assert POST body contains every expected field name with correct value sourced from new schema
- **In-app template rendering test**: render seeded template against each scenario's context; assert resolved HTML contains expected substrings; assert `pdfme` produces a non-empty PDF
- **Visual diff**: render in-app EOI to PDF, compare against committed golden-image PDFs per scenario; regressions surface as image diffs in PR
- **Error paths**: missing yacht, missing company with company-owned yacht reference, missing config (Documenso API key missing) — all produce explicit errors, not silent blanks
### Tier 5 — Security tests
- Cross-tenant isolation: yacht/company/reservation in port A invisible/unmodifiable from port B
- Permission enforcement: user without `yachts:write` cannot `POST /yachts`; `yachts:transfer` required for transfer endpoint
- Portal authorization: portal user cannot see yachts they don't own/represent
- Public interest endpoint: anonymous submitter cannot read existing records
### Test infrastructure
Fixture factories in `tests/helpers/factories.ts`:
```
makeYacht({ owner: client|company, ...overrides })
makeCompany({ overrides })
makeMembership({ client, company, role, ...overrides })
makeOwnershipHistoryRow({ yacht, owner, startDate, endDate })
makeReservation({ berth, client, yacht, status })
```
Scenario builders produce Tier 3 multi-cardinality setups in a single call.
Integration tests run against a fresh migrated DB; each test file wraps in a transaction that rolls back OR uses per-file schema isolation.
## Rollout plan
Green-field Postgres DB — no dual-write, no phased migration needed. Concern is only sequencing so the working tree never enters a broken half-migrated state.
### PR sequence (≈ 15 PRs, feature branch `refactor/data-model`)
| # | PR | Depends on |
| --- | --------------------------------------------------------------------------------------------------- | ------------ |
| 1 | Schema migration: add all new tables, leave old client columns in place | — |
| 2 | Service layer: new services (yachts, companies, memberships, reservations) | 1 |
| 3 | API routes for new services + new permissions | 2 |
| 4 | Seeder rewrite with multi-cardinality fixtures | 2 |
| 5 | UI: yacht list + detail + form + picker + ownership-history + transfer-dialog | 3 |
| 6 | UI: company list + detail + form + picker + memberships tab + add-membership dialog | 3 |
| 7 | UI: berth reservations tab + reserve dialog + ownership-transfer wiring | 3 |
| 8 | Client form refactor: strip yacht/company/proxy fields, add nav links to yachts/companies | 5, 6 |
| 9 | Interest form: require `yachtId` + public interest form creates trio | 5 |
| 10 | Invoice billing-entity support (client or company) | 6 |
| 11 | EOI shared payload builder + seed in-app Standard EOI template + dual-path dialog | 5, 6 |
| 12 | Merge-field catalog update + resolver extension for `{{yacht.*}}` / `{{company.*}}` / `{{owner.*}}` | 11 |
| 13 | Drop old columns from `clients` (`yacht*`, `companyName`, proxy fields) | 8, 9, 10, 11 |
| 14 | Exhaustive Playwright click-through suite (Tier 3.5) | 13 |
| 15 | Documentation updates (CLAUDE.md, numbered spec files 01-15, API catalog) | 13 |
After PR 15, merge the feature branch into `main` as one final PR.
## Risks and mitigations
| Risk | Severity | Mitigation |
| -------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Spec 2 (importer) depends on final schema; mid-development schema churn → rework | High | Schema freeze after PR 1 lands; amendments require deliberate spec update |
| Polymorphic owner columns have no DB-level FK — service-layer bug could insert inconsistent owner | Medium | Service-layer validation + integration test for every create/update path; runtime assertion in `buildEoiContext` |
| EOI dual-template drift (two engines produce subtly different output) | Medium | Golden-image visual-diff tests in Tier 4, CI-gated |
| Documenso template at `templateId=8` expects specific field names — new payload builder must match | Medium | One-time audit: document every field the existing template expects; map each to a source in new schema; Spec 2's importer uses same mapping |
| Old `client-portal/` sub-repo coordination during Spec 2 cutover | Low | Confirm old client-portal is decommissioned at Spec 2 cutover (not running concurrently against shared data) |
| Seeder becomes dev-onboarding bottleneck | Low | Seeder uses same factory helpers as tests — code path shared + tested |
| Documentation rot in numbered spec files | Low | PR 15 updates them before the feature branch merges to `main` |
| Exhaustive-click-suite runtime (15-20 min per PR) | Low | Separate CI job, runs in parallel with other tiers |
| Handoff quality — "EOIs don't work" / "I can't see my yachts" | Addressed | Dual template paths + exhaustive click coverage + golden-image diff + template regression tests collectively mitigate |
## Open questions / deferred items
Explicitly out of scope for this spec:
- Yacht survey / class-cert document categorization (requires taxonomy work)
- Multi-level company hierarchy (holding → subsidiary) — additive later
- Invoice line items referencing specific yacht
- Berth reservation auto-renewal flow
- Per-yacht row-level permissions (e.g., "broker can only see yachts they represent")
- Portal branding per company
## Success criteria
Spec 1 is complete when:
1. All PRs in the sequence are merged to `main`
2. CI is green: all coverage gates met, zero skipped tests, exhaustive click-through suite passes
3. Manual verification: developer walks through every multi-cardinality scenario in Tier 3 E2E list against a dev build
4. Both EOI paths produce documents that match the current system's outputs (visual verification + golden images committed)
5. Documentation (CLAUDE.md + numbered spec files) updated
6. Spec 2 (NocoDB+MinIO importer) can begin against a frozen schema

View File

@@ -0,0 +1,171 @@
# Country / Phone / Timezone — i18n form polish
**Status:** Agenda — awaiting prioritization (likely Phase B or B.5)
**Date:** 2026-04-28
**Phase:** Cross-cutting; touches every form that captures contact data
## Why
Today every CRM form takes free-text strings for nationality, phone, and timezone. That's fine for a marina with one operator typing it in once, but it leaks operator inconsistencies into reports and breaks any later system that consumes these fields (Documenso prefill, public website inquiry, portal sync, exports). For a multi-port platform that's about to onboard non-Polish-speaking residential clients, the data quality matters.
Three coupled UX upgrades:
1. **Nationality → ISO-3166 country dropdown.** Searchable. Stores ISO alpha-2 code (`'GB'`), displays localized country name.
2. **Phone → country-code dropdown + format-as-you-type.** E.164 storage on the wire, formatted display per country.
3. **Timezone → autofilled from country with override dropdown.** Most countries are single-zone; the few that aren't (US, RU, AU, BR, CA, ID, KZ, MN, MX, CD) get a sub-select. Stores IANA TZ string (`'Europe/Warsaw'`).
## Scope
### In scope
- New shared primitives: `<CountryCombobox>`, `<PhoneInput>`, `<TimezoneCombobox>`
- ISO-3166 country list bundled (no API call); names from `Intl.DisplayNames` with locale fallback to English
- Country → primary IANA timezone map (~250 entries, JSON)
- Phone parsing/validation/formatting via `libphonenumber-js` (server + client)
- Wire into every form that captures contact data:
- `<ClientForm>` (name, nationality, phone)
- `<ResidentialClientDetail>` inline editor (nationality, phone, place_of_residence — country-aware)
- `<CompanyForm>` (incorporation_country)
- `<PortalActivateForm>` (phone)
- public inquiry form (form-template renderer, when phone field present)
- DB migration: store ISO codes (`countries`, `nationality_iso`), E.164 phone (`phone_e164`), IANA timezone (`timezone`)
- Backfill: best-effort parse existing free-text into the new columns; keep originals as `_legacy` for one release cycle
- Display: localized country name in tables/detail pages; phone formatted per country (e.g. `+44 20 7946 0958`); timezone shown as friendly `'London (UTC+1)'` when current
- Tests: unit (parser edge cases), integration (form submit → E.164 storage), smoke (typing + selecting flows)
### Out of scope (deferred)
- Multilingual UI surface (only the country _names_ localize via `Intl.DisplayNames`; rest of the UI stays English for now)
- Subdivision picker (states/provinces) — only top-level country
- Phone number geocoding / carrier lookup
- Address autocomplete (Google Places, etc.)
- Currency localization
- RTL layout
## Library choices
| Concern | Library | Why |
| --------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Phone input + flag dropdown | `omeralpi/shadcn-phone-input` | Built on shadcn-ui's `Input` primitive (zero styling friction with our component library), wraps `libphonenumber-js`, ships with country dropdown + format-as-you-type. Small bundle. |
| Phone parsing/validation | `libphonenumber-js` | Google's library, ~88 benchmark, used by every popular React phone input. Server-side validation in zod. |
| Country list | Bundled JSON of ISO-3166 alpha-2 codes + 3-letter codes + display names (English baseline) | No need for the heavier `country-state-city` databases — we don't need cities or states yet. |
| Country → timezone | Hand-curated `country-timezones.json` (250 entries, ~10kb) sourced from `country-tz` or moment-timezone's data | Static, no network call. For multi-zone countries, expose a sub-select. |
| Timezone formatting | `Intl.DateTimeFormat` (built-in) | Browser API; renders `'Europe/Warsaw (UTC+1)'`-style labels. |
| Timezone list | `Intl.supportedValuesOf('timeZone')` (built-in, ~600 entries) | Used as the override dropdown when a user wants a non-primary zone. |
Bundle impact: `libphonenumber-js` mobile build is ~80 KB gz; `shadcn-phone-input` is ~5 KB; country/timezone JSONs ~30 KB. All client-side, lazy-loaded on first form render via `next/dynamic`.
## Schema deltas
```sql
-- clients
ALTER TABLE clients ADD COLUMN nationality_iso text; -- 'GB'
ALTER TABLE clients ADD COLUMN timezone text; -- 'Europe/London'
-- existing 'nationality' free-text column stays for a release; new code reads ISO
-- client_contacts (or wherever phone lives)
ALTER TABLE client_contacts ADD COLUMN value_e164 text; -- '+442079460958'
ALTER TABLE client_contacts ADD COLUMN value_country text; -- 'GB' (where the number was parsed against)
-- existing 'value' stays as the human-displayable formatted form
-- residential_clients — same pattern
ALTER TABLE residential_clients ADD COLUMN nationality_iso text;
ALTER TABLE residential_clients ADD COLUMN timezone text;
ALTER TABLE residential_clients ADD COLUMN phone_e164 text;
ALTER TABLE residential_clients ADD COLUMN phone_country text;
-- companies
ALTER TABLE companies ADD COLUMN incorporation_country_iso text;
```
Indexes: `idx_clients_nationality_iso`, `idx_clients_timezone` (cheap; powers analytics filters later).
## Component primitives
```tsx
<CountryCombobox
value={iso} // 'GB' | undefined
onChange={(iso) => }
locale="en" // for name lookup; default to navigator.language
variant="default" | "compact" // compact = icon-only flag, default = name
/>
<PhoneInput
value={e164} // '+442079460958'
onChange={({ e164, country }) => }
defaultCountry={'GB'} // pre-selects the dropdown
required={false}
/>
<TimezoneCombobox
value={iana} // 'Europe/London'
onChange={(iana) => }
countryHint={'GB'} // when set, narrows the dropdown to matching zones first
/>
```
All three are shadcn-styled, keyboard-accessible, support form integration with react-hook-form + zod.
## Validators
```ts
// src/lib/validators/contact.ts
import { isValidPhoneNumber } from 'libphonenumber-js';
export const phoneE164Schema = z
.string()
.refine((v) => isValidPhoneNumber(v), 'Invalid phone number');
export const isoCountrySchema = z
.string()
.length(2)
.toUpperCase()
.refine((c) => ISO_COUNTRIES.has(c), 'Unknown country');
export const ianaTimezoneSchema = z
.string()
.refine((tz) => Intl.supportedValuesOf('timeZone').includes(tz), 'Unknown timezone');
```
## Backfill plan
A migration script (`scripts/backfill-iso-and-e164.ts`) that:
1. For each client/residential_client, attempt `libphonenumber-js` `parsePhoneNumber(rawPhone, { defaultCountry: 'PL' })` → if valid, write `phone_e164` + `phone_country`.
2. For each free-text `nationality`, fuzzy-match against the country name list (exact match first, then Levenshtein ≤2). Write `nationality_iso` if confident.
3. For each timezone, exact-match against IANA list. Otherwise leave null and let user fill it.
4. Log unparseable rows to `backfill-iso-report.csv` for manual review.
Run on staging first; require dry-run flag.
## Build sequence
| # | PR | Effort | Depends on |
| --- | ------------------------------------------------------------ | ------ | ---------- |
| 1 | Country list JSON + ISO sets + `<CountryCombobox>` primitive | 0.5d | — |
| 2 | `libphonenumber-js` integration + `<PhoneInput>` primitive | 1d | — |
| 3 | Country → timezone JSON + `<TimezoneCombobox>` primitive | 0.5d | 1 |
| 4 | Schema deltas + drizzle migrations + zod validators | 0.5d | — |
| 5 | Wire into ClientForm + ClientDetail inline editors | 1d | 1, 2, 3, 4 |
| 6 | Wire into ResidentialClientDetail | 0.5d | 5 |
| 7 | Wire into CompanyForm | 0.5d | 1 |
| 8 | Public inquiry form template renderer support | 0.5d | 2 |
| 9 | Backfill script + dry-run runbook | 1d | 4 |
| 10 | Smoke + integration tests | 1d | 59 |
Total: ~7 dev days. Self-contained; no external dependencies on Phase B (analytics/alerts).
## Risk register
| Risk | Mitigation |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Bundle bloat from libphonenumber data | Use the `mobile` metadata build, lazy-import via `next/dynamic` |
| Existing free-text data is too messy to backfill | Keep the legacy column for one release; expose a "needs review" badge in admin |
| Multi-zone country UX confusion | Sub-select only appears when country is multi-zone; otherwise zone is hidden behind "Override" |
| Public inquiry form breaks if phone is required and user can't find their country | Default to PL, search by country name and dial code |
## Open questions for the user
- Which port's locale should drive the _default_ country in `<PhoneInput>` (Poland for now, or detect from browser)?
- Should existing free-text `nationality` field be removed once backfilled, or kept indefinitely as a fallback?
- Is there an appetite for adding the same treatment to subdivision (state/region/voivodship) selectors, or strictly country-level for now?

View File

@@ -0,0 +1,775 @@
# Documents Hub, Reservation Agreements, and Visual Polish (Phase A)
**Status:** Draft — awaiting final review
**Date:** 2026-04-28
**Phase:** A of D (B = Insights & Alerts; C = Website integration; D = Pre-prod ops)
## Overview
Phase A delivers a unified Documents Hub that tracks every signature-based document (EOI, Reservation Agreements, NDAs, ad-hoc uploads), generalises the existing single-purpose EOI dialog into a multi-format create-document wizard, builds the missing CRM-side reservation detail page with an end-to-end agreement workflow, polishes the reminder framework so non-EOI docs auto-remind correctly, and applies a system-wide visual upgrade to the polished-SaaS aesthetic the project already has tokens for.
The project already ships a usable CRM with auth, multi-tenancy, full client/yacht/company/interest/berth/reservation data model, an EOI dual-path (Documenso template + in-app PDF), socket-driven real-time updates, and 130 smoke specs. What's missing for the next release: a single place to see what documents need signing and chase the people who haven't signed.
## Scope boundaries
### In scope (this spec)
- New `/[port]/documents` hub page replacing the existing list
- New `/[port]/documents/[id]` document detail page
- Generalised create-document wizard supporting four template formats (HTML, PDF AcroForm fillable, PDF overlay-positioned, Documenso-rendered) plus ad-hoc PDF upload
- New `/[port]/berth-reservations/[id]` reservation detail page with agreement-generation flow
- Reservation Agreement as a first-class document type with default template seeded
- Email composer extended with attachments and a System-vs-User From selector (admin-gated)
- Reminder framework: per-template cadence, per-doc override, per-doc disable, per-signer manual reminders
- Documenso version-aware abstraction layer covering field placement and document voiding across v1.13.1 and v2.x
- System-wide visual polish: shadow scale, gradient layer, animation tokens, primitive components (`<StatusPill>`, `<KPITile>`, `<EmptyState>`, polished `<PageHeader>`), applied across all list and detail pages
- Mobile-responsive sweep across every page touched
- Comprehensive test coverage: unit, integration, smoke, exhaustive click-through, real-API round-trips, visual baseline regeneration
### Explicitly out of scope (deferred to later phases)
- Analytics dashboard, alert framework, interests-by-berth view, expense duplicate detection (Phase B)
- Website-side integration: `/api/form/[token]/data` prefill endpoint, `/api/webhook/document-signed` callback receiver, public-endpoint shape compat (Phase C)
- NocoDB to Postgres data migration, email deliverability (DKIM/SPF/DMARC), Sentry error reporting, audit log retention, performance baseline at 5k clients / 50k interests, backup/restore automation, production deploy readiness (Phase D)
- Native in-CRM PDF field-placement editor (deferred until upload-path pain emerges; Phase A v1 ships with auto-placed footer signature fields and a "Customize fields in Documenso" link)
- Word `.docx` template upload (deferred; PDF prioritized because Word adds LibreOffice/CloudConvert toolchain dependency without saving the field-placement step)
- Per-interest "silence all reminders" toggle (was implicit in old `interests.reminderEnabled` gating which this spec drops; can be re-added as a bulk action if anyone misses it)
## Information architecture
### URL surface
```
/[port]/documents hub (replaces existing list)
/[port]/documents/[id] document detail (new)
/[port]/documents/new create-document wizard (new)
/[port]/berth-reservations/[id] reservation detail (new)
/[port]/admin/templates existing; extended for new template formats
/[port]/admin/email existing; one new toggle
```
### Schema deltas
```
documents — additions:
+ reservation_id text null references berth_reservations(id)
+ reminders_disabled boolean default false
+ reminder_cadence_override int null
document_templates — additions:
+ reminder_cadence_days int null (null = no auto-reminders)
+ template_format text default 'html' ('html'|'pdf_form'|'pdf_overlay'|'documenso_render')
+ source_file_id text null references files(id)
+ documenso_template_id text null
+ field_mapping jsonb default '{}' (pdf_form: { acroFieldName: mergeToken })
+ overlay_positions jsonb default '[]' (pdf_overlay: [{token, page, x, y, fontSize}])
document_templates.body_html — relax to nullable (only required when template_format='html')
document_watchers — new table:
document_id text not null references documents(id) on delete cascade
user_id text not null references users(id)
added_by text not null references users(id)
added_at timestamptz default now()
primary key (document_id, user_id)
documents indexes — additions:
+ idx_docs_reservation on (reservation_id)
+ idx_docs_status_port on (port_id, status) — powers tab counts cheaply
document_watchers indexes:
+ idx_doc_watchers_doc on (document_id)
+ idx_doc_watchers_user on (user_id)
documents.documentType enum — already includes 'reservation_agreement'; no migration needed
documents.status enum — already accepts 'expired'; no migration needed
documentSigners.status enum — pending|signed|declined; no migration needed
```
Backfill (one statement, safe to run in same migration):
```sql
UPDATE document_templates SET reminder_cadence_days = 1 WHERE template_type = 'eoi';
```
This preserves the existing 1-day-effective reminder cadence for existing EOI templates. Admins can edit per-template later.
After running migration on a dev/staging server, restart `next dev` to flush postgres.js prepared-statement cache (existing project convention).
### Polymorphic ownership pattern
Documents already use the multi-FK pattern (`interest_id`, `client_id`, `yacht_id`, `company_id` as separate nullable columns). Adding `reservation_id` matches this. No conversion to polymorphic discriminator columns despite yachts and invoices using that pattern; staying consistent with the existing documents shape avoids a destructive migration.
### Service-layer changes
- `documents.service.ts`:
- `createFromWizard(portId, data, meta)` — dispatches across template/upload paths
- `createFromUpload(portId, data, meta)` — new upload-driven path; calls Documenso `createDocument`, stores file in MinIO via `files` service, mirrors to `documents` + `documentSigners`, optionally calls `sendDocument` if `sendImmediately`
- `cancelDocument(documentId, portId, meta)` — user-initiated cancel; calls Documenso void, updates DB status, logs event
- `composeSignedDocEmail(documentId, portId)` — returns prefilled `{ to, cc, subject, body, attachments, defaultSenderType }` for the composer
- `getDocumentDetail(id, portId)` — single-roundtrip aggregator returning doc + signers + events + watchers + linked-entity summary
- `document-templates.ts`:
- `generateAndSign` extended for new `template_format` values
- `fillAcroForm(sourceFile, fieldMapping, mergeContext)` — pdf-lib AcroForm fill
- `drawOverlay(sourceFile, overlayPositions, mergeContext)` — pdf-lib text-draw at positions
- Documenso-render path uses existing `generateDocumentFromTemplate`
- `documenso-client.ts`:
- `placeFields(docId, fields, portId?)` — version-aware bulk field placement
- `placeDefaultSignatureFields(docId, recipientIds, portId?)` — auto-position one SIGNATURE per recipient at footer
- `voidDocument(docId, portId?)` — version-aware doc void/delete
- Coordinate normalization helpers (caller passes percent 0-100; converted to pixels for v1 using cached page dimensions)
- `document-reminders.ts`:
- `sendReminderIfAllowed(documentId, portId, options?)` — extended signature with optional `signerId` and `auto: boolean`
- `processReminderQueue(portId)` — query rewritten around `documents.reminder_cadence_override ?? template.reminder_cadence_days`; drops `interests.reminderEnabled` gating
- `notifications.service.ts`:
- `notifyDocumentEvent(docId, eventType)` — fans out to creator + entity-assignee + watchers; existing socket events keep firing
- New: `reservation-agreement-context.ts`:
- `buildReservationAgreementContext(reservationId, portId)` — joins reservation -> client + yacht + berth -> port; returns context shape for template merge
- `email-compose.service.ts`:
- Validator extended: `{ senderType: 'system'|'user', accountId? (when user), attachments[] }`
- System path: calls `lib/email/index.ts → sendEmail()` with `portId` + attachments; logs `documentEvents` row `signed_doc_emailed`; skips `email_messages`/`email_threads` writes
- User path: existing flow, with attachments resolution from `files` table
- Port-isolation: cross-port `fileId` returns 403
- `lib/email/index.ts`:
- `SendEmailOptions.attachments?: Array<{ fileId, filename? }>` — fetches files from MinIO, passes to nodemailer
## Documents hub page
Replaces existing `/[port]/documents` list.
### Layout
```
[ Header strip: title, KPI sub-line, "+ New document" button ]
[ Tabs: All | Awaiting them (count) | Awaiting me (count) | Completed | Expired ]
[ Search · Type · Status · Sent · Watcher filter chips · saved-view selector · overflow ]
[ Table:
checkbox | Document | Type pill | Subject pill | Status (X/Y signed + dot) | Sent
▾ expand row inline to show signers + watchers strip
]
[ Sticky bulk-action bar appears when ≥1 row checked:
"N selected" | Remind unsigned | Cancel | Export | pagination
]
```
### Tab queries
- All — every document in port
- Awaiting them — `status IN ('sent','partially_signed')` AND has pending signer != current user
- Awaiting me — at least one `documentSigners` row matching `signer_email = current user email` AND `status = 'pending'`
- Completed — `status IN ('completed','signed')`
- Expired — `status = 'expired'` OR (`status IN ('sent','partially_signed')` AND `expires_at < now()`)
Counts run cheap thanks to `idx_docs_status_port`.
### Filters and saved views
- Search: fuzzy match on title, subject name, signer email
- Type: multi-select doc types
- Status: multi-select status enum
- Sent: date-range chips (Today, 7d, 30d, custom)
- Watcher: filter by watching user
- "Signature-based only" chip defaults to ON; toggle off to see non-signed docs (welcome letters etc.) as well, rendered with a "Delivered" pill
- Saved-view integration: filter combos save to existing `saved_views` table
### Row anatomy
- Collapsed: name (links to detail), type pill (colored per type), subject pill (links to entity), status indicator (X/Y signed with progress dot), sent age
- Expanded: per-signer rows with email, status pill, sent timestamp, signed timestamp, `[Remind]` and overflow `[...]` (resend invite, copy signing link, skip — skip is UI-only flag, not implemented in v1)
- Watchers strip at bottom of expansion: chips + `+ Add watcher` autocomplete
- Hover: row gets soft brand-soft gradient bg
### Real-time
Subscribes to existing `documents.service.ts`-emitted socket events: `document:created`, `document:updated`, `document:deleted`, `document:sent`, `document:completed`, `document:expired`, `document:cancelled`, `document:rejected`, `document:signer:signed`, `document:signer:opened`. All already fire today.
### Empty states
- No docs yet: illustration + 1-line explanation + `[+ New document]` CTA
- Filtered empty: "No docs match these filters. Clear filters?"
### Mobile (< 768px)
- Tabs collapse into `<select>`
- Filters collapse behind `[Filters]` button into a sheet
- Rows stack as cards: title + status + age, expand to show signers
- "+ New document" floats as FAB bottom-right
## Document detail page
New `/[port]/documents/[id]` page. No detail page exists today.
### Layout
```
[ Breadcrumb: All documents ]
[ Header strip with gradient: title (editable inline), type pill, status pill, subtitle (subject link, creator, age) ]
[ Action bar — context-aware ]
[ Two-column body:
Left (2fr):
Signers panel (vertical list, replaces existing horizontal SigningProgress)
Linked entity card
Right (1fr):
Watchers panel (chips + add)
Activity timeline (from documentEvents)
Notes (auto-saving editable text)
Preview (PDF; tabbed Original/Signed when completed)
]
```
### Action bar by status
- `draft``[Send for signing]` `[Edit signers]` `[Delete]`
- `sent | partially_signed``[Send reminder to all]` `[Resend invite]` `[Cancel]`
- `completed``[Download signed PDF]` `[Email signed PDF to all signatories]`
- `cancelled | rejected | expired``[Duplicate]`
- Always `[...]` overflow: Duplicate, Move to other entity, View Documenso URL, Audit log
### Signers panel (vertical, replaces horizontal stepper)
Per-row:
- Numbered status circle (pending grey, signed green, declined red)
- Name, email, role
- Sent age, last-reminded age, signed timestamp
- `[Remind]` button — disabled with countdown if cooldown active (24h-or-cadence) for auto mode; bypassed in manual mode
- `[Copy signing link]` — copies `signingUrl` (hosted Documenso); overflow offers "Copy embed link" if `embeddedUrl` present (used by website embed at `/sign/[type]/[token]`)
- `[...]` overflow: Resend invite, View signing history, Replace email (draft only)
- Sequential mode: only current pending signer's `[Remind]` active; others greyed with tooltip
### Send-signed-PDF email flow
Action visible only when `status='completed' AND signedFileId IS NOT NULL`.
Click opens email composer drawer prefilled:
- From: dropdown defaulting to System (port-config noreply identity); Personal accounts available only when port admin enables `email.allowPersonalAccountSends`
- To: union of `documentSigners.signerEmail` for the doc
- Cc: empty; "Cc watchers" toggle adds users from `document_watchers`
- Subject: `"Signed {document type} — {document title}"`
- Body: from `signed_doc_completion` per-port template (new template type; default seeded for new ports)
- Attachments: signed PDF auto-attached from `documents.signedFileId` (chip with filename + size; removable)
Send dispatch:
- System path: `lib/email/index.ts → sendEmail()` with portId + attachments; writes `documentEvents` row; skips email_messages/threads writes (no IMAP sync expected)
- User path: `email-compose.service.ts` existing flow; writes email_messages + thread; subject to `allowPersonalAccountSends` gate (server-side enforces 403 on user senderType when toggle off)
### Backend additions
- `POST /api/v1/documents/[id]/cancel` — calls `cancelDocument` service; service calls Documenso void via new client function
- `POST /api/v1/documents/[id]/remind` — accepts optional `{ signerId }`; passes `auto: false` to service
- `GET /api/v1/documents/[id]/watchers` — list
- `POST /api/v1/documents/[id]/watchers` — add `{ userId }`
- `DELETE /api/v1/documents/[id]/watchers/[userId]` — remove
- `POST /api/v1/documents/[id]/compose-completion-email` — returns prefilled draft
## Create-document wizard
Replaces `<EoiGenerateDialog>`. Single drawer/dialog, three steps.
### Step 1 — Type and source
```
Render: ● Generate the PDF here (using template format below)
○ Use a Documenso-stored template (Documenso renders + signs)
Format (when "Generate the PDF here" selected):
● HTML (write inline)
○ PDF (AcroForm fillable upload)
○ PDF (overlay positioning)
Template: [ pick from port's templates of selected format ]
OR
Upload PDF: [ drop or pick file; preview renders inline ]
Document type: [ auto-derived from template, or picked from DOCUMENT_TYPES enum ]
```
Signing destination is always Documenso. The "Render in CRM" vs "Render in Documenso" axis is about PDF generation only.
### Step 2 — Recipients
```
Attached to: [ Interest #142 — Smith family Change ]
↑ pre-filled if launched from a detail page
Signers: (hidden for documenso-render path; signers embedded in template)
① name email role [✕]
② name email role [✕]
[+ Add signer] (autocomplete from clients/companies/users; or manual entry)
Drag to reorder; signing-order assigned by row position
Signing mode: ● Sequential ○ Parallel
Watchers (optional): [chips] [+ Add watcher] (CRM users)
Reminder cadence:
● Use template default (every 7 days)
○ Override: [_____] days
○ Disable for this document
[ For upload path only ]
☑ Auto-place signature fields at footer (default; refine later in Documenso)
```
### Step 3 — Review and send
```
Title: [ EOI — Smith family ____________ ] (editable; default rendered from merge tokens)
Notes (internal): [_____________]
Preview: [ rendered PDF inline · 4 pages · scrollable ]
Signing-order banner (multi-signer in-app/upload only): "Sequential — Carol must sign before Bob" [Switch to parallel]
[← Back] [Save as draft] [Send →]
```
Save as draft → status='draft'; `[Send for signing]` available later from detail page. Send → calls Documenso, status='sent', socket event fires.
### Documenso version-aware field placement
For upload path, `placeDefaultSignatureFields` auto-positions one SIGNATURE per recipient at last-page footer (staggered to avoid overlap). User can refine in Documenso via "Customize fields in Documenso" link on detail page.
`placeFields` and `placeDefaultSignatureFields` in `documenso-client.ts` hide v1/v2 differences:
- v1: `POST /api/v1/documents/{id}/fields` per field; pixel coordinates; requires page dimension lookup
- v2: `POST /api/v2/envelope/field/create-many` bulk; percentage 0-100 coordinates; rich `fieldMeta`
- Caller passes percentage; abstraction converts for v1 using cached page dimensions
### `createDocumentSchema` extension
```ts
export const createDocumentSchema = z.object({
source: z.enum(['template', 'upload']),
templateId: z.string().uuid().optional(),
uploadedFileId: z.string().uuid().optional(),
documentType: z.enum(DOCUMENT_TYPES),
title: z.string().min(1).max(200),
notes: z.string().optional(),
// Subject (exactly one required)
interestId: z.string().uuid().optional(),
reservationId: z.string().uuid().optional(),
clientId: z.string().uuid().optional(),
companyId: z.string().uuid().optional(),
yachtId: z.string().uuid().optional(),
// Signers (required when render=in-app or source=upload)
signers: z.array(z.object({
signerName: z.string().min(1),
signerEmail: z.string().email(),
signerRole: z.enum(['client', 'sales', 'approver', 'developer', 'other']),
signingOrder: z.number().int().min(1),
})).optional(),
signingMode: z.enum(['sequential', 'parallel']).default('sequential'),
pathway: z.enum(['documenso-template', 'inapp', 'upload']).optional(),
watchers: z.array(z.string().uuid()).optional(),
reminderCadenceOverride: z.number().int().min(1).max(365).nullable().optional(),
remindersDisabled: z.boolean().default(false),
autoPlaceFields: z.boolean().default(true),
sendImmediately: z.boolean().default(true),
}).refine(...one-subject-FK-required...);
```
## Template formats
### Authoring paths
| Format | Authoring | Merge fields | Best for |
| ---------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------ |
| HTML (existing) | Inline rich-text editor with merge tokens | Server-side substitution, rendered to PDF via pdfme | Welcome letters, acknowledgments, correspondence |
| PDF (AcroForm fillable) | Admin uploads fillable PDF; UI scans AcroForm field names; admin maps each to a merge token | pdf-lib fills form at gen time | EOI, Reservation Agreement, NDA |
| PDF (overlay positioning) | Admin uploads any PDF; UI specifies merge token positions per page+x+y+fontSize | pdf-lib draws text over PDF at positions | Quick wins where preparing AcroForm is overkill |
| Documenso template reference | Admin enters Documenso template ID + label | None in CRM; Documenso owns it | Documenso-rendered signing flows |
### Generator dispatch
```ts
switch (template.template_format) {
case 'html': generatePdf(template.body_html, mergeContext);
case 'pdf_form': fillAcroForm(template.source_file_id, template.field_mapping, mergeContext);
case 'pdf_overlay': drawOverlay(template.source_file_id, template.overlay_positions, mergeContext);
case 'documenso_render': documenso.generateDocumentFromTemplate(template.documenso_template_id, ...);
}
```
All four formats end at Documenso for signing — only PDF generation location differs. Non-signature templates (welcome letters etc.) skip the upload-to-Documenso step entirely; they render to PDF then get emailed.
### Admin template editor extension
Format picker added to `/admin/templates` editor:
- For PDF (AcroForm): file upload field, then two-column mapping UI (AcroForm field names ↔ merge tokens autocomplete from existing `MERGE_FIELDS` catalog)
- For PDF (overlay): file upload, then per-token form with page/x/y/fontSize inputs (visual placement editor deferred)
- For Documenso template: single text input + Test connection button calling `getDocumensoTemplate`
- For HTML: existing inline editor unchanged
### Word (.docx) deferred
Reasons: LibreOffice headless adds significant install/memory/security surface; CloudConvert adds paid dependency and third-party data exposure; `docxtemplater` merge syntax incompatible with existing `{{token}}` convention; field placement still needs PDF flow afterwards. If marinas push back, the feasible path is `.docx → server-side conversion → PDF → existing AcroForm/overlay flow`. Not worth the engineering until requested.
## Reservation agreements as a doc type
### What differs from EOI's pattern
| Aspect | EOI | Reservation Agreement |
| --------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ |
| Subject FK | `interestId` | `reservationId` |
| Default template | Documenso EOI per port | Documenso reservation_agreement per port (seeded) |
| Default signers | client + sales/approver | client + port admin |
| Trigger | Manual on interest detail | Manual on reservation detail |
| Lifecycle integration | None | Active reservations without an agreement get flagged in dashboard alert |
| Final-PDF storage | `documents.signedFileId` only | `documents.signedFileId` AND mirrored to `berth_reservations.contractFileId` on completion |
### New CRM-side reservation detail page
`/[port]/berth-reservations/[id]` doesn't exist today (only the portal's `/portal/my-reservations`). Phase A builds it.
Layout:
```
[ Header: "Reservation #88 · M/Y Tate" status pill subtitle: berth, client, dates, tenure ]
[ Action bar: Activate | Generate agreement | Cancel | ... ]
[ Two columns:
Left: Reservation details card
Linked interest card
Activity timeline
Right: Agreement card (state-dependent: no agreement / in-flight / completed)
]
```
Agreement card states:
- No agreement yet: warning + `[Generate agreement →]`
- In-flight (sent/partially_signed): "X/Y signed", per-signer status, `[View document →]` `[Send reminder]` `[Cancel]`
- Completed: "Completed YYYY-MM-DD", `[Download signed PDF]` `[Email to all signatories]`, "Signed contract attached to reservation."
Generate-agreement button launches the wizard with prefills:
- `documentType='reservation_agreement'`
- `templateId=<port's default>`
- `reservationId=<current>`
- Default signers from linked client + configurable port-admin user
- Wizard step 1 pre-validated; user lands on step 2
### Backend additions
- Merge field catalog extended in `src/lib/templates/merge-fields.ts`:
- `{{reservation.startDate}}` `{{reservation.endDate}}` `{{reservation.tenureType}}` `{{reservation.termSummary}}` `{{reservation.signedDate}}`
- New service `reservation-agreement-context.ts.buildReservationAgreementContext(reservationId, portId)`
- New seeder for default `reservation_agreement` template on port creation (HTML format; admins can switch to AcroForm/overlay later); template stored at `assets/templates/reservation-agreement-default.html`
- Webhook handler extension: `handleDocumentCompleted` detects `documentType='reservation_agreement'` and sets `berth_reservations.contractFileId = doc.signedFileId` for the linked reservation
- Dashboard alert query: active reservations without a completed agreement (LEFT JOIN against documents filtered on type+status); rows surface as a warning card
### Trade-off
`berth_reservations.contractFileId` becomes a denormalized convenience pointer duplicated with `documents.signedFileId` for the linked reservation. Updating it on completion costs one extra UPDATE. Benefit: anyone querying reservations directly (portal "My Reservations") doesn't need to join through documents to know which file is the contract.
## Reminder framework polish
### Problems with today's logic
1. Eligibility gated by `interests.reminderEnabled` — reservation agreements, NDAs, ad-hoc upload docs (no interest link) never auto-remind
2. Hardcoded 24h cooldown — effective cadence is 1 day; can't slow down for low-urgency docs
3. Always reminds lowest-pending signer — parallel-signing docs can't nudge a specific signer
4. No per-doc disable
### New eligibility logic
```
function isReminderDue(doc, template, lastReminderAt) {
if (!['sent','partially_signed'].includes(doc.status)) return false;
if (doc.documenso_id == null) return false;
if (doc.reminders_disabled) return false;
const effectiveCadence = doc.reminder_cadence_override ?? template.reminder_cadence_days;
if (effectiveCadence === null) return false;
if (lastReminderAt == null) return true;
return (now - lastReminderAt) >= effectiveCadence * 24h;
}
```
`processReminderQueue` query rewritten:
```sql
SELECT d.* FROM documents d
LEFT JOIN document_templates t ON t.id = d.template_id
WHERE d.port_id = $1
AND d.status IN ('sent','partially_signed')
AND d.documenso_id IS NOT NULL
AND d.reminders_disabled = false
AND COALESCE(d.reminder_cadence_override, t.reminder_cadence_days) IS NOT NULL;
```
`interests.reminderEnabled` is dropped from the gating logic but the column stays for now (no migration). Future cleanup PR can drop the column.
### `sendReminderIfAllowed` extended signature
```ts
export async function sendReminderIfAllowed(
documentId: string,
portId: string,
options: {
auto?: boolean; // true = cron; false (default) = manual
signerId?: string; // optional — target a specific pending signer
} = {},
): Promise<{ sent: boolean; reason?: string; signerId?: string }>;
```
Behaviour matrix:
| Mode | 9-16 window | Cadence cooldown | Manual cooldown |
| ----------- | ----------- | ---------------- | ------------------------ |
| auto: true | enforced | enforced | n/a |
| auto: false | bypassed | bypassed | 30s client-side debounce |
Per-signer logic:
- If `signerId` provided in sequential-mode doc, signer must be the lowest-pending signer (otherwise reason='Signer is not next in sequence')
- In parallel-mode doc, any pending signer can be reminded independently
- Returns `{ sent, reason }` so caller can show toast on skip
### Admin and per-doc UI
Admin `/admin/templates` editor:
```
Auto-reminders for this template:
☑ Enabled Cadence: every [_____] days (1-365; default 7)
☐ Disabled (manual reminders only)
```
Doc detail page (Section 3) "Reminders" panel under signers, with edit drawer for per-doc override.
## Visual polish system
### Token additions
```
--radius-sm: 0.375rem (existing)
--radius-md: 0.5rem (NEW — default cards)
--radius-lg: 0.625rem (NEW — sheets, dialogs)
--radius-xl: 0.875rem (NEW — KPI tiles, hero strips)
--shadow-xs: 0 1px 2px 0 rgb(15 23 42 / 0.04)
--shadow-sm: 0 2px 4px -1px rgb(15 23 42 / 0.06)
--shadow-md: 0 4px 12px -2px rgb(15 23 42 / 0.08)
--shadow-lg: 0 12px 32px -8px rgb(15 23 42 / 0.12)
--shadow-glow: 0 0 0 4px rgb(58 123 200 / 0.12)
--gradient-brand: linear-gradient(135deg, #3a7bc8 0%, #2f6ab5 100%)
--gradient-brand-soft: linear-gradient(135deg, #d8e5f4 0%, #ffffff 100%)
--gradient-success: linear-gradient(135deg, #e8f5e9 0%, #ffffff 100%)
--gradient-warning: linear-gradient(135deg, #fef3c7 0%, #ffffff 100%)
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1)
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1)
--duration-fast: 150ms
--duration-base: 200ms
--duration-slow: 300ms
```
All exposed as Tailwind utilities.
### Existing token foundation (already in place; not changing)
- Full HSL shadcn token system (primary, secondary, muted, accent, destructive, border, input, ring, popover, card)
- Brand palette `brand` (50-700, default `#3a7bc8`)
- Navy palette `navy` (50-600, default `#1e2844` for sidebar)
- Maritime accents: `sage`, `mint`, `teal`, `purple` with light/default/dark variants
- Semantic `success` / `warning` with bg+border
- Recharts chart-1 through chart-6 token system
- Dark mode wired
- Sidebar tokens separate from main palette
### New primitive components
- `<StatusPill status="...">` — colored-by-state pill (pending grey, sent brand, partial teal, completed success, expired warning, rejected destructive, cancelled muted-darker, active success, archived muted)
- `<KPITile title value delta sparkline?>` — rounded-xl, shadow-sm, gradient-brand-soft border-top accent stripe; recharts mini sparkline using `--chart-1`
- `<EmptyState icon title body actions>` — large icon in brand-soft circle, title, body, action buttons
- `<PageHeader>` polished — gradient-brand-soft background, eyebrow optional, KPI sub-line, primary action right-aligned
### Component pattern updates
- List rows: hover gradient (subtle brand-soft 4% opacity), shadow-xs lift, animation `transition-all duration-base ease-smooth`; row-update from socket events animates 1s fade-in highlight
- Detail pages: two-column responsive grammar (header strip → 2fr main + 1fr side; cards stack vertical < 768px)
- Sidebar (already dark navy): active item gets 4px brand left-edge stripe instead of bg shift; section headers smaller-caps + brand-200 text
- Topbar: search inset shadow + brand focus ring; "+ New" trigger gets `bg-gradient-brand`; notification bell gets badge spring animation; user avatar gets shadow-sm + 2px white ring
- Forms: focus ring uses `--shadow-glow`; primary submit buttons get `bg-gradient-brand` with hover scale-1.01; inline validation gets destructive-bg pill with caret pointing up
### Loading skeleton system
- List pages: 8 skeleton rows matching column widths with subtle pulse
- Detail pages: header strip skeleton + 2-column section skeletons
- Dashboard: KPI tile skeletons + chart skeletons
- Replaces today's mix of "Loading..." text and spinners
### Mobile responsive (full sweep)
Breakpoints:
- < 640px (phone): single column, sticky bottom action bar, sheet overlays for filters
- 640-1024px (tablet): single column with wider gutters, side column under main
- ≥ 1024px (desktop): full two-column
Per-page rules:
- List tables → card stack < 768px
- Detail page header collapses subtitle to "Show more"
- Tabs collapse to `<select>` < 640px
- Sidebar slides over content < 1024px
- Primary "+ New" actions float as FAB bottom-right < 640px
## Test plan
### Unit (`tests/unit/`)
- `document-reminders-cadence.test.ts``isReminderDue` math; manual-vs-auto window/cooldown bypass
- `documenso-place-fields.test.ts` — v1/v2 dispatch (mocked HTTP); coord normalization; default field staggering for 1/2/3/5 recipients
- `email-attachments-resolver.test.ts` — fileId → MinIO buffer; cross-port 403; 10 MB cap warning
### Integration (`tests/integration/`)
- Extend `document-templates-generate-and-sign.test.ts` — new template formats (`pdf_form`, `pdf_overlay`, `documenso_render`); upload-path test
- New `document-watchers.test.ts` — add/remove endpoints; notification fan-out; port isolation
- New `document-cancel.test.ts` — user-initiated cancel; mocked Documenso void; status + event log; reject 409 if completed
- New `reservation-agreement-contract-mirror.test.ts``handleDocumentCompleted` mirrors `signedFileId` to `berth_reservations.contractFileId` only for `reservation_agreement` type
- New `reminder-cron-cadence.test.ts` — seed varied templates; simulated time advance; assert correct docs reminded
### E2E smoke (`tests/e2e/smoke/`)
- Extend `04-documents.spec.ts` — hub tabs, expand row, per-signer remind with cooldown, type/status filters, saved-view round-trip, bulk-remind with per-row toast reasons
- Extend `05-eoi-generate.spec.ts` — wizard invocation prefills (template, interest); existing flow regression
- New `27-document-create-wizard.spec.ts` — template path full flow; upload path full flow; watcher addition; reminder-override radios produce correct DB state
- New `28-reservation-agreements.spec.ts` — reservation detail → Generate agreement → wizard prefilled → Send → agreement section state transitions; post-completion contract attached + email button visible
- New `29-email-attachments.spec.ts` — system path send (documentEvents row, no email_messages); user path send when toggle on (email_messages with attachment_file_ids); cross-port 403
### E2E exhaustive (`tests/e2e/exhaustive/`) — click-everything sweep
- New `10-documents-hub.spec.ts` — crawl each tab, filter dropdowns, saved-view, expand row, signer-row buttons, bulk-action bar
- New `11-document-detail.spec.ts` — crawl in three states (draft/sent/completed); watcher add/remove; notes auto-save; preview download; "Email signed PDF" launch
- New `12-document-create-wizard.spec.ts` — crawl each wizard step under both template and upload paths; picker dropdowns, signer add/remove, drag-handle, reminder-cadence radios
- New `13-reservation-detail.spec.ts` — crawl in three states (pending no agreement / agreement-in-flight / agreement-completed); Activate/Cancel/Generate buttons; inline notes
- New `14-email-composer.spec.ts` — crawl composer drawer with attachments; From dropdown; attach button; recipient chips
- Extend exhaustive `05-eoi-generate.spec.ts` — parallel-mode + signing-order edge cases (greyed-out reminder buttons; out-of-order remind rejection)
### E2E real-API (`tests/e2e/realapi/`)
Each spec gates on env vars; clean skip if missing.
- Extend `documenso-real-api.spec.ts`:
- Generate from Documenso template (real send) and assert in real Documenso
- Generate from in-app PDF AcroForm fill, upload to real Documenso, assert
- Generate from upload path with auto-placed signature fields, assert fields visible in Documenso
- v1 and v2 explicit version-flag tests (via `DOCUMENSO_API_VERSION`)
- Manually sign in real Documenso (or simulate webhook) and assert local DB updates
- Cancel real in-flight doc, assert local + remote state
- Send reminder via real Documenso, assert HTTP + documentEvents row
- New `smtp-system-send.spec.ts` — system-path send → IMAP fetch → assert subject + attachment; verify port-config from-identity; cleanup via IMAP delete
- New `smtp-user-send.spec.ts` — user-path send (requires connected account, allowPersonalAccountSends=true) → IMAP fetch → email_messages row with attachment_file_ids
- New `minio-file-lifecycle.spec.ts` — upload, list, preview, download (byte-equal), delete; port isolation; mime-type validation
- New `documenso-webhook-ingress.spec.ts` — requires cloudflared tunnel; configure tunnel URL as Documenso webhook target; trigger doc completion; assert webhook fires + handler updates DB; verify timing-safe secret check rejects wrong secret with 401; verify event normalisation (uppercase enum + lowercase-dotted both accepted)
- New `email-attachments-roundtrip.spec.ts` — compose with fileId attachment; SMTP send; IMAP fetch; assert attachment bytes match; reject cross-port fileId with 403 before SMTP touched
### Visual baselines (`tests/e2e/visual/`)
`snapshots.spec.ts-snapshots/` regenerated as polish ships per page; one PR per surface group, baselines reviewed in PR diff. New baselines added: documents hub, doc detail, create-document wizard (each step), reservation detail, email composer with attachments.
### Test data fixtures
`global-setup.ts` extended with:
- Seed default `reservation_agreement` template (HTML format)
- Seed default `signed_doc_completion` template
- Seed one in-flight EOI doc with two pending signers (for hub-tab tests)
- Seed one `berth_reservation` with `status='active'` and no agreement (for lifecycle alert query)
### CI vs local runs
| Project | When |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `setup` + `smoke` (~14 min) | Every PR via CI |
| `exhaustive` (with new click-everything specs) | Every PR via CI; ~25 min budget |
| `visual` | Every PR; baselines reviewed in PR diffs |
| `realapi` | Locally before merging touch-points; pre-release; not on CI (avoids burning Documenso quota and SMTP costs) |
## Build sequence
| # | Title | Effort | Depends on |
| ----- | ------------------------------------------------- | ------ | -------------- |
| 1 | Data model + service skeletons | 1d | — |
| 2 | Documenso v1/v2 abstraction layer | 1d | — |
| 3 | Visual primitives + token additions | 1.5d | — |
| 4 | Documents hub page | 2d | 1, 3 |
| 5 | Document detail page | 2d | 1, 3 |
| 6 | Create-document wizard + new template formats | 2.5d | 1, 2, 3 |
| 7 | Reservation detail + agreement flow | 1.5d | 1, 6 |
| 8 | Email composer attachments + From selector | 1d | 1, 3 |
| 9 | Reminder framework polish | 1d | 1 |
| 10a-e | Visual polish sweep (5 PRs across surface groups) | 3-4d | 3 |
| 11 | Real-API integration tests | 1.5d | 2, 4-9 shipped |
### Critical path
```
1 → 2 → 6 → 7 (data model → Documenso → wizard → reservation)
1 → 3 → 4 → 5 → 9 (data model → primitives → hub → detail → reminders)
1 → 8 (composer)
3 → 10a-e (sweep)
all → 11 (realapi)
```
Wall-clock minimum ~9 days; realistic with overhead ~17 days; calendar ~3.5-5 weeks.
### Acceptance gates per PR
- `pnpm tsc --noEmit` and `pnpm lint` clean
- Vitest unit + integration green
- Playwright smoke green for surface touched
- Visual baselines regenerated and reviewed in PR diff
- For PRs touching external integrations (2, 6 upload, 7 contract mirror, 8 SMTP, 11): relevant `realapi` spec verified locally before merge
### Risk register
| Risk | Mitigation |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Documenso v2 endpoint shape drifts from docs | PR2 validates against real v2 instance during dev; realapi spec re-runs nightly post-ship |
| Visual polish scope creeps | One PR per surface group (10a-e), each independently shippable |
| Cron migration changes effective behaviour | Backfill sets EOI cadence to 1 day matching today's effective; run on staging first |
| Mobile responsive regressions | Visual baselines include phone-viewport snapshots; PR10e is the responsive sweep |
| EOI dialog → wizard migration breaks "Generate EOI" button | Wizard launched with prefills from interest detail; PR6 includes regression spec |
| AcroForm template format confuses non-technical admins | HTML default; inline help; default templates seeded |
| Phase A wall-clock past 5 weeks | Tier-2 sweep items + optional realapi specs deferrable to follow-up release |
## Glossary
- **Documenso** — open-source document signing service, self-hosted instance at `signatures.portnimara.dev`
- **EOI** — Expression of Interest, a pre-reservation signed document
- **Reservation Agreement** — contract signed when a berth reservation is committed
- **Hub** — the new `/[port]/documents` page
- **Watcher** — a CRM user added to a doc to receive notifications on signature events without being a signer themselves
- **Signing order** — sequential index across signers; sequential mode requires lower order to sign first; parallel mode lets all sign concurrently
- **Cadence** — interval in days between auto-reminders to unsigned signers
- **System send / User send** — email dispatch identity: System uses port-config noreply SMTP; User uses connected personal email account (gated by admin toggle)
- **Render location** — where the PDF is generated (CRM-local via HTML/AcroForm/overlay, or in Documenso). Signing is always Documenso; render location is independent.

View File

@@ -0,0 +1,435 @@
# Phase B — Insights, Alerts, and Operational Awareness
**Status:** Draft — awaiting review
**Date:** 2026-04-28
**Phase:** B of D (A = Documents hub + visual polish ✓ shipped; C = Website integration; D = Pre-prod ops)
## Overview
Phase A made the CRM look polished and finished the documents/signing surface. Phase B turns it into a tool that _tells operators what's happening_ — instead of forcing them to navigate every list to find pipeline drift, expiring documents, or stalled reservations. It also closes the seven highest-priority Nuxt→Next gaps the 2026-04-28 audit surfaced (analytics, berth-interests, EOI queue, OCR, alerts, audit log, expense dedup).
The product story changes from "system of record" to "system of attention." Operators land on the dashboard and immediately see what needs them today — not a flat list they have to filter.
## Scope boundaries
### In scope (this spec)
- **Analytics dashboard** — chart-driven KPI page replacing the current 4-tile placeholder; pipeline funnel, occupancy timeline, revenue breakdown, lead-source attribution, with date-range and per-port filters
- **Alert framework** — rule engine that evaluates conditions on a schedule and surfaces actionable cards (alerts) in the dashboard's right rail; dismissible per-user; deep-links into the offending entity
- **Interests-by-berth view** — `/[port]/berths/[id]/interests` panel showing every interest targeting a berth, sortable by stage/score/age
- **Expense duplicate detection** — heuristic match on (vendor + amount + date ± 3 days); surfaces in expense detail with "Merge" action; background scan on new expense
- **EOI queue** — saved-view filter on the existing documents hub for `documentType='eoi' AND status IN ('sent','partially_signed')`, surfaced as a hub tab and a dashboard alert link
- **OCR for expense receipts** — Claude Vision integration on the existing `/expenses/scan` route to extract vendor, amount, date, currency, line items from uploaded receipts; user confirms before save
- **Audit log read view** — admin-gated UI for the existing `audit_logs` table with filters (user, action, entity type, date range, entity id search) and per-port + global (super-admin) scopes
### Explicitly out of scope (deferred to later phases)
- Custom user-defined alert rules (Phase B v1 ships with a fixed catalog of ~10 rules; user-rule creation deferred to Phase D)
- Real-time alert push notifications (only socket-fired updates of the alert list; SMS/email push deferred)
- Alert grouping / digests (each alert is its own card)
- Predictive analytics, ML scoring (separate from existing AI feature flag)
- Cross-port roll-up dashboards for super-admins (per-port only in v1)
- Full audit-log retention / archival policy (Phase D)
- OCR for PDF receipts (only image formats: jpg/png/heic; PDF expense uploads bypass OCR and stay manual until Phase D)
- Excel/CSV import for bulk expense backfill
- Country / phone / timezone work (separate cross-cutting agenda at `2026-04-28-country-phone-timezone-design.md`)
## Information architecture
### URL surface
```
/[port]/dashboard replaces existing; analytics-driven
/[port]/insights deep-link analytics page (charts only, no alerts)
/[port]/alerts full alert list (admin filter, dismissed history)
/[port]/berths/[id]/interests new tab on berth detail
/[port]/expenses/scan extend existing route with Claude Vision OCR
/[port]/admin/audit admin-gated audit log viewer
/[port]/documents extended: 'EOI queue' tab pre-filters to EOI in flight
```
### Schema deltas
```sql
-- alerts: surfaces operational warnings the user should act on
CREATE TABLE alerts (
id text PRIMARY KEY DEFAULT generate_id('alrt'),
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
rule_id text NOT NULL, -- 'reservation.no_agreement', 'interest.stale', ...
severity text NOT NULL, -- 'info' | 'warning' | 'critical'
title text NOT NULL,
body text,
link text NOT NULL, -- relative path the card deep-links to
entity_type text, -- optional FK target ('interest', 'reservation', ...)
entity_id text,
fingerprint text NOT NULL, -- hash of (rule_id + entity_type + entity_id) — dedupe
fired_at timestamptz NOT NULL DEFAULT now(),
dismissed_at timestamptz,
dismissed_by text REFERENCES users(id),
acknowledged_at timestamptz, -- "I'm on it" without dismissing
acknowledged_by text REFERENCES users(id),
resolved_at timestamptz, -- auto-set when underlying condition clears
metadata jsonb DEFAULT '{}' -- per-rule extras (e.g. days_stale, amount_at_risk)
);
CREATE UNIQUE INDEX idx_alerts_fingerprint_open ON alerts (port_id, fingerprint) WHERE resolved_at IS NULL;
CREATE INDEX idx_alerts_port_fired ON alerts (port_id, fired_at DESC);
CREATE INDEX idx_alerts_port_severity_open ON alerts (port_id, severity) WHERE resolved_at IS NULL AND dismissed_at IS NULL;
-- expense duplicate detection (column-only, no new table)
ALTER TABLE expenses ADD COLUMN duplicate_of text REFERENCES expenses(id);
ALTER TABLE expenses ADD COLUMN dedup_scanned_at timestamptz;
CREATE INDEX idx_expenses_dedup ON expenses (port_id, vendor_name, amount, expense_date)
WHERE duplicate_of IS NULL;
-- analytics support: materialized refresh tracking (avoids recomputing on every dashboard hit)
CREATE TABLE analytics_snapshots (
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
metric_id text NOT NULL, -- 'pipeline_funnel.30d', 'occupancy_timeline.90d', ...
computed_at timestamptz NOT NULL DEFAULT now(),
data jsonb NOT NULL,
PRIMARY KEY (port_id, metric_id)
);
-- audit_logs already exists; add a tsvector column for fast search
ALTER TABLE audit_logs ADD COLUMN search_text tsvector
GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(action, '') || ' ' ||
coalesce(entity_type, '') || ' ' ||
coalesce(entity_id::text, '') || ' ' ||
coalesce(actor_email, ''))
) STORED;
CREATE INDEX idx_audit_search ON audit_logs USING gin(search_text);
-- ocr extracted fields on receipt files (most fields already on expenses)
ALTER TABLE expenses ADD COLUMN ocr_status text DEFAULT 'pending'; -- 'pending'|'ok'|'failed'|'low_confidence'
ALTER TABLE expenses ADD COLUMN ocr_raw jsonb; -- the model's full response
ALTER TABLE expenses ADD COLUMN ocr_confidence numeric; -- 0..1
```
After running migration on dev/staging, restart `next dev` to flush postgres.js prepared-statement cache (project convention).
### Service-layer changes
**New services:**
- `alerts.service.ts` — CRUD + fanout: `evaluateRules(portId)`, `dismissAlert(id, userId)`, `acknowledgeAlert(id, userId)`, `resolveStaleAlerts(portId)`
- `alert-rules.ts` — fixed catalog of evaluator functions, each takes `(portId, db)` and returns `Array<{ rule_id, severity, fingerprint, ... }>`
- `analytics.service.ts``getPipelineFunnel(portId, range)`, `getOccupancyTimeline(portId, range)`, `getRevenueBreakdown(portId, range)`, `getLeadSourceAttribution(portId, range)`; reads `analytics_snapshots` first, recomputes if stale
- `analytics-snapshot-job.ts` — BullMQ recurring job that recomputes snapshots every 15 min per port
- `expense-dedup.service.ts``scanForDuplicates(expenseId)`, returns candidate matches with confidence; called from BullMQ on `expense:created`
- `expense-ocr.service.ts` — Claude Vision wrapper: takes file URL, returns parsed expense fields; uses prompt caching for the system prompt to keep cost down
- `audit-search.service.ts` — wraps drizzle query with tsvector match + filters
**Extended services:**
- `documents.service.ts` — adds `getEoiQueueRows(portId, opts)` that joins documents + signers + last-reminder for the EOI queue tab
- `expenses.service.ts``createExpense` triggers OCR + dedup BullMQ jobs after row insert
- `notifications.service.ts` — fires `alert:created` and `alert:resolved` socket events
### Alert rule catalog (v1)
| Rule ID | Severity | Trigger | Resolves when | Why it matters |
| ---------------------------- | -------- | -------------------------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------- |
| `reservation.no_agreement` | warning | active reservation > 3d old without a `reservation_agreement` doc in any non-cancelled state | doc reaches `sent` | flagged in Phase A spec |
| `interest.stale` | info | `pipelineStage IN ('details_sent','in_communication','visited')` AND last activity > 14d | activity timestamp updates | dropped leads |
| `document.expiring_soon` | warning | `expires_at` within 7 days, `status IN ('sent','partially_signed')` | doc completed/cancelled or expires_at passes | nudge before contracts lapse |
| `document.signer_overdue` | warning | signer pending > 14d AND last reminder > 7d ago | signer signs/declines | classic chase target |
| `berth.under_offer_stalled` | info | berth `status='under_offer'` > 30d | status changes | reservation never closed |
| `expense.duplicate` | info | `expense.duplicate_of IS NOT NULL` | merged or marked-not-duplicate | bookkeeping cleanup |
| `expense.unscanned` | info | expense with file but `ocr_status='pending'` > 1h | `ocr_status='ok'` | OCR failed silently |
| `interest.high_value_silent` | critical | `leadCategory='hot_lead'` AND last activity > 7d | activity update | revenue at risk |
| `eoi.unsigned_long` | warning | EOI doc `status='sent'` > 21d | doc completed/cancelled | EOI funnel leak |
| `audit.suspicious_login` | critical | >3 failed logins from same IP in 1h | manual dismiss | security awareness |
Rules are pure functions; the engine takes their outputs, upserts on `(port_id, fingerprint)` to avoid spam, and auto-resolves alerts whose rule no longer fires.
## Per-feature design
### Analytics dashboard
Replaces the current 4-tile dashboard. Layout:
```
[ Gradient PageHeader: "Dashboard" · last-updated stamp · Date range picker (Today / 7d / 30d / 90d / custom) ]
[ KPI row (4 KPITiles, sparkline + delta vs prior period):
Total Clients Active Interests Pipeline Value Occupancy Rate
]
[ Pipeline funnel (recharts FunnelChart): | Alert rail (right column):
horizontal bars per stage with conversion % | Critical (red) cards
click bar → filtered interests list | Warning (amber) cards
| Info (blue) cards
| "Show dismissed" toggle
] |
[ Revenue breakdown (recharts BarChart, stacked by source) ] | (continues)
[ Occupancy timeline (recharts AreaChart, daily/weekly) ] |
[ Lead source attribution (recharts PieChart with legend) ]
```
Charts are server-rendered via the recharts already-in-bundle. Data comes from `analytics.service.ts` which reads `analytics_snapshots` (refreshed every 15 min by cron) — first hit warms the cache, subsequent hits are sub-100ms.
Date-range picker re-runs `analytics.service` queries with the selected range; cache key includes the range so 30d and 90d don't fight.
Export: each chart card has a `[...]` overflow menu with "Download as CSV" and "Download as PNG"; uses recharts' `getDataUrl()` for PNG.
### Alert rail
Right column on `/dashboard`, full page at `/alerts`. Each alert is a card:
```
[severity-color stripe-left]
[rule-icon] Title (entity name)
Body — body text describing the condition
Last fired N days ago · entity: link
[Acknowledge] [Dismiss] [Open →]
```
- Acknowledge: marks `acknowledged_at` but stays visible (someone's on it)
- Dismiss: hides from the rail; appears in `/alerts` "Dismissed" tab
- Auto-resolve: when the rule re-evaluates and the condition no longer fires, alert moves to "Resolved" history
Real-time: socket emits `alert:created` / `alert:resolved` from the cron worker; React Query invalidates the alert list.
### Interests-by-berth view
New tab on `/[port]/berths/[id]` called "Interests" — count badge in tab.
```
[ Berth header (existing) ]
[ Tabs: Overview | Reservations | Interests (N) | Notes | Files | Activity ]
[ Interests tab body:
[Filter: All stages | Active only | Lost] [Sort: Newest | Stage progress | Lead score]
Table: client name | stage pill | source | category | last activity | score badge
Click row → interest detail
]
```
Pure read; no mutations. The list filters interests where `interest.berthId = berth.id`. Already exists in DB; just needs the UI tab.
### Expense duplicate detection
When a new expense is created, BullMQ job `expense.dedup` runs:
```ts
async function scanForDuplicates(expenseId: string) {
const e = await db.query.expenses.findFirst({ where: eq(expenses.id, expenseId) });
const candidates = await db.query.expenses.findMany({
where: and(
eq(expenses.portId, e.portId),
eq(expenses.vendorName, e.vendorName),
eq(expenses.amount, e.amount),
between(expenses.expenseDate, addDays(e.expenseDate, -3), addDays(e.expenseDate, 3)),
ne(expenses.id, e.id),
),
});
if (candidates.length > 0) {
await db
.update(expenses)
.set({ duplicate_of: candidates[0].id, dedup_scanned_at: new Date() })
.where(eq(expenses.id, expenseId));
// fires `expense.duplicate` alert via rule engine on next sweep
}
}
```
Detail page: when `duplicate_of` is set, show a yellow banner: "Looks like a duplicate of {linked expense}. [Merge them] [Mark as not duplicate]". Merge: deletes the new expense and merges any line items into the original.
### EOI queue tab
Documents hub gets a new tab between "Awaiting them" and "Awaiting me":
```
Tabs: All | EOI queue (N) | Awaiting them | Awaiting me | Completed | Expired
```
`EOI queue` filters: `documentType='eoi' AND status IN ('sent','partially_signed')`. Same row chrome as the rest of the hub. Bulk-action bar adds an "EOI bulk reminder" preset that respects the rule engine's reminder cooldown.
### OCR for expense receipts
Existing `/expenses/scan` route — extend to call Claude Vision on upload:
```ts
// expense-ocr.service.ts (uses Anthropic SDK; already in deps)
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const SYSTEM_PROMPT = `You extract structured expense data from receipts...
Output JSON: { vendor, amount, currency, date (ISO), lineItems: [...], confidence (0-1) }
`; /* cached via ephemeral cache_control for cost savings */
export async function ocrReceipt(fileUrl: string) {
const file = await fetch(fileUrl);
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
const message = await client.messages.create({
model: 'claude-haiku-4-5-20251001', // haiku for cost; sonnet if quality needed
max_tokens: 1024,
system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
messages: [
{
role: 'user',
content: [
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
{ type: 'text', text: 'Extract expense fields from this receipt.' },
],
},
],
});
return parseAndValidate(message.content[0].text);
}
```
UI: existing scan page now shows a 3-step flow:
1. Upload receipt photo
2. Wait for OCR (spinner; ~3s avg with Haiku)
3. Confirm extracted fields (pre-filled form, user can edit)
4. Save → existing expense create flow
Low-confidence (< 0.6) extractions show a yellow banner "Please verify all fields" and pre-select the file uploader.
### Audit log read view
Admin route `/[port]/admin/audit`:
```
[ PageHeader: "Audit Log" · "Last 30 days · 12,847 events" ]
[ Filter row:
Search [tsvector] Actor [combobox of users] Action [pills] Entity type [select]
Date range [picker] Severity [pills] [Reset]
]
[ Table:
Timestamp | Actor | Action | Entity | Diff button | IP | User-agent
Click row → expand to show before/after JSON diff
]
[ Pagination · Export CSV button (admin-gated) ]
```
Server-side: `audit-search.service.ts` builds a drizzle query with the tsvector match + filters; supports cursor pagination on `(created_at, id)`.
Super-admin sees a port toggle that switches between current port and "All ports" view.
## Test plan
### Unit (`tests/unit/`)
- `alert-rules-evaluators.test.ts` — each rule tested with seeded data; covers fire/no-fire cases and resolution conditions
- `expense-dedup-heuristic.test.ts` — vendor/amount/date matching with edge cases (case-insensitive, ±3d window, currency mismatch ignored)
- `analytics-pipeline-funnel.test.ts` — funnel math against fixture interests
- `analytics-occupancy-timeline.test.ts` — daily aggregation against fixture berth status changes
- `audit-search-filters.test.ts` — tsvector + filter composition
- `ocr-prompt-caching.test.ts` — assert cache_control presence on system prompt; mocked Claude response
### Integration (`tests/integration/`)
- `alerts-engine.test.ts` — full evaluation cycle: seed conditions, run engine, assert correct alerts upserted, run again to assert dedupe via fingerprint, mutate state, assert auto-resolve
- `analytics-snapshot-refresh.test.ts` — recurring job: snapshot row written, served from cache on next read, refreshed on next tick
- `expense-dedup-flow.test.ts` — create A, create matching B, assert B.duplicate_of=A; merge B → A absorbs line items, B archived
- `audit-search-tsvector.test.ts` — seed audit_logs, query by free-text, assert returned ids
- `eoi-queue-listing.test.ts` — extends documents-hub test; assert EOI tab returns correct subset
### E2E smoke (`tests/e2e/smoke/`)
- New `27-analytics-dashboard.spec.ts` — dashboard renders charts; date-range picker re-renders; KPI tiles show non-zero data after seed
- New `28-alerts.spec.ts` — alert appears after seeding stale-interest condition; click-to-deep-link; dismiss persists; resolve hides
- New `29-interests-by-berth.spec.ts` — tab visible on berth detail; lists interests; sort works
- New `30-expense-dedup.spec.ts` — create two matching expenses; banner appears; merge button works
- New `31-ocr-flow.spec.ts` — uploads fixture receipt image; extracted fields pre-filled; user can edit and save
- New `32-audit-log.spec.ts` — admin page loads; search by entity id returns expected row; date filter narrows
- Extend `04-documents.spec.ts` — EOI queue tab presence + count badge
### E2E exhaustive (`tests/e2e/exhaustive/`)
- `15-analytics-dashboard.spec.ts` — crawl every chart's hover tooltips, legend toggles, export menu
- `16-alerts.spec.ts` — crawl alert card actions, severity filters, dismissed history, real-time arrival via socket emit
- `17-audit-log.spec.ts` — crawl filter combos, expand row diffs, super-admin all-ports toggle
### E2E real-API (`tests/e2e/realapi/`)
- New `claude-vision-receipt-ocr.spec.ts` — gates on `ANTHROPIC_API_KEY`; uploads two real fixture receipts (one clean, one blurry); asserts Haiku response shape and confidence score; verifies `cache_control` headers in HTTP trace; cleanup deletes test expense
### Test data fixtures
`global-setup.ts` extends:
- Seed one stale interest in `details_sent` stage with `last_activity_at = now - 20d` (fires `interest.stale`)
- Seed one active reservation without an agreement (fires `reservation.no_agreement`)
- Seed two matching expenses (fires `expense.duplicate`)
- Seed 90 days of pipeline activity for analytics charts
- Add a `tests/e2e/fixtures/receipts/` dir with two .jpg receipts for OCR tests
## Build sequence
| # | Title | Effort | Depends on |
| --- | ------------------------------------------------------------ | ------ | ----------------- |
| 1 | Schema + alert/analytics service skeletons | 1d | — |
| 2 | Alert rules engine + recurring evaluator + socket | 1.5d | 1 |
| 3 | Analytics snapshot job + service layer | 1d | 1 |
| 4 | Analytics dashboard page (KPI tiles + 4 charts + date-range) | 2.5d | 1, 3, A's KPITile |
| 5 | Alert rail UI + `/alerts` page | 1.5d | 2 |
| 6 | EOI queue tab on documents hub | 0.5d | A's hub |
| 7 | Interests-by-berth tab on berth detail | 0.5d | — |
| 8 | Expense duplicate detection (job + UI banner + merge) | 1.5d | 1 |
| 9 | OCR for expense receipts (Claude Vision + 3-step UI) | 1.5d | — |
| 10 | Audit log read view (admin page + filters + tsvector search) | 1.5d | 1 |
| 11 | Real-API integration tests | 1d | 9 |
### Critical path
```
1 → 2 → 5 (data → alert engine → alert UI)
1 → 3 → 4 (data → analytics service → analytics page)
8 → 2 (alert rule) (dedup populates the data the alert reads)
9 (OCR) → 11 (realapi)
```
Wall-clock minimum ~10 days (one engineer, sequential critical path); realistic with overhead ~13 days; calendar 2.53 weeks.
### Acceptance gates per PR
- `pnpm tsc --noEmit` and `pnpm lint` clean
- Vitest unit + integration green (incl. new tests)
- Playwright smoke green for the surface touched
- Visual baselines regenerated and reviewed in PR diff
- For PRs touching external integrations (9 OCR, 11 realapi): relevant `realapi` spec verified locally before merge
### Risk register
| Risk | Mitigation |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Alert engine false positives spam users | Each rule has a "snooze" window in metadata; rules ship behind a feature flag `alerts.{rule_id}.enabled`; QA seeds production-shape data before flipping flags on |
| Analytics queries slow on large datasets | `analytics_snapshots` materialized cache; cron recomputes off the request path; queries use existing per-port indexes |
| Claude Vision OCR cost spirals | Default to Haiku 4.5 (~10× cheaper than Sonnet); ephemeral system-prompt cache hits ~80%; per-port quota with admin-visible meter |
| OCR low-quality on blurry receipts | Confidence threshold (< 0.6) flips to "verify mode" — user must touch every field before save; failure metric tracked in admin/monitoring |
| Audit log table large (millions of rows) | Already partitioned-friendly via the GIN tsvector index; pagination uses cursor on `(created_at, id)` not OFFSET |
| Alert socket fanout overwhelms client | Throttle the engine cron to once per 5min; client debounces React Query refetches |
| Interest stale rule fires for legitimately paused leads | Add a per-interest `paused_until` field as a follow-up if operators ask; v1 ships without |
## Glossary
- **Alert** — operator-facing actionable card, rule-fired, dismissible
- **Rule** — a pure-function evaluator that takes (port, db) and returns alert candidates
- **Fingerprint** — `hash(rule_id + entity_type + entity_id)` used to dedupe alerts across re-evaluations
- **Snapshot** — cached chart data row in `analytics_snapshots`, refreshed on cron
- **EOI queue** — saved-view filter on the documents hub, not a separate page
- **OCR** — Claude Vision extraction of structured expense fields from receipt images
- **Audit log** — read view of the existing `audit_logs` table; no schema change beyond a tsvector column
## Open questions for the user
- Which port should be the **default landing dashboard** when a super-admin logs in (currently first-port-by-name; analytics page works the same)?
- Should the alert rail be **always visible on all dashboard pages** or only on `/dashboard` (currently spec'd as the latter)?
- Do you want the **Audit log retention policy** (delete > N days old) wired in v1 or deferred to Phase D?
- Should **OCR be opt-in per port** (admin toggle) or always-on with a quota?

View File

@@ -0,0 +1,376 @@
# Google Workspace inbox-triage integration (exploratory)
**Status:** Exploratory — not approved for build
**Date:** 2026-04-29
**Tracks:** AI inbox-triage, Google Workspace email connection
## What this spec is for
The user has flagged inbox-triage as the most valuable AI surface left to
build, but conditioned email integration on it being via Google Workspace
specifically (not generic IMAP), with a per-port toggle so clients who
don't use GWS aren't billed for capability they can't reach.
This document captures what that build actually costs — especially on
the Google side, which is where most teams underestimate the work — so
we can decide whether to commit before writing any code. **Nothing in
this spec is approved for implementation.** The deliverable is a go /
no-go decision and, if go, a scope choice between three deployment
models that cost wildly different amounts of calendar time.
## What inbox-triage actually does for the user
Concretely, on the staff member's desktop:
1. **Linked-inbox panel on the client detail page.** When you open
`/[port]/clients/<id>` you see the last N email threads with that
client, pulled from the staff member's own Gmail. Each thread has
the latest message preview, an "open in Gmail" deep-link, and a
"draft reply" button (Phase 2+).
2. **Inbox triage queue.** A new top-level page `/[port]/inbox` that
lists unread/unanswered threads ranked by AI-assessed importance
(high-value client, contractual urgency, chase-overdue). Each row
has one-click actions: "log this as a note on the client",
"create a follow-up reminder", "draft reply".
3. **Email-driven alerts.** When a high-value client emails and no one
responds within X hours, the existing alerts engine fires a
`inbox.unanswered_high_value` rule (slots into the alert framework
from Phase B without schema change).
4. **Reply drafts (Phase 3).** AI generates a reply draft grounded in
the client's CRM record (open interests, pending reservations,
recent invoices). Staff edit and send through Gmail.
The value is selective: a port with three staff members fielding 50
client emails a day saves maybe an hour a day collectively if the
ranking is right. Below that volume the build doesn't pay back.
## What already exists in the codebase
The CRM is roughly halfway scaffolded for this:
| Surface | Status | Notes |
| ----------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `email_accounts` table | ✅ Exists | Has `provider: 'google' \| 'outlook' \| 'custom'` discriminator and `imap_*` / `smtp_*` cols. Built for IMAP, not OAuth. |
| `email_threads` / `email_messages` tables | ✅ Exists | Already linked to `clientId`. Schema is good as-is for Gmail. |
| `email-threads.service.ts` `syncInbox()` | ⚠ Stub-ish | IMAP-flow only. Won't reach Gmail without OAuth + Gmail API rewrite. |
| `email` BullMQ queue + `inbox-sync` job name | ✅ Exists | Worker dispatches on the job name; new sync impl drops in. |
| `google_calendar_tokens` table | ✅ Exists | OAuth token storage shape we can mirror for Gmail. |
| Per-port email override (port `email_settings`) | ✅ Exists | Used for outbound only today; Gmail integration is per-staff-user, not per-port. |
| `ai_usage_ledger` + per-port `aiEnabled` flag | ✅ Exists (Phase 3a/3b) | Triage AI calls book against the same ledger. |
| `withRateLimit('ai', ...)` wrapper | ✅ Exists (Phase 3c) | Caps triage AI traffic at 60/min/user out of the box. |
Net: schemas are mostly right. The OAuth flow, Gmail API client, push
notification receiver, and triage classifier are the new builds.
## Why Google Workspace specifically
The user's stated constraint: "I don't think we need email integration
unless we connect it to Google Workspace." Reasons that hold up:
- **No password storage.** OAuth tokens are revocable, scoped, and
rotate. IMAP requires app passwords, which Google has been actively
deprecating since 2024 — they'll be gone for the workspace plans
this product targets.
- **Push notifications, not polling.** Gmail's `users.watch` API plus
Google Pub/Sub means we get an HTTP callback within seconds of a new
message landing. IMAP requires polling on a 30-60 second cadence,
which costs more and lags worse.
- **Search and labels.** The Gmail API exposes label management and
full-text search natively; IMAP search is much weaker.
- **Threading.** Gmail's `threadId` is canonical. Reconstructing
threads over IMAP from `In-Reply-To` / `References` headers is
reliable in theory, painful in practice.
Microsoft 365 is the obvious peer integration but is out of scope here.
The Graph API model is similar enough that a future M365 path can reuse
most of the storage shape.
## Three deployment models — pick one before building
This is the most important decision in the spec. Each model has
different OAuth-verification consequences, which dominate everything
else.
### Model A — Marketplace-published OAuth app
A single OAuth client owned by Port Nimara, listed in the Google
Workspace Marketplace, that any GWS customer can install. Each staff
member clicks "Connect Gmail," consents to the scopes, and the CRM
stores their refresh token.
**Google-side work:**
1. Build the OAuth flow in CRM (~1 week).
2. Submit for OAuth verification. Gmail's `gmail.readonly` /
`gmail.modify` scopes are **restricted scopes** — they require:
- Domain-verified production URLs
- A homepage with a privacy policy that explicitly enumerates which
scopes are used and why
- A demo video (literally a screen recording) showing the consent
screen and what happens next
- **A third-party security assessment from a Google-approved
vendor** ($15k$75k, 612 weeks)
- A Cloud Application Security Assessment (CASA) report
3. Marketplace listing review (~2 weeks after CASA passes).
**Calendar time:** 46 months.
**Money:** $15k$75k for the security assessment alone.
**Recurring:** Re-verification every 12 months.
Right answer if Port Nimara wants to be the marina-CRM that ships GWS
out of the box for _any_ customer. Wrong answer if there are <5
customers who'd use it.
### Model B — Per-customer "Internal" OAuth app
Each customer's GWS admin creates an OAuth client _inside their own
workspace_ and gives Port Nimara the client ID + secret. Because the
app is "Internal," Google skips verification entirely — the consent
screen is unverified-but-permitted. Tokens never cross workspace
boundaries.
**Google-side work per customer:**
1. Customer's GWS admin enables the Gmail API in their Cloud project.
2. Creates an OAuth 2.0 client ID with type "Internal" + your CRM's
redirect URI.
3. Hands the client ID + secret to Port Nimara out-of-band.
4. Staff connect their Gmail through that client.
**Calendar time per customer:** ~1 hour of admin work.
**Money:** $0.
**Limit:** Doesn't span across GWS workspaces. A user with two GWS
accounts (e.g. the marina + a personal workspace) can only connect the
one matching the OAuth client.
This is the **clear winner for the current customer base**: small
number of customers, each with their own GWS workspace, and each
buying the integration as part of an onboarding conversation.
### Model C — Forward-to-CRM mailbox
The CRM exposes a per-port email alias (e.g.
`port-nimara-NN@inbox.portnimara.com`). Customers configure a Gmail
filter or mailing rule that BCCs that alias on relevant threads. The
CRM ingests via SMTP and runs the same triage pipeline.
**Google-side work:** None. Customer does it as a Gmail filter.
**Calendar time:** ~1 week of CRM-side build.
**Limit:** Receive-only — no reply drafts, no thread state changes,
no labels. The "draft reply" feature in Phase 3 above is impossible
under this model.
Model C is the right answer if the user wants to ship inbox-triage
_now_ and decide on bidirectional Gmail integration later. The schema
is designed so the model can be upgraded to A or B without data
migration.
### Recommendation
**Build Model B first.** It costs nothing on the Google side, takes
~3 weeks of CRM work, and matches the actual customer profile.
**Promote to Model A only after 3+ paying customers ask for it
unprompted.** Until then, the security-assessment cost can't justify
itself.
Model C as a fallback for customers who refuse to set up an Internal
OAuth app. Build it last, lazily — the schema accommodates it.
## End-to-end flow (Model B)
### 1. Per-port OAuth-app config
New admin page `/[port]/admin/google-workspace`:
- Field: "OAuth client ID" (their internal client ID)
- Field: "OAuth client secret" (encrypted at rest using `ENCRYPTION_KEY`)
- Field: "Authorized redirect URI" (read-only; we display the value
they need to paste into their Google Cloud Console)
- Toggle: "Enable Gmail integration for this port"
Stored in `system_settings` under key `gws.config`, port-scoped.
Resolution mirrors the existing OCR config service.
### 2. Per-staff connect flow
Staff member visits `/[port]/me/integrations`, clicks "Connect Gmail."
```
GET /api/v1/auth/gws/start
→ looks up port's gws.config
→ builds Google authorize URL with port's client_id + state token
→ 302 to Google
[ user consents ]
→ 302 back to /api/v1/auth/gws/callback?code=…&state=…
→ exchanges code for tokens via port's client_secret
→ stores in new `gws_user_tokens` table (encrypted)
→ schedules an `inbox-watch` job
```
### 3. Push notification subscription
After tokens are stored, the worker calls
`gmail.users.watch({ topicName: <Pub/Sub topic>, labelIds: ['INBOX'] })`.
Gmail then posts to a Pub/Sub topic on every inbox change. The CRM
exposes a Pub/Sub push subscription endpoint at
`/api/webhooks/gmail-push` which fetches the changed messages via the
delta `historyId` and writes them into `email_messages`.
Watch subscriptions expire every 7 days. A maintenance job
re-establishes them daily.
### 4. Triage pipeline
For each new inbound message:
1. Match against `clients` and `companies` by `from_address` against
`client_contacts` (email channel). Persist a thread→client link if
found.
2. If port has `aiEnabled` AND `gws.triageEnabled`, queue an `ai`
job that classifies the thread:
- `urgency`: low / medium / high
- `category`: invoice-question / availability / contract / other
- `requires_response`: boolean
3. AI call records into `ai_usage_ledger` with `feature='inbox_triage'`.
The existing per-port budget gates apply automatically.
4. Triage output written to a new `email_triage` table keyed on
`email_messages.id`.
### 5. UI surfaces
- `/[port]/inbox` — sorted by triage rank, port-wide view.
- Linked-inbox panel on `client-tabs.tsx` — adds a new "Email" tab
pulling from `email_threads` filtered to that client.
- Alert rule `inbox.unanswered_high_value` slots into Phase B's
alert engine; no schema change.
## Schema additions
Three new tables, all port-scoped where it matters:
```ts
// Per-staff Gmail tokens. Mirror of google_calendar_tokens.
gws_user_tokens {
id, userId (UNIQUE), portId, emailAddress,
accessTokenEnc, refreshTokenEnc, tokenExpiry,
scope, watchExpiresAt, watchHistoryId,
connectedAt, lastSyncAt, syncEnabled, createdAt, updatedAt
}
// Triage classifications keyed to messages.
email_triage {
messageId (PK, FK email_messages.id ON DELETE CASCADE),
urgency, category, requiresResponse,
modelVersion, tokensUsed, classifiedAt
}
// Pub/Sub idempotency log. Gmail re-delivers; we dedupe.
gws_push_log {
messageId (Pub/Sub message id, PK),
historyId, receivedAt
}
```
Plus extensions to `email_messages`:
- `googleMessageId` (text, indexed) — Gmail's own ID for thread ops.
- `googleThreadId` (text, indexed).
- `gmailLabels` (text[]) — for "is unread" checks without hitting Gmail.
The existing `emailAccounts.provider='google'` column repurposes
unchanged; the IMAP fields go nullable since OAuth-flow accounts won't
populate them.
## AI cost interaction
Triage AI is opt-in **twice**: the port admin must turn on
`aiEnabled` (Phase 3a flag, default off) **and** `gws.triageEnabled`
(this spec, default off). Either toggle off and the inbox sync still
runs but skips classification, so staff can manually scan threads
without burning tokens.
Per-message token cost on a current Haiku-class model is roughly
15002500 tokens including the system prompt. A port doing 200 inbound
emails a day at the upper bound is ~500k tokens/day. The default
hard-cap is 500k/month, so triage will trip it inside a day. Two
mitigations baked in:
- The system prompt is short (<500 tokens) and prompt-cached on the
Anthropic side, so most tokens are output.
- Triage runs only on threads not already classified — re-syncs from
the watch loop don't re-bill.
The admin UI shows triage as its own line in the per-feature breakdown
so customers can see how much their inbox is costing them and tune
caps accordingly.
## Phased build (assuming Model B)
| Phase | Scope | Effort | Ships when |
| ---------------------------- | ------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------- |
| **G1** Connect | OAuth flow + per-port config + per-user token storage. No sync yet. Staff can connect; nothing happens. | 1 week | Standalone |
| **G2** Read-only sync | Pub/Sub push receiver + delta sync into `email_messages`. Linked-inbox tab on client detail. No AI. | 1 week | After G1 |
| **G3** Triage classification | AI classifier, `email_triage` writes, `/inbox` page sorting. Per-port toggle. | 1 week | After G2; depends on Phase 3b budgets being live (they are) |
| **G4** Reply drafts | Gmail API send + draft creation. "Draft reply" button on the client detail Email tab. | 1 week | After G3 |
| **G5** Alerts | New `inbox.unanswered_high_value` rule. Hooks into Phase B alert engine. | 2 days | After G3 |
Total: ~5 weeks for a single engineer, assuming the user provides one
real GWS workspace to test against during G1.
## Open decisions for the user
These are the questions to resolve before scheduling the build, in
priority order:
1. **Deployment model — A, B, or C?** Default recommendation B.
2. **Single user or domain-wide delegation?** Per-staff connect (one
token per user) is simpler. Domain-wide delegation lets the port
admin connect once on behalf of every staff member but requires
the customer to grant a service account broader access. Default
recommendation: per-staff.
3. **Scope set.** Minimal viable scope is `gmail.readonly`. To send
replies (G4) we need `gmail.send`. To manage labels (e.g. mark
"triaged-by-CRM") we need `gmail.modify`. Each scope expansion
widens the consent screen scariness but doesn't add new
verification steps under Model B.
4. **Pub/Sub topic ownership.** Pub/Sub topics live in _some_ GCP
project. Under Model B the customer's project owns the topic —
they pay for Pub/Sub (cents/month) and grant our service account
subscriber access. Alternative: Port Nimara owns the topic and
the customer's Gmail publishes cross-project (allowed, slightly
more setup). Default: customer-owned topic, fewer moving parts.
5. **Triage model.** Haiku 4.5 is right for cost; Sonnet 4.6 is
right if the ranking quality on Haiku turns out to be poor.
Defer this until G3 has real-world tuning data.
## Things that are NOT in this spec
- **Microsoft 365 / Outlook integration.** Same shape, different API.
Once Model B is proven on GWS, Graph API takes another ~3 weeks.
- **Reply drafts grounded in CRM context.** That's G4 and depends on
the work in this spec, but the prompt engineering for "good replies
citing this client's open interests + reservations + invoices"
deserves its own design pass before building.
- **Cross-staff triage queue (i.e. "show me all unanswered emails
across the team").** That requires either domain-wide delegation
(decision #2 above) or per-staff opt-in to a shared view. Punt
until staff actually ask for it.
- **Sentiment / urgency tone analysis.** Tempting; almost always
wrong; skip in v1.
- **"Smart drafts" using the recipient's past replies as context.**
Every customer asks for this and almost no one uses it once
built. Skip.
## Cost summary at a glance
| Item | Model A | Model B | Model C |
| ------------------------------- | ------------------------------- | -------------------------------------- | ------------------------------------ |
| Build effort | 34 weeks | ~5 weeks (over G1G5) | ~1 week (receive-only) |
| Calendar time to first customer | 46 months | 1 hour of customer admin work | 1 hour of customer Gmail-filter work |
| Up-front cash | $15k$75k (CASA) | $0 | $0 |
| Recurring | Re-verification annually | None | None |
| Best for | 50+ customers, Marketplace play | 110 customers, white-glove onboarding | Customers who refuse OAuth setup |
The recommendation stands: build Model B for G1 + G2 + G3, ship that,
and let real customer demand decide whether G4/G5 and Model A
promotion are worth the calendar time.

View File

@@ -0,0 +1,189 @@
# Mobile Optimization Design
**Status**: Design approved 2026-04-29 — pending plan.
**Plan decomposition**: Foundation PR (§3) is one implementation plan; per-page migration phases (§5) become follow-up plans, scoped per phase.
**Branch base**: stacks on `refactor/data-model`.
**Out of scope**: Phase B/C features, desktop redesign, Capacitor wrapper, swipe-actions on rows, native menus, server-driven UI.
---
## 1. Background
The CRM was built desktop-first. A 2026-04-29 mobile audit captured every authenticated and public page across the active iPhone viewport range. Findings:
1. **No `viewport` meta in the root layout** (one exists only in the scanner PWA sub-layout, `src/app/(scanner)/[portSlug]/scan/layout.tsx`). Without it, iOS Safari renders pages at the default 980px logical width and zooms out to fit — text becomes unreadable and touch targets sub-tappable. Playwright's `isMobile` emulation in the audit forces 393px-wide rendering, which exposes the layout breakage you'd otherwise have to discover by pinching to zoom.
2. **Topbar overflows**. Search input + port switcher + sign-out button cram into one row; sign-out clips off the right edge as a half-visible blue bar on every authenticated page.
3. **Tables render as desktop tables**. Every list page (clients, yachts, companies, invoices, expenses, interests, audit, users, etc.) shows truncated columns with horizontal scroll.
4. **Page headers don't downsize**. Titles like "Dashboard" truncate to "Dash..."; primary action buttons (`+ New Client`) overlap their subtitles.
5. **Detail page action chips overflow**. The chip row ("Invite to portal | GDPR export | Archive | …") horizontally overflows on every detail page.
6. **One half-good pattern**: detail pages already collapse their tabs to a `<select>` dropdown on small screens. Worth extending.
7. **Auth + scanner pages are already mobile-first** (`/login`, `/[portSlug]/scan`). Reference for the "what good looks like" target.
The audit harness (`tests/e2e/audit/mobile.spec.ts` + `mobile-audit` Playwright project) is added on this branch (not yet committed); re-runs regenerate `.audit/mobile/` (gitignored).
## 2. Approach
**Adaptive shell + responsive content** — chosen over (a) per-page conditional render, (b) a separate `(mobile)` route group, and (c) Tailwind-only responsive.
The "native feel" the user wants comes from the chrome — bottom tab bar, sheet modals, sticky compact header, safe-area awareness. Page content (forms, lists, details) doesn't need duplication; it gets responsive via shared mobile-aware primitives. This concentrates the dedicated-mobile work in ~10 components and keeps content single-source.
**Breakpoint**: Tailwind `lg` (1024px). Below `lg`, the mobile shell renders. At and above, the existing desktop shell is untouched.
### 2.1 Target iPhone viewport range
The mobile shell + content primitives must look correct across the full active iPhone viewport range (portrait):
| Tier | Models | Viewport |
| ------------------------------------------ | ----------------------------------------------- | -------- |
| Narrowest | iPhone SE 2nd / 3rd gen | 375×667 |
| Standard | iPhone 12/13/14 (and Mini) | 390×844 |
| Standard newer | iPhone 15 / 15 Pro / 16 | 393×852 |
| Pro newer (Dynamic Island, thinner bezels) | iPhone 16 Pro / 17 Pro | 402×874 |
| Plus / older Max | iPhone 14 Plus / 15 Plus / 15 Pro Max / 16 Plus | 430×932 |
| Pro Max | iPhone 16 Pro Max / 17 Pro Max | 440×956 |
**Anchors used by audit and design validation**: 375×667 (worst-case narrow + short), 393×852 (most common current), 402×874 (current Pro), 440×956 (current Pro Max). Models within ±5px of an anchor (390, 430) are skipped — primitives that look correct at the anchors will look correct at neighbors.
**Dynamic Island**: iPhone 14 Pro and later have a larger top safe-area inset (~59px vs ~47px on classic-notch models). The CSS `env(safe-area-inset-top)` we expose as `pt-safe` handles this transparently — no per-model code paths.
**Landscape**: out of scope for this design. Phones in landscape are rare for CRM-style work; if needed later, the mobile shell at landscape widths would still fall under `lg` and would just stretch. Tablet landscape is addressed in the §5 tablet-pass phase.
**Routing**: no new route group. URLs and middleware unchanged. RBAC, services, queries, validators, RHF/zod forms, TanStack Query stores, socket.io — all unchanged.
## 3. Foundation PR
A single branch lands the infra + shell + primitives before any per-page work. After this merges, every authenticated page already gains: real viewport meta, no clipped topbar, bottom tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.
### 3.1 Infrastructure
- `viewport` export in `src/app/layout.tsx``width=device-width, initial-scale=1, viewport-fit=cover`.
- `theme-color` meta + `apple-mobile-web-app-capable` meta + `apple-mobile-web-app-status-bar-style` for PWA-ish status-bar integration.
- Safe-area CSS variables (`env(safe-area-inset-*)`) exposed as Tailwind utilities (`pt-safe`, `pb-safe`, `pl-safe`, `pr-safe`).
- `useIsMobile()` hook in `src/hooks/use-is-mobile.ts` — backed by `window.matchMedia('(max-width: 1023.98px)')`, no resize listener.
- Server-side body-class detection: the root layout (`src/app/layout.tsx`) reads the `user-agent` request header via `next/headers`'s `headers()`, runs a small known-mobile-token check (Mobile / iPhone / iPad / Android — no library), and renders `<body data-form-factor="mobile|desktop">`. No middleware needed. CSS `[data-form-factor="mobile"]` reveals the mobile shell. The CSS media-query fallback (`@media (max-width: 1023.98px)`) handles UA misclassification (e.g., desktop browser resized to narrow width, or stripped UA).
### 3.2 Mobile shell
Both desktop and mobile shells are rendered to the DOM by the root layout; CSS reveals one and hides the other based on `[data-form-factor="mobile"]` plus a `@media (max-width: 1023.98px)` fallback. The existing `<Sidebar>` and `<Topbar>` components stay unchanged for the desktop shell. The mobile shell is wholly new:
- **`<MobileLayout>`** (`src/components/layout/mobile/mobile-layout.tsx`)
Fixed 52px compact topbar (safe-area aware) + scrollable content + fixed 56px bottom tab bar (safe-area inset). Renders instead of the desktop sidebar+topbar shell when the form factor resolves to mobile.
- **`<MobileTopbar>`**
Page title (auto-truncating, single-line) + back button when route depth > 1 + single primary action slot (passed via context from the page) + port-switcher behind a `<Sheet>` trigger.
- **`<MobileBottomTabs>`**
Fixed 5 tabs: **Dashboard / Clients / Yachts / Berths / More**. Active state from current path. Lucide icons (no emoji). Badge support for the alerts count.
- **`<MoreSheet>`**
Bottom sheet opened by the More tab. Holds the long tail in a scrollable list grouped by section: Companies, Interests, Invoices, Expenses, Documents, Email, Alerts, Reports, Reminders, Settings, Admin (with admin nesting one level deep into a child sheet).
- **`<MobileLayoutProvider>`**
React context that lets each page push its title, back button, and primary action slot to `<MobileTopbar>` via a hook (`useMobileChrome({ title, action })`).
### 3.3 Primitives
All built once in `src/components/shared/`. Render desktop-style above `lg`, mobile-style below.
- **`<Sheet>`** — vaul-based bottom sheet on mobile, falls through to existing Radix `<Dialog>` on desktop. Same API as `<Dialog>` so adoption is mechanical.
- **`<DataView>`** — accepts the same column defs the codebase uses today via TanStack Table. Above `lg`: renders the existing table. Below `lg`: renders a card list with a per-row `cardRender({ row }) => ReactNode` callback. Filter chips stay above the list; sort moves into a `<Sheet>` opened by a sort button.
- **`<PageHeader>`** — title + optional subtitle + actions. Truncates title to one line, stacks actions to a second row on mobile, hides subtitle below `sm` if action row is present.
- **`<ActionRow>`** — chip-style action group; `flex-nowrap overflow-x-auto scroll-smooth snap-x` on mobile, no overflow on desktop.
- **`<DetailPageShell>`** — wraps detail pages with: sticky compact header (entity name, primary status), tab dropdown selector (existing pattern, extracted), scrollable content area, optional sticky bottom action bar (Save / Archive / etc.) on mobile that pins above the bottom tab bar.
- **`<FilterChips>`** — chip-row filter UI used by `<DataView>`. Active filters are dismissable chips; "Add filter" opens a `<Sheet>`.
### 3.4 Default style adjustments
- `<Button>` and `<Input>` defaults: `min-h-11` (44px, Apple HIG touch-target).
- `<Input>` and `<Textarea>` body text: `text-base` (16px) so iOS doesn't zoom on focus.
- `<Dialog>` default base styling tweaked so any remaining unmigrated dialogs render full-screen on mobile (until they get migrated to `<Sheet>`).
### 3.5 Bundle impact
Both shells render server-side and switch via the `data-form-factor` body attribute, so both ship to every client (dynamic-importing one would cause a hydration flash). Rough estimate ~40KB gzipped added to the layout subtree for the mobile shell + new primitives (vaul ≈ 5KB gz, the rest is in-house components). Verify post-build with `pnpm build` and adjust if it's materially higher. Acceptable trade for no flash and no UA-based render-time branching.
### 3.6 PWA assets
The PWA scanner already references `icon-192.png`, `icon-512.png`, `icon-512-maskable.png` from `public/`, but those files don't exist yet (separate flagged blocker). The mobile shell adds an `apple-touch-icon` reference too. The Foundation PR includes placeholder PNGs so home-screen install works; production-quality icons can replace them without a code change.
## 4. Per-page playbook
Once foundation lands, each page follows the same workflow:
1. Open the page in headed Playwright at the anchor viewports per §2.1 (start at 393×852 for the iteration loop, spot-check 375 and 440 before declaring done).
2. Replace any `<Dialog>` with `<Sheet>`.
3. If list page: wrap the table in `<DataView>` and provide a `cardRender` callback. The 2-3 fields shown on the card are decided per page during migration with the user.
4. Replace the ad-hoc page header with `<PageHeader>`.
5. Replace ad-hoc action button rows with `<ActionRow>`.
6. Touch up any custom embedded widgets the page uses (rare for simple pages, common for `email`, `documents`, `expenses/scan`).
7. User reviews live in the headed browser, points out tweaks, iterate.
Most pages take 515 minutes in this loop. Heavy pages (email inbox, documents hub) may take 3060 because the embedded widgets need their own mobile treatment beyond the primitives.
## 5. Migration sequence
After foundation PR:
1. **Quick-win sweep** (~half day) — pages mostly fixed by foundation alone. Just need `<PageHeader>` swap-in (no list-card conversion, no detail-shell wrap):
`dashboard` (overview), `settings` (user-profile), `reports`, and the admin sub-pages that are forms or stat cards: `admin/settings`, `admin/branding`, `admin/forms`, `admin/ocr`, `admin/roles`, `admin/tags`, `admin/documenso`, `admin/templates`, `admin/custom-fields`, `admin/monitoring`, `admin/backup`, `admin/webhooks`, `admin/import`, `admin/ports`.
2. **List pages** (~12 days) — convert via `<DataView>` + per-page `cardRender`:
`clients`, `yachts`, `companies`, `berths`, `interests`, `invoices`, `expenses`, `alerts`, `reminders`, `admin/audit`, `admin/users`.
3. **Heavy pages** (~1 day each) — embedded widgets need their own mobile treatment beyond the primitives:
`documents` (sig-tracking + filters from Phase A), `email` (thread list + reader + composer).
4. **Detail pages** (~12 days) — wrap in `<DetailPageShell>`, extend the tab-dropdown pattern, add sticky bottom action shelf:
`clients/[clientId]`, `yachts/[yachtId]`, `companies/[companyId]`, `berths/[berthId]`, `invoices/[id]`, `expenses/[id]`.
5. **Forms & wizards** — touch-up only, since `<Input>`/`<Button>` defaults handle the bulk:
`invoices/new` (3-step wizard), `expenses/scan` (already mobile-first, just verify).
6. **Portal** — same patterns, smaller scope:
authenticated: `portal/dashboard`, `portal/invoices`, `portal/my-yachts`, `portal/documents`, `portal/interests`, `portal/my-reservations`. Public: `portal/login`, `portal/activate`, `portal/forgot-password`, `portal/reset-password` (already styled by `<BrandedAuthShell>` — just verify).
7. **Tablet pass** — re-audit at iPad Air 11" portrait (820×1180) and landscape (1180×820), iPad Air 13" portrait (1024×1366) and landscape (1366×1024). The 820 portrait case will hit the mobile shell (820 < 1024) and probably want a "tablet-portrait" treatment with sidebar visible — flagged for design refinement at that phase, not now. The other three viewports fall above `lg` and use the desktop shell unchanged.
## 6. Testing
- **Mobile audit project** (`mobile-audit` in `playwright.config.ts`) is the regression harness. Re-runs after every page-migration PR; output goes to `.audit/mobile/` (gitignored). Audit covers the four anchor viewports defined in §2.1: 375×667, 393×852, 402×874, 440×956. Run time ~14 min headed.
- **Smoke project** gets a curated mobile-viewport variant (~10 pages at the 393×852 anchor) — adds ~2 min to CI; full audit stays out of CI to avoid the ~14 min cost.
- **Visual baselines** — `visual` project gets new mobile snapshots at the 393×852 anchor for: dashboard, clients-list, clients-detail, invoices-list, invoices-new, scan, documents, login. Regenerate with `--update-snapshots` after intentional changes (existing convention).
- **Anchor device descriptors** lifted into a shared fixture at `tests/e2e/fixtures/devices.ts` (one per anchor in §2.1) so specs don't redefine viewport.
- **No new unit tests** for the primitives — they are presentational. Coverage comes from visual + integration runs.
## 7. Open questions
- **Bottom-tab taxonomy**: locked at Dashboard / Clients / Yachts / Berths / More for now. The More sheet holds everything else losslessly, so this is reversible — if real usage suggests a different top-5 (e.g., Interests or Invoices in the tabs), swap them later without code restructure.
- **`refactor/data-model` push order**: 155 commits unpushed. Foundation PR can stack on top and rebase, or wait until that branch merges. Decision deferred to user.
- **Desktop touch-target adjustments**: bumping `<Button>`/`<Input>` to `min-h-11` will affect desktop too. Verify visually that no desktop layout breaks; if any does, scope the bump to mobile-only via the `data-form-factor` attribute.
## 8. Files to create
```
src/hooks/use-is-mobile.ts
src/components/layout/mobile/
mobile-layout.tsx
mobile-topbar.tsx
mobile-bottom-tabs.tsx
more-sheet.tsx
mobile-layout-provider.tsx
src/components/shared/
sheet.tsx (new — vaul wrapper)
data-view.tsx (new — table↔card)
page-header.tsx (new)
action-row.tsx (new)
detail-page-shell.tsx (new)
filter-chips.tsx (new)
src/app/layout.tsx (modified — viewport export, theme-color, UA-derived data-form-factor body attribute via headers())
public/icon-192.png (placeholder PWA asset)
public/icon-512.png (placeholder PWA asset)
public/icon-512-maskable.png (placeholder PWA asset)
public/apple-touch-icon.png (placeholder PWA asset)
tailwind.config.ts (modified — safe-area utilities, touch-target defaults)
tests/e2e/fixtures/devices.ts (new — shared device descriptors)
```
## 9. Files to modify per page
Per the playbook in §4, each page typically needs:
- One swap of header markup → `<PageHeader>`.
- For list pages: one wrap of table → `<DataView>` + add `cardRender` callback.
- For detail pages: wrap in `<DetailPageShell>`.
- Replace `<Dialog>` imports with `<Sheet>`.
- No service, validator, query, or schema changes anywhere.

View File

@@ -1,564 +0,0 @@
# Client Deduplication and NocoDB Migration Design
**Status**: Design draft 2026-05-03 — pending approval.
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
---
## 1. Background
### 1.1 Why this exists
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
- **252 Interests rows** in NocoDB, against an estimated ~190200 unique humans (~2025% duplication rate).
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
- **No Clients table.** The conflated structure is structural, not accidental.
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
### 1.2 Real duplicate patterns observed in the live data
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
| Pattern | Example rows | Signature |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
### 1.3 Dirty data inventory
The migration normalizer must survive these real values from production:
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
### 1.4 Existing battle-tested algorithm
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
### 1.5 Why the website is no longer the source of new dirty data
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
---
## 2. Approach
Three artifacts, layered:
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
---
## 3. Normalization library
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
### 3.1 `normalizeName(raw: string)`
```ts
export function normalizeName(raw: string): {
display: string; // human-readable, kept for UI
normalized: string; // for matching
surnameToken?: string; // for surname-based blocking
};
```
- Trim leading/trailing whitespace
- Replace `\r`, `\n`, tabs with single space
- Collapse consecutive whitespace to single space
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
- `display` preserves user's intent (slash-with-company stays intact)
- `normalized` is `display.toLowerCase()` for comparison
- `surnameToken` is the last non-particle token for blocking
### 3.2 `normalizeEmail(raw: string)`
```ts
export function normalizeEmail(raw: string): string | null;
```
- Trim + lowercase
- Validate via `zod.email()` schema
- Returns `null` for empty / invalid (caller decides what to do)
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
```ts
export function normalizePhone(
raw: string,
defaultCountry: string,
): {
e164: string | null; // canonical, e.g. '+15742740548'
country: string | null; // ISO-3166-1 alpha-2
display: string | null; // user-facing pretty
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
} | null;
```
Pipeline:
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
4. If starts with `00` → replace with `+`
5. If starts with `+` → parse as E.164
6. Else if `defaultCountry` provided → parse against that country
7. Else return null (caller's problem)
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
### 3.4 `resolveCountry(text: string)`
```ts
export function resolveCountry(text: string): {
iso: string | null; // ISO-3166-1 alpha-2
confidence: 'exact' | 'fuzzy' | 'city' | null;
};
```
Reuses `src/lib/i18n/countries.ts`. Pipeline:
1. Lowercase + strip diacritics
2. Exact match against country names (any locale we ship)
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
---
## 4. Dedup algorithm
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
### 4.1 Public API
```ts
export interface MatchCandidate {
id: string;
fullName: string | null;
emails: string[]; // already normalized
phonesE164: string[]; // already normalized E.164
countryIso: string | null;
}
export interface MatchResult {
candidate: MatchCandidate;
score: number; // 0100
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
confidence: 'high' | 'medium' | 'low';
}
export function findClientMatches(
input: MatchCandidate,
pool: MatchCandidate[],
thresholds: DedupThresholds,
): MatchResult[];
```
### 4.2 Scoring rules (compound)
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
| Rule | Score | Notes |
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
| **Negative**: Same email but different country code on phone | 15 | Suggests spouse / coworker / shared inbox |
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | 20 | Two distinct people with the same name |
### 4.3 Confidence tiers (post-compound)
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
- **score 5089 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
### 4.4 Blocking strategy
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 05 candidates per query, regardless of N.
### 4.5 Performance budget
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
---
## 5. Configurable thresholds (admin settings)
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
| Key | Default | Effect |
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
---
## 6. Merge service contract
### 6.1 Data flow
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
- `interests.clientId`
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
- `clientAddresses.clientId` — same conflict handling
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
- `clientTags.clientId`
- `clientYachtMembership.clientId` (or whatever the table is called)
- `auditLogs.entityId` — annotate, don't move (audit truth)
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
4. **Soft-archive loser**`loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
5. **Write `clientMergeLog`**`{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
### 6.2 Schema additions (migration)
`clients` table gets a new column:
```ts
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
```
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
```sql
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
```
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
### 6.3 Undo
`unmergeClients(mergeLogId, ctx)`:
1. Within the undo window, look up the snapshot
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
3. Restore loser's contacts/addresses/notes/tags from snapshot
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
### 6.4 Concurrency
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
---
## 7. Runtime surfaces
### 7.1 Layer 1 — At-create suggestion
In `ClientForm` (and the public `register` form once that hits the new system):
- Debounced 300ms after email or phone field changes
- Calls `findClientMatches` against current port's clients
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
```
┌─────────────────────────────────────┐
│ This looks like an existing client │
│ ML Marcus Laurent │
│ marcus@… +33 6 12 34 56 78 │
│ 2 interests · last 9d ago │
│ [ Use this client ] [ Create new ] │
└─────────────────────────────────────┘
```
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
### 7.2 Layer 2 — Interest-level same-berth guard
Cheap one-liner in `createInterest` service:
- Check `(clientId, berthId)` against existing non-archived interests
- If hit, throw `BerthDuplicateError` with the existing interest details
- UI catches and prompts: "Update existing or create separate?"
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
### 7.3 Layer 3 — Background scoring + review queue
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
```ts
export const clientMergeCandidates = pgTable('client_merge_candidates', {
id: text('id').primaryKey()...,
portId: text('port_id').notNull()...,
clientAId: text('client_a_id').notNull()...,
clientBId: text('client_b_id').notNull()...,
score: integer('score').notNull(),
reasons: jsonb('reasons').notNull(),
status: text('status').notNull().default('pending'), // pending | dismissed | merged
createdAt: timestamp('created_at')...,
resolvedAt: timestamp('resolved_at'),
resolvedBy: text('resolved_by'),
})
```
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
---
## 8. NocoDB → new system field mapping
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
### 8.1 Top-level transform
```
NocoDB Interests row
─→ 01 client (deduped against existing pool)
─→ 01 client_address
─→ 02 client_contacts (email, phone)
─→ exactly 1 interest
─→ 01 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
─→ 01 document (when documensoID present)
```
### 8.2 Field map
| NocoDB field | Target | Transform |
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `Full Name` | `clients.fullName` | `normalizeName().display` |
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
| `Time LOI Sent` | `interests.dateContractSent` | parse |
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
### 8.3 Sales-stage mapping (8 → 9)
| NocoDB | New (PIPELINE_STAGES) |
| ------------------------------- | ------------------------------------------------------------------------ |
| General Qualified Interest | `open` |
| Specific Qualified Interest | `details_sent` |
| EOI and NDA Sent | `eoi_sent` |
| Signed EOI and NDA | `eoi_signed` |
| Made Reservation | `deposit_10pct` |
| Contract Negotiation | `contract_sent` |
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
### 8.4 Other tables
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
---
## 9. Migration script
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
```
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
Reads the apply log, undoes the writes (only valid within the undo window).
```
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
### 9.1 Dry-run report format
`.migration/<timestamp>/report.csv`:
```csv
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
```
Plus `.migration/<timestamp>/summary.md`:
```
# Migration Dry-Run — 2026-05-03 14:23 UTC
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
Auto-linked (high confidence, no human action needed):
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
- John Lynch: rows 716,725 → 1 client + 2 interests
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
- [12 more]
Flagged for manual review (medium confidence):
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
- [4 more]
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
- Row 239: "Sag Harbor Y" → AI (likely US)
- [6 more]
Phone parsing failed for 3 rows. All flagged, no contact created:
- Row 178: empty
- Row 641: placeholder "+447000000000"
- Row 175: empty
Run `--apply` to commit these changes.
```
### 9.2 Apply phase
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
### 9.3 Idempotency
The script tracks NocoDB row IDs in a `migration_source_links` table:
```ts
export const migrationSourceLinks = pgTable('migration_source_links', {
id: text('id').primaryKey()...,
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
sourceId: text('source_id').notNull(), // NocoDB row id as string
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
targetEntityId: text('target_entity_id').notNull(),
appliedAt: timestamp('applied_at')...,
appliedBy: text('applied_by'),
}, (table) => [
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
]);
```
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
---
## 10. Test plan
### 10.1 Library-level (vitest unit)
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
### 10.2 Service-level (vitest integration)
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
### 10.3 Migration script (vitest integration with NocoDB mock)
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
### 10.4 E2E (Playwright)
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
---
## 11. Rollback plan
Three layers of safety, ordered by reversibility:
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
---
## 12. Open items
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
- **Profile photo / face match** — out of scope.
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
---
## Implementation sequence
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~57 days.
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
Total: ~1012 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.

160
docs/website-refactor.md Normal file
View File

@@ -0,0 +1,160 @@
# Website → CRM wiring refactor
The `website/` subrepo (Nuxt) currently writes inquiry submissions to NocoDB.
The new CRM exposes its own public ingestion endpoints, so the website needs
to be re-pointed at the CRM and the website's local server-side helpers can
eventually be retired.
This document describes **what needs to change in the website repo**. Nothing
here applies to the CRM repo — that side is already done.
## Endpoints the CRM now exposes
Both are unauthenticated, IP-rate-limited (5/hour), and require an explicit
port id (query param `?portId=…` or header `X-Port-Id`).
| Form intent | New CRM endpoint | Old NocoDB target |
| -------------------- | ---------------------------------------- | ------------------------ |
| Berth interest | `POST /api/public/interests` | `Interests` (NocoDB) |
| Residential interest | `POST /api/public/residential-inquiries` | `Interests (Residences)` |
Notification emails (client confirmation + sales-team alert) are sent by the
CRM itself when these endpoints succeed, so the website's
`sendRegistrationEmails` helper (`server/utils/email.ts`) is no longer
required for these flows.
## Required changes in the website repo
### 1. New env vars
Add to `.env` and the deploy environment:
```
PN_CRM_BASE_URL=https://crm.portnimara.com
PN_CRM_PORT_ID=<uuid of the Port Nimara port row in CRM>
```
`PN_CRM_BASE_URL` defaults to the prod CRM. In dev it can point to the local
tunnel (`shoulder-contain-…trycloudflare.com`) so submissions hit a dev DB.
### 2. Refactor `server/api/register.ts`
Today the file owns both the berth and residence branches and writes to
NocoDB directly. After the refactor, both branches just relay to the CRM:
```ts
const baseUrl = process.env.PN_CRM_BASE_URL;
const portId = process.env.PN_CRM_PORT_ID;
if (category === 'Residences') {
await $fetch(`${baseUrl}/api/public/residential-inquiries?portId=${portId}`, {
method: 'POST',
body: {
firstName: body.first_name,
lastName: body.last_name,
email: body.email,
phone: body.phone,
placeOfResidence: body.address,
preferredContactMethod: body.method_of_contact, // 'email' | 'phone'
notes: body.notes,
// preferences: collect via new optional textarea (see section 4)
},
});
return { success: true };
}
// Berth branch
await $fetch(`${baseUrl}/api/public/interests?portId=${portId}`, {
method: 'POST',
body: {
// map to the CRM's publicInterestSchema (see src/lib/validators/interests.ts)
firstName: body.first_name,
lastName: body.last_name,
email: body.email,
phone: body.phone,
address: body.address,
berthSize: body.berth_size,
berthMinLength: body.berth_min_length,
berthMinWidth: body.berth_min_width,
berthMinDraught: body.berth_min_draught,
yachtName: body.berth_yacht_name,
preferredMethodOfContact: body.method_of_contact,
specificBerthMooring: body.berth, // optional, links interest to a specific berth
},
});
return { success: true };
```
The reCAPTCHA verification stays in the website handler — the CRM trusts the
website to gate its public endpoints.
### 3. Retire dead code
After step 2, the following can be deleted from the website:
- `server/utils/websiteInterests.ts`
- `server/utils/residentialInterests.ts`
- `server/utils/nocodb.ts`
- The NocoDB-specific call sites in `server/utils/email.ts` (the CRM
sends its own confirmation/alert emails)
- NocoDB env vars (`NOCODB_*`)
The Nuxt `/api/berths` route stays as-is — it reads from the
`directus_items.berths` collection for the public site, not the CRM.
### 4. Form additions on `pages/register.vue`
The current residence branch only collects contact info. The CRM accepts an
optional `preferences` field (free-text) and `notes` field. Add a
"Preferences" textarea inside the residences block of
`components/pn/specific/website/register/form.vue`:
```vue
<transition name="fade-down">
<div v-show="interest === 'residences'">
<vee-field
as="textarea"
class="form-input py-3 px-0 md:text-lg border-0 border-t border-davysgrey ..."
placeholder="Tell us what you're looking for (unit type, budget, timeline)"
name="residence_preferences"
:disabled="loading"
/>
</div>
</transition>
```
Append `preferences: body.residence_preferences` in the POST body in
`server/api/register.ts`.
### 5. Stand up a residential-only `residences.vue` form (optional)
Today the residences interest is captured on `register.vue` via a radio. If
the marketing team wants a dedicated CTA on `residences.vue`, add a small
inline form using the same submit handler from step 2. No new endpoint —
this is purely a UX addition.
## Deployment order
1. **CRM first**: deploy this repo, ensure `/api/public/interests` and
`/api/public/residential-inquiries` are reachable from the website host.
2. **Verify in CRM**: configure `Inquiry Contact Email` and (for residential)
`Residential Notification Recipients` per port in
admin → settings.
3. **Smoke test from a dev tunnel** (curl the public endpoints with a JSON
payload). Confirm rows land in `clients`/`residential_clients` and
notification emails are received.
4. **Then deploy website changes** (sections 13 above). The form
submissions immediately start landing in the new CRM.
5. **Cut-over note**: once the website is pointed at the CRM, leave the
NocoDB tables read-only as a historical archive. Don't delete them until
prod data has been imported into the new CRM (see "Prod data import
strategy" task #59 in the task list).
## Open questions
- **Port routing for multi-port deploys**: today the website only knows about
Port Nimara. If/when the website serves multiple ports, the `portId`
resolution needs to happen per-domain or per-route, not a single env var.
- **Brand/email domain**: confirm whether residential confirmations should
send from the same `noreply@letsbe.solutions` address as marina, or a
dedicated residential mailbox. The CRM uses `SMTP_FROM`, which is global.

View File

@@ -18,6 +18,12 @@ const nextConfig: NextConfig = {
experimental: { experimental: {
typedRoutes: true, typedRoutes: true,
}, },
outputFileTracingIncludes: {
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
// runtime in the standalone build. Reading via fs.readFile from
// process.cwd() requires the file to be traced explicitly.
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -14,6 +14,10 @@
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "tsx src/lib/db/seed.ts", "db:seed": "tsx src/lib/db/seed.ts",
"test:e2e": "playwright test",
"test:e2e:smoke": "playwright test --project=smoke",
"test:e2e:exhaustive": "playwright test --project=exhaustive",
"test:e2e:destructive": "playwright test --project=destructive",
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
@@ -48,6 +52,7 @@
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-devtools": "^5.62.0", "@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"archiver": "^7.0.1",
"better-auth": "^1.2.0", "better-auth": "^1.2.0",
"bullmq": "^5.25.0", "bullmq": "^5.25.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@@ -57,7 +62,9 @@
"drizzle-orm": "^0.38.0", "drizzle-orm": "^0.38.0",
"imapflow": "^1.2.13", "imapflow": "^1.2.13",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"iso-3166-2": "^1.0.0",
"jose": "^6.2.1", "jose": "^6.2.1",
"libphonenumber-js": "^1.12.42",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"mailparser": "^3.9.4", "mailparser": "^3.9.4",
"minio": "^8.0.0", "minio": "^8.0.0",
@@ -65,6 +72,7 @@
"next-themes": "^0.4.0", "next-themes": "^0.4.0",
"nodemailer": "^6.9.0", "nodemailer": "^6.9.0",
"openai": "^6.27.0", "openai": "^6.27.0",
"pdf-lib": "^1.17.1",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"postgres": "^3.4.0", "postgres": "^3.4.0",
@@ -78,12 +86,16 @@
"sonner": "^1.7.0", "sonner": "^1.7.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tesseract.js": "^7.0.0",
"vaul": "^1.1.2",
"zod": "^3.24.0", "zod": "^3.24.0",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@types/archiver": "^7.0.0",
"@types/iso-3166-2": "^1.0.4",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
@@ -91,9 +103,9 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"esbuild": "^0.25.0",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"drizzle-kit": "^0.30.0", "drizzle-kit": "^0.30.0",
"esbuild": "^0.25.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-next": "15.1.0", "eslint-config-next": "15.1.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
@@ -101,6 +113,7 @@
"lint-staged": "^15.2.0", "lint-staged": "^15.2.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"prettier": "^3.4.0", "prettier": "^3.4.0",
"react-grab": "^0.1.32",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",

View File

@@ -1,7 +1,7 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e/smoke', testDir: './tests/e2e',
fullyParallel: false, fullyParallel: false,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: 0, retries: 0,
@@ -22,17 +22,77 @@ export default defineConfig({
projects: [ projects: [
{ {
name: 'setup', name: 'setup',
testMatch: /global-setup\.ts/, testMatch: /smoke\/global-setup\.ts/,
}, },
{ {
name: 'smoke', name: 'smoke',
testMatch: /\d{2}-.*\.spec\.ts/, testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
dependencies: ['setup'], dependencies: ['setup'],
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 }, viewport: { width: 1440, height: 900 },
}, },
}, },
{
name: 'exhaustive',
testMatch: /exhaustive\/.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'destructive',
testMatch: /destructive\/.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
// Real-API tests hit live external services (Documenso, IMAP, etc.).
// Opt-in only: pnpm exec playwright test --project=realapi
name: 'realapi',
testMatch: /realapi\/.*\.spec\.ts/,
dependencies: ['setup'],
timeout: 120_000,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
// Visual regression baselines. Regenerate with --update-snapshots after
// intentional UI changes; otherwise pnpm exec playwright test --project=visual
// diffs against the committed PNGs.
name: 'visual',
testMatch: /visual\/.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
// Mobile / tablet audit — visits every page in headed Chromium at iPhone
// viewports (portrait), screenshots full-page to .audit/mobile/<viewport>/,
// and writes an index.md. Depends on `setup` for seeded admin + port-role.
name: 'mobile-audit',
testMatch: /audit\/mobile\.spec\.ts/,
dependencies: ['setup'],
// Single test walks 4 viewports × ~45 routes sequentially with slowMo;
// 30 min headroom keeps us well under the wall-clock cost.
timeout: 1_800_000,
use: {
headless: false,
launchOptions: { slowMo: 200 },
screenshot: 'off',
video: 'off',
trace: 'off',
},
},
], ],
// Don't start the dev server — we expect it to already be running // Don't start the dev server — we expect it to already be running

799
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,188 @@
/**
* Permission-matrix audit.
*
* Walks every src/app/api/v1/** /route.ts file and reports each exported HTTP
* handler (GET/POST/PUT/PATCH/DELETE) that is *not* wrapped in withPermission().
* Internal v1 routes should be permission-gated; routes that intentionally use
* withAuth() alone (e.g. user-self endpoints) can be allow-listed below.
*
* Run:
* pnpm tsx scripts/audit-permissions.ts
*
* Exit code:
* 0 — every handler is permission-gated or in the allow-list
* 1 — at least one handler is missing both a withPermission wrapper and an
* allow-list entry. CI should fail.
*/
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
const ROOT = join(process.cwd(), 'src/app/api/v1');
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
/**
* Routes intentionally exempt from withPermission. Each entry should explain
* why — typically because the route operates on the caller's own resources
* (no port-level permission semantics) or is admin-only and gated by
* isSuperAdmin inside the handler.
*/
const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
// Self / admin / public
{ pattern: /\/me\/route\.ts$/, reason: 'Self-endpoint — auth is sufficient.' },
{ pattern: /\/admin\//, reason: 'Admin-only — gated by isSuperAdmin inside handler.' },
{
pattern: /\/notifications\//,
reason: 'User-scoped notifications — caller is the resource owner.',
},
{ pattern: /\/socket\//, reason: 'Socket auth handshake.' },
{ pattern: /\/health\//, reason: 'Public health check.' },
{ pattern: /\/users\/me\//, reason: 'User-self preferences — caller is the resource owner.' },
{ pattern: /\/saved-views\//, reason: 'User-self saved views — caller is the resource owner.' },
{
pattern: /\/settings\/feature-flag\//,
reason: 'Public read of feature-flag bool — no PII; auth is sufficient.',
},
// Cross-cutting / port-scoped reference data
{ pattern: /\/tags\//, reason: 'Tags are cross-cutting reference data; port-scoped via auth.' },
{
pattern: /\/currency\/(convert|rates)\/route\.ts$/,
reason: 'Currency reference data; port-scoped, no PII.',
},
{
pattern: /\/currency\/rates\/refresh\//,
reason: 'TODO: gate with admin:manage_settings — currently allow-listed.',
},
{
pattern: /\/search\//,
reason: 'Port-scoped search — results filtered by auth context (resources have own perms).',
},
// Alerts surface in topbar/dashboard for every signed-in user; per-port not per-resource.
{ pattern: /\/alerts\//, reason: 'Alerts are user-scoped; port-filtered via auth context.' },
// Internally gated by isSuperAdmin
{
pattern: /\/expenses\/export\/parent-company\//,
reason: 'Internally gated by isSuperAdmin inside the handler.',
},
// Pending dedicated permissions
{
pattern: /\/ai\//,
reason: 'TODO: needs ai:* permission catalog entry. Currently allow-listed.',
},
{
pattern: /\/custom-fields\/\[entityId\]\//,
reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.',
},
{
pattern: /\/berth-reservations\/\[id\]\/route\.ts$/,
reason: 'TODO: PATCH should map to reservations:edit (not currently in catalog).',
},
];
interface Finding {
file: string;
method: string;
reason: 'no-withPermission' | 'no-withAuth' | 'allow-listed';
allowReason?: string;
}
async function* walk(dir: string): AsyncGenerator<string> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(path);
else if (entry.isFile() && entry.name === 'route.ts') yield path;
}
}
function isAllowListed(file: string): { allowed: boolean; reason?: string } {
for (const { pattern, reason } of ALLOW_LIST) {
if (pattern.test(file)) return { allowed: true, reason };
}
return { allowed: false };
}
async function auditFile(file: string): Promise<Finding[]> {
const src = await readFile(file, 'utf-8');
const findings: Finding[] = [];
for (const method of HTTP_METHODS) {
// Match: export const GET = withAuth(...
const declRe = new RegExp(`export\\s+const\\s+${method}\\s*=\\s*(.+?);`, 's');
const m = declRe.exec(src);
if (!m) continue;
const block = m[1] ?? '';
const hasAuth = /withAuth\s*\(/.test(block);
const hasPerm = /withPermission\s*\(/.test(block);
const allow = isAllowListed(file);
if (!hasAuth) {
findings.push({ file, method, reason: 'no-withAuth' });
continue;
}
if (!hasPerm) {
if (allow.allowed) {
findings.push({ file, method, reason: 'allow-listed', allowReason: allow.reason });
} else {
findings.push({ file, method, reason: 'no-withPermission' });
}
}
}
return findings;
}
async function main() {
const files: string[] = [];
for await (const f of walk(ROOT)) files.push(f);
files.sort();
const all: Finding[] = [];
for (const f of files) all.push(...(await auditFile(f)));
const violations = all.filter(
(f) => f.reason === 'no-withPermission' || f.reason === 'no-withAuth',
);
const allowListed = all.filter((f) => f.reason === 'allow-listed');
// Markdown report
const lines: string[] = [];
lines.push('# Permission Matrix Audit');
lines.push('');
lines.push(`Scanned ${files.length} route files under \`src/app/api/v1/\`.`);
lines.push('');
if (violations.length === 0) {
lines.push('**No violations.** Every internal v1 handler is permission-gated.');
} else {
lines.push(`**${violations.length} violation(s):**`);
lines.push('');
lines.push('| File | Method | Issue |');
lines.push('| --- | --- | --- |');
for (const v of violations) {
const rel = relative(process.cwd(), v.file);
lines.push(`| \`${rel}\` | ${v.method} | ${v.reason} |`);
}
}
lines.push('');
lines.push(
`**Allow-listed:** ${allowListed.length} handler(s) intentionally skip \`withPermission\`.`,
);
if (allowListed.length > 0) {
lines.push('');
lines.push('| File | Method | Reason |');
lines.push('| --- | --- | --- |');
for (const a of allowListed) {
const rel = relative(process.cwd(), a.file);
lines.push(`| \`${rel}\` | ${a.method} | ${a.allowReason} |`);
}
}
process.stdout.write(lines.join('\n') + '\n');
process.exit(violations.length > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(2);
});

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Hourly MinIO mirror for Port Nimara CRM.
#
# Mirrors the live `MINIO_BUCKET` to the backup destination. `mc mirror`
# is incremental — only changed objects transfer — so this is cheap.
#
# Versioning on the destination bucket is what protects against object
# deletes / overwrites; we don't try to roll our own.
set -euo pipefail
: "${MINIO_ENDPOINT:?MINIO_ENDPOINT not set}"
: "${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY not set}"
: "${MINIO_SECRET_KEY:?MINIO_SECRET_KEY not set}"
: "${MINIO_BUCKET:?MINIO_BUCKET not set}"
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
# Default scheme: live MinIO is plain HTTP unless MINIO_USE_SSL=true.
LIVE_URL="${MINIO_ENDPOINT}"
if [[ "${MINIO_USE_SSL:-false}" == "true" ]]; then
LIVE_URL="https://${MINIO_ENDPOINT}:${MINIO_PORT:-443}"
else
LIVE_URL="http://${MINIO_ENDPOINT}:${MINIO_PORT:-9000}"
fi
LIVE_ALIAS="live-$$"
BACKUP_ALIAS="bk-$$"
trap 'mc alias remove "$LIVE_ALIAS" 2>/dev/null || true; mc alias remove "$BACKUP_ALIAS" 2>/dev/null || true' EXIT
mc alias set "$LIVE_ALIAS" "$LIVE_URL" \
"$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY" --api S3v4 >/dev/null
mc alias set "$BACKUP_ALIAS" "$BACKUP_S3_ENDPOINT" \
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" --api S3v4 >/dev/null
SOURCE="${LIVE_ALIAS}/${MINIO_BUCKET}/"
DEST="${BACKUP_ALIAS}/${BACKUP_S3_BUCKET}/minio/"
echo "[$(date -u +%FT%TZ)] Mirroring $SOURCE$DEST"
# `--remove` would delete objects from the destination that no longer
# exist in source — we DON'T pass it, because that would let an
# accidental delete on the live bucket cascade into permanent loss on
# the backup side. Versioning + lifecycle handle stale-object cleanup.
mc mirror --quiet --overwrite "$SOURCE" "$DEST"
# Print byte / count diff for the operator.
echo "[$(date -u +%FT%TZ)] Done. Destination summary:"
mc du "$DEST"

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Hourly PostgreSQL backup for Port Nimara CRM.
#
# Reads DATABASE_URL and BACKUP_S3_* from the environment. Dumps to a
# tmpfile, gzips, optionally GPG-encrypts to BACKUP_GPG_RECIPIENT, and
# uploads to s3://${BACKUP_S3_BUCKET}/pg/<hostname>/<UTC-date>/<hour>.dump.gz[.gpg].
#
# Designed to fail loud: any non-zero exit halts the script and propagates
# to the cron / CI runner so the operator sees the failure.
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL not set}"
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
HOST="${BACKUP_HOST_OVERRIDE:-$(hostname -s)}"
DATE_UTC="$(date -u +%Y-%m-%d)"
HOUR_UTC="$(date -u +%H)"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
DUMP_FILE="$WORKDIR/${HOUR_UTC}.dump"
ARCHIVE_NAME="${HOUR_UTC}.dump.gz"
echo "[$(date -u +%FT%TZ)] Dumping $DATABASE_URL$DUMP_FILE"
pg_dump --format=custom --compress=9 --no-owner --no-privileges \
--file="$DUMP_FILE" "$DATABASE_URL"
# pg_dump's `custom` format is already compressed, but we wrap in gzip so
# the file looks the same regardless of the dump format on disk.
gzip -n "$DUMP_FILE"
GZ_FILE="${DUMP_FILE}.gz"
# Optional GPG layer. Only encrypt if the recipient is configured.
if [[ -n "${BACKUP_GPG_RECIPIENT:-}" ]]; then
echo "[$(date -u +%FT%TZ)] Encrypting for $BACKUP_GPG_RECIPIENT"
gpg --batch --yes --trust-model always \
--recipient "$BACKUP_GPG_RECIPIENT" \
--encrypt --output "${GZ_FILE}.gpg" "$GZ_FILE"
rm "$GZ_FILE"
GZ_FILE="${GZ_FILE}.gpg"
ARCHIVE_NAME="${ARCHIVE_NAME}.gpg"
fi
# Configure mc client for the backup destination.
MC_ALIAS="bk-$$"
mc alias set "$MC_ALIAS" "$BACKUP_S3_ENDPOINT" \
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" \
--api S3v4 >/dev/null
REMOTE_PATH="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${DATE_UTC}/${ARCHIVE_NAME}"
echo "[$(date -u +%FT%TZ)] Uploading → $REMOTE_PATH"
mc cp --quiet "$GZ_FILE" "$REMOTE_PATH"
# Tag with retention metadata so lifecycle rules can decide what to expire.
mc tag set "$REMOTE_PATH" "kind=hourly&host=${HOST}&date=${DATE_UTC}" >/dev/null
mc alias remove "$MC_ALIAS" >/dev/null
echo "[$(date -u +%FT%TZ)] OK ${ARCHIVE_NAME} ($(du -h "$GZ_FILE" | cut -f1))"

121
scripts/backup/restore.sh Normal file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Cold-restore script for Port Nimara CRM.
#
# Two modes:
# --drill Restore to a sandbox DB ($DRILL_DATABASE_URL) + a tagged
# sandbox path on the live MinIO bucket. Used by the weekly
# cron drill so the runbook stays accurate.
# (no --drill) Interactive production restore. Prompts before each
# destructive step; refuses to run if the live DB has
# non-empty tables (caller is expected to drop first).
#
# Common args:
# --snapshot YYYY-MM-DD/HH Specific dump to restore. Defaults to "latest".
set -euo pipefail
DRILL=0
SNAPSHOT="latest"
while [[ $# -gt 0 ]]; do
case "$1" in
--drill) DRILL=1; shift ;;
--snapshot) SNAPSHOT="$2"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
if [[ "$DRILL" -eq 1 ]]; then
: "${DRILL_DATABASE_URL:?DRILL_DATABASE_URL not set}"
TARGET_DB="$DRILL_DATABASE_URL"
echo "[drill] target DB = $TARGET_DB"
else
: "${DATABASE_URL:?DATABASE_URL not set}"
TARGET_DB="$DATABASE_URL"
read -rp "About to overwrite $TARGET_DB. Type 'restore' to continue: " confirm
[[ "$confirm" == "restore" ]] || { echo "aborted"; exit 1; }
fi
HOST="${BACKUP_HOST_OVERRIDE:-$(hostname -s)}"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
MC_ALIAS="bk-$$"
mc alias set "$MC_ALIAS" "$BACKUP_S3_ENDPOINT" \
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" --api S3v4 >/dev/null
trap 'rm -rf "$WORKDIR"; mc alias remove "$MC_ALIAS" 2>/dev/null || true' EXIT
# Resolve the snapshot path.
if [[ "$SNAPSHOT" == "latest" ]]; then
REMOTE=$(mc ls --recursive "${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/" \
| awk '{print $NF}' | sort | tail -1)
if [[ -z "$REMOTE" ]]; then
echo "no snapshots found under ${BACKUP_S3_BUCKET}/pg/${HOST}/" >&2
exit 1
fi
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${REMOTE}"
else
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${SNAPSHOT}.dump.gz"
# If GPG was used, the file lives at .dump.gz.gpg. Try both.
if ! mc stat "$REMOTE" >/dev/null 2>&1; then
REMOTE="${REMOTE}.gpg"
fi
fi
echo "[$(date -u +%FT%TZ)] Pulling $REMOTE"
LOCAL="$WORKDIR/$(basename "$REMOTE")"
mc cp --quiet "$REMOTE" "$LOCAL"
# Decrypt if needed.
if [[ "$LOCAL" == *.gpg ]]; then
echo "[$(date -u +%FT%TZ)] Decrypting"
gpg --batch --yes --decrypt --output "${LOCAL%.gpg}" "$LOCAL"
rm "$LOCAL"
LOCAL="${LOCAL%.gpg}"
fi
# Decompress.
gunzip "$LOCAL"
LOCAL="${LOCAL%.gz}"
echo "[$(date -u +%FT%TZ)] Restoring into $TARGET_DB"
# Drop & recreate to guarantee no half-state from a prior run.
DB_NAME=$(echo "$TARGET_DB" | sed -E 's|.*/([^?]+).*|\1|')
ADMIN_URL=$(echo "$TARGET_DB" | sed -E "s|/${DB_NAME}|/postgres|")
psql "$ADMIN_URL" -v ON_ERROR_STOP=1 <<SQL
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS "${DB_NAME}";
CREATE DATABASE "${DB_NAME}";
SQL
pg_restore --no-owner --no-privileges --dbname "$TARGET_DB" "$LOCAL"
# Drill mode: compare row counts vs the live producer for parity.
if [[ "$DRILL" -eq 1 ]]; then
echo "[$(date -u +%FT%TZ)] Drill row-count diff (live vs restored):"
TABLES=$(psql -At "$TARGET_DB" -c \
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;")
diff_count=0
while IFS= read -r tbl; do
[[ -z "$tbl" ]] && continue
live=$(psql -At "${LIVE_DATABASE_URL:-$DATABASE_URL}" -c "SELECT count(*) FROM \"$tbl\";")
restored=$(psql -At "$TARGET_DB" -c "SELECT count(*) FROM \"$tbl\";")
delta=$((live - restored))
if [[ "$delta" -ne 0 ]]; then
echo "$tbl: live=$live restored=$restored delta=$delta"
diff_count=$((diff_count + 1))
fi
done <<< "$TABLES"
if [[ "$diff_count" -eq 0 ]]; then
echo " ✓ row counts match across all tables"
fi
fi
echo "[$(date -u +%FT%TZ)] Restore complete."

View File

@@ -0,0 +1,102 @@
/**
* Dev-only helper: create (or upsert) a CRM better-auth user and mark them
* super_admin. Idempotent — re-running with the same email will reset the
* password.
*
* Run: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]
*/
import 'dotenv/config';
import postgres from 'postgres';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema/users';
import { env } from '@/lib/env';
import { eq } from 'drizzle-orm';
async function main() {
const [email, password, displayNameArg] = process.argv.slice(2);
if (!email || !password) {
console.error(
'Usage: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]',
);
process.exit(1);
}
const displayName = displayNameArg ?? email.split('@')[0] ?? 'User';
const sql = postgres(env.DATABASE_URL);
try {
// 1. Check if better-auth user already exists.
const existing = await sql<{ id: string }[]>`
SELECT id FROM "user" WHERE email = ${email} LIMIT 1
`;
let userId: string;
if (existing.length > 0) {
const row = existing[0];
if (!row) throw new Error('unreachable');
userId = row.id;
console.log(`User ${email} exists (id=${userId}); resetting password.`);
// Use better-auth's internal context to hash and update the credential.
const ctx = await auth.$context;
const hash = await ctx.password.hash(password);
await sql`
UPDATE account
SET password = ${hash}, updated_at = NOW()
WHERE user_id = ${userId} AND provider_id = 'credential'
`;
} else {
console.log(`Creating better-auth user ${email}`);
const result = await auth.api.signUpEmail({
body: { email, password, name: displayName },
});
userId = result.user.id;
console.log(`Created user_id=${userId}`);
}
// 2. Upsert user_profiles entry as super admin.
const profile = await db
.select()
.from(userProfiles)
.where(eq(userProfiles.userId, userId))
.limit(1);
if (profile.length === 0) {
await db.insert(userProfiles).values({
id: crypto.randomUUID(),
userId,
displayName,
avatarUrl: null,
phone: null,
isSuperAdmin: true,
isActive: true,
lastLoginAt: null,
preferences: {},
});
console.log(`Created super_admin profile for ${userId}`);
} else {
await db
.update(userProfiles)
.set({ displayName, isSuperAdmin: true, isActive: true })
.where(eq(userProfiles.userId, userId));
console.log(`Updated profile for ${userId} (super_admin=true)`);
}
console.log('');
console.log(`✓ Done. Sign in at http://localhost:3000/login with`);
console.log(` email: ${email}`);
console.log(` password: ${password}`);
} finally {
await sql.end();
process.exit(0);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

66
scripts/dev-imap-probe.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Dev diagnostic: connect to IMAP and print the most recent ~10 messages,
* showing TO/FROM/subject/date so we can see what the dev mailbox is
* actually receiving.
*
* Run: pnpm tsx scripts/dev-imap-probe.ts
*/
import 'dotenv/config';
import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
async function main(): Promise<void> {
const host = process.env.IMAP_HOST!;
const port = Number(process.env.IMAP_PORT ?? 993);
const user = process.env.IMAP_USER!;
const pass = process.env.IMAP_PASS!;
if (!host || !user || !pass) {
throw new Error('IMAP_HOST / IMAP_USER / IMAP_PASS not set');
}
console.log(`Connecting to ${user}@${host}:${port}`);
const client = new ImapFlow({
host,
port,
secure: port === 993,
auth: { user, pass },
logger: false,
});
await client.connect();
console.log('Connected. Inbox status:');
const lock = await client.getMailboxLock('INBOX');
try {
const status = await client.status('INBOX', { messages: true, recent: true });
console.log(' total:', status.messages, '| recent:', status.recent);
// Pull the last 10 by UID
const since = new Date(Date.now() - 30 * 60 * 1000); // last 30 min
const result = await client.search({ since });
const uids = Array.isArray(result) ? result.slice(-10).reverse() : [];
console.log(`Found ${uids.length} messages in last 30min:`);
for (const uid of uids) {
const msg = await client.fetchOne(String(uid), { source: true, envelope: true });
if (!msg || !msg.source) continue;
const parsed = await simpleParser(msg.source);
const tos = (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : [])
.flatMap((a) => a.value.map((v) => v.address ?? ''))
.join(', ');
console.log(
` uid=${uid} date=${parsed.date?.toISOString()} from=${parsed.from?.text} to=${tos} subject=${parsed.subject}`,
);
}
} finally {
lock.release();
}
await client.logout();
console.log('Done.');
process.exit(0);
}
main().catch((err) => {
console.error('Probe failed:', err);
process.exit(1);
});

25
scripts/dev-list-users.ts Normal file
View File

@@ -0,0 +1,25 @@
import 'dotenv/config';
import postgres from 'postgres';
import { env } from '@/lib/env';
async function main() {
const sql = postgres(env.DATABASE_URL);
const users =
await sql`SELECT id, email, name, email_verified, created_at FROM "user" ORDER BY created_at DESC LIMIT 20`;
console.log('--- user ---');
console.log(JSON.stringify(users, null, 2));
const profiles =
await sql`SELECT user_id, display_name, is_super_admin, is_active FROM user_profiles ORDER BY created_at DESC LIMIT 20`;
console.log('--- user_profiles ---');
console.log(JSON.stringify(profiles, null, 2));
const accounts =
await sql`SELECT user_id, provider_id, account_id FROM account ORDER BY created_at DESC LIMIT 20`;
console.log('--- account ---');
console.log(JSON.stringify(accounts, null, 2));
await sql.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,40 @@
/**
* Dev helper: set a user's password directly (bypasses email reset).
* Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>
*/
import 'dotenv/config';
import { hashPassword } from 'better-auth/crypto';
import { eq, and } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, account } from '@/lib/db/schema/users';
async function main() {
const [, , email, password] = process.argv;
if (!email || !password) {
console.error('Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>');
process.exit(1);
}
const u = await db.query.user.findFirst({ where: eq(user.email, email) });
if (!u) {
console.error(`User not found: ${email}`);
process.exit(1);
}
const hash = await hashPassword(password);
const result = await db
.update(account)
.set({ password: hash, updatedAt: new Date() })
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
.returning({ id: account.id });
if (result.length === 0) {
console.error(`No credential account row for ${email}`);
process.exit(1);
}
console.log(`Updated password for ${email} (account id ${result[0]?.id}).`);
process.exit(0);
}
main();

View File

@@ -0,0 +1,44 @@
/**
* Dev-only helper: issue a CRM admin invite and send the activation email.
* The email gets routed via EMAIL_REDIRECT_TO if that's set, so it always
* lands in the dev inbox.
*
* Run: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]
*/
import 'dotenv/config';
import { createCrmInvite } from '@/lib/services/crm-invite.service';
async function main() {
const args = process.argv.slice(2);
const email = args[0];
if (!email) {
console.error('Usage: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]');
process.exit(1);
}
const isSuperAdmin = args.includes('--super');
const name = args.find((a, i) => i > 0 && !a.startsWith('--'));
// Dev script runs out-of-band (no HTTP request, no session). The service's
// super-admin gate requires `invitedBy.isSuperAdmin === true` for super
// invites; the script bypasses that with a synthetic caller identity.
const { inviteId, link } = await createCrmInvite({
email,
name,
isSuperAdmin,
invitedBy: { userId: 'cli-script', isSuperAdmin: true },
});
console.log(`✓ Invite created (id=${inviteId})`);
console.log(` email: ${email}`);
console.log(` super_admin: ${isSuperAdmin}`);
console.log(` activation link: ${link}`);
console.log('');
console.log('Email sent (subject permitting via EMAIL_REDIRECT_TO).');
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,59 @@
/**
* Dev-only helper: pick an existing client and trigger a portal-invite email.
* The activation email gets routed to EMAIL_REDIRECT_TO (set in .env) regardless
* of the per-portal-user `email` field — so we can use any throwaway address
* here without conflicting with seed data.
*
* Run: pnpm tsx scripts/dev-trigger-portal-invite.ts
*/
import 'dotenv/config';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { portalUsers } from '@/lib/db/schema/portal';
import { createPortalUser } from '@/lib/services/portal-auth.service';
import { env } from '@/lib/env';
import { eq } from 'drizzle-orm';
async function main(): Promise<void> {
if (!env.EMAIL_REDIRECT_TO) {
throw new Error(
'EMAIL_REDIRECT_TO is not set — refusing to send a real activation email to a real client.',
);
}
console.log(`EMAIL_REDIRECT_TO is set: ${env.EMAIL_REDIRECT_TO}`);
const client = await db.query.clients.findFirst({
where: eq(clients.portId, '294c8240-49a7-403e-92e8-fc3a524c00b4'),
});
if (!client) throw new Error('No client found in port-nimara');
// Use the redirect target as the portal user's actual email, so the
// tester can sign in with the same address that received the activation mail.
const portalEmail = env.EMAIL_REDIRECT_TO;
console.log(
`Creating portal user for client ${client.fullName} (${client.id}) with email ${portalEmail}`,
);
// Clear any prior dev-script seed so uniqueness checks don't trip.
await db.delete(portalUsers).where(eq(portalUsers.clientId, client.id));
await db.delete(portalUsers).where(eq(portalUsers.email, portalEmail));
const result = await createPortalUser({
clientId: client.id,
portId: client.portId,
email: portalEmail,
name: client.fullName,
createdBy: 'dev-script',
});
console.log('Portal user created:', result);
console.log(`Activation email enqueued — should arrive at ${portalEmail}.`);
process.exit(0);
}
main().catch((err) => {
console.error('Script failed:', err);
process.exit(1);
});

View File

@@ -8,14 +8,5 @@ export const metadata: Metadata = {
}; };
export default function AuthLayout({ children }: { children: React.ReactNode }) { export default function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return <>{children}</>;
<div
className="min-h-screen flex items-center justify-center wave-watermark"
style={{ backgroundColor: '#1e2844' }}
>
<div className="w-full max-w-md px-4">
{children}
</div>
</div>
);
} }

View File

@@ -10,9 +10,9 @@ import { toast } from 'sonner';
import { authClient } from '@/lib/auth/client'; import { authClient } from '@/lib/auth/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'), email: z.string().email('Please enter a valid email address'),
@@ -55,18 +55,14 @@ export default function LoginPage() {
} }
return ( return (
<div <BrandedAuthShell>
className="min-h-screen flex items-center justify-center px-4" <div className="text-center mb-6">
style={{ backgroundColor: '#1e2844' }} <h1 className="text-xl font-semibold text-gray-900">Port Nimara CRM</h1>
> <p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
<Card className="w-full max-w-md"> </div>
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Marina CRM</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input
id="email" id="email"
@@ -77,18 +73,13 @@ export default function LoginPage() {
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')} className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
{...register('email')} {...register('email')}
/> />
{errors.email && ( {errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Link <Link href="/reset-password" className="text-xs text-[#007bff] hover:underline">
href="/reset-password"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Forgot password? Forgot password?
</Link> </Link>
</div> </div>
@@ -97,22 +88,20 @@ export default function LoginPage() {
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
disabled={isLoading} disabled={isLoading}
className={cn( className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
errors.password && 'border-destructive focus-visible:ring-destructive',
)}
{...register('password')} {...register('password')}
/> />
{errors.password && ( {errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading}> <Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Signing in…' : 'Sign in'} {isLoading ? 'Signing in…' : 'Sign in'}
</Button> </Button>
</form> </form>
</CardContent> </BrandedAuthShell>
</Card>
</div>
); );
} }

View File

@@ -7,9 +7,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const resetSchema = z.object({ const resetSchema = z.object({
@@ -49,35 +49,26 @@ export default function ResetPasswordPage() {
} }
return ( return (
<div <BrandedAuthShell>
className="min-h-screen flex items-center justify-center px-4" <div className="text-center mb-6">
style={{ backgroundColor: '#1e2844' }} <h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
> <p className="text-sm text-gray-500 mt-1">We&apos;ll email you a link</p>
<Card className="w-full max-w-md"> </div>
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Reset your password</p>
</CardHeader>
<CardContent>
{submitted ? ( {submitted ? (
<div className="space-y-4 text-center"> <div className="space-y-4 text-center">
<div className="space-y-2"> <p className="font-medium text-gray-900">Check your email</p>
<p className="font-medium text-foreground">Check your email</p> <p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground"> If an account exists for that email address, we have sent a password reset link. Please
If an account exists for that email address, we have sent a password reset link. check your inbox and spam folder.
Please check your inbox and spam folder.
</p> </p>
</div> <Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
<Link
href="/login"
className="inline-block text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Back to sign in Back to sign in
</Link> </Link>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input
id="email" id="email"
@@ -85,33 +76,28 @@ export default function ResetPasswordPage() {
autoComplete="email" autoComplete="email"
placeholder="you@example.com" placeholder="you@example.com"
disabled={isLoading} disabled={isLoading}
className={cn( className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
errors.email && 'border-destructive focus-visible:ring-destructive',
)}
{...register('email')} {...register('email')}
/> />
{errors.email && ( {errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading}> <Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Sending…' : 'Send reset link'} {isLoading ? 'Sending…' : 'Send reset link'}
</Button> </Button>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-gray-500">
Remember your password?{' '} Remember your password?{' '}
<Link <Link href="/login" className="text-[#007bff] hover:underline">
href="/login"
className="text-foreground underline-offset-4 hover:underline"
>
Sign in Sign in
</Link> </Link>
</p> </p>
</form> </form>
)} )}
</CardContent> </BrandedAuthShell>
</Card>
</div>
); );
} }

View File

@@ -1,27 +1,23 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CheckCircle2, Circle } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
const MIN_LENGTH = 9;
const passwordSchema = z const passwordSchema = z
.object({ .object({
password: z password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`),
.string()
.min(12, 'Must be at least 12 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[a-z]/, 'Must contain a lowercase letter')
.regex(/[0-9]/, 'Must contain a number')
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
confirmPassword: z.string().min(1, 'Please confirm your password'), confirmPassword: z.string().min(1, 'Please confirm your password'),
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
@@ -31,25 +27,11 @@ const passwordSchema = z
type SetPasswordFormData = z.infer<typeof passwordSchema>; type SetPasswordFormData = z.infer<typeof passwordSchema>;
type Requirement = { function SetPasswordInner() {
label: string;
test: (value: string) => boolean;
};
const requirements: Requirement[] = [
{ label: 'At least 12 characters', test: (v) => v.length >= 12 },
{ label: 'Uppercase letter', test: (v) => /[A-Z]/.test(v) },
{ label: 'Lowercase letter', test: (v) => /[a-z]/.test(v) },
{ label: 'Number', test: (v) => /[0-9]/.test(v) },
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
];
export default function SetPasswordPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [passwordValue, setPasswordValue] = useState('');
const { const {
register, register,
@@ -61,7 +43,7 @@ export default function SetPasswordPage() {
async function onSubmit(data: SetPasswordFormData) { async function onSubmit(data: SetPasswordFormData) {
if (!token) { if (!token) {
toast.error('Invalid or missing reset token. Please request a new password reset link.'); toast.error('Invalid or missing reset token. Please request a new link.');
return; return;
} }
@@ -75,7 +57,7 @@ export default function SetPasswordPage() {
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
toast.error(body.message ?? 'Failed to set password. Please try again.'); toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.');
return; return;
} }
@@ -88,74 +70,54 @@ export default function SetPasswordPage() {
} }
} }
if (!token) {
return ( return (
<div <BrandedAuthShell>
className="min-h-screen flex items-center justify-center px-4" <div className="text-center space-y-3">
style={{ backgroundColor: '#1e2844' }} <h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
> <p className="text-sm text-gray-500">
<Card className="w-full max-w-md"> Please use the link from the email we sent you. If the link is broken, ask your
<CardHeader className="space-y-1 text-center pb-6"> administrator for a new one.
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Set your password</p>
</CardHeader>
<CardContent>
{!token ? (
<p className="text-center text-sm text-destructive">
Invalid or missing token. Please request a new password reset link.
</p> </p>
) : ( <Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
Back to sign in
</Link>
</div>
</BrandedAuthShell>
);
}
return (
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Set your password</h1>
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="password">New Password</Label> <Label htmlFor="password">New password</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
className={cn( className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
errors.password && 'border-destructive focus-visible:ring-destructive', {...register('password')}
)}
{...register('password', {
onChange: (e) => setPasswordValue(e.target.value),
})}
/> />
{errors.password && ( <p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
<p className="text-sm text-destructive">{errors.password.message}</p> {errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
)}
<ul className="space-y-1 pt-1">
{requirements.map((req) => {
const met = req.test(passwordValue);
return (
<li
key={req.label}
className={cn(
'flex items-center gap-2 text-xs',
met ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground',
)}
>
{met ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
) : (
<Circle className="h-3.5 w-3.5 shrink-0" />
)}
{req.label}
</li>
);
})}
</ul>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="confirmPassword">Confirm Password</Label> <Label htmlFor="confirmPassword">Confirm password</Label>
<Input <Input
id="confirmPassword" id="confirmPassword"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
className={cn( className={cn(
errors.confirmPassword && errors.confirmPassword && 'border-destructive focus-visible:ring-destructive',
'border-destructive focus-visible:ring-destructive',
)} )}
{...register('confirmPassword')} {...register('confirmPassword')}
/> />
@@ -164,13 +126,22 @@ export default function SetPasswordPage() {
)} )}
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading}> <Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Setting password…' : 'Set password'} {isLoading ? 'Setting password…' : 'Set password'}
</Button> </Button>
</form> </form>
)} </BrandedAuthShell>
</CardContent> );
</Card> }
</div>
export default function SetPasswordPage() {
return (
<Suspense fallback={<BrandedAuthShell>{null}</BrandedAuthShell>}>
<SetPasswordInner />
</Suspense>
); );
} }

View File

@@ -1,10 +1,9 @@
import { PageHeader } from '@/components/shared/page-header';
export default function BackupManagementPage() { export default function BackupManagementPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader title="Backup Management" description="Manage system backups and restoration" />
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
<p className="text-muted-foreground">Manage system backups and restoration</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p> <p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,67 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
const FIELDS: SettingFieldDef[] = [
{
key: 'branding_app_name',
label: 'App name',
description: 'Shown in the email subject prefix and the in-app header.',
type: 'string',
placeholder: 'Port Nimara CRM',
defaultValue: '',
},
{
key: 'branding_logo_url',
label: 'Logo URL',
description:
'Public HTTPS URL of the logo used in email headers and the branded auth shell. Recommended size: 240×80 PNG with transparent background.',
type: 'string',
placeholder: 'https://example.com/logo.png',
defaultValue: '',
},
{
key: 'branding_primary_color',
label: 'Primary color',
description: 'Used for buttons and links in transactional email templates.',
type: 'color',
defaultValue: '#1e293b',
},
{
key: 'branding_email_header_html',
label: 'Email header HTML',
description: 'Optional HTML rendered above each email body. Leave blank to use the default.',
type: 'html',
defaultValue: '',
},
{
key: 'branding_email_footer_html',
label: 'Email footer HTML',
description: 'Optional HTML rendered at the very bottom of each email (above the signature).',
type: 'html',
defaultValue: '',
},
];
export default function BrandingSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Branding"
description="Logo, primary color, app name, and email header/footer HTML used by the branded auth shell and outgoing email templates."
/>
<SettingsFormCard
title="Identity"
description="App name, logo, and primary color."
fields={FIELDS.slice(0, 3)}
/>
<SettingsFormCard
title="Email branding"
description="HTML fragments rendered around every transactional email."
fields={FIELDS.slice(3)}
/>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
import { PageHeader } from '@/components/shared/page-header';
const API_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_api_url_override',
label: 'API URL override',
description: 'Optional. Falls back to DOCUMENSO_API_URL env when blank.',
type: 'string',
placeholder: 'https://documenso.example.com',
defaultValue: '',
},
{
key: 'documenso_api_key_override',
label: 'API key override',
description: 'Optional. Falls back to DOCUMENSO_API_KEY env when blank. Stored in plain text.',
type: 'password',
defaultValue: '',
},
];
const EOI_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_eoi_template_id',
label: 'EOI Documenso template ID',
description: 'Numeric template ID used by the Documenso EOI pathway.',
type: 'string',
placeholder: '12345',
defaultValue: '',
},
{
key: 'eoi_default_pathway',
label: 'Default EOI pathway',
description:
'Which pathway is used when an EOI is generated without an explicit choice. Documenso = signed via Documenso, In-app = filled locally with pdf-lib.',
type: 'select',
options: [
{ value: 'documenso-template', label: 'Documenso template' },
{ value: 'inapp', label: 'In-app (pdf-lib)' },
],
defaultValue: 'documenso-template',
},
];
export default function DocumensoSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Documenso & EOI"
description="API credentials and default EOI generation pathway. Use the test-connection button to verify a saved configuration before relying on it."
/>
<SettingsFormCard
title="Documenso API"
description="Per-port API credentials. Leave blank to use the global env defaults."
fields={API_FIELDS}
extra={<DocumensoTestButton />}
/>
<SettingsFormCard
title="EOI generation"
description="Default pathway and template used when an interest's EOI is generated."
fields={EOI_FIELDS}
/>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
const FIELDS: SettingFieldDef[] = [
{
key: 'email_from_name',
label: 'From name',
description: 'Display name shown in the From: header on outgoing email.',
type: 'string',
placeholder: 'Port Nimara',
defaultValue: '',
},
{
key: 'email_from_address',
label: 'From address',
description: 'Sender email address. Falls back to SMTP_FROM env when blank.',
type: 'string',
placeholder: 'noreply@example.com',
defaultValue: '',
},
{
key: 'email_reply_to',
label: 'Reply-to address',
description: 'Optional Reply-To: header for replies (e.g. sales@example.com).',
type: 'string',
placeholder: 'sales@example.com',
defaultValue: '',
},
{
key: 'email_signature_html',
label: 'Default signature (HTML)',
description: 'Appended to the bottom of system-generated emails.',
type: 'html',
placeholder: '<p>—<br>The Port Nimara team</p>',
defaultValue: '',
},
{
key: 'email_footer_html',
label: 'Email footer (HTML)',
description: 'Legal/contact footer rendered at the very bottom of all emails.',
type: 'html',
placeholder: '<p style="font-size:11px;color:#888;">© Port Nimara · ul. ...</p>',
defaultValue: '',
},
{
key: 'smtp_host_override',
label: 'SMTP host override',
description: 'Optional. Falls back to SMTP_HOST env when blank.',
type: 'string',
placeholder: 'mail.example.com',
defaultValue: '',
},
{
key: 'smtp_port_override',
label: 'SMTP port override',
description: 'Optional. Falls back to SMTP_PORT env when blank.',
type: 'number',
placeholder: '587',
defaultValue: null,
},
{
key: 'smtp_user_override',
label: 'SMTP username override',
description: 'Optional. Falls back to SMTP_USER env when blank.',
type: 'string',
defaultValue: '',
},
{
key: 'smtp_pass_override',
label: 'SMTP password override',
description: 'Optional. Stored in plain text — only set when overriding env credentials.',
type: 'password',
defaultValue: '',
},
];
export default function EmailSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Email Settings"
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank."
/>
<SettingsFormCard
title="From address & signature"
description="Identity headers and shared HTML used by system-generated emails."
fields={FIELDS.slice(0, 5)}
/>
<SettingsFormCard
title="SMTP transport overrides"
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
fields={FIELDS.slice(5)}
/>
</div>
);
}

View File

@@ -1,16 +1,5 @@
import { FormTemplateList } from '@/components/admin/forms/form-template-list';
export default function FormTemplatesPage() { export default function FormTemplatesPage() {
return ( return <FormTemplateList />;
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Form Templates</h1>
<p className="text-muted-foreground">Create and manage intake form templates</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
} }

View File

@@ -1,10 +1,9 @@
import { PageHeader } from '@/components/shared/page-header';
export default function DataImportPage() { export default function DataImportPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader title="Data Import" description="Import data from external sources" />
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
<p className="text-muted-foreground">Import data from external sources</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p> <p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,14 @@
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
import { PageHeader } from '@/components/shared/page-header';
export default function InvitationsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Invitations"
description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
/>
<InvitationsManager />
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema/users';
/**
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
*/
export default async function AdminLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect('/login');
}
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
if (!profile?.isSuperAdmin) {
redirect(`/${portSlug}/dashboard`);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,5 @@
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
export default function OcrSettingsPage() {
return <OcrSettingsForm />;
}

View File

@@ -1,10 +1,9 @@
import { PageHeader } from '@/components/shared/page-header';
export default function OnboardingPage() { export default function OnboardingPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader title="Onboarding" description="Guided setup for new port configurations" />
<h1 className="text-2xl font-bold text-foreground">Onboarding</h1>
<p className="text-muted-foreground">Guided setup for new port configurations</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p> <p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,200 @@
import Link from 'next/link';
import {
Bell,
Briefcase,
Database,
FileText,
HardDrive,
Key,
LayoutDashboard,
Mail,
Palette,
ScrollText,
Settings,
Shield,
Sliders,
Tag,
Upload,
Users,
Webhook,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/shared/page-header';
interface AdminSection {
href: string;
label: string;
description: string;
icon: typeof Settings;
}
const SECTIONS: AdminSection[] = [
{
href: 'users',
label: 'Users',
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
icon: Users,
},
{
href: 'invitations',
label: 'Invitations',
description: 'Send invitations, track pending invites, and resend or revoke them.',
icon: Mail,
},
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
},
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
{
href: 'email',
label: 'Email Settings',
description: 'From address, signatures, and per-port SMTP overrides.',
icon: Mail,
},
{
href: 'documenso',
label: 'Documenso & EOI',
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
icon: FileText,
},
{
href: 'reminders',
label: 'Reminders',
description: 'Default reminder behaviour and the daily-digest delivery window.',
icon: Bell,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Palette,
},
{
href: 'settings',
label: 'System Settings',
description: 'Generic key/value configuration store for advanced flags.',
icon: Settings,
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
{
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: Sliders,
},
{
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key,
},
{
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: LayoutDashboard,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Database snapshots and on-demand exports.',
icon: HardDrive,
},
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Briefcase,
},
{
href: 'onboarding',
label: 'Onboarding',
description: 'Initial-setup wizard for fresh ports.',
icon: LayoutDashboard,
},
{
href: 'ocr',
label: 'Receipt OCR',
description: 'Configure the AI provider used by the mobile receipt scanner.',
icon: ScrollText,
},
];
export default async function AdminLandingPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return (
<div className="space-y-6">
<PageHeader
title="Administration"
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{SECTIONS.map((s) => {
const Icon = s.icon;
return (
<Link
key={s.href}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/admin/${s.href}` as any}
className="block group"
>
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
<div className="flex-1">
<CardTitle className="text-base">{s.label}</CardTitle>
</div>
</CardHeader>
<CardContent>
<CardDescription>{s.description}</CardDescription>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
const DEFAULT_FIELDS: SettingFieldDef[] = [
{
key: 'reminder_default_enabled',
label: 'Enable reminders by default on new interests',
description:
'When on, newly-created interests inherit reminderEnabled=true. Users can still toggle it on a per-interest basis.',
type: 'boolean',
defaultValue: false,
},
{
key: 'reminder_default_days',
label: 'Default inactivity days',
description:
"Default value for an interest's reminderDays field. Reminders fire after this many days of no contact.",
type: 'number',
placeholder: '7',
defaultValue: 7,
},
];
const DIGEST_FIELDS: SettingFieldDef[] = [
{
key: 'reminder_digest_enabled',
label: 'Batch reminders into a daily digest',
description:
'Off (default): reminders fire as soon as the threshold is hit. On: pending reminders are accumulated and delivered once per day at the digest time.',
type: 'boolean',
defaultValue: false,
},
{
key: 'reminder_digest_time',
label: 'Digest delivery time',
description: '24-hour HH:MM in the digest timezone.',
type: 'string',
placeholder: '09:00',
defaultValue: '09:00',
},
{
key: 'reminder_digest_timezone',
label: 'Digest timezone',
description: 'IANA timezone name used to interpret the delivery time (e.g. Europe/Warsaw).',
type: 'string',
placeholder: 'Europe/Warsaw',
defaultValue: 'Europe/Warsaw',
},
];
export default function ReminderSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Reminders"
description="Default reminder behaviour for new interests and the optional daily-digest delivery window. Individual users can still configure their own digest preferences in Notifications → Preferences."
/>
<SettingsFormCard
title="Defaults for new interests"
description="Applied when an interest is created without an explicit reminder configuration."
fields={DEFAULT_FIELDS}
/>
<SettingsFormCard
title="Daily digest"
description="Optional batching window so reminder notifications go out once per day instead of as they fire."
fields={DIGEST_FIELDS}
/>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { PageHeader } from '@/components/shared/page-header';
export default function ScheduledReportsPage() { export default function ScheduledReportsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-bold text-foreground">Scheduled Reports</h1> title="Scheduled Reports"
<p className="text-muted-foreground">Configure and manage automated report delivery</p> description="Configure and manage automated report delivery"
</div> />
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p> <p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
AlertDialog, AlertDialog,
@@ -36,7 +37,11 @@ export default function WebhooksPage() {
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null); const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [regenerating, setRegenerating] = useState<string | null>(null); const [regenerating, setRegenerating] = useState<string | null>(null);
const [newSecret, setNewSecret] = useState<{ webhookId: string; secret: string; masked: string } | null>(null); const [newSecret, setNewSecret] = useState<{
webhookId: string;
secret: string;
masked: string;
} | null>(null);
const loadWebhooks = useCallback(async () => { const loadWebhooks = useCallback(async () => {
try { try {
@@ -98,15 +103,20 @@ export default function WebhooksPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <PageHeader
<div> title="Webhooks"
<h1 className="text-2xl font-bold text-foreground">Webhooks</h1> description="Configure outgoing webhook integrations"
<p className="text-muted-foreground">Configure outgoing webhook integrations</p> actions={
</div> <Button
<Button onClick={() => { setEditTarget(null); setFormOpen(true); }}> onClick={() => {
setEditTarget(null);
setFormOpen(true);
}}
>
Add Webhook Add Webhook
</Button> </Button>
</div> }
/>
{loading ? ( {loading ? (
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">Loading...</p>
@@ -116,7 +126,13 @@ export default function WebhooksPage() {
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Add a webhook to receive real-time notifications of CRM events. Add a webhook to receive real-time notifications of CRM events.
</p> </p>
<Button className="mt-4" onClick={() => { setEditTarget(null); setFormOpen(true); }}> <Button
className="mt-4"
onClick={() => {
setEditTarget(null);
setFormOpen(true);
}}
>
Add Webhook Add Webhook
</Button> </Button>
</div> </div>
@@ -141,17 +157,16 @@ export default function WebhooksPage() {
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Button <Button variant="ghost" size="sm" onClick={() => handleToggleActive(webhook)}>
variant="ghost"
size="sm"
onClick={() => handleToggleActive(webhook)}
>
{webhook.isActive ? 'Disable' : 'Enable'} {webhook.isActive ? 'Disable' : 'Enable'}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { setEditTarget(webhook); setFormOpen(true); }} onClick={() => {
setEditTarget(webhook);
setFormOpen(true);
}}
> >
Edit Edit
</Button> </Button>
@@ -163,11 +178,7 @@ export default function WebhooksPage() {
> >
Delete Delete
</Button> </Button>
<Button <Button variant="ghost" size="sm" onClick={() => toggleExpand(webhook.id)}>
variant="ghost"
size="sm"
onClick={() => toggleExpand(webhook.id)}
>
{expandedId === webhook.id ? 'Collapse' : 'Details'} {expandedId === webhook.id ? 'Collapse' : 'Details'}
</Button> </Button>
</div> </div>
@@ -228,18 +239,26 @@ export default function WebhooksPage() {
onSuccess={loadWebhooks} onSuccess={loadWebhooks}
/> />
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}> <AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Webhook</AlertDialogTitle> <AlertDialogTitle>Delete Webhook</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Delete &quot;{deleteTarget?.name}&quot;? This will also delete all delivery history. This action Delete &quot;{deleteTarget?.name}&quot;? This will also delete all delivery history.
cannot be undone. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground"> <AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground"
>
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@@ -0,0 +1,5 @@
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
export default function AlertsPage() {
return <AlertsPageShell />;
}

View File

@@ -0,0 +1,10 @@
import { ReservationDetail } from '@/components/reservations/reservation-detail';
interface PageProps {
params: Promise<{ portSlug: string; id: string }>;
}
export default async function ReservationDetailPage({ params }: PageProps) {
const { portSlug, id } = await params;
return <ReservationDetail reservationId={id} portSlug={portSlug} />;
}

View File

@@ -0,0 +1,5 @@
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
export default function BerthReservationsPage() {
return <BerthReservationsList />;
}

View File

@@ -0,0 +1,41 @@
import { Skeleton } from '@/components/ui/skeleton';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
/**
* Route-level loading UI for the client detail page. Renders while the
* server component resolves the session and the client component bootstraps
* its initial query — replaces the previous empty-header flash on direct
* URL visits.
*/
export default function Loading() {
return (
<div className="space-y-6">
{/* Header strip — title, badges, action buttons */}
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-56" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="flex flex-wrap gap-2">
<Skeleton className="h-9 w-20 rounded-md" />
<Skeleton className="h-9 w-20 rounded-md" />
<Skeleton className="h-9 w-24 rounded-md" />
<Skeleton className="h-9 w-32 rounded-md" />
</div>
</div>
{/* Tab strip */}
<div className="flex gap-2 border-b border-border pb-1">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-20 rounded-md" />
))}
</div>
{/* Two-column overview */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CardSkeleton />
<CardSkeleton />
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { CompanyDetail } from '@/components/companies/company-detail';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
interface CompanyDetailPageProps {
params: Promise<{ companyId: string }>;
}
export default async function CompanyDetailPage({ params }: CompanyDetailPageProps) {
const { companyId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id;
return <CompanyDetail companyId={companyId} currentUserId={currentUserId} />;
}

View File

@@ -0,0 +1,5 @@
import { CompanyList } from '@/components/companies/company-list';
export default function CompaniesPage() {
return <CompanyList />;
}

View File

@@ -0,0 +1,5 @@
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
export default function DashboardPage() {
return <DashboardShell />;
}

View File

@@ -0,0 +1,10 @@
import { DocumentDetail } from '@/components/documents/document-detail';
interface PageProps {
params: Promise<{ portSlug: string; id: string }>;
}
export default async function DocumentDetailPage({ params }: PageProps) {
const { portSlug, id } = await params;
return <DocumentDetail documentId={id} portSlug={portSlug} />;
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState } from 'react';
import { Grid, List, Upload } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { FileGrid } from '@/components/files/file-grid';
import { FolderTree } from '@/components/files/folder-tree';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useFileBrowserStore } from '@/stores/file-browser-store';
import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid';
export default function DocumentsPage() {
const queryClient = useQueryClient();
const { viewMode, setViewMode, currentFolder, setCurrentFolder } = useFileBrowserStore();
const [showUpload, setShowUpload] = useState(false);
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const [, setRenameFile] = useState<FileRow | null>(null);
const { data, isLoading } = usePaginatedQuery<FileRow & { storagePath: string }>({
queryKey: ['files'],
endpoint: '/api/v1/files',
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [['files']],
'file:updated': [['files']],
'file:deleted': [['files']],
});
const filesInFolder = currentFolder
? data.filter((f) => f.storagePath?.includes(currentFolder))
: data;
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
}
};
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['files'] });
} catch {
// silent
}
};
return (
<div className="flex h-full flex-col gap-4">
<PageHeader
title="Documents"
description="Store and manage port documents and attachments"
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
>
{viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />}
</Button>
<PermissionGate resource="files" action="upload">
<Button size="sm" onClick={() => setShowUpload((v) => !v)}>
<Upload className="mr-1.5 h-4 w-4" />
Upload
</Button>
</PermissionGate>
</div>
}
/>
{showUpload && (
<PermissionGate resource="files" action="upload">
<FileUploadZone
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files'] });
setShowUpload(false);
}}
/>
</PermissionGate>
)}
<div className="flex flex-1 gap-4 overflow-hidden">
{/* Folder tree sidebar */}
<aside className="w-48 shrink-0 overflow-y-auto rounded-lg border bg-card p-2">
<p className="mb-1 px-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Folders
</p>
<FolderTree
files={data}
currentFolder={currentFolder}
onFolderSelect={setCurrentFolder}
/>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto rounded-lg border bg-card p-4">
<FileGrid
files={filesInFolder}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={setRenameFile}
onDelete={handleDelete}
isLoading={isLoading}
/>
</main>
</div>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { CreateDocumentWizard } from '@/components/documents/create-document-wizard';
interface PageProps {
params: Promise<{ portSlug: string }>;
}
export default async function NewDocumentPage({ params }: PageProps) {
const { portSlug } = await params;
return <CreateDocumentWizard portSlug={portSlug} />;
}

View File

@@ -1,142 +1,10 @@
'use client'; import { DocumentsHub } from '@/components/documents/documents-hub';
import { useState } from 'react'; interface PageProps {
import { Grid, List, Upload } from 'lucide-react'; params: Promise<{ portSlug: string }>;
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { FileGrid } from '@/components/files/file-grid';
import { FolderTree } from '@/components/files/folder-tree';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useFileBrowserStore } from '@/stores/file-browser-store';
import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid';
export default function DocumentsPage() {
const queryClient = useQueryClient();
const { viewMode, setViewMode, currentFolder, setCurrentFolder } = useFileBrowserStore();
const [showUpload, setShowUpload] = useState(false);
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const [, setRenameFile] = useState<FileRow | null>(null);
const { data, isLoading } = usePaginatedQuery<FileRow & { storagePath: string }>({
queryKey: ['files'],
endpoint: '/api/v1/files',
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [['files']],
'file:updated': [['files']],
'file:deleted': [['files']],
});
const filesInFolder = currentFolder
? data.filter((f) => f.storagePath?.includes(currentFolder))
: data;
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
} }
};
const handleDelete = async (file: FileRow) => { export default async function DocumentsPage({ params }: PageProps) {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return; const { portSlug } = await params;
try { return <DocumentsHub portSlug={portSlug} />;
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['files'] });
} catch {
// silent
}
};
return (
<div className="flex h-full flex-col gap-4">
<PageHeader
title="Documents"
description="Store and manage port documents and attachments"
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
>
{viewMode === 'grid' ? (
<List className="h-4 w-4" />
) : (
<Grid className="h-4 w-4" />
)}
</Button>
<PermissionGate resource="files" action="upload">
<Button size="sm" onClick={() => setShowUpload((v) => !v)}>
<Upload className="mr-1.5 h-4 w-4" />
Upload
</Button>
</PermissionGate>
</div>
}
/>
{showUpload && (
<PermissionGate resource="files" action="upload">
<FileUploadZone
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files'] });
setShowUpload(false);
}}
/>
</PermissionGate>
)}
<div className="flex flex-1 gap-4 overflow-hidden">
{/* Folder tree sidebar */}
<aside className="w-48 shrink-0 overflow-y-auto rounded-lg border bg-card p-2">
<p className="mb-1 px-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Folders
</p>
<FolderTree
files={data}
currentFolder={currentFolder}
onFolderSelect={setCurrentFolder}
/>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto rounded-lg border bg-card p-4">
<FileGrid
files={filesInFolder}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={setRenameFile}
onDelete={handleDelete}
isLoading={isLoading}
/>
</main>
</div>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);
} }

View File

@@ -1,16 +1,47 @@
'use client';
import { useState } from 'react';
import { Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { EmailAccountsList } from '@/components/email/email-accounts-list';
import { EmailThreadsList } from '@/components/email/email-threads-list';
import { ComposeDialog } from '@/components/email/compose-dialog';
export default function EmailPage() { export default function EmailPage() {
const [tab, setTab] = useState('threads');
const [composeOpen, setComposeOpen] = useState(false);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Email</h1> <h1 className="text-2xl font-bold text-foreground">Email</h1>
<p className="text-muted-foreground">Send and manage client communications</p> <p className="text-muted-foreground">Send and manage client communications</p>
</div> </div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <Button onClick={() => setComposeOpen(true)}>
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p> <Send className="h-4 w-4 mr-1.5" />
<p className="text-sm text-muted-foreground"> Compose
This feature will be implemented in the next phase. </Button>
</p>
</div> </div>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="threads">Inbox</TabsTrigger>
<TabsTrigger value="accounts">Accounts</TabsTrigger>
</TabsList>
<TabsContent value="threads" className="pt-4">
<EmailThreadsList />
</TabsContent>
<TabsContent value="accounts" className="pt-4">
<EmailAccountsList />
</TabsContent>
</Tabs>
<ComposeDialog open={composeOpen} onOpenChange={setComposeOpen} />
</div> </div>
); );
} }

View File

@@ -20,6 +20,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog'; import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { ExpenseCard } from '@/components/expenses/expense-card';
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters'; import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns'; import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -60,8 +61,7 @@ export default function ExpensesPage() {
}); });
const archiveMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] }); queryClient.invalidateQueries({ queryKey: ['expenses'] });
setArchiveExpense(null); setArchiveExpense(null);
@@ -151,6 +151,14 @@ export default function ExpensesPage() {
onSortChange={setSort} onSortChange={setSort}
isLoading={isFetching && !isLoading} isLoading={isFetching && !isLoading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
cardRender={(row) => (
<ExpenseCard
expense={row.original}
portSlug={portSlug}
onEdit={setEditExpense}
onArchive={setArchiveExpense}
/>
)}
emptyState={ emptyState={
<EmptyState <EmptyState
title="No expenses found" title="No expenses found"

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Upload, Loader2, ScanLine } from 'lucide-react'; import { Camera, Loader2, ScanLine, Upload } from 'lucide-react';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -33,9 +35,16 @@ export default function ScanReceiptPage() {
const router = useRouter(); const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null); const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Scan Receipt', showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// Editable fields from scan // Editable fields from scan
const [establishment, setEstablishment] = useState(''); const [establishment, setEstablishment] = useState('');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
@@ -94,7 +103,7 @@ export default function ScanReceiptPage() {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
<div> <div className="hidden sm:block">
<h1 className="text-2xl font-bold">Scan Receipt</h1> <h1 className="text-2xl font-bold">Scan Receipt</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
Upload a receipt image and we will extract the expense details automatically. Upload a receipt image and we will extract the expense details automatically.
@@ -109,28 +118,44 @@ export default function ScanReceiptPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{previewUrl ? (
<div <div
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors" className="border-2 border-dashed rounded-lg p-4 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
{previewUrl ? (
<img <img
src={previewUrl} src={previewUrl}
alt="Receipt preview" alt="Receipt preview"
className="max-h-64 mx-auto rounded object-contain" className="max-h-64 mx-auto rounded object-contain"
/> />
</div>
) : ( ) : (
<div className="space-y-2"> <div className="grid gap-2 sm:grid-cols-2">
<Upload className="h-8 w-8 mx-auto text-muted-foreground" /> <Button
<p className="text-sm text-muted-foreground"> type="button"
Click to upload or drag and drop size="lg"
</p> className="w-full h-14 sm:hidden"
<p className="text-xs text-muted-foreground"> onClick={() => cameraInputRef.current?.click()}
>
<Camera className="mr-2 h-5 w-5" />
Take photo
</Button>
<Button
type="button"
variant="outline"
size="lg"
className="w-full h-14"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-2 h-5 w-5" />
<span className="sm:hidden">Choose from library</span>
<span className="hidden sm:inline">Click to upload or drag and drop</span>
</Button>
<p className="text-xs text-muted-foreground sm:col-span-2 text-center">
JPEG, PNG, WebP up to 10MB JPEG, PNG, WebP up to 10MB
</p> </p>
</div> </div>
)} )}
</div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -138,6 +163,14 @@ export default function ScanReceiptPage() {
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}
/> />
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleFileChange}
/>
{scanMutation.isPending && ( {scanMutation.isPending && (
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground"> <div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
@@ -222,25 +255,18 @@ export default function ScanReceiptPage() {
</div> </div>
{saveMutation.isError && ( {saveMutation.isError && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{(saveMutation.error as Error).message}</p>
{(saveMutation.error as Error).message}
</p>
)} )}
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button <Button variant="outline" onClick={() => router.push(`/${params.portSlug}/expenses`)}>
variant="outline"
onClick={() => router.push(`/${params.portSlug}/expenses`)}
>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !amount} disabled={saveMutation.isPending || !amount}
> >
{saveMutation.isPending && ( {saveMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save as Expense Save as Expense
</Button> </Button>
</div> </div>

View File

@@ -1,11 +1,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react'; import { ChevronLeft, ChevronRight, Check, Loader2, Wallet } from 'lucide-react';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -19,6 +21,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { OwnerPicker } from '@/components/shared/owner-picker';
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items'; import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices'; import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
@@ -42,9 +45,35 @@ export default function NewInvoicePage() {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const prefilledInterestId = searchParams.get('interestId') ?? undefined;
const prefilledKind =
searchParams.get('kind') === 'deposit' ? ('deposit' as const) : ('general' as const);
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'New Invoice', showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
// fetch enough of the interest to display "Deposit for {client} — Berth {n}" in
// the review step. Doubles as the source of truth for the billing entity prefill.
const { data: prefilledInterest } = useQuery<{
data: {
id: string;
clientId: string;
clientName: string | null;
berthMooringNumber: string | null;
};
}>({
queryKey: ['interest-prefill', prefilledInterestId],
queryFn: () => apiFetch(`/api/v1/interests/${prefilledInterestId}`),
enabled: !!prefilledInterestId,
});
const methods = useForm<CreateInvoiceInput>({ const methods = useForm<CreateInvoiceInput>({
resolver: zodResolver(createInvoiceSchema), resolver: zodResolver(createInvoiceSchema),
defaultValues: { defaultValues: {
@@ -52,12 +81,57 @@ export default function NewInvoicePage() {
currency: 'USD', currency: 'USD',
lineItems: [], lineItems: [],
expenseIds: [], expenseIds: [],
interestId: prefilledInterestId,
kind: prefilledKind,
}, },
}); });
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods; const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = methods;
const watchedValues = watch(); const watchedValues = watch();
const isDepositInvoice = watchedValues.kind === 'deposit';
// Resolve the selected billing entity to a human name so the review step
// shows "Acme Yacht Charters" instead of "company 4f2a1b…".
const billingEntityRef = watchedValues.billingEntity ?? null;
const { data: billingEntityName } = useQuery<{ name: string }>({
queryKey: ['billing-entity-name', billingEntityRef?.type, billingEntityRef?.id],
queryFn: async () => {
if (!billingEntityRef) return { name: '' };
const path =
billingEntityRef.type === 'company'
? `/api/v1/companies/${billingEntityRef.id}`
: `/api/v1/clients/${billingEntityRef.id}`;
const res = await apiFetch<{
data: { fullName?: string; name?: string };
}>(path);
return {
name: res?.data?.fullName ?? res?.data?.name ?? '',
};
},
enabled: !!billingEntityRef?.id,
staleTime: 60_000,
});
// Pre-fill the billing entity from the linked interest's client on launch.
useEffect(() => {
if (prefilledInterest?.data && !watchedValues.billingEntity) {
setValue(
'billingEntity',
{ type: 'client', id: prefilledInterest.data.clientId },
{ shouldValidate: true },
);
}
// We only want this to run when the interest data first arrives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefilledInterest?.data?.clientId]);
const lineItems = watchedValues.lineItems ?? []; const lineItems = watchedValues.lineItems ?? [];
const subtotal = lineItems.reduce( const subtotal = lineItems.reduce(
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0), (sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
@@ -87,7 +161,7 @@ export default function NewInvoicePage() {
async function goNext() { async function goNext() {
if (step === 1) { if (step === 1) {
const valid = await methods.trigger([ const valid = await methods.trigger([
'clientName', 'billingEntity',
'billingEmail', 'billingEmail',
'billingAddress', 'billingAddress',
'dueDate', 'dueDate',
@@ -110,13 +184,9 @@ export default function NewInvoicePage() {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
{/* Header */} {/* Header — desktop only; mobile gets the title from the topbar */}
<div className="flex items-center gap-3"> <div className="hidden sm:flex items-center gap-3">
<Button <Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
variant="ghost"
size="sm"
onClick={() => router.push(`/${portSlug}/invoices`)}
>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-xl font-semibold">New Invoice</h1> <h1 className="text-xl font-semibold">New Invoice</h1>
@@ -137,16 +207,10 @@ export default function NewInvoicePage() {
> >
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id} {step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
</div> </div>
<span <span className={`text-sm ${step === s.id ? 'font-medium' : 'text-muted-foreground'}`}>
className={`text-sm ${
step === s.id ? 'font-medium' : 'text-muted-foreground'
}`}
>
{s.label} {s.label}
</span> </span>
{idx < STEPS.length - 1 && ( {idx < STEPS.length - 1 && <div className="w-8 h-px bg-border mx-1" />}
<div className="w-8 h-px bg-border mx-1" />
)}
</div> </div>
))} ))}
</div> </div>
@@ -160,18 +224,46 @@ export default function NewInvoicePage() {
<CardTitle className="text-base">Client Information</CardTitle> <CardTitle className="text-base">Client Information</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-1"> {isDepositInvoice ? (
<Label htmlFor="clientName"> <div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Client Name <span className="text-destructive">*</span> <Wallet className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">Deposit invoice</p>
<p className="text-xs text-amber-800">
{prefilledInterest?.data
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
prefilledInterest.data.berthMooringNumber
? ` — Berth ${prefilledInterest.data.berthMooringNumber}`
: ''
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
</p>
</div>
</div>
) : null}
<div className="space-y-2">
<Label>
Billing entity <span className="text-destructive">*</span>
</Label> </Label>
<Input <OwnerPicker
id="clientName" value={watchedValues.billingEntity ?? null}
{...register('clientName')} onChange={(ref) => {
placeholder="Client or company name" if (ref) {
setValue('billingEntity', ref, { shouldValidate: true });
}
}}
/> />
{errors.clientName && ( {errors.billingEntity && (
<p className="text-xs text-destructive">{errors.clientName.message}</p> <p className="text-xs text-destructive">
{errors.billingEntity.message ??
errors.billingEntity.id?.message ??
errors.billingEntity.type?.message}
</p>
)} )}
<p className="text-xs text-muted-foreground">
Select the client or company to invoice. Their name will be snapshotted into the
invoice.
</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -202,11 +294,7 @@ export default function NewInvoicePage() {
<Label htmlFor="dueDate"> <Label htmlFor="dueDate">
Due Date <span className="text-destructive">*</span> Due Date <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input id="dueDate" type="date" {...register('dueDate')} />
id="dueDate"
type="date"
{...register('dueDate')}
/>
{errors.dueDate && ( {errors.dueDate && (
<p className="text-xs text-destructive">{errors.dueDate.message}</p> <p className="text-xs text-destructive">{errors.dueDate.message}</p>
)} )}
@@ -216,7 +304,9 @@ export default function NewInvoicePage() {
<Label>Payment Terms</Label> <Label>Payment Terms</Label>
<Select <Select
defaultValue="net30" defaultValue="net30"
onValueChange={(v) => setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])} onValueChange={(v) =>
setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select terms" /> <SelectValue placeholder="Select terms" />
@@ -284,8 +374,23 @@ export default function NewInvoicePage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="text-muted-foreground">Client</span> <span className="text-muted-foreground">Billing Entity</span>
<p className="font-medium mt-0.5">{watchedValues.clientName}</p> <p className="font-medium mt-0.5">
{watchedValues.billingEntity ? (
<>
{billingEntityName?.name ? (
<span>{billingEntityName.name}</span>
) : (
<span className="text-muted-foreground">Loading</span>
)}{' '}
<span className="text-xs text-muted-foreground capitalize">
({watchedValues.billingEntity.type})
</span>
</>
) : (
<span className="text-muted-foreground italic">Not selected</span>
)}
</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Due Date</span> <span className="text-muted-foreground">Due Date</span>
@@ -293,9 +398,7 @@ export default function NewInvoicePage() {
</div> </div>
<div> <div>
<span className="text-muted-foreground">Payment Terms</span> <span className="text-muted-foreground">Payment Terms</span>
<p className="font-medium mt-0.5 capitalize"> <p className="font-medium mt-0.5 capitalize">{watchedValues.paymentTerms}</p>
{watchedValues.paymentTerms}
</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Currency</span> <span className="text-muted-foreground">Currency</span>
@@ -354,12 +457,7 @@ export default function NewInvoicePage() {
{/* Navigation */} {/* Navigation */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button type="button" variant="outline" onClick={goBack} disabled={step === 1}>
type="button"
variant="outline"
onClick={goBack}
disabled={step === 1}
>
<ChevronLeft className="mr-1.5 h-4 w-4" /> <ChevronLeft className="mr-1.5 h-4 w-4" />
Back Back
</Button> </Button>

View File

@@ -12,6 +12,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { InvoiceCard } from '@/components/invoices/invoice-card';
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters'; import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns'; import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -63,8 +64,7 @@ export default function InvoicesPage() {
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoices'] });
setDeleteTarget(null); setDeleteTarget(null);
@@ -72,8 +72,7 @@ export default function InvoicesPage() {
}); });
const sendMutation = useMutation({ const sendMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoices'] });
}, },
@@ -82,8 +81,7 @@ export default function InvoicesPage() {
const columns = getInvoiceColumns({ const columns = getInvoiceColumns({
portSlug, portSlug,
onSend: (invoice) => sendMutation.mutate(invoice.id), onSend: (invoice) => sendMutation.mutate(invoice.id),
onRecordPayment: (invoice) => onRecordPayment: (invoice) => router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
onDelete: (invoice) => setDeleteTarget(invoice), onDelete: (invoice) => setDeleteTarget(invoice),
}); });
@@ -141,6 +139,17 @@ export default function InvoicesPage() {
onSortChange={setSort} onSortChange={setSort}
isLoading={isFetching && !isLoading} isLoading={isFetching && !isLoading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
cardRender={(row) => (
<InvoiceCard
invoice={row.original}
portSlug={portSlug}
onSend={(invoice) => sendMutation.mutate(invoice.id)}
onRecordPayment={(invoice) =>
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`)
}
onDelete={setDeleteTarget}
/>
)}
emptyState={ emptyState={
<EmptyState <EmptyState
title="No invoices found" title="No invoices found"
@@ -161,15 +170,11 @@ export default function InvoicesPage() {
<h3 className="font-semibold">Delete Invoice?</h3> <h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This will permanently delete invoice{' '} This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. <span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
This action cannot be undone. action cannot be undone.
</p> </p>
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Button <Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
variant="outline"
size="sm"
onClick={() => setDeleteTarget(null)}
>
Cancel Cancel
</Button> </Button>
<Button <Button

View File

@@ -0,0 +1,17 @@
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
export default function NotificationPreferencesPage() {
return (
<div className="max-w-2xl mx-auto py-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Notification Preferences</h1>
<p className="text-sm text-muted-foreground">
Choose which notifications you receive and how.
</p>
</div>
<NotificationPreferencesForm />
<ReminderDigestForm />
</div>
);
}

View File

@@ -1,5 +1,7 @@
import { DashboardShell } from '@/components/dashboard/dashboard-shell'; import { redirect } from 'next/navigation';
export default function DashboardPage() { export default async function PortIndexPage({ params }: { params: Promise<{ portSlug: string }> }) {
return <DashboardShell />; const { portSlug } = await params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/${portSlug}/dashboard` as any);
} }

View File

@@ -0,0 +1,10 @@
import { ResidentialClientDetail } from '@/components/residential/residential-client-detail';
interface Props {
params: Promise<{ id: string }>;
}
export default async function ResidentialClientDetailPage({ params }: Props) {
const { id } = await params;
return <ResidentialClientDetail clientId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { ResidentialClientsList } from '@/components/residential/residential-clients-list';
export default function ResidentialClientsPage() {
return <ResidentialClientsList />;
}

View File

@@ -0,0 +1,10 @@
import { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail';
interface Props {
params: Promise<{ id: string }>;
}
export default async function ResidentialInterestDetailPage({ params }: Props) {
const { id } = await params;
return <ResidentialInterestDetail interestId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { ResidentialInterestsList } from '@/components/residential/residential-interests-list';
export default function ResidentialInterestsPage() {
return <ResidentialInterestsList />;
}

View File

@@ -0,0 +1,16 @@
import { YachtDetail } from '@/components/yachts/yacht-detail';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
interface YachtDetailPageProps {
params: Promise<{ yachtId: string }>;
}
export default async function YachtDetailPage({ params }: YachtDetailPageProps) {
const { yachtId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id;
return <YachtDetail yachtId={yachtId} currentUserId={currentUserId} />;
}

View File

@@ -0,0 +1,5 @@
import { YachtList } from '@/components/yachts/yacht-list';
export default function YachtsPage() {
return <YachtList />;
}

View File

@@ -4,40 +4,66 @@ import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { userPortRoles } from '@/lib/db/schema/users'; import { ports as portsTable } from '@/lib/db/schema/ports';
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
import { QueryProvider } from '@/providers/query-provider'; import { QueryProvider } from '@/providers/query-provider';
import { SocketProvider } from '@/providers/socket-provider'; import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider'; import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider'; import { PermissionsProvider } from '@/providers/permissions-provider';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar'; import { Topbar } from '@/components/layout/topbar';
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) redirect('/login'); if (!session?.user) redirect('/login');
// Load user's port assignments for PortProvider // Super admins have implicit access to every port; everyone else only sees
// ports they have an explicit user_port_roles row for.
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
const portRoles = await db.query.userPortRoles.findMany({ const portRoles = await db.query.userPortRoles.findMany({
where: eq(userPortRoles.userId, session.user.id), where: eq(userPortRoles.userId, session.user.id),
with: { port: true, role: true }, with: { port: true, role: true },
}); });
const ports = portRoles.map((pr) => pr.port); const ports = profile?.isSuperAdmin
? await db.query.ports.findMany({ orderBy: portsTable.name })
: portRoles.map((pr) => pr.port);
return ( return (
<QueryProvider> <QueryProvider>
<PortProvider ports={ports} defaultPortId={portRoles[0]?.port.id ?? null}> <PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
<PermissionsProvider> <PermissionsProvider>
<SocketProvider> <SocketProvider>
<div className="flex h-screen overflow-hidden bg-background"> <RealtimeToasts />
<Sidebar portRoles={portRoles} /> {/* Desktop shell — hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar
portRoles={portRoles}
isSuperAdmin={profile?.isSuperAdmin ?? false}
user={{
name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email,
}}
/>
<div className="flex-1 flex flex-col overflow-hidden min-w-0"> <div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar ports={ports} /> <Topbar
<main className="flex-1 overflow-y-auto bg-background p-6"> ports={ports}
{children} user={{
</main> name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email,
}}
/>
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
</div> </div>
</div> </div>
{/* Mobile shell — hidden by CSS on desktop */}
<MobileLayout>{children}</MobileLayout>
</SocketProvider> </SocketProvider>
</PermissionsProvider> </PermissionsProvider>
</PortProvider> </PortProvider>

View File

@@ -0,0 +1,24 @@
import { Suspense } from 'react';
import { PasswordSetForm } from '@/components/portal/password-set-form';
export default function PortalActivatePage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500">
Loading
</div>
}
>
<PasswordSetForm
endpoint="/api/portal/auth/activate"
title="Activate your account"
description="Welcome — choose a password to finish setting up your client portal account."
successTitle="Account activated"
successDescription="You can now sign in with your new password."
submitLabel="Activate account"
/>
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { Anchor, FileText, Receipt } from 'lucide-react'; import { Anchor, FileText, Receipt, Sailboat, Building2, CalendarCheck } from 'lucide-react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth'; import { getPortalSession } from '@/lib/portal/auth';
@@ -21,15 +21,12 @@ export default async function PortalDashboardPage() {
<h1 className="text-2xl font-semibold text-gray-900"> <h1 className="text-2xl font-semibold text-gray-900">
Welcome back, {dashboard.client.fullName.split(' ')[0]} Welcome back, {dashboard.client.fullName.split(' ')[0]}
</h1> </h1>
{dashboard.client.companyName && ( {dashboard.client.nationality && (
<p className="text-gray-500 mt-0.5">{dashboard.client.companyName}</p> <p className="text-sm text-gray-400 mt-0.5">{dashboard.client.nationality}</p>
)}
{dashboard.client.yachtName && (
<p className="text-sm text-gray-400 mt-0.5">Vessel: {dashboard.client.yachtName}</p>
)} )}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<PortalCard <PortalCard
title="Berth Interests" title="Berth Interests"
value={dashboard.counts.interests} value={dashboard.counts.interests}
@@ -51,13 +48,33 @@ export default async function PortalDashboardPage() {
icon={Receipt} icon={Receipt}
href="/portal/invoices" href="/portal/invoices"
/> />
<PortalCard
title="My Yachts"
value={dashboard.counts.yachts}
description="Vessels you own directly or through a company"
icon={Sailboat}
href="/portal/my-yachts"
/>
<PortalCard
title="My Memberships"
value={dashboard.counts.memberships}
description="Companies where you hold an active role"
icon={Building2}
/>
<PortalCard
title="My Active Reservations"
value={dashboard.counts.activeReservations}
description="Current and pending berth reservations"
icon={CalendarCheck}
href="/portal/my-reservations"
/>
</div> </div>
<div className="bg-white rounded-lg border p-6"> <div className="bg-white rounded-lg border p-6">
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2> <h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Contact the {dashboard.port.name} team directly. This portal provides a read-only view Contact the {dashboard.port.name} team directly. This portal provides a read-only view of
of your account. All changes must be made through your port contact. your account. All changes must be made through your port contact.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,105 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
export default function PortalForgotPasswordPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
// Always returns 200 — caller never sees whether email exists.
await fetch('/api/portal/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
} finally {
setSubmitted(true);
setLoading(false);
}
}
if (submitted) {
return (
<BrandedAuthShell>
<div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<CheckCircle2 className="h-7 w-7 text-green-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
<p className="text-sm text-gray-500 leading-relaxed">
If <strong>{email}</strong> matches a portal account, we&apos;ve sent a reset link. The
link expires in 30 minutes.
</p>
<Link
href="/portal/login"
className="mt-6 inline-block text-sm text-[#007bff] hover:underline"
>
Back to sign in
</Link>
</div>
</BrandedAuthShell>
);
}
return (
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
<p className="text-sm text-gray-500 mt-1">
Enter your email and we&apos;ll send you a reset link.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
autoComplete="email"
disabled={loading}
/>
</div>
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={loading || !email}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending
</>
) : (
'Send reset link'
)}
</Button>
<p className="text-center text-sm text-gray-500">
Remember your password?{' '}
<Link href="/portal/login" className="text-[#007bff] hover:underline">
Sign in
</Link>
</p>
</form>
</BrandedAuthShell>
);
}

View File

@@ -5,28 +5,19 @@ import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth'; import { getPortalSession } from '@/lib/portal/auth';
import { getClientInterests } from '@/lib/services/portal.service'; import { getClientInterests } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { stageLabel, safeStage, type PipelineStage } from '@/lib/constants';
export const metadata: Metadata = { title: 'Interests' }; export const metadata: Metadata = { title: 'Interests' };
const STAGE_LABELS: Record<string, string> = { const STAGE_VARIANT: Record<PipelineStage, 'default' | 'secondary' | 'destructive' | 'outline'> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'EOI / NDA Signed',
deposit_10pct: 'Deposit Received',
contract: 'Contract Stage',
completed: 'Completed',
};
const STAGE_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
open: 'secondary', open: 'secondary',
details_sent: 'secondary', details_sent: 'secondary',
in_communication: 'default', in_communication: 'default',
visited: 'default', eoi_sent: 'default',
signed_eoi_nda: 'default', eoi_signed: 'default',
deposit_10pct: 'default', deposit_10pct: 'default',
contract: 'default', contract_sent: 'default',
contract_signed: 'default',
completed: 'outline', completed: 'outline',
}; };
@@ -40,9 +31,7 @@ export default async function PortalInterestsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1> <h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">Your berth enquiries and applications</p>
Your berth enquiries and applications
</p>
</div> </div>
{interests.length === 0 ? ( {interests.length === 0 ? (
@@ -56,10 +45,7 @@ export default async function PortalInterestsPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{interests.map((interest) => ( {interests.map((interest) => (
<div <div key={interest.id} className="bg-white rounded-lg border p-5">
key={interest.id}
className="bg-white rounded-lg border p-5"
>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -98,8 +84,8 @@ export default async function PortalInterestsPage() {
)} )}
</div> </div>
</div> </div>
<Badge variant={STAGE_COLORS[interest.pipelineStage] ?? 'default'}> <Badge variant={STAGE_VARIANT[safeStage(interest.pipelineStage)]}>
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage} {stageLabel(interest.pipelineStage)}
</Badge> </Badge>
</div> </div>
</div> </div>

View File

@@ -1,15 +1,23 @@
'use client'; 'use client';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { Mail, Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
export default function PortalLoginPage() { export default function PortalLoginPage() {
const router = useRouter();
const search = useSearchParams();
const next = search.get('next') ?? '/portal/dashboard';
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
@@ -18,59 +26,33 @@ export default function PortalLoginPage() {
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/portal/auth/request', { const res = await fetch('/api/portal/auth/sign-in', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }), body: JSON.stringify({ email, password }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Something went wrong. Please try again.'); setError((data as { error?: string }).error ?? 'Invalid email or password');
return; return;
} }
setSubmitted(true); // typedRoutes: `next` is a runtime string we can't statically check.
router.replace(next as never);
router.refresh();
} catch { } catch {
setError('Unable to connect. Please check your connection and try again.'); setError('Unable to connect. Please try again.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
if (submitted) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <BrandedAuthShell>
<div className="w-full max-w-md text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<Mail className="h-7 w-7 text-green-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
<p className="text-gray-500 text-sm leading-relaxed">
If <strong>{email}</strong> is associated with a client account, you will receive a
sign-in link shortly. The link expires in 24 hours.
</p>
<button
type="button"
onClick={() => { setSubmitted(false); setEmail(''); }}
className="mt-6 text-sm text-[#1e2844] hover:underline"
>
Try a different email
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-lg border p-8 shadow-sm">
<div className="text-center mb-6"> <div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1> <h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
Enter your email to receive a sign-in link
</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -84,35 +66,50 @@ export default function PortalLoginPage() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoFocus autoFocus
autoComplete="email"
disabled={loading} disabled={loading}
/> />
</div> </div>
{error && ( <div className="space-y-1.5">
<p className="text-sm text-red-600">{error}</p> <div className="flex items-center justify-between">
)} <Label htmlFor="password">Password</Label>
<Link href="/portal/forgot-password" className="text-xs text-[#007bff] hover:underline">
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button <Button
type="submit" type="submit"
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white" className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={loading || !email} disabled={loading || !email || !password}
> >
{loading ? ( {loading ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending link... Signing in
</> </>
) : ( ) : (
'Send sign-in link' 'Sign in'
)} )}
</Button> </Button>
</form> </form>
</div>
<p className="text-center text-xs text-gray-400 mt-4"> <p className="text-center text-xs text-gray-400 mt-6">
This portal is for existing clients only. This portal is for existing clients only.
</p> </p>
</div> </BrandedAuthShell>
</div>
); );
} }

View File

@@ -0,0 +1,83 @@
import { redirect } from 'next/navigation';
import { CalendarCheck } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalUserReservations } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'My Reservations' };
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
active: 'default',
ended: 'outline',
cancelled: 'destructive',
};
const TENURE_LABELS: Record<string, string> = {
permanent: 'Permanent',
fixed_term: 'Fixed term',
seasonal: 'Seasonal',
};
function formatDate(d: Date | string): string {
return new Date(d).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export default async function PortalMyReservationsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const reservations = await getPortalUserReservations(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">My Reservations</h1>
<p className="text-sm text-gray-500 mt-1">Your current and pending berth reservations</p>
</div>
{reservations.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<CalendarCheck className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No active reservations</p>
<p className="text-sm text-gray-400 mt-1">
Contact your port representative to discuss reservations.
</p>
</div>
) : (
<div className="space-y-3">
{reservations.map((r) => (
<div key={r.id} className="bg-white rounded-lg border p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
{r.berthMooringNumber && (
<span className="text-sm text-gray-400"> Berth {r.berthMooringNumber}</span>
)}
</div>
<p className="text-sm text-gray-500">
{TENURE_LABELS[r.tenureType] ?? r.tenureType}
</p>
<div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-400">
<span>
From {formatDate(r.startDate)}
{r.endDate ? ` to ${formatDate(r.endDate)}` : ' · ongoing'}
</span>
</div>
</div>
<Badge variant={STATUS_COLORS[r.status] ?? 'default'}>{r.status}</Badge>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { redirect } from 'next/navigation';
import { Sailboat } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalUserYachts } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'My Yachts' };
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
active: 'default',
retired: 'secondary',
sold_away: 'outline',
};
export default async function PortalMyYachtsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const yachts = await getPortalUserYachts(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">My Yachts</h1>
<p className="text-sm text-gray-500 mt-1">Vessels you own directly or through a company</p>
</div>
{yachts.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Sailboat className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No yachts on file</p>
<p className="text-sm text-gray-400 mt-1">
Yachts owned by you or a company you are a member of will appear here.
</p>
</div>
) : (
<div className="space-y-3">
{yachts.map((y) => (
<div key={y.id} className="bg-white rounded-lg border p-5">
<div className="flex items-start gap-4">
<Sailboat className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">{y.name}</p>
<p className="text-sm text-gray-500 mt-0.5">
{y.hullNumber ? `Hull ${y.hullNumber}` : 'No hull number'}
{y.flag ? ` · ${y.flag}` : ''}
{y.yearBuilt ? ` · ${y.yearBuilt}` : ''}
</p>
{y.ownerContext === 'company' && y.ownerCompanyName && (
<p className="text-xs text-[#1e2844] mt-2">Owned by {y.ownerCompanyName}</p>
)}
</div>
<Badge variant={STATUS_COLORS[y.status] ?? 'default'}>
{y.status.replace(/_/g, ' ')}
</Badge>
</div>
{(y.lengthFt || y.widthFt || y.registration) && (
<div className="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
{y.registration && <span>Reg: {y.registration}</span>}
{y.lengthFt && <span>Length: {y.lengthFt}ft</span>}
{y.widthFt && <span>Beam: {y.widthFt}ft</span>}
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from 'react';
import { PasswordSetForm } from '@/components/portal/password-set-form';
export default function PortalResetPasswordPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500">
Loading
</div>
}
>
<PasswordSetForm
endpoint="/api/portal/auth/reset-password"
title="Choose a new password"
description="Enter a new password to regain access to your client portal."
successTitle="Password updated"
successDescription="You can now sign in with your new password."
submitLabel="Update password"
/>
</Suspense>
);
}

View File

@@ -1,35 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
export default function PortalVerifyPage() {
const router = useRouter();
const searchParams = useSearchParams();
const calledRef = useRef(false);
useEffect(() => {
if (calledRef.current) return;
calledRef.current = true;
const token = searchParams.get('token');
if (!token) {
router.replace('/portal/login?error=missing_token');
return;
}
// Redirect to the verify API route which will set the cookie and redirect
window.location.href = `/api/portal/auth/verify?token=${encodeURIComponent(token)}`;
}, [searchParams, router]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-[#1e2844] mx-auto mb-3" />
<p className="text-sm text-gray-500">Verifying your access...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { QueryProvider } from '@/providers/query-provider';
import { PortProvider } from '@/providers/port-provider';
import { eq } from 'drizzle-orm';
/**
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
* topbar — the scanner is its own contained surface. Adds the PWA
* manifest link + theme color so iOS/Android pick up "Add to Home
* Screen". Auth check matches the dashboard layout so unauthorized
* users still bounce to /login.
*/
export default async function ScannerLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) redirect('/login');
const { portSlug } = await params;
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
});
if (!port) redirect('/login');
return (
<QueryProvider>
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}>
<head>
<link rel="manifest" href={`/${portSlug}/scan/manifest.webmanifest`} />
<meta name="theme-color" content="#3a7bc8" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="PN Scanner" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head>
<div className="min-h-[100dvh] bg-background">{children}</div>
</PortProvider>
</QueryProvider>
);
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
/**
* Per-port PWA manifest. Scoped to `/<portSlug>/scan` so the install
* only covers the scanner page, not the rest of the CRM. Each port
* gets its own homescreen icon labeled with its name.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ portSlug: string }> }) {
const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const portName = port?.name ?? 'Port Nimara';
const manifest = {
name: `${portName} — Scanner`,
short_name: 'Scanner',
description: `Capture and submit expense receipts for ${portName}.`,
start_url: `/${portSlug}/scan`,
scope: `/${portSlug}/scan`,
display: 'standalone',
orientation: 'portrait',
background_color: '#ffffff',
theme_color: '#3a7bc8',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{
src: '/icon-512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
};
return NextResponse.json(manifest, {
headers: {
'Content-Type': 'application/manifest+json',
'Cache-Control': 'public, max-age=300, must-revalidate',
},
});
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import { ScanShell } from '@/components/scan/scan-shell';
export const metadata: Metadata = {
title: 'Scan receipt — Port Nimara',
};
export default function ScanPage() {
return <ScanShell />;
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
const bodySchema = z.object({
token: z.string().min(1),
password: z.string().min(9),
});
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ message: 'Invalid request body' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ message: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ status: 400 },
);
}
try {
const result = await consumeCrmInvite({
token: parsed.data.token,
password: parsed.data.password,
});
return NextResponse.json({ success: true, email: result.email });
} catch (err) {
return errorResponse(err);
}
}

View File

@@ -1,68 +1,15 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { redis } from '@/lib/redis';
import { minioClient } from '@/lib/minio';
import { env } from '@/lib/env';
import { sql } from 'drizzle-orm';
type CheckStatus = 'ok' | 'error'; /**
* Liveness probe — confirms the Next.js process is responding.
interface HealthChecks { *
postgres: CheckStatus; * Returns 200 unconditionally; if the process is wedged or has crashed
redis: CheckStatus; * the request never lands here at all. Do NOT include database/Redis/MinIO
minio: CheckStatus; * checks in this endpoint — a transient downstream blip should drop the
} * pod from the load balancer (readiness), not restart the pod (liveness).
*
interface HealthResponse { * For deep dependency checks, hit `/api/ready` instead.
status: 'healthy' | 'degraded'; */
checks: HealthChecks; export async function GET() {
timestamp: string; return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
}
export async function GET(): Promise<NextResponse<HealthResponse>> {
const checks: HealthChecks = {
postgres: 'error',
redis: 'error',
minio: 'error',
};
await Promise.allSettled([
db
.execute(sql`SELECT 1`)
.then(() => {
checks.postgres = 'ok';
})
.catch(() => {
checks.postgres = 'error';
}),
redis
.ping()
.then(() => {
checks.redis = 'ok';
})
.catch(() => {
checks.redis = 'error';
}),
minioClient
.bucketExists(env.MINIO_BUCKET)
.then(() => {
checks.minio = 'ok';
})
.catch(() => {
checks.minio = 'error';
}),
]);
const allHealthy = Object.values(checks).every((s) => s === 'ok');
const status: HealthResponse['status'] = allHealthy ? 'healthy' : 'degraded';
const body: HealthResponse = {
status,
checks,
timestamp: new Date().toISOString(),
};
return NextResponse.json(body, { status: allHealthy ? 200 : 503 });
} }

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { activateAccount } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({
token: z.string().min(1),
password: z.string().min(9),
});
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ status: 400 },
);
}
try {
await activateAccount(parsed.data.token, parsed.data.password);
return NextResponse.json({ success: true });
} catch (err) {
return errorResponse(err);
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { logger } from '@/lib/logger';
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({ email: z.string().email() });
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
}
// Always return 200 to prevent account-enumeration. Errors are logged
// server-side, never surfaced to the client.
try {
await requestPasswordReset(parsed.data.email);
} catch (err) {
logger.error({ err }, 'Portal forgot-password failed (swallowed)');
}
return NextResponse.json({ success: true });
}

Some files were not shown because too many files have changed in this diff Show More