23 Commits

Author SHA1 Message Date
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
233 changed files with 55892 additions and 1279 deletions

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. |

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

@@ -52,6 +52,7 @@
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-table": "^8.21.3",
"archiver": "^7.0.1",
"better-auth": "^1.2.0",
"bullmq": "^5.25.0",
"class-variance-authority": "^0.7.0",
@@ -61,7 +62,9 @@
"drizzle-orm": "^0.38.0",
"imapflow": "^1.2.13",
"ioredis": "^5.4.0",
"iso-3166-2": "^1.0.0",
"jose": "^6.2.1",
"libphonenumber-js": "^1.12.42",
"lucide-react": "^0.460.0",
"mailparser": "^3.9.4",
"minio": "^8.0.0",
@@ -83,12 +86,15 @@
"sonner": "^1.7.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tesseract.js": "^7.0.0",
"zod": "^3.24.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@playwright/test": "^1.58.2",
"@types/archiver": "^7.0.0",
"@types/iso-3166-2": "^1.0.4",
"@types/mailparser": "^3.4.6",
"@types/node": "^22.0.0",
"@types/nodemailer": "^6.4.0",

622
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@@ -20,7 +20,15 @@ async function main() {
const isSuperAdmin = args.includes('--super');
const name = args.find((a, i) => i > 0 && !a.startsWith('--'));
const { inviteId, link } = await createCrmInvite({ email, name, isSuperAdmin });
// 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}`);

View File

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

View File

@@ -149,6 +149,12 @@ const SECTIONS: AdminSection[] = [
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({

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,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

@@ -1,68 +1,15 @@
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';
interface HealthChecks {
postgres: CheckStatus;
redis: CheckStatus;
minio: CheckStatus;
}
interface HealthResponse {
status: 'healthy' | 'degraded';
checks: HealthChecks;
timestamp: string;
}
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 });
/**
* Liveness probe — confirms the Next.js process is responding.
*
* Returns 200 unconditionally; if the process is wedged or has crashed
* the request never lands here at all. Do NOT include database/Redis/MinIO
* checks in this endpoint — a transient downstream blip should drop the
* pod from the load balancer (readiness), not restart the pod (liveness).
*
* For deep dependency checks, hit `/api/ready` instead.
*/
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
}

View File

@@ -12,31 +12,23 @@ import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, RateLimitError } from '@/lib/errors';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import { publicInterestSchema } from '@/lib/validators/interests';
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
import { parsePhone } from '@/lib/i18n/phone';
import type { CountryCode } from '@/lib/i18n/countries';
// ─── Simple in-memory rate limiter ───────────────────────────────────────────
// Max 5 requests per hour per IP
const ipHits = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
const MAX_HITS = 5;
function checkRateLimit(ip: string): void {
const now = Date.now();
const entry = ipHits.get(ip);
if (!entry || now > entry.resetAt) {
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return;
}
if (entry.count >= MAX_HITS) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
/**
* Throws RateLimitError if the IP has exceeded the public-form quota.
* Backed by the Redis sliding-window limiter so the cap survives restarts
* and is shared across worker processes.
*/
async function gateRateLimit(ip: string): Promise<void> {
const result = await checkRateLimit(ip, rateLimiters.publicForm);
if (!result.allowed) {
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
throw new RateLimitError(retryAfter);
}
entry.count += 1;
}
type PublicInterestData = z.infer<typeof publicInterestSchema>;
@@ -50,7 +42,7 @@ type Tx = typeof db;
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
checkRateLimit(ip);
await gateRateLimit(ip);
const body = await req.json();
const data = publicInterestSchema.parse(body);
@@ -61,6 +53,16 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
}
// Server-side phone normalization for older website builds that post raw
// international/national strings. Newer builds may pre-fill phoneE164/Country.
let phoneE164 = data.phoneE164 ?? null;
let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null;
if (!phoneE164) {
const parsed = parsePhone(data.phone, phoneCountry ?? undefined);
phoneE164 = parsed.e164;
phoneCountry = parsed.country ?? phoneCountry;
}
const fullName =
data.firstName && data.lastName
? `${data.firstName} ${data.lastName}`
@@ -96,17 +98,21 @@ export async function POST(req: NextRequest) {
});
if (existingClient && existingClient.portId === portId) {
clientId = existingClient.id;
const updates: Partial<typeof clients.$inferInsert> = {};
if (data.preferredContactMethod) {
await tx
.update(clients)
.set({ preferredContactMethod: data.preferredContactMethod })
.where(eq(clients.id, clientId));
updates.preferredContactMethod = data.preferredContactMethod;
}
if (data.nationalityIso && !existingClient.nationalityIso) {
updates.nationalityIso = data.nationalityIso;
}
if (Object.keys(updates).length > 0) {
await tx.update(clients).set(updates).where(eq(clients.id, clientId));
}
} else {
clientId = await createClientInTx(tx, portId, fullName, data);
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
}
} else {
clientId = await createClientInTx(tx, portId, fullName, data);
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
}
// 2. Optional: upsert company + add membership
@@ -128,7 +134,8 @@ export async function POST(req: NextRequest) {
name: data.company.name,
legalName: data.company.legalName ?? null,
taxId: data.company.taxId ?? null,
incorporationCountry: data.company.incorporationCountry ?? null,
incorporationCountryIso: data.company.incorporationCountryIso ?? null,
incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null,
status: 'active',
})
.returning();
@@ -198,9 +205,9 @@ export async function POST(req: NextRequest) {
label: 'Primary',
streetAddress: data.address.street ?? null,
city: data.address.city ?? null,
stateProvince: data.address.stateProvince ?? null,
subdivisionIso: data.address.subdivisionIso ?? null,
postalCode: data.address.postalCode ?? null,
country: data.address.country ?? null,
countryIso: data.address.countryIso ?? null,
isPrimary: true,
});
}
@@ -279,7 +286,9 @@ async function createClientInTx(
tx: Tx,
portId: string,
fullName: string,
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod' | 'nationalityIso'>,
phoneE164: string | null,
phoneCountry: CountryCode | null,
): Promise<string> {
const [newClient] = await tx
.insert(clients)
@@ -287,6 +296,7 @@ async function createClientInTx(
portId,
fullName,
preferredContactMethod: data.preferredContactMethod,
nationalityIso: data.nationalityIso ?? null,
source: 'website',
})
.returning();
@@ -303,6 +313,8 @@ async function createClientInTx(
clientId,
channel: 'phone',
value: data.phone,
valueE164: phoneE164,
valueCountry: phoneCountry,
isPrimary: false,
});

View File

@@ -14,26 +14,23 @@ import {
import { env } from '@/lib/env';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
import { emitToRoom } from '@/lib/socket/server';
import { parsePhone } from '@/lib/i18n/phone';
import type { CountryCode } from '@/lib/i18n/countries';
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
const ipHits = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60 * 60 * 1000;
const MAX_HITS = 5;
function checkRateLimit(ip: string): void {
const now = Date.now();
const entry = ipHits.get(ip);
if (!entry || now > entry.resetAt) {
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return;
/**
* Throws RateLimitError if the IP has exceeded the public-form quota.
* Backed by the Redis sliding-window limiter so the cap survives restarts
* and is shared across worker processes.
*/
async function gateRateLimit(ip: string): Promise<void> {
const result = await checkRateLimit(ip, rateLimiters.publicForm);
if (!result.allowed) {
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
throw new RateLimitError(retryAfter);
}
if (entry.count >= MAX_HITS) {
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
}
entry.count += 1;
}
/**
@@ -47,7 +44,7 @@ function checkRateLimit(ip: string): void {
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
checkRateLimit(ip);
await gateRateLimit(ip);
const body = await req.json();
const data = publicResidentialInquirySchema.parse(body);
@@ -61,6 +58,16 @@ export async function POST(req: NextRequest) {
throw new ValidationError('Unknown port');
}
// If the website didn't pre-normalize, parse server-side. International
// strings parse without a hint; national-format submissions need a country.
let phoneE164 = data.phoneE164 ?? null;
let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null;
if (!phoneE164) {
const parsed = parsePhone(data.phone, phoneCountry ?? undefined);
phoneE164 = parsed.e164;
phoneCountry = parsed.country ?? phoneCountry;
}
const result = await withTransaction(async (tx) => {
const [client] = await tx
.insert(residentialClients)
@@ -69,7 +76,13 @@ export async function POST(req: NextRequest) {
fullName: `${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
email: data.email,
phone: data.phone,
phoneE164,
phoneCountry,
nationalityIso: data.nationalityIso ?? null,
timezone: data.timezone ?? null,
placeOfResidence: data.placeOfResidence,
placeOfResidenceCountryIso: data.placeOfResidenceCountryIso ?? null,
subdivisionIso: data.subdivisionIso ?? null,
preferredContactMethod: data.preferredContactMethod,
source: 'website',
status: 'prospect',

View File

@@ -0,0 +1,82 @@
import { NextResponse } from 'next/server';
import { sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { redis } from '@/lib/redis';
import { minioClient } from '@/lib/minio';
import { env } from '@/lib/env';
type CheckStatus = 'ok' | 'error';
interface ReadyChecks {
postgres: CheckStatus;
redis: CheckStatus;
minio: CheckStatus;
}
interface ReadyResponse {
status: 'ready' | 'degraded';
checks: ReadyChecks;
timestamp: string;
}
/**
* Readiness probe — verifies that every backing service this process
* needs to serve traffic is reachable. A 503 should drop the pod from the
* load balancer until the next probe succeeds; it should not trigger a
* pod restart (that's what `/api/health` is for).
*
* Checks:
* - postgres: `SELECT 1` against the primary
* - redis: `PING`
* - minio: `bucketExists(<configured-bucket>)`
*
* Documenso + SMTP are intentionally not probed here: they're optional
* integrations, and each tenant configures its own credentials. A
* tenant-misconfigured Documenso instance shouldn't deadline the entire
* shared CRM.
*/
export async function GET(): Promise<NextResponse<ReadyResponse>> {
const checks: ReadyChecks = {
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 allReady = Object.values(checks).every((s) => s === 'ok');
const status: ReadyResponse['status'] = allReady ? 'ready' : 'degraded';
return NextResponse.json(
{ status, checks, timestamp: new Date().toISOString() },
{ status: allReady ? 200 : 503 },
);
}

View File

@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
getAiBudget,
setAiBudget,
currentPeriodTokens,
periodBreakdown,
} from '@/lib/services/ai-budget.service';
const saveSchema = z.object({
enabled: z.boolean().optional(),
softCapTokens: z.number().int().nonnegative().max(100_000_000).optional(),
hardCapTokens: z.number().int().nonnegative().max(100_000_000).optional(),
period: z.enum(['day', 'week', 'month']).optional(),
});
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const [budget, used, breakdown] = await Promise.all([
getAiBudget(ctx.portId),
currentPeriodTokens(ctx.portId),
periodBreakdown(ctx.portId),
]);
return NextResponse.json({ data: { budget, used, breakdown } });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PUT = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, saveSchema);
const next = await setAiBudget(ctx.portId, body, ctx.userId);
return NextResponse.json({ data: next });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
/**
* Admin trigger for an immediate alert engine sweep over the caller's port.
* Useful for manual ops ("re-evaluate now after I fixed a rule") and
* exercised by the realapi socket fanout test.
*
* Requires super_admin or per-port admin permissions; the engine itself
* is idempotent — duplicate runs only re-evaluate, never duplicate rows.
*/
export const POST = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
}
const summary = await runAlertEngineForPorts([ctx.portId]);
return NextResponse.json({ data: summary });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -1,29 +1,76 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { inArray } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { listAuditLogs } from '@/lib/services/audit.service';
import { searchAuditLogs } from '@/lib/services/audit-search.service';
import { db } from '@/lib/db';
import { user } from '@/lib/db/schema/users';
import { errorResponse } from '@/lib/errors';
const auditQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
limit: z.coerce.number().int().min(1).max(200).default(50),
entityType: z.string().optional(),
action: z.string().optional(),
userId: z.string().optional(),
entityId: z.string().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
/** Free-text query against the tsvector `search_text` column. */
search: z.string().optional(),
/** Cursor pair from the previous page's response. */
cursorAt: z.string().optional(),
cursorId: z.string().optional(),
});
export const GET = withAuth(
withPermission('admin', 'view_audit_log', async (req, ctx) => {
try {
const query = parseQuery(req, auditQuerySchema);
const result = await listAuditLogs(ctx.portId, query);
return NextResponse.json(result);
const cursor =
query.cursorAt && query.cursorId
? { createdAt: new Date(query.cursorAt), id: query.cursorId }
: undefined;
const { rows, nextCursor } = await searchAuditLogs({
portId: ctx.portId,
q: query.search,
userId: query.userId,
action: query.action,
entityType: query.entityType,
entityId: query.entityId,
from: query.dateFrom ? new Date(query.dateFrom) : undefined,
to: query.dateTo ? new Date(query.dateTo) : undefined,
cursor,
limit: query.limit,
});
// Resolve actor emails in one batched query so the table can show
// who did what without N+1 round trips.
const userIds = Array.from(
new Set(rows.map((r) => r.userId).filter((id): id is string => Boolean(id))),
);
const userRows = userIds.length
? await db
.select({ id: user.id, email: user.email, name: user.name })
.from(user)
.where(inArray(user.id, userIds))
: [];
const userMap = new Map(userRows.map((u) => [u.id, u]));
const data = rows.map((r) => ({
...r,
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
}));
return NextResponse.json({
data,
pagination: {
nextCursor: nextCursor
? { createdAt: nextCursor.createdAt.toISOString(), id: nextCursor.id }
: null,
},
});
} catch (error) {
return errorResponse(error);
}

View File

@@ -1,12 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { resendCrmInvite } from '@/lib/services/crm-invite.service';
// Resend mints a fresh token + new email on a global invite row;
// restrict to super-admins to match revoke/list and avoid cross-tenant
// re-issuance of foreign-port invitations.
export const POST = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Resending CRM invites requires super-admin');
}
const id = params.id ?? '';
const result = await resendCrmInvite(id, {
userId: ctx.userId,

View File

@@ -1,12 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
// Invites are a global resource (no portId column). Revoking a foreign
// tenant's pending invite by id would be cross-tenant tampering;
// restrict to super-admins to match the listing endpoint.
export const DELETE = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Revoking CRM invites requires super-admin');
}
const id = params.id ?? '';
await revokeCrmInvite(id, {
userId: ctx.userId,

View File

@@ -3,12 +3,20 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, _ctx) => {
withPermission('admin', 'manage_users', async (_req, ctx) => {
try {
// crm_user_invites is a global table (no per-port column) — invites
// mint better-auth users that may later be assigned roles in any
// port. Listing it cross-tenant would let a port-A director
// enumerate pending invitee emails, names, and isSuperAdmin flags
// for every other tenant. Restrict the listing to super-admins.
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Listing CRM invites requires super-admin');
}
const data = await listCrmInvites();
return NextResponse.json({ data });
} catch (error) {
@@ -24,10 +32,17 @@ const createInviteSchema = z.object({
});
export const POST = withAuth(
withPermission('admin', 'manage_users', async (req, _ctx) => {
withPermission('admin', 'manage_users', async (req, ctx) => {
try {
const body = await parseBody(req, createInviteSchema);
const result = await createCrmInvite(body);
// Only existing super-admins can mint super-admin invitations. The
// manage_users permission is granted to port-scoped director roles,
// which must not be able to elevate themselves cross-tenant by
// inviting a fresh super_admin.
if (body.isSuperAdmin && !ctx.isSuperAdmin) {
throw new ForbiddenError('Only super admins can mint super-admin invitations');
}
const result = await createCrmInvite({ ...body, invitedBy: ctx });
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {
return errorResponse(error);

View File

@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getPublicOcrConfig, saveOcrConfig, OCR_MODELS } from '@/lib/services/ocr-config.service';
const saveSchema = z.object({
/** When 'global', requires super_admin and stores at port_id=null. */
scope: z.enum(['port', 'global']),
provider: z.enum(['openai', 'claude']),
model: z.string().min(1),
apiKey: z.string().optional(),
clearApiKey: z.boolean().optional(),
useGlobal: z.boolean().optional(),
aiEnabled: z.boolean().optional(),
});
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
// may read or write the OCR config: the apiKey is stored encrypted but is
// passed straight into the receipt-scan handler, so a swapped key would
// exfiltrate every subsequent receipt image to whatever endpoint that key
// authenticates with.
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const url = new URL(req.url);
const scope = url.searchParams.get('scope') ?? 'port';
if (scope === 'global' && !ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
}
const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId);
return NextResponse.json({ data: config, models: OCR_MODELS });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PUT = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, saveSchema);
if (body.scope === 'global' && !ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
}
const validModels = OCR_MODELS[body.provider];
if (!validModels.includes(body.model)) {
return NextResponse.json(
{ error: `Invalid model for provider ${body.provider}` },
{ status: 400 },
);
}
await saveOcrConfig(
body.scope === 'global' ? null : ctx.portId,
{
provider: body.provider,
model: body.model,
apiKey: body.apiKey,
clearApiKey: body.clearApiKey,
useGlobal: body.useGlobal,
aiEnabled: body.aiEnabled,
},
ctx.userId,
);
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { OCR_MODELS } from '@/lib/services/ocr-config.service';
import { testProvider } from '@/lib/services/ocr-providers';
const schema = z.object({
provider: z.enum(['openai', 'claude']),
model: z.string().min(1),
apiKey: z.string().min(1),
});
// `manage_settings`-gated for parity with the parent OCR settings route —
// triggers outbound AI provider auth requests using a caller-supplied key.
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req) => {
try {
const body = await parseBody(req, schema);
if (!OCR_MODELS[body.provider].includes(body.model)) {
return NextResponse.json({ error: 'Invalid model' }, { status: 400 });
}
const result = await testProvider(body.provider, body.apiKey, body.model);
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -4,11 +4,25 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { getPort, updatePort } from '@/lib/services/ports.service';
import { updatePortSchema } from '@/lib/validators/ports';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ForbiddenError } from '@/lib/errors';
/**
* Non-super-admin callers (e.g. port directors holding admin.manage_settings)
* may only read/mutate THEIR OWN port row. The path id is therefore
* compared against ctx.portId and a foreign target is rejected before the
* service is touched. Super-admins retain unrestricted access.
*/
function assertPortInScope(targetPortId: string, ctx: { portId: string; isSuperAdmin: boolean }) {
if (ctx.isSuperAdmin) return;
if (targetPortId !== ctx.portId) {
throw new ForbiddenError('Cross-tenant port access denied');
}
}
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, _ctx, params) => {
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
assertPortInScope(params.id!, ctx);
const data = await getPort(params.id!);
return NextResponse.json({ data });
} catch (error) {
@@ -20,6 +34,7 @@ export const GET = withAuth(
export const PATCH = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
assertPortInScope(params.id!, ctx);
const body = await parseBody(req, updatePortSchema);
const data = await updatePort(params.id!, body, {
userId: ctx.userId,

View File

@@ -4,11 +4,18 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { listPorts, createPort } from '@/lib/services/ports.service';
import { createPortSchema } from '@/lib/validators/ports';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ForbiddenError } from '@/lib/errors';
// Listing every tenant and creating new tenants are super-admin operations:
// a port director must not be able to enumerate other ports (target
// discovery for cross-tenant attacks) or spin up new tenants whose admin
// they implicitly become.
export const GET = withAuth(
withPermission('admin', 'manage_settings', async () => {
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Listing all ports requires super-admin');
}
const data = await listPorts();
return NextResponse.json({ data });
} catch (error) {
@@ -20,6 +27,9 @@ export const GET = withAuth(
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Creating ports requires super-admin');
}
const body = await parseBody(req, createPortSchema);
const data = await createPort(body, {
userId: ctx.userId,

View File

@@ -4,14 +4,17 @@ import { withAuth } from '@/lib/api/helpers';
import { getEmailDraftResult } from '@/lib/services/email-draft.service';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(async (_req, _ctx, params) => {
export const GET = withAuth(async (_req, ctx, params) => {
try {
const { jobId } = params;
if (!jobId) {
return NextResponse.json({ error: 'jobId is required' }, { status: 400 });
}
const result = await getEmailDraftResult(jobId);
const result = await getEmailDraftResult(jobId, {
userId: ctx.userId,
portId: ctx.portId,
});
if (result === null) {
return NextResponse.json({ status: 'processing' });

View File

@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { acknowledgeAlert } from '@/lib/services/alerts.service';
export const POST = withAuth(async (_req, ctx, params) => {
const id = params.id;
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
await acknowledgeAlert(id, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
});

View File

@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { dismissAlert } from '@/lib/services/alerts.service';
export const POST = withAuth(async (_req, ctx, params) => {
const id = params.id;
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
await dismissAlert(id, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
});

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { alerts } from '@/lib/db/schema/insights';
export const GET = withAuth(async (_req, ctx) => {
const rows = await db
.select({ severity: alerts.severity, count: sql<number>`count(*)::int` })
.from(alerts)
.where(
and(eq(alerts.portId, ctx.portId), isNull(alerts.resolvedAt), isNull(alerts.dismissedAt)),
)
.groupBy(alerts.severity);
const bySeverity = { info: 0, warning: 0, critical: 0 } as Record<string, number>;
let total = 0;
for (const r of rows) {
bySeverity[r.severity] = r.count;
total += r.count;
}
return NextResponse.json({ total, bySeverity });
});

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { listAlertsForPort } from '@/lib/services/alerts.service';
type AlertStatus = 'open' | 'dismissed' | 'resolved';
export const GET = withAuth(async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
const rows = await listAlertsForPort(ctx.portId, {
includeDismissed: status !== 'open',
includeResolved: status !== 'open',
});
// Filter to the requested status bucket so callers don't see overlap.
const filtered = rows.filter((a) => {
if (status === 'open') return !a.dismissedAt && !a.resolvedAt;
if (status === 'dismissed') return Boolean(a.dismissedAt) && !a.resolvedAt;
if (status === 'resolved') return Boolean(a.resolvedAt);
return true;
});
return NextResponse.json({ data: filtered });
});

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import {
ALL_RANGES,
getLeadSourceAttribution,
getOccupancyTimeline,
getPipelineFunnel,
getRevenueBreakdown,
type DateRange,
type MetricBase,
} from '@/lib/services/analytics.service';
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
pipeline_funnel: getPipelineFunnel,
occupancy_timeline: getOccupancyTimeline,
revenue_breakdown: getRevenueBreakdown,
lead_source_attribution: getLeadSourceAttribution,
};
export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const metric = url.searchParams.get('metric') as MetricBase | null;
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
if (!metric || !(metric in METRICS)) {
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
}
if (!ALL_RANGES.includes(range)) {
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
}
const data = await METRICS[metric](ctx.portId, range);
return NextResponse.json({ metric, range, data });
}),
);

View File

@@ -8,7 +8,7 @@ import { reorderWaitingListSchema } from '@/lib/validators/interests';
import { getWaitingList, updateWaitingList } from '@/lib/services/berths.service';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { db } from '@/lib/db';
import { berthWaitingList } from '@/lib/db/schema/berths';
import { berths, berthWaitingList } from '@/lib/db/schema/berths';
// GET /api/v1/berths/[id]/waiting-list
export const GET = withAuth(
@@ -47,11 +47,17 @@ export const PATCH = withAuth(
const body = await parseBody(req, reorderWaitingListSchema);
const berthId = params.id!;
// Tenant scope: refuse to reorder a foreign-port berth's waiting
// list. The route's URL id and the entry id are otherwise enough
// for any user with manage_waiting_list to mutate any tenant's
// queue ordering.
const berthRow = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, ctx.portId)),
});
if (!berthRow) throw new NotFoundError('Berth');
const entry = await db.query.berthWaitingList.findFirst({
where: and(
eq(berthWaitingList.id, body.entryId),
eq(berthWaitingList.berthId, berthId),
),
where: and(eq(berthWaitingList.id, body.entryId), eq(berthWaitingList.berthId, berthId)),
});
if (!entry) throw new NotFoundError('Waiting list entry');

View File

@@ -1,15 +1,17 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getBerthOptions } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/options — lightweight list for selects/comboboxes
export const GET = withAuth(async (req, ctx) => {
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => {
try {
const options = await getBerthOptions(ctx.portId);
return NextResponse.json({ data: options });
} catch (error) {
return errorResponse(error);
}
});
}),
);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { updateClientAddress, removeClientAddress } from '@/lib/services/clients.service';
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
const updateAddressSchema = z.object({
label: z.string().min(1).max(80).optional(),
streetAddress: z.string().max(500).optional().nullable(),
city: z.string().max(120).optional().nullable(),
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
postalCode: z.string().max(40).optional().nullable(),
countryIso: optionalCountryIsoSchema.optional(),
isPrimary: z.boolean().optional(),
});
export const PATCH = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateAddressSchema);
const row = await updateClientAddress(params.addressId!, params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: row });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
await removeClientAddress(params.addressId!, params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listClientAddresses, addClientAddress } from '@/lib/services/clients.service';
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
const addAddressSchema = z.object({
label: z.string().min(1).max(80).optional(),
streetAddress: z.string().max(500).optional().nullable(),
city: z.string().max(120).optional().nullable(),
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
postalCode: z.string().max(40).optional().nullable(),
countryIso: optionalCountryIsoSchema.optional(),
isPrimary: z.boolean().optional(),
});
export const GET = withAuth(
withPermission('clients', 'view', async (req, ctx, params) => {
try {
const rows = await listClientAddresses(params.id!, ctx.portId);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, addAddressSchema);
const row = await addClientAddress(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: row }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -5,10 +5,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { updateContact, removeContact } from '@/lib/services/clients.service';
import { optionalCountryIsoSchema, optionalPhoneE164Schema } from '@/lib/validators/i18n';
const updateContactSchema = z.object({
channel: z.enum(['email', 'phone', 'whatsapp', 'other']).optional(),
value: z.string().min(1).optional(),
valueE164: optionalPhoneE164Schema.optional(),
valueCountry: optionalCountryIsoSchema.optional(),
label: z.string().optional(),
isPrimary: z.boolean().optional(),
notes: z.string().optional(),
@@ -18,18 +21,12 @@ export const PATCH = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateContactSchema);
const contact = await updateContact(
params.contactId!,
params.id!,
ctx.portId,
body,
{
const contact = await updateContact(params.contactId!, params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
});
return NextResponse.json({ data: contact });
} catch (error) {
return errorResponse(error);

View File

@@ -5,10 +5,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listContacts, addContact } from '@/lib/services/clients.service';
import { optionalCountryIsoSchema, optionalPhoneE164Schema } from '@/lib/validators/i18n';
const addContactSchema = z.object({
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
value: z.string().min(1),
valueE164: optionalPhoneE164Schema.optional(),
valueCountry: optionalCountryIsoSchema.optional(),
label: z.string().optional(),
isPrimary: z.boolean().optional().default(false),
notes: z.string().optional(),

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getExportDownloadUrl } from '@/lib/services/gdpr-export.service';
/**
* Returns a fresh signed URL for an existing GDPR export. Staff use this
* from the admin UI; the email path embeds its own signed URL.
*/
export const GET = withAuth(
withPermission(
'admin',
'manage_settings',
withRateLimit('exports', async (req, ctx, params) => {
try {
const url = await getExportDownloadUrl(params.exportId!, ctx.portId);
return NextResponse.json({ data: { url } });
} catch (error) {
return errorResponse(error);
}
}),
),
);

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { requestGdprExport, listClientExports } from '@/lib/services/gdpr-export.service';
const requestSchema = z.object({
/** When true, the bundle is emailed to the client once it finishes building. */
emailToClient: z.boolean().optional().default(false),
/** Optional override recipient (e.g. legal counsel). Skips the primary-email lookup. */
emailOverride: z.string().email().optional().nullable(),
});
export const GET = withAuth(
withPermission('clients', 'view', async (req, ctx, params) => {
try {
const rows = await listClientExports(params.id!, ctx.portId);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission(
'admin',
'manage_settings',
withRateLimit('exports', async (req, ctx, params) => {
try {
const body = await parseBody(req, requestSchema);
const result = await requestGdprExport({
clientId: params.id!,
portId: ctx.portId,
requestedBy: ctx.userId,
emailToClient: body.emailToClient,
emailOverride: body.emailOverride ?? null,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result.export }, { status: 202 });
} catch (error) {
return errorResponse(error);
}
}),
),
);

View File

@@ -1,10 +1,11 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { listClientOptions } from '@/lib/services/clients.service';
export const GET = withAuth(async (req, ctx) => {
export const GET = withAuth(
withPermission('clients', 'view', async (req, ctx) => {
try {
const search = req.nextUrl.searchParams.get('search') ?? undefined;
const data = await listClientOptions(ctx.portId, search);
@@ -12,4 +13,5 @@ export const GET = withAuth(async (req, ctx) => {
} catch (error) {
return errorResponse(error);
}
});
}),
);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { updateCompanyAddress, removeCompanyAddress } from '@/lib/services/companies.service';
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
const updateAddressSchema = z.object({
label: z.string().min(1).max(80).optional(),
streetAddress: z.string().max(500).optional().nullable(),
city: z.string().max(120).optional().nullable(),
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
postalCode: z.string().max(40).optional().nullable(),
countryIso: optionalCountryIsoSchema.optional(),
isPrimary: z.boolean().optional(),
});
export const PATCH = withAuth(
withPermission('companies', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateAddressSchema);
const row = await updateCompanyAddress(params.addressId!, params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: row });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('companies', 'edit', async (req, ctx, params) => {
try {
await removeCompanyAddress(params.addressId!, params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listCompanyAddresses, addCompanyAddress } from '@/lib/services/companies.service';
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
const addAddressSchema = z.object({
label: z.string().min(1).max(80).optional(),
streetAddress: z.string().max(500).optional().nullable(),
city: z.string().max(120).optional().nullable(),
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
postalCode: z.string().max(40).optional().nullable(),
countryIso: optionalCountryIsoSchema.optional(),
isPrimary: z.boolean().optional(),
});
export const GET = withAuth(
withPermission('companies', 'view', async (req, ctx, params) => {
try {
const rows = await listCompanyAddresses(params.id!, ctx.portId);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('companies', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, addAddressSchema);
const row = await addCompanyAddress(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: row }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,11 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { setValuesSchema } from '@/lib/validators/custom-fields';
import { getValues, setValues } from '@/lib/services/custom-fields.service';
export const GET = withAuth(async (_req: NextRequest, ctx, params) => {
// Custom-field values live on top of a port-scoped entity (client, yacht,
// interest, berth, company). Reading the values is in scope for any role
// that can view clients (the most common surface); writing requires the
// equivalent edit permission. The service-layer also re-validates the
// entityId against the field definition's entityType + portId so a
// caller cannot poke values onto an arbitrary or foreign-port entity.
export const GET = withAuth(
withPermission('clients', 'view', async (_req: NextRequest, ctx, params) => {
try {
const { entityId } = params;
if (!entityId) throw new NotFoundError('Entity');
@@ -15,9 +22,11 @@ export const GET = withAuth(async (_req: NextRequest, ctx, params) => {
} catch (error) {
return errorResponse(error);
}
});
}),
);
export const PUT = withAuth(async (req: NextRequest, ctx, params) => {
export const PUT = withAuth(
withPermission('clients', 'edit', async (req: NextRequest, ctx, params) => {
try {
const { entityId } = params;
if (!entityId) throw new NotFoundError('Entity');
@@ -42,4 +51,5 @@ export const PUT = withAuth(async (req: NextRequest, ctx, params) => {
} catch (error) {
return errorResponse(error);
}
});
}),
);

View File

@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getRecentActivity } from '@/lib/services/dashboard.service';
export const GET = withAuth(async (req: NextRequest, ctx) => {
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
const result = await getRecentActivity(ctx.portId);
return NextResponse.json(result);
});
}),
);

View File

@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getRevenueForecast } from '@/lib/services/dashboard.service';
export const GET = withAuth(async (req: NextRequest, ctx) => {
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
const result = await getRevenueForecast(ctx.portId);
return NextResponse.json(result);
});
}),
);

View File

@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getKpis } from '@/lib/services/dashboard.service';
export const GET = withAuth(async (req: NextRequest, ctx) => {
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
const result = await getKpis(ctx.portId);
return NextResponse.json(result);
});
}),
);

View File

@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getPipelineCounts } from '@/lib/services/dashboard.service';
export const GET = withAuth(async (req: NextRequest, ctx) => {
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
const result = await getPipelineCounts(ctx.portId);
return NextResponse.json(result);
});
}),
);

View File

@@ -1,14 +1,32 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { db } from '@/lib/db';
import { emailAccounts } from '@/lib/db/schema/email';
import { errorResponse, ForbiddenError, NotFoundError } from '@/lib/errors';
import { getQueue } from '@/lib/queue';
export const POST = withAuth(
withPermission('email', 'view', async (_req, _ctx, params) => {
withPermission('email', 'view', async (_req, ctx, params) => {
try {
const accountId = params.accountId!;
// Owner check: the sibling toggle/disconnect endpoints already enforce
// account.userId === ctx.userId. Without the same check here, any
// user with `email:view` could force IMAP sync against a foreign
// account, advancing lastSyncAt (data-loss risk on the legitimate
// owner's next sync) and triggering work using the foreign user's
// decrypted credentials.
const account = await db.query.emailAccounts.findFirst({
where: eq(emailAccounts.id, accountId),
});
if (!account) throw new NotFoundError('Email account');
if (account.userId !== ctx.userId) {
throw new ForbiddenError('You do not own this email account');
}
const queue = getQueue('email');
const job = await queue.add('inbox-sync', { accountId: params.accountId! });
const job = await queue.add('inbox-sync', { accountId });
return NextResponse.json({ data: { jobId: job.id } }, { status: 202 });
} catch (error) {
return errorResponse(error);

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { clearDuplicate } from '@/lib/services/expense-dedup.service';
export const POST = withAuth(
withPermission('expenses', 'edit', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
await clearDuplicate(id, ctx.portId);
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
const mergeSchema = z.object({
/** Surviving expense id — typically the row's existing `duplicateOf` pointer. */
targetId: z.string().min(1),
});
export const POST = withAuth(
withPermission('expenses', 'edit', async (req, ctx, params) => {
try {
const sourceId = params.id;
if (!sourceId) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const body = await parseBody(req, mergeSchema);
await mergeDuplicate(sourceId, body.targetId, ctx.portId);
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,27 +1,117 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { scanReceipt } from '@/lib/services/receipt-scanner';
import { logger } from '@/lib/logger';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
import {
runOcr,
type ParsedReceipt,
OCR_FEATURE,
OCR_ESTIMATED_TOKENS,
} from '@/lib/services/ocr-providers';
import { checkBudget, recordAiUsage } from '@/lib/services/ai-budget.service';
const EMPTY: ParsedReceipt = {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
};
export const POST = withAuth(
withPermission('expenses', 'create', async (req, _ctx) => {
withPermission(
'expenses',
'create',
withRateLimit('ocr', async (req, ctx) => {
try {
const formData = await req.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/jpeg';
const result = await scanReceipt(buffer, mimeType);
const config = await getResolvedOcrConfig(ctx.portId);
// Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its
// local Tesseract result.
if (!config.aiEnabled) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'ai-disabled' },
});
}
if (!config.apiKey) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'no-ocr-configured' },
});
}
return NextResponse.json({ data: result });
// Per-port budget gate — refuse the call before we spend tokens
// when the port has already hit its hard cap, or when the request
// would push it past the cap. Soft-cap warnings ride along on the
// success response so the UI can show a banner without blocking.
const budget = await checkBudget({
portId: ctx.portId,
estimatedTokens: OCR_ESTIMATED_TOKENS,
});
if (!budget.ok) {
return NextResponse.json({
data: {
parsed: EMPTY,
source: 'manual',
reason: 'budget-exceeded',
providerError: `AI budget reached (${budget.usedTokens}/${budget.capTokens} tokens this period).`,
},
});
}
try {
const result = await runOcr({
provider: config.provider,
model: config.model,
apiKey: config.apiKey,
imageBuffer: buffer,
mimeType,
});
await recordAiUsage({
portId: ctx.portId,
userId: ctx.userId,
feature: OCR_FEATURE,
provider: config.provider,
model: config.model,
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
requestId: result.usage.requestId,
});
return NextResponse.json({
data: {
parsed: result.parsed,
source: 'ai',
provider: config.provider,
model: config.model,
softCapWarning: budget.softCap,
},
});
} catch (err) {
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
// Provider hiccup — degrade to manual entry rather than 500-ing.
return NextResponse.json({
data: {
parsed: EMPTY,
source: 'manual',
reason: 'provider-error',
providerError: err instanceof Error ? err.message.slice(0, 200) : 'Unknown error',
},
});
}
} catch (error) {
return errorResponse(error);
}
}),
),
);

View File

@@ -0,0 +1,195 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { apiFetch } from '@/lib/api/client';
type Period = 'day' | 'week' | 'month';
interface BudgetResp {
data: {
budget: { enabled: boolean; softCapTokens: number; hardCapTokens: number; period: Period };
used: number;
breakdown: Array<{ feature: string; tokens: number; calls: number }>;
};
}
function formatNum(n: number): string {
return n.toLocaleString();
}
export function AiBudgetCard() {
const qc = useQueryClient();
const queryKey = ['admin-ai-budget'];
const { data, isLoading } = useQuery<BudgetResp>({
queryKey,
queryFn: () => apiFetch<BudgetResp>('/api/v1/admin/ai-budget'),
});
const [enabled, setEnabled] = useState(false);
const [softCap, setSoftCap] = useState('100000');
const [hardCap, setHardCap] = useState('500000');
const [period, setPeriod] = useState<Period>('month');
useEffect(() => {
if (!data?.data) return;
setEnabled(data.data.budget.enabled);
setSoftCap(String(data.data.budget.softCapTokens));
setHardCap(String(data.data.budget.hardCapTokens));
setPeriod(data.data.budget.period);
}, [data?.data]);
const save = useMutation({
mutationFn: () =>
apiFetch('/api/v1/admin/ai-budget', {
method: 'PUT',
body: {
enabled,
softCapTokens: Number.parseInt(softCap || '0', 10),
hardCapTokens: Number.parseInt(hardCap || '0', 10),
period,
},
}),
onSuccess: () => qc.invalidateQueries({ queryKey }),
});
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>AI cost guardrails</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</CardContent>
</Card>
);
}
const used = data?.data.used ?? 0;
const hard = data?.data.budget.hardCapTokens ?? 0;
const soft = data?.data.budget.softCapTokens ?? 0;
const pctOfHard = hard > 0 ? Math.min(100, Math.round((used / hard) * 100)) : 0;
const breakdown = data?.data.breakdown ?? [];
return (
<Card>
<CardHeader>
<CardTitle>AI cost guardrails</CardTitle>
<p className="text-sm text-muted-foreground">
Cap how many AI tokens this port can spend per period. The hard cap blocks new calls; the
soft cap surfaces a warning banner. Tokens are the unit both OpenAI and Anthropic bill on,
so the cap survives model price changes.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
<div className="flex items-baseline justify-between text-sm">
<span className="font-medium">
This {period}: {formatNum(used)} tokens
</span>
<span className="text-muted-foreground">
soft {formatNum(soft)} · hard {formatNum(hard)}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full transition-all ${
used >= hard ? 'bg-destructive' : used >= soft ? 'bg-amber-500' : 'bg-emerald-500'
}`}
style={{ width: `${pctOfHard}%` }}
/>
</div>
{breakdown.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-0.5 pt-1">
{breakdown.map((b) => (
<li key={b.feature} className="flex justify-between">
<span className="capitalize">{b.feature.replace(/_/g, ' ')}</span>
<span>
{formatNum(b.tokens)} tokens · {b.calls} call{b.calls === 1 ? '' : 's'}
</span>
</li>
))}
</ul>
) : null}
</div>
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id="ai-budget-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor="ai-budget-enabled" className="text-sm font-medium">
Enforce token caps for this port
</Label>
<p className="text-xs text-muted-foreground">
When off, usage is still recorded for visibility but no requests are blocked.
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="period">Period</Label>
<Select value={period} onValueChange={(v) => setPeriod(v as Period)}>
<SelectTrigger id="period">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">Day (UTC)</SelectItem>
<SelectItem value="week">Week (MonSun UTC)</SelectItem>
<SelectItem value="month">Calendar month (UTC)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="soft-cap">Soft cap (tokens)</Label>
<Input
id="soft-cap"
type="number"
min="0"
value={softCap}
onChange={(e) => setSoftCap(e.target.value)}
disabled={!enabled}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hard-cap">Hard cap (tokens)</Label>
<Input
id="hard-cap"
type="number"
min="0"
value={hardCap}
onChange={(e) => setHardCap(e.target.value)}
disabled={!enabled}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={() => save.mutate()} disabled={save.isPending}>
{save.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
Save guardrails
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,14 +1,16 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns';
import { Search } from 'lucide-react';
import { Search, X } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -23,13 +25,19 @@ interface AuditEntry {
userId: string | null;
action: string;
entityType: string;
entityId: string;
entityId: string | null;
fieldChanged: string | null;
oldValue: Record<string, unknown> | null;
newValue: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
ipAddress: string | null;
createdAt: string;
actor: { id: string; email: string; name: string } | null;
}
interface AuditResponse {
data: AuditEntry[];
pagination: { nextCursor: { createdAt: string; id: string } | null };
}
const ACTION_COLORS: Record<string, string> = {
@@ -40,6 +48,8 @@ const ACTION_COLORS: Record<string, string> = {
restore: 'bg-teal-500',
login: 'bg-gray-500',
permission_denied: 'bg-red-800',
merge: 'bg-purple-500',
revert: 'bg-amber-500',
};
const ENTITY_TYPES = [
@@ -58,40 +68,96 @@ const ENTITY_TYPES = [
'webhook',
];
function useDebounced<T>(value: T, ms = 300): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
export function AuditLogList() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [nextCursor, setNextCursor] = useState<{
createdAt: string;
id: string;
} | null>(null);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
const [actionFilter, setActionFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const [loadingMore, setLoadingMore] = useState(false);
const fetchLogs = useCallback(async () => {
// Filter state — debounce text inputs.
const [search, setSearch] = useState('');
const [entityType, setEntityType] = useState<string>('all');
const [action, setAction] = useState<string>('all');
const [userId, setUserId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const debouncedSearch = useDebounced(search);
const debouncedUserId = useDebounced(userId);
const queryString = useMemo(() => {
const params = new URLSearchParams({ limit: '50' });
if (entityType !== 'all') params.set('entityType', entityType);
if (action !== 'all') params.set('action', action);
if (debouncedSearch) params.set('search', debouncedSearch);
if (debouncedUserId) params.set('userId', debouncedUserId);
if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString());
if (dateTo) {
const end = new Date(dateTo);
end.setHours(23, 59, 59, 999);
params.set('dateTo', end.toISOString());
}
return params.toString();
}, [entityType, action, debouncedSearch, debouncedUserId, dateFrom, dateTo]);
const fetchFirstPage = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
limit: '50',
});
if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
if (actionFilter !== 'all') params.set('action', actionFilter);
if (search) params.set('search', search);
const res = await apiFetch<{
data: AuditEntry[];
pagination: { total: number };
}>(`/api/v1/admin/audit?${params}`);
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${queryString}`);
setEntries(res.data);
setTotal(res.pagination.total);
setNextCursor(res.pagination.nextCursor);
} finally {
setLoading(false);
}
}, [page, entityTypeFilter, actionFilter, search]);
}, [queryString]);
const loadMore = useCallback(async () => {
if (!nextCursor) return;
setLoadingMore(true);
try {
const params = new URLSearchParams(queryString);
params.set('cursorAt', nextCursor.createdAt);
params.set('cursorId', nextCursor.id);
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${params}`);
setEntries((prev) => [...prev, ...res.data]);
setNextCursor(res.pagination.nextCursor);
} finally {
setLoadingMore(false);
}
}, [queryString, nextCursor]);
useEffect(() => {
void fetchLogs();
}, [fetchLogs]);
void fetchFirstPage();
}, [fetchFirstPage]);
function clearFilters() {
setSearch('');
setEntityType('all');
setAction('all');
setUserId('');
setDateFrom('');
setDateTo('');
}
const hasActiveFilter =
Boolean(search) ||
entityType !== 'all' ||
action !== 'all' ||
Boolean(userId) ||
Boolean(dateFrom) ||
Boolean(dateTo);
const columns: ColumnDef<AuditEntry, unknown>[] = [
{
@@ -117,7 +183,7 @@ export function AuditLogList() {
{row.original.action}
</Badge>
),
size: 100,
size: 110,
},
{
accessorKey: 'entityType',
@@ -125,9 +191,11 @@ export function AuditLogList() {
cell: ({ row }) => (
<div>
<span className="font-medium capitalize">{row.original.entityType}</span>
{row.original.entityId ? (
<code className="ml-2 text-xs text-muted-foreground">
{row.original.entityId.slice(0, 8)}...
{row.original.entityId.slice(0, 8)}
</code>
) : null}
</div>
),
},
@@ -150,46 +218,62 @@ export function AuditLogList() {
},
},
{
accessorKey: 'userId',
header: 'User',
cell: ({ row }) => (
<code className="text-xs">
{row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
</code>
),
size: 100,
id: 'actor',
header: 'Actor',
cell: ({ row }) => {
const { actor, userId: rawId } = row.original;
if (actor) {
return (
<div className="text-sm">
<div className="font-medium">{actor.name}</div>
<div className="text-xs text-muted-foreground">{actor.email}</div>
</div>
);
}
if (rawId) {
return <code className="text-xs">{rawId.slice(0, 8)}</code>;
}
return <span className="text-xs text-muted-foreground">system</span>;
},
size: 200,
},
];
return (
<div>
<PageHeader title="Audit Log" description={`${total} entries`} />
<PageHeader
title="Audit Log"
eyebrow="Admin"
description="Every state change in this port — fully searchable."
variant="gradient"
/>
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-sm">
<div className="mt-4 flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<Label htmlFor="audit-search" className="text-xs">
Search
</Label>
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="audit-search"
className="pl-9"
placeholder="Search..."
placeholder="entity id, action, vendor…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
onChange={(e) => setSearch(e.target.value)}
data-testid="audit-search"
/>
</div>
<Select
value={entityTypeFilter}
onValueChange={(v) => {
setEntityTypeFilter(v);
setPage(1);
}}
>
<SelectTrigger className="w-36">
</div>
<div className="space-y-1.5">
<Label className="text-xs">Entity</Label>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-36" data-testid="audit-entity">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
<SelectItem value="all">All entities</SelectItem>
{ENTITY_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
@@ -197,28 +281,77 @@ export function AuditLogList() {
))}
</SelectContent>
</Select>
<Select
value={actionFilter}
onValueChange={(v) => {
setActionFilter(v);
setPage(1);
}}
>
<SelectTrigger className="w-36">
</div>
<div className="space-y-1.5">
<Label className="text-xs">Action</Label>
<Select value={action} onValueChange={setAction}>
<SelectTrigger className="w-36" data-testid="audit-action">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Actions</SelectItem>
<SelectItem value="all">All actions</SelectItem>
<SelectItem value="create">Create</SelectItem>
<SelectItem value="update">Update</SelectItem>
<SelectItem value="delete">Delete</SelectItem>
<SelectItem value="archive">Archive</SelectItem>
<SelectItem value="restore">Restore</SelectItem>
<SelectItem value="permission_denied">Permission Denied</SelectItem>
<SelectItem value="merge">Merge</SelectItem>
<SelectItem value="revert">Revert</SelectItem>
<SelectItem value="login">Login</SelectItem>
<SelectItem value="permission_denied">Permission denied</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-user" className="text-xs">
User id
</Label>
<Input
id="audit-user"
className="w-44"
placeholder="exact user id"
value={userId}
onChange={(e) => setUserId(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-from" className="text-xs">
From
</Label>
<Input
id="audit-from"
type="date"
className="w-36"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-to" className="text-xs">
To
</Label>
<Input
id="audit-to"
type="date"
className="w-36"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
{hasActiveFilter ? (
<Button variant="ghost" size="sm" onClick={clearFilters} className="ml-auto">
<X className="mr-1.5 h-3 w-3" />
Clear
</Button>
) : null}
</div>
<div className="mt-4">
<DataTable
columns={columns}
data={entries}
@@ -230,28 +363,21 @@ export function AuditLogList() {
</div>
}
/>
{total > 50 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {Math.ceil(total / 50)}
</span>
<button
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
disabled={page >= Math.ceil(total / 50)}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
{nextCursor ? (
<div className="mt-4 flex justify-center">
<Button
variant="outline"
size="sm"
disabled={loadingMore}
onClick={() => void loadMore()}
data-testid="audit-load-more"
>
{loadingMore ? 'Loading…' : 'Load more'}
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,317 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Eye, EyeOff, Loader2, XCircle } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { usePermissions } from '@/hooks/use-permissions';
import { apiFetch } from '@/lib/api/client';
import { AiBudgetCard } from '@/components/admin/ai-budget-card';
type Provider = 'openai' | 'claude';
interface ConfigResp {
data: {
provider: Provider;
model: string;
hasApiKey: boolean;
useGlobal: boolean;
aiEnabled: boolean;
};
models: Record<Provider, string[]>;
}
type Scope = 'port' | 'global';
interface SettingsBlockProps {
scope: Scope;
title: string;
description: string;
/** Hide the "use global" checkbox on the global tab. */
showUseGlobal?: boolean;
}
function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlockProps) {
const queryClient = useQueryClient();
const queryKey = ['ocr-settings', scope];
const { data, isLoading } = useQuery<ConfigResp>({
queryKey,
queryFn: () => apiFetch<ConfigResp>(`/api/v1/admin/ocr-settings?scope=${scope}`),
});
const [provider, setProvider] = useState<Provider>('openai');
const [model, setModel] = useState<string>('gpt-4o-mini');
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(false);
const [aiEnabled, setAiEnabled] = useState(false);
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null,
);
useEffect(() => {
if (!data?.data) return;
setProvider(data.data.provider);
setModel(data.data.model);
setUseGlobal(data.data.useGlobal);
setAiEnabled(data.data.aiEnabled);
}, [data?.data]);
const save = useMutation({
mutationFn: (clearApiKey?: boolean) =>
apiFetch('/api/v1/admin/ocr-settings', {
method: 'PUT',
body: {
scope,
provider,
model,
apiKey: apiKey.length > 0 ? apiKey : undefined,
clearApiKey: Boolean(clearApiKey),
useGlobal: scope === 'global' ? false : useGlobal,
aiEnabled: scope === 'global' ? false : aiEnabled,
},
}),
onSuccess: () => {
setApiKey('');
queryClient.invalidateQueries({ queryKey });
},
});
const test = useMutation({
mutationFn: () =>
apiFetch<{ ok: boolean; reason?: string }>(`/api/v1/admin/ocr-settings/test`, {
method: 'POST',
body: { provider, model, apiKey },
}),
onSuccess: (res) =>
setTestStatus(res.ok ? { ok: true } : { ok: false, reason: res.reason ?? 'Unknown' }),
onError: (err: unknown) =>
setTestStatus({
ok: false,
reason: err instanceof Error ? err.message : 'Network error',
}),
});
const models = data?.models[provider] ?? [];
const hasKey = data?.data.hasApiKey ?? false;
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<p className="text-sm text-muted-foreground">{description}</p>
</CardHeader>
<CardContent className="space-y-4">
{showUseGlobal ? (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id={`useGlobal-${scope}`}
checked={useGlobal}
onCheckedChange={(v) => setUseGlobal(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor={`useGlobal-${scope}`} className="text-sm font-medium">
Use the global API key for this port
</Label>
<p className="text-xs text-muted-foreground">
When enabled, this port falls back to the system-wide OCR settings. Per-port
provider/model/key are ignored.
</p>
</div>
</div>
) : null}
{scope === 'port' ? (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id={`aiEnabled-${scope}`}
checked={aiEnabled}
onCheckedChange={(v) => setAiEnabled(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor={`aiEnabled-${scope}`} className="text-sm font-medium">
Enable AI receipt parsing for this port
</Label>
<p className="text-xs text-muted-foreground">
Off by default. Receipts are read on-device using Tesseract.js accurate enough for
most receipts and incurs no AI cost. Turning this on lets the configured provider
re-parse receipts server-side for higher accuracy on hard-to-read images.
</p>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`provider-${scope}`}>Provider</Label>
<Select
value={provider}
onValueChange={(v) => {
const p = v as Provider;
setProvider(p);
setModel(data?.models[p][0] ?? '');
setTestStatus(null);
}}
>
<SelectTrigger id={`provider-${scope}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="claude">Claude (Anthropic)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor={`model-${scope}`}>Model</Label>
<Select value={model} onValueChange={setModel}>
<SelectTrigger id={`model-${scope}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor={`apiKey-${scope}`}>API key</Label>
<div className="flex gap-2">
<Input
id={`apiKey-${scope}`}
type={showKey ? 'text' : 'password'}
autoComplete="off"
placeholder={hasKey ? '•••••• (saved — leave blank to keep)' : 'sk-…'}
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setTestStatus(null);
}}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowKey((v) => !v)}
aria-label={showKey ? 'Hide key' : 'Show key'}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Stored encrypted at rest. Never re-displayed after saving.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => save.mutate(false)}
disabled={save.isPending}
data-testid={`save-${scope}`}
>
{save.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
Save settings
</Button>
<Button
type="button"
variant="outline"
onClick={() => test.mutate()}
disabled={test.isPending || apiKey.length === 0}
>
{test.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
Test connection
</Button>
{hasKey ? (
<Button
type="button"
variant="ghost"
onClick={() => save.mutate(true)}
disabled={save.isPending}
className="text-destructive"
>
Clear stored key
</Button>
) : null}
{testStatus?.ok ? (
<span className="inline-flex items-center gap-1 text-sm text-green-700">
<CheckCircle2 className="h-4 w-4" />
Connection OK
</span>
) : null}
{testStatus && !testStatus.ok ? (
<span className="inline-flex items-center gap-1 text-sm text-destructive">
<XCircle className="h-4 w-4" />
{testStatus.reason}
</span>
) : null}
</div>
</CardContent>
</Card>
);
}
export function OcrSettingsForm() {
const { isSuperAdmin } = usePermissions();
return (
<div className="space-y-6">
<PageHeader
title="Receipt OCR"
eyebrow="Admin"
description="Receipts are scanned on-device by default. Optionally configure an AI provider for higher-accuracy parsing on tricky receipts."
variant="gradient"
/>
<SettingsBlock
scope="port"
title="This port"
description="Optional AI provider for staff at this port. Tesseract.js handles all scans on-device until AI is enabled."
showUseGlobal
/>
<AiBudgetCard />
{isSuperAdmin ? (
<SettingsBlock
scope="global"
title="Global default"
description="Used by any port that opted into the global key. Super-admin only."
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { ShieldAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useUIStore } from '@/stores/ui-store';
import { cn } from '@/lib/utils';
import { AlertCard, AlertCardEmpty } from './alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
export function AlertBell() {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [open, setOpen] = useState(false);
// Count is cheap (one aggregate query) — fire on every page so the badge stays live.
// List is heavier — only fetch when the popover is actually open.
const { data: count } = useAlertCount();
const { data: list, isLoading } = useAlertList('open', open);
useAlertRealtime();
const total = count?.total ?? 0;
const critical = count?.bySeverity.critical ?? 0;
const alerts = list?.data ?? [];
const top = alerts.slice(0, 5);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative"
aria-label={`Alerts${total > 0 ? ` (${total} active)` : ''}`}
data-testid="alert-bell"
>
<ShieldAlert className="h-5 w-5" />
{total > 0 ? (
<span
key={total}
data-testid="alert-bell-badge"
className={cn(
'absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white shadow-sm ring-2 ring-background animate-badge-pop',
critical > 0 ? 'bg-destructive' : 'bg-amber-500',
)}
>
{total > 99 ? '99+' : total}
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-96 p-0">
<div className="flex items-center justify-between px-4 py-3">
<h4 className="text-sm font-semibold">Active alerts</h4>
<Link
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
className="text-xs text-muted-foreground hover:text-foreground"
>
View all
</Link>
</div>
<Separator />
<ScrollArea className="max-h-[420px]">
{isLoading ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">Loading</div>
) : top.length === 0 ? (
<div className="p-3">
<AlertCardEmpty />
</div>
) : (
<div className="space-y-2 p-3">
{top.map((a) => (
<AlertCard key={a.id} alert={a} />
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,116 @@
'use client';
import { AlertTriangle, Bell, Check, ExternalLink, Info, ShieldAlert, X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { formatDistanceToNow } from 'date-fns';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AlertRow } from './types';
import { useAlertActions } from './use-alerts';
interface AlertCardProps {
alert: AlertRow;
/** Hide the side action buttons in compact contexts (e.g. resolved/dismissed history). */
readOnly?: boolean;
}
const SEVERITY_STYLES: Record<string, { stripe: string; icon: typeof Info }> = {
info: { stripe: 'bg-[hsl(var(--chart-1))]', icon: Info },
warning: { stripe: 'bg-amber-500', icon: AlertTriangle },
critical: { stripe: 'bg-destructive', icon: ShieldAlert },
};
export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
const router = useRouter();
const { acknowledge, dismiss } = useAlertActions();
const sev = SEVERITY_STYLES[alert.severity] ?? SEVERITY_STYLES.info!;
const Icon = sev.icon;
const acknowledged = Boolean(alert.acknowledgedAt);
const fired = formatDistanceToNow(new Date(alert.firedAt), { addSuffix: true });
return (
<div
data-testid="alert-card"
data-severity={alert.severity}
className={cn(
'group relative flex gap-3 overflow-hidden rounded-lg border border-border bg-card p-3 shadow-xs transition-shadow duration-base ease-spring hover:shadow-sm',
acknowledged && 'opacity-70',
)}
>
<span className={cn('absolute inset-y-0 left-0 w-1', sev.stripe)} aria-hidden />
<Icon
className={cn(
'mt-0.5 h-4 w-4 shrink-0',
alert.severity === 'critical' && 'text-destructive',
alert.severity === 'warning' && 'text-amber-600',
alert.severity === 'info' && 'text-foreground',
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<p className="truncate text-sm font-medium text-foreground">{alert.title}</p>
{acknowledged ? (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">ack</span>
) : null}
</div>
{alert.body ? (
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{alert.body}</p>
) : null}
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{fired}</span>
<span aria-hidden>·</span>
<span className="font-mono text-[10px]">{alert.ruleId}</span>
</div>
</div>
{!readOnly ? (
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100">
{!acknowledged ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Acknowledge"
disabled={acknowledge.isPending}
onClick={() => acknowledge.mutate(alert.id)}
>
<Check className="h-3.5 w-3.5" />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Dismiss"
disabled={dismiss.isPending}
onClick={() => dismiss.mutate(alert.id)}
>
<X className="h-3.5 w-3.5" />
</Button>
{alert.link ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Open"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick={() => router.push(alert.link as any)}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
) : null}
</div>
);
}
export function AlertCardEmpty() {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-10 text-center">
<Bell className="mb-2 h-8 w-8 text-muted-foreground/40" aria-hidden />
<p className="text-sm font-medium">All clear</p>
<p className="mt-1 text-xs text-muted-foreground">No active alerts right now.</p>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import { useUIStore } from '@/stores/ui-store';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCard, AlertCardEmpty } from './alert-card';
import { useAlertList, useAlertRealtime } from './use-alerts';
export function AlertRail() {
const portSlug = useUIStore((s) => s.currentPortSlug);
const { data, isLoading } = useAlertList('open');
useAlertRealtime();
const alerts = data?.data ?? [];
// Show first 5 in the rail; surplus pushes user to the full /alerts page.
const visible = alerts.slice(0, 5);
const overflow = Math.max(alerts.length - visible.length, 0);
return (
<section
data-testid="alert-rail"
aria-label="Active alerts"
className="flex h-full flex-col gap-3"
>
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
<Link
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
className="text-xs text-muted-foreground hover:text-foreground"
>
View all
<ArrowRight className="ml-1 inline h-3 w-3" aria-hidden />
</Link>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : visible.length === 0 ? (
<AlertCardEmpty />
) : (
<div className="space-y-2">
{visible.map((a) => (
<AlertCard key={a.id} alert={a} />
))}
{overflow > 0 ? (
<Link
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
>
+{overflow} more view all
</Link>
) : null}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import { ShieldAlert } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCard, AlertCardEmpty } from './alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
import type { AlertStatus } from './types';
export function AlertsPageShell() {
const [tab, setTab] = useState<AlertStatus>('open');
const { data: count } = useAlertCount();
const { data, isLoading } = useAlertList(tab);
useAlertRealtime();
const total = count?.total ?? 0;
const alerts = data?.data ?? [];
return (
<div className="space-y-6">
<PageHeader
title="Alerts"
eyebrow="Operational"
description="Rules-based signals about pipeline, agreements, expenses, and access"
kpiLine={
<span>
<ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden />
{total} active
</span>
}
variant="gradient"
/>
<Tabs value={tab} onValueChange={(v) => setTab(v as AlertStatus)}>
<TabsList>
<TabsTrigger value="open" data-testid="tab-open">
Active{total > 0 ? ` · ${total}` : ''}
</TabsTrigger>
<TabsTrigger value="dismissed" data-testid="tab-dismissed">
Dismissed
</TabsTrigger>
<TabsTrigger value="resolved" data-testid="tab-resolved">
Resolved
</TabsTrigger>
</TabsList>
<TabsContent value={tab} className="mt-4 space-y-2">
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : alerts.length === 0 ? (
<AlertCardEmpty />
) : (
alerts.map((a) => <AlertCard key={a.id} alert={a} readOnly={tab !== 'open'} />)
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import type { Alert } from '@/lib/db/schema/insights';
export type AlertRow = Alert;
export interface AlertListResponse {
data: AlertRow[];
}
export interface AlertCountResponse {
total: number;
bySeverity: Record<'info' | 'warning' | 'critical', number>;
}
export type AlertStatus = 'open' | 'dismissed' | 'resolved';

View File

@@ -0,0 +1,50 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import type { AlertCountResponse, AlertListResponse, AlertStatus } from './types';
export function useAlertList(status: AlertStatus = 'open', enabled = true) {
return useQuery<AlertListResponse>({
queryKey: ['alerts', status],
queryFn: () => apiFetch<AlertListResponse>(`/api/v1/alerts?status=${status}`),
staleTime: 30_000,
enabled,
});
}
export function useAlertCount() {
return useQuery<AlertCountResponse>({
queryKey: ['alerts', 'count'],
queryFn: () => apiFetch<AlertCountResponse>('/api/v1/alerts/count'),
staleTime: 30_000,
});
}
export function useAlertActions() {
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['alerts'] });
};
const acknowledge = useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/alerts/${id}/acknowledge`, { method: 'POST' }),
onSuccess: invalidate,
});
const dismiss = useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/alerts/${id}/dismiss`, { method: 'POST' }),
onSuccess: invalidate,
});
return { acknowledge, dismiss };
}
export function useAlertRealtime() {
useRealtimeInvalidation({
'alert:created': [['alerts']],
'alert:resolved': [['alerts']],
'alert:dismissed': [['alerts']],
});
}

View File

@@ -0,0 +1,216 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { Bookmark } from 'lucide-react';
import type { InterestRow } from '@/components/interests/interest-columns';
interface BerthInterestsTabProps {
berthId: string;
}
type StageFilter = 'all' | 'active' | 'lost';
type SortMode = 'newest' | 'stage' | 'category';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
const STAGE_ORDER: Record<string, number> = {
open: 0,
details_sent: 1,
in_communication: 2,
visited: 3,
signed_eoi_nda: 4,
deposit_10pct: 5,
contract: 6,
completed: 7,
};
const CATEGORY_RANK: Record<string, number> = {
hot_lead: 0,
specific_qualified: 1,
general_interest: 2,
};
const CATEGORY_LABELS: Record<string, string> = {
hot_lead: 'Hot Lead',
specific_qualified: 'Specific Qualified',
general_interest: 'General Interest',
};
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface ListResponse {
data: InterestRow[];
total: number;
}
export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [stage, setStage] = useState<StageFilter>('all');
const [sortMode, setSortMode] = useState<SortMode>('newest');
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['interests', 'by-berth', berthId],
queryFn: () => apiFetch<ListResponse>(`/api/v1/interests?berthId=${berthId}&limit=200`),
staleTime: 30_000,
});
useRealtimeInvalidation({
'interest:created': [['interests', 'by-berth', berthId]],
'interest:updated': [['interests', 'by-berth', berthId]],
'interest:stageChanged': [['interests', 'by-berth', berthId]],
'interest:archived': [['interests', 'by-berth', berthId]],
'interest:berthLinked': [['interests', 'by-berth', berthId]],
'interest:berthUnlinked': [['interests', 'by-berth', berthId]],
});
const rows = useMemo<InterestRow[]>(() => {
const all = data?.data ?? [];
const filtered = all.filter((i) => {
if (stage === 'active') return i.pipelineStage !== 'completed' && !i.archivedAt;
if (stage === 'lost') return Boolean(i.archivedAt);
return true;
});
const sorted = [...filtered].sort((a, b) => {
if (sortMode === 'stage') {
const sa = STAGE_ORDER[a.pipelineStage] ?? 99;
const sb = STAGE_ORDER[b.pipelineStage] ?? 99;
if (sa !== sb) return sb - sa; // furthest along first
}
if (sortMode === 'category') {
const ca = CATEGORY_RANK[a.leadCategory ?? ''] ?? 99;
const cb = CATEGORY_RANK[b.leadCategory ?? ''] ?? 99;
if (ca !== cb) return ca - cb; // hottest first
}
// Default + tiebreaker: newest first.
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
return sorted;
}, [data?.data, stage, sortMode]);
if (isLoading) return <TableSkeleton />;
if ((data?.data ?? []).length === 0) {
return (
<EmptyState
icon={Bookmark}
title="No interests linked to this berth"
description="Interests will appear here when prospects express interest in this specific berth."
/>
);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="text-xs text-muted-foreground">
{rows.length} of {data?.total ?? 0} interest{(data?.total ?? 0) === 1 ? '' : 's'}
</div>
<div className="ml-auto flex items-center gap-2">
<Select value={stage} onValueChange={(v) => setStage(v as StageFilter)}>
<SelectTrigger className="h-8 w-[140px]" data-testid="berth-interests-filter">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All stages</SelectItem>
<SelectItem value="active">Active only</SelectItem>
<SelectItem value="lost">Lost / archived</SelectItem>
</SelectContent>
</Select>
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<SelectTrigger className="h-8 w-[160px]" data-testid="berth-interests-sort">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="stage">Stage progress</SelectItem>
<SelectItem value="category">Lead category</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-card">
<table className="w-full text-sm" data-testid="berth-interests-table">
<thead className="bg-muted/40 text-left text-xs font-medium text-muted-foreground">
<tr>
<th className="px-3 py-2">Client</th>
<th className="px-3 py-2">Stage</th>
<th className="px-3 py-2">Category</th>
<th className="px-3 py-2">Source</th>
<th className="px-3 py-2">Last activity</th>
<th className="px-3 py-2 text-right" />
</tr>
</thead>
<tbody>
{rows.map((i) => (
<tr
key={i.id}
className="border-t border-border last:border-b-0 hover:bg-gradient-brand-soft/40"
>
<td className="px-3 py-2 font-medium text-foreground">
<Link
href={`/${portSlug}/interests/${i.id}` as never}
className="hover:text-brand"
>
{i.clientName ?? '—'}
</Link>
</td>
<td className="px-3 py-2">
<Badge variant="secondary" className="font-normal">
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '—'}
</td>
<td className="px-3 py-2 text-muted-foreground">
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '—'}
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{new Date(i.createdAt).toLocaleDateString()}
</td>
<td className="px-3 py-2 text-right">
<Button asChild variant="ghost" size="sm" className="h-7 text-xs">
<Link href={`/${portSlug}/interests/${i.id}` as never}>Open</Link>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { type DetailTab } from '@/components/shared/detail-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TagBadge } from '@/components/shared/tag-badge';
import { BerthReservationsTab } from './berth-reservations-tab';
import { BerthInterestsTab } from './berth-interests-tab';
type BerthData = {
id: string;
@@ -181,7 +182,7 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] {
{
id: 'interests',
label: 'Interests',
content: <StubTab label="Interests" />,
content: <BerthInterestsTab berthId={berth.id} />,
},
{
id: 'reservations',

View File

@@ -14,11 +14,12 @@ import {
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { getCountryName } from '@/lib/i18n/countries';
export interface ClientRow {
id: string;
fullName: string;
nationality: string | null;
nationalityIso: string | null;
source: string | null;
archivedAt: string | null;
createdAt: string;
@@ -78,11 +79,14 @@ export function getClientColumns({
},
{
id: 'nationality',
accessorKey: 'nationality',
accessorKey: 'nationalityIso',
header: 'Nationality',
cell: ({ getValue }) => (
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
),
cell: ({ getValue }) => {
const iso = getValue() as string | null;
return (
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '—'}</span>
);
},
},
{
id: 'source',

View File

@@ -10,6 +10,7 @@ import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
import { apiFetch } from '@/lib/api/client';
interface ClientDetailHeaderProps {
@@ -122,6 +123,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
defaultEmail={primaryEmail?.value}
/>
)}
<GdprExportButton clientId={client.id} />
<Button
variant={isArchived ? 'outline' : 'outline'}
size="sm"

View File

@@ -7,12 +7,13 @@ import { ClientDetailHeader } from '@/components/clients/client-detail-header';
import { getClientTabs } from '@/components/clients/client-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { Address } from '@/components/shared/addresses-editor';
interface ClientData {
id: string;
portId: string;
fullName: string;
nationality: string | null;
nationalityIso: string | null;
preferredContactMethod: string | null;
preferredLanguage: string | null;
timezone: string | null;
@@ -64,6 +65,7 @@ interface ClientData {
tenureType: string;
status: string;
}>;
addresses: Address[];
}
interface ClientDetailProps {

View File

@@ -20,8 +20,12 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { PhoneInput } from '@/components/shared/phone-input';
import { apiFetch } from '@/lib/api/client';
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
import type { CountryCode } from '@/lib/i18n/countries';
interface ClientFormProps {
open: boolean;
@@ -30,7 +34,7 @@ interface ClientFormProps {
client?: {
id: string;
fullName: string;
nationality?: string | null;
nationalityIso?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
@@ -39,6 +43,8 @@ interface ClientFormProps {
contacts?: Array<{
channel: string;
value: string;
valueE164?: string | null;
valueCountry?: string | null;
label?: string | null;
isPrimary?: boolean;
notes?: string | null;
@@ -76,7 +82,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
if (client && open) {
reset({
fullName: client.fullName,
nationality: client.nationality ?? undefined,
nationalityIso: client.nationalityIso ?? undefined,
preferredContactMethod:
(client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ??
undefined,
@@ -89,6 +95,8 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
? client.contacts.map((c) => ({
channel: c.channel as 'email' | 'phone' | 'whatsapp' | 'other',
value: c.value,
valueE164: c.valueE164 ?? undefined,
valueCountry: c.valueCountry ?? undefined,
label: c.label ?? undefined,
isPrimary: c.isPrimary ?? false,
notes: c.notes ?? undefined,
@@ -152,7 +160,11 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
<div className="space-y-1">
<Label>Nationality</Label>
<Input {...register('nationality')} placeholder="British" />
<CountryCombobox
value={watch('nationalityIso')}
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
data-testid="client-nationality"
/>
</div>
</div>
</div>
@@ -211,11 +223,40 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
<div className="col-span-5 space-y-1">
<Label className="text-xs">Value</Label>
{(() => {
const channel = watch(`contacts.${index}.channel`);
if (channel === 'phone' || channel === 'whatsapp') {
const e164 = watch(`contacts.${index}.valueE164`) ?? null;
const country =
(watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ??
undefined;
return (
<PhoneInput
value={
e164 || country
? {
e164: e164 ?? null,
country: country ?? 'US',
}
: null
}
onChange={(v) => {
setValue(`contacts.${index}.value`, v.e164 ?? '');
setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined);
setValue(`contacts.${index}.valueCountry`, v.country);
}}
data-testid={`contact-${index}-phone`}
/>
);
}
return (
<Input
{...register(`contacts.${index}.value`)}
className="h-8"
placeholder="email@example.com"
placeholder={channel === 'email' ? 'email@example.com' : 'value'}
/>
);
})()}
</div>
<div className="col-span-2 space-y-1">
@@ -304,7 +345,12 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
</div>
<div className="space-y-1">
<Label>Timezone</Label>
<Input {...register('timezone')} placeholder="UTC+0" />
<TimezoneCombobox
value={watch('timezone')}
onChange={(tz) => setValue('timezone', tz ?? undefined)}
countryHint={(watch('nationalityIso') as CountryCode | undefined) ?? undefined}
data-testid="client-timezone"
/>
</div>
<div className="col-span-2 space-y-1">
<Label>Source Details</Label>

View File

@@ -4,17 +4,22 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import type { CountryCode } from '@/lib/i18n/countries';
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
import { ContactsEditor } from '@/components/clients/contacts-editor';
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
import { apiFetch } from '@/lib/api/client';
type ClientPatchField =
| 'fullName'
| 'nationality'
| 'nationalityIso'
| 'preferredContactMethod'
| 'preferredLanguage'
| 'timezone'
@@ -64,6 +69,7 @@ interface ClientTabsOptions {
client: {
fullName: string;
nationality?: string | null;
nationalityIso?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
@@ -73,9 +79,12 @@ interface ClientTabsOptions {
id: string;
channel: string;
value: string;
valueE164?: string | null;
valueCountry?: string | null;
label?: string | null;
isPrimary: boolean;
}>;
addresses?: Address[];
yachts: Array<{
id: string;
name: string;
@@ -131,7 +140,13 @@ function OverviewTab({
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Nationality">
<InlineEditableField value={client.nationality} onSave={save('nationality')} />
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
await mutation.mutateAsync({ nationalityIso: iso });
}}
data-testid="client-nationality-inline"
/>
</EditableRow>
<EditableRow label="Preferred Language">
<InlineEditableField
@@ -140,7 +155,14 @@ function OverviewTab({
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineEditableField value={client.timezone} onSave={save('timezone')} />
<InlineTimezoneField
value={client.timezone}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
}}
data-testid="client-timezone-inline"
/>
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
@@ -217,6 +239,18 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
<ClientReservationsTab clientId={clientId} activeReservations={client.activeReservations} />
),
},
{
id: 'addresses',
label: 'Addresses',
badge: client.addresses?.length ?? 0,
content: (
<AddressesEditor
endpoint={`/api/v1/clients/${clientId}/addresses`}
invalidateKey={['clients', clientId]}
addresses={client.addresses ?? []}
/>
),
},
{
id: 'interests',
label: 'Interests',

View File

@@ -24,6 +24,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
@@ -31,6 +33,8 @@ interface Contact {
id: string;
channel: string;
value: string;
valueE164?: string | null;
valueCountry?: string | null;
label?: string | null;
isPrimary: boolean;
}
@@ -63,7 +67,9 @@ export function ContactsEditor({ clientId, contacts }: { clientId: string; conta
patch,
}: {
contactId: string;
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>;
patch: Partial<
Pick<Contact, 'channel' | 'value' | 'valueE164' | 'valueCountry' | 'label' | 'isPrimary'>
>;
}) =>
apiFetch(`/api/v1/clients/${clientId}/contacts/${contactId}`, {
method: 'PATCH',
@@ -73,7 +79,13 @@ export function ContactsEditor({ clientId, contacts }: { clientId: string; conta
});
const addMutation = useMutation({
mutationFn: async (data: { channel: string; value: string; label?: string }) =>
mutationFn: async (data: {
channel: string;
value: string;
valueE164?: string | null;
valueCountry?: string | null;
label?: string;
}) =>
apiFetch(`/api/v1/clients/${clientId}/contacts`, {
method: 'POST',
body: { ...data, isPrimary: false },
@@ -136,7 +148,9 @@ function ContactRow({
}: {
contact: Contact;
onUpdate: (
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>,
patch: Partial<
Pick<Contact, 'channel' | 'value' | 'valueE164' | 'valueCountry' | 'label' | 'isPrimary'>
>,
) => Promise<unknown>;
onRemove: () => void;
}) {
@@ -167,6 +181,19 @@ function ContactRow({
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</ChannelPicker>
<div className="min-w-0">
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
<InlinePhoneField
e164={contact.valueE164 ?? null}
country={contact.valueCountry ?? null}
onSave={async ({ e164, country }) => {
if (!e164) {
toast.error('Phone number is required');
return;
}
await onUpdate({ value: e164, valueE164: e164, valueCountry: country });
}}
/>
) : (
<InlineEditableField
value={contact.value}
onSave={async (v) => {
@@ -177,6 +204,7 @@ function ContactRow({
await onUpdate({ value: v });
}}
/>
)}
</div>
</div>
@@ -252,15 +280,42 @@ function NewContactForm({
onSave,
onCancel,
}: {
onSave: (data: { channel: string; value: string; label?: string }) => Promise<void>;
onSave: (data: {
channel: string;
value: string;
valueE164?: string | null;
valueCountry?: string | null;
label?: string;
}) => Promise<void>;
onCancel: () => void;
}) {
const [channel, setChannel] = useState('email');
const [value, setValue] = useState('');
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(null);
const [label, setLabel] = useState('');
const [saving, setSaving] = useState(false);
const isPhoneChannel = channel === 'phone' || channel === 'whatsapp';
async function submit() {
if (isPhoneChannel) {
if (!phoneValue?.e164) return;
setSaving(true);
try {
await onSave({
channel,
value: phoneValue.e164,
valueE164: phoneValue.e164,
valueCountry: phoneValue.country,
label: label.trim() || undefined,
});
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add contact');
} finally {
setSaving(false);
}
return;
}
if (!value.trim()) return;
setSaving(true);
try {
@@ -272,9 +327,19 @@ function NewContactForm({
}
}
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
return (
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
<Select value={channel} onValueChange={setChannel}>
<Select
value={channel}
onValueChange={(next) => {
setChannel(next);
// Reset cross-mode state so a stale email doesn't ride along on a phone submit.
if (next === 'phone' || next === 'whatsapp') setValue('');
else setPhoneValue(null);
}}
>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
@@ -287,10 +352,19 @@ function NewContactForm({
</SelectContent>
</Select>
{isPhoneChannel ? (
<div className="flex-1 min-w-0">
<PhoneInput
value={phoneValue}
onChange={(v) => setPhoneValue(v)}
data-testid="new-contact-phone"
/>
</div>
) : (
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={channel === 'email' ? 'name@example.com' : '+1 555 0100'}
placeholder={channel === 'email' ? 'name@example.com' : 'value'}
className="h-7 text-sm flex-1 min-w-0"
autoFocus
disabled={saving}
@@ -302,6 +376,7 @@ function NewContactForm({
if (e.key === 'Escape') onCancel();
}}
/>
)}
<Input
value={label}
@@ -318,7 +393,7 @@ function NewContactForm({
}}
/>
<Button type="button" size="sm" onClick={submit} disabled={!value.trim() || saving}>
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>

View File

@@ -0,0 +1,207 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Download, FileDown, Loader2, Mail } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { usePermissions } from '@/hooks/use-permissions';
import { apiFetch } from '@/lib/api/client';
interface ExportRow {
id: string;
status: 'pending' | 'building' | 'ready' | 'sent' | 'failed';
storageKey: string | null;
sizeBytes: number | null;
createdAt: string;
readyAt: string | null;
sentAt: string | null;
sentTo: string | null;
error: string | null;
}
interface ListResp {
data: ExportRow[];
}
const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'destructive'> = {
pending: 'outline',
building: 'outline',
ready: 'secondary',
sent: 'secondary',
failed: 'destructive',
};
export function GdprExportButton({ clientId }: { clientId: string }) {
const { can, isSuperAdmin } = usePermissions();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [emailToClient, setEmailToClient] = useState(false);
const [emailOverride, setEmailOverride] = useState('');
const allowed = isSuperAdmin || can('admin', 'manage_settings');
const queryKey = ['gdpr-exports', clientId];
const { data, isLoading } = useQuery<ListResp>({
queryKey,
queryFn: () => apiFetch<ListResp>(`/api/v1/clients/${clientId}/gdpr-export`),
enabled: open && allowed,
refetchInterval: open && allowed ? 5_000 : false,
});
const request = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/clients/${clientId}/gdpr-export`, {
method: 'POST',
body: {
emailToClient,
emailOverride: emailOverride.trim() || null,
},
}),
onSuccess: () => {
toast.success('Export queued — refresh in ~30 seconds');
qc.invalidateQueries({ queryKey });
setEmailOverride('');
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Failed to queue export');
},
});
if (!allowed) return null;
async function downloadById(exportId: string) {
try {
const res = await apiFetch<{ data: { url: string } }>(
`/api/v1/clients/${clientId}/gdpr-export/${exportId}`,
);
window.open(res.data.url, '_blank', 'noopener');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to fetch download URL');
}
}
const rows = data?.data ?? [];
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<FileDown className="mr-1.5 h-3.5 w-3.5" />
GDPR export
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Personal data export</DialogTitle>
<DialogDescription>
Bundles every record we hold about this client (profile, contacts, addresses, yachts,
companies, interests, reservations, invoices, documents, audit log) into a ZIP with JSON
and HTML copies. Used to satisfy GDPR Article 15 access requests.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id="email-to-client"
checked={emailToClient}
onCheckedChange={(v) => setEmailToClient(v === true)}
/>
<div className="space-y-2 flex-1 min-w-0">
<Label htmlFor="email-to-client" className="text-sm font-medium">
Email the bundle when ready
</Label>
<p className="text-xs text-muted-foreground">
Sends a 7-day signed download link to the client&apos;s primary email or to the
override below.
</p>
{emailToClient ? (
<Input
type="email"
placeholder="optional override (defaults to primary contact)"
value={emailOverride}
onChange={(e) => setEmailOverride(e.target.value)}
className="h-8 text-sm"
/>
) : null}
</div>
</div>
<Button onClick={() => request.mutate()} disabled={request.isPending}>
{request.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="mr-1.5 h-3.5 w-3.5" />
)}
Queue export
</Button>
<div>
<h4 className="text-sm font-medium mb-2">Recent exports</h4>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : rows.length === 0 ? (
<p className="text-sm text-muted-foreground">No exports yet.</p>
) : (
<ul className="text-sm divide-y border rounded-lg">
{rows.map((r) => (
<li key={r.id} className="flex items-center gap-2 py-2 px-3 hover:bg-muted/50">
<Badge variant={STATUS_VARIANT[r.status]} className="capitalize text-xs">
{r.status}
</Badge>
<div className="flex-1 min-w-0">
<div className="text-xs">
Requested {format(new Date(r.createdAt), 'MMM d, yyyy HH:mm')}
</div>
{r.sentTo ? (
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
<Mail className="h-3 w-3" />
Sent to {r.sentTo}
</div>
) : null}
{r.error ? (
<div className="text-xs text-destructive truncate">{r.error}</div>
) : null}
</div>
{(r.status === 'ready' || r.status === 'sent') && r.storageKey ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => downloadById(r.id)}
>
<Download className="h-3.5 w-3.5" />
</Button>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -19,7 +19,8 @@ export interface CompanyRow {
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;

View File

@@ -20,7 +20,8 @@ interface CompanyDetailHeaderCompany {
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
@@ -130,7 +131,8 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
legalName: company.legalName,
taxId: company.taxId,
registrationNumber: company.registrationNumber,
incorporationCountry: company.incorporationCountry,
incorporationCountryIso: company.incorporationCountryIso,
incorporationSubdivisionIso: company.incorporationSubdivisionIso,
incorporationDate: company.incorporationDate,
status: company.status,
billingEmail: company.billingEmail,

View File

@@ -8,6 +8,7 @@ import { CompanyDetailHeader } from '@/components/companies/company-detail-heade
import { getCompanyTabs } from '@/components/companies/company-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { Address } from '@/components/shared/addresses-editor';
export interface CompanyData {
id: string;
@@ -16,7 +17,8 @@ export interface CompanyData {
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
@@ -24,6 +26,8 @@ export interface CompanyData {
archivedAt: string | null;
createdAt: string;
updatedAt: string;
tags?: Array<{ id: string; name: string; color: string }>;
addresses?: Address[];
}
interface CompanyDetailProps {

View File

@@ -21,8 +21,11 @@ import {
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { apiFetch } from '@/lib/api/client';
import { createCompanySchema, type CreateCompanyInput } from '@/lib/validators/companies';
import type { CountryCode } from '@/lib/i18n/countries';
type CompanyStatus = 'active' | 'dissolved';
@@ -38,7 +41,8 @@ interface CompanyFormProps {
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
@@ -78,7 +82,8 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
legalName: company.legalName ?? undefined,
taxId: company.taxId ?? undefined,
registrationNumber: company.registrationNumber ?? undefined,
incorporationCountry: company.incorporationCountry ?? undefined,
incorporationCountryIso: company.incorporationCountryIso ?? undefined,
incorporationSubdivisionIso: company.incorporationSubdivisionIso ?? undefined,
incorporationDate: company.incorporationDate
? new Date(company.incorporationDate)
: undefined,
@@ -169,7 +174,24 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
</div>
<div className="space-y-1">
<Label>Incorporation Country</Label>
<Input {...register('incorporationCountry')} placeholder="e.g. MT" />
<CountryCombobox
value={watch('incorporationCountryIso')}
onChange={(iso) => {
setValue('incorporationCountryIso', iso ?? undefined);
// Wipe subdivision when country flips — codes are country-scoped.
setValue('incorporationSubdivisionIso', undefined);
}}
data-testid="company-incorp-country"
/>
</div>
<div className="space-y-1">
<Label>Incorporation Region</Label>
<SubdivisionCombobox
value={watch('incorporationSubdivisionIso')}
onChange={(code) => setValue('incorporationSubdivisionIso', code ?? undefined)}
country={(watch('incorporationCountryIso') as CountryCode | undefined) ?? null}
data-testid="company-incorp-subdivision"
/>
</div>
<div className="space-y-1">
<Label>Incorporation Date</Label>

View File

@@ -145,7 +145,8 @@ export function CompanyList() {
legalName: editCompany.legalName,
taxId: editCompany.taxId,
registrationNumber: editCompany.registrationNumber,
incorporationCountry: editCompany.incorporationCountry,
incorporationCountryIso: editCompany.incorporationCountryIso,
incorporationSubdivisionIso: editCompany.incorporationSubdivisionIso,
incorporationDate: editCompany.incorporationDate,
status: editCompany.status,
billingEmail: editCompany.billingEmail,

View File

@@ -5,11 +5,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { EmptyState } from '@/components/shared/empty-state';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineCountryField } from '@/components/shared/inline-country-field';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
import { apiFetch } from '@/lib/api/client';
import type { CountryCode } from '@/lib/i18n/countries';
type CompanyPatchField =
| 'name'
@@ -17,6 +21,8 @@ type CompanyPatchField =
| 'taxId'
| 'registrationNumber'
| 'incorporationCountry'
| 'incorporationCountryIso'
| 'incorporationSubdivisionIso'
| 'incorporationDate'
| 'status'
| 'billingEmail'
@@ -33,12 +39,14 @@ interface CompanyTabsCompany {
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
notes: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
addresses?: Address[];
}
interface CompanyTabsOptions {
@@ -114,9 +122,26 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
/>
</EditableRow>
<EditableRow label="Incorporation Country">
<InlineEditableField
value={company.incorporationCountry}
onSave={save('incorporationCountry')}
<InlineCountryField
value={company.incorporationCountryIso}
onSave={async (iso) => {
// Wipe subdivision when country flips — codes are country-scoped.
await mutation.mutateAsync({
incorporationCountryIso: iso,
incorporationSubdivisionIso: null,
});
}}
data-testid="company-incorp-country-inline"
/>
</EditableRow>
<EditableRow label="Incorporation Region">
<SubdivisionCombobox
value={company.incorporationSubdivisionIso}
onChange={(code) => {
void mutation.mutateAsync({ incorporationSubdivisionIso: code });
}}
country={(company.incorporationCountryIso as CountryCode | null) ?? null}
data-testid="company-incorp-subdivision-inline"
/>
</EditableRow>
<EditableRow label="Incorporation Date">
@@ -188,10 +213,12 @@ export function getCompanyTabs({
{
id: 'addresses',
label: 'Addresses',
badge: company.addresses?.length ?? 0,
content: (
<EmptyState
title="Addresses"
description="Company addresses coming soon — the addresses endpoint is pending wiring."
<AddressesEditor
endpoint={`/api/v1/companies/${companyId}/addresses`}
invalidateKey={['companies', companyId]}
addresses={company.addresses ?? []}
/>
),
},

View File

@@ -0,0 +1,134 @@
'use client';
import { useRef, type ReactNode } from 'react';
import { MoreHorizontal, Download, Image as ImageIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
interface ChartCardProps {
title: string;
description?: string;
/** Filename stem used for both CSV + PNG exports (no extension). */
exportFilename: string;
/** Returns CSV content for the current chart data, or null when nothing to export. */
toCsv?: () => string | null;
children: ReactNode;
className?: string;
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function exportContainerAsPng(container: HTMLElement, filename: string) {
const svg = container.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true) as SVGSVGElement;
const { width, height } = svg.getBoundingClientRect();
clone.setAttribute('width', String(width));
clone.setAttribute('height', String(height));
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const xml = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Failed to load chart for export'));
img.src = url;
});
const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio ?? 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
return;
}
ctx.scale(dpr, dpr);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
if (blob) downloadBlob(blob, filename);
}, 'image/png');
}
export function ChartCard({
title,
description,
exportFilename,
toCsv,
children,
className,
}: ChartCardProps) {
const containerRef = useRef<HTMLDivElement>(null);
function onDownloadCsv() {
const csv = toCsv?.();
if (!csv) return;
downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `${exportFilename}.csv`);
}
function onDownloadPng() {
if (containerRef.current) {
void exportContainerAsPng(containerRef.current, `${exportFilename}.png`);
}
}
return (
<Card className={cn('h-full', className)}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div>
<CardTitle className="text-base">{title}</CardTitle>
{description ? <p className="mt-1 text-xs text-muted-foreground">{description}</p> : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Chart options"
data-testid="chart-menu"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{toCsv ? (
<DropdownMenuItem onSelect={onDownloadCsv}>
<Download className="mr-2 h-4 w-4" />
Download CSV
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={onDownloadPng}>
<ImageIcon className="mr-2 h-4 w-4" />
Download PNG
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent>
<div ref={containerRef}>{children}</div>
</CardContent>
</Card>
);
}

View File

@@ -1,22 +1,40 @@
'use client';
import { useState } from 'react';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { PageHeader } from '@/components/shared/page-header';
import { KpiCardsWithBoundary } from './kpi-cards';
import { PipelineChart } from './pipeline-chart';
import { RevenueForecast } from './revenue-forecast';
import { ActivityFeed } from './activity-feed';
import { DateRangePicker } from './date-range-picker';
import { PipelineFunnelChart } from './pipeline-funnel-chart';
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
import { LeadSourceChart } from './lead-source-chart';
import { WidgetErrorBoundary } from './widget-error-boundary';
import { AlertRail } from '@/components/alerts/alert-rail';
import type { DateRange } from '@/lib/services/analytics.service';
const RANGE_LABELS: Record<DateRange, string> = {
today: 'Today',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
};
export function DashboardShell() {
const [range, setRange] = useState<DateRange>('30d');
useRealtimeInvalidation({
'interest:stageChanged': [
['dashboard', 'pipeline'],
['dashboard', 'forecast'],
['analytics', 'pipeline_funnel', range],
['analytics', 'lead_source_attribution', range],
['dashboard', 'kpis'],
],
'client:created': [['dashboard', 'kpis']],
'berth:statusChanged': [
['analytics', 'occupancy_timeline', range],
['dashboard', 'kpis'],
['dashboard', 'forecast'],
],
});
@@ -26,26 +44,37 @@ export function DashboardShell() {
title="Dashboard"
eyebrow="Overview"
description="Live snapshot of your marina activity"
kpiLine={<span>Last 30 days</span>}
kpiLine={<span>{RANGE_LABELS[range]}</span>}
variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />}
/>
{/* Row 1: KPI cards */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
<KpiCardsWithBoundary />
</div>
{/* Row 2: Pipeline chart + Revenue forecast */}
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
<div className="lg:col-span-2">
<PipelineChart />
</div>
<div className="lg:col-span-1">
<RevenueForecast />
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
<WidgetErrorBoundary>
<PipelineFunnelChart range={range} />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<OccupancyTimelineChart range={range} />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<RevenueBreakdownChart range={range} />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<LeadSourceChart range={range} />
</WidgetErrorBoundary>
</div>
<aside className="min-w-0">
<WidgetErrorBoundary>
<AlertRail />
</WidgetErrorBoundary>
</aside>
</div>
{/* Row 3: Activity feed */}
<ActivityFeed />
</div>
);

View File

@@ -0,0 +1,55 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { DateRange } from '@/lib/services/analytics.service';
interface DateRangePickerProps {
value: DateRange;
onChange: (next: DateRange) => void;
className?: string;
}
const OPTIONS: Array<{ value: DateRange; label: string }> = [
{ value: 'today', label: 'Today' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ value: '90d', label: '90d' },
];
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
return (
<div
role="tablist"
aria-label="Date range"
className={cn(
'inline-flex items-center rounded-lg border border-border bg-muted/40 p-0.5 shadow-xs',
className,
)}
>
{OPTIONS.map((opt) => {
const active = opt.value === value;
return (
<Button
key={opt.value}
type="button"
role="tab"
aria-selected={active}
variant="ghost"
size="sm"
onClick={() => onChange(opt.value)}
className={cn(
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
data-testid={`range-${opt.value}`}
>
{opt.label}
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card';
import { useLeadSource } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
interface Props {
range: DateRange;
}
const COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-2))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-5))',
];
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
referral: 'Referral',
manual: 'Manual',
social: 'Social',
unspecified: 'Unspecified',
};
export function LeadSourceChart({ range }: Props) {
const { data, isLoading } = useLeadSource(range);
const slices = data?.slices ?? [];
function toCsv(): string | null {
if (!slices.length) return null;
const header = 'source,count';
const rows = slices.map((s) => `${s.source},${s.count}`);
return [header, ...rows].join('\n');
}
const chartData = slices.map((s) => ({
name: SOURCE_LABELS[s.source] ?? s.source,
value: s.count,
}));
return (
<ChartCard
title="Lead Source Attribution"
description="Where new interests came from"
exportFilename={`lead-source-${range}`}
toCsv={toCsv}
>
{isLoading ? (
<CardSkeleton />
) : !slices.length ? (
<EmptyState title="No interests in range" />
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={90}
innerRadius={50}
paddingAngle={2}
>
{chartData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card';
import { useOccupancy } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
interface Props {
range: DateRange;
}
function shortDate(iso: string) {
const d = new Date(`${iso}T00:00:00`);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export function OccupancyTimelineChart({ range }: Props) {
const { data, isLoading } = useOccupancy(range);
const points = data?.points ?? [];
const noBerths = points.length > 0 && points[0]?.total === 0;
function toCsv(): string | null {
if (!points.length) return null;
const header = 'date,occupied,total,occupancy_pct';
const rows = points.map((p) => `${p.date},${p.occupied},${p.total},${p.occupancyPct}`);
return [header, ...rows].join('\n');
}
return (
<ChartCard
title="Occupancy Timeline"
description="Daily berth occupancy across the range"
exportFilename={`occupancy-timeline-${range}`}
toCsv={toCsv}
>
{isLoading ? (
<CardSkeleton />
) : noBerths ? (
<EmptyState title="No berths configured" description="Add berths to see occupancy." />
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart
data={points.map((p) => ({ ...p, label: shortDate(p.date) }))}
margin={{ top: 8, right: 8, left: -16, bottom: 8 }}
>
<defs>
<linearGradient id="occupancyGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-2))" stopOpacity={0.4} />
<stop offset="100%" stopColor="hsl(var(--chart-2))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
minTickGap={20}
/>
<YAxis
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
domain={[0, 100]}
tickFormatter={(v: number) => `${v}%`}
/>
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(value, _name, item) => {
const p = item?.payload as { occupied?: number; total?: number } | undefined;
return [`${value}% (${p?.occupied ?? 0}/${p?.total ?? 0})`, 'Occupancy'];
}}
/>
<Area
type="monotone"
dataKey="occupancyPct"
stroke="hsl(var(--chart-2))"
strokeWidth={2}
fill="url(#occupancyGradient)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card';
import { useFunnel } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
interface Props {
range: DateRange;
}
export function PipelineFunnelChart({ range }: Props) {
const { data, isLoading } = useFunnel(range);
const stages = data?.stages ?? [];
const chartData = stages.map((s) => ({
stage: STAGE_LABELS[s.stage] ?? s.stage,
count: s.count,
conversionPct: s.conversionPct,
}));
const allZero = stages.every((s) => s.count === 0);
function toCsv(): string | null {
if (!stages.length) return null;
const header = 'stage,count,conversion_pct';
const rows = stages.map((s) => `${s.stage},${s.count},${s.conversionPct}`);
return [header, ...rows].join('\n');
}
return (
<ChartCard
title="Pipeline Funnel"
description="Interests by stage with conversion rate vs. open"
exportFilename={`pipeline-funnel-${range}`}
toCsv={toCsv}
>
{isLoading ? (
<CardSkeleton />
) : allZero ? (
<EmptyState title="No interests in range" description="Try a longer date range." />
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="stage"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
angle={-40}
textAnchor="end"
interval={0}
/>
<YAxis
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(value, _name, item) => {
const pct = (item?.payload as { conversionPct?: number } | undefined)
?.conversionPct;
return [`${value} (${pct ?? 0}%)`, 'Count'];
}}
/>
<Bar dataKey="count" fill="hsl(var(--chart-1))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card';
import { useRevenue } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
interface Props {
range: DateRange;
}
const STATUS_LABELS: Record<string, string> = {
draft: 'Draft',
sent: 'Sent',
paid: 'Paid',
overdue: 'Overdue',
cancelled: 'Cancelled',
};
export function RevenueBreakdownChart({ range }: Props) {
const { data, isLoading } = useRevenue(range);
const bars = data?.bars ?? [];
function toCsv(): string | null {
if (!bars.length) return null;
const header = 'status,currency,amount';
const rows = bars.map((b) => `${b.status},${b.currency},${b.amount}`);
return [header, ...rows].join('\n');
}
const chartData = bars.map((b) => ({
label: `${STATUS_LABELS[b.status] ?? b.status} (${b.currency})`,
amount: b.amount,
currency: b.currency,
}));
return (
<ChartCard
title="Revenue Breakdown"
description="Invoice totals grouped by status and currency"
exportFilename={`revenue-breakdown-${range}`}
toCsv={toCsv}
>
{isLoading ? (
<CardSkeleton />
) : !bars.length ? (
<EmptyState title="No invoices in range" description="Invoices appear here once issued." />
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
angle={-30}
textAnchor="end"
interval={0}
/>
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} />
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(value, _name, item) => {
const c = (item?.payload as { currency?: string } | undefined)?.currency ?? '';
const num = typeof value === 'number' ? value : Number(value);
return [`${num.toLocaleString()} ${c}`, 'Amount'];
}}
/>
<Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import type {
DateRange,
LeadSourceAttributionData,
MetricBase,
OccupancyTimelineData,
PipelineFunnelData,
RevenueBreakdownData,
} from '@/lib/services/analytics.service';
interface MetricResponse<T> {
metric: MetricBase;
range: DateRange;
data: T;
}
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
return useQuery<T>({
queryKey: ['analytics', metric, range],
queryFn: async () => {
const res = await apiFetch<MetricResponse<T>>(
`/api/v1/analytics?metric=${metric}&range=${range}`,
);
return res.data;
},
staleTime: 60_000,
retry: 2,
});
}
export const useFunnel = (range: DateRange) =>
useAnalyticsMetric<PipelineFunnelData>('pipeline_funnel', range);
export const useOccupancy = (range: DateRange) =>
useAnalyticsMetric<OccupancyTimelineData>('occupancy_timeline', range);
export const useRevenue = (range: DateRange) =>
useAnalyticsMetric<RevenueBreakdownData>('revenue_breakdown', range);
export const useLeadSource = (range: DateRange) =>
useAnalyticsMetric<LeadSourceAttributionData>('lead_source_attribution', range);

View File

@@ -35,6 +35,7 @@ interface HubDoc {
interface HubCounts {
all: number;
eoi_queue: number;
awaiting_them: number;
awaiting_me: number;
completed: number;
@@ -43,6 +44,7 @@ interface HubCounts {
const TAB_LABELS: Record<DocumentsHubTab, string> = {
all: 'All',
eoi_queue: 'EOI queue',
awaiting_them: 'Awaiting them',
awaiting_me: 'Awaiting me',
completed: 'Completed',
@@ -118,6 +120,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
const counts: HubCounts = countsResp?.data ?? {
all: 0,
eoi_queue: 0,
awaiting_them: 0,
awaiting_me: 0,
completed: 0,

View File

@@ -29,6 +29,9 @@ export interface ExpenseRow {
receiptFileIds: string[] | null;
archivedAt: string | null;
createdAt: string;
/** Set by the dedup engine when this expense looks like a duplicate of another. */
duplicateOf: string | null;
dedupScannedAt: string | null;
}
const PAYMENT_STATUS_COLORS: Record<string, string> = {
@@ -94,7 +97,8 @@ export function getExpenseColumns({
cell: ({ row }) =>
row.original.amountUsd ? (
<span className="text-sm text-muted-foreground tabular-nums">
${Number(row.original.amountUsd).toLocaleString('en-US', {
$
{Number(row.original.amountUsd).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
@@ -125,10 +129,7 @@ export function getExpenseColumns({
const status = (getValue() as string | null) ?? 'unpaid';
const colorClass = PAYMENT_STATUS_COLORS[status] ?? '';
return (
<Badge
variant="outline"
className={`capitalize text-xs border ${colorClass}`}
>
<Badge variant="outline" className={`capitalize text-xs border ${colorClass}`}>
{status}
</Badge>
);
@@ -162,10 +163,7 @@ export function getExpenseColumns({
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onArchive(row.original)}
>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>

View File

@@ -11,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { apiFetch } from '@/lib/api/client';
import type { ExpenseRow } from './expense-columns';
import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
const PAYMENT_STATUS_COLORS: Record<string, string> = {
unpaid: 'bg-red-100 text-red-700 border-red-200',
@@ -52,9 +53,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
if (error || !data?.data) {
return (
<div className="p-6 text-center text-muted-foreground">
Failed to load expense details.
</div>
<div className="p-6 text-center text-muted-foreground">Failed to load expense details.</div>
);
}
@@ -64,6 +63,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
return (
<div className="space-y-6">
<ExpenseDuplicateBanner expense={expense} />
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">
@@ -107,10 +107,12 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
</p>
{expense.amountUsd && expense.currency !== 'USD' && (
<p className="text-sm text-muted-foreground mt-1">
${Number(expense.amountUsd).toLocaleString('en-US', {
$
{Number(expense.amountUsd).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} USD
})}{' '}
USD
</p>
)}
</CardContent>
@@ -121,10 +123,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
<CardTitle className="text-sm font-medium">Payment Status</CardTitle>
</CardHeader>
<CardContent>
<Badge
variant="outline"
className={`capitalize text-sm border ${statusColor}`}
>
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
{status}
</Badge>
</CardContent>
@@ -138,15 +137,11 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
<CardContent className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Category</span>
<p className="mt-0.5 capitalize">
{expense.category?.replace(/_/g, ' ') ?? '—'}
</p>
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '—'}</p>
</div>
<div>
<span className="text-muted-foreground">Payment Method</span>
<p className="mt-0.5 capitalize">
{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}
</p>
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}</p>
</div>
<div>
<span className="text-muted-foreground">Payer</span>

View File

@@ -0,0 +1,121 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import type { ExpenseRow } from './expense-columns';
interface Props {
expense: ExpenseRow;
}
export function ExpenseDuplicateBanner({ expense }: Props) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [resolving, setResolving] = useState(false);
// Fetch the candidate expense for context.
const { data: candidateResp } = useQuery<{ data: ExpenseRow }>({
queryKey: ['expenses', expense.duplicateOf],
queryFn: () => apiFetch(`/api/v1/expenses/${expense.duplicateOf}`),
enabled: Boolean(expense.duplicateOf),
staleTime: 30_000,
});
const candidate = candidateResp?.data;
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
};
const merge = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/expenses/${expense.id}/merge`, {
method: 'POST',
body: { targetId: expense.duplicateOf },
}),
onSuccess: () => {
invalidate();
setResolving(false);
},
});
const clear = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/expenses/${expense.id}/clear-duplicate`, { method: 'POST' }),
onSuccess: () => {
invalidate();
setResolving(false);
},
});
if (!expense.duplicateOf) return null;
const candidateLabel = candidate
? `${candidate.establishmentName ?? 'Unnamed expense'} · ${
candidate.amount
} ${candidate.currency} · ${format(new Date(candidate.expenseDate), 'd MMM yyyy')}`
: 'a previously recorded expense';
return (
<div
data-testid="expense-duplicate-banner"
className={cn(
'flex flex-col gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900',
'sm:flex-row sm:items-center sm:justify-between',
)}
>
<div className="flex min-w-0 items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">Looks like a duplicate</p>
<p className="mt-0.5 text-xs text-amber-800">
This expense matches{' '}
<Link
href={`/${portSlug}/expenses/${expense.duplicateOf}` as never}
className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
>
{candidateLabel}
<ExternalLink className="h-3 w-3" aria-hidden />
</Link>
. Merge to consolidate, or mark as not a duplicate to keep them separate.
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="border-amber-400 bg-white"
disabled={resolving || merge.isPending || clear.isPending}
onClick={() => {
setResolving(true);
merge.mutate();
}}
data-testid="expense-merge-btn"
>
Merge them
</Button>
<Button
variant="ghost"
size="sm"
disabled={resolving || merge.isPending || clear.isPending}
onClick={() => {
setResolving(true);
clear.mutate();
}}
data-testid="expense-not-duplicate-btn"
>
Not a duplicate
</Button>
</div>
</div>
);
}

View File

@@ -15,6 +15,8 @@ import {
FolderOpen,
Mail,
Bell,
Camera,
ShieldAlert,
Settings,
Shield,
Home,
@@ -69,6 +71,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
marinaRequired: true,
items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/alerts`, label: 'Alerts', icon: ShieldAlert },
{ href: `${base}/clients`, label: 'Clients', icon: Users },
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
@@ -105,6 +108,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
marinaRequired: true,
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
{ href: `${base}/scan`, label: 'Scan receipt', icon: Camera },
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
],
},

View File

@@ -19,6 +19,7 @@ import { PortSwitcher } from '@/components/layout/port-switcher';
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
import { CommandSearch } from '@/components/search/command-search';
import { NotificationBell } from '@/components/notifications/notification-bell';
import { AlertBell } from '@/components/alerts/alert-bell';
import type { Port } from '@/lib/db/schema/ports';
interface TopbarProps {
@@ -87,6 +88,9 @@ export function Topbar({ ports, user }: TopbarProps) {
</DropdownMenuContent>
</DropdownMenu>
{/* Phase B operational alerts — distinct from user notifications */}
<AlertBell />
{/* Notification bell — real-time via socket */}
<NotificationBell />

View File

@@ -12,8 +12,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { CountryCode } from '@/lib/i18n/countries';
interface ResidentialInterestSummary {
id: string;
@@ -29,7 +34,13 @@ interface ResidentialClientDetail {
fullName: string;
email: string | null;
phone: string | null;
phoneE164: string | null;
phoneCountry: string | null;
nationalityIso: string | null;
timezone: string | null;
placeOfResidence: string | null;
placeOfResidenceCountryIso: string | null;
subdivisionIso: string | null;
preferredContactMethod: string | null;
status: string;
source: string | null;
@@ -130,7 +141,17 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
<InlineEditableField value={client.email} onSave={save('email')} />
</Row>
<Row label="Phone">
<InlineEditableField value={client.phone} onSave={save('phone')} />
<InlinePhoneField
e164={client.phoneE164}
country={client.phoneCountry}
onSave={async ({ e164, country }) => {
await update.mutateAsync({
phone: e164,
phoneE164: e164,
phoneCountry: country,
});
}}
/>
</Row>
<Row label="Preferred contact">
<InlineEditableField
@@ -140,12 +161,50 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
onSave={save('preferredContactMethod')}
/>
</Row>
<Row label="Nationality">
<InlineCountryField
value={client.nationalityIso}
onSave={async (iso) => {
await update.mutateAsync({ nationalityIso: iso });
}}
/>
</Row>
<Row label="Timezone">
<InlineTimezoneField
value={client.timezone}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await update.mutateAsync({ timezone: tz });
}}
/>
</Row>
<Row label="Place of residence">
<InlineEditableField
value={client.placeOfResidence}
onSave={save('placeOfResidence')}
/>
</Row>
<Row label="Country of residence">
<InlineCountryField
value={client.placeOfResidenceCountryIso}
onSave={async (iso) => {
// When country flips, clear the subdivision — codes are country-scoped.
await update.mutateAsync({
placeOfResidenceCountryIso: iso,
subdivisionIso: null,
});
}}
/>
</Row>
<Row label="Region">
<SubdivisionCombobox
value={client.subdivisionIso}
onChange={(code) => {
void update.mutateAsync({ subdivisionIso: code });
}}
country={(client.placeOfResidenceCountryIso as CountryCode | null) ?? null}
/>
</Row>
</div>
<div className="space-y-1">

View File

@@ -12,8 +12,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { PageHeader } from '@/components/shared/page-header';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { CountryCode } from '@/lib/i18n/countries';
interface ResidentialClientRow {
id: string;
@@ -147,10 +152,26 @@ function NewResidentialClientSheet({
const qc = useQueryClient();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [phone, setPhone] = useState<PhoneInputValue | null>(null);
const [nationalityIso, setNationalityIso] = useState<CountryCode | null>(null);
const [timezone, setTimezone] = useState<string | null>(null);
const [placeOfResidence, setPlaceOfResidence] = useState('');
const [residenceCountry, setResidenceCountry] = useState<CountryCode | null>(null);
const [residenceSubdivision, setResidenceSubdivision] = useState<string | null>(null);
const [notes, setNotes] = useState('');
function reset() {
setFullName('');
setEmail('');
setPhone(null);
setNationalityIso(null);
setTimezone(null);
setPlaceOfResidence('');
setResidenceCountry(null);
setResidenceSubdivision(null);
setNotes('');
}
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/residential/clients', {
@@ -158,8 +179,14 @@ function NewResidentialClientSheet({
body: {
fullName,
email: email || undefined,
phone: phone || undefined,
phone: phone?.e164 ?? undefined,
phoneE164: phone?.e164 ?? undefined,
phoneCountry: phone?.country ?? undefined,
nationalityIso: nationalityIso ?? undefined,
timezone: timezone ?? undefined,
placeOfResidence: placeOfResidence || undefined,
placeOfResidenceCountryIso: residenceCountry ?? undefined,
subdivisionIso: residenceSubdivision ?? undefined,
notes: notes || undefined,
source: 'manual',
},
@@ -167,11 +194,7 @@ function NewResidentialClientSheet({
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['residential-clients'] });
onOpenChange(false);
setFullName('');
setEmail('');
setPhone('');
setPlaceOfResidence('');
setNotes('');
reset();
toast.success('Residential client added');
},
onError: (err) => {
@@ -212,7 +235,28 @@ function NewResidentialClientSheet({
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-phone">Phone</Label>
<Input id="rc-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
<PhoneInput id="rc-phone" value={phone} onChange={setPhone} data-testid="rc-phone" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="rc-nationality">Nationality</Label>
<CountryCombobox
id="rc-nationality"
value={nationalityIso}
onChange={setNationalityIso}
data-testid="rc-nationality"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-timezone">Timezone</Label>
<TimezoneCombobox
id="rc-timezone"
value={timezone}
onChange={setTimezone}
countryHint={nationalityIso ?? undefined}
data-testid="rc-timezone"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-residence">Place of residence</Label>
@@ -220,8 +264,34 @@ function NewResidentialClientSheet({
id="rc-residence"
value={placeOfResidence}
onChange={(e) => setPlaceOfResidence(e.target.value)}
placeholder="City or area"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="rc-residence-country">Country</Label>
<CountryCombobox
id="rc-residence-country"
value={residenceCountry}
onChange={(iso) => {
setResidenceCountry(iso);
// Wipe subdivision when country flips — codes are scoped per country.
setResidenceSubdivision(null);
}}
data-testid="rc-residence-country"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-residence-subdivision">Region</Label>
<SubdivisionCombobox
id="rc-residence-subdivision"
value={residenceSubdivision}
onChange={setResidenceSubdivision}
country={residenceCountry}
data-testid="rc-residence-subdivision"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-notes">Notes</Label>
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />

View File

@@ -0,0 +1,550 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useUIStore } from '@/stores/ui-store';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
import { runTesseract } from '@/lib/ocr/tesseract-client';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ParsedReceipt {
establishment: string | null;
date: string | null;
amount: number | null;
currency: string | null;
lineItems: Array<{ description: string; amount: number }>;
confidence: number;
}
type ScanState =
| { kind: 'idle' }
| { kind: 'processing'; engine: 'tesseract' | 'ai' }
| {
kind: 'verify';
parsed: ParsedReceipt;
source: 'ai' | 'tesseract' | 'manual';
reason?: string;
providerError?: string;
}
| { kind: 'saving' }
| { kind: 'saved'; expenseId: string }
| { kind: 'error'; message: string };
interface ScanResp {
data: {
parsed: ParsedReceipt;
source: 'ai' | 'manual';
reason?: string;
provider?: string;
model?: string;
providerError?: string;
};
}
// ─── Form ─────────────────────────────────────────────────────────────────────
interface VerifyFormProps {
parsed: ParsedReceipt;
imagePreview: string;
imageFile: File;
source: 'ai' | 'tesseract' | 'manual';
reason?: string;
providerError?: string;
onSubmit: (input: {
establishmentName: string;
amount: string;
currency: string;
expenseDate: string;
category: string;
paymentMethod: string;
description: string;
file: File;
}) => void;
onRetake: () => void;
saving: boolean;
}
const TODAY = () => new Date().toISOString().slice(0, 10);
function VerifyForm({
parsed,
imagePreview,
imageFile,
source,
reason: _reason,
providerError,
onSubmit,
onRetake,
saving,
}: VerifyFormProps) {
const [establishmentName, setEstablishment] = useState(parsed.establishment ?? '');
const [amount, setAmount] = useState(parsed.amount != null ? String(parsed.amount) : '');
const [currency, setCurrency] = useState((parsed.currency ?? 'USD').toUpperCase());
const [expenseDate, setExpenseDate] = useState(parsed.date ?? TODAY());
const [category, setCategory] = useState<string>('other');
const [paymentMethod, setPaymentMethod] = useState<string>('credit_card');
const [description, setDescription] = useState('');
const lowConfidence = source !== 'manual' && parsed.confidence < 0.6;
const noOcr = source === 'manual';
const engineLabel = source === 'ai' ? 'AI' : source === 'tesseract' ? 'on-device OCR' : 'manual';
const banner = noOcr ? (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Manual entry mode</p>
<p className="text-xs mt-0.5">
{providerError
? `We couldn't read the receipt automatically: ${providerError}.`
: "We couldn't read the receipt automatically."}{' '}
Fill in the details below to save the expense with the photo attached.
</p>
</div>
</div>
) : lowConfidence ? (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Low-confidence read please double-check the fields</p>
<p className="text-xs mt-0.5">
{engineLabel} returned {Math.round(parsed.confidence * 100)}% confidence.
</p>
</div>
</div>
) : (
<div className="flex items-start gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Receipt parsed confirm the fields and save</p>
<p className="text-xs mt-0.5">
{engineLabel} · {Math.round(parsed.confidence * 100)}% confidence.
</p>
</div>
</div>
);
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
onSubmit({
establishmentName,
amount,
currency,
expenseDate,
category,
paymentMethod,
description,
file: imageFile,
});
}}
>
{banner}
<div className="overflow-hidden rounded-lg border border-border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreview}
alt="Receipt preview"
className="block w-full max-h-[40vh] object-contain bg-muted"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="establishmentName">Vendor / establishment</Label>
<Input
id="establishmentName"
value={establishmentName}
onChange={(e) => setEstablishment(e.target.value)}
placeholder="e.g. Marina Fuel Station"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="amount">Amount</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input
id="currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
maxLength={3}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="expenseDate">Date</Label>
<Input
id="expenseDate"
type="date"
value={expenseDate}
onChange={(e) => setExpenseDate(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPENSE_CATEGORIES.map((c) => (
<SelectItem key={c} value={c} className="capitalize">
{c.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="paymentMethod">Payment method</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger id="paymentMethod">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map((p) => (
<SelectItem key={p} value={p} className="capitalize">
{p.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="description">Notes (optional)</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
disabled={saving || !amount}
className="h-12 text-base sm:flex-1"
data-testid="scan-save"
>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save expense
</Button>
<Button
type="button"
variant="outline"
onClick={onRetake}
disabled={saving}
className="h-12 text-base"
>
<RotateCcw className="mr-2 h-4 w-4" />
Retake
</Button>
</div>
</form>
);
}
// ─── Shell ────────────────────────────────────────────────────────────────────
export function ScanShell() {
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const fileRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<ScanState>({ kind: 'idle' });
const [imagePreview, setImagePreview] = useState<string | null>(null);
// Revoke blob URL on unmount.
useEffect(() => {
return () => {
if (imagePreview) URL.revokeObjectURL(imagePreview);
};
}, [imagePreview]);
async function handleFile(file: File) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(URL.createObjectURL(file));
setState({ kind: 'processing', engine: 'tesseract' });
// Always run Tesseract first — it's free, on-device, and gives us a
// baseline parse we can fall back to if the optional AI pass is off
// or fails. The WASM bundle dynamic-imports inside `runTesseract`.
let tesseract: Awaited<ReturnType<typeof runTesseract>> | null = null;
try {
tesseract = await runTesseract(file);
} catch (err) {
// Tesseract.js itself failed (corrupt image, OOM, etc). Don't bail —
// give the user the manual form so they can still save the expense.
setState({
kind: 'verify',
parsed: {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
},
source: 'manual',
reason: 'tesseract-error',
providerError: err instanceof Error ? err.message : 'On-device OCR failed',
});
return;
}
// Now ask the server whether AI is enabled for this port. If it is,
// the server runs the configured provider and returns a richer parse;
// otherwise we keep the Tesseract result.
setState({ kind: 'processing', engine: 'ai' });
try {
const fd = new FormData();
fd.append('file', file);
const portId = useUIStore.getState().currentPortId;
const headers = new Headers();
if (portId) headers.set('X-Port-Id', portId);
const res = await fetch('/api/v1/expenses/scan-receipt', {
method: 'POST',
body: fd,
credentials: 'include',
headers,
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const body = (await res.json()) as ScanResp;
if (body.data.source === 'ai' && body.data.parsed.confidence >= tesseract.parsed.confidence) {
// AI did at least as well as Tesseract — prefer its result.
setState({
kind: 'verify',
parsed: body.data.parsed,
source: 'ai',
reason: body.data.reason,
providerError: body.data.providerError,
});
return;
}
// Either AI is disabled (`source: 'manual', reason: 'ai-disabled'`),
// not configured, or it underperformed — fall back to Tesseract.
setState({
kind: 'verify',
parsed: tesseract.parsed,
source: 'tesseract',
reason: body.data.reason,
providerError: body.data.providerError,
});
} catch {
// Server unreachable — still let the user verify with the Tesseract
// result and save the expense. We don't surface the network error
// because the local parse is usable.
setState({
kind: 'verify',
parsed: tesseract.parsed,
source: 'tesseract',
});
}
}
async function handleSubmit(input: {
establishmentName: string;
amount: string;
currency: string;
expenseDate: string;
category: string;
paymentMethod: string;
description: string;
file: File;
}) {
setState({ kind: 'saving' });
try {
// Upload the image (multipart — apiFetch wraps JSON, so use raw fetch).
const fd = new FormData();
fd.append('file', input.file);
fd.append('category', 'receipt');
const portId = useUIStore.getState().currentPortId;
const headers = new Headers();
if (portId) headers.set('X-Port-Id', portId);
const upRes = await fetch('/api/v1/files/upload', {
method: 'POST',
body: fd,
credentials: 'include',
headers,
});
if (!upRes.ok) throw new Error(`Upload failed: ${upRes.status}`);
const upJson = (await upRes.json()) as { data: { id: string } };
const expense = await apiFetch<{ data: { id: string } }>(`/api/v1/expenses`, {
method: 'POST',
body: {
establishmentName: input.establishmentName || undefined,
amount: input.amount,
currency: input.currency,
expenseDate: input.expenseDate,
category: input.category,
paymentMethod: input.paymentMethod,
description: input.description || undefined,
receiptFileIds: [upJson.data.id],
paymentStatus: 'unpaid',
},
});
setState({ kind: 'saved', expenseId: expense.data.id });
} catch (err) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'Save failed',
});
}
}
function reset() {
if (imagePreview) {
URL.revokeObjectURL(imagePreview);
setImagePreview(null);
}
setState({ kind: 'idle' });
if (fileRef.current) fileRef.current.value = '';
}
return (
<main className="mx-auto flex min-h-[100dvh] w-full max-w-xl flex-col gap-4 px-4 py-6 sm:py-10">
<header className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Scan a receipt</h1>
{state.kind !== 'idle' ? (
<Button variant="ghost" size="sm" onClick={reset}>
Start over
</Button>
) : null}
</header>
{state.kind === 'idle' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed border-border bg-muted/20 p-8 text-center">
<Camera className="h-12 w-12 text-muted-foreground/60" aria-hidden />
<div>
<p className="text-base font-medium">Tap to capture a receipt</p>
<p className="mt-1 text-xs text-muted-foreground">
Use your camera or pick an image from your library. We&apos;ll read it and pre-fill
the form for you to confirm.
</p>
</div>
<Button
type="button"
className="h-12 px-6 text-base"
onClick={() => fileRef.current?.click()}
data-testid="scan-capture"
>
<Camera className="mr-2 h-5 w-5" />
Capture receipt
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
</section>
) : null}
{state.kind === 'processing' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
<Loader2 className="h-10 w-10 animate-spin text-brand" />
<p className="text-sm text-muted-foreground">
{state.engine === 'tesseract' ? 'Reading on-device…' : 'Refining with AI…'}
</p>
</section>
) : null}
{state.kind === 'verify' && imagePreview ? (
<VerifyForm
parsed={state.parsed}
imagePreview={imagePreview}
imageFile={fileRef.current?.files?.[0] as File}
source={state.source}
reason={state.reason}
providerError={state.providerError}
onSubmit={handleSubmit}
onRetake={reset}
saving={false}
/>
) : null}
{state.kind === 'saving' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
<Loader2 className="h-10 w-10 animate-spin text-brand" />
<p className="text-sm text-muted-foreground">Saving expense</p>
</section>
) : null}
{state.kind === 'saved' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 p-8 text-center">
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
<p className="text-base font-semibold text-emerald-900">Expense saved</p>
<div className="flex gap-2">
<Button onClick={reset} variant="outline" data-testid="scan-another">
Scan another
</Button>
<Button
onClick={() => router.push(`/${portSlug}/expenses/${state.expenseId}` as never)}
>
View expense
</Button>
</div>
</section>
) : null}
{state.kind === 'error' ? (
<section
className={cn(
'flex flex-col items-center gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-6 text-center',
)}
>
<AlertTriangle className="h-10 w-10 text-destructive" />
<p className="text-base font-medium text-destructive">{state.message}</p>
<Button onClick={reset} variant="outline">
Try again
</Button>
</section>
) : null}
</main>
);
}

View File

@@ -0,0 +1,411 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { apiFetch } from '@/lib/api/client';
import type { CountryCode } from '@/lib/i18n/countries';
import { getCountryName } from '@/lib/i18n/countries';
import { getSubdivisionName } from '@/lib/i18n/subdivisions';
import { cn } from '@/lib/utils';
export interface Address {
id: string;
label: string;
streetAddress: string | null;
city: string | null;
subdivisionIso: string | null;
postalCode: string | null;
countryIso: string | null;
isPrimary: boolean;
}
type AddressPatch = Partial<Omit<Address, 'id'>>;
interface AddressesEditorProps {
/** Base API endpoint, e.g. `/api/v1/clients/abc/addresses` */
endpoint: string;
/** React-Query invalidation key for the parent entity. */
invalidateKey: readonly unknown[];
addresses: Address[];
}
export function AddressesEditor({ endpoint, invalidateKey, addresses }: AddressesEditorProps) {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
function invalidate() {
qc.invalidateQueries({ queryKey: invalidateKey });
}
const updateMutation = useMutation({
mutationFn: async ({ id, patch }: { id: string; patch: AddressPatch }) =>
apiFetch(`${endpoint}/${id}`, { method: 'PATCH', body: patch }),
onSuccess: invalidate,
});
const addMutation = useMutation({
mutationFn: async (data: AddressPatch) => apiFetch(endpoint, { method: 'POST', body: data }),
onSuccess: invalidate,
});
const removeMutation = useMutation({
mutationFn: async (id: string) => apiFetch(`${endpoint}/${id}`, { method: 'DELETE' }),
onSuccess: invalidate,
});
return (
<div className="space-y-2">
{addresses.length === 0 && !adding && (
<p className="text-sm text-muted-foreground">No addresses yet</p>
)}
{addresses.map((a) => (
<AddressCard
key={a.id}
address={a}
onUpdate={(patch) => updateMutation.mutateAsync({ id: a.id, patch })}
onRemove={async () => {
if (!confirm('Remove this address?')) return;
await removeMutation.mutateAsync(a.id);
}}
/>
))}
{adding ? (
<NewAddressForm
isFirst={addresses.length === 0}
onCancel={() => setAdding(false)}
onSave={async (data) => {
await addMutation.mutateAsync(data);
setAdding(false);
}}
/>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdding(true)}
className="w-full justify-center"
data-testid="add-address-button"
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add address
</Button>
)}
</div>
);
}
function AddressCard({
address,
onUpdate,
onRemove,
}: {
address: Address;
onUpdate: (patch: AddressPatch) => Promise<unknown>;
onRemove: () => void;
}) {
async function togglePrimary() {
if (address.isPrimary) return; // already primary; demoting via toggle would orphan all
try {
await onUpdate({ isPrimary: true });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
}
}
return (
<div className="group rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<InlineEditableField
value={address.label}
placeholder="Label (e.g. Home, Office)"
onSave={async (v) => {
if (!v) {
toast.error('Label is required');
return;
}
await onUpdate({ label: v });
}}
/>
</div>
<button
type="button"
onClick={togglePrimary}
title={address.isPrimary ? 'Primary address' : 'Make primary'}
className={cn(
'p-1 rounded hover:bg-background/60 transition-colors',
address.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)}
data-testid="address-primary-toggle"
>
<Star className={cn('h-3.5 w-3.5', address.isPrimary && 'fill-current')} />
</button>
<button
type="button"
onClick={onRemove}
title="Remove"
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
data-testid="address-remove-button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 pl-5">
<Field label="Street">
<InlineEditableField
value={address.streetAddress}
placeholder="123 Main St"
onSave={async (v) => {
await onUpdate({ streetAddress: v });
}}
/>
</Field>
<Field label="City">
<InlineEditableField
value={address.city}
placeholder="City"
onSave={async (v) => {
await onUpdate({ city: v });
}}
/>
</Field>
<Field label="Country">
<CountryFieldInline
value={address.countryIso}
onSave={async (iso) => {
// Clear subdivision if country changes — codes are scoped per country.
const patch: AddressPatch = { countryIso: iso };
if (iso !== address.countryIso) patch.subdivisionIso = null;
await onUpdate(patch);
}}
/>
</Field>
<Field label="Region">
<SubdivisionFieldInline
value={address.subdivisionIso}
country={(address.countryIso as CountryCode | null) ?? null}
onSave={async (code) => {
await onUpdate({ subdivisionIso: code });
}}
/>
</Field>
<Field label="Postal Code">
<InlineEditableField
value={address.postalCode}
placeholder="ZIP / Postal"
onSave={async (v) => {
await onUpdate({ postalCode: v });
}}
/>
</Field>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-baseline gap-2">
<span className="text-xs text-muted-foreground w-20 shrink-0">{label}</span>
<span className="flex-1 min-w-0">{children}</span>
</div>
);
}
function CountryFieldInline({
value,
onSave,
}: {
value: string | null;
onSave: (iso: string | null) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<CountryCombobox
value={value ?? null}
onChange={async (iso) => {
setEditing(false);
await onSave(iso ?? null);
}}
clearable
className="w-full"
/>
);
}
const display = value ? getCountryName(value, 'en') : null;
return (
<button
type="button"
onClick={() => setEditing(true)}
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
>
{display ?? <span className="text-muted-foreground italic">Not set</span>}
</button>
);
}
function SubdivisionFieldInline({
value,
country,
onSave,
}: {
value: string | null;
country: CountryCode | null;
onSave: (code: string | null) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<SubdivisionCombobox
value={value ?? null}
country={country}
onChange={async (code) => {
setEditing(false);
await onSave(code ?? null);
}}
clearable
className="w-full"
/>
);
}
if (!country) {
return <span className="text-muted-foreground italic text-xs">Pick country first</span>;
}
const display = value ? getSubdivisionName(value) : null;
return (
<button
type="button"
onClick={() => setEditing(true)}
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
>
{display ?? <span className="text-muted-foreground italic">Not set</span>}
</button>
);
}
function NewAddressForm({
onSave,
onCancel,
isFirst,
}: {
onSave: (data: AddressPatch) => Promise<void>;
onCancel: () => void;
isFirst: boolean;
}) {
const [label, setLabel] = useState('Primary');
const [streetAddress, setStreet] = useState('');
const [city, setCity] = useState('');
const [countryIso, setCountryIso] = useState<string | null>(null);
const [subdivisionIso, setSubdivisionIso] = useState<string | null>(null);
const [postalCode, setPostal] = useState('');
const [makePrimary, setMakePrimary] = useState(isFirst);
const [saving, setSaving] = useState(false);
async function submit() {
if (!label.trim()) {
toast.error('Label is required');
return;
}
setSaving(true);
try {
await onSave({
label: label.trim(),
streetAddress: streetAddress.trim() || null,
city: city.trim() || null,
countryIso,
subdivisionIso,
postalCode: postalCode.trim() || null,
isPrimary: makePrimary,
});
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add address');
} finally {
setSaving(false);
}
}
return (
<div className="rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Label (Home, Office)"
className="h-8"
autoFocus
disabled={saving}
/>
<Input
value={streetAddress}
onChange={(e) => setStreet(e.target.value)}
placeholder="Street address"
className="h-8"
disabled={saving}
/>
<Input
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="City"
className="h-8"
disabled={saving}
/>
<CountryCombobox
value={countryIso}
onChange={(iso) => {
setCountryIso(iso ?? null);
setSubdivisionIso(null);
}}
clearable
placeholder="Country"
/>
<SubdivisionCombobox
value={subdivisionIso}
country={(countryIso as CountryCode | null) ?? null}
onChange={(code) => setSubdivisionIso(code ?? null)}
clearable
placeholder="Region (optional)"
/>
<Input
value={postalCode}
onChange={(e) => setPostal(e.target.value)}
placeholder="Postal code"
className="h-8"
disabled={saving}
/>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={makePrimary}
onChange={(e) => setMakePrimary(e.target.checked)}
disabled={saving}
/>
Set as primary address
</label>
<div className="flex gap-2">
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
Cancel
</Button>
<Button type="button" size="sm" onClick={submit} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useMemo, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { ALL_COUNTRY_CODES, getCountryName, type CountryCode } from '@/lib/i18n/countries';
interface CountryComboboxProps {
value: string | null | undefined;
onChange: (iso: CountryCode | null) => void;
/** Display locale; defaults to navigator.language so country names follow the user. */
locale?: string;
/** When true, renders just the flag/code (compact 24×24 trigger). */
compact?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
/** Allow clearing the selection. */
clearable?: boolean;
id?: string;
'data-testid'?: string;
}
/**
* Returns the regional-indicator emoji flag for an ISO alpha-2 code.
* E.g. 'GB' → 🇬🇧. Avoids shipping a flag-image asset and respects the
* platform's emoji rendering (iOS/macOS render real flags; Windows
* shows the country code on a flag rectangle).
*/
function flagEmoji(code: string): string {
if (code.length !== 2) return '';
const A = 0x1f1e6;
const a = 'A'.charCodeAt(0);
const cp1 = A + code.charCodeAt(0) - a;
const cp2 = A + code.charCodeAt(1) - a;
return String.fromCodePoint(cp1, cp2);
}
export function CountryCombobox({
value,
onChange,
locale,
compact = false,
placeholder = 'Select country…',
disabled,
className,
clearable = true,
id,
'data-testid': testId,
}: CountryComboboxProps) {
const [open, setOpen] = useState(false);
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
// Pre-build the options list once per locale change so the cmdk filter
// can search by both code + localized name without re-allocating.
const options = useMemo(() => {
return ALL_COUNTRY_CODES.map((code) => ({
code,
name: getCountryName(code, effectiveLocale),
flag: flagEmoji(code),
})).sort((a, b) => a.name.localeCompare(b.name, effectiveLocale));
}, [effectiveLocale]);
const selected = value ? options.find((o) => o.code === value) : undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
'justify-between',
compact ? 'w-20 px-2' : 'w-full',
!selected && 'text-muted-foreground',
className,
)}
data-testid={testId}
>
{selected ? (
<span className="flex min-w-0 items-center gap-2">
<span className="text-base leading-none">{selected.flag}</span>
{!compact ? (
<span className="truncate text-sm">{selected.name}</span>
) : (
<span className="text-xs font-medium">{selected.code}</span>
)}
</span>
) : (
<span className="truncate">{placeholder}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country or code…" />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
{clearable && value ? (
<CommandGroup>
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
</CommandGroup>
) : null}
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.code}
// cmdk filters by `value` — include both code + name.
value={`${opt.name} ${opt.code}`}
onSelect={() => {
onChange(opt.code);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === opt.code ? 'opacity-100' : 'opacity-0')}
/>
<span className="mr-2 text-base leading-none">{opt.flag}</span>
<span className="flex-1 truncate text-sm">{opt.name}</span>
<span className="text-xs text-muted-foreground">{opt.code}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

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