Compare commits
273 Commits
9d7decfc5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 60365dc3de | |||
| 5c8c12ba1f | |||
| 3e4d9d6310 | |||
| 267c2b6d1f | |||
| a0e68eb060 | |||
|
|
05babe57a0 | ||
|
|
1a87f28fd4 | ||
|
|
f3143d7561 | ||
|
|
0f648a924b | ||
|
|
b4fb3b2ca6 | ||
|
|
da7ede71d6 | ||
|
|
0a5f085a9e | ||
|
|
c312cd3685 | ||
|
|
59b9e8f177 | ||
|
|
5fc68a5f34 | ||
|
|
a8c6c071e6 | ||
|
|
94331bd6ec | ||
|
|
588f8bc43c | ||
|
|
c5b41ca4b5 | ||
|
|
9890d065f8 | ||
|
|
d2171ea79b | ||
|
|
4592789712 | ||
|
|
758d8628cf | ||
|
|
44db579988 | ||
|
|
7274baf1e1 | ||
|
|
70105715a7 | ||
|
|
472c12280b | ||
|
|
1ae5d88af4 | ||
|
|
8c02f88cbd | ||
|
|
789656bc70 | ||
|
|
fb02f3d5e1 | ||
|
|
e95316bd8a | ||
|
|
d07f1ed5e0 | ||
|
|
f10334683d | ||
|
|
8690352c56 | ||
|
|
9240cf1808 | ||
|
|
adba73fcca | ||
|
|
c60cbf4014 | ||
|
|
f93de75bb5 | ||
|
|
64f0e0a1b8 | ||
|
|
3f6a8aa3b8 | ||
|
|
c90876abad | ||
|
|
8cdee99310 | ||
|
|
d19b74b935 | ||
|
|
1b78eadd36 | ||
|
|
1fb3aa3aeb | ||
|
|
7bd969b41a | ||
|
|
63c4073e64 | ||
|
|
83239104e0 | ||
|
|
4bab6de8be | ||
|
|
4eea4ceff9 | ||
|
|
7854cbabe4 | ||
|
|
d3a6a9beef | ||
|
|
fc7595faf8 | ||
|
|
6a609ecf94 | ||
|
|
cf430d70c3 | ||
|
|
312779c0c5 | ||
|
|
4723994bdc | ||
|
|
c4a41d5f5b | ||
|
|
687a1f1c2f | ||
|
|
ade4c9e77d | ||
|
|
d4b3a1338f | ||
|
|
cf37d09519 | ||
|
|
180912ba9f | ||
|
|
014bbe1923 | ||
|
|
a3e002852b | ||
|
|
312ebf1a88 | ||
|
|
0b8d08b57e | ||
|
|
86372a857f | ||
|
|
b4776b4c3c | ||
|
|
a0091e4ca6 | ||
|
|
249ffe3e4a | ||
|
|
83693dd993 | ||
|
|
15d4849030 | ||
|
|
e00e812199 | ||
|
|
b1e787e55c | ||
|
|
fb1116f1d4 | ||
|
|
5b70e9b04b | ||
|
|
57cbc9a506 | ||
|
|
6e3d910c76 | ||
|
|
ff92a08620 | ||
|
|
05257723f6 | ||
|
|
3017ce4b3a | ||
|
|
a2588f2c4a | ||
|
|
18119644ae | ||
|
|
61e2fbb2db | ||
|
|
05be89ec6f | ||
|
|
8699f81879 | ||
|
|
d62822c284 | ||
|
|
089f4a67a4 | ||
|
|
77ad10ced1 | ||
|
|
e598cc0708 | ||
|
|
f5772ce318 | ||
|
|
49d34e00c8 | ||
|
|
c612bbdfd9 | ||
|
|
872c75f1a1 | ||
|
|
c45aac551d | ||
|
|
9ad1df85d2 | ||
|
|
8e4d2fc5b4 | ||
|
|
78f2f46d41 | ||
|
|
3a9419fe10 | ||
|
|
b703684285 | ||
|
|
a792d9a182 | ||
|
|
d7ec2a8507 | ||
|
|
cb83b09b2d | ||
|
|
7574c3b575 | ||
|
|
bb105f5365 | ||
|
|
caafae15dd | ||
|
|
46c7389930 | ||
|
|
80fc5932be | ||
|
|
b26b87b2fa | ||
|
|
88f76b6b04 | ||
|
|
a32f41b91d | ||
|
|
cf1c8b66db | ||
|
|
596476280d | ||
|
|
e9359fc431 | ||
|
|
4767caec01 | ||
|
|
49d92234dd | ||
|
|
cad55e3565 | ||
|
|
21868ee5fc | ||
|
|
c7ab816c99 | ||
|
|
e40b6c3d99 | ||
|
|
4bcc7f8be6 | ||
|
|
18e5c124b0 | ||
|
|
8b077e1999 | ||
|
|
36b92eb827 | ||
|
|
e2398099c4 | ||
|
|
d364b09885 | ||
|
|
57a099acc4 | ||
|
|
a391934b73 | ||
|
|
e3e0e69c04 | ||
|
|
6af2ac9680 | ||
|
|
a767652d74 | ||
|
|
c824b2df12 | ||
|
|
d197f8b321 | ||
|
|
76a7387dcc | ||
|
|
868b1f40c0 | ||
|
|
dbbd03fd22 | ||
|
|
ba5fb6db5e | ||
|
|
886119cbde | ||
|
|
0d357731ad | ||
|
|
a75d4f5d69 | ||
|
|
0fb7920db5 | ||
|
|
16ad61ce15 | ||
|
|
d080bc52fa | ||
|
|
a653c8e039 | ||
|
|
7e8110b2ff | ||
|
|
9eadaf035e | ||
|
|
bcea28cd71 | ||
|
|
722491a9dd | ||
|
|
6009ccb7de | ||
|
|
71da6e8fdc | ||
|
|
c405124bc3 | ||
|
|
53cbee1d3d | ||
|
|
ac7f1db62c | ||
|
|
5d44f3cfa4 | ||
|
|
d0540dca55 | ||
|
|
0e9c24e222 | ||
|
|
3aba2181dc | ||
|
|
6237ad1567 | ||
|
|
34916d855e | ||
|
|
41ae8a328f | ||
|
|
1ff3160eac | ||
|
|
5698d742d3 | ||
|
|
e6ce265be0 | ||
|
|
19bc2f2a54 | ||
|
|
b0a11f1785 | ||
|
|
3cbf2444fe | ||
|
|
0330be1312 | ||
|
|
210360738d | ||
|
|
4df04e1a58 | ||
|
|
0c3baf04c5 | ||
|
|
79667b24da | ||
|
|
c4fdb29bbe | ||
|
|
38527d71fc | ||
|
|
3fbfba6598 | ||
|
|
e3a835675b | ||
|
|
1b085f81ed | ||
|
|
9f786fbcf3 | ||
|
|
906127a292 | ||
|
|
737b43589b | ||
|
|
fbb1f1f366 | ||
|
|
ba89b61b3f | ||
|
|
4eea19a85b | ||
|
|
47a1a51832 | ||
|
|
9a5479c2c7 | ||
|
|
e06fb9545b | ||
|
|
4c5334d471 | ||
|
|
61e40b5e76 | ||
|
|
7f9d90ad05 | ||
|
|
5d29bfc153 | ||
|
|
43f68ca093 | ||
|
|
d9557edfc5 | ||
|
|
6eb0d3dc92 | ||
|
|
a3305a94f3 | ||
|
|
9dfa04094b | ||
|
|
e7d23b254c | ||
|
|
2cf1bd9754 | ||
|
|
46937bbcb9 | ||
|
|
27cdbcc695 | ||
|
|
31fa3d08ec | ||
|
|
16d98d630e | ||
|
|
f52d21df83 | ||
|
|
2fa70f4582 | ||
|
|
01b201e1a2 | ||
|
|
94f049c8b8 | ||
|
|
df495133b7 | ||
|
|
639025ebf9 | ||
|
|
e77d55ac50 | ||
|
|
f1ed2a5f87 | ||
|
|
4036c16f39 | ||
|
|
5f9bbb97bd | ||
|
|
4911083d0f | ||
|
|
3a7fef59b0 | ||
|
|
c081334020 | ||
|
|
2d1b50745a | ||
|
|
40ae860a88 | ||
|
|
c7ca7c1f96 | ||
|
|
22b019a27e | ||
|
|
a3424b80d5 | ||
|
|
5bcdfefde3 | ||
|
|
22f944fde2 | ||
|
|
cda44e721b | ||
|
|
0406778c44 | ||
|
|
259cd7b8bb | ||
|
|
e42b8fde84 | ||
|
|
f354f4adab | ||
|
|
38cd36a616 | ||
|
|
77b6ef5026 | ||
|
|
978df1c4d7 | ||
|
|
df0b408b7a | ||
|
|
1151768159 | ||
|
|
9e69c13202 | ||
|
|
6212c118e5 | ||
|
|
6795db9aa8 | ||
|
|
d8f0cdd7d2 | ||
|
|
2dc53842c0 | ||
|
|
aa15807063 | ||
|
|
2a3fae4d6a | ||
|
|
da7262f18f | ||
|
|
398d6322f1 | ||
|
|
deafc5ef38 | ||
|
|
9b87b14c99 | ||
|
|
da44e8ecbe | ||
|
|
af2db06244 | ||
|
|
0eff6050ae | ||
|
|
d8ac62f6f4 | ||
|
|
dd138547fb | ||
|
|
1791dd7319 | ||
|
|
0ccc66833d | ||
|
|
4877b97f27 | ||
|
|
f2c57c513e | ||
|
|
999622fd08 | ||
|
|
e8d61c91c4 | ||
|
|
fac8021156 | ||
|
|
ea8181d108 | ||
|
|
65b241805e | ||
|
|
4a859245b7 | ||
|
|
4441f1177f | ||
|
|
c4085265ff | ||
|
|
475b051e29 | ||
|
|
4da8ed3ae4 | ||
|
|
4c67b9dbd4 | ||
|
|
0ed401d083 | ||
|
|
456d399ee2 | ||
|
|
f4ec51002c | ||
|
|
2ff24a7132 | ||
|
|
f8255cedb8 | ||
|
|
13d07e3906 | ||
|
|
7ef7b9bb5f | ||
|
|
7200c31486 | ||
|
|
db74c9394b | ||
|
|
d133d6d656 |
@@ -1 +0,0 @@
|
||||
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}
|
||||
34
.gitignore
vendored
34
.gitignore
vendored
@@ -19,3 +19,37 @@ tsconfig.tsbuildinfo
|
||||
.playwright-mcp/
|
||||
docker-compose.override.yml
|
||||
.remember/
|
||||
.DS_Store
|
||||
# Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match.
|
||||
/eoi/
|
||||
|
||||
# Brainstorming companion mockup files
|
||||
.superpowers/
|
||||
|
||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||
/*.png
|
||||
/*.jpg
|
||||
|
||||
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||
/client-portal/
|
||||
|
||||
# Sister marketing site — separate Nuxt project, not part of CRM tracking
|
||||
/website/
|
||||
|
||||
# Mobile audit screenshots — generated locally, regenerable
|
||||
/.audit/
|
||||
/.audit-screenshots/
|
||||
|
||||
# Migration script output (CSV reports, transcripts)
|
||||
.migration/
|
||||
|
||||
# Tool caches / runtime state
|
||||
/.claude/
|
||||
/.serena/
|
||||
/ruvector.db
|
||||
|
||||
# Filesystem storage backend root (FilesystemBackend default location)
|
||||
/storage/
|
||||
|
||||
# Local berth-PDF + brochure samples used as upload fixtures during dev.
|
||||
/berth_pdf_example/
|
||||
|
||||
@@ -20,16 +20,42 @@
|
||||
|
||||
### Client Domain
|
||||
|
||||
- `clients` — Anchor records for people/entities
|
||||
- `clients` — Anchor records for people/entities. Yacht and company details
|
||||
are no longer stored here — see the Yacht and Company domains.
|
||||
- `client_contacts` — Multi-channel contact entries per client
|
||||
- `client_addresses` — Physical addresses per client (primary + others)
|
||||
- `client_relationships` — Relationships between clients (referrals, broker, family)
|
||||
- `client_notes` — Timestamped notes on clients
|
||||
- `client_tags` — Tags assigned to clients
|
||||
- `client_merge_log` — Audit trail of client merges
|
||||
|
||||
### Yacht Domain
|
||||
|
||||
- `yachts` — First-class yacht records. Polymorphic ownership via
|
||||
`current_owner_type` (`'client' | 'company'`) + `current_owner_id`.
|
||||
- `yacht_ownership_history` — Append-only log of every transfer; partial
|
||||
unique index `idx_yoh_active` enforces a single active owner per yacht.
|
||||
- `yacht_notes`, `yacht_tags` — Notes / tags on yachts.
|
||||
|
||||
### Company Domain
|
||||
|
||||
- `companies` — Legal entities that may own yachts or be billed.
|
||||
- `company_addresses` — Addresses per company.
|
||||
- `company_memberships` — Active client ↔ company links with role
|
||||
(director / shareholder / beneficial_owner / authorised_signatory),
|
||||
start/end dates.
|
||||
|
||||
### Reservation Domain
|
||||
|
||||
- `berth_reservations` — Concrete client + yacht + berth holds with
|
||||
start/end dates and status. Partial unique index `idx_br_active`
|
||||
enforces one active reservation per berth.
|
||||
|
||||
### Interest Domain
|
||||
|
||||
- `interests` — Per-berth pipeline records, each belonging to a client (milestone dates are inline columns)
|
||||
- `interests` — Per-berth pipeline records. Each row references a
|
||||
`client_id`, `yacht_id` (the yacht in scope for the inquiry), and
|
||||
optional `berth_id`. Milestone dates are inline columns.
|
||||
- `interest_notes` — Timestamped notes on interests
|
||||
- `interest_tags` — Tags assigned to interests
|
||||
|
||||
|
||||
77
CLAUDE.md
77
CLAUDE.md
@@ -13,6 +13,19 @@ pnpm db:generate # Generate Drizzle migrations
|
||||
pnpm db:push # Push schema to DB
|
||||
pnpm db:studio # Drizzle Studio GUI
|
||||
pnpm db:seed # Seed database (tsx src/lib/db/seed.ts)
|
||||
|
||||
# Tests
|
||||
pnpm exec vitest run # Unit + integration (~3s)
|
||||
pnpm exec playwright test --project=smoke # Click-through smoke (~10min)
|
||||
pnpm exec playwright test --project=exhaustive # Full UI exhaustive
|
||||
pnpm exec playwright test --project=destructive # Archive/delete flows
|
||||
pnpm exec playwright test --project=realapi # Real Documenso/IMAP (opt-in)
|
||||
pnpm exec playwright test --project=visual # Pixel-diff baselines
|
||||
pnpm exec playwright test --project=visual --update-snapshots # Regenerate baselines
|
||||
|
||||
# Dev helpers
|
||||
pnpm tsx scripts/dev-trigger-portal-invite.ts # Send a portal activation email
|
||||
pnpm tsx scripts/dev-imap-probe.ts # Dump recent IMAP inbox messages
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
@@ -70,15 +83,57 @@ src/
|
||||
- **Formatting:** Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width.
|
||||
- **Lint:** ESLint flat config extending `next/core-web-vitals`, `next/typescript`, `prettier`. Unused vars prefixed with `_` are allowed.
|
||||
- **Imports:** Use `@/*` path alias (maps to `src/*`).
|
||||
- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`.
|
||||
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`.
|
||||
- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`. Yacht / company / reservation domains live in `components/yachts`, `components/companies`, `components/reservations` respectively.
|
||||
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`. Domain files include `clients.ts`, `yachts.ts`, `companies.ts`, `reservations.ts`, `interests.ts`, `berths.ts`, `documents.ts`, `invoices.ts`, etc.
|
||||
- **Polymorphic ownership:** Yachts and invoice billing-entities use `<entity>_type` + `<entity>_id` column pairs (`'client' | 'company'`). Resolve owner identity through `src/lib/services/yachts.service.ts` / `eoi-context.ts` rather than reading the columns ad hoc — those services apply the type discriminator.
|
||||
- **EOI generation:** Two pathways share the same `EoiContext` (`src/lib/services/eoi-context.ts`). Documenso pathway calls the template-generate endpoint via `documenso-payload.ts`; in-app pathway fills the same source PDF (`assets/eoi-template.pdf`) via `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm). Routed through `generateAndSign(...)` in `src/lib/services/document-templates.ts` with a `pathway` parameter.
|
||||
- **Merge fields:** Token catalog lives in `src/lib/templates/merge-fields.ts`; the `createTemplateSchema` validator uses `VALID_MERGE_TOKENS` as an allow-list, so unknown tokens are rejected at template creation time.
|
||||
- **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat.
|
||||
- **Documenso API responses:** 2.x renamed `id` → `documentId` and recipient `id` → `recipientId`; v1.13 still uses `id`. `src/lib/services/documenso-client.ts` runs every response through `normalizeDocument()` which reads either field name and surfaces the legacy `id` form to downstream consumers.
|
||||
- **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later).
|
||||
- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
|
||||
- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
|
||||
- **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape.
|
||||
- **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware.
|
||||
- **Multi-berth interest model:** `interest_berths` is the source of truth for which berths an interest is linked to; `interests.berth_id` does not exist (dropped in migration 0029). Three role flags: `is_primary` (≤1 row per interest, enforced by partial unique index — surfaces as "the berth for this deal" in templates / forms / list views), `is_specific_interest` (true → berth shows as "Under Offer" on the public map; false → legal/EOI-only link), `is_in_eoi_bundle` (covered by the interest's EOI signature). Read/write through `src/lib/services/interest-berths.service.ts` helpers (`getPrimaryBerth`, `getPrimaryBerthsForInterests`, `upsertInterestBerth`, `setPrimaryBerth`, `removeInterestBerth`); never query `interest_berths` from outside that service.
|
||||
- **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, and rendered in EOIs in this exact form. Phase 0 normalized the entire CRM dataset; the mooring-pattern regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit.
|
||||
- **Public berths API:** `/api/public/berths` (list) and `/api/public/berths/[mooringNumber]` (single) are the public-facing data feed for the marketing website. Output shape mirrors the legacy NocoDB Berths shape verbatim (`"Mooring Number"`, `"Side Pontoon"`, etc.) — see `src/lib/services/public-berths.ts`. Cache headers: `s-maxage=300, stale-while-revalidate=60`. Status mapping: `"Sold"` (berth.status=sold) > `"Under Offer"` (status=under_offer OR has any active `interest_berths.is_specific_interest=true` link with `interests.outcome IS NULL`) > `"Available"`. The companion `/api/public/health` endpoint returns `{env, appUrl}` so the website refuses to start when its `CRM_PUBLIC_URL` points at a different deployment env.
|
||||
- **Berth recommender:** Pure SQL ranking (no AI). Lives in `src/lib/services/berth-recommender.service.ts`. Tier ladder A/B/C/D classifies each feasible berth based on its `interest_berths` aggregates. Heat scoring (recency / furthest stage / interest count / EOI count) only fires for tier B (lost/cancelled-only history); per-port admin tunes weights via `system_settings` keys (`heat_weight_*`, `recommender_max_oversize_pct`, `recommender_top_n_default`, `fallthrough_policy`, `fallthrough_cooldown_days`, `tier_ladder_hide_late_stage`). The recommender enforces multi-port isolation both at the entry point (rejects cross-port interest lookups) AND inside the SQL aggregates CTE (defense-in-depth `i.port_id` filter).
|
||||
- **EOI bundle / range formatter:** Multi-berth EOIs render the in-bundle berth set as a compact range string ("A1-A3, B5-B7") via `formatBerthRange()` in `src/lib/templates/berth-range.ts`. Used only inside the Documenso `Berth Range` form field — CRM UI always shows berths as individual chips. The `{{eoi.berthRange}}` token is in `VALID_MERGE_TOKENS`.
|
||||
- **Pluggable storage backend:** Code never imports MinIO/S3 directly. All file I/O goes through `getStorageBackend()` from `src/lib/storage/`. Configured via `system_settings.storage_backend` ('s3' | 'filesystem'). Switching backends is a settings change + `pnpm tsx scripts/migrate-storage.ts` run. **Filesystem backend is single-node only**: refuses to start when `MULTI_NODE_DEPLOYMENT=true`. Multi-node deployments must use the s3-compatible backend.
|
||||
- **Per-berth PDFs:** Versioned via `berth_pdf_versions`; `berths.current_pdf_version_id` always points to the latest active version. Storage key is UUID-based per upload (not version-numbered) so concurrent uploads can't collide on blob paths; `pg_advisory_xact_lock` per berth_id serializes the version-number allocation. 3-tier parser: AcroForm → OCR (Tesseract.js with positional heuristics) → optional AI (rep clicks "AI parse" only when OCR confidence is low). Magic-byte (`%PDF-`) check enforced on BOTH the in-server upload path AND the presigned-PUT path (the post-upload service streams the first 5 bytes via the storage backend). Mooring-number mismatch between PDF and target berth surfaces as a service-level `ConflictError` unless the apply call passes `confirmMooringMismatch: true`.
|
||||
- **Brochures:** Per-port; default brochure marked via `is_default` (enforced by partial unique index on `(port_id) WHERE is_default=true AND archived_at IS NULL`). Archived brochures retain version history. Same upload flow as berth PDFs (presign + magic-byte verification on the post-upload register endpoint).
|
||||
- **Send-from accounts (sales send-outs):** Configurable via `system_settings`; defaults to `sales@portnimara.com` for human-touch and `noreply@portnimara.com` for automation. SMTP/IMAP passwords are AES-256-GCM encrypted at rest; the API never returns decrypted secrets — only `*PassIsSet` boolean markers. Send-out audit goes to `document_sends` (separate from `audit_logs` because of volume + binary refs). Body markdown is XSS-safe via `renderEmailBody()` (escape-then-allowlist; tested against the standard XSS vector list). Rate limit: 50 sends/user/hour individual. Pre-send size threshold: files > `email_attach_threshold_mb` ship as a 24h signed-URL link rather than an attachment (avoids the duplicate-send race from async bounces). The download-link fallback HTML-escapes the filename to prevent injection from admin-supplied brochure names. Bounce monitoring requires IMAP credentials in addition to SMTP — without them, the size-rejection banner stays disabled.
|
||||
- **NocoDB berth import:** `pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara` re-imports from the legacy NocoDB Berths table. Idempotent: rows where `updated_at > last_imported_at` (the "human edited this since last import" guard) are skipped unless `--force`. Adds `--update-snapshot` to also rewrite `src/lib/db/seed-data/berths.json`. Uses `pg_advisory_xact_lock` so two simultaneous runs serialize. Pure helpers in `src/lib/services/berth-import.ts` are unit-tested.
|
||||
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
|
||||
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files.
|
||||
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed.
|
||||
|
||||
## Schema migrations during dev
|
||||
|
||||
When you run a `db:push` or apply a migration via `psql` against a running dev server, **restart the dev server afterwards**. Drizzle/postgres.js keeps connection-level prepared statements that can hold stale column lists; a stale pool causes `column X does not exist` errors on pages that touch the migrated table even though the column is present in the DB. Symptom: pages return 500 with `errorMissingColumn`/`42703` after a successful migration. Fix: kill `next dev` and restart it.
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build).
|
||||
|
||||
Optional dev/test-only env vars (not in `.env.example`):
|
||||
|
||||
- `EMAIL_REDIRECT_TO=<address>` — when set, every outbound email is rerouted to this address regardless of the requested recipient and the subject is prefixed with `[redirected from <original>]`. Dev safety net so seeded fake-client emails don't escape; **must be unset in production**.
|
||||
- `IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` / `IMAP_PASS` — read by `tests/e2e/realapi/portal-imap-activation.spec.ts` to fetch the activation email from a real mailbox during the IMAP round-trip test. The spec skips when any are missing.
|
||||
|
||||
## Testing
|
||||
|
||||
Five Playwright projects, defined in `playwright.config.ts`:
|
||||
|
||||
- `setup` — global setup (seeds users, port, berths, system settings).
|
||||
- `smoke` — fast click-through over every major flow. Run on every change (~10 min, 125 specs).
|
||||
- `exhaustive` — deeper UI coverage that takes longer.
|
||||
- `destructive` — archive/delete/cancel paths against throwaway entities.
|
||||
- `realapi` — opt-in suite that hits real external services (Documenso send-side + IMAP round-trip). Requires `DOCUMENSO_API_*`, `SMTP_*`, `IMAP_*` env. Cloudflared tunnel needs to be running so Documenso can call the local webhook receiver.
|
||||
- `visual` — pixel-diff baselines for stable list/landing pages. Snapshots committed under `tests/e2e/visual/snapshots.spec.ts-snapshots/`. Regenerate with `--update-snapshots` after intentional UI changes.
|
||||
|
||||
Vitest covers unit + integration with mocked external services (`tests/unit/`, `tests/integration/`).
|
||||
|
||||
## Docker
|
||||
|
||||
- `Dockerfile` - Production multi-stage build (deps -> build -> runner)
|
||||
@@ -89,3 +144,19 @@ Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full s
|
||||
## Architecture docs
|
||||
|
||||
Numbered spec files in repo root (`01-CONSOLIDATED-SYSTEM-SPEC.md` through `15-DESIGN-TOKENS.md`) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence.
|
||||
|
||||
Domain-specific references:
|
||||
|
||||
- `docs/eoi-documenso-field-mapping.md` — canonical mapping from `EoiContext`
|
||||
paths to the Documenso template's `formValues` keys, with the matching
|
||||
AcroForm field names used by the in-app pathway. **Note:** the multi-
|
||||
berth EOI bundle adds a new `Berth Range` form field populated by
|
||||
`formatBerthRange()` from `src/lib/templates/berth-range.ts` — the live
|
||||
Documenso template needs the field added before multi-berth EOIs render
|
||||
with the compact range string instead of just the primary mooring.
|
||||
- `assets/README.md` — what the in-app EOI source PDF must contain and how
|
||||
to override its path in dev/test.
|
||||
- `docs/berth-recommender-and-pdf-plan.md` — the comprehensive plan for the
|
||||
Phase 0–8 berth-recommender + PDF + send-outs work bundle. Single source
|
||||
of truth for the multi-berth interest model, recommender tier ladder,
|
||||
pluggable storage, per-berth PDF parser, and sales send-out flows.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Stage 2: Build the application
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
FROM node:20-alpine
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
# Stage 1: Install dependencies (dev deps needed for esbuild)
|
||||
FROM node:20-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Stage 2: Build the worker bundle
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
RUN pnpm build:worker
|
||||
|
||||
# Stage 3: Production runner (prod deps only)
|
||||
# Stage 3: Production runner (prod deps only).
|
||||
#
|
||||
# Critical ordering: create the worker user FIRST and chown the workdir
|
||||
# BEFORE pnpm install, so node_modules + lazy-cache directories
|
||||
# (tesseract.js, sharp) are owned by the worker user. Without this, the
|
||||
# previous layout had pnpm install run as root → node_modules root-owned
|
||||
# → tesseract.js / sharp wrote to node_modules/.cache and EACCES'd at
|
||||
# first PDF parse in prod (auditor-K §39).
|
||||
FROM node:20-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
|
||||
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js
|
||||
WORKDIR /app
|
||||
RUN chown -R worker:nodejs /app
|
||||
USER worker
|
||||
COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js
|
||||
# Healthcheck — pings Redis from inside the worker container. Without
|
||||
# this, a worker whose Redis connection has silently dropped (BullMQ
|
||||
# rejects new jobs but the Node process is alive) is invisible to
|
||||
# compose / swarm and jobs queue indefinitely (auditor-K §40).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "const Redis=require('ioredis');const r=new Redis(process.env.REDIS_URL,{maxRetriesPerRequest:1,connectTimeout:3000,lazyConnect:true});r.connect().then(()=>r.ping()).then(()=>{r.disconnect();process.exit(0)}).catch(()=>process.exit(1))" || exit 1
|
||||
CMD ["node", "worker.js"]
|
||||
|
||||
48
assets/README.md
Normal file
48
assets/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# `assets/`
|
||||
|
||||
Server-side runtime assets bundled by Next.js (via `outputFileTracingIncludes`
|
||||
in `next.config.ts`). These files are read with `fs.readFile` from
|
||||
`process.cwd()` at runtime, so they are NOT served as public URLs — use
|
||||
`public/` for that.
|
||||
|
||||
## `eoi-template.pdf`
|
||||
|
||||
The source PDF used by the in-app EOI generation pathway
|
||||
(`src/lib/pdf/fill-eoi-form.ts`). It must be the **same** PDF that the
|
||||
Documenso EOI template uploads, so both pathways produce equivalent
|
||||
documents.
|
||||
|
||||
The PDF must contain AcroForm fields with these exact names (mirroring the
|
||||
Documenso template's `formValues` keys — see
|
||||
`docs/eoi-documenso-field-mapping.md`):
|
||||
|
||||
| Field name | Type | Filled with |
|
||||
| -------------- | -------- | ----------------------------------------------------- |
|
||||
| `Name` | Text | `EoiContext.client.fullName` |
|
||||
| `Email` | Text | `EoiContext.client.primaryEmail` |
|
||||
| `Address` | Text | `street, city, country` |
|
||||
| `Yacht Name` | Text | `EoiContext.yacht.name` |
|
||||
| `Length` | Text | `EoiContext.yacht.lengthFt` |
|
||||
| `Width` | Text | `EoiContext.yacht.widthFt` |
|
||||
| `Draft` | Text | `EoiContext.yacht.draftFt` |
|
||||
| `Berth Number` | Text | `EoiContext.berth.mooringNumber` |
|
||||
| `Lease_10` | Checkbox | always `false` (legacy default — Purchase, not Lease) |
|
||||
| `Purchase` | Checkbox | always `true` |
|
||||
|
||||
Form fields stay interactive after generation (not flattened), so the
|
||||
recipient can still tweak values before signing if the in-app pathway is
|
||||
followed by a Documenso send.
|
||||
|
||||
### Override path
|
||||
|
||||
In dev/test, set `EOI_TEMPLATE_PDF_PATH=/abs/path/to/your/template.pdf` to
|
||||
point at a different file (e.g. a fixture).
|
||||
|
||||
### How to extract this PDF
|
||||
|
||||
The legacy flow uploads this PDF to Documenso template ID 8. To get the
|
||||
exact bytes:
|
||||
|
||||
1. In Documenso, open the EOI template.
|
||||
2. Download the source PDF.
|
||||
3. Drop it here as `eoi-template.pdf`.
|
||||
BIN
assets/eoi-template.pdf
Normal file
BIN
assets/eoi-template.pdf
Normal file
Binary file not shown.
Submodule client-portal deleted from 84f89f9409
@@ -46,6 +46,10 @@ services:
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
# Give the SIGTERM handler in src/server.ts time to drain in-flight
|
||||
# HTTP requests, close Socket.io, and disconnect Redis before Docker
|
||||
# SIGKILLs the process. The internal hard timeout is 25s.
|
||||
stop_grace_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
@@ -58,6 +62,9 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# Match the app: BullMQ jobs need time to finish or be released back
|
||||
# to the queue when worker.ts handles SIGTERM.
|
||||
stop_grace_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
|
||||
138
docs/BACKLOG.md
Normal file
138
docs/BACKLOG.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Master backlog index
|
||||
|
||||
**Single source of truth for everything outstanding.** Start here when
|
||||
asking "what's left to build/fix?". Items are grouped by source doc;
|
||||
each entry links back to the original spec for full context.
|
||||
|
||||
Last updated: 2026-05-07 (after the audit-final-deferred sweep — partial
|
||||
archived indexes, document_sends interestId port-verify, custom-fields
|
||||
per-entity permission gate, recommender bool parsing, expense PDF cursor
|
||||
math, berth PDF silent-drop logging, YachtForm preset-owner + interest
|
||||
form member-company yacht filter + add-new shortcut, invoice detail
|
||||
typed). Many older items in §C and §F were already resolved by earlier
|
||||
fix-audit commit waves; the audit doc was stale.
|
||||
|
||||
---
|
||||
|
||||
## A. Documenso build (deferred for later)
|
||||
|
||||
**Source:** [`docs/documenso-build-plan.md`](./documenso-build-plan.md) — full phase plan with locked decisions (Q1–Q10).
|
||||
**Tracker delta:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) — what landed in Phase 1.
|
||||
|
||||
Phase 1 (EOI generate flow polish + APPROVER-as-CC + per-port settings + signing-URL fix) is **DONE** and committed.
|
||||
|
||||
Remaining phases — explicitly back-burnered by the user on 2026-05-07:
|
||||
|
||||
| Phase | Scope | Estimate | Notes |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Phase 2** | Webhook handler enhancement: cascading "your turn" emails, on-completion PDF distribution, token-based recipient matching, idempotency lock | ~3–4h | Schema columns already in place from Phase 1 (`document_signers.invited_at / opened_at / signing_token`, `documents.completion_cc_emails`). |
|
||||
| **Phase 3** | Custom doc upload-to-Documenso: `custom-document-upload.service.ts` + `POST /api/v1/interests/[id]/upload-for-signing` | ~6–8h | Depends on Phase 2 webhook UX in anger before locking the upload UX. |
|
||||
| **Phase 4** | Field placement UI: react-pdf + dnd-kit overlay + auto-detect anchor scanner via pdfjs `getTextContent` | ~10–14h | Largest piece. Plan locked in build-plan Phase 4 — regexes, anchors, type-to-bbox sizing all spelled out. Best done in a focused session with the user watching. |
|
||||
| **Phase 5** | Embedded signing URL emission verification: confirm website's `/sign/<type>/<token>` page handles every signer-role × documentType combination; update `signerMessages` map; apply nginx CORS block from integration audit | ~1–2h | |
|
||||
| **Phase 6** | Polish: auto-send delay, audit-log additions, per-document customisation, document expiration, reminder rate-limit display, failed-webhook recovery UI | each ~2–3h | All deferred until Phases 1–4 ship. |
|
||||
| **Phase 7** | Project Director RBAC — UI binding for the developer-user fields. Add "Linked to CRM user" dropdown in `/admin/documenso/page.tsx`; auto-fill name/email; webhook handler matches against linked user's email for in-CRM signing-status updates. Schema + setting keys (`documenso_developer_user_id`, `documenso_approver_user_id`, `_label`) already in place from Phase 1. | ~1h | Smallest piece; could be picked off independently of Phase 2. |
|
||||
| **Risk #4** | v2 webhook payload audit against a live v2 instance (`payload.documentId` vs `payload.id`, `recipient.token` vs `recipient.recipientId`) before relying on Phase 2 cascading emails | ~1h | Needs a live v2 instance. |
|
||||
|
||||
---
|
||||
|
||||
## B. Custom-fields hardening (~ongoing, deferred)
|
||||
|
||||
**Source:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) §7.
|
||||
|
||||
Custom Settings page already shows the amber warning banner. Remediation work:
|
||||
|
||||
- **Search index** — extend the GIN tsvector to include `customFieldValues` content
|
||||
- **Audit diff** — extend `diffEntity` to walk the `customFieldValues` blob
|
||||
- **Merge tokens** — add `{{custom.<fieldName>}}` handling at template-render time, plus surface them in the merge-tokens UI
|
||||
|
||||
---
|
||||
|
||||
## C. Audit-final deferred items
|
||||
|
||||
**Source:** [`docs/audit-final-deferred.md`](./audit-final-deferred.md) — pre-merge + post-merge audit findings explicitly carried over.
|
||||
|
||||
The 2026-05-07 backlog sweep landed every small/concrete item. Remaining
|
||||
entries are deferred because they need design decisions, live external
|
||||
instances, or cross-cutting refactors:
|
||||
|
||||
### Deferred — needs design or larger refactor
|
||||
|
||||
- **Storage proxy token does not bind to port_id** — `src/lib/storage/filesystem.ts:73-84`. Adding a `p` (portId) claim is mechanical; the meaningful security gain requires the proxy verifier to look up the file's owning row + assert `owner.portId === payload.p`. That requires either a routing prefix in the key (currently `${portSlug}/...` already, so a prefix check is plausible) or a per-table lookup across all owners. Decide which approach before implementing — current state ships with `validateStorageKey` + per-issuer port scoping, so this is defense-in-depth rather than an open hole.
|
||||
- **Documenso webhook does not enforce port_id on document lookups** — `src/app/api/webhooks/documenso/route.ts:96-148`. Adding port scope requires either including the originating Documenso instance/team id in the lookup (Documenso doesn't surface that on the webhook payload today) OR proving `documents(documenso_id)` is globally unique with a DB constraint and a backfill check. Pick the strategy with the audit doc open.
|
||||
- **Webhook dedup vs per-recipient signed events** — `src/app/api/webhooks/documenso/route.ts:103-110`. Replacing the body-hash dedup with a `(documensoDocumentId, recipientEmail, eventType)` composite unique requires schema column for recipient_email on `documentEvents`. Right place to do this is alongside Documenso Phase 2 (webhook handler enhancement) since they touch the same code.
|
||||
- **v2 voidDocument endpoint shape verification** — `src/lib/services/documenso-client.ts:450-466`. Needs a live Documenso 2.x instance to confirm `POST /api/v2/envelope/delete` body shape. Bundle with Documenso Phase 5.
|
||||
- **Public POST routes bypass service layer** — `src/app/api/public/{interests,website-inquiries,residential-inquiries}/route.ts`. Multi-route refactor extracting a shared `publicInterestService.create(...)`. Worth doing but big enough to deserve its own session.
|
||||
- **Inconsistent response shapes** — most endpoints return `{ data: ... }`, but `notifications/[notificationId]` returns `{ success: true }`, `website-inquiries` returns `{ id, deduped }`. Codebase-wide migration; document a convention in CLAUDE.md first.
|
||||
- **`systemSettings` PK / unique-index drift** — `src/lib/db/schema/system.ts:119-133`. Schema declares `uniqueIndex` on `(key, port_id)`, migration uses `key` as PK. `port_id` is nullable so `(key, port_id)` cannot serve as a PK with default NULLs-not-equal semantics. Reconcile by either making `portId` non-null with a sentinel ("**global**") and declaring composite PK, OR by dropping the schema-level unique index and using partial unique indexes for global vs per-port. Either path is a data migration.
|
||||
|
||||
### Done in 2026-05-07 sweep (commits in this session)
|
||||
|
||||
- ✅ Partial archived indexes (migration 0046) — `clients`, `interests`, `yachts`, `residential_clients`, `residential_interests`
|
||||
- ✅ `document_sends` interestId port-verification helper
|
||||
- ✅ Custom-fields per-entity permission gate (replaces hardcoded `clients.view/edit`)
|
||||
- ✅ EOI Berth Range warn log (was already in place)
|
||||
- ✅ v1 `placeFields` retry with backoff (was already in place)
|
||||
- ✅ S3 bucket-exists check at boot (was already in place)
|
||||
- ✅ Filesystem dev HMAC fallback warn (was already in place)
|
||||
- ✅ Storage cache fingerprint documentation comment
|
||||
- ✅ AI worker cost ledger writes (was already in place)
|
||||
- ✅ Logger redact paths covering headers, encrypted blobs, two-level nesting (was already in place)
|
||||
- ✅ `loadRecommenderSettings` accepts string `"true"`/`"false"` JSONB booleans
|
||||
- ✅ `renderReceiptHeader` cursor math anchored to captured `baseY`
|
||||
- ✅ Berth PDF apply: silent-drop logging for non-finite numeric coercions
|
||||
- ✅ Saved-views: confirmed by-design owner-only (existing inline doc)
|
||||
- ✅ Alerts ack/dismiss: confirmed by-design port-wide (service correctly bounded)
|
||||
- ✅ Storage admin migration toasts (already in place)
|
||||
- ✅ Invoice send/payment toasts + permission gates (already in place)
|
||||
- ✅ Admin user list edit + remove gates (added remove gate)
|
||||
- ✅ Email threads list skeleton + empty state (already in place)
|
||||
- ✅ Scan page error state for OCR failures (already in place)
|
||||
- ✅ Invoice detail typed (replaced `any` with `InvoiceDetailData` interface)
|
||||
- ✅ All FK indexes called out in audit doc (already in place — audit was stale)
|
||||
- ✅ `documentSends.sentByUserId` FK (already had `.references(...)`)
|
||||
|
||||
### Still open — small enough to bundle next time
|
||||
|
||||
- **`berths.current_pdf_version_id` lacks Drizzle FK** — `src/lib/db/schema/berths.ts:83`. The in-line comment fully documents why (circular FK between `berths` ↔ `berth_pdf_versions` makes column-level `.references()` infeasible). FK is enforced via migration 0030. Treat as documented limitation; revisit if Drizzle adds deferred-FK support.
|
||||
- **`req.json()` without `parseBody` helper** — admin custom-fields routes use `await req.json(); schema.parse(body)` directly. Migrate for uniform 400 error shapes when the surface area calms down.
|
||||
|
||||
---
|
||||
|
||||
## D. Inline TODOs in code (2 remaining)
|
||||
|
||||
| File:line | Note | Status |
|
||||
| ------------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| ~~`client-yachts-tab.tsx:93`~~ | YachtForm preset owner prop | ✅ landed 2026-05-07 (`initialOwner` prop) |
|
||||
| ~~`interest-form.tsx:329`~~ | Include company-owned yachts where client is a member | ✅ landed 2026-05-07 (`yachtOwnerFilter` array filter) |
|
||||
| ~~`interest-form.tsx:330`~~ | "Add new yacht" inline shortcut | ✅ landed 2026-05-07 (Plus button + YachtForm sheet) |
|
||||
| [`src/lib/queue/scheduler.ts:44`](../src/lib/queue/scheduler.ts#L44) | Per-user reminder schedule configurable from `user_settings` | Open — needs `user_settings` UI surface |
|
||||
| [`src/lib/queue/workers/import.ts:13`](../src/lib/queue/workers/import.ts#L13) | Import job handlers — worker is a stub | Open — entire feature surface |
|
||||
|
||||
---
|
||||
|
||||
## E. Hidden / stubbed UI tabs
|
||||
|
||||
- **Company Documents tab** — `src/components/companies/company-tabs.tsx:229`. Hidden until `/api/v1/files` accepts a `companyId` filter (schema supports it, validator doesn't).
|
||||
- **Berth Waiting List + Maintenance Log tabs** — `src/components/berths/berth-tabs.tsx:346`. Removed entirely; revisit if/when product asks.
|
||||
- **Interest Contract / Reservation tabs** — `src/components/interests/interest-{contract,reservation}-tab.tsx`. Render a "coming soon" friendly card; the real flow is gated on Documenso Phases 2–6.
|
||||
|
||||
---
|
||||
|
||||
## F. Historical audit docs (mostly resolved)
|
||||
|
||||
These dossiers drove the audit-fix commit waves on 2026-05-05/06. Items
|
||||
not surfaced in §C above were resolved via the `fix(audit): …` commits
|
||||
(`588f8bc`, `94331bd`, `a8c6c07`, `5fc68a5`, `da7ede7`, `c5b41ca`,
|
||||
`b4fb3b2`, `0f648a9`, `c312cd3`, `0a5f085`, `1a87f28`, `f3143d7`,
|
||||
`05babe5`). Keep for historical context:
|
||||
|
||||
- [`audit-comprehensive-2026-05-05.md`](./audit-comprehensive-2026-05-05.md) — pre-merge audit (1 CRIT + 18 HIGH at start)
|
||||
- [`audit-comprehensive-2026-05-06.md`](./audit-comprehensive-2026-05-06.md) — post-merge audit (1 CRIT + 7 HIGH + 10 MED + 7 LOW)
|
||||
- [`audit-frontend-2026-05-06.md`](./audit-frontend-2026-05-06.md) — frontend-only sweep
|
||||
- [`audit-missing-features-2026-05-06.md`](./audit-missing-features-2026-05-06.md) — admin-promised-but-unwired features (V1–V12)
|
||||
- [`audit-permissions-2026-05-06.md`](./audit-permissions-2026-05-06.md) — permission-gate gaps
|
||||
- [`audit-reliability-2026-05-06.md`](./audit-reliability-2026-05-06.md) — transactional integrity / TOCTOU
|
||||
- [`berth-feature-handoff-prompt.md`](./berth-feature-handoff-prompt.md) — berth recommender handoff (shipped, kept as reference)
|
||||
- [`berth-recommender-and-pdf-plan.md`](./berth-recommender-and-pdf-plan.md) — berth recommender + per-berth PDF plan (Phases 0–8 shipped)
|
||||
- [`documenso-integration-audit.md`](./documenso-integration-audit.md) — Documenso integration spec (drives §A)
|
||||
- [`website-refactor.md`](./website-refactor.md) — public website cutover plan
|
||||
196
docs/admin-ux-backlog.md
Normal file
196
docs/admin-ux-backlog.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Admin / settings UX backlog — STATUS
|
||||
|
||||
Living tracker for the admin/UX backlog. Items are marked DONE or
|
||||
REMAINING based on what landed in the autonomous-push session.
|
||||
|
||||
---
|
||||
|
||||
## DONE in the autonomous push
|
||||
|
||||
### Foundations
|
||||
|
||||
- **Currency API verified end-to-end**. `scripts/test-currency-api.ts`
|
||||
fetches live Frankfurter rates → upserts → reads back → converts.
|
||||
Inverse-rate drift confirmed at ≤0.001.
|
||||
- **Storage abstraction audit complete**. Every byte path
|
||||
(signed EOIs, contracts, brochures, berth PDFs, files, avatars,
|
||||
branding logos) goes through `getStorageBackend()`. `/api/ready`
|
||||
and the system-monitoring health probe now check the active
|
||||
backend (S3 or filesystem) instead of always probing MinIO.
|
||||
|
||||
### User settings
|
||||
|
||||
- Country + Timezone selectors with cross-defaulting + auto-detect
|
||||
banner ("Looks like you're in Europe/Paris — Update?")
|
||||
- Email change with verification flow (`user_email_changes` table,
|
||||
`/api/v1/me/email/confirm/<token>`, `/api/v1/me/email/cancel/<token>`)
|
||||
- Password reset triggered via better-auth `requestPasswordReset`
|
||||
- Profile photo upload + crop (square 256×256) via shared
|
||||
`<ImageCropperDialog>` + `/api/v1/me/avatar`
|
||||
|
||||
### Branding
|
||||
|
||||
- Logo upload + crop modal in admin/branding (uses the same shared
|
||||
cropper, persists via `/api/v1/admin/settings/image` → storage backend)
|
||||
- Email header/footer HTML defaults injectable via "Insert default" button
|
||||
- Brand colour picker, app-name field, logo URL all in one card
|
||||
|
||||
### Storage admin
|
||||
|
||||
- New layout: S3 config form FIRST, swap action SECOND
|
||||
- Test connection button before any switch
|
||||
- Two-button switch: "Switch + migrate" vs "Switch only" with warning modal
|
||||
- `runMigration()` honours `skipMigration` flag
|
||||
|
||||
### Backup management
|
||||
|
||||
- Real `/admin/backup` page driven by new `backup_jobs` table
|
||||
- `runBackup()` service spawns `pg_dump --format=custom`, streams to
|
||||
active storage backend, records size + path
|
||||
- Download button presigns the .dump for offline restore
|
||||
- Super-admin gated
|
||||
|
||||
### AI admin panel
|
||||
|
||||
- Dedicated `/admin/ai` page consolidating master switch +
|
||||
monthly token cap + provider credentials
|
||||
- Per-feature settings (OCR, berth-PDF parser, recommender)
|
||||
linked from the same page
|
||||
|
||||
### Onboarding
|
||||
|
||||
- Real `/admin/onboarding` page with auto-checked steps
|
||||
- Reads each setting key + lists endpoint (roles / users / tags) to
|
||||
decide completion
|
||||
- Manual checkboxes for steps without an auto-detect signal
|
||||
- Progress bar + "Mark done"/"Mark incomplete" buttons
|
||||
- State persisted in `system_settings.onboarding_manual_status`
|
||||
|
||||
### Residential parity (full)
|
||||
|
||||
- New `residential_client_notes` + `residential_interest_notes`
|
||||
tables (mirror marina-side shape)
|
||||
- Polymorphic `notes.service.ts` extended with two new entity types
|
||||
through verifyParent + listForEntity + create + update + delete
|
||||
- New `<NotesList>` accepts `residential_clients` /
|
||||
`residential_interests` entity types
|
||||
- Activity endpoints: `/api/v1/residential/clients/[id]/activity` +
|
||||
`/api/v1/residential/interests/[id]/activity`
|
||||
- Notes endpoints: 4 new routes covering GET/POST/PATCH/DELETE
|
||||
- `residential-client-tabs.tsx` + `residential-interest-tabs.tsx`
|
||||
built using the marina-side `DetailLayout` pattern (Overview +
|
||||
Notes + Activity tabs, Interests tab on the client)
|
||||
- Detail header components mirror the marina-side strip
|
||||
- `useBreadcrumbHint` wired into both detail components
|
||||
|
||||
### Residential pipeline stages — configurable
|
||||
|
||||
- New `residential-stages.service.ts` with list/save + orphan-check
|
||||
- `/api/v1/residential/stages` GET/PUT
|
||||
- `/admin/residential-stages` admin UI with reassign-on-remove
|
||||
modal (select new stage per affected interest before save)
|
||||
- Validators relaxed from `z.enum(...)` to `z.string()` so any
|
||||
admin-defined stage id round-trips
|
||||
|
||||
### Documenso Phase 1 (EOI generate flow polish)
|
||||
|
||||
- Schema migrations applied:
|
||||
`document_signers.invited_at / opened_at / last_reminder_sent_at / signing_token`,
|
||||
`documents.completion_cc_emails / auto_reminder_interval_days`
|
||||
- `transformSigningUrl()` now maps SignerRole → URL segment correctly
|
||||
(approver→cc, witness→witness) so emails don't land on `/sign/error`
|
||||
- New `POST /api/v1/documents/[id]/send-invitation` endpoint with
|
||||
next-pending-signer auto-pick
|
||||
- Per-port settings added: `documenso_developer_label`,
|
||||
`documenso_approver_label`, `documenso_developer_user_id`,
|
||||
`documenso_approver_user_id` (Phase 7 RBAC binding fields)
|
||||
|
||||
### Misc UI/UX
|
||||
|
||||
- Sidebar collapse removed (always expanded)
|
||||
- Audit log filter inputs sized + dates widened
|
||||
- Custom Settings section got a long-form description
|
||||
- Reminder digest timezone uses `TimezoneCombobox`
|
||||
- Port form: currency dropdown + timezone combobox + brand color
|
||||
- Permissions count badge opens a modal with granted/denied
|
||||
- Role names display-normalized via `prettifyRoleName`
|
||||
- Sales email config: token list + tooltips on threshold + body fields
|
||||
- Custom Fields page: amber heads-up about non-integration with
|
||||
search / recommender / audit / merge tokens
|
||||
- Tag form: native `<input type="color">`
|
||||
- FilterBar Select crash fixed (no empty-string item values)
|
||||
|
||||
---
|
||||
|
||||
## REMAINING — large pieces that didn't fit this push
|
||||
|
||||
### 1. Documenso Phase 2 — Webhook handler enhancement (~3-4 hours)
|
||||
|
||||
Cascading "your turn" emails when each signer completes; on-completion
|
||||
PDF distribution; token-based recipient matching; idempotency lock.
|
||||
File to extend: `src/app/api/webhooks/documenso/route.ts`. The
|
||||
schema columns are already in place (Phase 1).
|
||||
|
||||
### 2. Documenso Phase 3 — Custom doc upload-to-Documenso (~6-8 hours)
|
||||
|
||||
Backend service `custom-document-upload.service.ts` + endpoint
|
||||
`POST /api/v1/interests/[id]/upload-for-signing`. Accepts a PDF +
|
||||
recipient list + field-placement JSON, calls `createDocument` →
|
||||
`placeFields` → `sendDocument` on the per-port Documenso client.
|
||||
Persists a row in `documents` table.
|
||||
|
||||
### 3. Documenso Phase 4 — Field placement UI (~10-14 hours)
|
||||
|
||||
The biggest piece. Needs:
|
||||
|
||||
- 4a: Recipient configurator dialog (~2-3h)
|
||||
- 4b: PDF rendering with `react-pdf` (~3-4h)
|
||||
- 4c: Auto-detect anchor scanner via `pdfjs-dist.getTextContent` (~4-6h)
|
||||
- 4d: Drag-drop overlay using `dnd-kit` (~3-4h)
|
||||
- 4e: Send button → calls Phase 3 endpoint (~1h)
|
||||
|
||||
Plan locked in `docs/documenso-build-plan.md` Phase 4 — the
|
||||
field-detector regexes, the anchor patterns, and the type-to-bbox
|
||||
sizing table are all spelled out.
|
||||
|
||||
### 4. Documenso Phase 5 — Embedded signing URL emission verification (~1-2 hours)
|
||||
|
||||
Verify the website's `/sign/<type>/<token>` page handles every signer
|
||||
role + every documentType combination. Update website's
|
||||
`signerMessages` map keyed on `(documentType, role)`. Apply the
|
||||
nginx CORS block from `docs/documenso-integration-audit.md`.
|
||||
|
||||
### 5. Documenso Phase 6 — Polish items (deferred)
|
||||
|
||||
Auto-send delay, audit-log additions, per-document customisation,
|
||||
document expiration, reminder rate-limit display, failed-webhook
|
||||
recovery UI. Each ~2-3 hours; all deferred until Phases 1-4 ship.
|
||||
|
||||
### 6. Project Director — UI binding for the developer-user fields
|
||||
|
||||
Schema + setting keys are now in place
|
||||
(`documenso_developer_user_id`, `documenso_approver_user_id` +
|
||||
`documenso_developer_label` / `_approver_label`). The remaining
|
||||
work is: add a "Linked to CRM user" dropdown in
|
||||
`/admin/documenso/page.tsx` that lists port users; when bound,
|
||||
auto-fill name/email from the user profile and mark name/email
|
||||
fields read-only. Webhook handler can then match against the
|
||||
linked user's email for in-CRM signing-status updates.
|
||||
|
||||
### 7. Custom-fields hardening (~ongoing)
|
||||
|
||||
Remediation paths for the heads-up banner concerns:
|
||||
|
||||
- **Search index**: extend the GIN tsvector to include
|
||||
customFieldValues content
|
||||
- **Audit diff**: extend `diffEntity` to walk the
|
||||
customFieldValues blob
|
||||
- **Merge tokens**: add `{{custom.<fieldName>}}` handling at
|
||||
template-render time, plus surface them in the merge-tokens UI
|
||||
|
||||
### 8. Documenso v2 webhook payload audit (small)
|
||||
|
||||
Risk #4 from `docs/documenso-build-plan.md` — confirm v2 payload
|
||||
shape (`payload.documentId` vs `payload.id`, recipient.token vs
|
||||
`recipient.recipientId`) against a live v2 instance before relying
|
||||
on Phase 2 cascading emails.
|
||||
1126
docs/audit-comprehensive-2026-05-05.md
Normal file
1126
docs/audit-comprehensive-2026-05-05.md
Normal file
File diff suppressed because it is too large
Load Diff
753
docs/audit-comprehensive-2026-05-06.md
Normal file
753
docs/audit-comprehensive-2026-05-06.md
Normal file
@@ -0,0 +1,753 @@
|
||||
# Comprehensive Audit — 2026-05-06
|
||||
|
||||
Conducted directly after the smart-archive / hard-delete / bulk-wizard /
|
||||
audit-overhaul / synthetic-seed batches landed (commits `d07f1ed`
|
||||
through `9890d06`). Prior comprehensive audit:
|
||||
`docs/audit-comprehensive-2026-05-05.md`.
|
||||
|
||||
Findings are sorted by severity. Each has a concrete file:line, a
|
||||
scenario, and a fix recommendation.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C1. 5 of 10 BullMQ workers are never imported (production + dev)
|
||||
|
||||
**Files:** `src/worker.ts:13-17`, `src/server.ts:72-76`
|
||||
|
||||
`src/worker.ts` (production) and `src/server.ts` (dev fallback) both
|
||||
import only:
|
||||
|
||||
- `emailWorker`
|
||||
- `documentsWorker`
|
||||
- `notificationsWorker`
|
||||
- `importWorker`
|
||||
- `exportWorker`
|
||||
|
||||
**Missing:** `aiWorker`, `bulkWorker`, `maintenanceWorker`, `reportsWorker`, `webhooksWorker`.
|
||||
|
||||
Because BullMQ workers are constructed at the top of each worker
|
||||
module and only "start" when the module is imported, never importing
|
||||
them means:
|
||||
|
||||
- **Webhooks never deliver.** `webhooksWorker` is what processes the
|
||||
`webhooks` queue; the admin "Replay" button we just shipped enqueues
|
||||
jobs that pile up in `pending` forever.
|
||||
- **All maintenance crons silently no-op.** `maintenanceWorker` handles
|
||||
`database-backup`, `backup-cleanup`, `session-cleanup`,
|
||||
`currency-refresh`, `gdpr-export-cleanup`, `ai-usage-retention`,
|
||||
`error-events-retention`, `website-submissions-retention`,
|
||||
`alerts-evaluate`, `analytics-refresh`, `calendar-sync`,
|
||||
`temp-file-cleanup`, `form-expiry-check` — none run.
|
||||
- **Scheduled reports never generate.** `reportsWorker` handles
|
||||
`report-scheduler` (every minute).
|
||||
- **Bulk jobs never process** (the synchronous bulk endpoints work, but
|
||||
any deferred-bulk path is dead).
|
||||
- **AI usage features never run.**
|
||||
|
||||
**Impact:** Production CRM has been silently shedding webhook
|
||||
deliveries, never running retention/cleanup, never sending scheduled
|
||||
reports.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```ts
|
||||
// Append to src/worker.ts AND the inline section of src/server.ts:
|
||||
import { aiWorker } from '@/lib/queue/workers/ai';
|
||||
import { bulkWorker } from '@/lib/queue/workers/bulk';
|
||||
import { maintenanceWorker } from '@/lib/queue/workers/maintenance';
|
||||
import { reportsWorker } from '@/lib/queue/workers/reports';
|
||||
import { webhooksWorker } from '@/lib/queue/workers/webhooks';
|
||||
|
||||
const workers = [
|
||||
emailWorker,
|
||||
documentsWorker,
|
||||
notificationsWorker,
|
||||
importWorker,
|
||||
exportWorker,
|
||||
aiWorker,
|
||||
bulkWorker,
|
||||
maintenanceWorker,
|
||||
reportsWorker,
|
||||
webhooksWorker,
|
||||
];
|
||||
```
|
||||
|
||||
After fix, run `pnpm dev` and watch `/admin/webhooks/{id}` deliveries
|
||||
go from `pending` → `success` to confirm.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1. Hard-delete request endpoints have zero rate limiting
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/api/v1/clients/[id]/hard-delete-request/route.ts:1-37`
|
||||
- `src/app/api/v1/clients/bulk-hard-delete-request/route.ts:1-32`
|
||||
|
||||
Each call writes a fresh code to Redis and emails it to the operator's
|
||||
address. No `withRateLimit(...)`. An attacker who has compromised an
|
||||
admin account (or even just the new `permanently_delete_clients`
|
||||
permission) can:
|
||||
|
||||
1. Email-bomb the admin's own inbox (every request → email).
|
||||
2. Probe whether arbitrary client IDs exist (200 + `sentToMaskedEmail`
|
||||
vs 404 `client not found` is a UID oracle).
|
||||
3. Burn SMTP quota.
|
||||
|
||||
**Fix:** add `withRateLimit('auth', ...)` or a new dedicated bucket
|
||||
(e.g. 5 per hour per user). Pattern is already in
|
||||
`src/app/api/v1/clients/[id]/gdpr-export/route.ts`.
|
||||
|
||||
### H2. Audit-page view fires on every paginated reload (log spam)
|
||||
|
||||
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
|
||||
|
||||
I added a "watch the watchers" `view` audit row for first-page audit
|
||||
fetches. That's the right idea, but the page also re-fires the request
|
||||
on every filter change (severity, source, action, date range, search).
|
||||
A diligent admin filtering through the inspector for an investigation
|
||||
will write dozens of `view` audit rows per minute — making it harder to
|
||||
find the actual events they're looking for.
|
||||
|
||||
**Fix:** dedupe in Redis with a 60-second per-user TTL key, only emit
|
||||
if the key didn't exist. Or only fire when no filters are active.
|
||||
|
||||
### H3. Hard-delete error messages distinguish "no code" vs "wrong code"
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:166-174`
|
||||
|
||||
```ts
|
||||
if (!stored) throw new ValidationError('Confirmation code expired or not requested');
|
||||
if (!safeEqualStr(stored, args.code.trim())) {
|
||||
throw new ValidationError('Confirmation code is incorrect');
|
||||
}
|
||||
```
|
||||
|
||||
The two messages let an attacker distinguish "you've never requested a
|
||||
code" (so spam the request endpoint to open the window) from "wrong
|
||||
code" (so brute-force more codes). 4-digit space is only 10,000 — with
|
||||
distinguishable feedback an attacker can confirm code validity in
|
||||
≤5,000 attempts on average.
|
||||
|
||||
**Fix:** collapse to a single `'Invalid or expired code'` message; the
|
||||
operator already has the email open and knows what they typed.
|
||||
|
||||
### H4. Synthetic seed leaves `super_admin` linked-port-roles empty
|
||||
|
||||
**File:** `src/lib/db/seed-bootstrap.ts:147-160`
|
||||
|
||||
The bootstrap creates the `userProfiles` row with
|
||||
`isSuperAdmin: true` for `super-admin-matt-portnimara`, but doesn't
|
||||
create `userPortRoles` rows. The actual real `user` rows (admin@,
|
||||
agent@, viewer@) are only created via the Playwright global-setup.
|
||||
Anyone running `pnpm db:seed:synthetic` then `pnpm dev` and trying to
|
||||
log in via the UI hits an unauthenticated state until they also run
|
||||
playwright setup or sign up via better-auth manually.
|
||||
|
||||
**Fix:** either document this in `CLAUDE.md` Quick Reference, or add a
|
||||
`pnpm db:seed:dev-users` companion script that signs up the three
|
||||
test users + links roles. Today's synthetic-seed flow felt clean
|
||||
because the playwright setup was still applied; in a fresh clone it
|
||||
will surprise.
|
||||
|
||||
### H5. Documenso bad-secret 200 response is correct, but enables enum oracle
|
||||
|
||||
**File:** `src/app/api/webhooks/documenso/route.ts:67-86`
|
||||
|
||||
The route returns `200 ok=false error=Invalid secret` for a wrong
|
||||
secret. That's webhook best-practice (don't leak signal to attackers),
|
||||
but combined with the new audit row that captures
|
||||
`metadata.providedLen`, an attacker can probe secret-length over time
|
||||
without being detected (just a "warning" row per attempt). On an admin
|
||||
inspector with 1000s of rows, a slow-rate probe is invisible.
|
||||
|
||||
**Fix:** add per-IP rate limit (5/min) to `/api/webhooks/documenso/`
|
||||
when secret check fails. Don't block real Documenso traffic — it
|
||||
shouldn't fail the secret check.
|
||||
|
||||
### H6. The audit-log inspector page itself isn't backed by a real "view" gate beyond `admin.view_audit_log`
|
||||
|
||||
**File:** `src/app/api/v1/admin/audit/route.ts:31`
|
||||
|
||||
Audit log has the most sensitive cross-cutting data in the system
|
||||
(every login attempt with attempted email, every secret-regenerate,
|
||||
every hard-delete). It's gated only by `admin.view_audit_log`. The
|
||||
seed grants this to `director` AND `super_admin`. Consider:
|
||||
|
||||
- making the page super-admin-only for production, OR
|
||||
- adding a secondary confirmation when viewing rows that contain
|
||||
attempted emails / IP ranges (PII).
|
||||
|
||||
**Fix:** change `withPermission('admin', 'view_audit_log', ...)` to
|
||||
add `if (!ctx.isSuperAdmin) check sensitive_audit_view`. Or accept
|
||||
the current model but document it in the role docs.
|
||||
|
||||
### H7. Three "coming soon" stubs in production UI
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/components/clients/client-tabs.tsx:276` — "File attachments coming soon."
|
||||
- `src/components/clients/client-reservations-tab.tsx:41` — "History is coming soon."
|
||||
- `src/components/berths/berth-tabs.tsx:327` — "{label} coming soon"
|
||||
|
||||
Visible to every user on every client / berth detail page. Either ship
|
||||
the feature or hide the tab.
|
||||
|
||||
**Fix:** for `client-tabs.tsx` line 276 (Files), the `files` table
|
||||
already exists and supports clientId — ship a list view.
|
||||
For `berth-tabs.tsx` line 327 — find the calling tab labels and
|
||||
either implement or remove from the tabs array.
|
||||
For `client-reservations-tab.tsx` line 41 — query past reservations
|
||||
when the user toggles a "show history" filter.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1. `attachWorkerAudit` recurring job names list duplicates scheduler.ts (drift risk)
|
||||
|
||||
**File:** `src/lib/queue/audit-helpers.ts:23-46`
|
||||
|
||||
The 20 recurring job names are hardcoded in the audit helper; the
|
||||
scheduler also has its own list. If someone adds a new cron without
|
||||
updating both, the cron_run audit row never fires for that job.
|
||||
|
||||
**Fix:** export the list from `scheduler.ts` and import it in
|
||||
`audit-helpers.ts`. Single source of truth.
|
||||
|
||||
### M2. `client-merge-log.surviving_client_id` deleted by hard-delete (history loss)
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:200-202`
|
||||
|
||||
Hard-delete drops every `client_merge_log` row whose surviving id
|
||||
matches. Those rows are the audit trail of WHO was merged INTO this
|
||||
client. Once deleted, you've lost evidence of the prior merge.
|
||||
|
||||
**Fix:** replace `delete` with a column nullification, or move the row
|
||||
to a `client_merge_log_archive` table. Audit trail per GDPR Article 5
|
||||
should outlive the data.
|
||||
|
||||
### M3. Bulk hard-delete loops one-shot codes through Redis (5x writes)
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:382-396`
|
||||
|
||||
For a 100-client bulk delete, the function writes 100 single-client
|
||||
codes to Redis just to satisfy `hardDeleteClient`'s expectation. Each
|
||||
write is a round-trip; on a Redis hiccup mid-loop, you can end up
|
||||
with a half-deleted batch.
|
||||
|
||||
**Fix:** refactor `hardDeleteClient` so the inner deletion can be called
|
||||
without the per-client code check (extract `_doHardDelete()` private
|
||||
helper used by both single and bulk paths). Keeps Redis clean.
|
||||
|
||||
### M4. Smart-restore wizard has dead reversal applier for `berth_released`
|
||||
|
||||
**File:** `src/lib/services/client-restore.service.ts:360-372`
|
||||
|
||||
The `applyReversal` switch case for `'berth_released'` does nothing —
|
||||
it just leaves the berth available. The wizard surfaces this as
|
||||
"auto-reversible" if the berth is still free, but the actual restore
|
||||
doesn't re-attach the berth to any interest. Operator clicks Restore
|
||||
expecting their berth back; nothing changes on the berth.
|
||||
|
||||
**Fix:** either (a) at archive time, persist the original interestId
|
||||
in the decision metadata so we can re-link, or (b) update the wizard
|
||||
copy to make clear the berth is "available for re-attach" rather than
|
||||
"will be re-attached."
|
||||
|
||||
### M5. Several services use `void createAuditLog(...)` without `.catch()`
|
||||
|
||||
**Files:** widespread; e.g. `src/lib/services/client-hard-delete.service.ts:127-136, 230-240`,
|
||||
`src/lib/services/portal-auth.service.ts:269-276`
|
||||
|
||||
`createAuditLog` is documented as never-throwing (catches internally),
|
||||
but defense-in-depth: a `void` Promise that throws produces an
|
||||
unhandled rejection event. Most paths are fine because the helper
|
||||
catches; if anyone refactors `createAuditLog` and removes the catch,
|
||||
this becomes a process-killer.
|
||||
|
||||
**Fix:** convention rule: every `void someAsync()` must have a `.catch()`.
|
||||
Codify with a custom ESLint rule, or wrap at call sites:
|
||||
`void createAuditLog({...}).catch(() => undefined);`
|
||||
|
||||
### M6. Hard-delete audit metadata leaks client `fullName`
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:241-247`
|
||||
|
||||
After the hard-delete the audit row carries
|
||||
`metadata: { fullName: client.fullName }`. The client record itself is
|
||||
gone but their name lives on in the audit log. For a GDPR data subject
|
||||
who exercised their right-to-erasure, this is technically a retention
|
||||
of personal data in audit history. Not necessarily wrong (audit logs
|
||||
have a legitimate-interest basis), but should be conscious.
|
||||
|
||||
**Fix:** decide policy: either (a) keep as-is and document, (b) replace
|
||||
with a hash of the name, or (c) substitute a tombstone identifier.
|
||||
|
||||
### M7. Webhook delivery DLQ admin-replay can re-trigger downstream side-effects
|
||||
|
||||
**File:** `src/lib/services/webhooks.service.ts:282-326`
|
||||
|
||||
Replaying a successful webhook (operator presses Replay on a delivery
|
||||
that already had `status: 'success'`) re-fires the same payload to the
|
||||
recipient. If the recipient's idempotency check is weak, you've just
|
||||
caused a duplicate. The replay payload includes `retried_from` /
|
||||
`retried_at` markers, which is good — but most recipients won't honor
|
||||
them.
|
||||
|
||||
**Fix:** disable the Replay button when `status === 'success'`. The UI
|
||||
already gates on `'failed' || 'dead_letter'` — verify it stays that
|
||||
way (`webhook-delivery-log.tsx:118-131` looks correct; double-check
|
||||
no regressions).
|
||||
|
||||
### M8. `audit_logs` table has no DELETE permission gate
|
||||
|
||||
**Files:** schema and routes
|
||||
|
||||
There's no admin endpoint to delete audit rows (good). But there's no
|
||||
DB-level guard either. A super_admin who runs `db:reset` wipes audit
|
||||
history. Audit retention should be enforced at the schema level so
|
||||
even a misconfigured operator can't blow away the trail.
|
||||
|
||||
**Fix:** create a `audit_logs_no_delete_role` postgres role that lacks
|
||||
DELETE on the table; document that the app's DB user should not have
|
||||
DELETE on `audit_logs` in production deployments.
|
||||
|
||||
### M9. Documenso void worker uses dynamic import every time
|
||||
|
||||
**File:** `src/lib/queue/workers/documents.ts:25`
|
||||
|
||||
```ts
|
||||
const { voidDocument } = await import('@/lib/services/documenso-client');
|
||||
```
|
||||
|
||||
Dynamic import inside a hot per-job path is fine the first time but
|
||||
slows every subsequent call slightly. Move to top-of-file import
|
||||
unless there's a deliberate reason (circular dep?).
|
||||
|
||||
**Fix:** test moving to top-level import; if it works (no circular
|
||||
deps), keep it there.
|
||||
|
||||
### M10. Bulk archive wizard "blocked" reason copy truncates at first line
|
||||
|
||||
**File:** `src/components/clients/bulk-archive-wizard.tsx:153-163`
|
||||
|
||||
The wizard shows `b.blockers[0]` for blocked clients. If the dossier
|
||||
has multiple blockers, only the first is shown. Operators may fix the
|
||||
first one, retry, and discover a second.
|
||||
|
||||
**Fix:** show all blockers (joined with `·`) or a "+N more" badge
|
||||
with click-to-expand.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### L1. `next-in-line-notify.service.ts` could double-fire on archive retry
|
||||
|
||||
**File:** `src/app/api/v1/clients/[id]/archive/route.ts:114-135`
|
||||
|
||||
If the smart-archive request succeeds at the DB transaction level but
|
||||
the response upload-side fails (network blip, browser closes), the
|
||||
operator may retry. Each retry re-fires the next-in-line notification
|
||||
to all sales recipients. The `dedupeKey: berth-released:{berthId}`
|
||||
inside the notification helper deduplicates within a cooldown window —
|
||||
so this is mitigated, but worth verifying the cooldown is set and
|
||||
not 0.
|
||||
|
||||
### L2. `interests.berth_id` reference in `seed-data.ts` (legacy seed)
|
||||
|
||||
**File:** `src/lib/db/seed-data.ts:973`
|
||||
|
||||
The realistic seed inserts `berthId: ...` on the interests table. Per
|
||||
`CLAUDE.md`, that column was dropped in migration 0029 and replaced
|
||||
with `interest_berths` junction. The synthetic seed uses the junction
|
||||
correctly. The realistic seed will FAIL at insert time if anyone
|
||||
tries to run it on a freshly-migrated DB.
|
||||
|
||||
**Fix:** rewrite `seed-data.ts:969-982` to insert into `interests`
|
||||
without `berthId`, then insert the junction rows separately (mirror
|
||||
the synthetic seed's pattern).
|
||||
|
||||
### L3. Audit log entry for failed login uses `entityId = attemptedEmail` (unbounded)
|
||||
|
||||
**File:** `src/app/api/auth/[...all]/route.ts:53-68`
|
||||
|
||||
If the entityId is very long (a 500-char "email"), it goes into the
|
||||
DB column. The column is `text` (unbounded) so no DB error, but FTS
|
||||
search-text may bloat.
|
||||
|
||||
**Fix:** truncate attempted email to 256 chars before using as
|
||||
entityId.
|
||||
|
||||
### L4. The "watch the watchers" audit fires for filtered queries too
|
||||
|
||||
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
|
||||
|
||||
(See H2 above for the page-spam variant.) Even on a single search,
|
||||
an audit row containing the search term is written. If the search
|
||||
term itself is sensitive (e.g. an admin searches for a specific
|
||||
client's name in audit logs), it's now in the audit log of audit-log
|
||||
viewing. Acceptable but worth documenting.
|
||||
|
||||
### L5. Import worker is a stub
|
||||
|
||||
**File:** `src/lib/queue/workers/import.ts:13`
|
||||
|
||||
`// TODO(L2): implement import job handlers` — the worker is wired
|
||||
into the queue and registered, but does nothing. If anyone enqueues
|
||||
an `import:*` job, it returns immediately. Either ship the feature
|
||||
or remove the queue.
|
||||
|
||||
### L6. `interest-form.tsx` two TODOs about company-yacht filter + add-yacht inline
|
||||
|
||||
**File:** `src/components/interests/interest-form.tsx:332-333`
|
||||
|
||||
Real product gaps. When creating an interest for a client who's a
|
||||
member of a company, you can't pick a yacht owned by that company.
|
||||
And there's no inline "Add yacht" shortcut in the form.
|
||||
|
||||
### L7. `berth-spec-template.ts` defaults to `'Price: TBD'` when price is null
|
||||
|
||||
**File:** `src/lib/pdf/templates/berth-spec-template.ts:128`
|
||||
|
||||
Generated berth-spec PDFs say "Price: TBD" for any berth without a
|
||||
price. Cosmetic — verify whether sales considers this an acceptable
|
||||
fallback or wants to suppress the line entirely.
|
||||
|
||||
---
|
||||
|
||||
## Things checked and found OK (so we don't re-audit)
|
||||
|
||||
- Tenant isolation on hard-delete (`portId` filter on every query and
|
||||
inside the tx).
|
||||
- `withPermission` gates on every new route (bulk-archive-preflight,
|
||||
hard-delete-_, bulk-hard-delete-_, redeliver).
|
||||
- Audit log: no public DELETE endpoint, no PATCH endpoint.
|
||||
- Sidebar nav properly gates marina sections from `residential_partner`
|
||||
via `hasMarinaAccess`.
|
||||
- Auth wrapper rebuilds the request body correctly so the upstream
|
||||
better-auth handler can re-read it (no body-already-consumed bug).
|
||||
- Webhook outbound SSRF guard with DNS rebinding protection still
|
||||
intact.
|
||||
- 1175/1175 vitest suite passing as of last run.
|
||||
|
||||
---
|
||||
|
||||
## Recommended fix order (ROUND 1 + 2 combined — see below for Round 2)
|
||||
|
||||
See **"Triage list" at the end** of this document — combined ranking
|
||||
across both audit rounds.
|
||||
|
||||
---
|
||||
|
||||
## Round 2 — focused agents (added 2026-05-06 evening)
|
||||
|
||||
After the original synthesis above, four scoped agents (smaller blast
|
||||
radius, hard finding caps) successfully audited their domains and
|
||||
produced dedicated docs. Findings are linked here with `R2-`-prefixed
|
||||
IDs. Detail in:
|
||||
|
||||
- [audit-reliability-2026-05-06.md](audit-reliability-2026-05-06.md) — 11 findings
|
||||
- [audit-frontend-2026-05-06.md](audit-frontend-2026-05-06.md) — 12 findings
|
||||
- [audit-permissions-2026-05-06.md](audit-permissions-2026-05-06.md) — 9 findings
|
||||
- [audit-missing-features-2026-05-06.md](audit-missing-features-2026-05-06.md) — 12 findings
|
||||
|
||||
### Round 2 — CRITICAL
|
||||
|
||||
**R2-C1. Bulk archive discards post-commit side effects** ([reliability C1](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/app/api/v1/clients/bulk/route.ts:68-134`
|
||||
- The bulk wizard's `runBulk` callback discards the return value from
|
||||
`archiveClientWithDecisions`. **Documenso envelopes marked
|
||||
`void_documenso` are never queued for void; "next-in-line" sales
|
||||
notifications never fire**. The CRM ends up showing `documents.status='cancelled'`
|
||||
while the live envelope is still out for signature — a signer can
|
||||
legally complete a doc the CRM thinks is voided.
|
||||
- Same severity tier as the original C1 (worker-imports).
|
||||
|
||||
**R2-C2. Frontend: Restore icon hovers destructive-red on archived clients** ([frontend C1](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-detail-header.tsx:174-186`
|
||||
- Conditional `hover:text-destructive` is overridden by an unconditional
|
||||
`hover:text-foreground` earlier in the class string. Result: the
|
||||
Restore button on archived clients hovers blood-red, signalling
|
||||
"destructive" on a fully reversible action. Users hesitate to click.
|
||||
Promoted to "critical UX" because it's directly misleading on every
|
||||
archived client view.
|
||||
|
||||
### Round 2 — HIGH
|
||||
|
||||
**R2-H1. Smart-restore wizard's `berth_released` reversal is a no-op but the audit log claims success**
|
||||
([reliability H1](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/client-restore.service.ts:359-372`
|
||||
- Already noted as M4 in the original synthesis. Round-2 reliability
|
||||
agent escalated to HIGH because the wizard counter increments and
|
||||
the audit log records "1 auto-reversed" — operator believes the berth
|
||||
was re-attached when nothing happened. Same fix path: persist the
|
||||
original `interestId` in the decision detail and re-link on restore.
|
||||
|
||||
**R2-H2. Smart-archive berth status update has TOCTOU race**
|
||||
([reliability H2](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/client-archive.service.ts:191-207`
|
||||
- Berth row read outside tx, mutated inside tx without `for update`
|
||||
lock. Concurrent archive + sale of the same berth can race: the
|
||||
archive flow flips a freshly-sold berth back to `available`. Add
|
||||
`select … for update` on `berths` before the status flip.
|
||||
|
||||
**R2-H3. Bulk archive can pick the wrong interest for berth release**
|
||||
([reliability H3](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/app/api/v1/clients/bulk/route.ts:95-103`
|
||||
- Lookup by `primaryBerthMooring` falls back to `dossier.interests[0]?.interestId ?? ''`.
|
||||
Empty-string `interestId` reaches the delete and silently matches
|
||||
zero rows; the link is silently retained while the audit log claims
|
||||
it was removed.
|
||||
|
||||
**R2-H4. External EOI runs five operations outside a transaction**
|
||||
([reliability H4](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/external-eoi.service.ts:67-155`
|
||||
- Storage upload + 4 DB writes are independent. Mid-flight failure
|
||||
leaves orphan PDFs in S3/MinIO and partial DB state.
|
||||
|
||||
**R2-H5. Bulk wizard double-submit treats `ConflictError('already archived')` as a per-row error**
|
||||
([reliability H5](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/app/api/v1/clients/bulk/route.ts:68-120`
|
||||
- No idempotency key on the bulk endpoint. A double-submit (network
|
||||
retry, double click) makes the second response look like all rows
|
||||
failed even though the first succeeded.
|
||||
|
||||
**R2-H6. Webhook replay button has no UI permission gate (403 toast spam)**
|
||||
([permissions H1](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
|
||||
- Replay button renders for any user who can load the page. Server gates
|
||||
on `admin.manage_webhooks`. Non-admins see enabled buttons; clicking
|
||||
surfaces a generic 403 toast.
|
||||
|
||||
**R2-H7. Bulk Archive bulk action exposed to roles without `clients.delete`**
|
||||
([permissions H2](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-list.tsx:182-190`
|
||||
- `sales_agent` and `viewer` see the Archive bulk action; clicking
|
||||
surfaces a 403 from preflight. Mirror the `canHardDelete` pattern:
|
||||
`const canBulkArchive = can('clients', 'delete');`
|
||||
|
||||
**R2-H8. Bulk add_tag / remove_tag exposed to viewer**
|
||||
([permissions H3](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-list.tsx:165-181`
|
||||
- Same pattern as R2-H7 — no UI gate; server gates on `clients.edit`.
|
||||
|
||||
**R2-H9. Bulk hard-delete silently skips rows that vanish between preflight and execute**
|
||||
([permissions H4](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/client-hard-delete.service.ts:377`
|
||||
- `if (!c) continue;` swallows any client that was archived/restored/
|
||||
deleted by another operator between preflight and execute. Operator
|
||||
sees a `deletedCount` lower than requested and no signal which IDs
|
||||
were skipped.
|
||||
|
||||
**R2-H10. Frontend: `webhook-delivery-log` and `audit-log-list` swallow fetch errors silently**
|
||||
([frontend H3, H4](audit-frontend-2026-05-06.md))
|
||||
|
||||
- Files: `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`,
|
||||
`src/components/admin/audit/audit-log-list.tsx:150-175`
|
||||
- Both wrap fetches in `try/finally` with no `catch`. Failed loads show
|
||||
spinner forever or stale data; user has no signal that anything
|
||||
failed. Surface via `toast.error` + inline retry banner.
|
||||
|
||||
**R2-H11. Frontend: `audit-log-card` renders as `<a href="#">` — page-jumps on mobile tap**
|
||||
([frontend H5](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/admin/audit/audit-log-card.tsx:96`
|
||||
- Card view rows on mobile insert `#` in URL on tap (back-button trap).
|
||||
Render as button or div, or link to a useful destination.
|
||||
|
||||
**R2-H12. Frontend: `smart-archive-dialog` doesn't invalidate the dossier or single-client query**
|
||||
([frontend H6](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/smart-archive-dialog.tsx:197-212`
|
||||
- Detail page header keeps showing client as un-archived after a
|
||||
successful archive until hard reload. Add
|
||||
`qc.invalidateQueries({queryKey: ['clients', clientId]})` and
|
||||
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})`.
|
||||
|
||||
**R2-H13. Frontend: bulk tag mutation uses `alert()` and lacks `onError`**
|
||||
([frontend H2](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-list.tsx:88-106`
|
||||
- Native `alert()` blocks the page on partial failure; pure network
|
||||
failure shows nothing. Replace with `toast.warning` / `toast.error`.
|
||||
|
||||
**R2-H14. Email-template subject overrides are no-ops for 6 of 8 templates**
|
||||
([missing-features V1](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Files: `src/components/admin/email-templates-admin.tsx:24-72` (UI),
|
||||
`src/lib/services/portal-auth.service.ts:120,332` (only consumers)
|
||||
- Admin sees an "Overridden" badge after saving a custom subject for
|
||||
CRM invite, inquiry confirmation, residential templates, etc. — but
|
||||
the senders ship the hardcoded subject regardless. Wire
|
||||
`loadSubjectOverride(portId, key)` into the 6 missing senders.
|
||||
|
||||
**R2-H15. Branding admin saves 5 settings that nothing reads**
|
||||
([missing-features V2](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Files: `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx`,
|
||||
`src/lib/services/port-config.ts:240-272`
|
||||
- Logo URL, app name, primary color, header HTML, footer HTML all
|
||||
dead-end. `getPortBrandingConfig` has zero callers. **Multi-tenant
|
||||
promise broken — every port's emails ship Port Nimara's branding.**
|
||||
|
||||
**R2-H16. Reminder admin saves digest defaults that no scheduler applies**
|
||||
([missing-features V3](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Files: `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx`,
|
||||
`src/lib/services/port-config.ts:284-306`
|
||||
- Sales reps think they configured a daily digest at 09:00 in their
|
||||
TZ; they get fire-as-they-hit notifications instead. The digest
|
||||
scheduler doesn't exist.
|
||||
|
||||
### Round 2 — MEDIUM (selected highlights)
|
||||
|
||||
**R2-M1. Portal "My Memberships" tile is a dead-end** ([missing-features V4](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Tile on `/portal/dashboard` has no `href`; route doesn't exist. Either
|
||||
ship `/portal/memberships` or remove the tile.
|
||||
|
||||
**R2-M2. Company detail Documents tab is a "Coming soon" stub** ([missing-features V5](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- `src/components/companies/company-tabs.tsx:230-234`. Same problem
|
||||
as the three already-noted "coming soon" stubs but on a different
|
||||
entity.
|
||||
|
||||
**R2-M3. Onboarding page is a static checklist not the wizard it advertises** ([missing-features V6](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- The page literally says "what this page will become". Either build
|
||||
the wizard or relabel the landing card.
|
||||
|
||||
**R2-M4. Backup admin page is a docs page despite landing copy promising "on-demand exports"** ([missing-features V7](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Once C1 (worker imports) is fixed, the existing `database-backup`
|
||||
job is reachable; small lift to wire a "Take backup now" button.
|
||||
|
||||
**R2-M5. Inquiry inbox has zero triage actions** ([missing-features V8](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- No "Convert to client", no "Resolve", no "Assign". `website_submissions`
|
||||
table is permanent; sales has to copy-paste emails into client forms.
|
||||
|
||||
**R2-M6. external-eoi grants only `documents.upload_signed` but mutates interest state** ([permissions M1](audit-permissions-2026-05-06.md))
|
||||
|
||||
- A custom role with `documents.upload_signed:true` + `interests.edit:false`
|
||||
can flip an interest to "signed" via the external-EOI route.
|
||||
|
||||
**R2-M7. `InlineStagePicker` never sends `override:true` — `override_stage` permission unreachable from the most-used UI path** ([permissions M2](audit-permissions-2026-05-06.md))
|
||||
|
||||
- Users with the perm have to fall back to the modal `InterestStagePicker`
|
||||
to actually use it.
|
||||
|
||||
**R2-M8. `sales_agent` granted `interests.override_stage:true` — likely copy-paste from sales_manager** ([permissions M3](audit-permissions-2026-05-06.md))
|
||||
|
||||
- All other trust-elevated flags are stripped from sales_agent. Needs a
|
||||
product decision; either flip to false or document intent.
|
||||
|
||||
**R2-M9. `bulk-archive-preflight` leaks dossier-loader error text in `blockers`** ([permissions M4](audit-permissions-2026-05-06.md))
|
||||
|
||||
- An attacker enumerating UUIDs can distinguish "doesn't exist" vs
|
||||
"exists but you can't see it". Replace with generic "Could not load
|
||||
dossier".
|
||||
|
||||
**R2-M10. Documenso void worker has no max-retry alert hook** ([reliability M2](audit-reliability-2026-05-06.md))
|
||||
|
||||
- A persistent 401/403 retries forever. On exhaustion, write back to
|
||||
`documents` (`cancellation_failed=true`) and notify admin.
|
||||
|
||||
**R2-M11. Mobile More-sheet missing residential, notifications, berth-reservations, website-analytics** ([missing-features V9](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Mobile users have zero path to entire feature domains. Add to
|
||||
`MORE_ITEMS`.
|
||||
|
||||
**R2-M12. Portal has no profile / change-password surface** ([missing-features V10](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Forces every portal user to use the forgot-password flow even when
|
||||
they remember their old password. Ship `/portal/profile`.
|
||||
|
||||
**R2-M13. Portal invoices show amounts but no PDF download** ([missing-features V11](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Documents page does have downloads; mirror the pattern.
|
||||
|
||||
(Plus several more medium/low items in the dedicated docs; see those
|
||||
for the full set.)
|
||||
|
||||
---
|
||||
|
||||
## TRIAGE LIST (combined Round 1 + Round 2)
|
||||
|
||||
### Ship now — CRITICAL
|
||||
|
||||
1. **C1** — wire the 5 missing BullMQ workers (`worker.ts`, `server.ts`)
|
||||
— 5-line fix; every webhook + cron flow is currently dead.
|
||||
2. **R2-C1** — make bulk archive enqueue Documenso voids + next-in-line
|
||||
notifications (return value plumbing in `bulk/route.ts`).
|
||||
3. **R2-C2** — fix the destructive-red hover on the Restore button
|
||||
(`client-detail-header.tsx`). Trivial CSS fix.
|
||||
|
||||
### Ship this week — HIGH (security/UX with concrete user impact)
|
||||
|
||||
4. **H1** — rate-limit the hard-delete-request endpoints.
|
||||
5. **H3** — collapse "no code" vs "wrong code" into one error message.
|
||||
6. **H7** — three "coming soon" stubs in client/berth tabs.
|
||||
7. **R2-H1** — fix smart-restore's silent `berth_released` no-op (or
|
||||
reclassify as `reversibleWithPrompt`).
|
||||
8. **R2-H2** — add `for update` lock on the smart-archive berth status
|
||||
flip (TOCTOU race).
|
||||
9. **R2-H3** — bulk-archive's wrong-interest fallback — empty-string
|
||||
interestId silently no-ops.
|
||||
10. **R2-H6, R2-H7, R2-H8** — three permission UI-gate misses on
|
||||
bulk actions and the webhook-replay button. ~30 lines total.
|
||||
11. **R2-H10, R2-H12, R2-H13** — frontend swallowed errors + missing
|
||||
invalidation + alert() instead of toast. Small fixes, immediate UX
|
||||
win.
|
||||
12. **R2-H11** — `audit-log-card` `href="#"` mobile back-button trap.
|
||||
13. **R2-H14** — wire 6 missing email-subject overrides through their
|
||||
senders.
|
||||
|
||||
### Next sprint — HIGH/MEDIUM (operational + multi-tenant correctness)
|
||||
|
||||
14. **R2-H4** — wrap external-EOI in a transaction.
|
||||
15. **R2-H5** — bulk-archive idempotency key + treat already-archived as
|
||||
success in bulk.
|
||||
16. **R2-H9** — bulk hard-delete should return `skipped: string[]`.
|
||||
17. **R2-H15, R2-H16** — branding + reminder admin pages save settings
|
||||
nothing reads (silently broken multi-tenancy).
|
||||
18. **H2** — audit-page-view de-dupe (don't spam on every filter change).
|
||||
19. **H4** — synthetic seed needs documented dev-user setup or its own
|
||||
bootstrap script.
|
||||
20. **H5** — Documenso bad-secret rate-limit per IP.
|
||||
21. **R2-M1 through R2-M5** — portal memberships dead-end, company
|
||||
Documents stub, onboarding wizard, backup page, inquiry inbox triage.
|
||||
|
||||
### Backlog — MEDIUM/LOW + remaining items
|
||||
|
||||
22. The remaining MEDIUM/LOW from both rounds — see the dedicated docs.
|
||||
|
||||
---
|
||||
|
||||
## Headline numbers (combined)
|
||||
|
||||
- **3 CRITICAL** (worker imports, bulk-archive side-effects, restore-button hover)
|
||||
- **22 HIGH** (security + UX with concrete impact)
|
||||
- **~15 MEDIUM** (operational hygiene, multi-tenancy gaps, unfinished features)
|
||||
- **~10 LOW** (cleanup, defensive)
|
||||
|
||||
Round 1 was a manual synthesis after agent-pool stalls; Round 2 was
|
||||
four focused agents with hard finding caps that all completed inside
|
||||
the watchdog window. Every finding is grounded in code references.
|
||||
278
docs/audit-final-deferred.md
Normal file
278
docs/audit-final-deferred.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Final audit deferred findings
|
||||
|
||||
> **Status update (audit-v3 round)**: most of the v2 deferred items have
|
||||
> now landed. Items struck through below are completed. The remaining
|
||||
> open items are bigger refactors (custom-fields per-entity routes,
|
||||
> systemSettings PK reconciliation, Documenso v2 voidDocument verification,
|
||||
> partial-vs-composite archived index conversion, storage-proxy port_id
|
||||
> claim, Documenso webhook port_id enforcement, response-shape
|
||||
> standardization, berths.current_pdf_version_id Drizzle FK).
|
||||
|
||||
The pre-merge audit on `feat/berth-recommender` produced ~30 findings. The
|
||||
critical + high-severity items were fixed in-branch. The items below are
|
||||
medium / low severity and deferred to follow-up issues so the merge isn't
|
||||
held up. Each entry is self-contained — pick one off and ship it.
|
||||
|
||||
## Cross-cutting integration
|
||||
|
||||
- **EOI in-app pathway silently swallows missing `Berth Range` AcroForm field**
|
||||
— `src/lib/pdf/fill-eoi-form.ts:93`. `setText(form, 'Berth Range', ...)`
|
||||
is wrapped in a try/catch that succeeds silently when the field is
|
||||
absent. CLAUDE.md already warns ops about needing to add the field to
|
||||
the live Documenso template; this code change would make the deployment
|
||||
gap observable. Fix: when `context.eoiBerthRange` is non-empty AND the
|
||||
field is absent, log at warn level + surface a structured response field.
|
||||
|
||||
- **Email body merge expansion happens after token validation** —
|
||||
`src/lib/services/document-sends.service.ts:399-403`. If a merge value
|
||||
contains a `{{token}}` substring (e.g. a client name like
|
||||
`"Acme {{discount}} Inc."`), the expanded body will contain a token
|
||||
the unresolved-check missed and ships with literal braces. Fix: HTML-
|
||||
escape merge values before expansion, OR run a second
|
||||
`findUnresolvedTokens` against the expanded body.
|
||||
|
||||
- **Filesystem dev-fallback HMAC secret can drift across processes** —
|
||||
`src/lib/storage/filesystem.ts:328-331`. The dev-only fallback derives
|
||||
the HMAC secret from `BETTER_AUTH_SECRET`. Two CRM processes running
|
||||
with different secrets (web vs worker) reject each other's tokens.
|
||||
Fix: assert `BETTER_AUTH_SECRET` is set when filesystem backend is
|
||||
active in non-prod, or document the requirement loudly.
|
||||
|
||||
- **Berth PDF apply path: numeric column nulling silently drops** —
|
||||
`src/lib/services/berth-pdf.service.ts:473-475`. When
|
||||
`Number.isFinite(n)` is false the apply loop `continue`s without
|
||||
pushing to `applied` and without warning. Combined with the
|
||||
"no appliable fields supplied" check (only fires when ALL drop), partial
|
||||
silent drops are invisible. Fix: collect dropped keys and surface them.
|
||||
|
||||
## Multi-tenant isolation hardening
|
||||
|
||||
- **document_sends row stores `interestId` without verifying port match** —
|
||||
`src/lib/services/document-sends.service.ts:422`. Audit-log pollution
|
||||
rather than data exposure (the recipient lookup is port-checked already).
|
||||
Fix: when `recipient.interestId` is set, fetch with
|
||||
`and(eq(interests.id, ...), eq(interests.portId, input.portId))` and
|
||||
throw if missing.
|
||||
|
||||
- **Storage proxy token does not bind to port_id** —
|
||||
`src/lib/storage/filesystem.ts:73-84`. ProxyTokenPayload is `{k, e, n,
|
||||
f?, c?}` with a global HMAC. The current "issuer always checks port
|
||||
first" relies on every issuer being correct in perpetuity. Fix: add a
|
||||
`p` (portId) claim and have the proxy route resolve key→owner row +
|
||||
assert `owner.portId === payload.p` before streaming.
|
||||
|
||||
- **Documenso webhook does not enforce port_id on document lookups** —
|
||||
`src/app/api/webhooks/documenso/route.ts:96-148`. Handlers dispatch by
|
||||
global `documensoId`. If two ports' documents were ever issued the
|
||||
same Documenso ID (replay across staging/prod, forwarded webhook from
|
||||
a foreign instance), the wrong port's interest could be mutated. The
|
||||
per-body `signatureHash` dedup is partial mitigation. Fix: either
|
||||
(a) include the originating Documenso instance/team in the lookup, or
|
||||
(b) verify `documents(documenso_id)` has a unique index port-wide.
|
||||
|
||||
## Recent expense work polish
|
||||
|
||||
- **renderReceiptHeader cursor math drifts after multi-step writes** —
|
||||
`src/lib/services/expense-pdf.service.ts:854`. After
|
||||
`doc.text(...)` with auto-flow, `doc.y` advances. Using `doc.y -
|
||||
headerH + 10` after the rect+stroke block computes against the
|
||||
post-rect position; works only because pdfkit's text-after-rect
|
||||
hasn't moved y yet. Headers may misalign on the first receipt page
|
||||
after a soft page break. Fix: capture `const baseY = doc.y` before
|
||||
drawing the rect and compute all subsequent offsets relative to it.
|
||||
|
||||
## Settings parsing
|
||||
|
||||
- **`loadRecommenderSettings` rejects string-shaped JSONB booleans** —
|
||||
`src/lib/services/berth-recommender.service.ts:116`. Postgres returns
|
||||
JSONB `true/false` as JS booleans, but if an admin saves `"true"`
|
||||
via a UI that wraps the value as a string, `asBool` returns null and
|
||||
the per-port override silently falls through to defaults. Not a
|
||||
security bug; a tuning footgun. Fix: accept `"true"`/`"false"` string
|
||||
forms in `asBool`.
|
||||
|
||||
# Audit-final v2 (post-merge platform-wide pass) deferred findings
|
||||
|
||||
A second comprehensive audit (security, routes, DB, integrations, UI/UX)
|
||||
ran after the merge. The high-impact items landed in commit
|
||||
`fix(audit-final-v2): platform-wide hardening` (or similar). Items below
|
||||
are deferred follow-ups.
|
||||
|
||||
## Routes / API
|
||||
|
||||
- **Saved-views routes lack `withPermission`** —
|
||||
`src/app/api/v1/saved-views/[id]/route.ts:4-5` and
|
||||
`src/app/api/v1/saved-views/route.ts:24`. Convention is
|
||||
`withAuth(withPermission(...))`. Verify the service applies
|
||||
`(ctx.userId, ctx.portId)` ownership filtering, then add either an
|
||||
explicit owner-only comment or wrap with a benign permission gate.
|
||||
|
||||
- **Custom-fields permission resource hardcoded to `clients`** —
|
||||
`src/app/api/v1/custom-fields/[entityId]/route.ts:15,29`. Custom fields
|
||||
attach to client / yacht / interest / berth / company, but the route
|
||||
always checks `clients.view` / `clients.edit`. A user with
|
||||
`companies.view` can read confidential company custom-field values via
|
||||
this endpoint (the service-level `customFieldDefinitions.portId` filter
|
||||
prevents cross-tenant access but not cross-resource within a tenant).
|
||||
Fix: split into per-entity routes, OR resolve `entityType` and gate on
|
||||
the matching permission inline.
|
||||
|
||||
- **`alerts/[id]/acknowledge|dismiss` ungated** —
|
||||
`src/app/api/v1/alerts/[id]/acknowledge/route.ts:6` etc. only `withAuth`,
|
||||
no `withPermission`. Verify the service requires user ownership; if
|
||||
not, gate on `reports.view_dashboard` or similar.
|
||||
|
||||
- **Public POST routes bypass service layer** —
|
||||
`src/app/api/public/interests/route.ts`, `…/website-inquiries/route.ts`,
|
||||
`…/residential-inquiries/route.ts`. These do extensive `tx.insert(...)`
|
||||
with hand-rolled audit logs (`userId: null as unknown as string`).
|
||||
Extract a `publicInterestService.create(...)` so the same code path is
|
||||
unit-testable and port-id discipline is uniform. Verify
|
||||
`audit_logs.user_id` is nullable (the cast pattern signals it is, but
|
||||
enforce in schema if not).
|
||||
|
||||
- **Inconsistent response shapes** — most endpoints return `{ data: ... }`,
|
||||
but `notifications/[notificationId]` returns `{ success: true }`,
|
||||
`website-inquiries` returns `{ id, deduped }`. Document a convention in
|
||||
CLAUDE.md and migrate.
|
||||
|
||||
- **`req.json()` without `parseBody` helper** — admin custom-fields
|
||||
routes use `await req.json(); schema.parse(body)` directly instead of
|
||||
the project's `parseBody(req, schema)` helper. Migrate for uniform
|
||||
400 error shapes.
|
||||
|
||||
## Documenso integration
|
||||
|
||||
- **v2 voidDocument endpoint may not match real API** —
|
||||
`src/lib/services/documenso-client.ts:450-466`. The audit flagged that
|
||||
Documenso 2.x exposes envelope deletion as
|
||||
`POST /api/v2/envelope/delete` with `{ envelopeId }` body, not
|
||||
`DELETE /api/v2/envelope/{id}`. The unit test mocks fetch so it can't
|
||||
catch the real shape. Verify against a live Documenso 2.x instance
|
||||
(`pnpm exec playwright test --project=realapi`) before flipping any
|
||||
port to v2.
|
||||
|
||||
- **Webhook dedup vs per-recipient signed events** —
|
||||
`src/app/api/webhooks/documenso/route.ts:103-110`. The top-level
|
||||
`signatureHash` (sha256 of raw body) blocks exact replays, but a
|
||||
duplicate webhook delivery for a multi-recipient document with a
|
||||
re-encoded body will go through the per-recipient loop. Make
|
||||
`documentEvents.signatureHash` unique cover the suffixed values OR add
|
||||
a composite unique index `(documensoDocumentId, recipientEmail, eventType)`.
|
||||
|
||||
- **v1 `placeFields` per-field POST has no retry** —
|
||||
`src/lib/services/documenso-client.ts:374-398`. A single transient 500
|
||||
mid-loop leaves the document with a partial field set. Add 3-attempt
|
||||
exponential backoff on 5xx + voidDocument on final failure.
|
||||
|
||||
## Storage
|
||||
|
||||
- **S3 backend has no startup bucket-exists check** —
|
||||
`src/lib/storage/s3.ts:100-111`. A typo'd bucket name surfaces as a
|
||||
500 inside a user-facing request rather than at boot. Add
|
||||
`await client.bucketExists(bucket)` in `S3Backend.create` with a clear
|
||||
error message.
|
||||
|
||||
- **Storage cache fingerprint includes encrypted secret** —
|
||||
`src/lib/storage/index.ts:158-159`. After a key rotation the old
|
||||
cached client survives until `resetStorageBackendCache()` is called
|
||||
(already called via the settings-write hook). Document the
|
||||
invariant or fingerprint on a content-hash that excludes encrypted
|
||||
material.
|
||||
|
||||
- **Filesystem dev HMAC silent fallback** —
|
||||
`src/lib/storage/filesystem.ts:309-332`. Two dev nodes started with
|
||||
different `BETTER_AUTH_SECRET` derive different secrets and reject
|
||||
each other's tokens. Log a one-line warn at backend boot in non-prod.
|
||||
|
||||
## DB schema
|
||||
|
||||
- **`berths.current_pdf_version_id` lacks Drizzle FK** —
|
||||
`src/lib/db/schema/berths.ts:83`. The FK exists in migration 0030
|
||||
but not in the schema source-of-truth, so `pnpm db:push` against an
|
||||
empty DB skips the constraint. Either add the FK with a deferred
|
||||
declaration or document that `db:push` is unsupported.
|
||||
|
||||
- **Missing indexes on FK columns** — `berthReservations.interestId`,
|
||||
`berthReservations.contractFileId`, `documents.fileId`,
|
||||
`documents.signedFileId`, `documentEvents.signerId`,
|
||||
`documentTemplates.sourceFileId`, `formSubmissions.formTemplateId`,
|
||||
`formSubmissions.clientId`, `documentSends.brochureId`,
|
||||
`documentSends.brochureVersionId`, `documentSends.sentByUserId`. Add
|
||||
`index(...)` declarations to avoid full-scan FK checks on parent
|
||||
delete.
|
||||
|
||||
- **`systemSettings` PK / unique-index drift** —
|
||||
`src/lib/db/schema/system.ts:119-133`. Schema declares only a
|
||||
`uniqueIndex` on `(key, port_id)` but the migration uses `key` as PK.
|
||||
`port_id` is nullable so `(key, port_id)` cannot serve as a PK with
|
||||
default NULLs-not-equal semantics. Reconcile: declare
|
||||
`primaryKey({ columns: [table.key, table.portId] })` (after making
|
||||
`portId` non-null with a sentinel) OR use partial unique indexes for
|
||||
global + per-port settings.
|
||||
|
||||
- **Composite vs partial archived indexes** — many tables use
|
||||
`index('idx_*_archived').on(portId, archivedAt)` when the dominant
|
||||
query is `WHERE port_id = ? AND archived_at IS NULL`. Convert to
|
||||
`index(...).on(portId).where(sql\`archived_at IS NULL\`)` partial
|
||||
indexes for smaller storage + faster planner choice.
|
||||
|
||||
- **`documentSends.sentByUserId` ungated FK** —
|
||||
`src/lib/db/schema/brochures.ts:118` is `notNull()` but has no FK
|
||||
reference. If a user is hard-deleted (rare; we soft-delete), an
|
||||
orphan id remains. Add `.references(() => users.id, { onDelete: 'set null' })`
|
||||
and make the column nullable. Same audit-trail rationale as the
|
||||
other documentSends FK fixes (commit 0035).
|
||||
|
||||
## UI/UX
|
||||
|
||||
- **Storage admin migration mutation lacks toasts** —
|
||||
`src/components/admin/storage-admin-panel.tsx:61-72`. Add `onSuccess`
|
||||
toast with row count + `onError` toast.
|
||||
|
||||
- **Invoice detail send/payment mutations lack error feedback + gates** —
|
||||
`src/components/invoices/invoice-detail.tsx:93-99,152-167`. Add
|
||||
`onError: (e) => toast.error(...)` and wrap mutating buttons in
|
||||
`<PermissionGate resource="invoices" action="send">` /
|
||||
`record_payment`.
|
||||
|
||||
- **Admin user list edit button ungated** —
|
||||
`src/components/admin/users/user-list.tsx:114`. Wrap in
|
||||
`<PermissionGate resource="admin" action="manage_users">`.
|
||||
|
||||
- **Email threads list missing skeleton** —
|
||||
`src/components/email/email-threads-list.tsx:29-45`. Use `<Skeleton>`
|
||||
rows during load + `<EmptyState>` for the empty case.
|
||||
|
||||
- **Scan page mutations swallow OCR errors** —
|
||||
`src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx:67-87`. Add an
|
||||
inline error state for `scanMutation.isError` (the upload-side
|
||||
already does this).
|
||||
|
||||
- **Invoice detail uses `any` for query data** — strict-mode escape
|
||||
hatch. Define a proper response type matching the API contract.
|
||||
|
||||
## Security defense-in-depth
|
||||
|
||||
- **Storage proxy token does not bind to port_id** —
|
||||
`src/lib/storage/filesystem.ts:73-84`. Token's HMAC is global. Fix:
|
||||
add `p` (portId) claim and have the proxy resolve key→owner row +
|
||||
assert `owner.portId === payload.p`.
|
||||
|
||||
- **Documenso webhook does not enforce port_id** —
|
||||
`src/app/api/webhooks/documenso/route.ts:96-148`. Handlers dispatch
|
||||
by global `documensoId`. Verify `documents(documenso_id)` is unique
|
||||
port-wide OR include the originating instance/team in the lookup.
|
||||
|
||||
- **EOI in-app pathway silently swallows missing `Berth Range` field** —
|
||||
`src/lib/pdf/fill-eoi-form.ts:93`. Log warn when
|
||||
`context.eoiBerthRange` is non-empty AND the field is absent so the
|
||||
Documenso template deployment gap is observable.
|
||||
|
||||
- **AI worker has no cost-tracking ledger write** —
|
||||
`src/lib/queue/workers/ai.ts:122-177`. Persist token usage to the
|
||||
`ai_usage` ledger after every call.
|
||||
|
||||
- **Logger redact paths miss nested credentials** —
|
||||
`src/lib/logger.ts:5-19`. Extend redact list to cover
|
||||
`*.headers.authorization`, `**.token`, `secretKeyEncrypted`, etc.
|
||||
223
docs/audit-frontend-2026-05-06.md
Normal file
223
docs/audit-frontend-2026-05-06.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Frontend audit — 2026-05-06
|
||||
|
||||
Scope: new archive/restore/hard-delete dialogs, bulk archive wizard, client
|
||||
detail header, audit log inspector, webhook delivery log, client list bulk
|
||||
section. Companion to `docs/audit-comprehensive-2026-05-06.md` (does NOT
|
||||
re-flag the Files-tab / reservations / berth-tab "coming soon" stubs already
|
||||
covered there).
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1 — `client-detail-header` opens restore dialog from the Archive icon for archived clients
|
||||
|
||||
**File:** `src/components/clients/client-detail-header.tsx:174-186`
|
||||
|
||||
**Scenario:** On an archived client the icon button still renders `<Archive>`
|
||||
when `isArchived` is true (`isArchived ? <RotateCcw /> : <Archive />` is
|
||||
correct), BUT both states use the same `setArchiveOpen(true)` handler and
|
||||
the conditional below routes `<SmartRestoreDialog>` vs `<SmartArchiveDialog>`
|
||||
off of `isArchived`. That part is fine. The real problem: the destructive
|
||||
hover colour `hover:text-destructive` is applied via
|
||||
`isArchived ? 'hover:text-foreground' : 'hover:text-destructive'` — but the
|
||||
preceding class string already sets `hover:text-foreground` unconditionally,
|
||||
so the conditional is dead and the restore button hovers red the same as
|
||||
archive. Misleading colour signal on a reversible action; users hesitate to
|
||||
click it.
|
||||
|
||||
**Fix:** Drop the always-applied `hover:text-foreground` from the base class
|
||||
list and let the conditional own the hover colour, or just colour the
|
||||
restore icon emerald to differentiate.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H1 — `bulk-archive-wizard` lets users skip the reasons step by clicking Continue while preflight is loading then Cancel/reopen
|
||||
|
||||
**File:** `src/components/clients/bulk-archive-wizard.tsx:253-267, 80-107`
|
||||
|
||||
**Scenario:** In the `preflight` stage the Continue button is only disabled
|
||||
when `archivable.length === 0 || preflight.isLoading`. But `archivable` is
|
||||
derived from `items = preflight.data ?? []`. While loading, `archivable` is
|
||||
`[]` so Continue is disabled — good. After load with all-blocked selection,
|
||||
`archivable.length === 0` so still disabled — good. However, the
|
||||
`reasonsByClientId: reasons` payload is sent verbatim, so a user who advances
|
||||
to "reasons", types into one client's box, then uses the carousel back arrow
|
||||
and edits another, can submit reasons for clients NOT in `archivable` (e.g.
|
||||
if the preflight is refetched on stale-time). Reasons for blocked or removed
|
||||
client IDs are forwarded to the API. Minor data-quality issue.
|
||||
|
||||
**Fix:** Filter `reasons` to `archivable` IDs before mutating:
|
||||
`reasonsByClientId: Object.fromEntries(Object.entries(reasons).filter(([id]) => archivable.some(a => a.clientId === id)))`.
|
||||
|
||||
### H2 — `client-list` bulk tag mutation uses `alert()` for partial failures and has no `onError`
|
||||
|
||||
**File:** `src/components/clients/client-list.tsx:88-106`
|
||||
|
||||
**Scenario:** User bulk-adds a tag to 50 clients; backend returns 200 with
|
||||
`{succeeded: 30, failed: 20}` → user sees a native browser `alert()` blocking
|
||||
the page. If the request itself errors (network drop, 500), there is no
|
||||
`onError` so the dialog closes via `onSettled` and the user sees nothing —
|
||||
silent failure. Inconsistent UX vs. every other mutation in this audit which
|
||||
uses `toast`.
|
||||
|
||||
**Fix:** Replace `alert(...)` with `toast.warning(...)`, add an
|
||||
`onError: (err) => toast.error(...)` branch matching the pattern used in
|
||||
`bulk-archive-wizard.tsx` and `bulk-hard-delete-dialog.tsx`.
|
||||
|
||||
### H3 — `webhook-delivery-log` swallows fetch errors silently
|
||||
|
||||
**File:** `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`
|
||||
|
||||
**Scenario:** Admin opens a webhook detail page while the API is down or the
|
||||
webhook was just deleted. `load()` catches and discards the error
|
||||
(`} catch { /* ignore */ }`). UI shows "Loading deliveries…" forever on the
|
||||
first load, or stays on the last successful page on subsequent loads, with
|
||||
no indication that anything failed. No error state, no toast, no retry.
|
||||
|
||||
**Fix:** Surface errors via `toast.error` and show an inline error state
|
||||
("Couldn't load deliveries — Retry") instead of swallowing.
|
||||
|
||||
### H4 — `audit-log-list` first-page fetch swallows errors and shows no error state
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-list.tsx:150-175`
|
||||
|
||||
**Scenario:** Filter form is fully interactive, user changes a date — request
|
||||
fires, server 500s. The `try/finally` has no `catch`, so the rejected promise
|
||||
becomes an unhandled rejection. The list shows whatever was previously
|
||||
loaded (or empty state), and the user has no idea their filter didn't apply.
|
||||
Same applies to `loadMore`.
|
||||
|
||||
**Fix:** Add `catch` blocks that set an error state and render an inline
|
||||
error banner above the table, with a Retry button.
|
||||
|
||||
### H5 — `audit-log-card` renders as a link to `href="#"` — clicking jumps the page
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-card.tsx:96`
|
||||
|
||||
**Scenario:** On mobile / card view the audit log entries become clickable
|
||||
cards with `href="#"`. Tapping any card scrolls the page to top and inserts
|
||||
`#` in the URL (back-button trap). There's no detail view to navigate to.
|
||||
|
||||
**Fix:** Either render a non-link wrapper (button or div) when no detail
|
||||
target exists, or link to a useful destination like
|
||||
`/{portSlug}/{entityType}/{entityId}` when the entity is resolvable.
|
||||
|
||||
### H6 — `smart-archive-dialog` `archiveMutation` doesn't invalidate the dossier or single-client query
|
||||
|
||||
**File:** `src/components/clients/smart-archive-dialog.tsx:197-212`
|
||||
|
||||
**Scenario:** User archives a client successfully. The dialog invalidates
|
||||
`['clients']`, `['berths']`, `['interests']` but NOT
|
||||
`['client-archive-dossier', clientId]` nor `['clients', clientId]`. If the
|
||||
parent screen (e.g. detail page) keeps the client query mounted, the
|
||||
detail header continues to show the client as un-archived until a hard
|
||||
reload. The Restore icon won't appear.
|
||||
|
||||
**Fix:** Add `qc.invalidateQueries({queryKey: ['clients', clientId]})` and
|
||||
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})` so a
|
||||
re-open re-fetches a fresh dossier (e.g. if user re-archives after restoring
|
||||
in the same session).
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M1 — `smart-archive-dialog` derives `interestId` from a name match against `primaryBerthMooring` — wrong key
|
||||
|
||||
**File:** `src/components/clients/smart-archive-dialog.tsx:158-167`
|
||||
|
||||
**Scenario:** When building per-berth decisions the code does
|
||||
`dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)?.interestId`.
|
||||
Multiple interests can share the same primary mooring (rare, but possible
|
||||
historically), and worse, when no interest has this berth as primary it
|
||||
falls back to `dossier.interests[0]?.interestId` regardless of which berth
|
||||
is being decided. The wrong interest gets credited with the release, which
|
||||
is then audit-logged.
|
||||
|
||||
**Fix:** Have the dossier API return `interestId` per berth row (it already
|
||||
joins `interest_berths`), or look up by membership not by primary flag.
|
||||
|
||||
### M2 — `hard-delete-dialog` doesn't reset state when switching from intent → confirm if request fails midway
|
||||
|
||||
**File:** `src/components/clients/hard-delete-dialog.tsx:39-46, 64-79`
|
||||
|
||||
**Scenario:** User submits hard delete with wrong code → backend returns 400
|
||||
→ toast fires, but the dialog stays on `confirm` stage with the bad code
|
||||
still in the input and no clear cue. If the user then closes (X) and
|
||||
reopens, the `useEffect` resets correctly. But if the email code expired
|
||||
(10 min) and they request a fresh one, there's no "Resend code" button —
|
||||
they must cancel and start over from intent. Minor.
|
||||
|
||||
**Fix:** Add a "Send a new code" link in the confirm stage that calls
|
||||
`requestCode.mutate()` again and clears `code`.
|
||||
|
||||
### M3 — `bulk-hard-delete-dialog` doesn't refetch / invalidate after partial failure shows totals
|
||||
|
||||
**File:** `src/components/clients/bulk-hard-delete-dialog.tsx:64-85`
|
||||
|
||||
**Scenario:** Bulk delete returns `{deletedCount: 7}` for 10 selected; toast
|
||||
warns but `qc.invalidateQueries({queryKey: ['clients']})` is invalidated
|
||||
unconditionally — fine. However, the dialog closes immediately
|
||||
(`onOpenChange(false)`), so the user can't see WHICH 3 failed. The toast
|
||||
just says "see audit log". For a destructive bulk op this is too sparse;
|
||||
users will repeat the action thinking it didn't work.
|
||||
|
||||
**Fix:** Stay open on partial failure and render a list of failed IDs (the
|
||||
API likely already returns per-item results — if not, return them).
|
||||
|
||||
### M4 — `audit-log-list` doesn't validate that `dateFrom <= dateTo`
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-list.tsx:142-146`
|
||||
|
||||
**Scenario:** User picks From=2026-06-01, To=2026-05-01. Query fires with an
|
||||
empty result range; user sees "No audit log entries found" and assumes
|
||||
their data isn't there. No client-side validation hint.
|
||||
|
||||
**Fix:** Show an inline warning "From date must be before To date" and skip
|
||||
the request when invalid.
|
||||
|
||||
### M5 — `bulk-archive-wizard` `Cancel` during `archiveMutation.isPending` discards mutation tracking
|
||||
|
||||
**File:** `src/components/clients/bulk-archive-wizard.tsx:248-251, 293-307`
|
||||
|
||||
**Scenario:** User clicks "Archive 50" → mutation in flight (10s) → user
|
||||
clicks Cancel. The dialog closes; the mutation continues server-side and
|
||||
its onSuccess fires later, showing a toast for an action the user thought
|
||||
they cancelled. Worse, the dialog is gone so they can't tell which clients
|
||||
got archived.
|
||||
|
||||
**Fix:** Disable Cancel while `archiveMutation.isPending`, or relabel to
|
||||
"Cancel (won't stop in-progress)" and keep the mutation visible.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L1 — `audit-log-list` filter row overflows on narrow viewports
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-list.tsx:321-467`
|
||||
|
||||
**Scenario:** 8 filter controls (`Search` 288px, `Entity` 144px, `Action`
|
||||
176px, `Severity` 128px, `Source` 128px, `User id` 176px, `From` 144px,
|
||||
`To` 144px, total ~1330px) sit in a single `flex-wrap` row. At <1280px
|
||||
viewports they wrap onto multiple lines pushing the table down 200+px;
|
||||
at <640px (mobile) each control wraps onto its own line and the "Clear"
|
||||
button (`ml-auto`) lands on the wrong row.
|
||||
|
||||
**Fix:** Collapse rarely-used filters (User id / Severity / Source) into a
|
||||
"More filters" Popover for sm: viewports.
|
||||
|
||||
### L2 — `audit-log-card` action map missing entries silently fall back to grey "Activity" icon and grey badge
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-card.tsx:27-44, 46-52`
|
||||
|
||||
**Scenario:** New webhook/cron/job actions are in `audit-log-list.tsx`
|
||||
ACTION_COLORS but absent from `audit-log-card.tsx` ACTION_BADGE_COLORS and
|
||||
ACTION_ACCENT. Card view of these entries looks identical to a generic
|
||||
"unknown" entry — visual loss vs. table view.
|
||||
|
||||
**Fix:** Sync the two maps; consider extracting to a shared module so they
|
||||
can't drift.
|
||||
405
docs/audit-missing-features-2026-05-06.md
Normal file
405
docs/audit-missing-features-2026-05-06.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Missing-Features Audit — 2026-05-06
|
||||
|
||||
Focused pass on **features that look done in the UI but aren't fully
|
||||
wired through the service layer**, plus **admin settings exposed to
|
||||
users that no code reads**. Companion to
|
||||
`docs/audit-comprehensive-2026-05-06.md` — the three "coming soon" stubs
|
||||
already documented there (client Files tab, client reservations history,
|
||||
berth tabs), the import-worker stub, the two interest-form TODOs, and
|
||||
the EOI "Price: TBD" finding are NOT re-flagged here.
|
||||
|
||||
Hard cap: 12 findings. Severity tiers below.
|
||||
|
||||
---
|
||||
|
||||
## VISIBLE-BROKEN (admin sees a control, click is a no-op or wrong)
|
||||
|
||||
### V1. 6 of 8 admin-editable email subject overrides are silently ignored at send time
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/components/admin/email-templates-admin.tsx:24-72` (UI)
|
||||
- `src/lib/email/template-catalog.ts:16-25` (catalog of 8 keys)
|
||||
- `src/lib/services/portal-auth.service.ts:120-127, 332-339` (the only
|
||||
consumers of `loadSubjectOverride`)
|
||||
|
||||
The `/admin/email-templates` page lets an admin override the subject
|
||||
line on **eight** transactional templates:
|
||||
`portal_activation`, `portal_reset`, `portal_invite_resend`,
|
||||
`crm_invite`, `inquiry_client_confirmation`,
|
||||
`inquiry_sales_notification`, `residential_inquiry_client_confirmation`,
|
||||
`residential_inquiry_sales_alert`. The save endpoint persists each one
|
||||
to `system_settings` (`email_template_<key>_subject`).
|
||||
|
||||
Only **two** of those eight are ever read at send time —
|
||||
`portal_activation` and `portal_reset` in `portal-auth.service.ts`.
|
||||
A repo-wide search for `loadSubjectOverride` / `settingKeyForSubject`
|
||||
returns no other consumers. The other six templates use their hardcoded
|
||||
subject regardless of the admin override.
|
||||
|
||||
**Impact:** sales/ops teams will customize an inquiry confirmation
|
||||
subject, hit Save, see the "Overridden" badge, and silently ship the
|
||||
default subject to every prospect.
|
||||
|
||||
**Fix:** small per template — call `loadSubjectOverride(portId, key)`
|
||||
in each sender (`crm-invite.service.ts`, the inquiry sender, the
|
||||
residential inquiry sender, the portal-invite-resend path) and pass the
|
||||
result through as the email subject.
|
||||
|
||||
**Scope:** small (5 callsites + tests).
|
||||
|
||||
---
|
||||
|
||||
### V2. Branding admin (logo / app name / primary color / email header & footer HTML) saves to settings but no code reads them
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx:7-46` — UI
|
||||
with five fields.
|
||||
- `src/lib/services/port-config.ts:240-272` — `getPortBrandingConfig()`
|
||||
resolves the five `branding_*` settings into a typed config.
|
||||
- Repo-wide: `getPortBrandingConfig` has **zero callers** outside its
|
||||
declaration. The five `SETTING_KEYS.branding*` constants are only
|
||||
read inside `getPortBrandingConfig` itself.
|
||||
|
||||
The admin panel is functional end-to-end (write hits the settings API,
|
||||
"Reset to default" works), and the email-templates module hardcodes
|
||||
`s3.portnimara.com/...` for the logo URL plus a fixed table layout.
|
||||
None of the email-rendering helpers (`renderEmail`, the template
|
||||
modules in `src/lib/email/templates/`) call `getPortBrandingConfig`,
|
||||
and the `<BrandedAuthShell>` component sources its logo + colors from
|
||||
constants too.
|
||||
|
||||
**Impact:** every multi-tenant assumption made about branding is
|
||||
broken. A second port wired into this CRM will see Port Nimara's logo
|
||||
|
||||
- colors in every transactional email and on the auth pages, even
|
||||
after their admin "configures branding" successfully.
|
||||
|
||||
**Fix:** plumb `getPortBrandingConfig(portId)` through the email
|
||||
renderer (header/footer HTML + primary button color), and through
|
||||
`<BrandedAuthShell>` via a server-fetched prop.
|
||||
|
||||
**Scope:** medium (touches every transactional email + auth shell).
|
||||
|
||||
---
|
||||
|
||||
### V3. Reminder admin page configures defaults that no service applies
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx:7-50` — UI
|
||||
for default-enabled, default-days, digest-enabled, digest-time,
|
||||
digest-timezone.
|
||||
- `src/lib/services/port-config.ts:284-306` —
|
||||
`getPortReminderConfig()` defines the schema.
|
||||
- Repo-wide: the keys (`reminder_default_*`, `reminder_digest_*`) and
|
||||
`getPortReminderConfig` have **zero callers**.
|
||||
|
||||
Same pattern as V2. The admin sets "enable reminders by default on new
|
||||
interests" → toggles to true → save succeeds → newly-created interests
|
||||
still default to `reminderEnabled=false`. The digest-time +
|
||||
timezone fields go nowhere — there is no scheduler that batches
|
||||
pending reminders into a daily digest.
|
||||
|
||||
**Impact:** the entire reminder UX is decorative. Sales reps think
|
||||
they configured a daily digest at 09:00 Europe/Warsaw, get
|
||||
fire-as-they-hit notifications instead.
|
||||
|
||||
**Fix:** wire `getPortReminderConfig` into (a) the interest-create
|
||||
service (defaults), (b) the maintenance/notifications worker that
|
||||
fires reminders (digest batching + delivery window). The `digest`
|
||||
behavior didn't exist before this audit — needs a new scheduled job.
|
||||
|
||||
**Scope:** medium (defaults are small, digest job is new code).
|
||||
|
||||
---
|
||||
|
||||
### V4. Portal dashboard "My Memberships" tile has no link, no destination page, and isn't reachable from nav
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/(portal)/portal/dashboard/page.tsx:58-63` — `<PortalCard
|
||||
title="My Memberships" ... icon={Building2} />` — note no `href`
|
||||
prop.
|
||||
- `src/components/portal/portal-nav.tsx:8-15` — six nav entries, no
|
||||
memberships.
|
||||
- Filesystem: `src/app/(portal)/portal/memberships/` does not exist.
|
||||
|
||||
The dashboard shows a count of "memberships" (companies the portal
|
||||
user belongs to) but the tile is non-clickable and there is no
|
||||
`/portal/memberships` route. A user with 3 memberships sees the tile,
|
||||
clicks → nothing happens.
|
||||
|
||||
**Impact:** dead-end on the portal home for any client tied to a
|
||||
company (the residential and yacht-ownership use-cases).
|
||||
|
||||
**Fix:** ship `/portal/memberships/page.tsx` listing the companies
|
||||
returned by the existing `companyMemberships` query (already
|
||||
aggregated in `getPortalDashboard`), and add it to `PortalNav`. Or
|
||||
pull the tile if memberships isn't a portal feature.
|
||||
|
||||
**Scope:** small.
|
||||
|
||||
---
|
||||
|
||||
### V5. Company detail page Documents tab is a "Coming soon" stub
|
||||
|
||||
**File:** `src/components/companies/company-tabs.tsx:230-234`
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Documents',
|
||||
content: <EmptyState title="Documents" description="Coming soon" />,
|
||||
},
|
||||
```
|
||||
|
||||
Visible alongside the working Notes / Activity / Addresses / Members
|
||||
tabs on every company detail page. NOT covered by the existing audit
|
||||
doc's H7 (which lists clients, client reservations, and berths).
|
||||
|
||||
**Impact:** the same UX problem H7 calls out for clients.
|
||||
|
||||
**Fix:** mirror what client-Files-tab needs — query `documents` joined
|
||||
to a polymorphic billing-entity = company link, render a list, ship a
|
||||
download button. Or hide the tab.
|
||||
|
||||
**Scope:** small to medium.
|
||||
|
||||
---
|
||||
|
||||
## HALF-WIRED (the page works but the surrounding promise overstates it)
|
||||
|
||||
### V6. "Onboarding" admin page is a static checklist, not the wizard the page itself promises
|
||||
|
||||
**File:** `src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx`
|
||||
|
||||
The page renders 8 stepwise links and explicitly says (lines 71-72,
|
||||
98-110): "The future onboarding wizard will track progress per port…",
|
||||
"What this page will become", "The wizard will record completion per
|
||||
port in `system_settings`, gate the public marketing-site cutover…".
|
||||
|
||||
The admin landing card describes it as the "Initial-setup wizard for
|
||||
fresh ports" — admins clicking through expect a wizard, get a static
|
||||
table of contents.
|
||||
|
||||
**Impact:** the only "fresh port" workflow doesn't exist; cutover
|
||||
gating logic mentioned in the page body is also unimplemented.
|
||||
|
||||
**Fix:** either (a) build the wizard with progress in `system_settings`
|
||||
|
||||
- banner integration, or (b) re-label both this page and the admin
|
||||
landing card to "Setup checklist" so expectations match reality.
|
||||
|
||||
**Scope:** large for the wizard; tiny for the relabel.
|
||||
|
||||
---
|
||||
|
||||
### V7. Backup & Restore admin page is informational only — admin landing card promises actions
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/(dashboard)/[portSlug]/admin/backup/page.tsx`
|
||||
- `src/app/(dashboard)/[portSlug]/admin/page.tsx:148` — landing card
|
||||
description: "Database snapshots and on-demand exports."
|
||||
|
||||
The landing card sells "on-demand exports". The actual page renders a
|
||||
two-card explainer: "Current backup posture" (read-only) and "What
|
||||
this page will become" (the entire interactive surface — list
|
||||
snapshots, "Take backup now" button, per-port logical export, restore
|
||||
preview, GDPR per-client export). None of those exist.
|
||||
|
||||
**Impact:** the "Backup & Restore" tile is functionally a docs page.
|
||||
Compliance officers / users expecting a self-serve GDPR export
|
||||
button have to file a support ticket.
|
||||
|
||||
**Fix:** match the language on the landing card to the page reality
|
||||
("Backup posture" → docs only) until the snapshot/export buttons
|
||||
ship. The maintenance worker already runs `database-backup` (per
|
||||
`docs/audit-comprehensive-2026-05-06.md` C1 — though that worker isn't
|
||||
imported), so wiring "Take backup now" against the existing job is
|
||||
small once C1 is fixed.
|
||||
|
||||
**Scope:** small (doc tweak) or medium (button + per-port export
|
||||
endpoint).
|
||||
|
||||
---
|
||||
|
||||
### V8. Inquiry inbox is read-only — no "Convert to Client" / "Mark resolved" / "Assign" actions
|
||||
|
||||
**File:** `src/components/admin/inquiry-inbox.tsx` (entire file, 207
|
||||
lines, ends at the View payload toggle)
|
||||
|
||||
The inbox lists website-form submissions (berth_inquiry,
|
||||
residence_inquiry, contact_form) with filter chips and a
|
||||
"View payload" expand. There is no action to:
|
||||
|
||||
- create a client/interest from the submission,
|
||||
- assign the inquiry to a sales rep,
|
||||
- mark it resolved / triaged,
|
||||
- reply directly,
|
||||
- archive or trash the row,
|
||||
- export.
|
||||
|
||||
The `website_submissions` table appears to be permanent — every
|
||||
inquiry ever received remains in the inbox forever, with no triage
|
||||
state. Sales has to manually copy the email into a new client form
|
||||
and back-reference the original submission.
|
||||
|
||||
**Impact:** the inquiry-to-pipeline conversion step isn't supported in
|
||||
the CRM. The marketing-site cutover (per the user's
|
||||
`project_email_ownership_at_cutover.md` memory) will increase volume
|
||||
on this surface and make the missing triage UX painful.
|
||||
|
||||
**Fix:** add a per-submission "Convert" action that prefills the
|
||||
client + interest forms with the payload, plus a `triage_state`
|
||||
column (open / converted / dismissed) and a default filter that hides
|
||||
non-open rows.
|
||||
|
||||
**Scope:** medium.
|
||||
|
||||
---
|
||||
|
||||
## MOBILE PARITY
|
||||
|
||||
### V9. Mobile More-sheet is missing several real top-nav destinations
|
||||
|
||||
**File:** `src/components/layout/mobile/more-sheet.tsx:38-50`
|
||||
|
||||
`MORE_ITEMS` lists 11 entries. The dashboard route directory has at
|
||||
least these top-level segments not represented anywhere in the mobile
|
||||
bottom-tabs OR more-sheet:
|
||||
|
||||
- `residential` — exists at `/[portSlug]/residential/...`
|
||||
- `notifications` — exists at `/[portSlug]/notifications/...`
|
||||
- `berth-reservations` — exists at `/[portSlug]/berth-reservations/...`
|
||||
- `documents` — exists as a top-level page (separate from the bottom
|
||||
tab `documents`, which IS in mobile-bottom-tabs)
|
||||
- `website-analytics` — exists at `/[portSlug]/website-analytics/...`
|
||||
|
||||
A mobile-only user has no path to any of them. The Documents bottom
|
||||
tab does cover the doc list, but residential is an entire feature
|
||||
domain (per the `(dashboard)/.../residential` directory) with no
|
||||
mobile entry point.
|
||||
|
||||
**Impact:** anyone using the mobile chrome to triage on the go can't
|
||||
reach residential clients/interests, alerts (`alerts` IS in the
|
||||
sheet), or notifications.
|
||||
|
||||
**Fix:** add the missing segments to `MORE_ITEMS`. If the grid feels
|
||||
too dense, reorganize into sections.
|
||||
|
||||
**Scope:** small.
|
||||
|
||||
---
|
||||
|
||||
### V10. Portal has no "Profile" / "Change password" surface
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/components/portal/portal-nav.tsx:8-15` — six tabs, no profile.
|
||||
- Filesystem: no `src/app/(portal)/portal/profile/` directory.
|
||||
|
||||
A portal user who wants to change their email, phone, mailing address,
|
||||
or password has no UI. The portal sign-in flow goes through the
|
||||
better-auth session but the app exposes zero account-management
|
||||
controls. The "Need assistance?" card on the dashboard tells the user
|
||||
to contact the port team — which is the explicit answer for data
|
||||
edits, but does not cover password changes (a security expectation,
|
||||
not a per-port-staff burden).
|
||||
|
||||
**Impact:** every portal user who forgets their password (after
|
||||
already activating) has to use `/portal/forgot-password` even if they
|
||||
remember the old one. There's no proactive password rotation. A user
|
||||
who changes their phone number has to email the port to update it.
|
||||
|
||||
**Fix:** ship `/portal/profile` with at minimum: read-only PII view +
|
||||
"Change password" form (re-uses the existing reset-password endpoint
|
||||
or a new `change-password` endpoint that takes the current pw).
|
||||
Phone/address editing is a longer fix because of the audit-trail
|
||||
implications.
|
||||
|
||||
**Scope:** small for password; medium with PII edits.
|
||||
|
||||
---
|
||||
|
||||
### V11. Portal invoices page lists invoices but offers no view/download — even though documents do
|
||||
|
||||
**File:** `src/app/(portal)/portal/invoices/page.tsx:53-99`
|
||||
|
||||
Each invoice row shows number, status, due/paid dates, amount, and a
|
||||
small payment-status caption. There is no link, no PDF view, no
|
||||
download. By contrast, the portal Documents page (peer route) ends
|
||||
each row with a `<DocumentDownloadButton documentId={doc.id} />` that
|
||||
fetches a signed S3 URL.
|
||||
|
||||
Compare to admin/CRM where invoices have a full PDF render flow
|
||||
(invoice service generates the PDF + signed URL).
|
||||
|
||||
**Impact:** a portal user can see they owe money and cannot retrieve
|
||||
the actual invoice document. They have to email the port to ask for a
|
||||
PDF copy.
|
||||
|
||||
**Fix:** add an invoice-PDF endpoint under `/api/portal/invoices/[id]/
|
||||
download` mirroring the documents one, and a download button on each
|
||||
row. The invoice PDF generator already exists (`src/lib/services/
|
||||
invoices.ts`).
|
||||
|
||||
**Scope:** small.
|
||||
|
||||
---
|
||||
|
||||
## DEV-NOTES (legitimately staged-for-later, calling out so they're not forgotten)
|
||||
|
||||
### V12. Email-templates admin only edits subject lines — body editing is a documented "next iteration"
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/components/admin/email-templates-admin.tsx:78-79` —
|
||||
"Customize the subject line of transactional emails per port. Body
|
||||
editing is the next iteration; for now the layout and HTML stay
|
||||
locked to the default template."
|
||||
- `src/lib/email/template-catalog.ts:5-9` — same statement in the
|
||||
catalog header.
|
||||
|
||||
The page is honest about the limitation, so this isn't a "broken"
|
||||
finding. But it's a notable shipped-without-the-killer-feature gap:
|
||||
the multi-tenant promise of per-port email customization can't deliver
|
||||
the body changes that ports actually want (logo placement, signature,
|
||||
language). Combined with V2 (branding HTML fragments aren't read at
|
||||
all), there is currently NO way for a non-super-admin per-port admin
|
||||
to customize the email body in any way.
|
||||
|
||||
**Impact:** confined to admin expectations — most ports will assume
|
||||
"Email templates" = "edit the email", click in, see only a subject
|
||||
field, and request the missing body editor.
|
||||
|
||||
**Fix:** scope a body-editing flow that reuses the
|
||||
`merge_fields.ts` token catalog (the validator already exists for
|
||||
document templates) for safety. Until that's built, V2 + this finding
|
||||
together mean a "rebrand the emails" task is single-tenant only.
|
||||
|
||||
**Scope:** large (HTML editor + token validator + per-port override
|
||||
storage + render-side composition).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
12 findings, four severity tiers:
|
||||
|
||||
- **Visible-broken (V1-V5):** five admin/portal controls produce no
|
||||
effect. V1 (email overrides) and V2 (branding) are the highest
|
||||
impact — both silently break the multi-tenant promise.
|
||||
- **Half-wired (V6-V8):** three pages where the surrounding wrapper
|
||||
oversells what's there. V8 (inquiry inbox) is the largest scope.
|
||||
- **Mobile parity (V9-V11):** mobile users can't reach several real
|
||||
features; portal users have no profile/password surface and can't
|
||||
download invoices.
|
||||
- **Dev-notes (V12):** documented limitations called out for the
|
||||
roadmap.
|
||||
|
||||
The two highest-leverage quick wins are **V1** (wire 6 missing
|
||||
template subject overrides — a few hours) and **V11** (portal invoice
|
||||
download — small, fixes a real customer pain point).
|
||||
266
docs/audit-permissions-2026-05-06.md
Normal file
266
docs/audit-permissions-2026-05-06.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Per-role permission audit — 2026-05-06
|
||||
|
||||
Focused review of UI/server permission divergence on the new endpoints
|
||||
shipped during the smart-archive / hard-delete / bulk-wizard /
|
||||
external-EOI / webhook-replay work bundle. Skips items already covered
|
||||
in `docs/audit-comprehensive-2026-05-06.md` (audit-log gating H6,
|
||||
residential_partner sidebar nav).
|
||||
|
||||
The pattern hunted for: `<PermissionGate>` (or `usePermissions().can`)
|
||||
on the UI side hides a control under permission **X**, while the
|
||||
matching API route gates on permission **Y** (or doesn't gate at all,
|
||||
or gates strictly — producing 403 toast spam for users who can see the
|
||||
button but can't use it).
|
||||
|
||||
Scope: 8 routes + 5 components + the seed permission matrix. Hard cap
|
||||
of 10 findings, ranked by impact. Critical/High/Medium/Low.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
_None._ The four new hard-delete endpoints all gate on
|
||||
`admin.permanently_delete_clients` on both layers (UI hides the button
|
||||
via `<PermissionGate resource="admin" action="permanently_delete_clients">`
|
||||
in `client-detail-header.tsx:162` and via `canHardDelete = can('admin',
|
||||
'permanently_delete_clients')` in `client-list.tsx:53`; the four routes
|
||||
all wrap with `withPermission('admin', 'permanently_delete_clients', …)`).
|
||||
The webhook-replay route gates on `admin.manage_webhooks` — see H1 below
|
||||
for the matching UI gap.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1. Webhook replay button has no UI permission gate (403 toast for non-admins)
|
||||
|
||||
- **UI:** `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
|
||||
— the Replay `<Button>` renders for any user who can load the page,
|
||||
with no `<PermissionGate>` wrapper and no `usePermissions().can('admin',
|
||||
'manage_webhooks')` check.
|
||||
- **Server:** `src/app/api/v1/admin/webhooks/[webhookId]/deliveries/[deliveryId]/redeliver/route.ts:15`
|
||||
— `withPermission('admin', 'manage_webhooks', …)`.
|
||||
|
||||
**Divergence:** A `sales_manager` / `sales_agent` / `viewer` who
|
||||
somehow lands on `/admin/webhooks/{id}` (e.g. via a deep link from a
|
||||
shared message) sees enabled Replay buttons. Clicking surfaces a
|
||||
generic 403 toast — the user has no signal that the action is
|
||||
restricted, just that "Replay failed".
|
||||
|
||||
**Fix:** wrap the Replay `<Button>` in
|
||||
`<PermissionGate resource="admin" action="manage_webhooks">…</PermissionGate>`,
|
||||
or skip rendering the entire "Replay" column when
|
||||
`!can('admin', 'manage_webhooks')`. The page-level guard on
|
||||
`/admin/webhooks` should prevent non-admins from reaching the route in
|
||||
the first place, but defense-in-depth is cheap and the toast UX is
|
||||
poor.
|
||||
|
||||
---
|
||||
|
||||
### H2. Bulk-archive bulk action exposed to roles without `clients.delete`
|
||||
|
||||
- **UI:** `src/components/clients/client-list.tsx:182-190` — the
|
||||
"Archive" entry in `bulkActions` is unconditionally rendered (only
|
||||
the "Permanently delete" entry checks `canHardDelete`).
|
||||
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — gates
|
||||
`archive` action on `clients.delete`. Also
|
||||
`src/app/api/v1/clients/bulk-archive-preflight/route.ts:30` —
|
||||
`withPermission('clients', 'delete', …)`.
|
||||
|
||||
**Divergence:** `sales_agent` (`clients.delete:false`,
|
||||
seed-permissions.ts:246) and `viewer` (`clients.delete:false`,
|
||||
seed-permissions.ts:323) both see the Archive bulk action. Selecting
|
||||
clients and pressing it fires the `BulkArchiveWizard`, which calls
|
||||
`bulk-archive-preflight` (returns 403) followed by `bulk` archive
|
||||
(also 403). The wizard surfaces this as an opaque error.
|
||||
|
||||
**Fix:** mirror the `canHardDelete` pattern — compute
|
||||
`const canBulkArchive = can('clients', 'delete');` near
|
||||
`client-list.tsx:53` and conditionally include the Archive entry.
|
||||
|
||||
---
|
||||
|
||||
### H3. Bulk add_tag / remove_tag exposed to viewer (clients.edit:false)
|
||||
|
||||
- **UI:** `src/components/clients/client-list.tsx:165-181` — the "Add
|
||||
tag" / "Remove tag" bulk actions render with no permission check.
|
||||
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — both gate
|
||||
on `clients.edit`.
|
||||
|
||||
**Divergence:** A `viewer` can multi-select rows, click "Add tag" or
|
||||
"Remove tag", pick a tag in the dialog, hit "Apply", and receive a 403. The standalone bulk tag dialog has no inline gating to prevent
|
||||
this.
|
||||
|
||||
**Fix:** the bulk action menu entries should gate on
|
||||
`can('clients', 'edit')`. (Sales agent and above pass; only `viewer`
|
||||
and `residential_partner` see the bug.)
|
||||
|
||||
---
|
||||
|
||||
### H4. `client-merge-log.surviving_client_id` enforcement absent from per-row port check on bulk hard-delete
|
||||
|
||||
- **Server:** `src/lib/services/client-hard-delete.service.ts:269-272`
|
||||
|
||||
The bulk preflight loads **every** row in the port
|
||||
(`db.select(...).from(clients).where(eq(clients.portId, args.portId))`)
|
||||
into memory, then validates the requested `clientIds` against that map.
|
||||
That's correct for tenant isolation — a foreign-port id can't appear in
|
||||
the map — but the inner loop at lines 364-389 then re-fetches each
|
||||
client by `(id, portId)` and **silently skips** rows where the second
|
||||
fetch returns nothing (line 377: `if (!c) continue;`). If a client is
|
||||
archived between preflight and execute by another operator, the bulk
|
||||
delete reports `deletedCount` lower than the requested set with no
|
||||
error — the operator has no way to tell which ids were skipped.
|
||||
|
||||
**Divergence (perm-adjacent):** the per-row gate is enforced for
|
||||
tenancy but the failure mode masquerades as success. Combined with
|
||||
the route's all-or-nothing `withPermission` at the top, a
|
||||
`permanently_delete_clients`-bearing operator can quietly under-delete.
|
||||
|
||||
**Fix:** when `c` is null, push the id into a `skipped: string[]`
|
||||
array and return it in the response so the UI can surface "3
|
||||
deleted, 1 skipped (not archived / removed by another user)".
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1. `external-eoi` upload allows any role with `documents.upload_signed` regardless of `interests.edit`
|
||||
|
||||
- **UI:** `src/components/interests/interest-detail-header.tsx:382-395`
|
||||
— `<PermissionGate resource="documents" action="upload_signed">`.
|
||||
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8`
|
||||
— `withPermission('documents', 'upload_signed', …)`.
|
||||
|
||||
**Divergence:** UI and server agree on the permission, but the seed
|
||||
matrix has `documents.upload_signed:true` for `sales_agent` (line 264) AND any custom role with that flag — uploading an externally
|
||||
signed EOI mutates the **interest** (it's the operative `signedDocument`
|
||||
that flips the interest into a "signed" state inside
|
||||
`uploadExternallySignedEoi`). The user only needs `documents.upload_signed`,
|
||||
not `interests.edit`. A custom role with `documents.upload_signed:true`
|
||||
|
||||
- `interests.edit:false` can mutate the interest's effective state.
|
||||
|
||||
**Fix:** add a second gate inside the route handler:
|
||||
`if (!ctx.isSuperAdmin && !ctx.permissions?.interests?.edit) throw new ForbiddenError(...)`.
|
||||
Rationale: signing a doc against an interest is an interest-state
|
||||
change, not just a document upload. Mirror the same check in
|
||||
`<PermissionGate>` (use `<PermissionGate resource="interests" action="edit">`
|
||||
nested inside the `documents.upload_signed` gate).
|
||||
|
||||
---
|
||||
|
||||
### M2. `change_stage` UI doesn't expose override checkbox in `InlineStagePicker` — server still accepts override
|
||||
|
||||
- **UI:** `src/components/interests/inline-stage-picker.tsx:52-58` —
|
||||
the inline picker (used in the detail header at
|
||||
`interest-detail-header.tsx:221`) sends only
|
||||
`{ pipelineStage, reason }` and never sets `override:true`. Users
|
||||
with `override_stage` get no UI affordance to actually use the
|
||||
permission from the inline picker; they have to open the modal
|
||||
`InterestStagePicker` (which does expose the checkbox at line 137).
|
||||
Worse, when a user picks a stage that isn't a legal forward
|
||||
transition, the inline picker just shows the toast from the server's
|
||||
`ConflictError` — instead of "you need override; toggle this box".
|
||||
- **Server:** `src/app/api/v1/interests/[id]/stage/route.ts:14-22` —
|
||||
reads `body.override` and re-checks `interests.override_stage`
|
||||
permission.
|
||||
|
||||
**Divergence:** UI and permission map diverge in the affordance, not
|
||||
the gate. End-result: the `override_stage` permission is partially
|
||||
unreachable from the inline picker. Sales managers / agents can
|
||||
override only via the modal picker.
|
||||
|
||||
**Fix:** when the inline picker sees a transition that isn't allowed
|
||||
by `canTransitionStage(currentStage, newStage)`, check
|
||||
`can('interests', 'override_stage')` and either auto-set
|
||||
`override:true` (with a confirmation) or surface a "Use override"
|
||||
secondary action. Keep the inline picker UX; just don't let the
|
||||
override permission be silently inaccessible from the most-used
|
||||
path.
|
||||
|
||||
---
|
||||
|
||||
### M3. `sales_agent` granted `interests.override_stage:true` — possible copy-paste from sales_manager
|
||||
|
||||
- **Seed:** `src/lib/db/seed-permissions.ts:253` — `SALES_AGENT_PERMISSIONS.interests.override_stage = true`.
|
||||
|
||||
This is identical to `SALES_MANAGER_PERMISSIONS.interests.override_stage = true`
|
||||
at line 176. The same `sales_agent` block has `delete:false` for
|
||||
clients/interests/yachts/companies/files/etc — all the other
|
||||
"trust-elevated" flags are explicitly stripped from sales_agent. The
|
||||
ability to bypass the pipeline-stage transition table is a meaningful
|
||||
trust elevation: it lets an agent skip prerequisites (e.g. mark an
|
||||
interest as `eoi_signed` without an actual signed doc) which has
|
||||
downstream implications for the public berths feed (`Under Offer`
|
||||
status), the recommender's tier ladder, and the EOI bundle.
|
||||
|
||||
**Divergence:** likely intent vs. permission map. Worth confirming
|
||||
with a product owner; if intentional, leave a code comment. If
|
||||
unintentional, flip to `false`.
|
||||
|
||||
**Fix:** product decision. If demoted, also update
|
||||
`src/components/admin/roles/role-form.tsx → DEFAULT_PERMISSIONS`
|
||||
(noted in the file header at seed-permissions.ts:9) so the UI
|
||||
default for new roles matches.
|
||||
|
||||
---
|
||||
|
||||
### M4. `bulk-archive-preflight` returns dossier even when client is in another port (defense-in-depth)
|
||||
|
||||
- **Server:** `src/app/api/v1/clients/bulk-archive-preflight/route.ts:33-62`
|
||||
|
||||
The route loops through `ids` and calls `getClientArchiveDossier(id, ctx.portId)`
|
||||
for each. If a `clientId` belongs to another port, `getClientArchiveDossier`
|
||||
throws and the route catches it (line 52-61) and returns a fallback row
|
||||
with `blockers: ['<error message>']`. This leaks **the existence of an
|
||||
unknown client id** — an attacker enumerating UUIDs can distinguish
|
||||
"client doesn't exist" from "client exists but you can't see it" by
|
||||
parsing the blocker text. The bulk hard-delete route has the same
|
||||
shape but returns `NotFoundError`.
|
||||
|
||||
**Divergence (perm-adjacent):** the preflight route doesn't enforce a
|
||||
per-id port check before falling through to the dossier service, and
|
||||
the catch block leaks the failure mode in the response.
|
||||
|
||||
**Fix:** in the catch block, replace the dossier error message with a
|
||||
generic `'Could not load dossier'` blocker. The operator already
|
||||
selected these ids so they know the count; they don't need the inner
|
||||
error.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### L1. `external-eoi` route doesn't enforce `interests.edit` defense-in-depth on the interest port
|
||||
|
||||
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8-14`
|
||||
|
||||
The route receives `interestId` from the URL and passes it +
|
||||
`ctx.portId` into `uploadExternallySignedEoi`. The service is
|
||||
expected to enforce port isolation, but the route itself does no
|
||||
upfront `(interestId, portId)` existence check before reading the
|
||||
multipart body — meaning a cross-port id will fully process the
|
||||
upload (read the file into memory) before the service rejects.
|
||||
|
||||
**Divergence:** not strictly a permission divergence; it's resource
|
||||
waste from missing early port-ownership check. Low because the
|
||||
service-level reject does close the security hole.
|
||||
|
||||
**Fix:** add a one-row `select` on `interests` matching `id` + `portId`
|
||||
before parsing form data, throw `NotFoundError` on miss.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- 0 critical
|
||||
- 4 high (H1–H4)
|
||||
- 4 medium (M1–M4)
|
||||
- 1 low (L1)
|
||||
|
||||
Top recommendation: H1 (webhook-replay UI gate) is a
|
||||
ten-line fix that closes a 403-toast UX bug. H2 + H3 (bulk-archive +
|
||||
bulk-tag UI gates) are also trivial and remove the same class of bug
|
||||
across the bulk actions menu. M3 (sales_agent override_stage) needs a
|
||||
product decision, not code; flag it before shipping the audit.
|
||||
220
docs/audit-reliability-2026-05-06.md
Normal file
220
docs/audit-reliability-2026-05-06.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Reliability audit — 2026-05-06 (focused, post-batch deltas)
|
||||
|
||||
Scope: NEW services from the recent archive/restore/hard-delete/external-EOI batches.
|
||||
Out of scope (already covered in `docs/audit-comprehensive-2026-05-06.md`):
|
||||
worker imports, rate limits, hard-delete error message UX, smart-restore
|
||||
dead reversal applier, bulk hard-delete redis loop, audit log spam.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1. Bulk archive enqueues zero post-commit side effects
|
||||
|
||||
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-134`
|
||||
- **Scenario:** When the bulk wizard archives 100 clients with high-stakes
|
||||
reasons, `archiveClientWithDecisions` returns `externalCleanups` and
|
||||
`releasedBerths` arrays per-client, but `runBulk` discards the return
|
||||
value. Documenso envelopes that the wizard marked `void_documenso`
|
||||
never get queued, and "next-in-line" notifications never fire. The
|
||||
database is left in `documents.status='cancelled'` with the live
|
||||
Documenso envelope still out for signature — the signer can complete
|
||||
a legally-binding envelope that the CRM thinks is voided.
|
||||
- **Fix:** Make the per-row callback return the result, then loop over
|
||||
`results` after `runBulk` to enqueue Documenso voids and fire
|
||||
next-in-line notifications (mirroring the single-client route).
|
||||
Defaulting `documentDecisions` to `'leave'` (line 113-116) hides the
|
||||
symptom for the bulk wizard but isn't enough — the single-client
|
||||
service can still surface this if the bulk path is ever generalized.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H1. Restore wizard silently drops every released berth
|
||||
|
||||
- **File:** `src/lib/services/client-restore.service.ts:359-372`
|
||||
- **Scenario:** `applyReversal` for `berth_released` is a no-op with a
|
||||
comment saying "v1 leaves the berth available". But the dossier (line
|
||||
122-129) classifies these as `autoReversible` and the UI tells the
|
||||
operator "still available — re-attaching to the restored client". The
|
||||
wizard increments `autoReversed` and the audit log records a
|
||||
successful auto-reverse — but nothing actually happens. Operator
|
||||
thinks restore re-linked their berth; it didn't.
|
||||
- **Fix:** Either (a) actually re-link by persisting the original
|
||||
`interestId` in the `berth_released` decision detail (it's already
|
||||
there, line 211) and re-inserting an `interestBerths` row + flipping
|
||||
the berth status back to `under_offer`, or (b) reclassify these as
|
||||
`reversibleWithPrompt` with copy that says "berth left available —
|
||||
re-add via the interest detail page".
|
||||
|
||||
### H2. Smart-archive berth status update has TOCTOU race
|
||||
|
||||
- **File:** `src/lib/services/client-archive.service.ts:191-207`
|
||||
- **Scenario:** Berth row is read via `dossier.berths` (read outside the
|
||||
tx) and modified inside the tx without a `for update` lock on
|
||||
`berths`. Two concurrent flows — e.g. operator A archives client X
|
||||
while operator B sells berth A1 to client Y — can race: A reads
|
||||
`berth.status === 'sold' → false`, B's tx commits sold, A's tx then
|
||||
flips it back to `available`. The "still under offer" subselect
|
||||
doesn't catch this because berth.status is the source of truth, not
|
||||
interest_berths.
|
||||
- **Fix:** Add `tx.select(...).from(berths).where(eq(berths.id, d.berthId)).for('update')`
|
||||
before the status flip and re-check `status !== 'sold'` against the
|
||||
locked row.
|
||||
|
||||
### H3. Bulk archive can pick the wrong interest for berth release
|
||||
|
||||
- **File:** `src/app/api/v1/clients/bulk/route.ts:95-103`
|
||||
- **Scenario:** When a client has multiple interests linked to the same
|
||||
berth, the bulk wizard picks `dossier.interests.find((i) =>
|
||||
i.primaryBerthMooring === b.mooringNumber)` and falls back to
|
||||
`dossier.interests[0]?.interestId ?? ''`. The fallback to the
|
||||
first-interest-or-empty-string can hand `archiveClientWithDecisions`
|
||||
an `interestId` that was never linked to that berth — so the
|
||||
`delete from interest_berths where berthId=… and interestId=…`
|
||||
matches zero rows and the link is silently retained. Worse: an empty
|
||||
string `''` reaches the delete, which still matches zero rows but
|
||||
leaves the berth status check believing the link was removed.
|
||||
- **Fix:** Build the berth→interest map from `interestBerthRows` (the
|
||||
authoritative join) rather than guessing by `primaryBerthMooring`,
|
||||
and skip berths with no resolvable interest rather than emitting an
|
||||
empty-string interestId.
|
||||
|
||||
### H4. External EOI runs four writes outside a transaction
|
||||
|
||||
- **File:** `src/lib/services/external-eoi.service.ts:67-155`
|
||||
- **Scenario:** `getStorageBackend().put()`, `files.insert`,
|
||||
`documents.insert`, `documentEvents.insert`, and the interests
|
||||
update happen as five independent operations. If any one fails after
|
||||
the storage upload, you're left with an orphan PDF in S3/MinIO and
|
||||
partial DB state. If the documents insert fails after the file
|
||||
insert, the file row points to a storage key with no document
|
||||
referencing it — and the interest never advances.
|
||||
- **Fix:** Wrap files/documents/documentEvents/interests in a single
|
||||
`db.transaction`. Storage upload stays outside (S3 isn't
|
||||
transactional) but on tx failure, schedule a cleanup job that deletes
|
||||
the orphan storage object, or accept the orphan and add a janitor.
|
||||
|
||||
### H5. Bulk wizard double-submit re-archives the same client and racy errors
|
||||
|
||||
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-120` +
|
||||
`src/lib/services/client-archive.service.ts:165-173`
|
||||
- **Scenario:** The single-client `archiveClientWithDecisions` locks
|
||||
the row and throws `ConflictError('Client is already archived')` on
|
||||
re-entry — good. But `runBulk` swallows the error string and returns
|
||||
it as `{ok:false, error:"Client is already archived"}` for that
|
||||
client. If the bulk wizard double-submits (network retry, double
|
||||
click), partial successes from the first request now look like
|
||||
per-client failures in the response, confusing the operator. There's
|
||||
no idempotency key on the bulk submit.
|
||||
- **Fix:** Treat `ConflictError('already archived')` as success in the
|
||||
bulk per-row handler (the desired end state is reached). Or add an
|
||||
idempotency-key header on the bulk endpoint that short-circuits a
|
||||
duplicate request with the cached response.
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M1. Hard-delete `clientMergeLog.surviving_client_id` deletes audit history
|
||||
|
||||
- **File:** `src/lib/services/client-hard-delete.service.ts:209`
|
||||
- **Scenario:** The comment says "merged records remain in the log
|
||||
because mergedClientId has no FK", but the delete is wider than
|
||||
needed: it removes every merge-log row where this client was the
|
||||
survivor. If client X (being deleted) previously absorbed clients
|
||||
A/B/C, the audit trail of those merges is lost on X's deletion. The
|
||||
surviving rows that remain (`mergedClientId = X`) are now
|
||||
inconsistent — they reference a survivor that no longer exists.
|
||||
- **Fix:** Either preserve the survivor rows by setting
|
||||
`surviving_client_id = NULL` (requires column nullable) or keep the
|
||||
current behavior but document it more visibly. At minimum, log the
|
||||
deleted merge-log row count so operators can investigate gaps.
|
||||
|
||||
### M2. Documenso void worker has no max-retry guard for non-404 errors
|
||||
|
||||
- **File:** `src/lib/queue/workers/documents.ts:19-37`
|
||||
- **Scenario:** `voidDocument` throws `CodedError` on non-404 failures
|
||||
(auth error, network blip, Documenso 500). BullMQ retries with
|
||||
backoff, but there's no per-job idempotency check — the second
|
||||
retry hits the same envelope, voidDocument's 404 short-circuit only
|
||||
kicks in if Documenso has actually voided it on the first retry
|
||||
before the API call returned an error. A persistent 401 / 403 will
|
||||
retry forever (until BullMQ exhausts attempts) and the documents row
|
||||
stays `cancelled` in the CRM with the envelope still live in
|
||||
Documenso. The DLQ is mentioned in the comment but the worker
|
||||
doesn't surface a DLQ alert hook.
|
||||
- **Fix:** On exhaustion, write back to `documents` (e.g.
|
||||
`cancellation_failed=true`) and emit an admin notification so the
|
||||
envelope can be voided manually.
|
||||
|
||||
### M3. Next-in-line notification fan-out unhandled rejection
|
||||
|
||||
- **File:** `src/lib/services/next-in-line-notify.service.ts:75-87`
|
||||
- **Scenario:** Each `void createNotification(...)` is a fire-and-forget
|
||||
promise with no `.catch` handler. If `notifications.service`
|
||||
dispatches to a DB that's transiently down, the unhandled rejection
|
||||
will surface in the Node process with no recipient context (the
|
||||
closure captured `userId` is in the stack but pino won't include it
|
||||
unless explicitly logged). Process-level handlers will log it but
|
||||
individual recipients silently lose their notification.
|
||||
- **Fix:** `.catch((err) => logger.warn({err, userId, berthId:
|
||||
input.berthId}, 'next-in-line notification failed'))`.
|
||||
|
||||
### M4. Restore service uses `any` for transaction type
|
||||
|
||||
- **File:** `src/lib/services/client-restore.service.ts:354-355`
|
||||
- **Scenario:** `applyReversal(tx: any, ...)` defeats Drizzle's type
|
||||
safety. A future schema rename (e.g. `yachts.status` enum change)
|
||||
won't fail at compile time inside this function. Combined with the
|
||||
documented v1 no-op for `berth_released`, the function looks
|
||||
innocuous but carries the most risk.
|
||||
- **Fix:** Use the proper Drizzle tx type — `Parameters<Parameters<typeof
|
||||
db.transaction>[0]>[0]` or a named type alias from
|
||||
`@/lib/db/types.ts` if one exists.
|
||||
|
||||
### M5. interests.changeInterestStage milestones write outside tx
|
||||
|
||||
- **File:** `src/lib/services/interests.service.ts:630-648`
|
||||
- **Scenario:** The override path (and normal path) writes
|
||||
`pipelineStage` in one update and milestone fields
|
||||
(`dateEoiSent`, `dateContractSigned`, etc.) in a second update. If
|
||||
the process crashes between the two, the stage advances but the
|
||||
milestone is never recorded. Funnel/conversion math then under-
|
||||
counts that interest. Over-the-wire this is rare but the audit log
|
||||
fires before the milestone update succeeds, so the audit trail
|
||||
claims a complete transition that's actually half-applied.
|
||||
- **Fix:** Combine both into a single update statement, computing the
|
||||
milestone fields in JS and merging them into the `set({...})` clause.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L1. Smart-archive coalesces invoice notes via SQL string concat
|
||||
|
||||
- **File:** `src/lib/services/client-archive.service.ts:288-291`
|
||||
- **Scenario:** `notes: sql\`coalesce(${invoices.notes}, '') || ${...}\``embeds`new Date().toISOString()`and the action label inside a
|
||||
parameterized string. The values are bound, so it's not an injection
|
||||
risk, but the`\n[archive ...]` marker is appended unconditionally —
|
||||
re-running the archive on a not-yet-committed client would double
|
||||
the marker. Combined with H5 (no idempotency on bulk), a retry could
|
||||
bloat invoice notes with duplicate markers.
|
||||
- **Fix:** Append only when the marker isn't already present, or rely
|
||||
on the `clients.archivedAt is null` precheck (which already guards
|
||||
re-entry) and accept the duplicate as theoretically impossible.
|
||||
|
||||
### L2. Hard-delete `requestHardDeleteCode` reveals client existence pre-archive
|
||||
|
||||
- **File:** `src/lib/services/client-hard-delete.service.ts:77-85`
|
||||
- **Scenario:** A user without `admin.permanently_delete_clients`
|
||||
shouldn't reach this service, so this is theoretical, but the
|
||||
ConflictError "Client must be archived" leaks the existence of an
|
||||
unarchived client to anyone who can reach the route. The audit doc
|
||||
flagged hard-delete error messages already (out of scope), but this
|
||||
specific error path isn't covered there.
|
||||
- **Fix:** Same as the audit-doc finding for the symmetric path —
|
||||
return a generic `NotFoundError` instead of distinguishing
|
||||
"not found" from "not archived" externally; log the distinction
|
||||
internally only.
|
||||
147
docs/berth-feature-handoff-prompt.md
Normal file
147
docs/berth-feature-handoff-prompt.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Handoff prompt for new Claude Code session
|
||||
|
||||
Copy everything below the `---` line into the new chat as your first message.
|
||||
|
||||
---
|
||||
|
||||
I'm continuing work on a comprehensive multi-feature push that was fully designed in a prior session but not yet implemented. The complete plan lives at `docs/berth-recommender-and-pdf-plan.md` (~1030 lines). **Read that file end-to-end before doing anything else — every design decision, schema change, edge case, and confirmed answer to a product question is captured there.** Don't re-litigate decisions; if something seems unclear, the answer is almost certainly in the plan.
|
||||
|
||||
## What the project is
|
||||
|
||||
A multi-tenant marina/port-management CRM at `/Users/matt/Repos/new-pn-crm`. Next.js 15 App Router, React 19, TypeScript strict, Drizzle ORM on Postgres, MinIO for files, BullMQ on Redis, better-auth, shadcn/ui, Tailwind. See `CLAUDE.md` for the conventions.
|
||||
|
||||
## What we're building (high level)
|
||||
|
||||
The plan bundles 8 capabilities into one branch (`feat/berth-recommender`):
|
||||
|
||||
1. **/clients + /interests list-column fix** (the original bug — list views show `-` everywhere because the service didn't join contacts/yachts)
|
||||
2. **Full NocoDB Berths import** + seeding + mooring-number normalization (current CRM has `A-01..E-18`; canonical is `A1..E18`)
|
||||
3. **Schema refactor** to many-to-many `interest_berths` with role flags (`is_primary`, `is_specific_interest`, `is_in_eoi_bundle`)
|
||||
4. **Berth recommender** (SQL ranking, tier ladder, heat scoring, UI panel) — no AI; pure SQL
|
||||
5. **EOI bundle** support (multi-berth EOIs + range formatter for the Documenso PDF: `["A1","A2","A3","B5","B6"]` → `"A1-A3, B5-B6"`)
|
||||
6. **Pluggable storage backend** (s3-compatible OR local filesystem) so admins can run without MinIO if they want
|
||||
7. **Per-berth PDFs** (versioned uploads, OCR-based reverse parser, conflict-resolution diff dialog)
|
||||
8. **Sales send-out emails** (berth PDF + brochure) with full audit + size-aware fallback to download links
|
||||
|
||||
## Phase ordering (from plan §2)
|
||||
|
||||
```
|
||||
Phase 0: Full NocoDB berth import + mooring normalization + 5 new pricing columns
|
||||
Phase 1: /clients + /interests list column fix
|
||||
Phase 2: M:M interest_berths schema refactor + desired dimensions on interests
|
||||
Phase 3: CRM /api/public/berths endpoint + website cutover
|
||||
Phase 4: Recommender SQL + tier ladder + heat + UI panel
|
||||
Phase 5: EOI bundle + range formatter
|
||||
Phase 6a: Pluggable storage backend + migration CLI + admin UI
|
||||
Phase 6b: Per-berth PDF storage (versioned) + reverse parser
|
||||
Phase 7: Sales send-outs + brochure admin + email-from settings
|
||||
Phase 8: CLAUDE.md updates + final validation
|
||||
```
|
||||
|
||||
**Start with Phase 0**.
|
||||
|
||||
## Working tree state at handoff
|
||||
|
||||
- Branch: `main` (you'll create `feat/berth-recommender` from here)
|
||||
- Recent commits (already pushed):
|
||||
- `8699f81 chore(style): codebase em-dash sweep + minor layout polish`
|
||||
- `d62822c fix(migration): NocoDB import safety + dedup helpers + lead-source backfill`
|
||||
- `089f4a6 feat(receipts): upload guide page + scanner head-tag fix`
|
||||
- `77ad10c feat(dashboard): custom date range + KPI port-hydration gate`
|
||||
- `e598cc0 feat(layout): unified Inbox + UserMenu extraction`
|
||||
- `f5772ce feat(analytics): Umami integration with per-port admin settings`
|
||||
- `49d34e0 feat(website-intake): dual-write endpoint + migration chain repair`
|
||||
- Untracked / uncommitted at handoff:
|
||||
- `docs/berth-recommender-and-pdf-plan.md` (the plan — read this first)
|
||||
- `docs/berth-feature-handoff-prompt.md` (this file)
|
||||
- `berth_pdf_example/` (two reference files — see below)
|
||||
- `.env.example` (modified — adds `WEBSITE_INTAKE_SECRET=`; pre-commit hook blocks `.env*` files so user adds this manually)
|
||||
- Dev DB state:
|
||||
- 245 clients (210 with no `nationality_iso` — Phase 1 backfills from primary phone's `value_country`)
|
||||
- 4 test rows in `website_submissions` (from a previous live audit; safe to ignore)
|
||||
- 90 berths with `mooring_number` in `A-01` format (Phase 0 normalizes to `A1`)
|
||||
- vitest: 956 tests passing
|
||||
- tsc: clean (one pre-existing issue in `scripts/smoke-test-redirect.ts` that's unrelated)
|
||||
|
||||
## Reference files
|
||||
|
||||
- `berth_pdf_example/Berth_Spec_Sheet_A1.pdf` (358 KB) — sample per-berth PDF. **0 AcroForm fields** (confirmed via pdf-lib) so OCR with positional heuristics is the primary parser tier; the AcroForm tier is built defensively. Plan §9.2 captures the layout structure.
|
||||
- `berth_pdf_example/Port-Nimara-Brochure-March-2025_5nT92g.pdf` (10.26 MB) — sample brochure. Sized so it ships as an attachment under the 15 MB threshold. Plan §11.1 covers brochure handling.
|
||||
|
||||
## NocoDB access
|
||||
|
||||
You have `mcp__NocoDB_Base_-_Port_Nimara__*` tools available. Tables you'll touch most:
|
||||
|
||||
- `mczgos9hr3oa9qc` — Berths (Phase 0 imports from here; mooring numbers are stored as `A1..E18`)
|
||||
- `mbs9hjauug4eseo` — Interests (the combined client+deal table the old system used)
|
||||
|
||||
## Branch & commit conventions
|
||||
|
||||
- Create the branch: `git checkout -b feat/berth-recommender`
|
||||
- Commit messages match recent history style: `<type>(<scope>): <subject>` lowercase, terse subject, body explains why not what.
|
||||
- **Pre-commit hook blocks any `.env*` file** including `.env.example`. If you need to update `.env.example`, leave it staged and tell the user to commit manually with `--no-verify` (they're aware of this).
|
||||
- **Don't push without explicit user permission.** Commits are fine; pushes need approval.
|
||||
- **Don't run `git rebase`, `git push --force`, or anything destructive without checking.** The branch is solo-owned but the repo's `main` is shared.
|
||||
|
||||
## User communication preferences (from prior session)
|
||||
|
||||
- Direct, no fluff. If something is a bad idea, say so — don't sycophant.
|
||||
- When proposing changes, include trade-offs explicitly.
|
||||
- For multi-question decisions, use `AskUserQuestion` rather than long bulleted lists.
|
||||
- Run validation (vitest + tsc) at logical checkpoints. Don't ship a commit with regressions.
|
||||
- The user prefers small focused commits over mega-commits. Within Phase 0 alone there will probably be 2-3 commits (e.g. mooring normalization, schema additions, NocoDB import script).
|
||||
|
||||
## Critical rules (from plan §14)
|
||||
|
||||
Eleven 🔴 critical items requiring tests before their phase ships:
|
||||
|
||||
1. NocoDB mooring collisions → unique constraint + ON CONFLICT
|
||||
2. Non-PDF disguised upload → magic-byte check
|
||||
3. Recipient email typos → pre-send confirmation
|
||||
4. XSS in email body markdown → DOMPurify + payload tests
|
||||
5. SMTP credentials silently failing → loud error + failed `document_sends` row
|
||||
6. Wrong-environment `CRM_PUBLIC_URL` → health-check env match
|
||||
7. Mooring format drift breaking `/berths/A1` URLs → Phase 0 normalization gates Phase 3
|
||||
8. Multi-port isolation in recommender → explicit `port_id` filter + cross-port test
|
||||
9. Permission escalation on SMTP creds → per-port admin only, no rep visibility
|
||||
10. Filesystem backend in multi-node deployment → refuse to start; documented + health-check enforced
|
||||
11. Path traversal via storage key in filesystem mode → strict regex validation + path realpath check
|
||||
|
||||
## Pending items (from plan §9)
|
||||
|
||||
These are non-blocking but worth knowing:
|
||||
|
||||
- Sample brochure already provided (the 10.26 MB file above).
|
||||
- SMTP app password for `sales@portnimara.com` — not yet obtained; expected close to production cutover. Phase 7 ships the admin UI immediately and the credential gets entered when available.
|
||||
- `CRM_PUBLIC_URL` confirmed as `https://crm.portnimara.com` once live; configurable via env.
|
||||
- GDPR cascade behavior for `document_sends` (delete vs. anonymize-PII vs. keep) — left `OPEN` in §14.10, default lean: anonymize-PII. Revisit when Phase 7 schema lands.
|
||||
|
||||
## Scope reminder
|
||||
|
||||
- **No prod data depends on the current CRM schema** — refactors don't need backwards-compatibility shims. But every schema change still ships as a Drizzle migration with `pnpm db:generate`.
|
||||
- **Pluggable storage** rejects Postgres `bytea` as an option (§4.7a). The two backends are s3-compatible (MinIO/AWS/B2/R2/etc.) and local filesystem. Filesystem is single-node only.
|
||||
|
||||
## What to do first
|
||||
|
||||
1. Read `docs/berth-recommender-and-pdf-plan.md` end-to-end. Don't skim. The edge-case audit in §14 alone is critical context.
|
||||
2. Confirm you've understood the plan by stating back the 8-phase outline and the 11 critical items, then ask the user if they want to proceed with Phase 0.
|
||||
3. Once approved, create `feat/berth-recommender` and start Phase 0.
|
||||
|
||||
Phase 0 deliverables (per plan):
|
||||
|
||||
- One commit normalizing existing CRM mooring numbers from `A-01` → `A1` form (via `regexp_replace` migration). Delete the offending `scripts/load-berths-to-port-nimara.ts`.
|
||||
- One commit adding the 5 new berth columns (`weekly_rate_high_usd`, `weekly_rate_low_usd`, `daily_rate_high_usd`, `daily_rate_low_usd`, `pricing_valid_until`, `last_imported_at`). Run `pnpm db:generate`. Verify `meta/_journal.json` prevId chain stays contiguous.
|
||||
- One commit adding `scripts/import-berths-from-nocodb.ts` — the idempotent NocoDB import (handles updates, preserves CRM-side edits via `last_imported_at vs updated_at` check, `pg_advisory_lock`, dry-run flag, etc. per §4.1 and §14.1).
|
||||
- Update `src/lib/db/seed-data.ts` with the imported berth set so fresh installs get them.
|
||||
- Final vitest + tsc validation at the end of Phase 0.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't push to remote during this session (user will batch the push later).
|
||||
- Don't commit `.env*` files (hook blocks them anyway).
|
||||
- Don't edit `.gitignore` to exclude generated artifacts; the repo's existing ignores are correct.
|
||||
- Don't add documentation files unless the plan asks for them — the plan itself is the doc.
|
||||
- Don't add features not in the plan. If something seems missing, ask.
|
||||
- Don't use AI for the recommender (plan §1 + §13). Pure SQL ranking.
|
||||
|
||||
Once you've read the plan and confirmed understanding, ask me whether to proceed with Phase 0.
|
||||
1086
docs/berth-recommender-and-pdf-plan.md
Normal file
1086
docs/berth-recommender-and-pdf-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
722
docs/documenso-build-plan.md
Normal file
722
docs/documenso-build-plan.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# Documenso signing-flow build plan
|
||||
|
||||
Captures every Documenso-related piece that isn't shipped yet, in attack order. A fresh session should be able to pick this up without re-reading the whole conversation.
|
||||
|
||||
**Companion docs:**
|
||||
|
||||
- [docs/documenso-integration-audit.md](./documenso-integration-audit.md) — what's already built, v1/v2 endpoint mapping, nginx CORS block
|
||||
- Old system reference: [client-portal/server/api/eoi/generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [client-portal/server/api/webhooks/documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [client-portal/server/services/documenso-notifications.ts](../client-portal/server/services/documenso-notifications.ts), [Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)
|
||||
|
||||
---
|
||||
|
||||
## Locked design decisions (from user, do NOT re-ask)
|
||||
|
||||
| Q | Decision |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Embedded signing host | `portnimara.com/sign/<role>/<token>` (marketing website hosts the embed page; CRM emits URLs in this format) |
|
||||
| Initial "please sign" email | **Per-port admin setting** `eoi_send_mode`: `auto` = send branded email immediately on generate; `manual` = generate + show URL + Send button |
|
||||
| Contract / Reservation generation | **Upload-and-place-fields per deal only.** EOI is the only template-driven flow. (Resolved Q6 — template-fallback dropped.) |
|
||||
| Reminder cadence | **Manual by default.** Rep clicks "Send reminder" button. Per-doc opt-in for auto-reminders at upload time. (Resolved Q1) |
|
||||
| Document expiration | **Never expire.** No `expiresAt` UI in v1. (Resolved Q2) |
|
||||
| Approver vs CC | **Two concepts**: `APPROVER` = real Documenso recipient that gates signing; `Completion CC` = passive recipient that only receives the signed PDF. (Resolved Q4) |
|
||||
| Witness | **First-class signer role.** Configurable per-document; full reminder/tracking flow. (Resolved Q7) |
|
||||
| Per-port developer label | **Configurable** via `documenso_developer_label` / `documenso_approver_label`. (Resolved Q8 bonus) |
|
||||
| Multi-port template config | All Documenso settings are per-port via `/[portSlug]/admin/documenso` (already wired) |
|
||||
| Documenso API version | Both v1 + v2 supported. Per-port config picks. v1 is prod (1.32) — primary. v2 unlocks embed + envelope |
|
||||
| nginx CORS | User applies manually. Block is in [docs/documenso-integration-audit.md](./documenso-integration-audit.md). Supports multi-origin via `set $cors_origin` regex |
|
||||
| Signer override | **Hybrid** — template docs (EOI) keep template-fixed signers (per-port settings fill the slots). Custom-uploaded docs (contract, reservation) get full per-deal signer customization. |
|
||||
| Multi-berth | EOI keeps existing bundle support. Contract/reservation are custom-uploaded PDFs — no PDF form-fill, just Documenso signature/initials/date fields |
|
||||
| Test mode | Reuse `EMAIL_REDIRECT_TO` env var (already redirects every outbound email + Documenso recipient) |
|
||||
| Regenerate handling | Match old system: 3 retries to delete prior Documenso doc with 2-second wait. **Plus** a confirm modal: "Retain old EOI? (default no)" |
|
||||
| Field placement strategy | **Auto-detect (anchor text scanner) + manual drag-drop UI as safety net.** Auto-detect populates the initial state; rep can drag/delete/reassign before sending. |
|
||||
|
||||
---
|
||||
|
||||
## What's already shipped (foundation)
|
||||
|
||||
Files in place; do NOT rebuild:
|
||||
|
||||
- `src/lib/services/port-config.ts` — extended with: `documenso_developer_name/email`, `documenso_approver_name/email`, `eoi_send_mode`, `embedded_signing_host`, `documenso_contract_template_id`, `documenso_reservation_template_id`
|
||||
- `src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx` — admin UI exposes every Documenso knob across 5 cards
|
||||
- `src/lib/email/templates/document-signing.ts` — `signingInvitationEmail`, `signingCompletedEmail`, `signingReminderEmail` with per-port branding
|
||||
- `src/lib/services/document-signing-emails.service.ts` — `sendSigningInvitation`, `sendSigningReminder`, `sendSigningCompleted`. Includes `transformSigningUrl(rawUrl, host, role)` for embed URL wrapping
|
||||
- `src/lib/services/documenso-client.ts` — extended `DocumensoFieldType` to all 11 types: SIGNATURE, FREE_SIGNATURE, INITIALS, DATE, EMAIL, NAME, TEXT, NUMBER, CHECKBOX, DROPDOWN, RADIO. Plus typed `DocumensoTextFieldMeta`/`NumberFieldMeta`/`ChoiceFieldMeta` interfaces and `fieldTypeNeedsMeta(type)` helper
|
||||
- `src/components/interests/interest-eoi-tab.tsx` — EOI workspace with active-doc hero, signing progress, paper-signed upload, history strip
|
||||
- `src/components/interests/interest-contract-tab.tsx` — Contract workspace shell with paper-signed upload + "send for signing" placeholder dialog
|
||||
- `src/components/interests/interest-reservation-tab.tsx` — Reservation workspace shell (clone of Contract)
|
||||
- `src/components/interests/interest-tabs.tsx` — stage-conditional visibility wired
|
||||
|
||||
What works today end-to-end: generate EOI → Documenso template path → manual link sharing (rep copies URL out of UI). What does NOT yet work: auto-send branded invitation, cascading "your turn" emails, custom-doc upload-to-Documenso, embedded signing URL emission to the website, on-completion PDF distribution.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — EOI generate flow polish (~3 hours)
|
||||
|
||||
> **Updated for Q1, Q4, Q6, Q8 resolutions.** Adds manual-reminder endpoint, two new per-port label settings, drop of contract/reservation template settings, schema columns for completion CCs + auto-reminder. Also folds in webhook-secret hardening (Risk #7 Option A) and `transformSigningUrl` role mapping (Risk #5 fix).
|
||||
|
||||
**Why first**: Smallest surface area, validates the per-port `eoi_send_mode` setting works end-to-end, gets the cascading-email mental model in place before tackling the bigger pieces.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **Auto-send wiring**: in `src/components/documents/eoi-generate-dialog.tsx`, after `handleGenerate()` succeeds:
|
||||
- Fetch port's `eoi_send_mode` (already on `getPortDocumensoConfig(portId)`)
|
||||
- If `auto`: server-side already sent the doc to Documenso with `sendEmail: false`. Now call new endpoint `POST /api/v1/documents/[id]/send-invitation` (build it) which:
|
||||
- Looks up the document's signers
|
||||
- Calls `sendSigningInvitation()` for the first signer (the client; signing order 1)
|
||||
- Stores `sent_at` timestamp on the signer row
|
||||
- If `manual`: do nothing. Surface the signing URL in the EOI tab + a "Send invitation" button that hits the same endpoint.
|
||||
|
||||
2. **Regenerate confirm modal**: when EOI tab's "Generate EOI" button is clicked AND a Documenso doc already exists for this interest (`activeDoc !== null`):
|
||||
- Show a `<Dialog>` asking: "There's already an EOI in flight. Regenerating will create a new document and the existing one will be cancelled."
|
||||
- Two buttons: "Cancel" (default), "Regenerate" (destructive)
|
||||
- Below the buttons, a checkbox: "Keep the previous EOI in Documenso (don't delete)" — defaults UNCHECKED
|
||||
- On confirm: if checkbox unchecked, call `voidDocument(oldId, portId)` with 3 retries + 2-second wait between (mirror old system's `generate-quick-eoi.ts` lines 110-162). Then run the normal generate flow.
|
||||
|
||||
3. **Send-invitation endpoint**: new file `src/app/api/v1/documents/[id]/send-invitation/route.ts`:
|
||||
|
||||
```ts
|
||||
POST /api/v1/documents/[id]/send-invitation
|
||||
Body: { recipientId?: string } // optional — defaults to first unsigned recipient
|
||||
```
|
||||
|
||||
- Loads the document + signers
|
||||
- Resolves the target recipient (passed-in or first unsigned in signing order)
|
||||
- Resolves port's documenso config + the recipient's signing URL from the document_signers row
|
||||
- Calls `sendSigningInvitation` from the email service
|
||||
- Updates `document_signers.invited_at` (need to add column — see schema migration below)
|
||||
|
||||
4. **Schema migration**: add `invited_at` and `last_reminder_sent_at` columns to `document_signers`:
|
||||
```sql
|
||||
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
|
||||
```
|
||||
The webhook handler updates these (Phase 2). Apply via psql then restart dev server (per CLAUDE.md migration note).
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Setting `eoi_send_mode=auto` in admin → generating an EOI fires off our branded HTML email to the client immediately
|
||||
- Setting `eoi_send_mode=manual` → no email fires; "Send invitation" button in EOI tab hits the endpoint
|
||||
- Clicking Generate when an active EOI exists → confirm dialog with checkbox; default deletes prior doc with retries
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Webhook handler enhancement (~3-4 hours)
|
||||
|
||||
**Why second**: Once invitations are flowing (Phase 1), the webhook needs to track the lifecycle and fire the cascading "your turn" emails as each signer completes. Without this, the system goes silent after the initial invite.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **Extend `src/app/api/webhooks/documenso/route.ts`** to handle `DOCUMENT_OPENED`, `DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED` (DOCUMENT_OPENED currently ignored).
|
||||
|
||||
2. **For `DOCUMENT_SIGNED`** (fires when one recipient signs, can fire multiple times per doc):
|
||||
- Resolve the (port, document, signer) — existing per-port secret lookup already does this
|
||||
- Update `document_signers.signed_at` for the matching signer
|
||||
- Find the next unsigned signer in signing order
|
||||
- If next signer exists AND we haven't already invited them: call `sendSigningInvitation()` with the next signer + their signing URL + role='developer' (or 'approver' depending on signing order). Mark `document_signers.invited_at` for them.
|
||||
- This is the cascading "your turn" flow that mirrors `client-portal/server/services/documenso-notifications.ts`
|
||||
|
||||
3. **For `DOCUMENT_OPENED`**:
|
||||
- Update `document_signers.opened_at` for the matching recipient (matched by token in payload)
|
||||
- Used for analytics later ("12% of clients open within an hour")
|
||||
|
||||
4. **For `DOCUMENT_COMPLETED`** (fires once when all signers have signed):
|
||||
- Update document `status='completed'`, `completed_at=...`
|
||||
- Download signed PDF: `await downloadSignedPdf(documensoId, portId)` (existing)
|
||||
- Store in storage backend via the file ingestion flow — this creates a `files` row
|
||||
- Update the document row to point at the signed file (`signed_file_id`)
|
||||
- Call `sendSigningCompleted()` with all signers + the signed file's id
|
||||
- Update the linked interest's pipeline stage:
|
||||
- If document type = `eoi` → `eoi_signed`
|
||||
- If document type = `contract` → `contract_signed`
|
||||
- If document type = `reservation_agreement` → leave stage; reservation is post-deal-close anyway
|
||||
|
||||
5. **Recipient-token matching**: webhooks include `payload.recipients[]` with each recipient's `token`. Use the token to match against `document_signers.signing_token` (need to add the column if not already). Old system's webhook does this via email match — fragile when the same email serves multiple roles. Token match is robust.
|
||||
|
||||
6. **Idempotency**: webhook can fire duplicates. Old system's `acquireWebhookLock` + signature comparison pattern is good. Port that logic.
|
||||
|
||||
### Schema migration
|
||||
|
||||
```sql
|
||||
-- Add fine-grained tracking columns to document_signers
|
||||
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN signing_token text; -- index this
|
||||
|
||||
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
|
||||
```
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Client signs → developer receives our branded "your turn" email within seconds
|
||||
- Developer signs → approver receives the same
|
||||
- All signed → all three recipients receive the signed PDF as attachment
|
||||
- Interest's pipeline stage advances to `eoi_signed` automatically
|
||||
- Re-firing of duplicate webhooks is no-op
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Custom document upload-to-Documenso (~6-8 hours)
|
||||
|
||||
**Why third**: Backend foundation for contract + reservation flows. Without this, the "Upload draft for signing" CTA on those tabs is a placeholder.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **New service** `src/lib/services/custom-document-upload.service.ts`:
|
||||
|
||||
```ts
|
||||
export async function uploadDocumentForSigning(args: {
|
||||
interestId: string;
|
||||
portId: string;
|
||||
documentType: 'contract' | 'reservation_agreement';
|
||||
pdfBuffer: Buffer;
|
||||
filename: string;
|
||||
title: string;
|
||||
recipients: Array<{
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'SIGNER' | 'APPROVER' | 'CC';
|
||||
signingOrder: number;
|
||||
}>;
|
||||
fields: DocumensoFieldPlacement[]; // from auto-detect or manual placement
|
||||
}): Promise<{ documentId: string; signingUrls: Record<string, string> }>;
|
||||
```
|
||||
|
||||
Steps:
|
||||
- Convert pdfBuffer → base64
|
||||
- Call `createDocument(title, base64, recipients, portId)` — existing client function
|
||||
- Call `placeFields(docId, fields, portId)` — existing client function (handles v1 + v2)
|
||||
- Call `sendDocument(docId, portId)` — existing
|
||||
- Return doc ID + per-recipient signing URLs
|
||||
- Mirror the timing-safe URL extraction from old system's generate-quick-eoi (recipients[].signingUrl)
|
||||
- Insert a row into our `documents` table with the new doc_id + signers + interest link
|
||||
- If port's `eoi_send_mode === 'auto'`: kick off `sendSigningInvitation()` to first signer
|
||||
|
||||
2. **API endpoint**: `POST /api/v1/interests/[id]/upload-for-signing`
|
||||
- Accepts multipart: `file` (the PDF), `documentType`, `title`, `recipients` (JSON), `fields` (JSON)
|
||||
- Validates: file is PDF (magic-byte check, see berth-pdf flow), recipients ≥ 1, fields ≥ 1
|
||||
- Calls service
|
||||
- Returns 201 with the new document row
|
||||
|
||||
3. **Update Contract + Reservation tab placeholders** to open a real upload dialog (see Phase 4).
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Endpoint accepts a PDF + recipients + fields and returns a Documenso doc ID
|
||||
- Document appears in the Documents tab with status `sent`
|
||||
- v1 and v2 paths both work (same code path; client chooses based on per-port config)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Recipient configurator + Field placement UI (~10-14 hours)
|
||||
|
||||
**Why fourth**: This is the BIG visual piece. Don't start until Phase 3 backend is proven via curl.
|
||||
|
||||
### Sub-phase 4a: Recipient configurator (~2-3 hours)
|
||||
|
||||
UI inside a new `<UploadForSigningDialog>` component:
|
||||
|
||||
- File picker (drag-drop + click)
|
||||
- Title input (defaults to filename minus extension)
|
||||
- Recipients list:
|
||||
- Add row → name + email + role (SIGNER/APPROVER/CC) + signing order (number, auto-increments)
|
||||
- Drag to reorder (uses `dnd-kit`, already in deps)
|
||||
- Delete row
|
||||
- Defaults: client (signing order 1) prefilled from interest's linked client; developer + approver prefilled from port settings
|
||||
- "Configure fields →" button advances to sub-phase 4b
|
||||
|
||||
### Sub-phase 4b: PDF rendering (~3-4 hours)
|
||||
|
||||
- Install: `pnpm add react-pdf` (uses pdfjs-dist under the hood; pdfme already pulls pdfjs-dist so no new dep weight)
|
||||
- Render the uploaded PDF page-by-page using `<Document>` + `<Page>` from react-pdf
|
||||
- Page navigation (prev/next, page picker)
|
||||
- Zoom controls (50%, 75%, 100%, 125%, 150%)
|
||||
|
||||
### Sub-phase 4c: Auto-detect scanner (~4-6 hours)
|
||||
|
||||
New file `src/lib/services/document-field-detector.ts`:
|
||||
|
||||
```ts
|
||||
export interface DetectedField {
|
||||
type: DocumensoFieldType;
|
||||
pageNumber: number;
|
||||
pageX: number; // 0-100 percent
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
/** Confidence 0-1 — how sure the scanner is. */
|
||||
confidence: number;
|
||||
/** Original anchor text (for debugging / display). */
|
||||
anchorText?: string;
|
||||
/** Inferred recipient (from nearby labels). null = unassigned. */
|
||||
inferredRecipientLabel?: string | null;
|
||||
}
|
||||
|
||||
export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>;
|
||||
```
|
||||
|
||||
Implementation:
|
||||
|
||||
- Use `pdfjs-dist` to extract text per page with `getTextContent()` — gives `{str, transform: [a,b,c,d,e,f]}` per text item where `e,f` is position in PDF user space, plus `width/height`
|
||||
- Anchor patterns:
|
||||
- `SIGNATURE`: `/signature[:\s_-]+/i`, `/sign\s*here[:\s_-]*/i`, `/X\s*_{4,}/i`, `/signed\s*by[:\s]+/i`
|
||||
- `INITIALS`: `/initials?[:\s_-]+/i`
|
||||
- `DATE`: `/dated?[:\s_-]+/i`, `/date\s+of\s+signature/i`
|
||||
- `NAME`: `/(printed?\s*)?name[:\s_-]+/i`, `/full\s+name[:\s_-]+/i`
|
||||
- `EMAIL`: `/email[:\s_-]+/i`
|
||||
- Catch-all: `/_{8,}/` → if not preceded by name/email/date keyword, default to TEXT
|
||||
- For each match: place field bounding box immediately AFTER the matched text (offset 5pt right), with type-appropriate width:
|
||||
- SIGNATURE: 150pt × 30pt
|
||||
- INITIALS: 50pt × 30pt
|
||||
- DATE: 80pt × 20pt
|
||||
- NAME: 150pt × 20pt
|
||||
- EMAIL: 200pt × 20pt
|
||||
- TEXT: 200pt × 20pt
|
||||
- Convert to PERCENT (divide by page width/height)
|
||||
- Recipient inference: scan ±100pt of the field for labels like "Buyer", "Seller", "Client", "Developer", "Witness", "Notary". Map to recipient by role.
|
||||
|
||||
### Sub-phase 4d: Drag-drop overlay (~3-4 hours)
|
||||
|
||||
- Overlay absolute-positioned divs on top of the PDF viewer for each field
|
||||
- Each field shows: type icon + recipient color + delete (×) handle + drag affordance
|
||||
- Use `dnd-kit` to enable drag — update `pageX/pageY` in state on drop
|
||||
- Field palette toolbar: 11 buttons (one per Documenso field type) — click to enter "place mode" → next click on the PDF places a new field at that coord
|
||||
- Side panel for selected field:
|
||||
- Type changer (dropdown)
|
||||
- Recipient assignment (dropdown of configured recipients)
|
||||
- Required toggle
|
||||
- Per-type config (TEXT label, NUMBER min/max, CHECKBOX/DROPDOWN/RADIO options) — drives `fieldMeta`
|
||||
- Width/height inputs
|
||||
- Delete button
|
||||
|
||||
### Sub-phase 4e: Send (~1 hour)
|
||||
|
||||
"Send for signing" button:
|
||||
|
||||
- Validates: ≥1 recipient, ≥1 field, every field has a recipient assigned
|
||||
- POSTs to `/api/v1/interests/[id]/upload-for-signing` (Phase 3)
|
||||
- On success, closes dialog and refreshes the Contract/Reservation tab
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Upload a draft PDF → auto-detect runs → fields appear overlaid in their detected positions
|
||||
- Rep can drag any field to reposition (state updates, persists to backend on send)
|
||||
- Rep can change a field's type, recipient, or metadata via side panel
|
||||
- Rep can add new fields by clicking palette button + clicking on PDF
|
||||
- Rep can delete fields they don't want
|
||||
- Click Send → fields ship to Documenso, signing flow starts, Contract tab shows the active doc
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Embedded signing URL emission verification (~1-2 hours)
|
||||
|
||||
**Why later**: The Vue page on the marketing website already exists. This phase is a verification + documentation pass, not a code build.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **Verify URL transformation matches website expectations**:
|
||||
- Website route: `/sign/[type]/[token]` where `type ∈ {client, cc, developer}`
|
||||
- Our `transformSigningUrl()` emits `/sign/<role>/<token>` where role can be `client | developer | approver | witness | other`
|
||||
- Mismatch: website only handles `client | cc | developer`. Our email service may emit `approver` (which the website doesn't route).
|
||||
- **Fix**: either (a) update website's `[type].vue` to accept `approver` (and `witness | other` if needed), OR (b) map our role names to the website's expected names in `transformSigningUrl()`.
|
||||
|
||||
2. **For contract + reservation document types**: the website's `signerMessages` map only covers EOI-specific copy. When a contract goes out for signing and the recipient hits `portnimara.com/sign/client/<token>`, the page would show "Sign Your Expression of Interest" — wrong copy.
|
||||
- **Fix**: add document-type to the URL too: `/sign/<docType>/<role>/<token>`. Update website's signerMessages to be keyed on `(docType, role)`.
|
||||
|
||||
3. **Webhook callback URL**: website POSTs to `client-portal.portnimara.com/api/webhook/document-signed` after signing. The new CRM is at a different domain. Update website's `handleDocumentSigned` to POST to the new CRM's webhook (a thin "client confirmed sign" notification, separate from Documenso's own webhook).
|
||||
|
||||
4. **Apply nginx CORS block** — already documented in [docs/documenso-integration-audit.md](./documenso-integration-audit.md). Apply via ssh when user grants access.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Embedded URL points at a working website page that loads the right Documenso embed for any document type / role combo
|
||||
- Post-sign callback updates our document_signers row (redundant with the Documenso webhook but useful as a real-time UI signal)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Polish & deferred items (~2-3 hours each, do as needed)
|
||||
|
||||
- **`auto` send mode delay**: optional per-port `eoi_send_delay_minutes` setting. When set, the auto-send fires after N minutes (BullMQ scheduled job) so the rep can review + cancel during the window. Default 0 (immediate).
|
||||
- **Audit log entries**: every Documenso-related action (generate, send, remind, cancel, sign-event-received) writes to `audit_logs` with structured metadata. Mostly already there for the existing flow; extend to cover Phase 1-3 additions.
|
||||
- **Per-document customization of email copy**: rep can override the default signing-invitation body before send. New textarea in the upload dialog. Stored as `documents.invitation_message`.
|
||||
- **Document expiration**: Documenso supports `expiresAt`. Surface as a per-document field in the upload dialog.
|
||||
- **Reminder rate-limit display**: surface "next reminder available in X days" on each unsigned signer in the signing-progress UI.
|
||||
- **Failed-webhook recovery UI**: admin page showing webhooks that errored, with a "Replay" button. Old system has the foundation; CRM doesn't.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Project Director role + RBAC layer (~6-8 hours)
|
||||
|
||||
> **Surfaced from Q8 conversation.** The `developer` signer slot is conceptually the "Project Director" — the person at the port who countersigns deals on behalf of the port. Today every CRM user is either a sales rep or admin; there's no Project Director user role. Attack alongside the Documenso build because (a) the Documenso developer-label setting is meaningless if no user actually has the role, and (b) a few permissions naturally cluster around it.
|
||||
|
||||
### What a Project Director needs (vs sales rep)
|
||||
|
||||
| Capability | Sales rep | Project Director | Admin |
|
||||
| -------------------------------------------------------- | --------- | ---------------- | ----------------------------- |
|
||||
| Generate EOI / contract / reservation | ✓ | ✓ | ✓ |
|
||||
| Approve / sign as the "developer" recipient on Documenso | — | ✓ | — (unless also designated PD) |
|
||||
| View own deals | ✓ | ✓ | ✓ |
|
||||
| View other reps' deals | — | ✓ | ✓ |
|
||||
| View audit logs (read-only) | — | ✓ | ✓ |
|
||||
| Trigger CSV / report exports | — | ✓ | ✓ |
|
||||
| Re-assign deals between reps | — | ✓ | ✓ |
|
||||
| Edit per-port settings | — | — | ✓ |
|
||||
| Manage users + invitations | — | — | ✓ |
|
||||
| Manage Documenso config | — | — | ✓ |
|
||||
|
||||
So Project Director sits between sales rep and admin: read-everywhere + a few action capabilities (re-assign, export, sign-as-PD), but no settings/user management.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **Add `project_director` to the role enum** in `src/lib/db/schema/users.ts` (or wherever port_roles enum lives). Existing role values (sales, admin, super_admin) stay; this is additive.
|
||||
|
||||
2. **Permission flags**: extend the per-port permissions matrix (`src/lib/auth/permissions.ts` or equivalent) with new flags:
|
||||
- `viewAllDeals` — true for project_director, admin, super_admin
|
||||
- `viewAuditLogs` — true for project_director, admin, super_admin
|
||||
- `exportReports` — true for project_director, admin, super_admin
|
||||
- `reassignDeals` — true for project_director, admin, super_admin
|
||||
- `signAsProjectDirector` — true for project_director only (admin can sign as PD only if also assigned the role on this port)
|
||||
|
||||
These flags get checked in the relevant API handlers via the existing `withPermission()` middleware.
|
||||
|
||||
3. **Documenso developer-slot binding**: per-port admin UI gets a "Project Director user" dropdown alongside the existing developer-name/email free-text inputs. When a real CRM user is selected, the admin UI:
|
||||
- Populates `documenso_developer_name/email` from the user's profile (read-only when bound)
|
||||
- When that user signs an EOI/contract via Documenso, the webhook handler can match by user-email and update the in-CRM signing UI in real time (signer chip turns green for them specifically)
|
||||
- Free-text fallback stays for ports without a CRM-PD user yet
|
||||
|
||||
4. **User invitations + role selection**: extend `src/components/admin/invite-user-dialog.tsx` to surface "Project Director" alongside Sales / Admin as a selectable role at invitation time.
|
||||
|
||||
5. **Audit-log access**: surface a new `/[portSlug]/admin/audit-log` route (or extend the existing one's permission gate) so Project Directors can read but not write. Hide write controls for non-admins.
|
||||
|
||||
6. **Reports page permission gate**: existing `/[portSlug]/reports` (or wherever exports live) checks `exportReports` permission flag instead of admin-only.
|
||||
|
||||
7. **Re-assign deals UI**: add a "Re-assign owner" action on the interest detail page, gated by `reassignDeals`. Writes to `interests.owner_user_id` (or whatever the assigned-rep field is) and audit-logs the change.
|
||||
|
||||
### Schema migration
|
||||
|
||||
```sql
|
||||
-- Add project_director as a valid role; depends on how roles are stored.
|
||||
-- If port_roles uses an enum:
|
||||
ALTER TYPE port_role ADD VALUE 'project_director';
|
||||
-- Or if it's a text column with check constraint:
|
||||
ALTER TABLE port_roles DROP CONSTRAINT port_roles_role_check;
|
||||
ALTER TABLE port_roles ADD CONSTRAINT port_roles_role_check
|
||||
CHECK (role IN ('sales', 'admin', 'super_admin', 'project_director'));
|
||||
|
||||
-- Optional: link the per-port Documenso developer slot to a real user
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS user_id text REFERENCES users(id) ON DELETE SET NULL;
|
||||
-- (Used for the documenso_developer_user_id setting; null for free-text fallback)
|
||||
```
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- A user invited as `project_director` can view all deals across the port (not just their own), read audit logs, trigger exports, and re-assign deals — but cannot edit settings or invite users
|
||||
- Admin can bind a CRM user to the per-port Documenso developer slot; the user's name + email auto-populate in invitations and emails
|
||||
- Non-PD users cannot trigger PD-only actions (server returns 403; UI hides the controls)
|
||||
- Existing sales / admin / super_admin permissions are unchanged
|
||||
|
||||
### Why attack at the same time as the Documenso build
|
||||
|
||||
- Both touch `port-config.ts` and `admin/documenso/page.tsx` — fewer rebases if done in one push
|
||||
- The `documenso_developer_label` setting (Q8 bonus) and the PD-user binding overlap; doing them together avoids re-touching the same admin card twice
|
||||
- The Documenso webhook's per-signer matching benefits from having a real `users.email` to bind against, not just a free-text developer name
|
||||
|
||||
### Out of scope (defer to a later RBAC pass)
|
||||
|
||||
- Custom permission templates (e.g. "PD with no audit-log access")
|
||||
- Per-deal ACLs (sharing a single interest with another rep)
|
||||
- Time-bound role grants
|
||||
- Cross-port role overrides for super_admin
|
||||
|
||||
---
|
||||
|
||||
## Risks + decisions (resolved through code review)
|
||||
|
||||
Each entry below was checked against the current code. The original "open question" form is preserved in italics for traceability; the **Decision** is what the next session should implement.
|
||||
|
||||
---
|
||||
|
||||
### 1. `fieldMeta` on Documenso v1.32
|
||||
|
||||
_Q: Does v1.32 silently ignore unknown properties, or does it reject the request?_
|
||||
|
||||
**Decision: not a risk in current code.** [src/lib/services/documenso-client.ts:491-501](../src/lib/services/documenso-client.ts#L491) shows the v1 path constructs its own body containing only `recipientId, type, pageNumber, pageX/Y/Width/Height` — `fieldMeta` is never sent on v1. The code comment at [line 341-344](../src/lib/services/documenso-client.ts#L341) is misleading — update it. Action for next session: change the comment to "v1 does not receive `fieldMeta` (we never send it). v1 renders TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO as blank inputs; if the per-port admin chose v1 the field UI should warn 'Configurable field types require Documenso v2'." The placement UI in Phase 4d should disable the meta-config side panel when the resolved port is on v1.
|
||||
|
||||
### 2. PDF dimension extraction (non-A4 contracts)
|
||||
|
||||
_Q: How do we get real page dimensions on the v1 path?_
|
||||
|
||||
**Decision: parse the PDF with pdf-lib in the upload service before calling `placeFields()`.** pdf-lib is already a transitive dep via the EOI form-fill flow ([src/lib/pdf/fill-eoi-form.ts](../src/lib/pdf/fill-eoi-form.ts)). Concrete change for Phase 3:
|
||||
|
||||
```ts
|
||||
// In src/lib/services/custom-document-upload.service.ts
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
||||
const pageDims = pdfDoc.getPages().map((p) => {
|
||||
const { width, height } = p.getSize();
|
||||
return { width, height };
|
||||
});
|
||||
// Pass to placeFields as a per-page dimension map override
|
||||
```
|
||||
|
||||
Then extend `placeFields` signature to accept an optional `pageDimensionsOverride?: DocumensoPageDimensions[]` (one entry per page). When provided, the v1 path uses `pageDimensionsOverride[fieldPageIndex]` instead of [`getPageDimensions()`'s A4 default](../src/lib/services/documenso-client.ts#L427). Falls back to A4 when override is missing — keeps the EOI template path (which IS A4) unchanged.
|
||||
|
||||
### 3. Multi-page signature blocks not picked up by auto-detect
|
||||
|
||||
_Q: What's the recovery path if the scanner misses a signature block on the last page?_
|
||||
|
||||
**Decision: not a risk — by design.** Phase 4d's drag-drop overlay + field palette is the explicit fallback. Auto-detect populates initial state; rep MUST be able to add fields manually. The acceptance criterion at the end of Phase 4 already covers this. Demoted from "risk" to "design note": every page must be reachable in the PDF viewer (Phase 4b's page navigation) and the field palette must be enabled even on auto-detected pages.
|
||||
|
||||
### 4. Webhook payload differences v1 vs v2
|
||||
|
||||
_Q: Does our webhook handler decode both v1 and v2 payload shapes correctly?_
|
||||
|
||||
**Decision: partially confirmed; finish the audit in Phase 2.** Confirmed working today:
|
||||
|
||||
- Secret transport: identical (`X-Documenso-Secret` plaintext) — see [route.ts:53](../src/app/api/webhooks/documenso/route.ts#L53)
|
||||
- Event names: both versions send the uppercase Prisma enum (`DOCUMENT_SIGNED`); CLAUDE.md note documents this. The route also normalizes lowercase-dotted variants for forward-compat.
|
||||
- Top-level shape `{ event, payload: { id, ... } }`: same on both versions
|
||||
|
||||
Still unverified (defer to Phase 2 implementation):
|
||||
|
||||
- v2 may rename `payload.id` → `payload.documentId` and `recipient.id` → `recipient.recipientId` (mirrors the API-response rename — see [src/lib/services/documenso-client.ts](../src/lib/services/documenso-client.ts) `normalizeDocument()`). Apply the same dual-field read pattern in the webhook handler: `const docId = payload.documentId ?? payload.id`.
|
||||
- v2 may include `payload.envelopeId` instead of `payload.id` for envelope-level events (DOCUMENT_COMPLETED). Read both.
|
||||
- Recipient token field: v1 uses `recipient.token`; v2 may differ. Phase 2's token-based matching (step 5) needs to handle both.
|
||||
|
||||
Test with a v2 instance during Phase 2; until then keep the per-port API version setting on v1 only.
|
||||
|
||||
### 5. `approver` role → `cc` URL mapping
|
||||
|
||||
_Q: How do we keep the website's signing page (which only routes `client | cc | developer`) working when our `SignerRole` includes `approver | witness | other`?_
|
||||
|
||||
**Decision: confirmed bug in current code; fix in Phase 5.** [Website route validation](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue#L175) explicitly redirects to `/sign/error` for any `signerType` not in `['client', 'cc', 'developer']`. Our [transformSigningUrl()](../src/lib/services/document-signing-emails.service.ts#L106) emits `${host}/sign/${signerRole}/${token}` with the raw `SignerRole` value. Today, an `approver` invite would land on `/sign/error`.
|
||||
|
||||
Concrete fix in `transformSigningUrl()`:
|
||||
|
||||
```ts
|
||||
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer'> = {
|
||||
client: 'client',
|
||||
developer: 'developer',
|
||||
approver: 'cc', // legacy: approver showed as "EmbeddedSignatureLinkCC"
|
||||
witness: 'cc', // route through cc page; copy needs a witness override (Phase 5)
|
||||
other: 'cc',
|
||||
};
|
||||
const urlRole = ROLE_TO_URL_SEGMENT[signerRole];
|
||||
return `${host}/sign/${urlRole}/${token}`;
|
||||
```
|
||||
|
||||
Two follow-ups for Phase 5:
|
||||
|
||||
- Add the mapping above to `transformSigningUrl()` — DO this in Phase 1 already since Phase 1 fires the first invitation email.
|
||||
- Update website's `signerMessages` (currently EOI-specific) to be keyed on `(documentType, signerType)` so contract+reservation invites get the right copy — see Phase 5 task 2.
|
||||
|
||||
### 6. Storage backend for signed PDFs
|
||||
|
||||
_Q: Does the on-completion download in Phase 2 use the pluggable storage backend?_
|
||||
|
||||
**Decision: confirmed — pattern already established, just follow it.** [`getStorageBackend()`](../src/lib/storage/index.ts) is used by 9 services in the codebase (berth-pdf, brochures, expense-pdf, invoices, gdpr-export, reports, document-templates, document-sends, email-compose). The [`documents` schema](../src/lib/db/schema/documents.ts) already has the `signedFileId` column with index `idx_docs_signed_file_id`. Phase 2 step 4 is just: `const buffer = await downloadSignedPdf(docId, portId); const file = await ingestFile({ buffer, portId, ... }); await db.update(documents).set({ signedFileId: file.id })...`. Demoted from "risk" to "implementation note" inside Phase 2.
|
||||
|
||||
### 7. Cross-port webhook secret collision
|
||||
|
||||
_Q: Can two ports happen to share the same webhook secret?_
|
||||
|
||||
**Decision: real risk — fix at write-time, not schema.** [system_settings](../src/lib/db/schema/system.ts#L137) is unique on `(key, port_id)`, so the same key+port combo is enforced unique, but there's no global uniqueness on the _value_. The [webhook handler](../src/app/api/webhooks/documenso/route.ts#L62) iterates all configured secrets and breaks on first match — if two ports paste the same secret, the second port's webhooks get attributed to the first. Three options, in preference order:
|
||||
|
||||
**Option A (recommended): generate, never paste.** Replace the textbox in [admin/documenso/page.tsx](<../src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx>) for `documenso_webhook_secret` with a "Generate secret" button that calls `crypto.randomBytes(32).toString('base64url')` server-side and writes it. Display once, mask after. Collision probability is negligible. Admin still has a "Regenerate" button for rotation.
|
||||
|
||||
**Option B: warn at write.** Keep the textbox but on PUT to the setting, query `system_settings WHERE key='documenso_webhook_secret' AND value=?` and fail with a 409 if any other port has this value. Cheap, defensive, but exposes that a value exists somewhere.
|
||||
|
||||
**Option C: schema-level enforcement.** Add a partial unique index `CREATE UNIQUE INDEX system_settings_documenso_secret_unique ON system_settings (value) WHERE key = 'documenso_webhook_secret'`. Strongest, but requires careful ordering during port-clone or restore-from-backup operations.
|
||||
|
||||
Pick Option A. Add to Phase 1 as a polish item — small change, eliminates the risk class.
|
||||
|
||||
---
|
||||
|
||||
## Open questions — RESOLVED 2026-05-07
|
||||
|
||||
All 10 questions plus the bonus role-label question have user-locked answers. Implementation must follow these decisions; do not re-litigate.
|
||||
|
||||
### Q1. Reminder cadence — RESOLVED
|
||||
|
||||
**Decision**: **Manual reminders by default.** Rep clicks a "Send reminder" button in the EOI/Contract tab. Per-document opt-in: rep can configure auto-reminders on a specific doc at send time (e.g. "remind every 7 days until signed").
|
||||
|
||||
**Implications**:
|
||||
|
||||
- No port-wide reminder schedule setting needed.
|
||||
- Phase 1 / 2: skip the BullMQ scheduled-reminder job for now. Add a `POST /api/v1/documents/[id]/send-reminder` endpoint that calls `sendSigningReminder()` for the next-pending signer. Track `last_reminder_sent_at` to enforce Documenso's 24h rate limit on the UI ("Next reminder available in X").
|
||||
- Phase 4a (upload dialog): add an optional "Auto-reminder schedule" field — None (default) / Every 3d / Every 7d. When set, store on `documents.auto_reminder_interval_days`; a once-daily worker iterates unsigned documents and fires due reminders.
|
||||
|
||||
### Q2. Document expiration — RESOLVED
|
||||
|
||||
**Decision**: **Never expire by default.** No expiration UI in v1. Skip Documenso's `expiresAt` entirely.
|
||||
|
||||
**Reasoning**: link expiration doesn't help the regenerate flow (regen already voids+recreates). Adding the UI is overhead with no immediate user benefit.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 3 `uploadDocumentForSigning`: don't expose `expiresAt`.
|
||||
- Phase 4a recipient configurator: no expiration field.
|
||||
- Phase 6 deferred-items list: drop the "Document expiration" item.
|
||||
|
||||
### Q3. Auto-detect confidence threshold — RESOLVED
|
||||
|
||||
**Decision**: **Default ≥0.8 silent / 0.5–0.8 flagged / <0.5 drop**, with the drag-drop overlay (Phase 4d) as the universal fix mechanism — rep can reposition or delete any auto-placed field.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 4c scanner: emit `DetectedField.confidence`; threshold checks live in the UI layer (Phase 4d) so they're easy to tune.
|
||||
- Phase 4d overlay: flagged fields render with a yellow border + "?" badge; rep can click to confirm-as-correct (clears the badge) or drag/delete.
|
||||
|
||||
### Q4. Approver semantics — RESOLVED
|
||||
|
||||
**Decision**: **TWO concepts, not one.**
|
||||
|
||||
1. **APPROVER** = real Documenso `APPROVER` recipient. Gates signing flow (e.g. client signs → approver approves → developer signs). Configured per-port (existing `documenso_approver_name/email` settings).
|
||||
2. **Completion CC** = passive recipient. Does NOT participate in signing. Receives only the final signed PDF as attachment when the doc completes. Set per-document by the rep at send time.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 3 `uploadDocumentForSigning` recipients: support `role: 'SIGNER' | 'APPROVER' | 'CC'`. CCs are NOT created as Documenso recipients — they're stored on `documents.completion_cc_emails` (text array) and emailed by our own service when DOCUMENT_COMPLETED webhook fires.
|
||||
- Phase 4a recipient configurator: split into two sections:
|
||||
- **Signing recipients**: name + email + role (Signer / Approver) + signing order
|
||||
- **Copy on completion** (CC): just email addresses, comma-separated
|
||||
- Phase 2 step 4 (on-completion email distribution): include `documents.completion_cc_emails` recipients with the signed PDF. Dedup by email (see Q5).
|
||||
- Schema migration: `ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];`
|
||||
|
||||
### Q5. On-completion PDF distribution — RESOLVED
|
||||
|
||||
**Decision**: **All signing recipients + rep who generated + per-deal CC**, deduplicated by email address.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 2 step 4: build the recipient list as union of (a) all `document_signers` for this doc, (b) the user who created the doc (`documents.createdBy` → `users.email`), (c) `documents.completion_cc_emails`. Lowercase + dedupe before calling `sendSigningCompleted`.
|
||||
- Common case (rep IS the approver): one email, not two.
|
||||
- Per-port distribution list (originally proposed) is NOT needed — the per-deal CC field covers it. If a port wants `legal@portnimara.com` on every deal, the rep types it once per doc; if it's truly always-on, add a port-default later (deferred to Phase 6).
|
||||
|
||||
### Q6. `documenso_contract_template_id` / `documenso_reservation_template_id` — RESOLVED
|
||||
|
||||
**Decision**: **DROP both settings. EOI is the only template-driven flow.** Contracts and reservations are custom-uploaded per deal — no template fallback.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Remove `documenso_contract_template_id` and `documenso_reservation_template_id` from `port-config.ts` `SETTING_KEYS` and `PortDocumensoConfig` type.
|
||||
- Remove the corresponding fields from `admin/documenso/page.tsx`. Card title becomes "Templates" with just the EOI template ID field.
|
||||
- Phase 3: contract/reservation tabs go straight into the upload dialog — no `if (templateId) { ... }` branch.
|
||||
- Locked design decisions table at top of this doc: update the "Contract / Reservation generation" row to remove the template-fallback option.
|
||||
|
||||
### Q7. Witness role — RESOLVED
|
||||
|
||||
**Decision**: **First-class. Configurable per-document at generation time.** Witness goes through the full invitation/reminder/tracking flow same as any other signer; signs the document attesting to having witnessed.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Keep `witness` in `SignerRole`.
|
||||
- Phase 4a recipient configurator: "Witness" is a selectable role in the role dropdown (alongside Signer / Approver / CC).
|
||||
- Phase 5 website edit: add witness copy to `signerMessages` map ("Witness this signing of…"). Add `witness` to the validated role list at line 175 of `[type]/[token].vue` — currently `['client', 'cc', 'developer']`, becomes `['client', 'cc', 'developer', 'witness']`.
|
||||
- Risk #5 mapping in `transformSigningUrl()`: `witness → 'witness'` (NOT mapped to `cc`). Update the role-to-URL-segment table accordingly.
|
||||
- Witness gets the same reminder/auto-reminder support as any signer — no special-casing.
|
||||
|
||||
### Q8. Multiple developers/approvers per port — RESOLVED (with rename)
|
||||
|
||||
**Decision**: **Stay single per port** for the standard `developer` and `approver` slots. If a port needs more on a custom doc, the rep adds extra signers via the upload-for-signing dialog (Phase 4a recipient configurator).
|
||||
|
||||
**Plus the bonus**: the per-port "developer" label IS configurable via a new `documenso_developer_label` setting (default: "Developer"). Used in email subjects, signer chips, and signing-progress UI. Backend type-name stays `developer` so no schema churn.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Add `documenso_developer_label` and `documenso_approver_label` to `SETTING_KEYS` + `PortDocumensoConfig`.
|
||||
- Admin UI in `documenso/page.tsx` Signers card: each signer card gets a "Display label" input next to name/email.
|
||||
- Email templates in `document-signing.ts`: read the label from the per-port branding config and use it in copy ("Your Project Director, {{name}}, has signed…").
|
||||
- **Open follow-up (out of scope for Documenso build)**: the user mentioned the project-director user MIGHT need different CRM permissions/access from a sales rep (e.g. exclusive audit-log access, more prominent reports). That's a separate RBAC initiative — note it on the audit backlog and don't action here.
|
||||
|
||||
### Q9. Field placement draft persistence — RESOLVED
|
||||
|
||||
**Decision**: **No persistence.** If the rep closes the dialog mid-placement, state is lost.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 4 architecture: keep all placement state in React component state. No localStorage, no DB drafts table.
|
||||
- Add a confirm-close on the dialog if the rep has placed any fields ("Discard placement work?").
|
||||
|
||||
### Q10. Embedded signing host fallback — RESOLVED
|
||||
|
||||
**Decision**: **Send raw Documenso URLs** when host is unset. The Documenso API already returns a working signing URL per recipient (e.g. `https://signatures.portnimara.dev/sign/<token>`); `transformSigningUrl()` returns this raw URL untouched when `embeddedSigningHost` is null/empty (current behaviour, see [document-signing-emails.service.ts:106-117](../src/lib/services/document-signing-emails.service.ts#L106)).
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 1: no behaviour change in `transformSigningUrl()`. The current null-host short-circuit IS the fallback.
|
||||
- Add a banner in the EOI/Contract tab when port has unset `embedded_signing_host` and at least one outstanding doc: "Signing emails currently link to signatures.portnimara.dev directly. Configure an embedded host in admin for branded signing pages."
|
||||
- No new env var. No blocking-on-send.
|
||||
|
||||
---
|
||||
|
||||
## Schema migration summary (resolved)
|
||||
|
||||
Combining all resolved decisions, the migrations needed are:
|
||||
|
||||
```sql
|
||||
-- Phase 1 (also covers Phase 2's lifecycle tracking)
|
||||
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN signing_token text;
|
||||
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
|
||||
|
||||
-- Phase 1 / Q4 (completion CCs are per-document)
|
||||
ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];
|
||||
|
||||
-- Phase 1 / Q1 (auto-reminder opt-in per document)
|
||||
ALTER TABLE documents ADD COLUMN auto_reminder_interval_days integer;
|
||||
```
|
||||
|
||||
## Settings to add / remove (resolved)
|
||||
|
||||
**Add to `SETTING_KEYS` + `PortDocumensoConfig`:**
|
||||
|
||||
- `documenso_developer_label` (text, default "Developer") — Q8 bonus
|
||||
- `documenso_approver_label` (text, default "Approver") — Q8 bonus
|
||||
|
||||
**Remove from `SETTING_KEYS` + `PortDocumensoConfig`:**
|
||||
|
||||
- `documenso_contract_template_id` — Q6
|
||||
- `documenso_reservation_template_id` — Q6
|
||||
|
||||
**Remove from admin UI** (`admin/documenso/page.tsx`):
|
||||
|
||||
- Contract template ID input — Q6
|
||||
- Reservation template ID input — Q6
|
||||
|
||||
**Add to admin UI:**
|
||||
|
||||
- Display-label inputs next to developer + approver name/email pairs — Q8 bonus
|
||||
|
||||
---
|
||||
|
||||
**Status**: Plan is now fully resolved. Phase 1 can start without further clarification.
|
||||
|
||||
---
|
||||
|
||||
## Quick file reference
|
||||
|
||||
**Existing — modify in place:**
|
||||
|
||||
- `src/lib/services/documenso-client.ts` (extend createDocument for v2; add recipient management functions)
|
||||
- `src/lib/services/port-config.ts` (no changes expected)
|
||||
- `src/lib/email/index.ts` (consider: add raw-Buffer attachment option to skip MinIO round-trip for one-off PDFs)
|
||||
- `src/app/api/webhooks/documenso/route.ts` (Phase 2 — major rewrite)
|
||||
- `src/components/interests/interest-contract-tab.tsx` (replace ComingSoonDialog with UploadForSigningDialog in Phase 4)
|
||||
- `src/components/interests/interest-reservation-tab.tsx` (same)
|
||||
- `src/components/documents/eoi-generate-dialog.tsx` (Phase 1 — add regenerate confirm)
|
||||
|
||||
**New files to create:**
|
||||
|
||||
- `src/lib/services/custom-document-upload.service.ts` (Phase 3)
|
||||
- `src/lib/services/document-field-detector.ts` (Phase 4c)
|
||||
- `src/components/documents/upload-for-signing-dialog.tsx` (Phase 4)
|
||||
- `src/components/documents/pdf-field-canvas.tsx` (Phase 4b/4d)
|
||||
- `src/components/documents/recipient-configurator.tsx` (Phase 4a)
|
||||
- `src/components/documents/field-palette-toolbar.tsx` (Phase 4d)
|
||||
- `src/components/documents/field-config-side-panel.tsx` (Phase 4d)
|
||||
- `src/app/api/v1/documents/[id]/send-invitation/route.ts` (Phase 1)
|
||||
- `src/app/api/v1/interests/[id]/upload-for-signing/route.ts` (Phase 3)
|
||||
- DB migrations for `document_signers.invited_at` etc. (Phase 1, Phase 2)
|
||||
223
docs/documenso-integration-audit.md
Normal file
223
docs/documenso-integration-audit.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Documenso integration audit
|
||||
|
||||
Reference for the multi-port Documenso signing pipeline in this CRM. Mirrors the legacy client portal's flow ([generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [documeso.ts](../client-portal/server/utils/documeso.ts), [documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [website /sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) but rewired for multi-tenant + better-auth + Drizzle.
|
||||
|
||||
---
|
||||
|
||||
## Per-port configuration
|
||||
|
||||
All Documenso settings live in `system_settings` keyed by `(key, port_id)` and are read via [`getPortDocumensoConfig(portId)`](../src/lib/services/port-config.ts). Falls back to env vars when no per-port row exists. Surfaced in the admin UI at `/[portSlug]/admin/documenso`.
|
||||
|
||||
| Setting key | Type | Purpose |
|
||||
| ----------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `documenso_api_url_override` | string | Per-port Documenso instance URL. Falls back to `DOCUMENSO_API_URL` env. |
|
||||
| `documenso_api_key_override` | string | API key. Stored plaintext. |
|
||||
| `documenso_api_version_override` | `'v1' \| 'v2'` | Different ports may run different Documenso versions. |
|
||||
| `documenso_eoi_template_id` | int | Template ID for EOI generation. |
|
||||
| `documenso_client_recipient_id` | int | Template recipient slot — client (signing order 1). |
|
||||
| `documenso_developer_recipient_id` | int | Template recipient slot — developer (signing order 2). |
|
||||
| `documenso_approval_recipient_id` | int | Template recipient slot — approver (signing order 3). |
|
||||
| `documenso_developer_name` | string | Display name for developer signer (legacy hardcoded "David Mizrahi"). |
|
||||
| `documenso_developer_email` | string | Developer signer email. |
|
||||
| `documenso_approver_name` | string | Approver display name. |
|
||||
| `documenso_approver_email` | string | Approver email. |
|
||||
| `documenso_webhook_secret` | string | Per-port webhook secret. Receiver tries each enabled secret with timing-safe equal. |
|
||||
| `eoi_default_pathway` | `'documenso-template' \| 'inapp'` | Which path is used when EOI is generated without explicit choice. |
|
||||
| `eoi_send_mode` | `'auto' \| 'manual'` | Auto = send branded invitation email immediately; manual = rep clicks Send. |
|
||||
| `embedded_signing_host` | string | Public host that wraps Documenso URLs into `{host}/sign/<type>/<token>`. |
|
||||
| `documenso_contract_template_id` | int (optional) | Optional template for sales contracts. Blank = upload-and-place-fields per deal. |
|
||||
| `documenso_reservation_template_id` | int (optional) | Optional template for reservation agreements. Same logic as contract. |
|
||||
|
||||
---
|
||||
|
||||
## Document type matrix
|
||||
|
||||
| Type | Generation flow | Signers | Field placement |
|
||||
| --------------- | ----------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- |
|
||||
| **EOI** | Documenso template (`eoi_template_id`) + form-fill values | Static: client, developer, approver (per-port) | Templated — fields baked into Documenso template |
|
||||
| **Contract** | Per-deal upload (drafted custom). Template fallback if configured | Custom per deal — rep specifies | Per-deal placement — default footer-anchored fallback |
|
||||
| **Reservation** | Per-deal upload OR template if configured | Custom per deal | Per-deal placement |
|
||||
|
||||
## Documenso field types
|
||||
|
||||
Custom-uploaded documents (contracts, reservations) need a per-deal field placement step — different documents need different mixes. The CRM exposes the full Documenso-supported field palette so reps can place whatever the document calls for without code changes.
|
||||
|
||||
| Field type | Use case | Needs `fieldMeta`? | What goes in meta |
|
||||
| ---------------- | ------------------------------------------------------- | ------------------ | --------------------------------------------------- |
|
||||
| `SIGNATURE` | Drawn signature — almost every signing flow | No | — |
|
||||
| `FREE_SIGNATURE` | Type-or-draw signature variant | No | — |
|
||||
| `INITIALS` | Per-page initials block | No | — |
|
||||
| `DATE` | Auto-fills the date when the recipient signs | No | — |
|
||||
| `EMAIL` | Auto-fills the recipient's email | No | — |
|
||||
| `NAME` | Auto-fills the recipient's name | No | — |
|
||||
| `TEXT` | Free text input (e.g. address, notes, place of signing) | Yes | `{ text?, label?, required?, readOnly? }` |
|
||||
| `NUMBER` | Numeric input with optional min/max | Yes | `{ numberFormat?, min?, max?, required? }` |
|
||||
| `CHECKBOX` | Boolean / single checkbox | Yes | `{ values: [{ checked, value }], validationRule? }` |
|
||||
| `DROPDOWN` | Pick from a fixed list | Yes | `{ values: [{ value }], defaultValue? }` |
|
||||
| `RADIO` | Mutually-exclusive options | Yes | `{ values: [{ checked, value }] }` |
|
||||
|
||||
Helper: [`fieldTypeNeedsMeta(type)`](../src/lib/services/documenso-client.ts) returns true for the configurable types so the placement UI knows when to surface a config side-panel.
|
||||
|
||||
`fieldMeta` is forwarded verbatim by [`placeFields()`](../src/lib/services/documenso-client.ts) on the v2 path. v1 silently ignores the property — fields render as blank inputs. Configurable behaviour (validation, defaults) only fires on v2 instances.
|
||||
|
||||
---
|
||||
|
||||
## Documenso v1 vs v2 endpoint mapping
|
||||
|
||||
The [`documenso-client.ts`](../src/lib/services/documenso-client.ts) abstracts both. Each function picks v1 or v2 from `getPortDocumensoConfig(portId).apiVersion`.
|
||||
|
||||
| Operation | v1 (1.13–1.32) | v2.x |
|
||||
| ------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| Create document from upload | `POST /api/v1/documents` (body: `{ title, document, recipients }`) | `POST /api/v2/envelope` |
|
||||
| Generate document from template | `POST /api/v1/templates/{id}/generate-document` | (template-from-envelope path) |
|
||||
| Send for signing | `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/{id}/send` |
|
||||
| Place a field | `POST /api/v1/documents/{id}/fields` (PIXEL coords, one at a time) | `POST /api/v2/envelope/field/create-many` (PERCENT, bulk) |
|
||||
| Get document state | `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{id}` |
|
||||
| Send reminder to one recipient | `POST /api/v1/documents/{id}/recipients/{rid}/remind` | `POST /api/v2/envelope/{id}/recipient/{rid}/remind` |
|
||||
| Download finalized PDF | `GET /api/v1/documents/{id}/download` → `{ downloadUrl }` then GET that URL | `GET /api/v2/envelope/{id}/download` (same shape) |
|
||||
| Cancel / void | `DELETE /api/v1/documents/{id}` | `DELETE /api/v2/envelope/{id}` |
|
||||
| Healthcheck | `GET /api/v1/health` | (v1 path used) |
|
||||
|
||||
**Field key rename in v2 responses**: `id` → `documentId` and recipient `id` → `recipientId`. Our [`normalizeDocument()`](../src/lib/services/documenso-client.ts) handles both shapes.
|
||||
|
||||
---
|
||||
|
||||
## Signing-flow lifecycle
|
||||
|
||||
```
|
||||
[rep clicks Generate] (CRM)
|
||||
│
|
||||
▼
|
||||
buildEoiContext(interestId, portId) service
|
||||
│
|
||||
▼
|
||||
generateAndSign(templateId, ctx, signers) creates Documenso doc
|
||||
│
|
||||
▼
|
||||
POST /documents/{id}/send {sendEmail:false} Documenso starts the chain;
|
||||
it does NOT email signers
|
||||
│
|
||||
▼
|
||||
extract signing URLs from response service
|
||||
│
|
||||
▼
|
||||
transformSigningUrl(url, host, role) wrap as {host}/sign/<role>/<token>
|
||||
│
|
||||
▼
|
||||
if eoi_send_mode === 'auto':
|
||||
sendSigningInvitation(client) our branded HTML email goes out
|
||||
else:
|
||||
UI shows the URL + Send button rep dispatches manually
|
||||
```
|
||||
|
||||
When the client signs:
|
||||
|
||||
```
|
||||
Documenso fires DOCUMENT_SIGNED webhook ──► /api/webhooks/documenso
|
||||
│
|
||||
▼
|
||||
verify x-documenso-secret (per-port lookup)
|
||||
│
|
||||
▼
|
||||
update document_signers row: status='signed', signedAt=...
|
||||
│
|
||||
▼
|
||||
if next signer in chain has not been notified:
|
||||
sendSigningInvitation(developer) cascading "your turn" email
|
||||
```
|
||||
|
||||
When the document reaches fully-signed:
|
||||
|
||||
```
|
||||
Documenso fires DOCUMENT_COMPLETED webhook
|
||||
│
|
||||
▼
|
||||
download signed PDF from Documenso
|
||||
│
|
||||
▼
|
||||
store in storage backend → creates files row
|
||||
│
|
||||
▼
|
||||
update document: status='completed', completedAt=...
|
||||
│
|
||||
▼
|
||||
sendSigningCompleted([client, developer, approver], pdfFileId)
|
||||
all parties get the signed PDF
|
||||
│
|
||||
▼
|
||||
update interest: pipelineStage='eoi_signed' (or contract_signed, etc)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Embedded signing on the marketing website
|
||||
|
||||
The CRM emits signing URLs in the form `{embeddedSigningHost}/sign/<role>/<token>`. The marketing website ([Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) hosts the page, embeds Documenso via `@documenso/embed-vue`'s `<EmbedSignDocument>`, and POSTs back to the CRM webhook on completion.
|
||||
|
||||
For the embed to work, the Documenso instance MUST send `Access-Control-Allow-Origin` headers permitting the website origin.
|
||||
|
||||
### nginx CORS block to apply on `signatures.portnimara.dev`
|
||||
|
||||
Add to the relevant `server { ... }` block:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
# CORS for embedded signing — allow the marketing-website origin
|
||||
# to load the Documenso signing iframe.
|
||||
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
# Preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# ... your existing proxy_pass block to Documenso
|
||||
}
|
||||
```
|
||||
|
||||
To support multiple website origins (e.g. Port Amador hosted on a different domain), use a regex:
|
||||
|
||||
```nginx
|
||||
set $cors_origin "";
|
||||
if ($http_origin ~* "^https://(portnimara\.com|portamador\.com)$") {
|
||||
set $cors_origin $http_origin;
|
||||
}
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's deferred vs landed in this build
|
||||
|
||||
**Landed:**
|
||||
|
||||
- Per-port admin settings — every Documenso config knob is exposed at `/admin/documenso`
|
||||
- Branded invitation, completion, and reminder email templates
|
||||
- `transformSigningUrl()` for `{host}/sign/<role>/<token>` URL wrapping
|
||||
- Documenso v1 + v2 dual-version client (existing)
|
||||
- Webhook handler with timing-safe per-port secret resolution (existing)
|
||||
- Contract + Reservation tab UI shells with paper-signed upload + "send for signing" placeholder
|
||||
- Stage-conditional tab visibility for EOI / Contract / Reservation
|
||||
|
||||
**Deferred (separate sessions):**
|
||||
|
||||
- Custom document upload-to-Documenso service for contract/reservation (POST PDF → place fields → send). The tabs currently surface a "coming soon" dialog.
|
||||
- Recipient + signing order configurator UI (rep specifies signers per deal for custom-uploaded docs).
|
||||
- Drag-and-drop field placement UI on uploaded PDF previews. The fallback when this lands will be `computeDefaultSignatureLayout()` (footer-anchored fields).
|
||||
- Webhook handler enhancements to track per-signer `sent_at`/`opened_at`/`signed_at` and trigger the cascading "your turn" branded emails. Currently the webhook just updates document status.
|
||||
- Auto-store signed PDFs in storage backend and trigger `sendSigningCompleted()` on `DOCUMENT_COMPLETED`. Old system has this; needs porting.
|
||||
|
||||
**Manual ops work for you:**
|
||||
|
||||
- Apply the nginx CORS block above on your prod Documenso instance.
|
||||
- Decide whether to upgrade prod Documenso to v2 (would unlock cleaner field placement + better envelope semantics).
|
||||
- Configure each port's developer/approver names and template IDs at `/[portSlug]/admin/documenso`.
|
||||
81
docs/eoi-documenso-field-mapping.md
Normal file
81
docs/eoi-documenso-field-mapping.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Documenso EOI Template — Field Mapping
|
||||
|
||||
**Purpose:** This doc is the canonical reference for mapping the Documenso EOI template's `formValues` keys to the new data model's `EoiContext` shape. It drives `buildDocumensoPayload()` (Task 11.2), the in-app Standard EOI HTML tokens (Task 11.3), and the Spec 2 importer's yacht/company hydration.
|
||||
|
||||
## Source
|
||||
|
||||
The legacy field list comes from `client-portal/server/api/eoi/generate-quick-eoi.ts`, specifically the POST body sent to `POST /api/v1/templates/{templateId}/generate-document` (Documenso template 8). The relevant lines in that file are around the `createDocumentPayload.formValues` object.
|
||||
|
||||
## Documenso template `formValues` keys
|
||||
|
||||
Documenso template IDs and recipient IDs are configured via env vars:
|
||||
|
||||
- `NUXT_DOCUMENSO_TEMPLATE_ID` (default: `8`)
|
||||
- `NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID` (default: `192`) — signing order 1
|
||||
- `NUXT_DOCUMENSO_DEVELOPER_RECIPIENT_ID` (default: `193`) — signing order 2
|
||||
- `NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID` (default: `194`) — APPROVER, signing order 3
|
||||
|
||||
The template exposes eight text fields (`formValues` keys) and two boolean checkboxes.
|
||||
|
||||
## Field mapping
|
||||
|
||||
The legacy template (Documenso template `8`, configured in production) auto-fills exactly the fields below. All eight text fields + two booleans are populated by `buildDocumensoPayload()` from the resolved `EoiContext`. Anything else on the form (signature, date, terms acknowledgment) is filled in by the client inside Documenso.
|
||||
|
||||
| Documenso key | Type | Legacy source | New `EoiContext` path | Notes |
|
||||
| -------------- | ------- | --------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). |
|
||||
| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. |
|
||||
| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. |
|
||||
| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. Empty string when no yacht is linked yet. |
|
||||
| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Boat dimension. Send as string. Documenso doesn't enforce numeric format. Empty string when not applicable. |
|
||||
| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. |
|
||||
| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". |
|
||||
| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | The interest's PRIMARY berth (resolved via `interest_berths.is_primary=true`). Empty string when no primary set. |
|
||||
| `Berth Range` | text | (new) | `context.eoiBerthRange` | **NEW IN PHASE 5** — compact range string for multi-berth EOIs (e.g. `"A1-A3, B5-B7"`) covering every junction row marked `is_in_eoi_bundle=true`. Empty string when the bundle is empty. **The live Documenso template (id `8`) does NOT yet have this field. Add a `Berth Range` text field to the template before multi-berth EOIs render the range; until then Documenso silently drops the value and only `Berth Number` (the primary mooring) renders.** |
|
||||
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). |
|
||||
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. |
|
||||
|
||||
**Backwards-compatibility guarantee**: every legacy `formValues` key is still emitted with the same name and type. The only addition is `Berth Range` (Phase 5). Documenso silently ignores unknown formValues keys, so old templates that don't have `Berth Range` will simply not render it — single-berth EOIs continue to work identically. No template changes are required for legacy use.
|
||||
|
||||
## Document `meta` fields (non-`formValues`)
|
||||
|
||||
| Documenso key | Type | Legacy source | New source |
|
||||
| ------------------------- | ---- | ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `meta.message` | text | `Dear ${interest['Full Name']}...` | `Dear ${context.client.fullName}, ...port name interpolated` |
|
||||
| `meta.subject` | text | `"Your LOI is ready to be signed"` | Same — constant. |
|
||||
| `meta.redirectUrl` | text | `"https://portnimara.com"` | `context.port.redirectUrl` if per-port; otherwise global app URL. |
|
||||
| `meta.distributionMethod` | text | `"NONE"` | Same — constant. We use manual send flow (Documenso webhook). |
|
||||
| `title` | text | `` `${interest['Full Name']}-EOI-NDA` `` | `` `${context.client.fullName}-EOI-NDA` `` |
|
||||
| `externalId` | text | `` `loi-${interestId}` `` | Same. |
|
||||
|
||||
## Recipients (non-`formValues`)
|
||||
|
||||
| Recipient | Role | Name | Email | Signing order |
|
||||
| ------------------- | -------- | ------------------------- | ----------------------------- | ------------- |
|
||||
| Client (signer) | SIGNER | `context.client.fullName` | `context.client.primaryEmail` | 1 |
|
||||
| Developer (signer) | SIGNER | `"David Mizrahi"` | `"dm@portnimara.com"` | 2 |
|
||||
| Approval (approver) | APPROVER | `"Abbie May"` | `"sales@portnimara.com"` | 3 |
|
||||
|
||||
The Developer and Approval recipients are currently hardcoded in the legacy flow. In the new system these should eventually come from port-level settings (e.g., `ports.settings.eoi.developerName` + email). For Task 11.2, keep them hardcoded as the legacy system does — tracking as TODO: "Replace hardcoded Developer/Approval recipients with port-level configuration."
|
||||
|
||||
## Company-owned yacht handling
|
||||
|
||||
The legacy flow has no concept of company ownership — the signer is always the interest's client. In the new system:
|
||||
|
||||
- If `context.yacht.ownerType === 'client'`: behavior unchanged.
|
||||
- If `context.yacht.ownerType === 'company'`: the interest's point-of-contact client still signs (they're the representative of the yacht's owning company), but an extra block should appear in the message body: `"On behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner)."`. This isn't a separate Documenso field — it's woven into `meta.message`.
|
||||
|
||||
Tracking this in the mapping doc rather than as a hard TODO because company-owned EOIs were rare in the legacy system and need product input before committing to the final wording.
|
||||
|
||||
## Deprecated fields (no longer sourced from `clients`)
|
||||
|
||||
The legacy system read these fields from the client row. They are now sourced elsewhere:
|
||||
|
||||
| Legacy source | New source |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `client.yachtName` | `yachts.name` via `interest.yachtId` |
|
||||
| `client.yachtLengthFt` | `yachts.lengthFt` via `interest.yachtId` |
|
||||
| `client.yachtWidthFt` | `yachts.widthFt` via `interest.yachtId` |
|
||||
| `client.yachtDraftFt` | `yachts.draftFt` via `interest.yachtId` |
|
||||
| `client.companyName` | `companies.name` via polymorphic owner resolution |
|
||||
| `client.berthSizeDesired` | Removed. Berth is picked via reservation, not text. |
|
||||
188
docs/error-handling.md
Normal file
188
docs/error-handling.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Error handling
|
||||
|
||||
## Overview
|
||||
|
||||
Every authenticated request runs inside an `AsyncLocalStorage` frame
|
||||
that carries a `requestId` (UUID) plus the resolved `portId` / `userId`
|
||||
/ HTTP method / path / start time. The id surfaces:
|
||||
|
||||
- as `X-Request-Id` on every response header (success or failure)
|
||||
- inside every pino log line emitted during the request
|
||||
- in the JSON error body returned to the client (`requestId` field)
|
||||
- as the primary key of the `error_events` row written when a 5xx fires
|
||||
|
||||
A user who hits a failure can copy the **Reference ID** from the toast
|
||||
and a super admin can paste it into `/<port>/admin/errors/<requestId>`
|
||||
to see the full request context, sanitized body, error stack, and a
|
||||
heuristic "likely culprit" hint.
|
||||
|
||||
## Throwing errors from a service
|
||||
|
||||
Use `CodedError` with a registered code:
|
||||
|
||||
```ts
|
||||
import { CodedError } from '@/lib/errors';
|
||||
|
||||
if (!hasReceipts && !ack) {
|
||||
throw new CodedError('EXPENSES_RECEIPT_REQUIRED');
|
||||
}
|
||||
```
|
||||
|
||||
The code drives:
|
||||
|
||||
- the HTTP status (defined in `src/lib/error-codes.ts`)
|
||||
- the **plain-text user-facing message** (no jargon — written for the
|
||||
rep on the phone with a customer)
|
||||
- the stable identifier the user can quote to support
|
||||
|
||||
For more verbose internal context — admin-only — use `internalMessage`:
|
||||
|
||||
```ts
|
||||
throw new CodedError('CROSS_PORT_LINK_REJECTED', {
|
||||
internalMessage: `interest ${a.id} (port ${a.portId}) ↔ berth ${b.id} (port ${b.portId})`,
|
||||
});
|
||||
```
|
||||
|
||||
The `internalMessage` lands in the `error_events` row and the admin
|
||||
inspector but **never** reaches the client.
|
||||
|
||||
## Adding a new error code
|
||||
|
||||
1. Open `src/lib/error-codes.ts`.
|
||||
2. Add an entry to the `ERROR_CODES` map. Convention: `DOMAIN_REASON`
|
||||
in SCREAMING_SNAKE_CASE.
|
||||
|
||||
```ts
|
||||
FOO_INVALID_BAR: {
|
||||
status: 400,
|
||||
userMessage: 'That bar value is no good. Please try another.',
|
||||
},
|
||||
```
|
||||
|
||||
3. Use it: `throw new CodedError('FOO_INVALID_BAR')`.
|
||||
4. The code, status, and message are now contractually stable —
|
||||
never rename a code once it has shipped. Documentation, UI, and
|
||||
external integrations may pin to it.
|
||||
|
||||
## Plain-text message guidelines
|
||||
|
||||
User-facing messages should:
|
||||
|
||||
- Avoid internal jargon (no "constraint violation", "FK", "row lock").
|
||||
- Be written for a rep on the phone with a customer.
|
||||
- Include the suggested next action when natural ("Ask an admin if you
|
||||
think you should").
|
||||
- Not include any technical detail that doesn't help the user — the
|
||||
request id + error code carry that.
|
||||
|
||||
Verbose technical detail belongs in `internalMessage` (admin-only).
|
||||
|
||||
## Client side
|
||||
|
||||
In a `useMutation`, render errors with the shared helper:
|
||||
|
||||
```ts
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => apiFetch('/api/v1/foo', { method: 'POST', body: { ... } }),
|
||||
onSuccess: () => { ... },
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
```
|
||||
|
||||
The toast renders three lines:
|
||||
|
||||
```
|
||||
{plain-text message}
|
||||
|
||||
Error code: EXPENSES_RECEIPT_REQUIRED
|
||||
Reference ID: 8f3c-ab12-… [Copy ID]
|
||||
```
|
||||
|
||||
The "Copy ID" action puts the request id on the clipboard so the
|
||||
user can paste it into a support ticket.
|
||||
|
||||
## Admin inspector
|
||||
|
||||
`/<port>/admin/errors` lists captured 5xx errors:
|
||||
|
||||
- Status badge + method + path
|
||||
- "Likely culprit" badge (heuristic — Postgres SQLSTATE, error name,
|
||||
stack-path patterns, message keywords)
|
||||
- Truncated error name + message
|
||||
- Timestamp + reference id
|
||||
|
||||
Click any row for `/<port>/admin/errors/<requestId>` which shows:
|
||||
|
||||
- Request shape (method / path / when / duration / port / user / IP / UA)
|
||||
- Likely culprit + plain-English hint + subsystem tag
|
||||
- Full error name, message, stack head (first 4 KB)
|
||||
- Sanitized request body excerpt (max 1 KB; sensitive keys redacted)
|
||||
- Raw metadata (Postgres SQLSTATE codes, internalMessage, etc.)
|
||||
|
||||
Permission: `admin.view_audit_log`. Super admins see every port's
|
||||
errors; regular admins are scoped to their active port.
|
||||
|
||||
## What gets persisted
|
||||
|
||||
| Status | error_events row? | Toast shows code? |
|
||||
| ------ | ----------------- | ----------------- |
|
||||
| 4xx | No | Yes |
|
||||
| 5xx | **Yes** | Yes |
|
||||
|
||||
4xx errors are user-action mistakes (validation, not-found, permission
|
||||
denied). They're visible in the audit log but not the error inspector
|
||||
— that table is reserved for platform faults.
|
||||
|
||||
5xx errors hit the `errorEvents` table via `captureErrorEvent` inside
|
||||
`errorResponse`, which:
|
||||
|
||||
1. Reads the request context from ALS.
|
||||
2. Sanitizes + truncates the body (1 KB cap, sensitive keys redacted).
|
||||
3. Pulls Postgres `code` / `severity` / `cause.code` if the underlying
|
||||
error is a `postgres` driver error.
|
||||
4. Truncates the stack to 4 KB.
|
||||
5. Inserts one row keyed on `requestId` with `ON CONFLICT DO NOTHING`.
|
||||
|
||||
Failure to persist NEVER throws — the user is already getting an
|
||||
error response; we don't want a logging-pipeline failure to mask it.
|
||||
|
||||
## Likely-culprit classifier
|
||||
|
||||
`src/lib/error-classifier.ts` runs four passes against an
|
||||
`error_events` row, first match wins:
|
||||
|
||||
1. **Postgres SQLSTATE** (from `metadata.code`): 23502 NOT NULL,
|
||||
23503 FK, 23505 unique, 23514 CHECK, 42703 schema drift, 42P01
|
||||
missing table, 40001 serialization, 53300 connection limit, …
|
||||
2. **Error class name**: `AbortError`, `TimeoutError`, `FetchError`,
|
||||
`ZodError`.
|
||||
3. **Stack path**: `/lib/storage/`, `/lib/email/`, `documenso`,
|
||||
`openai|claude`, `/queue/workers/`.
|
||||
4. **Message free-text**: `econnrefused`, `rate limit`, `timeout`,
|
||||
`unauthorized|invalid api key`.
|
||||
|
||||
Returns `null` when nothing matches; the inspector renders
|
||||
"Uncategorized" in that case. Adding a new heuristic is a one-line
|
||||
edit to the relevant array.
|
||||
|
||||
## Pruning
|
||||
|
||||
`error_events` rows are dropped after 90 days by the maintenance
|
||||
worker (TODO: confirm the worker has the deletion path; if not, add
|
||||
a periodic job that runs `DELETE FROM error_events WHERE created_at <
|
||||
now() - interval '90 days'`).
|
||||
|
||||
## Migration path for legacy throws
|
||||
|
||||
Existing `NotFoundError` / `ForbiddenError` / `ConflictError` /
|
||||
`ValidationError` / `RateLimitError` still work — the user-facing
|
||||
messages on these classes have been rewritten to plain-text defaults.
|
||||
|
||||
Migration to `CodedError` happens opportunistically: when touching a
|
||||
service to fix something else, swap the throw site for a registered
|
||||
code.
|
||||
|
||||
A follow-up audit pass should walk `git grep "throw new ValidationError"`
|
||||
and migrate the user-impactful ones to specific codes.
|
||||
123
docs/operations/outbound-comms-safety.md
Normal file
123
docs/operations/outbound-comms-safety.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Outbound communications safety net
|
||||
|
||||
**Last reviewed:** 2026-05-03
|
||||
**Owner:** matt@portnimara.com
|
||||
|
||||
This doc enumerates every channel through which the CRM can produce
|
||||
outbound communication (email, document signing, webhooks) and describes
|
||||
how each channel respects the `EMAIL_REDIRECT_TO` env var. The goal: a
|
||||
single environment flip pauses **all** outbound traffic, so a production
|
||||
data import, dedup migration dry-run, or staging environment can run
|
||||
against real data without anyone getting paged or spammed.
|
||||
|
||||
> **Single env switch:** when `EMAIL_REDIRECT_TO` is set to an address,
|
||||
> all outbound communication is rerouted there or short-circuited. Unset
|
||||
> it in production.
|
||||
|
||||
---
|
||||
|
||||
## Channels
|
||||
|
||||
### 1. Direct email (`sendEmail`)
|
||||
|
||||
**Path:** `src/lib/email/index.ts` → `sendEmail()` → nodemailer SMTP transport.
|
||||
|
||||
**Safety:** YES — covered.
|
||||
|
||||
When `EMAIL_REDIRECT_TO` is set, `sendEmail()` rewrites the `to` header
|
||||
to the redirect address and prefixes the subject with
|
||||
`[redirected from <orig>]`. The original recipient is logged.
|
||||
|
||||
**Call sites** (all flow through `sendEmail`, so all are covered):
|
||||
|
||||
- `src/lib/services/portal-auth.service.ts` — portal activation + reset
|
||||
- `src/lib/services/crm-invite.service.ts` — CRM user invitations
|
||||
- `src/lib/services/document-templates.ts` — template-generated PDFs sent
|
||||
as attachments (the PDF body is generated locally; the email itself
|
||||
goes through SMTP)
|
||||
- `src/lib/services/email-compose.service.ts` — ad-hoc emails composed
|
||||
in the in-app UI
|
||||
- `src/lib/services/gdpr-export.service.ts` — GDPR export delivery
|
||||
|
||||
### 2. Documenso e-signature recipients
|
||||
|
||||
**Path:** `src/lib/services/documenso-client.ts` → `createDocument()` /
|
||||
`generateDocumentFromTemplate()` → Documenso REST API.
|
||||
|
||||
**Safety:** YES — covered as of 2026-05-03.
|
||||
|
||||
Documenso's own server sends the signing-request email on our behalf.
|
||||
We can't intercept that at the SMTP layer because it's external. The
|
||||
fix is at the REST-call boundary: when `EMAIL_REDIRECT_TO` is set,
|
||||
`createDocument` rewrites every recipient's email to the redirect
|
||||
address and prefixes the recipient name with `(was: <orig email>)` so
|
||||
the doc is still traceable to its intended recipient.
|
||||
`generateDocumentFromTemplate` does the same for both shapes the
|
||||
template-generate endpoint accepts (v1.13 `formValues.*Email` keys and
|
||||
v2.x `recipients` array).
|
||||
|
||||
The redirect happens **before** the API call, so even if Documenso has
|
||||
its own retry logic the original email never leaves our process.
|
||||
|
||||
### 3. Webhooks (outbound to user-configured URLs)
|
||||
|
||||
**Path:** `src/lib/queue/workers/webhooks.ts` → BullMQ job → `fetch(webhook.url, ...)`.
|
||||
|
||||
**Safety:** YES — covered as of 2026-05-03.
|
||||
|
||||
When `EMAIL_REDIRECT_TO` is set, the webhook worker short-circuits
|
||||
before the HTTP call. The delivery row is marked `dead_letter` with a
|
||||
human-readable reason so it's still visible in the deliveries listing.
|
||||
The SSRF guard remains in place independently.
|
||||
|
||||
### 4. WhatsApp / phone deep-links
|
||||
|
||||
**Path:** `<a href="https://wa.me/...">` and `<a href="tel:...">` in
|
||||
client / interest detail headers.
|
||||
|
||||
**Safety:** N/A — user-initiated only.
|
||||
|
||||
These are deep links the user explicitly clicks. No automated dispatch.
|
||||
A deep link click opens the user's WhatsApp / phone app, which is the
|
||||
intended interaction. No safety net needed.
|
||||
|
||||
### 5. SMS
|
||||
|
||||
Not implemented. The `interests.preferredContactMethod` enum includes
|
||||
`'sms'` as a value but no sending path exists. If/when SMS is added (e.g.
|
||||
via Twilio), the new send function should respect `EMAIL_REDIRECT_TO`
|
||||
the same way `sendEmail` does — log the original number, drop the
|
||||
message, or reroute to a configurable `SMS_REDIRECT_TO` env.
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist before importing real data
|
||||
|
||||
- [ ] `.env` has `EMAIL_REDIRECT_TO=<my-address>` set.
|
||||
- [ ] Restart dev server (or worker) so the new env is picked up — env
|
||||
vars are read at import time in some paths.
|
||||
- [ ] Send a test email via `pnpm tsx scripts/dev-trigger-portal-invite.ts`
|
||||
or similar. Confirm subject is prefixed with `[redirected from ...]`.
|
||||
- [ ] Trigger an EOI send through the UI (any client). Confirm Documenso
|
||||
shows the redirect address as recipient (not the real client email).
|
||||
- [ ] If any webhooks are configured, trigger an event that fires one and
|
||||
confirm the delivery is recorded as `dead_letter` with the
|
||||
"EMAIL_REDIRECT_TO is set" reason.
|
||||
- [ ] Run the NocoDB migration `--dry-run` to count clients/interests; the
|
||||
`--apply` step is what creates real records but emails/webhooks are
|
||||
still gated by the redirect env.
|
||||
|
||||
## Production cutover
|
||||
|
||||
When ready to go live:
|
||||
|
||||
1. Run a final dry-run of the data migration with `EMAIL_REDIRECT_TO` set
|
||||
to a sandbox address.
|
||||
2. Verify the snapshot looks right (counts, client coverage).
|
||||
3. Unset `EMAIL_REDIRECT_TO` in the production env.
|
||||
4. Restart the app + worker.
|
||||
5. Run the migration with `--apply`. From this point forward, real
|
||||
recipients will receive real comms.
|
||||
|
||||
If you ever need to re-pause outbound (e.g. handling a security incident,
|
||||
re-importing on top of existing data), set `EMAIL_REDIRECT_TO` again.
|
||||
199
docs/runbooks/backup-and-restore.md
Normal file
199
docs/runbooks/backup-and-restore.md
Normal 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 15–30 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`.
|
||||
186
docs/runbooks/email-deliverability.md
Normal file
186
docs/runbooks/email-deliverability.md
Normal 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.
|
||||
56
docs/runbooks/permission-audit.md
Normal file
56
docs/runbooks/permission-audit.md
Normal 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. |
|
||||
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
# Country / Phone / Timezone — i18n form polish
|
||||
|
||||
**Status:** Agenda — awaiting prioritization (likely Phase B or B.5)
|
||||
**Date:** 2026-04-28
|
||||
**Phase:** Cross-cutting; touches every form that captures contact data
|
||||
|
||||
## Why
|
||||
|
||||
Today every CRM form takes free-text strings for nationality, phone, and timezone. That's fine for a marina with one operator typing it in once, but it leaks operator inconsistencies into reports and breaks any later system that consumes these fields (Documenso prefill, public website inquiry, portal sync, exports). For a multi-port platform that's about to onboard non-Polish-speaking residential clients, the data quality matters.
|
||||
|
||||
Three coupled UX upgrades:
|
||||
|
||||
1. **Nationality → ISO-3166 country dropdown.** Searchable. Stores ISO alpha-2 code (`'GB'`), displays localized country name.
|
||||
2. **Phone → country-code dropdown + format-as-you-type.** E.164 storage on the wire, formatted display per country.
|
||||
3. **Timezone → autofilled from country with override dropdown.** Most countries are single-zone; the few that aren't (US, RU, AU, BR, CA, ID, KZ, MN, MX, CD) get a sub-select. Stores IANA TZ string (`'Europe/Warsaw'`).
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- New shared primitives: `<CountryCombobox>`, `<PhoneInput>`, `<TimezoneCombobox>`
|
||||
- ISO-3166 country list bundled (no API call); names from `Intl.DisplayNames` with locale fallback to English
|
||||
- Country → primary IANA timezone map (~250 entries, JSON)
|
||||
- Phone parsing/validation/formatting via `libphonenumber-js` (server + client)
|
||||
- Wire into every form that captures contact data:
|
||||
- `<ClientForm>` (name, nationality, phone)
|
||||
- `<ResidentialClientDetail>` inline editor (nationality, phone, place_of_residence — country-aware)
|
||||
- `<CompanyForm>` (incorporation_country)
|
||||
- `<PortalActivateForm>` (phone)
|
||||
- public inquiry form (form-template renderer, when phone field present)
|
||||
- DB migration: store ISO codes (`countries`, `nationality_iso`), E.164 phone (`phone_e164`), IANA timezone (`timezone`)
|
||||
- Backfill: best-effort parse existing free-text into the new columns; keep originals as `_legacy` for one release cycle
|
||||
- Display: localized country name in tables/detail pages; phone formatted per country (e.g. `+44 20 7946 0958`); timezone shown as friendly `'London (UTC+1)'` when current
|
||||
- Tests: unit (parser edge cases), integration (form submit → E.164 storage), smoke (typing + selecting flows)
|
||||
|
||||
### Out of scope (deferred)
|
||||
|
||||
- Multilingual UI surface (only the country _names_ localize via `Intl.DisplayNames`; rest of the UI stays English for now)
|
||||
- Subdivision picker (states/provinces) — only top-level country
|
||||
- Phone number geocoding / carrier lookup
|
||||
- Address autocomplete (Google Places, etc.)
|
||||
- Currency localization
|
||||
- RTL layout
|
||||
|
||||
## Library choices
|
||||
|
||||
| Concern | Library | Why |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Phone input + flag dropdown | `omeralpi/shadcn-phone-input` | Built on shadcn-ui's `Input` primitive (zero styling friction with our component library), wraps `libphonenumber-js`, ships with country dropdown + format-as-you-type. Small bundle. |
|
||||
| Phone parsing/validation | `libphonenumber-js` | Google's library, ~88 benchmark, used by every popular React phone input. Server-side validation in zod. |
|
||||
| Country list | Bundled JSON of ISO-3166 alpha-2 codes + 3-letter codes + display names (English baseline) | No need for the heavier `country-state-city` databases — we don't need cities or states yet. |
|
||||
| Country → timezone | Hand-curated `country-timezones.json` (250 entries, ~10kb) sourced from `country-tz` or moment-timezone's data | Static, no network call. For multi-zone countries, expose a sub-select. |
|
||||
| Timezone formatting | `Intl.DateTimeFormat` (built-in) | Browser API; renders `'Europe/Warsaw (UTC+1)'`-style labels. |
|
||||
| Timezone list | `Intl.supportedValuesOf('timeZone')` (built-in, ~600 entries) | Used as the override dropdown when a user wants a non-primary zone. |
|
||||
|
||||
Bundle impact: `libphonenumber-js` mobile build is ~80 KB gz; `shadcn-phone-input` is ~5 KB; country/timezone JSONs ~30 KB. All client-side, lazy-loaded on first form render via `next/dynamic`.
|
||||
|
||||
## Schema deltas
|
||||
|
||||
```sql
|
||||
-- clients
|
||||
ALTER TABLE clients ADD COLUMN nationality_iso text; -- 'GB'
|
||||
ALTER TABLE clients ADD COLUMN timezone text; -- 'Europe/London'
|
||||
-- existing 'nationality' free-text column stays for a release; new code reads ISO
|
||||
|
||||
-- client_contacts (or wherever phone lives)
|
||||
ALTER TABLE client_contacts ADD COLUMN value_e164 text; -- '+442079460958'
|
||||
ALTER TABLE client_contacts ADD COLUMN value_country text; -- 'GB' (where the number was parsed against)
|
||||
-- existing 'value' stays as the human-displayable formatted form
|
||||
|
||||
-- residential_clients — same pattern
|
||||
ALTER TABLE residential_clients ADD COLUMN nationality_iso text;
|
||||
ALTER TABLE residential_clients ADD COLUMN timezone text;
|
||||
ALTER TABLE residential_clients ADD COLUMN phone_e164 text;
|
||||
ALTER TABLE residential_clients ADD COLUMN phone_country text;
|
||||
|
||||
-- companies
|
||||
ALTER TABLE companies ADD COLUMN incorporation_country_iso text;
|
||||
```
|
||||
|
||||
Indexes: `idx_clients_nationality_iso`, `idx_clients_timezone` (cheap; powers analytics filters later).
|
||||
|
||||
## Component primitives
|
||||
|
||||
```tsx
|
||||
<CountryCombobox
|
||||
value={iso} // 'GB' | undefined
|
||||
onChange={(iso) => …}
|
||||
locale="en" // for name lookup; default to navigator.language
|
||||
variant="default" | "compact" // compact = icon-only flag, default = name
|
||||
/>
|
||||
|
||||
<PhoneInput
|
||||
value={e164} // '+442079460958'
|
||||
onChange={({ e164, country }) => …}
|
||||
defaultCountry={'GB'} // pre-selects the dropdown
|
||||
required={false}
|
||||
/>
|
||||
|
||||
<TimezoneCombobox
|
||||
value={iana} // 'Europe/London'
|
||||
onChange={(iana) => …}
|
||||
countryHint={'GB'} // when set, narrows the dropdown to matching zones first
|
||||
/>
|
||||
```
|
||||
|
||||
All three are shadcn-styled, keyboard-accessible, support form integration with react-hook-form + zod.
|
||||
|
||||
## Validators
|
||||
|
||||
```ts
|
||||
// src/lib/validators/contact.ts
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
export const phoneE164Schema = z
|
||||
.string()
|
||||
.refine((v) => isValidPhoneNumber(v), 'Invalid phone number');
|
||||
|
||||
export const isoCountrySchema = z
|
||||
.string()
|
||||
.length(2)
|
||||
.toUpperCase()
|
||||
.refine((c) => ISO_COUNTRIES.has(c), 'Unknown country');
|
||||
|
||||
export const ianaTimezoneSchema = z
|
||||
.string()
|
||||
.refine((tz) => Intl.supportedValuesOf('timeZone').includes(tz), 'Unknown timezone');
|
||||
```
|
||||
|
||||
## Backfill plan
|
||||
|
||||
A migration script (`scripts/backfill-iso-and-e164.ts`) that:
|
||||
|
||||
1. For each client/residential_client, attempt `libphonenumber-js` `parsePhoneNumber(rawPhone, { defaultCountry: 'PL' })` → if valid, write `phone_e164` + `phone_country`.
|
||||
2. For each free-text `nationality`, fuzzy-match against the country name list (exact match first, then Levenshtein ≤2). Write `nationality_iso` if confident.
|
||||
3. For each timezone, exact-match against IANA list. Otherwise leave null and let user fill it.
|
||||
4. Log unparseable rows to `backfill-iso-report.csv` for manual review.
|
||||
|
||||
Run on staging first; require dry-run flag.
|
||||
|
||||
## Build sequence
|
||||
|
||||
| # | PR | Effort | Depends on |
|
||||
| --- | ------------------------------------------------------------ | ------ | ---------- |
|
||||
| 1 | Country list JSON + ISO sets + `<CountryCombobox>` primitive | 0.5d | — |
|
||||
| 2 | `libphonenumber-js` integration + `<PhoneInput>` primitive | 1d | — |
|
||||
| 3 | Country → timezone JSON + `<TimezoneCombobox>` primitive | 0.5d | 1 |
|
||||
| 4 | Schema deltas + drizzle migrations + zod validators | 0.5d | — |
|
||||
| 5 | Wire into ClientForm + ClientDetail inline editors | 1d | 1, 2, 3, 4 |
|
||||
| 6 | Wire into ResidentialClientDetail | 0.5d | 5 |
|
||||
| 7 | Wire into CompanyForm | 0.5d | 1 |
|
||||
| 8 | Public inquiry form template renderer support | 0.5d | 2 |
|
||||
| 9 | Backfill script + dry-run runbook | 1d | 4 |
|
||||
| 10 | Smoke + integration tests | 1d | 5–9 |
|
||||
|
||||
Total: ~7 dev days. Self-contained; no external dependencies on Phase B (analytics/alerts).
|
||||
|
||||
## Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Bundle bloat from libphonenumber data | Use the `mobile` metadata build, lazy-import via `next/dynamic` |
|
||||
| Existing free-text data is too messy to backfill | Keep the legacy column for one release; expose a "needs review" badge in admin |
|
||||
| Multi-zone country UX confusion | Sub-select only appears when country is multi-zone; otherwise zone is hidden behind "Override" |
|
||||
| Public inquiry form breaks if phone is required and user can't find their country | Default to PL, search by country name and dial code |
|
||||
|
||||
## Open questions for the user
|
||||
|
||||
- Which port's locale should drive the _default_ country in `<PhoneInput>` (Poland for now, or detect from browser)?
|
||||
- Should existing free-text `nationality` field be removed once backfilled, or kept indefinitely as a fallback?
|
||||
- Is there an appetite for adding the same treatment to subdivision (state/region/voivodship) selectors, or strictly country-level for now?
|
||||
775
docs/superpowers/specs/2026-04-28-documents-hub-design.md
Normal file
775
docs/superpowers/specs/2026-04-28-documents-hub-design.md
Normal file
@@ -0,0 +1,775 @@
|
||||
# Documents Hub, Reservation Agreements, and Visual Polish (Phase A)
|
||||
|
||||
**Status:** Draft — awaiting final review
|
||||
**Date:** 2026-04-28
|
||||
**Phase:** A of D (B = Insights & Alerts; C = Website integration; D = Pre-prod ops)
|
||||
|
||||
## Overview
|
||||
|
||||
Phase A delivers a unified Documents Hub that tracks every signature-based document (EOI, Reservation Agreements, NDAs, ad-hoc uploads), generalises the existing single-purpose EOI dialog into a multi-format create-document wizard, builds the missing CRM-side reservation detail page with an end-to-end agreement workflow, polishes the reminder framework so non-EOI docs auto-remind correctly, and applies a system-wide visual upgrade to the polished-SaaS aesthetic the project already has tokens for.
|
||||
|
||||
The project already ships a usable CRM with auth, multi-tenancy, full client/yacht/company/interest/berth/reservation data model, an EOI dual-path (Documenso template + in-app PDF), socket-driven real-time updates, and 130 smoke specs. What's missing for the next release: a single place to see what documents need signing and chase the people who haven't signed.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
### In scope (this spec)
|
||||
|
||||
- New `/[port]/documents` hub page replacing the existing list
|
||||
- New `/[port]/documents/[id]` document detail page
|
||||
- Generalised create-document wizard supporting four template formats (HTML, PDF AcroForm fillable, PDF overlay-positioned, Documenso-rendered) plus ad-hoc PDF upload
|
||||
- New `/[port]/berth-reservations/[id]` reservation detail page with agreement-generation flow
|
||||
- Reservation Agreement as a first-class document type with default template seeded
|
||||
- Email composer extended with attachments and a System-vs-User From selector (admin-gated)
|
||||
- Reminder framework: per-template cadence, per-doc override, per-doc disable, per-signer manual reminders
|
||||
- Documenso version-aware abstraction layer covering field placement and document voiding across v1.13.1 and v2.x
|
||||
- System-wide visual polish: shadow scale, gradient layer, animation tokens, primitive components (`<StatusPill>`, `<KPITile>`, `<EmptyState>`, polished `<PageHeader>`), applied across all list and detail pages
|
||||
- Mobile-responsive sweep across every page touched
|
||||
- Comprehensive test coverage: unit, integration, smoke, exhaustive click-through, real-API round-trips, visual baseline regeneration
|
||||
|
||||
### Explicitly out of scope (deferred to later phases)
|
||||
|
||||
- Analytics dashboard, alert framework, interests-by-berth view, expense duplicate detection (Phase B)
|
||||
- Website-side integration: `/api/form/[token]/data` prefill endpoint, `/api/webhook/document-signed` callback receiver, public-endpoint shape compat (Phase C)
|
||||
- NocoDB to Postgres data migration, email deliverability (DKIM/SPF/DMARC), Sentry error reporting, audit log retention, performance baseline at 5k clients / 50k interests, backup/restore automation, production deploy readiness (Phase D)
|
||||
- Native in-CRM PDF field-placement editor (deferred until upload-path pain emerges; Phase A v1 ships with auto-placed footer signature fields and a "Customize fields in Documenso" link)
|
||||
- Word `.docx` template upload (deferred; PDF prioritized because Word adds LibreOffice/CloudConvert toolchain dependency without saving the field-placement step)
|
||||
- Per-interest "silence all reminders" toggle (was implicit in old `interests.reminderEnabled` gating which this spec drops; can be re-added as a bulk action if anyone misses it)
|
||||
|
||||
## Information architecture
|
||||
|
||||
### URL surface
|
||||
|
||||
```
|
||||
/[port]/documents hub (replaces existing list)
|
||||
/[port]/documents/[id] document detail (new)
|
||||
/[port]/documents/new create-document wizard (new)
|
||||
/[port]/berth-reservations/[id] reservation detail (new)
|
||||
/[port]/admin/templates existing; extended for new template formats
|
||||
/[port]/admin/email existing; one new toggle
|
||||
```
|
||||
|
||||
### Schema deltas
|
||||
|
||||
```
|
||||
documents — additions:
|
||||
+ reservation_id text null references berth_reservations(id)
|
||||
+ reminders_disabled boolean default false
|
||||
+ reminder_cadence_override int null
|
||||
|
||||
document_templates — additions:
|
||||
+ reminder_cadence_days int null (null = no auto-reminders)
|
||||
+ template_format text default 'html' ('html'|'pdf_form'|'pdf_overlay'|'documenso_render')
|
||||
+ source_file_id text null references files(id)
|
||||
+ documenso_template_id text null
|
||||
+ field_mapping jsonb default '{}' (pdf_form: { acroFieldName: mergeToken })
|
||||
+ overlay_positions jsonb default '[]' (pdf_overlay: [{token, page, x, y, fontSize}])
|
||||
|
||||
document_templates.body_html — relax to nullable (only required when template_format='html')
|
||||
|
||||
document_watchers — new table:
|
||||
document_id text not null references documents(id) on delete cascade
|
||||
user_id text not null references users(id)
|
||||
added_by text not null references users(id)
|
||||
added_at timestamptz default now()
|
||||
primary key (document_id, user_id)
|
||||
|
||||
documents indexes — additions:
|
||||
+ idx_docs_reservation on (reservation_id)
|
||||
+ idx_docs_status_port on (port_id, status) — powers tab counts cheaply
|
||||
|
||||
document_watchers indexes:
|
||||
+ idx_doc_watchers_doc on (document_id)
|
||||
+ idx_doc_watchers_user on (user_id)
|
||||
|
||||
documents.documentType enum — already includes 'reservation_agreement'; no migration needed
|
||||
documents.status enum — already accepts 'expired'; no migration needed
|
||||
documentSigners.status enum — pending|signed|declined; no migration needed
|
||||
```
|
||||
|
||||
Backfill (one statement, safe to run in same migration):
|
||||
|
||||
```sql
|
||||
UPDATE document_templates SET reminder_cadence_days = 1 WHERE template_type = 'eoi';
|
||||
```
|
||||
|
||||
This preserves the existing 1-day-effective reminder cadence for existing EOI templates. Admins can edit per-template later.
|
||||
|
||||
After running migration on a dev/staging server, restart `next dev` to flush postgres.js prepared-statement cache (existing project convention).
|
||||
|
||||
### Polymorphic ownership pattern
|
||||
|
||||
Documents already use the multi-FK pattern (`interest_id`, `client_id`, `yacht_id`, `company_id` as separate nullable columns). Adding `reservation_id` matches this. No conversion to polymorphic discriminator columns despite yachts and invoices using that pattern; staying consistent with the existing documents shape avoids a destructive migration.
|
||||
|
||||
### Service-layer changes
|
||||
|
||||
- `documents.service.ts`:
|
||||
- `createFromWizard(portId, data, meta)` — dispatches across template/upload paths
|
||||
- `createFromUpload(portId, data, meta)` — new upload-driven path; calls Documenso `createDocument`, stores file in MinIO via `files` service, mirrors to `documents` + `documentSigners`, optionally calls `sendDocument` if `sendImmediately`
|
||||
- `cancelDocument(documentId, portId, meta)` — user-initiated cancel; calls Documenso void, updates DB status, logs event
|
||||
- `composeSignedDocEmail(documentId, portId)` — returns prefilled `{ to, cc, subject, body, attachments, defaultSenderType }` for the composer
|
||||
- `getDocumentDetail(id, portId)` — single-roundtrip aggregator returning doc + signers + events + watchers + linked-entity summary
|
||||
|
||||
- `document-templates.ts`:
|
||||
- `generateAndSign` extended for new `template_format` values
|
||||
- `fillAcroForm(sourceFile, fieldMapping, mergeContext)` — pdf-lib AcroForm fill
|
||||
- `drawOverlay(sourceFile, overlayPositions, mergeContext)` — pdf-lib text-draw at positions
|
||||
- Documenso-render path uses existing `generateDocumentFromTemplate`
|
||||
|
||||
- `documenso-client.ts`:
|
||||
- `placeFields(docId, fields, portId?)` — version-aware bulk field placement
|
||||
- `placeDefaultSignatureFields(docId, recipientIds, portId?)` — auto-position one SIGNATURE per recipient at footer
|
||||
- `voidDocument(docId, portId?)` — version-aware doc void/delete
|
||||
- Coordinate normalization helpers (caller passes percent 0-100; converted to pixels for v1 using cached page dimensions)
|
||||
|
||||
- `document-reminders.ts`:
|
||||
- `sendReminderIfAllowed(documentId, portId, options?)` — extended signature with optional `signerId` and `auto: boolean`
|
||||
- `processReminderQueue(portId)` — query rewritten around `documents.reminder_cadence_override ?? template.reminder_cadence_days`; drops `interests.reminderEnabled` gating
|
||||
|
||||
- `notifications.service.ts`:
|
||||
- `notifyDocumentEvent(docId, eventType)` — fans out to creator + entity-assignee + watchers; existing socket events keep firing
|
||||
|
||||
- New: `reservation-agreement-context.ts`:
|
||||
- `buildReservationAgreementContext(reservationId, portId)` — joins reservation -> client + yacht + berth -> port; returns context shape for template merge
|
||||
|
||||
- `email-compose.service.ts`:
|
||||
- Validator extended: `{ senderType: 'system'|'user', accountId? (when user), attachments[] }`
|
||||
- System path: calls `lib/email/index.ts → sendEmail()` with `portId` + attachments; logs `documentEvents` row `signed_doc_emailed`; skips `email_messages`/`email_threads` writes
|
||||
- User path: existing flow, with attachments resolution from `files` table
|
||||
- Port-isolation: cross-port `fileId` returns 403
|
||||
|
||||
- `lib/email/index.ts`:
|
||||
- `SendEmailOptions.attachments?: Array<{ fileId, filename? }>` — fetches files from MinIO, passes to nodemailer
|
||||
|
||||
## Documents hub page
|
||||
|
||||
Replaces existing `/[port]/documents` list.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
[ Header strip: title, KPI sub-line, "+ New document" button ]
|
||||
|
||||
[ Tabs: All | Awaiting them (count) | Awaiting me (count) | Completed | Expired ]
|
||||
|
||||
[ Search · Type · Status · Sent · Watcher filter chips · saved-view selector · overflow ]
|
||||
|
||||
[ Table:
|
||||
checkbox | Document | Type pill | Subject pill | Status (X/Y signed + dot) | Sent
|
||||
▾ expand row inline to show signers + watchers strip
|
||||
]
|
||||
|
||||
[ Sticky bulk-action bar appears when ≥1 row checked:
|
||||
"N selected" | Remind unsigned | Cancel | Export | pagination
|
||||
]
|
||||
```
|
||||
|
||||
### Tab queries
|
||||
|
||||
- All — every document in port
|
||||
- Awaiting them — `status IN ('sent','partially_signed')` AND has pending signer != current user
|
||||
- Awaiting me — at least one `documentSigners` row matching `signer_email = current user email` AND `status = 'pending'`
|
||||
- Completed — `status IN ('completed','signed')`
|
||||
- Expired — `status = 'expired'` OR (`status IN ('sent','partially_signed')` AND `expires_at < now()`)
|
||||
|
||||
Counts run cheap thanks to `idx_docs_status_port`.
|
||||
|
||||
### Filters and saved views
|
||||
|
||||
- Search: fuzzy match on title, subject name, signer email
|
||||
- Type: multi-select doc types
|
||||
- Status: multi-select status enum
|
||||
- Sent: date-range chips (Today, 7d, 30d, custom)
|
||||
- Watcher: filter by watching user
|
||||
- "Signature-based only" chip defaults to ON; toggle off to see non-signed docs (welcome letters etc.) as well, rendered with a "Delivered" pill
|
||||
- Saved-view integration: filter combos save to existing `saved_views` table
|
||||
|
||||
### Row anatomy
|
||||
|
||||
- Collapsed: name (links to detail), type pill (colored per type), subject pill (links to entity), status indicator (X/Y signed with progress dot), sent age
|
||||
- Expanded: per-signer rows with email, status pill, sent timestamp, signed timestamp, `[Remind]` and overflow `[...]` (resend invite, copy signing link, skip — skip is UI-only flag, not implemented in v1)
|
||||
- Watchers strip at bottom of expansion: chips + `+ Add watcher` autocomplete
|
||||
- Hover: row gets soft brand-soft gradient bg
|
||||
|
||||
### Real-time
|
||||
|
||||
Subscribes to existing `documents.service.ts`-emitted socket events: `document:created`, `document:updated`, `document:deleted`, `document:sent`, `document:completed`, `document:expired`, `document:cancelled`, `document:rejected`, `document:signer:signed`, `document:signer:opened`. All already fire today.
|
||||
|
||||
### Empty states
|
||||
|
||||
- No docs yet: illustration + 1-line explanation + `[+ New document]` CTA
|
||||
- Filtered empty: "No docs match these filters. Clear filters?"
|
||||
|
||||
### Mobile (< 768px)
|
||||
|
||||
- Tabs collapse into `<select>`
|
||||
- Filters collapse behind `[Filters]` button into a sheet
|
||||
- Rows stack as cards: title + status + age, expand to show signers
|
||||
- "+ New document" floats as FAB bottom-right
|
||||
|
||||
## Document detail page
|
||||
|
||||
New `/[port]/documents/[id]` page. No detail page exists today.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
[ Breadcrumb: All documents ]
|
||||
|
||||
[ Header strip with gradient: title (editable inline), type pill, status pill, subtitle (subject link, creator, age) ]
|
||||
|
||||
[ Action bar — context-aware ]
|
||||
|
||||
[ Two-column body:
|
||||
Left (2fr):
|
||||
Signers panel (vertical list, replaces existing horizontal SigningProgress)
|
||||
Linked entity card
|
||||
Right (1fr):
|
||||
Watchers panel (chips + add)
|
||||
Activity timeline (from documentEvents)
|
||||
Notes (auto-saving editable text)
|
||||
Preview (PDF; tabbed Original/Signed when completed)
|
||||
]
|
||||
```
|
||||
|
||||
### Action bar by status
|
||||
|
||||
- `draft` — `[Send for signing]` `[Edit signers]` `[Delete]`
|
||||
- `sent | partially_signed` — `[Send reminder to all]` `[Resend invite]` `[Cancel]`
|
||||
- `completed` — `[Download signed PDF]` `[Email signed PDF to all signatories]`
|
||||
- `cancelled | rejected | expired` — `[Duplicate]`
|
||||
- Always `[...]` overflow: Duplicate, Move to other entity, View Documenso URL, Audit log
|
||||
|
||||
### Signers panel (vertical, replaces horizontal stepper)
|
||||
|
||||
Per-row:
|
||||
|
||||
- Numbered status circle (pending grey, signed green, declined red)
|
||||
- Name, email, role
|
||||
- Sent age, last-reminded age, signed timestamp
|
||||
- `[Remind]` button — disabled with countdown if cooldown active (24h-or-cadence) for auto mode; bypassed in manual mode
|
||||
- `[Copy signing link]` — copies `signingUrl` (hosted Documenso); overflow offers "Copy embed link" if `embeddedUrl` present (used by website embed at `/sign/[type]/[token]`)
|
||||
- `[...]` overflow: Resend invite, View signing history, Replace email (draft only)
|
||||
- Sequential mode: only current pending signer's `[Remind]` active; others greyed with tooltip
|
||||
|
||||
### Send-signed-PDF email flow
|
||||
|
||||
Action visible only when `status='completed' AND signedFileId IS NOT NULL`.
|
||||
|
||||
Click opens email composer drawer prefilled:
|
||||
|
||||
- From: dropdown defaulting to System (port-config noreply identity); Personal accounts available only when port admin enables `email.allowPersonalAccountSends`
|
||||
- To: union of `documentSigners.signerEmail` for the doc
|
||||
- Cc: empty; "Cc watchers" toggle adds users from `document_watchers`
|
||||
- Subject: `"Signed {document type} — {document title}"`
|
||||
- Body: from `signed_doc_completion` per-port template (new template type; default seeded for new ports)
|
||||
- Attachments: signed PDF auto-attached from `documents.signedFileId` (chip with filename + size; removable)
|
||||
|
||||
Send dispatch:
|
||||
|
||||
- System path: `lib/email/index.ts → sendEmail()` with portId + attachments; writes `documentEvents` row; skips email_messages/threads writes (no IMAP sync expected)
|
||||
- User path: `email-compose.service.ts` existing flow; writes email_messages + thread; subject to `allowPersonalAccountSends` gate (server-side enforces 403 on user senderType when toggle off)
|
||||
|
||||
### Backend additions
|
||||
|
||||
- `POST /api/v1/documents/[id]/cancel` — calls `cancelDocument` service; service calls Documenso void via new client function
|
||||
- `POST /api/v1/documents/[id]/remind` — accepts optional `{ signerId }`; passes `auto: false` to service
|
||||
- `GET /api/v1/documents/[id]/watchers` — list
|
||||
- `POST /api/v1/documents/[id]/watchers` — add `{ userId }`
|
||||
- `DELETE /api/v1/documents/[id]/watchers/[userId]` — remove
|
||||
- `POST /api/v1/documents/[id]/compose-completion-email` — returns prefilled draft
|
||||
|
||||
## Create-document wizard
|
||||
|
||||
Replaces `<EoiGenerateDialog>`. Single drawer/dialog, three steps.
|
||||
|
||||
### Step 1 — Type and source
|
||||
|
||||
```
|
||||
Render: ● Generate the PDF here (using template format below)
|
||||
○ Use a Documenso-stored template (Documenso renders + signs)
|
||||
|
||||
Format (when "Generate the PDF here" selected):
|
||||
● HTML (write inline)
|
||||
○ PDF (AcroForm fillable upload)
|
||||
○ PDF (overlay positioning)
|
||||
|
||||
Template: [ pick from port's templates of selected format ]
|
||||
OR
|
||||
Upload PDF: [ drop or pick file; preview renders inline ]
|
||||
|
||||
Document type: [ auto-derived from template, or picked from DOCUMENT_TYPES enum ]
|
||||
```
|
||||
|
||||
Signing destination is always Documenso. The "Render in CRM" vs "Render in Documenso" axis is about PDF generation only.
|
||||
|
||||
### Step 2 — Recipients
|
||||
|
||||
```
|
||||
Attached to: [ Interest #142 — Smith family Change ]
|
||||
↑ pre-filled if launched from a detail page
|
||||
|
||||
Signers: (hidden for documenso-render path; signers embedded in template)
|
||||
① name email role [✕]
|
||||
② name email role [✕]
|
||||
[+ Add signer] (autocomplete from clients/companies/users; or manual entry)
|
||||
Drag to reorder; signing-order assigned by row position
|
||||
|
||||
Signing mode: ● Sequential ○ Parallel
|
||||
|
||||
Watchers (optional): [chips] [+ Add watcher] (CRM users)
|
||||
|
||||
Reminder cadence:
|
||||
● Use template default (every 7 days)
|
||||
○ Override: [_____] days
|
||||
○ Disable for this document
|
||||
|
||||
[ For upload path only ]
|
||||
☑ Auto-place signature fields at footer (default; refine later in Documenso)
|
||||
```
|
||||
|
||||
### Step 3 — Review and send
|
||||
|
||||
```
|
||||
Title: [ EOI — Smith family ____________ ] (editable; default rendered from merge tokens)
|
||||
Notes (internal): [_____________]
|
||||
Preview: [ rendered PDF inline · 4 pages · scrollable ]
|
||||
Signing-order banner (multi-signer in-app/upload only): "Sequential — Carol must sign before Bob" [Switch to parallel]
|
||||
[← Back] [Save as draft] [Send →]
|
||||
```
|
||||
|
||||
Save as draft → status='draft'; `[Send for signing]` available later from detail page. Send → calls Documenso, status='sent', socket event fires.
|
||||
|
||||
### Documenso version-aware field placement
|
||||
|
||||
For upload path, `placeDefaultSignatureFields` auto-positions one SIGNATURE per recipient at last-page footer (staggered to avoid overlap). User can refine in Documenso via "Customize fields in Documenso" link on detail page.
|
||||
|
||||
`placeFields` and `placeDefaultSignatureFields` in `documenso-client.ts` hide v1/v2 differences:
|
||||
|
||||
- v1: `POST /api/v1/documents/{id}/fields` per field; pixel coordinates; requires page dimension lookup
|
||||
- v2: `POST /api/v2/envelope/field/create-many` bulk; percentage 0-100 coordinates; rich `fieldMeta`
|
||||
- Caller passes percentage; abstraction converts for v1 using cached page dimensions
|
||||
|
||||
### `createDocumentSchema` extension
|
||||
|
||||
```ts
|
||||
export const createDocumentSchema = z.object({
|
||||
source: z.enum(['template', 'upload']),
|
||||
templateId: z.string().uuid().optional(),
|
||||
uploadedFileId: z.string().uuid().optional(),
|
||||
|
||||
documentType: z.enum(DOCUMENT_TYPES),
|
||||
title: z.string().min(1).max(200),
|
||||
notes: z.string().optional(),
|
||||
|
||||
// Subject (exactly one required)
|
||||
interestId: z.string().uuid().optional(),
|
||||
reservationId: z.string().uuid().optional(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
companyId: z.string().uuid().optional(),
|
||||
yachtId: z.string().uuid().optional(),
|
||||
|
||||
// Signers (required when render=in-app or source=upload)
|
||||
signers: z.array(z.object({
|
||||
signerName: z.string().min(1),
|
||||
signerEmail: z.string().email(),
|
||||
signerRole: z.enum(['client', 'sales', 'approver', 'developer', 'other']),
|
||||
signingOrder: z.number().int().min(1),
|
||||
})).optional(),
|
||||
signingMode: z.enum(['sequential', 'parallel']).default('sequential'),
|
||||
|
||||
pathway: z.enum(['documenso-template', 'inapp', 'upload']).optional(),
|
||||
|
||||
watchers: z.array(z.string().uuid()).optional(),
|
||||
|
||||
reminderCadenceOverride: z.number().int().min(1).max(365).nullable().optional(),
|
||||
remindersDisabled: z.boolean().default(false),
|
||||
|
||||
autoPlaceFields: z.boolean().default(true),
|
||||
|
||||
sendImmediately: z.boolean().default(true),
|
||||
}).refine(...one-subject-FK-required...);
|
||||
```
|
||||
|
||||
## Template formats
|
||||
|
||||
### Authoring paths
|
||||
|
||||
| Format | Authoring | Merge fields | Best for |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------ |
|
||||
| HTML (existing) | Inline rich-text editor with merge tokens | Server-side substitution, rendered to PDF via pdfme | Welcome letters, acknowledgments, correspondence |
|
||||
| PDF (AcroForm fillable) | Admin uploads fillable PDF; UI scans AcroForm field names; admin maps each to a merge token | pdf-lib fills form at gen time | EOI, Reservation Agreement, NDA |
|
||||
| PDF (overlay positioning) | Admin uploads any PDF; UI specifies merge token positions per page+x+y+fontSize | pdf-lib draws text over PDF at positions | Quick wins where preparing AcroForm is overkill |
|
||||
| Documenso template reference | Admin enters Documenso template ID + label | None in CRM; Documenso owns it | Documenso-rendered signing flows |
|
||||
|
||||
### Generator dispatch
|
||||
|
||||
```ts
|
||||
switch (template.template_format) {
|
||||
case 'html': generatePdf(template.body_html, mergeContext);
|
||||
case 'pdf_form': fillAcroForm(template.source_file_id, template.field_mapping, mergeContext);
|
||||
case 'pdf_overlay': drawOverlay(template.source_file_id, template.overlay_positions, mergeContext);
|
||||
case 'documenso_render': documenso.generateDocumentFromTemplate(template.documenso_template_id, ...);
|
||||
}
|
||||
```
|
||||
|
||||
All four formats end at Documenso for signing — only PDF generation location differs. Non-signature templates (welcome letters etc.) skip the upload-to-Documenso step entirely; they render to PDF then get emailed.
|
||||
|
||||
### Admin template editor extension
|
||||
|
||||
Format picker added to `/admin/templates` editor:
|
||||
|
||||
- For PDF (AcroForm): file upload field, then two-column mapping UI (AcroForm field names ↔ merge tokens autocomplete from existing `MERGE_FIELDS` catalog)
|
||||
- For PDF (overlay): file upload, then per-token form with page/x/y/fontSize inputs (visual placement editor deferred)
|
||||
- For Documenso template: single text input + Test connection button calling `getDocumensoTemplate`
|
||||
- For HTML: existing inline editor unchanged
|
||||
|
||||
### Word (.docx) deferred
|
||||
|
||||
Reasons: LibreOffice headless adds significant install/memory/security surface; CloudConvert adds paid dependency and third-party data exposure; `docxtemplater` merge syntax incompatible with existing `{{token}}` convention; field placement still needs PDF flow afterwards. If marinas push back, the feasible path is `.docx → server-side conversion → PDF → existing AcroForm/overlay flow`. Not worth the engineering until requested.
|
||||
|
||||
## Reservation agreements as a doc type
|
||||
|
||||
### What differs from EOI's pattern
|
||||
|
||||
| Aspect | EOI | Reservation Agreement |
|
||||
| --------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Subject FK | `interestId` | `reservationId` |
|
||||
| Default template | Documenso EOI per port | Documenso reservation_agreement per port (seeded) |
|
||||
| Default signers | client + sales/approver | client + port admin |
|
||||
| Trigger | Manual on interest detail | Manual on reservation detail |
|
||||
| Lifecycle integration | None | Active reservations without an agreement get flagged in dashboard alert |
|
||||
| Final-PDF storage | `documents.signedFileId` only | `documents.signedFileId` AND mirrored to `berth_reservations.contractFileId` on completion |
|
||||
|
||||
### New CRM-side reservation detail page
|
||||
|
||||
`/[port]/berth-reservations/[id]` doesn't exist today (only the portal's `/portal/my-reservations`). Phase A builds it.
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
[ Header: "Reservation #88 · M/Y Tate" status pill subtitle: berth, client, dates, tenure ]
|
||||
[ Action bar: Activate | Generate agreement | Cancel | ... ]
|
||||
[ Two columns:
|
||||
Left: Reservation details card
|
||||
Linked interest card
|
||||
Activity timeline
|
||||
Right: Agreement card (state-dependent: no agreement / in-flight / completed)
|
||||
]
|
||||
```
|
||||
|
||||
Agreement card states:
|
||||
|
||||
- No agreement yet: warning + `[Generate agreement →]`
|
||||
- In-flight (sent/partially_signed): "X/Y signed", per-signer status, `[View document →]` `[Send reminder]` `[Cancel]`
|
||||
- Completed: "Completed YYYY-MM-DD", `[Download signed PDF]` `[Email to all signatories]`, "Signed contract attached to reservation."
|
||||
|
||||
Generate-agreement button launches the wizard with prefills:
|
||||
|
||||
- `documentType='reservation_agreement'`
|
||||
- `templateId=<port's default>`
|
||||
- `reservationId=<current>`
|
||||
- Default signers from linked client + configurable port-admin user
|
||||
- Wizard step 1 pre-validated; user lands on step 2
|
||||
|
||||
### Backend additions
|
||||
|
||||
- Merge field catalog extended in `src/lib/templates/merge-fields.ts`:
|
||||
- `{{reservation.startDate}}` `{{reservation.endDate}}` `{{reservation.tenureType}}` `{{reservation.termSummary}}` `{{reservation.signedDate}}`
|
||||
- New service `reservation-agreement-context.ts.buildReservationAgreementContext(reservationId, portId)`
|
||||
- New seeder for default `reservation_agreement` template on port creation (HTML format; admins can switch to AcroForm/overlay later); template stored at `assets/templates/reservation-agreement-default.html`
|
||||
- Webhook handler extension: `handleDocumentCompleted` detects `documentType='reservation_agreement'` and sets `berth_reservations.contractFileId = doc.signedFileId` for the linked reservation
|
||||
- Dashboard alert query: active reservations without a completed agreement (LEFT JOIN against documents filtered on type+status); rows surface as a warning card
|
||||
|
||||
### Trade-off
|
||||
|
||||
`berth_reservations.contractFileId` becomes a denormalized convenience pointer duplicated with `documents.signedFileId` for the linked reservation. Updating it on completion costs one extra UPDATE. Benefit: anyone querying reservations directly (portal "My Reservations") doesn't need to join through documents to know which file is the contract.
|
||||
|
||||
## Reminder framework polish
|
||||
|
||||
### Problems with today's logic
|
||||
|
||||
1. Eligibility gated by `interests.reminderEnabled` — reservation agreements, NDAs, ad-hoc upload docs (no interest link) never auto-remind
|
||||
2. Hardcoded 24h cooldown — effective cadence is 1 day; can't slow down for low-urgency docs
|
||||
3. Always reminds lowest-pending signer — parallel-signing docs can't nudge a specific signer
|
||||
4. No per-doc disable
|
||||
|
||||
### New eligibility logic
|
||||
|
||||
```
|
||||
function isReminderDue(doc, template, lastReminderAt) {
|
||||
if (!['sent','partially_signed'].includes(doc.status)) return false;
|
||||
if (doc.documenso_id == null) return false;
|
||||
if (doc.reminders_disabled) return false;
|
||||
|
||||
const effectiveCadence = doc.reminder_cadence_override ?? template.reminder_cadence_days;
|
||||
if (effectiveCadence === null) return false;
|
||||
|
||||
if (lastReminderAt == null) return true;
|
||||
return (now - lastReminderAt) >= effectiveCadence * 24h;
|
||||
}
|
||||
```
|
||||
|
||||
`processReminderQueue` query rewritten:
|
||||
|
||||
```sql
|
||||
SELECT d.* FROM documents d
|
||||
LEFT JOIN document_templates t ON t.id = d.template_id
|
||||
WHERE d.port_id = $1
|
||||
AND d.status IN ('sent','partially_signed')
|
||||
AND d.documenso_id IS NOT NULL
|
||||
AND d.reminders_disabled = false
|
||||
AND COALESCE(d.reminder_cadence_override, t.reminder_cadence_days) IS NOT NULL;
|
||||
```
|
||||
|
||||
`interests.reminderEnabled` is dropped from the gating logic but the column stays for now (no migration). Future cleanup PR can drop the column.
|
||||
|
||||
### `sendReminderIfAllowed` extended signature
|
||||
|
||||
```ts
|
||||
export async function sendReminderIfAllowed(
|
||||
documentId: string,
|
||||
portId: string,
|
||||
options: {
|
||||
auto?: boolean; // true = cron; false (default) = manual
|
||||
signerId?: string; // optional — target a specific pending signer
|
||||
} = {},
|
||||
): Promise<{ sent: boolean; reason?: string; signerId?: string }>;
|
||||
```
|
||||
|
||||
Behaviour matrix:
|
||||
|
||||
| Mode | 9-16 window | Cadence cooldown | Manual cooldown |
|
||||
| ----------- | ----------- | ---------------- | ------------------------ |
|
||||
| auto: true | enforced | enforced | n/a |
|
||||
| auto: false | bypassed | bypassed | 30s client-side debounce |
|
||||
|
||||
Per-signer logic:
|
||||
|
||||
- If `signerId` provided in sequential-mode doc, signer must be the lowest-pending signer (otherwise reason='Signer is not next in sequence')
|
||||
- In parallel-mode doc, any pending signer can be reminded independently
|
||||
- Returns `{ sent, reason }` so caller can show toast on skip
|
||||
|
||||
### Admin and per-doc UI
|
||||
|
||||
Admin `/admin/templates` editor:
|
||||
|
||||
```
|
||||
Auto-reminders for this template:
|
||||
☑ Enabled Cadence: every [_____] days (1-365; default 7)
|
||||
☐ Disabled (manual reminders only)
|
||||
```
|
||||
|
||||
Doc detail page (Section 3) "Reminders" panel under signers, with edit drawer for per-doc override.
|
||||
|
||||
## Visual polish system
|
||||
|
||||
### Token additions
|
||||
|
||||
```
|
||||
--radius-sm: 0.375rem (existing)
|
||||
--radius-md: 0.5rem (NEW — default cards)
|
||||
--radius-lg: 0.625rem (NEW — sheets, dialogs)
|
||||
--radius-xl: 0.875rem (NEW — KPI tiles, hero strips)
|
||||
|
||||
--shadow-xs: 0 1px 2px 0 rgb(15 23 42 / 0.04)
|
||||
--shadow-sm: 0 2px 4px -1px rgb(15 23 42 / 0.06)
|
||||
--shadow-md: 0 4px 12px -2px rgb(15 23 42 / 0.08)
|
||||
--shadow-lg: 0 12px 32px -8px rgb(15 23 42 / 0.12)
|
||||
--shadow-glow: 0 0 0 4px rgb(58 123 200 / 0.12)
|
||||
|
||||
--gradient-brand: linear-gradient(135deg, #3a7bc8 0%, #2f6ab5 100%)
|
||||
--gradient-brand-soft: linear-gradient(135deg, #d8e5f4 0%, #ffffff 100%)
|
||||
--gradient-success: linear-gradient(135deg, #e8f5e9 0%, #ffffff 100%)
|
||||
--gradient-warning: linear-gradient(135deg, #fef3c7 0%, #ffffff 100%)
|
||||
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1)
|
||||
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--duration-fast: 150ms
|
||||
--duration-base: 200ms
|
||||
--duration-slow: 300ms
|
||||
```
|
||||
|
||||
All exposed as Tailwind utilities.
|
||||
|
||||
### Existing token foundation (already in place; not changing)
|
||||
|
||||
- Full HSL shadcn token system (primary, secondary, muted, accent, destructive, border, input, ring, popover, card)
|
||||
- Brand palette `brand` (50-700, default `#3a7bc8`)
|
||||
- Navy palette `navy` (50-600, default `#1e2844` for sidebar)
|
||||
- Maritime accents: `sage`, `mint`, `teal`, `purple` with light/default/dark variants
|
||||
- Semantic `success` / `warning` with bg+border
|
||||
- Recharts chart-1 through chart-6 token system
|
||||
- Dark mode wired
|
||||
- Sidebar tokens separate from main palette
|
||||
|
||||
### New primitive components
|
||||
|
||||
- `<StatusPill status="...">` — colored-by-state pill (pending grey, sent brand, partial teal, completed success, expired warning, rejected destructive, cancelled muted-darker, active success, archived muted)
|
||||
- `<KPITile title value delta sparkline?>` — rounded-xl, shadow-sm, gradient-brand-soft border-top accent stripe; recharts mini sparkline using `--chart-1`
|
||||
- `<EmptyState icon title body actions>` — large icon in brand-soft circle, title, body, action buttons
|
||||
- `<PageHeader>` polished — gradient-brand-soft background, eyebrow optional, KPI sub-line, primary action right-aligned
|
||||
|
||||
### Component pattern updates
|
||||
|
||||
- List rows: hover gradient (subtle brand-soft 4% opacity), shadow-xs lift, animation `transition-all duration-base ease-smooth`; row-update from socket events animates 1s fade-in highlight
|
||||
- Detail pages: two-column responsive grammar (header strip → 2fr main + 1fr side; cards stack vertical < 768px)
|
||||
- Sidebar (already dark navy): active item gets 4px brand left-edge stripe instead of bg shift; section headers smaller-caps + brand-200 text
|
||||
- Topbar: search inset shadow + brand focus ring; "+ New" trigger gets `bg-gradient-brand`; notification bell gets badge spring animation; user avatar gets shadow-sm + 2px white ring
|
||||
- Forms: focus ring uses `--shadow-glow`; primary submit buttons get `bg-gradient-brand` with hover scale-1.01; inline validation gets destructive-bg pill with caret pointing up
|
||||
|
||||
### Loading skeleton system
|
||||
|
||||
- List pages: 8 skeleton rows matching column widths with subtle pulse
|
||||
- Detail pages: header strip skeleton + 2-column section skeletons
|
||||
- Dashboard: KPI tile skeletons + chart skeletons
|
||||
- Replaces today's mix of "Loading..." text and spinners
|
||||
|
||||
### Mobile responsive (full sweep)
|
||||
|
||||
Breakpoints:
|
||||
|
||||
- < 640px (phone): single column, sticky bottom action bar, sheet overlays for filters
|
||||
- 640-1024px (tablet): single column with wider gutters, side column under main
|
||||
- ≥ 1024px (desktop): full two-column
|
||||
|
||||
Per-page rules:
|
||||
|
||||
- List tables → card stack < 768px
|
||||
- Detail page header collapses subtitle to "Show more"
|
||||
- Tabs collapse to `<select>` < 640px
|
||||
- Sidebar slides over content < 1024px
|
||||
- Primary "+ New" actions float as FAB bottom-right < 640px
|
||||
|
||||
## Test plan
|
||||
|
||||
### Unit (`tests/unit/`)
|
||||
|
||||
- `document-reminders-cadence.test.ts` — `isReminderDue` math; manual-vs-auto window/cooldown bypass
|
||||
- `documenso-place-fields.test.ts` — v1/v2 dispatch (mocked HTTP); coord normalization; default field staggering for 1/2/3/5 recipients
|
||||
- `email-attachments-resolver.test.ts` — fileId → MinIO buffer; cross-port 403; 10 MB cap warning
|
||||
|
||||
### Integration (`tests/integration/`)
|
||||
|
||||
- Extend `document-templates-generate-and-sign.test.ts` — new template formats (`pdf_form`, `pdf_overlay`, `documenso_render`); upload-path test
|
||||
- New `document-watchers.test.ts` — add/remove endpoints; notification fan-out; port isolation
|
||||
- New `document-cancel.test.ts` — user-initiated cancel; mocked Documenso void; status + event log; reject 409 if completed
|
||||
- New `reservation-agreement-contract-mirror.test.ts` — `handleDocumentCompleted` mirrors `signedFileId` to `berth_reservations.contractFileId` only for `reservation_agreement` type
|
||||
- New `reminder-cron-cadence.test.ts` — seed varied templates; simulated time advance; assert correct docs reminded
|
||||
|
||||
### E2E smoke (`tests/e2e/smoke/`)
|
||||
|
||||
- Extend `04-documents.spec.ts` — hub tabs, expand row, per-signer remind with cooldown, type/status filters, saved-view round-trip, bulk-remind with per-row toast reasons
|
||||
- Extend `05-eoi-generate.spec.ts` — wizard invocation prefills (template, interest); existing flow regression
|
||||
- New `27-document-create-wizard.spec.ts` — template path full flow; upload path full flow; watcher addition; reminder-override radios produce correct DB state
|
||||
- New `28-reservation-agreements.spec.ts` — reservation detail → Generate agreement → wizard prefilled → Send → agreement section state transitions; post-completion contract attached + email button visible
|
||||
- New `29-email-attachments.spec.ts` — system path send (documentEvents row, no email_messages); user path send when toggle on (email_messages with attachment_file_ids); cross-port 403
|
||||
|
||||
### E2E exhaustive (`tests/e2e/exhaustive/`) — click-everything sweep
|
||||
|
||||
- New `10-documents-hub.spec.ts` — crawl each tab, filter dropdowns, saved-view, expand row, signer-row buttons, bulk-action bar
|
||||
- New `11-document-detail.spec.ts` — crawl in three states (draft/sent/completed); watcher add/remove; notes auto-save; preview download; "Email signed PDF" launch
|
||||
- New `12-document-create-wizard.spec.ts` — crawl each wizard step under both template and upload paths; picker dropdowns, signer add/remove, drag-handle, reminder-cadence radios
|
||||
- New `13-reservation-detail.spec.ts` — crawl in three states (pending no agreement / agreement-in-flight / agreement-completed); Activate/Cancel/Generate buttons; inline notes
|
||||
- New `14-email-composer.spec.ts` — crawl composer drawer with attachments; From dropdown; attach button; recipient chips
|
||||
- Extend exhaustive `05-eoi-generate.spec.ts` — parallel-mode + signing-order edge cases (greyed-out reminder buttons; out-of-order remind rejection)
|
||||
|
||||
### E2E real-API (`tests/e2e/realapi/`)
|
||||
|
||||
Each spec gates on env vars; clean skip if missing.
|
||||
|
||||
- Extend `documenso-real-api.spec.ts`:
|
||||
- Generate from Documenso template (real send) and assert in real Documenso
|
||||
- Generate from in-app PDF AcroForm fill, upload to real Documenso, assert
|
||||
- Generate from upload path with auto-placed signature fields, assert fields visible in Documenso
|
||||
- v1 and v2 explicit version-flag tests (via `DOCUMENSO_API_VERSION`)
|
||||
- Manually sign in real Documenso (or simulate webhook) and assert local DB updates
|
||||
- Cancel real in-flight doc, assert local + remote state
|
||||
- Send reminder via real Documenso, assert HTTP + documentEvents row
|
||||
|
||||
- New `smtp-system-send.spec.ts` — system-path send → IMAP fetch → assert subject + attachment; verify port-config from-identity; cleanup via IMAP delete
|
||||
- New `smtp-user-send.spec.ts` — user-path send (requires connected account, allowPersonalAccountSends=true) → IMAP fetch → email_messages row with attachment_file_ids
|
||||
- New `minio-file-lifecycle.spec.ts` — upload, list, preview, download (byte-equal), delete; port isolation; mime-type validation
|
||||
- New `documenso-webhook-ingress.spec.ts` — requires cloudflared tunnel; configure tunnel URL as Documenso webhook target; trigger doc completion; assert webhook fires + handler updates DB; verify timing-safe secret check rejects wrong secret with 401; verify event normalisation (uppercase enum + lowercase-dotted both accepted)
|
||||
- New `email-attachments-roundtrip.spec.ts` — compose with fileId attachment; SMTP send; IMAP fetch; assert attachment bytes match; reject cross-port fileId with 403 before SMTP touched
|
||||
|
||||
### Visual baselines (`tests/e2e/visual/`)
|
||||
|
||||
`snapshots.spec.ts-snapshots/` regenerated as polish ships per page; one PR per surface group, baselines reviewed in PR diff. New baselines added: documents hub, doc detail, create-document wizard (each step), reservation detail, email composer with attachments.
|
||||
|
||||
### Test data fixtures
|
||||
|
||||
`global-setup.ts` extended with:
|
||||
|
||||
- Seed default `reservation_agreement` template (HTML format)
|
||||
- Seed default `signed_doc_completion` template
|
||||
- Seed one in-flight EOI doc with two pending signers (for hub-tab tests)
|
||||
- Seed one `berth_reservation` with `status='active'` and no agreement (for lifecycle alert query)
|
||||
|
||||
### CI vs local runs
|
||||
|
||||
| Project | When |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `setup` + `smoke` (~14 min) | Every PR via CI |
|
||||
| `exhaustive` (with new click-everything specs) | Every PR via CI; ~25 min budget |
|
||||
| `visual` | Every PR; baselines reviewed in PR diffs |
|
||||
| `realapi` | Locally before merging touch-points; pre-release; not on CI (avoids burning Documenso quota and SMTP costs) |
|
||||
|
||||
## Build sequence
|
||||
|
||||
| # | Title | Effort | Depends on |
|
||||
| ----- | ------------------------------------------------- | ------ | -------------- |
|
||||
| 1 | Data model + service skeletons | 1d | — |
|
||||
| 2 | Documenso v1/v2 abstraction layer | 1d | — |
|
||||
| 3 | Visual primitives + token additions | 1.5d | — |
|
||||
| 4 | Documents hub page | 2d | 1, 3 |
|
||||
| 5 | Document detail page | 2d | 1, 3 |
|
||||
| 6 | Create-document wizard + new template formats | 2.5d | 1, 2, 3 |
|
||||
| 7 | Reservation detail + agreement flow | 1.5d | 1, 6 |
|
||||
| 8 | Email composer attachments + From selector | 1d | 1, 3 |
|
||||
| 9 | Reminder framework polish | 1d | 1 |
|
||||
| 10a-e | Visual polish sweep (5 PRs across surface groups) | 3-4d | 3 |
|
||||
| 11 | Real-API integration tests | 1.5d | 2, 4-9 shipped |
|
||||
|
||||
### Critical path
|
||||
|
||||
```
|
||||
1 → 2 → 6 → 7 (data model → Documenso → wizard → reservation)
|
||||
1 → 3 → 4 → 5 → 9 (data model → primitives → hub → detail → reminders)
|
||||
1 → 8 (composer)
|
||||
3 → 10a-e (sweep)
|
||||
all → 11 (realapi)
|
||||
```
|
||||
|
||||
Wall-clock minimum ~9 days; realistic with overhead ~17 days; calendar ~3.5-5 weeks.
|
||||
|
||||
### Acceptance gates per PR
|
||||
|
||||
- `pnpm tsc --noEmit` and `pnpm lint` clean
|
||||
- Vitest unit + integration green
|
||||
- Playwright smoke green for surface touched
|
||||
- Visual baselines regenerated and reviewed in PR diff
|
||||
- For PRs touching external integrations (2, 6 upload, 7 contract mirror, 8 SMTP, 11): relevant `realapi` spec verified locally before merge
|
||||
|
||||
### Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Documenso v2 endpoint shape drifts from docs | PR2 validates against real v2 instance during dev; realapi spec re-runs nightly post-ship |
|
||||
| Visual polish scope creeps | One PR per surface group (10a-e), each independently shippable |
|
||||
| Cron migration changes effective behaviour | Backfill sets EOI cadence to 1 day matching today's effective; run on staging first |
|
||||
| Mobile responsive regressions | Visual baselines include phone-viewport snapshots; PR10e is the responsive sweep |
|
||||
| EOI dialog → wizard migration breaks "Generate EOI" button | Wizard launched with prefills from interest detail; PR6 includes regression spec |
|
||||
| AcroForm template format confuses non-technical admins | HTML default; inline help; default templates seeded |
|
||||
| Phase A wall-clock past 5 weeks | Tier-2 sweep items + optional realapi specs deferrable to follow-up release |
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Documenso** — open-source document signing service, self-hosted instance at `signatures.portnimara.dev`
|
||||
- **EOI** — Expression of Interest, a pre-reservation signed document
|
||||
- **Reservation Agreement** — contract signed when a berth reservation is committed
|
||||
- **Hub** — the new `/[port]/documents` page
|
||||
- **Watcher** — a CRM user added to a doc to receive notifications on signature events without being a signer themselves
|
||||
- **Signing order** — sequential index across signers; sequential mode requires lower order to sign first; parallel mode lets all sign concurrently
|
||||
- **Cadence** — interval in days between auto-reminders to unsigned signers
|
||||
- **System send / User send** — email dispatch identity: System uses port-config noreply SMTP; User uses connected personal email account (gated by admin toggle)
|
||||
- **Render location** — where the PDF is generated (CRM-local via HTML/AcroForm/overlay, or in Documenso). Signing is always Documenso; render location is independent.
|
||||
@@ -0,0 +1,435 @@
|
||||
# Phase B — Insights, Alerts, and Operational Awareness
|
||||
|
||||
**Status:** Draft — awaiting review
|
||||
**Date:** 2026-04-28
|
||||
**Phase:** B of D (A = Documents hub + visual polish ✓ shipped; C = Website integration; D = Pre-prod ops)
|
||||
|
||||
## Overview
|
||||
|
||||
Phase A made the CRM look polished and finished the documents/signing surface. Phase B turns it into a tool that _tells operators what's happening_ — instead of forcing them to navigate every list to find pipeline drift, expiring documents, or stalled reservations. It also closes the seven highest-priority Nuxt→Next gaps the 2026-04-28 audit surfaced (analytics, berth-interests, EOI queue, OCR, alerts, audit log, expense dedup).
|
||||
|
||||
The product story changes from "system of record" to "system of attention." Operators land on the dashboard and immediately see what needs them today — not a flat list they have to filter.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
### In scope (this spec)
|
||||
|
||||
- **Analytics dashboard** — chart-driven KPI page replacing the current 4-tile placeholder; pipeline funnel, occupancy timeline, revenue breakdown, lead-source attribution, with date-range and per-port filters
|
||||
- **Alert framework** — rule engine that evaluates conditions on a schedule and surfaces actionable cards (alerts) in the dashboard's right rail; dismissible per-user; deep-links into the offending entity
|
||||
- **Interests-by-berth view** — `/[port]/berths/[id]/interests` panel showing every interest targeting a berth, sortable by stage/score/age
|
||||
- **Expense duplicate detection** — heuristic match on (vendor + amount + date ± 3 days); surfaces in expense detail with "Merge" action; background scan on new expense
|
||||
- **EOI queue** — saved-view filter on the existing documents hub for `documentType='eoi' AND status IN ('sent','partially_signed')`, surfaced as a hub tab and a dashboard alert link
|
||||
- **OCR for expense receipts** — Claude Vision integration on the existing `/expenses/scan` route to extract vendor, amount, date, currency, line items from uploaded receipts; user confirms before save
|
||||
- **Audit log read view** — admin-gated UI for the existing `audit_logs` table with filters (user, action, entity type, date range, entity id search) and per-port + global (super-admin) scopes
|
||||
|
||||
### Explicitly out of scope (deferred to later phases)
|
||||
|
||||
- Custom user-defined alert rules (Phase B v1 ships with a fixed catalog of ~10 rules; user-rule creation deferred to Phase D)
|
||||
- Real-time alert push notifications (only socket-fired updates of the alert list; SMS/email push deferred)
|
||||
- Alert grouping / digests (each alert is its own card)
|
||||
- Predictive analytics, ML scoring (separate from existing AI feature flag)
|
||||
- Cross-port roll-up dashboards for super-admins (per-port only in v1)
|
||||
- Full audit-log retention / archival policy (Phase D)
|
||||
- OCR for PDF receipts (only image formats: jpg/png/heic; PDF expense uploads bypass OCR and stay manual until Phase D)
|
||||
- Excel/CSV import for bulk expense backfill
|
||||
- Country / phone / timezone work (separate cross-cutting agenda at `2026-04-28-country-phone-timezone-design.md`)
|
||||
|
||||
## Information architecture
|
||||
|
||||
### URL surface
|
||||
|
||||
```
|
||||
/[port]/dashboard replaces existing; analytics-driven
|
||||
/[port]/insights deep-link analytics page (charts only, no alerts)
|
||||
/[port]/alerts full alert list (admin filter, dismissed history)
|
||||
/[port]/berths/[id]/interests new tab on berth detail
|
||||
/[port]/expenses/scan extend existing route with Claude Vision OCR
|
||||
/[port]/admin/audit admin-gated audit log viewer
|
||||
/[port]/documents extended: 'EOI queue' tab pre-filters to EOI in flight
|
||||
```
|
||||
|
||||
### Schema deltas
|
||||
|
||||
```sql
|
||||
-- alerts: surfaces operational warnings the user should act on
|
||||
CREATE TABLE alerts (
|
||||
id text PRIMARY KEY DEFAULT generate_id('alrt'),
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
rule_id text NOT NULL, -- 'reservation.no_agreement', 'interest.stale', ...
|
||||
severity text NOT NULL, -- 'info' | 'warning' | 'critical'
|
||||
title text NOT NULL,
|
||||
body text,
|
||||
link text NOT NULL, -- relative path the card deep-links to
|
||||
entity_type text, -- optional FK target ('interest', 'reservation', ...)
|
||||
entity_id text,
|
||||
fingerprint text NOT NULL, -- hash of (rule_id + entity_type + entity_id) — dedupe
|
||||
fired_at timestamptz NOT NULL DEFAULT now(),
|
||||
dismissed_at timestamptz,
|
||||
dismissed_by text REFERENCES users(id),
|
||||
acknowledged_at timestamptz, -- "I'm on it" without dismissing
|
||||
acknowledged_by text REFERENCES users(id),
|
||||
resolved_at timestamptz, -- auto-set when underlying condition clears
|
||||
metadata jsonb DEFAULT '{}' -- per-rule extras (e.g. days_stale, amount_at_risk)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_alerts_fingerprint_open ON alerts (port_id, fingerprint) WHERE resolved_at IS NULL;
|
||||
CREATE INDEX idx_alerts_port_fired ON alerts (port_id, fired_at DESC);
|
||||
CREATE INDEX idx_alerts_port_severity_open ON alerts (port_id, severity) WHERE resolved_at IS NULL AND dismissed_at IS NULL;
|
||||
|
||||
-- expense duplicate detection (column-only, no new table)
|
||||
ALTER TABLE expenses ADD COLUMN duplicate_of text REFERENCES expenses(id);
|
||||
ALTER TABLE expenses ADD COLUMN dedup_scanned_at timestamptz;
|
||||
CREATE INDEX idx_expenses_dedup ON expenses (port_id, vendor_name, amount, expense_date)
|
||||
WHERE duplicate_of IS NULL;
|
||||
|
||||
-- analytics support: materialized refresh tracking (avoids recomputing on every dashboard hit)
|
||||
CREATE TABLE analytics_snapshots (
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
metric_id text NOT NULL, -- 'pipeline_funnel.30d', 'occupancy_timeline.90d', ...
|
||||
computed_at timestamptz NOT NULL DEFAULT now(),
|
||||
data jsonb NOT NULL,
|
||||
PRIMARY KEY (port_id, metric_id)
|
||||
);
|
||||
|
||||
-- audit_logs already exists; add a tsvector column for fast search
|
||||
ALTER TABLE audit_logs ADD COLUMN search_text tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
to_tsvector('simple',
|
||||
coalesce(action, '') || ' ' ||
|
||||
coalesce(entity_type, '') || ' ' ||
|
||||
coalesce(entity_id::text, '') || ' ' ||
|
||||
coalesce(actor_email, ''))
|
||||
) STORED;
|
||||
CREATE INDEX idx_audit_search ON audit_logs USING gin(search_text);
|
||||
|
||||
-- ocr extracted fields on receipt files (most fields already on expenses)
|
||||
ALTER TABLE expenses ADD COLUMN ocr_status text DEFAULT 'pending'; -- 'pending'|'ok'|'failed'|'low_confidence'
|
||||
ALTER TABLE expenses ADD COLUMN ocr_raw jsonb; -- the model's full response
|
||||
ALTER TABLE expenses ADD COLUMN ocr_confidence numeric; -- 0..1
|
||||
```
|
||||
|
||||
After running migration on dev/staging, restart `next dev` to flush postgres.js prepared-statement cache (project convention).
|
||||
|
||||
### Service-layer changes
|
||||
|
||||
**New services:**
|
||||
|
||||
- `alerts.service.ts` — CRUD + fanout: `evaluateRules(portId)`, `dismissAlert(id, userId)`, `acknowledgeAlert(id, userId)`, `resolveStaleAlerts(portId)`
|
||||
- `alert-rules.ts` — fixed catalog of evaluator functions, each takes `(portId, db)` and returns `Array<{ rule_id, severity, fingerprint, ... }>`
|
||||
- `analytics.service.ts` — `getPipelineFunnel(portId, range)`, `getOccupancyTimeline(portId, range)`, `getRevenueBreakdown(portId, range)`, `getLeadSourceAttribution(portId, range)`; reads `analytics_snapshots` first, recomputes if stale
|
||||
- `analytics-snapshot-job.ts` — BullMQ recurring job that recomputes snapshots every 15 min per port
|
||||
- `expense-dedup.service.ts` — `scanForDuplicates(expenseId)`, returns candidate matches with confidence; called from BullMQ on `expense:created`
|
||||
- `expense-ocr.service.ts` — Claude Vision wrapper: takes file URL, returns parsed expense fields; uses prompt caching for the system prompt to keep cost down
|
||||
- `audit-search.service.ts` — wraps drizzle query with tsvector match + filters
|
||||
|
||||
**Extended services:**
|
||||
|
||||
- `documents.service.ts` — adds `getEoiQueueRows(portId, opts)` that joins documents + signers + last-reminder for the EOI queue tab
|
||||
- `expenses.service.ts` — `createExpense` triggers OCR + dedup BullMQ jobs after row insert
|
||||
- `notifications.service.ts` — fires `alert:created` and `alert:resolved` socket events
|
||||
|
||||
### Alert rule catalog (v1)
|
||||
|
||||
| Rule ID | Severity | Trigger | Resolves when | Why it matters |
|
||||
| ---------------------------- | -------- | -------------------------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------- |
|
||||
| `reservation.no_agreement` | warning | active reservation > 3d old without a `reservation_agreement` doc in any non-cancelled state | doc reaches `sent` | flagged in Phase A spec |
|
||||
| `interest.stale` | info | `pipelineStage IN ('details_sent','in_communication','visited')` AND last activity > 14d | activity timestamp updates | dropped leads |
|
||||
| `document.expiring_soon` | warning | `expires_at` within 7 days, `status IN ('sent','partially_signed')` | doc completed/cancelled or expires_at passes | nudge before contracts lapse |
|
||||
| `document.signer_overdue` | warning | signer pending > 14d AND last reminder > 7d ago | signer signs/declines | classic chase target |
|
||||
| `berth.under_offer_stalled` | info | berth `status='under_offer'` > 30d | status changes | reservation never closed |
|
||||
| `expense.duplicate` | info | `expense.duplicate_of IS NOT NULL` | merged or marked-not-duplicate | bookkeeping cleanup |
|
||||
| `expense.unscanned` | info | expense with file but `ocr_status='pending'` > 1h | `ocr_status='ok'` | OCR failed silently |
|
||||
| `interest.high_value_silent` | critical | `leadCategory='hot_lead'` AND last activity > 7d | activity update | revenue at risk |
|
||||
| `eoi.unsigned_long` | warning | EOI doc `status='sent'` > 21d | doc completed/cancelled | EOI funnel leak |
|
||||
| `audit.suspicious_login` | critical | >3 failed logins from same IP in 1h | manual dismiss | security awareness |
|
||||
|
||||
Rules are pure functions; the engine takes their outputs, upserts on `(port_id, fingerprint)` to avoid spam, and auto-resolves alerts whose rule no longer fires.
|
||||
|
||||
## Per-feature design
|
||||
|
||||
### Analytics dashboard
|
||||
|
||||
Replaces the current 4-tile dashboard. Layout:
|
||||
|
||||
```
|
||||
[ Gradient PageHeader: "Dashboard" · last-updated stamp · Date range picker (Today / 7d / 30d / 90d / custom) ]
|
||||
|
||||
[ KPI row (4 KPITiles, sparkline + delta vs prior period):
|
||||
Total Clients Active Interests Pipeline Value Occupancy Rate
|
||||
]
|
||||
|
||||
[ Pipeline funnel (recharts FunnelChart): | Alert rail (right column):
|
||||
horizontal bars per stage with conversion % | Critical (red) cards
|
||||
click bar → filtered interests list | Warning (amber) cards
|
||||
| Info (blue) cards
|
||||
| "Show dismissed" toggle
|
||||
] |
|
||||
|
||||
[ Revenue breakdown (recharts BarChart, stacked by source) ] | (continues)
|
||||
|
||||
[ Occupancy timeline (recharts AreaChart, daily/weekly) ] |
|
||||
|
||||
[ Lead source attribution (recharts PieChart with legend) ]
|
||||
```
|
||||
|
||||
Charts are server-rendered via the recharts already-in-bundle. Data comes from `analytics.service.ts` which reads `analytics_snapshots` (refreshed every 15 min by cron) — first hit warms the cache, subsequent hits are sub-100ms.
|
||||
|
||||
Date-range picker re-runs `analytics.service` queries with the selected range; cache key includes the range so 30d and 90d don't fight.
|
||||
|
||||
Export: each chart card has a `[...]` overflow menu with "Download as CSV" and "Download as PNG"; uses recharts' `getDataUrl()` for PNG.
|
||||
|
||||
### Alert rail
|
||||
|
||||
Right column on `/dashboard`, full page at `/alerts`. Each alert is a card:
|
||||
|
||||
```
|
||||
[severity-color stripe-left]
|
||||
[rule-icon] Title (entity name)
|
||||
Body — body text describing the condition
|
||||
Last fired N days ago · entity: link
|
||||
[Acknowledge] [Dismiss] [Open →]
|
||||
```
|
||||
|
||||
- Acknowledge: marks `acknowledged_at` but stays visible (someone's on it)
|
||||
- Dismiss: hides from the rail; appears in `/alerts` "Dismissed" tab
|
||||
- Auto-resolve: when the rule re-evaluates and the condition no longer fires, alert moves to "Resolved" history
|
||||
|
||||
Real-time: socket emits `alert:created` / `alert:resolved` from the cron worker; React Query invalidates the alert list.
|
||||
|
||||
### Interests-by-berth view
|
||||
|
||||
New tab on `/[port]/berths/[id]` called "Interests" — count badge in tab.
|
||||
|
||||
```
|
||||
[ Berth header (existing) ]
|
||||
|
||||
[ Tabs: Overview | Reservations | Interests (N) | Notes | Files | Activity ]
|
||||
|
||||
[ Interests tab body:
|
||||
[Filter: All stages | Active only | Lost] [Sort: Newest | Stage progress | Lead score]
|
||||
Table: client name | stage pill | source | category | last activity | score badge
|
||||
Click row → interest detail
|
||||
]
|
||||
```
|
||||
|
||||
Pure read; no mutations. The list filters interests where `interest.berthId = berth.id`. Already exists in DB; just needs the UI tab.
|
||||
|
||||
### Expense duplicate detection
|
||||
|
||||
When a new expense is created, BullMQ job `expense.dedup` runs:
|
||||
|
||||
```ts
|
||||
async function scanForDuplicates(expenseId: string) {
|
||||
const e = await db.query.expenses.findFirst({ where: eq(expenses.id, expenseId) });
|
||||
const candidates = await db.query.expenses.findMany({
|
||||
where: and(
|
||||
eq(expenses.portId, e.portId),
|
||||
eq(expenses.vendorName, e.vendorName),
|
||||
eq(expenses.amount, e.amount),
|
||||
between(expenses.expenseDate, addDays(e.expenseDate, -3), addDays(e.expenseDate, 3)),
|
||||
ne(expenses.id, e.id),
|
||||
),
|
||||
});
|
||||
if (candidates.length > 0) {
|
||||
await db
|
||||
.update(expenses)
|
||||
.set({ duplicate_of: candidates[0].id, dedup_scanned_at: new Date() })
|
||||
.where(eq(expenses.id, expenseId));
|
||||
// fires `expense.duplicate` alert via rule engine on next sweep
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Detail page: when `duplicate_of` is set, show a yellow banner: "Looks like a duplicate of {linked expense}. [Merge them] [Mark as not duplicate]". Merge: deletes the new expense and merges any line items into the original.
|
||||
|
||||
### EOI queue tab
|
||||
|
||||
Documents hub gets a new tab between "Awaiting them" and "Awaiting me":
|
||||
|
||||
```
|
||||
Tabs: All | EOI queue (N) | Awaiting them | Awaiting me | Completed | Expired
|
||||
```
|
||||
|
||||
`EOI queue` filters: `documentType='eoi' AND status IN ('sent','partially_signed')`. Same row chrome as the rest of the hub. Bulk-action bar adds an "EOI bulk reminder" preset that respects the rule engine's reminder cooldown.
|
||||
|
||||
### OCR for expense receipts
|
||||
|
||||
Existing `/expenses/scan` route — extend to call Claude Vision on upload:
|
||||
|
||||
```ts
|
||||
// expense-ocr.service.ts (uses Anthropic SDK; already in deps)
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
const client = new Anthropic();
|
||||
|
||||
const SYSTEM_PROMPT = `You extract structured expense data from receipts...
|
||||
Output JSON: { vendor, amount, currency, date (ISO), lineItems: [...], confidence (0-1) }
|
||||
`; /* cached via ephemeral cache_control for cost savings */
|
||||
|
||||
export async function ocrReceipt(fileUrl: string) {
|
||||
const file = await fetch(fileUrl);
|
||||
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: 'claude-haiku-4-5-20251001', // haiku for cost; sonnet if quality needed
|
||||
max_tokens: 1024,
|
||||
system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
|
||||
{ type: 'text', text: 'Extract expense fields from this receipt.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return parseAndValidate(message.content[0].text);
|
||||
}
|
||||
```
|
||||
|
||||
UI: existing scan page now shows a 3-step flow:
|
||||
|
||||
1. Upload receipt photo
|
||||
2. Wait for OCR (spinner; ~3s avg with Haiku)
|
||||
3. Confirm extracted fields (pre-filled form, user can edit)
|
||||
4. Save → existing expense create flow
|
||||
|
||||
Low-confidence (< 0.6) extractions show a yellow banner "Please verify all fields" and pre-select the file uploader.
|
||||
|
||||
### Audit log read view
|
||||
|
||||
Admin route `/[port]/admin/audit`:
|
||||
|
||||
```
|
||||
[ PageHeader: "Audit Log" · "Last 30 days · 12,847 events" ]
|
||||
|
||||
[ Filter row:
|
||||
Search [tsvector] Actor [combobox of users] Action [pills] Entity type [select]
|
||||
Date range [picker] Severity [pills] [Reset]
|
||||
]
|
||||
|
||||
[ Table:
|
||||
Timestamp | Actor | Action | Entity | Diff button | IP | User-agent
|
||||
Click row → expand to show before/after JSON diff
|
||||
]
|
||||
|
||||
[ Pagination · Export CSV button (admin-gated) ]
|
||||
```
|
||||
|
||||
Server-side: `audit-search.service.ts` builds a drizzle query with the tsvector match + filters; supports cursor pagination on `(created_at, id)`.
|
||||
|
||||
Super-admin sees a port toggle that switches between current port and "All ports" view.
|
||||
|
||||
## Test plan
|
||||
|
||||
### Unit (`tests/unit/`)
|
||||
|
||||
- `alert-rules-evaluators.test.ts` — each rule tested with seeded data; covers fire/no-fire cases and resolution conditions
|
||||
- `expense-dedup-heuristic.test.ts` — vendor/amount/date matching with edge cases (case-insensitive, ±3d window, currency mismatch ignored)
|
||||
- `analytics-pipeline-funnel.test.ts` — funnel math against fixture interests
|
||||
- `analytics-occupancy-timeline.test.ts` — daily aggregation against fixture berth status changes
|
||||
- `audit-search-filters.test.ts` — tsvector + filter composition
|
||||
- `ocr-prompt-caching.test.ts` — assert cache_control presence on system prompt; mocked Claude response
|
||||
|
||||
### Integration (`tests/integration/`)
|
||||
|
||||
- `alerts-engine.test.ts` — full evaluation cycle: seed conditions, run engine, assert correct alerts upserted, run again to assert dedupe via fingerprint, mutate state, assert auto-resolve
|
||||
- `analytics-snapshot-refresh.test.ts` — recurring job: snapshot row written, served from cache on next read, refreshed on next tick
|
||||
- `expense-dedup-flow.test.ts` — create A, create matching B, assert B.duplicate_of=A; merge B → A absorbs line items, B archived
|
||||
- `audit-search-tsvector.test.ts` — seed audit_logs, query by free-text, assert returned ids
|
||||
- `eoi-queue-listing.test.ts` — extends documents-hub test; assert EOI tab returns correct subset
|
||||
|
||||
### E2E smoke (`tests/e2e/smoke/`)
|
||||
|
||||
- New `27-analytics-dashboard.spec.ts` — dashboard renders charts; date-range picker re-renders; KPI tiles show non-zero data after seed
|
||||
- New `28-alerts.spec.ts` — alert appears after seeding stale-interest condition; click-to-deep-link; dismiss persists; resolve hides
|
||||
- New `29-interests-by-berth.spec.ts` — tab visible on berth detail; lists interests; sort works
|
||||
- New `30-expense-dedup.spec.ts` — create two matching expenses; banner appears; merge button works
|
||||
- New `31-ocr-flow.spec.ts` — uploads fixture receipt image; extracted fields pre-filled; user can edit and save
|
||||
- New `32-audit-log.spec.ts` — admin page loads; search by entity id returns expected row; date filter narrows
|
||||
- Extend `04-documents.spec.ts` — EOI queue tab presence + count badge
|
||||
|
||||
### E2E exhaustive (`tests/e2e/exhaustive/`)
|
||||
|
||||
- `15-analytics-dashboard.spec.ts` — crawl every chart's hover tooltips, legend toggles, export menu
|
||||
- `16-alerts.spec.ts` — crawl alert card actions, severity filters, dismissed history, real-time arrival via socket emit
|
||||
- `17-audit-log.spec.ts` — crawl filter combos, expand row diffs, super-admin all-ports toggle
|
||||
|
||||
### E2E real-API (`tests/e2e/realapi/`)
|
||||
|
||||
- New `claude-vision-receipt-ocr.spec.ts` — gates on `ANTHROPIC_API_KEY`; uploads two real fixture receipts (one clean, one blurry); asserts Haiku response shape and confidence score; verifies `cache_control` headers in HTTP trace; cleanup deletes test expense
|
||||
|
||||
### Test data fixtures
|
||||
|
||||
`global-setup.ts` extends:
|
||||
|
||||
- Seed one stale interest in `details_sent` stage with `last_activity_at = now - 20d` (fires `interest.stale`)
|
||||
- Seed one active reservation without an agreement (fires `reservation.no_agreement`)
|
||||
- Seed two matching expenses (fires `expense.duplicate`)
|
||||
- Seed 90 days of pipeline activity for analytics charts
|
||||
- Add a `tests/e2e/fixtures/receipts/` dir with two .jpg receipts for OCR tests
|
||||
|
||||
## Build sequence
|
||||
|
||||
| # | Title | Effort | Depends on |
|
||||
| --- | ------------------------------------------------------------ | ------ | ----------------- |
|
||||
| 1 | Schema + alert/analytics service skeletons | 1d | — |
|
||||
| 2 | Alert rules engine + recurring evaluator + socket | 1.5d | 1 |
|
||||
| 3 | Analytics snapshot job + service layer | 1d | 1 |
|
||||
| 4 | Analytics dashboard page (KPI tiles + 4 charts + date-range) | 2.5d | 1, 3, A's KPITile |
|
||||
| 5 | Alert rail UI + `/alerts` page | 1.5d | 2 |
|
||||
| 6 | EOI queue tab on documents hub | 0.5d | A's hub |
|
||||
| 7 | Interests-by-berth tab on berth detail | 0.5d | — |
|
||||
| 8 | Expense duplicate detection (job + UI banner + merge) | 1.5d | 1 |
|
||||
| 9 | OCR for expense receipts (Claude Vision + 3-step UI) | 1.5d | — |
|
||||
| 10 | Audit log read view (admin page + filters + tsvector search) | 1.5d | 1 |
|
||||
| 11 | Real-API integration tests | 1d | 9 |
|
||||
|
||||
### Critical path
|
||||
|
||||
```
|
||||
1 → 2 → 5 (data → alert engine → alert UI)
|
||||
1 → 3 → 4 (data → analytics service → analytics page)
|
||||
8 → 2 (alert rule) (dedup populates the data the alert reads)
|
||||
9 (OCR) → 11 (realapi)
|
||||
```
|
||||
|
||||
Wall-clock minimum ~10 days (one engineer, sequential critical path); realistic with overhead ~13 days; calendar 2.5–3 weeks.
|
||||
|
||||
### Acceptance gates per PR
|
||||
|
||||
- `pnpm tsc --noEmit` and `pnpm lint` clean
|
||||
- Vitest unit + integration green (incl. new tests)
|
||||
- Playwright smoke green for the surface touched
|
||||
- Visual baselines regenerated and reviewed in PR diff
|
||||
- For PRs touching external integrations (9 OCR, 11 realapi): relevant `realapi` spec verified locally before merge
|
||||
|
||||
### Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Alert engine false positives spam users | Each rule has a "snooze" window in metadata; rules ship behind a feature flag `alerts.{rule_id}.enabled`; QA seeds production-shape data before flipping flags on |
|
||||
| Analytics queries slow on large datasets | `analytics_snapshots` materialized cache; cron recomputes off the request path; queries use existing per-port indexes |
|
||||
| Claude Vision OCR cost spirals | Default to Haiku 4.5 (~10× cheaper than Sonnet); ephemeral system-prompt cache hits ~80%; per-port quota with admin-visible meter |
|
||||
| OCR low-quality on blurry receipts | Confidence threshold (< 0.6) flips to "verify mode" — user must touch every field before save; failure metric tracked in admin/monitoring |
|
||||
| Audit log table large (millions of rows) | Already partitioned-friendly via the GIN tsvector index; pagination uses cursor on `(created_at, id)` not OFFSET |
|
||||
| Alert socket fanout overwhelms client | Throttle the engine cron to once per 5min; client debounces React Query refetches |
|
||||
| Interest stale rule fires for legitimately paused leads | Add a per-interest `paused_until` field as a follow-up if operators ask; v1 ships without |
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Alert** — operator-facing actionable card, rule-fired, dismissible
|
||||
- **Rule** — a pure-function evaluator that takes (port, db) and returns alert candidates
|
||||
- **Fingerprint** — `hash(rule_id + entity_type + entity_id)` used to dedupe alerts across re-evaluations
|
||||
- **Snapshot** — cached chart data row in `analytics_snapshots`, refreshed on cron
|
||||
- **EOI queue** — saved-view filter on the documents hub, not a separate page
|
||||
- **OCR** — Claude Vision extraction of structured expense fields from receipt images
|
||||
- **Audit log** — read view of the existing `audit_logs` table; no schema change beyond a tsvector column
|
||||
|
||||
## Open questions for the user
|
||||
|
||||
- Which port should be the **default landing dashboard** when a super-admin logs in (currently first-port-by-name; analytics page works the same)?
|
||||
- Should the alert rail be **always visible on all dashboard pages** or only on `/dashboard` (currently spec'd as the latter)?
|
||||
- Do you want the **Audit log retention policy** (delete > N days old) wired in v1 or deferred to Phase D?
|
||||
- Should **OCR be opt-in per port** (admin toggle) or always-on with a quota?
|
||||
376
docs/superpowers/specs/2026-04-29-gws-inbox-triage-design.md
Normal file
376
docs/superpowers/specs/2026-04-29-gws-inbox-triage-design.md
Normal 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, 6–12 weeks)
|
||||
- A Cloud Application Security Assessment (CASA) report
|
||||
3. Marketplace listing review (~2 weeks after CASA passes).
|
||||
|
||||
**Calendar time:** 4–6 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
|
||||
1500–2500 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 | 3–4 weeks | ~5 weeks (over G1–G5) | ~1 week (receive-only) |
|
||||
| Calendar time to first customer | 4–6 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 | 1–10 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.
|
||||
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Mobile Optimization Design
|
||||
|
||||
**Status**: Design approved 2026-04-29 — pending plan.
|
||||
**Plan decomposition**: Foundation PR (§3) is one implementation plan; per-page migration phases (§5) become follow-up plans, scoped per phase.
|
||||
**Branch base**: stacks on `refactor/data-model`.
|
||||
**Out of scope**: Phase B/C features, desktop redesign, Capacitor wrapper, swipe-actions on rows, native menus, server-driven UI.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background
|
||||
|
||||
The CRM was built desktop-first. A 2026-04-29 mobile audit captured every authenticated and public page across the active iPhone viewport range. Findings:
|
||||
|
||||
1. **No `viewport` meta in the root layout** (one exists only in the scanner PWA sub-layout, `src/app/(scanner)/[portSlug]/scan/layout.tsx`). Without it, iOS Safari renders pages at the default 980px logical width and zooms out to fit — text becomes unreadable and touch targets sub-tappable. Playwright's `isMobile` emulation in the audit forces 393px-wide rendering, which exposes the layout breakage you'd otherwise have to discover by pinching to zoom.
|
||||
2. **Topbar overflows**. Search input + port switcher + sign-out button cram into one row; sign-out clips off the right edge as a half-visible blue bar on every authenticated page.
|
||||
3. **Tables render as desktop tables**. Every list page (clients, yachts, companies, invoices, expenses, interests, audit, users, etc.) shows truncated columns with horizontal scroll.
|
||||
4. **Page headers don't downsize**. Titles like "Dashboard" truncate to "Dash..."; primary action buttons (`+ New Client`) overlap their subtitles.
|
||||
5. **Detail page action chips overflow**. The chip row ("Invite to portal | GDPR export | Archive | …") horizontally overflows on every detail page.
|
||||
6. **One half-good pattern**: detail pages already collapse their tabs to a `<select>` dropdown on small screens. Worth extending.
|
||||
7. **Auth + scanner pages are already mobile-first** (`/login`, `/[portSlug]/scan`). Reference for the "what good looks like" target.
|
||||
|
||||
The audit harness (`tests/e2e/audit/mobile.spec.ts` + `mobile-audit` Playwright project) is added on this branch (not yet committed); re-runs regenerate `.audit/mobile/` (gitignored).
|
||||
|
||||
## 2. Approach
|
||||
|
||||
**Adaptive shell + responsive content** — chosen over (a) per-page conditional render, (b) a separate `(mobile)` route group, and (c) Tailwind-only responsive.
|
||||
|
||||
The "native feel" the user wants comes from the chrome — bottom tab bar, sheet modals, sticky compact header, safe-area awareness. Page content (forms, lists, details) doesn't need duplication; it gets responsive via shared mobile-aware primitives. This concentrates the dedicated-mobile work in ~10 components and keeps content single-source.
|
||||
|
||||
**Breakpoint**: Tailwind `lg` (1024px). Below `lg`, the mobile shell renders. At and above, the existing desktop shell is untouched.
|
||||
|
||||
### 2.1 Target iPhone viewport range
|
||||
|
||||
The mobile shell + content primitives must look correct across the full active iPhone viewport range (portrait):
|
||||
|
||||
| Tier | Models | Viewport |
|
||||
| ------------------------------------------ | ----------------------------------------------- | -------- |
|
||||
| Narrowest | iPhone SE 2nd / 3rd gen | 375×667 |
|
||||
| Standard | iPhone 12/13/14 (and Mini) | 390×844 |
|
||||
| Standard newer | iPhone 15 / 15 Pro / 16 | 393×852 |
|
||||
| Pro newer (Dynamic Island, thinner bezels) | iPhone 16 Pro / 17 Pro | 402×874 |
|
||||
| Plus / older Max | iPhone 14 Plus / 15 Plus / 15 Pro Max / 16 Plus | 430×932 |
|
||||
| Pro Max | iPhone 16 Pro Max / 17 Pro Max | 440×956 |
|
||||
|
||||
**Anchors used by audit and design validation**: 375×667 (worst-case narrow + short), 393×852 (most common current), 402×874 (current Pro), 440×956 (current Pro Max). Models within ±5px of an anchor (390, 430) are skipped — primitives that look correct at the anchors will look correct at neighbors.
|
||||
|
||||
**Dynamic Island**: iPhone 14 Pro and later have a larger top safe-area inset (~59px vs ~47px on classic-notch models). The CSS `env(safe-area-inset-top)` we expose as `pt-safe` handles this transparently — no per-model code paths.
|
||||
|
||||
**Landscape**: out of scope for this design. Phones in landscape are rare for CRM-style work; if needed later, the mobile shell at landscape widths would still fall under `lg` and would just stretch. Tablet landscape is addressed in the §5 tablet-pass phase.
|
||||
|
||||
**Routing**: no new route group. URLs and middleware unchanged. RBAC, services, queries, validators, RHF/zod forms, TanStack Query stores, socket.io — all unchanged.
|
||||
|
||||
## 3. Foundation PR
|
||||
|
||||
A single branch lands the infra + shell + primitives before any per-page work. After this merges, every authenticated page already gains: real viewport meta, no clipped topbar, bottom tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.
|
||||
|
||||
### 3.1 Infrastructure
|
||||
|
||||
- `viewport` export in `src/app/layout.tsx` — `width=device-width, initial-scale=1, viewport-fit=cover`.
|
||||
- `theme-color` meta + `apple-mobile-web-app-capable` meta + `apple-mobile-web-app-status-bar-style` for PWA-ish status-bar integration.
|
||||
- Safe-area CSS variables (`env(safe-area-inset-*)`) exposed as Tailwind utilities (`pt-safe`, `pb-safe`, `pl-safe`, `pr-safe`).
|
||||
- `useIsMobile()` hook in `src/hooks/use-is-mobile.ts` — backed by `window.matchMedia('(max-width: 1023.98px)')`, no resize listener.
|
||||
- Server-side body-class detection: the root layout (`src/app/layout.tsx`) reads the `user-agent` request header via `next/headers`'s `headers()`, runs a small known-mobile-token check (Mobile / iPhone / iPad / Android — no library), and renders `<body data-form-factor="mobile|desktop">`. No middleware needed. CSS `[data-form-factor="mobile"]` reveals the mobile shell. The CSS media-query fallback (`@media (max-width: 1023.98px)`) handles UA misclassification (e.g., desktop browser resized to narrow width, or stripped UA).
|
||||
|
||||
### 3.2 Mobile shell
|
||||
|
||||
Both desktop and mobile shells are rendered to the DOM by the root layout; CSS reveals one and hides the other based on `[data-form-factor="mobile"]` plus a `@media (max-width: 1023.98px)` fallback. The existing `<Sidebar>` and `<Topbar>` components stay unchanged for the desktop shell. The mobile shell is wholly new:
|
||||
|
||||
- **`<MobileLayout>`** (`src/components/layout/mobile/mobile-layout.tsx`)
|
||||
Fixed 52px compact topbar (safe-area aware) + scrollable content + fixed 56px bottom tab bar (safe-area inset). Renders instead of the desktop sidebar+topbar shell when the form factor resolves to mobile.
|
||||
|
||||
- **`<MobileTopbar>`**
|
||||
Page title (auto-truncating, single-line) + back button when route depth > 1 + single primary action slot (passed via context from the page) + port-switcher behind a `<Sheet>` trigger.
|
||||
|
||||
- **`<MobileBottomTabs>`**
|
||||
Fixed 5 tabs: **Dashboard / Clients / Yachts / Berths / More**. Active state from current path. Lucide icons (no emoji). Badge support for the alerts count.
|
||||
|
||||
- **`<MoreSheet>`**
|
||||
Bottom sheet opened by the More tab. Holds the long tail in a scrollable list grouped by section: Companies, Interests, Invoices, Expenses, Documents, Email, Alerts, Reports, Reminders, Settings, Admin (with admin nesting one level deep into a child sheet).
|
||||
|
||||
- **`<MobileLayoutProvider>`**
|
||||
React context that lets each page push its title, back button, and primary action slot to `<MobileTopbar>` via a hook (`useMobileChrome({ title, action })`).
|
||||
|
||||
### 3.3 Primitives
|
||||
|
||||
All built once in `src/components/shared/`. Render desktop-style above `lg`, mobile-style below.
|
||||
|
||||
- **`<Sheet>`** — vaul-based bottom sheet on mobile, falls through to existing Radix `<Dialog>` on desktop. Same API as `<Dialog>` so adoption is mechanical.
|
||||
- **`<DataView>`** — accepts the same column defs the codebase uses today via TanStack Table. Above `lg`: renders the existing table. Below `lg`: renders a card list with a per-row `cardRender({ row }) => ReactNode` callback. Filter chips stay above the list; sort moves into a `<Sheet>` opened by a sort button.
|
||||
- **`<PageHeader>`** — title + optional subtitle + actions. Truncates title to one line, stacks actions to a second row on mobile, hides subtitle below `sm` if action row is present.
|
||||
- **`<ActionRow>`** — chip-style action group; `flex-nowrap overflow-x-auto scroll-smooth snap-x` on mobile, no overflow on desktop.
|
||||
- **`<DetailPageShell>`** — wraps detail pages with: sticky compact header (entity name, primary status), tab dropdown selector (existing pattern, extracted), scrollable content area, optional sticky bottom action bar (Save / Archive / etc.) on mobile that pins above the bottom tab bar.
|
||||
- **`<FilterChips>`** — chip-row filter UI used by `<DataView>`. Active filters are dismissable chips; "Add filter" opens a `<Sheet>`.
|
||||
|
||||
### 3.4 Default style adjustments
|
||||
|
||||
- `<Button>` and `<Input>` defaults: `min-h-11` (44px, Apple HIG touch-target).
|
||||
- `<Input>` and `<Textarea>` body text: `text-base` (16px) so iOS doesn't zoom on focus.
|
||||
- `<Dialog>` default base styling tweaked so any remaining unmigrated dialogs render full-screen on mobile (until they get migrated to `<Sheet>`).
|
||||
|
||||
### 3.5 Bundle impact
|
||||
|
||||
Both shells render server-side and switch via the `data-form-factor` body attribute, so both ship to every client (dynamic-importing one would cause a hydration flash). Rough estimate ~40KB gzipped added to the layout subtree for the mobile shell + new primitives (vaul ≈ 5KB gz, the rest is in-house components). Verify post-build with `pnpm build` and adjust if it's materially higher. Acceptable trade for no flash and no UA-based render-time branching.
|
||||
|
||||
### 3.6 PWA assets
|
||||
|
||||
The PWA scanner already references `icon-192.png`, `icon-512.png`, `icon-512-maskable.png` from `public/`, but those files don't exist yet (separate flagged blocker). The mobile shell adds an `apple-touch-icon` reference too. The Foundation PR includes placeholder PNGs so home-screen install works; production-quality icons can replace them without a code change.
|
||||
|
||||
## 4. Per-page playbook
|
||||
|
||||
Once foundation lands, each page follows the same workflow:
|
||||
|
||||
1. Open the page in headed Playwright at the anchor viewports per §2.1 (start at 393×852 for the iteration loop, spot-check 375 and 440 before declaring done).
|
||||
2. Replace any `<Dialog>` with `<Sheet>`.
|
||||
3. If list page: wrap the table in `<DataView>` and provide a `cardRender` callback. The 2-3 fields shown on the card are decided per page during migration with the user.
|
||||
4. Replace the ad-hoc page header with `<PageHeader>`.
|
||||
5. Replace ad-hoc action button rows with `<ActionRow>`.
|
||||
6. Touch up any custom embedded widgets the page uses (rare for simple pages, common for `email`, `documents`, `expenses/scan`).
|
||||
7. User reviews live in the headed browser, points out tweaks, iterate.
|
||||
|
||||
Most pages take 5–15 minutes in this loop. Heavy pages (email inbox, documents hub) may take 30–60 because the embedded widgets need their own mobile treatment beyond the primitives.
|
||||
|
||||
## 5. Migration sequence
|
||||
|
||||
After foundation PR:
|
||||
|
||||
1. **Quick-win sweep** (~half day) — pages mostly fixed by foundation alone. Just need `<PageHeader>` swap-in (no list-card conversion, no detail-shell wrap):
|
||||
`dashboard` (overview), `settings` (user-profile), `reports`, and the admin sub-pages that are forms or stat cards: `admin/settings`, `admin/branding`, `admin/forms`, `admin/ocr`, `admin/roles`, `admin/tags`, `admin/documenso`, `admin/templates`, `admin/custom-fields`, `admin/monitoring`, `admin/backup`, `admin/webhooks`, `admin/import`, `admin/ports`.
|
||||
2. **List pages** (~1–2 days) — convert via `<DataView>` + per-page `cardRender`:
|
||||
`clients`, `yachts`, `companies`, `berths`, `interests`, `invoices`, `expenses`, `alerts`, `reminders`, `admin/audit`, `admin/users`.
|
||||
3. **Heavy pages** (~1 day each) — embedded widgets need their own mobile treatment beyond the primitives:
|
||||
`documents` (sig-tracking + filters from Phase A), `email` (thread list + reader + composer).
|
||||
4. **Detail pages** (~1–2 days) — wrap in `<DetailPageShell>`, extend the tab-dropdown pattern, add sticky bottom action shelf:
|
||||
`clients/[clientId]`, `yachts/[yachtId]`, `companies/[companyId]`, `berths/[berthId]`, `invoices/[id]`, `expenses/[id]`.
|
||||
5. **Forms & wizards** — touch-up only, since `<Input>`/`<Button>` defaults handle the bulk:
|
||||
`invoices/new` (3-step wizard), `expenses/scan` (already mobile-first, just verify).
|
||||
6. **Portal** — same patterns, smaller scope:
|
||||
authenticated: `portal/dashboard`, `portal/invoices`, `portal/my-yachts`, `portal/documents`, `portal/interests`, `portal/my-reservations`. Public: `portal/login`, `portal/activate`, `portal/forgot-password`, `portal/reset-password` (already styled by `<BrandedAuthShell>` — just verify).
|
||||
7. **Tablet pass** — re-audit at iPad Air 11" portrait (820×1180) and landscape (1180×820), iPad Air 13" portrait (1024×1366) and landscape (1366×1024). The 820 portrait case will hit the mobile shell (820 < 1024) and probably want a "tablet-portrait" treatment with sidebar visible — flagged for design refinement at that phase, not now. The other three viewports fall above `lg` and use the desktop shell unchanged.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Mobile audit project** (`mobile-audit` in `playwright.config.ts`) is the regression harness. Re-runs after every page-migration PR; output goes to `.audit/mobile/` (gitignored). Audit covers the four anchor viewports defined in §2.1: 375×667, 393×852, 402×874, 440×956. Run time ~14 min headed.
|
||||
- **Smoke project** gets a curated mobile-viewport variant (~10 pages at the 393×852 anchor) — adds ~2 min to CI; full audit stays out of CI to avoid the ~14 min cost.
|
||||
- **Visual baselines** — `visual` project gets new mobile snapshots at the 393×852 anchor for: dashboard, clients-list, clients-detail, invoices-list, invoices-new, scan, documents, login. Regenerate with `--update-snapshots` after intentional changes (existing convention).
|
||||
- **Anchor device descriptors** lifted into a shared fixture at `tests/e2e/fixtures/devices.ts` (one per anchor in §2.1) so specs don't redefine viewport.
|
||||
- **No new unit tests** for the primitives — they are presentational. Coverage comes from visual + integration runs.
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
- **Bottom-tab taxonomy**: locked at Dashboard / Clients / Yachts / Berths / More for now. The More sheet holds everything else losslessly, so this is reversible — if real usage suggests a different top-5 (e.g., Interests or Invoices in the tabs), swap them later without code restructure.
|
||||
- **`refactor/data-model` push order**: 155 commits unpushed. Foundation PR can stack on top and rebase, or wait until that branch merges. Decision deferred to user.
|
||||
- **Desktop touch-target adjustments**: bumping `<Button>`/`<Input>` to `min-h-11` will affect desktop too. Verify visually that no desktop layout breaks; if any does, scope the bump to mobile-only via the `data-form-factor` attribute.
|
||||
|
||||
## 8. Files to create
|
||||
|
||||
```
|
||||
src/hooks/use-is-mobile.ts
|
||||
src/components/layout/mobile/
|
||||
mobile-layout.tsx
|
||||
mobile-topbar.tsx
|
||||
mobile-bottom-tabs.tsx
|
||||
more-sheet.tsx
|
||||
mobile-layout-provider.tsx
|
||||
src/components/shared/
|
||||
sheet.tsx (new — vaul wrapper)
|
||||
data-view.tsx (new — table↔card)
|
||||
page-header.tsx (new)
|
||||
action-row.tsx (new)
|
||||
detail-page-shell.tsx (new)
|
||||
filter-chips.tsx (new)
|
||||
src/app/layout.tsx (modified — viewport export, theme-color, UA-derived data-form-factor body attribute via headers())
|
||||
public/icon-192.png (placeholder PWA asset)
|
||||
public/icon-512.png (placeholder PWA asset)
|
||||
public/icon-512-maskable.png (placeholder PWA asset)
|
||||
public/apple-touch-icon.png (placeholder PWA asset)
|
||||
tailwind.config.ts (modified — safe-area utilities, touch-target defaults)
|
||||
tests/e2e/fixtures/devices.ts (new — shared device descriptors)
|
||||
```
|
||||
|
||||
## 9. Files to modify per page
|
||||
|
||||
Per the playbook in §4, each page typically needs:
|
||||
|
||||
- One swap of header markup → `<PageHeader>`.
|
||||
- For list pages: one wrap of table → `<DataView>` + add `cardRender` callback.
|
||||
- For detail pages: wrap in `<DetailPageShell>`.
|
||||
- Replace `<Dialog>` imports with `<Sheet>`.
|
||||
- No service, validator, query, or schema changes anywhere.
|
||||
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal file
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# Client Deduplication and NocoDB Migration Design
|
||||
|
||||
**Status**: Design draft 2026-05-03 — pending approval.
|
||||
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
|
||||
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
|
||||
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background
|
||||
|
||||
### 1.1 Why this exists
|
||||
|
||||
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
|
||||
|
||||
- **252 Interests rows** in NocoDB, against an estimated ~190–200 unique humans (~20–25% duplication rate).
|
||||
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
|
||||
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
|
||||
- **No Clients table.** The conflated structure is structural, not accidental.
|
||||
|
||||
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
|
||||
|
||||
### 1.2 Real duplicate patterns observed in the live data
|
||||
|
||||
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
|
||||
|
||||
| Pattern | Example rows | Signature |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
|
||||
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
|
||||
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
|
||||
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
|
||||
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
|
||||
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
|
||||
|
||||
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
|
||||
|
||||
### 1.3 Dirty data inventory
|
||||
|
||||
The migration normalizer must survive these real values from production:
|
||||
|
||||
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
|
||||
|
||||
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
|
||||
|
||||
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
|
||||
|
||||
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
|
||||
|
||||
### 1.4 Existing battle-tested algorithm
|
||||
|
||||
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
|
||||
|
||||
### 1.5 Why the website is no longer the source of new dirty data
|
||||
|
||||
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approach
|
||||
|
||||
Three artifacts, layered:
|
||||
|
||||
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
|
||||
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
|
||||
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
|
||||
|
||||
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
|
||||
|
||||
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Normalization library
|
||||
|
||||
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
|
||||
|
||||
### 3.1 `normalizeName(raw: string)`
|
||||
|
||||
```ts
|
||||
export function normalizeName(raw: string): {
|
||||
display: string; // human-readable, kept for UI
|
||||
normalized: string; // for matching
|
||||
surnameToken?: string; // for surname-based blocking
|
||||
};
|
||||
```
|
||||
|
||||
- Trim leading/trailing whitespace
|
||||
- Replace `\r`, `\n`, tabs with single space
|
||||
- Collapse consecutive whitespace to single space
|
||||
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
|
||||
- `display` preserves user's intent (slash-with-company stays intact)
|
||||
- `normalized` is `display.toLowerCase()` for comparison
|
||||
- `surnameToken` is the last non-particle token for blocking
|
||||
|
||||
### 3.2 `normalizeEmail(raw: string)`
|
||||
|
||||
```ts
|
||||
export function normalizeEmail(raw: string): string | null;
|
||||
```
|
||||
|
||||
- Trim + lowercase
|
||||
- Validate via `zod.email()` schema
|
||||
- Returns `null` for empty / invalid (caller decides what to do)
|
||||
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
|
||||
|
||||
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
|
||||
|
||||
```ts
|
||||
export function normalizePhone(
|
||||
raw: string,
|
||||
defaultCountry: string,
|
||||
): {
|
||||
e164: string | null; // canonical, e.g. '+15742740548'
|
||||
country: string | null; // ISO-3166-1 alpha-2
|
||||
display: string | null; // user-facing pretty
|
||||
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
|
||||
} | null;
|
||||
```
|
||||
|
||||
Pipeline:
|
||||
|
||||
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
|
||||
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
|
||||
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
|
||||
4. If starts with `00` → replace with `+`
|
||||
5. If starts with `+` → parse as E.164
|
||||
6. Else if `defaultCountry` provided → parse against that country
|
||||
7. Else return null (caller's problem)
|
||||
|
||||
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
|
||||
|
||||
### 3.4 `resolveCountry(text: string)`
|
||||
|
||||
```ts
|
||||
export function resolveCountry(text: string): {
|
||||
iso: string | null; // ISO-3166-1 alpha-2
|
||||
confidence: 'exact' | 'fuzzy' | 'city' | null;
|
||||
};
|
||||
```
|
||||
|
||||
Reuses `src/lib/i18n/countries.ts`. Pipeline:
|
||||
|
||||
1. Lowercase + strip diacritics
|
||||
2. Exact match against country names (any locale we ship)
|
||||
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
|
||||
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
|
||||
|
||||
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dedup algorithm
|
||||
|
||||
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
|
||||
|
||||
### 4.1 Public API
|
||||
|
||||
```ts
|
||||
export interface MatchCandidate {
|
||||
id: string;
|
||||
fullName: string | null;
|
||||
emails: string[]; // already normalized
|
||||
phonesE164: string[]; // already normalized E.164
|
||||
countryIso: string | null;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
candidate: MatchCandidate;
|
||||
score: number; // 0–100
|
||||
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export function findClientMatches(
|
||||
input: MatchCandidate,
|
||||
pool: MatchCandidate[],
|
||||
thresholds: DedupThresholds,
|
||||
): MatchResult[];
|
||||
```
|
||||
|
||||
### 4.2 Scoring rules (compound)
|
||||
|
||||
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
|
||||
|
||||
| Rule | Score | Notes |
|
||||
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
|
||||
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
|
||||
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
|
||||
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
|
||||
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
|
||||
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
|
||||
| **Negative**: Same email but different country code on phone | −15 | Suggests spouse / coworker / shared inbox |
|
||||
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | −20 | Two distinct people with the same name |
|
||||
|
||||
### 4.3 Confidence tiers (post-compound)
|
||||
|
||||
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
|
||||
- **score 50–89 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
|
||||
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
|
||||
|
||||
### 4.4 Blocking strategy
|
||||
|
||||
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
|
||||
|
||||
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
|
||||
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
|
||||
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
|
||||
|
||||
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 0–5 candidates per query, regardless of N.
|
||||
|
||||
### 4.5 Performance budget
|
||||
|
||||
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
|
||||
|
||||
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k–10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
|
||||
|
||||
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
|
||||
|
||||
---
|
||||
|
||||
## 5. Configurable thresholds (admin settings)
|
||||
|
||||
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
|
||||
|
||||
| Key | Default | Effect |
|
||||
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
|
||||
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
|
||||
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
|
||||
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
|
||||
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
|
||||
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
|
||||
|
||||
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
|
||||
|
||||
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
|
||||
|
||||
---
|
||||
|
||||
## 6. Merge service contract
|
||||
|
||||
### 6.1 Data flow
|
||||
|
||||
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
|
||||
|
||||
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
|
||||
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
|
||||
- `interests.clientId`
|
||||
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
|
||||
- `clientAddresses.clientId` — same conflict handling
|
||||
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
|
||||
- `clientTags.clientId`
|
||||
- `clientYachtMembership.clientId` (or whatever the table is called)
|
||||
- `auditLogs.entityId` — annotate, don't move (audit truth)
|
||||
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
|
||||
4. **Soft-archive loser** — `loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
|
||||
5. **Write `clientMergeLog`** — `{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
|
||||
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
|
||||
|
||||
### 6.2 Schema additions (migration)
|
||||
|
||||
`clients` table gets a new column:
|
||||
|
||||
```ts
|
||||
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
|
||||
```
|
||||
|
||||
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
```
|
||||
|
||||
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
|
||||
|
||||
### 6.3 Undo
|
||||
|
||||
`unmergeClients(mergeLogId, ctx)`:
|
||||
|
||||
1. Within the undo window, look up the snapshot
|
||||
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
|
||||
3. Restore loser's contacts/addresses/notes/tags from snapshot
|
||||
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
|
||||
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
|
||||
|
||||
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
|
||||
|
||||
### 6.4 Concurrency
|
||||
|
||||
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
|
||||
|
||||
---
|
||||
|
||||
## 7. Runtime surfaces
|
||||
|
||||
### 7.1 Layer 1 — At-create suggestion
|
||||
|
||||
In `ClientForm` (and the public `register` form once that hits the new system):
|
||||
|
||||
- Debounced 300ms after email or phone field changes
|
||||
- Calls `findClientMatches` against current port's clients
|
||||
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ This looks like an existing client │
|
||||
│ ML Marcus Laurent │
|
||||
│ marcus@… +33 6 12 34 56 78 │
|
||||
│ 2 interests · last 9d ago │
|
||||
│ [ Use this client ] [ Create new ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
|
||||
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
|
||||
|
||||
### 7.2 Layer 2 — Interest-level same-berth guard
|
||||
|
||||
Cheap one-liner in `createInterest` service:
|
||||
|
||||
- Check `(clientId, berthId)` against existing non-archived interests
|
||||
- If hit, throw `BerthDuplicateError` with the existing interest details
|
||||
- UI catches and prompts: "Update existing or create separate?"
|
||||
|
||||
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
|
||||
|
||||
### 7.3 Layer 3 — Background scoring + review queue
|
||||
|
||||
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
|
||||
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
|
||||
```ts
|
||||
export const clientMergeCandidates = pgTable('client_merge_candidates', {
|
||||
id: text('id').primaryKey()...,
|
||||
portId: text('port_id').notNull()...,
|
||||
clientAId: text('client_a_id').notNull()...,
|
||||
clientBId: text('client_b_id').notNull()...,
|
||||
score: integer('score').notNull(),
|
||||
reasons: jsonb('reasons').notNull(),
|
||||
status: text('status').notNull().default('pending'), // pending | dismissed | merged
|
||||
createdAt: timestamp('created_at')...,
|
||||
resolvedAt: timestamp('resolved_at'),
|
||||
resolvedBy: text('resolved_by'),
|
||||
})
|
||||
```
|
||||
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
|
||||
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
|
||||
|
||||
---
|
||||
|
||||
## 8. NocoDB → new system field mapping
|
||||
|
||||
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
|
||||
|
||||
### 8.1 Top-level transform
|
||||
|
||||
```
|
||||
NocoDB Interests row
|
||||
─→ 0–1 client (deduped against existing pool)
|
||||
─→ 0–1 client_address
|
||||
─→ 0–2 client_contacts (email, phone)
|
||||
─→ exactly 1 interest
|
||||
─→ 0–1 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
|
||||
─→ 0–1 document (when documensoID present)
|
||||
```
|
||||
|
||||
### 8.2 Field map
|
||||
|
||||
| NocoDB field | Target | Transform |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `Full Name` | `clients.fullName` | `normalizeName().display` |
|
||||
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
|
||||
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
|
||||
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
|
||||
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
|
||||
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
|
||||
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
|
||||
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
|
||||
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
|
||||
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
|
||||
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
|
||||
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
|
||||
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
|
||||
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
|
||||
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
|
||||
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
|
||||
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
|
||||
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
|
||||
| `Time LOI Sent` | `interests.dateContractSent` | parse |
|
||||
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
|
||||
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
|
||||
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
|
||||
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
|
||||
|
||||
### 8.3 Sales-stage mapping (8 → 9)
|
||||
|
||||
| NocoDB | New (PIPELINE_STAGES) |
|
||||
| ------------------------------- | ------------------------------------------------------------------------ |
|
||||
| General Qualified Interest | `open` |
|
||||
| Specific Qualified Interest | `details_sent` |
|
||||
| EOI and NDA Sent | `eoi_sent` |
|
||||
| Signed EOI and NDA | `eoi_signed` |
|
||||
| Made Reservation | `deposit_10pct` |
|
||||
| Contract Negotiation | `contract_sent` |
|
||||
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
|
||||
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
|
||||
|
||||
### 8.4 Other tables
|
||||
|
||||
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
|
||||
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
|
||||
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
|
||||
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
|
||||
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
|
||||
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration script
|
||||
|
||||
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
|
||||
|
||||
```
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
|
||||
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
|
||||
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
|
||||
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
|
||||
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
|
||||
Reads the apply log, undoes the writes (only valid within the undo window).
|
||||
```
|
||||
|
||||
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
|
||||
|
||||
### 9.1 Dry-run report format
|
||||
|
||||
`.migration/<timestamp>/report.csv`:
|
||||
|
||||
```csv
|
||||
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
|
||||
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
|
||||
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
|
||||
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
|
||||
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
|
||||
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
|
||||
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
|
||||
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
|
||||
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
|
||||
```
|
||||
|
||||
Plus `.migration/<timestamp>/summary.md`:
|
||||
|
||||
```
|
||||
# Migration Dry-Run — 2026-05-03 14:23 UTC
|
||||
|
||||
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
|
||||
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
|
||||
|
||||
Auto-linked (high confidence, no human action needed):
|
||||
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
|
||||
- John Lynch: rows 716,725 → 1 client + 2 interests
|
||||
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
|
||||
- [12 more]
|
||||
|
||||
Flagged for manual review (medium confidence):
|
||||
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
|
||||
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
|
||||
- [4 more]
|
||||
|
||||
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
|
||||
- Row 239: "Sag Harbor Y" → AI (likely US)
|
||||
- [6 more]
|
||||
|
||||
Phone parsing failed for 3 rows. All flagged, no contact created:
|
||||
- Row 178: empty
|
||||
- Row 641: placeholder "+447000000000"
|
||||
- Row 175: empty
|
||||
|
||||
Run `--apply` to commit these changes.
|
||||
```
|
||||
|
||||
### 9.2 Apply phase
|
||||
|
||||
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
|
||||
|
||||
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
|
||||
|
||||
### 9.3 Idempotency
|
||||
|
||||
The script tracks NocoDB row IDs in a `migration_source_links` table:
|
||||
|
||||
```ts
|
||||
export const migrationSourceLinks = pgTable('migration_source_links', {
|
||||
id: text('id').primaryKey()...,
|
||||
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
|
||||
sourceId: text('source_id').notNull(), // NocoDB row id as string
|
||||
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
|
||||
targetEntityId: text('target_entity_id').notNull(),
|
||||
appliedAt: timestamp('applied_at')...,
|
||||
appliedBy: text('applied_by'),
|
||||
}, (table) => [
|
||||
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
|
||||
]);
|
||||
```
|
||||
|
||||
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
|
||||
|
||||
---
|
||||
|
||||
## 10. Test plan
|
||||
|
||||
### 10.1 Library-level (vitest unit)
|
||||
|
||||
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
|
||||
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
|
||||
|
||||
### 10.2 Service-level (vitest integration)
|
||||
|
||||
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
|
||||
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
|
||||
|
||||
### 10.3 Migration script (vitest integration with NocoDB mock)
|
||||
|
||||
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
|
||||
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
|
||||
|
||||
### 10.4 E2E (Playwright)
|
||||
|
||||
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
|
||||
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
|
||||
|
||||
---
|
||||
|
||||
## 11. Rollback plan
|
||||
|
||||
Three layers of safety, ordered by reversibility:
|
||||
|
||||
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
|
||||
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
|
||||
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
|
||||
|
||||
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open items
|
||||
|
||||
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
|
||||
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
|
||||
- **Profile photo / face match** — out of scope.
|
||||
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
|
||||
|
||||
---
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
|
||||
|
||||
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
|
||||
|
||||
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~5–7 days.
|
||||
|
||||
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
|
||||
|
||||
Total: ~10–12 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.
|
||||
160
docs/website-refactor.md
Normal file
160
docs/website-refactor.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Website → CRM wiring refactor
|
||||
|
||||
The `website/` subrepo (Nuxt) currently writes inquiry submissions to NocoDB.
|
||||
The new CRM exposes its own public ingestion endpoints, so the website needs
|
||||
to be re-pointed at the CRM and the website's local server-side helpers can
|
||||
eventually be retired.
|
||||
|
||||
This document describes **what needs to change in the website repo**. Nothing
|
||||
here applies to the CRM repo — that side is already done.
|
||||
|
||||
## Endpoints the CRM now exposes
|
||||
|
||||
Both are unauthenticated, IP-rate-limited (5/hour), and require an explicit
|
||||
port id (query param `?portId=…` or header `X-Port-Id`).
|
||||
|
||||
| Form intent | New CRM endpoint | Old NocoDB target |
|
||||
| -------------------- | ---------------------------------------- | ------------------------ |
|
||||
| Berth interest | `POST /api/public/interests` | `Interests` (NocoDB) |
|
||||
| Residential interest | `POST /api/public/residential-inquiries` | `Interests (Residences)` |
|
||||
|
||||
Notification emails (client confirmation + sales-team alert) are sent by the
|
||||
CRM itself when these endpoints succeed, so the website's
|
||||
`sendRegistrationEmails` helper (`server/utils/email.ts`) is no longer
|
||||
required for these flows.
|
||||
|
||||
## Required changes in the website repo
|
||||
|
||||
### 1. New env vars
|
||||
|
||||
Add to `.env` and the deploy environment:
|
||||
|
||||
```
|
||||
PN_CRM_BASE_URL=https://crm.portnimara.com
|
||||
PN_CRM_PORT_ID=<uuid of the Port Nimara port row in CRM>
|
||||
```
|
||||
|
||||
`PN_CRM_BASE_URL` defaults to the prod CRM. In dev it can point to the local
|
||||
tunnel (`shoulder-contain-…trycloudflare.com`) so submissions hit a dev DB.
|
||||
|
||||
### 2. Refactor `server/api/register.ts`
|
||||
|
||||
Today the file owns both the berth and residence branches and writes to
|
||||
NocoDB directly. After the refactor, both branches just relay to the CRM:
|
||||
|
||||
```ts
|
||||
const baseUrl = process.env.PN_CRM_BASE_URL;
|
||||
const portId = process.env.PN_CRM_PORT_ID;
|
||||
|
||||
if (category === 'Residences') {
|
||||
await $fetch(`${baseUrl}/api/public/residential-inquiries?portId=${portId}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
firstName: body.first_name,
|
||||
lastName: body.last_name,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
placeOfResidence: body.address,
|
||||
preferredContactMethod: body.method_of_contact, // 'email' | 'phone'
|
||||
notes: body.notes,
|
||||
// preferences: collect via new optional textarea (see section 4)
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Berth branch
|
||||
await $fetch(`${baseUrl}/api/public/interests?portId=${portId}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
// map to the CRM's publicInterestSchema (see src/lib/validators/interests.ts)
|
||||
firstName: body.first_name,
|
||||
lastName: body.last_name,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
address: body.address,
|
||||
berthSize: body.berth_size,
|
||||
berthMinLength: body.berth_min_length,
|
||||
berthMinWidth: body.berth_min_width,
|
||||
berthMinDraught: body.berth_min_draught,
|
||||
yachtName: body.berth_yacht_name,
|
||||
preferredMethodOfContact: body.method_of_contact,
|
||||
specificBerthMooring: body.berth, // optional, links interest to a specific berth
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
```
|
||||
|
||||
The reCAPTCHA verification stays in the website handler — the CRM trusts the
|
||||
website to gate its public endpoints.
|
||||
|
||||
### 3. Retire dead code
|
||||
|
||||
After step 2, the following can be deleted from the website:
|
||||
|
||||
- `server/utils/websiteInterests.ts`
|
||||
- `server/utils/residentialInterests.ts`
|
||||
- `server/utils/nocodb.ts`
|
||||
- The NocoDB-specific call sites in `server/utils/email.ts` (the CRM
|
||||
sends its own confirmation/alert emails)
|
||||
- NocoDB env vars (`NOCODB_*`)
|
||||
|
||||
The Nuxt `/api/berths` route stays as-is — it reads from the
|
||||
`directus_items.berths` collection for the public site, not the CRM.
|
||||
|
||||
### 4. Form additions on `pages/register.vue`
|
||||
|
||||
The current residence branch only collects contact info. The CRM accepts an
|
||||
optional `preferences` field (free-text) and `notes` field. Add a
|
||||
"Preferences" textarea inside the residences block of
|
||||
`components/pn/specific/website/register/form.vue`:
|
||||
|
||||
```vue
|
||||
<transition name="fade-down">
|
||||
<div v-show="interest === 'residences'">
|
||||
<vee-field
|
||||
as="textarea"
|
||||
class="form-input py-3 px-0 md:text-lg border-0 border-t border-davysgrey ..."
|
||||
placeholder="Tell us what you're looking for (unit type, budget, timeline)"
|
||||
name="residence_preferences"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
```
|
||||
|
||||
Append `preferences: body.residence_preferences` in the POST body in
|
||||
`server/api/register.ts`.
|
||||
|
||||
### 5. Stand up a residential-only `residences.vue` form (optional)
|
||||
|
||||
Today the residences interest is captured on `register.vue` via a radio. If
|
||||
the marketing team wants a dedicated CTA on `residences.vue`, add a small
|
||||
inline form using the same submit handler from step 2. No new endpoint —
|
||||
this is purely a UX addition.
|
||||
|
||||
## Deployment order
|
||||
|
||||
1. **CRM first**: deploy this repo, ensure `/api/public/interests` and
|
||||
`/api/public/residential-inquiries` are reachable from the website host.
|
||||
2. **Verify in CRM**: configure `Inquiry Contact Email` and (for residential)
|
||||
`Residential Notification Recipients` per port in
|
||||
admin → settings.
|
||||
3. **Smoke test from a dev tunnel** (curl the public endpoints with a JSON
|
||||
payload). Confirm rows land in `clients`/`residential_clients` and
|
||||
notification emails are received.
|
||||
4. **Then deploy website changes** (sections 1–3 above). The form
|
||||
submissions immediately start landing in the new CRM.
|
||||
5. **Cut-over note**: once the website is pointed at the CRM, leave the
|
||||
NocoDB tables read-only as a historical archive. Don't delete them until
|
||||
prod data has been imported into the new CRM (see "Prod data import
|
||||
strategy" task #59 in the task list).
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Port routing for multi-port deploys**: today the website only knows about
|
||||
Port Nimara. If/when the website serves multiple ports, the `portId`
|
||||
resolution needs to happen per-domain or per-route, not a single env var.
|
||||
- **Brand/email domain**: confirm whether residential confirmations should
|
||||
send from the same `noreply@letsbe.solutions` address as marina, or a
|
||||
dedicated residential mailbox. The CRM uses `SMTP_FROM`, which is global.
|
||||
@@ -1,5 +1,55 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
/**
|
||||
* Security headers applied to every response. Per audit-pass-#3 finding:
|
||||
* the previous config emitted no CSP, X-Frame-Options, HSTS, or
|
||||
* X-Content-Type-Options — the app was open to clickjacking + MIME
|
||||
* sniffing.
|
||||
*
|
||||
* CSP notes:
|
||||
* - 'unsafe-inline' on style-src is required by Tailwind's runtime
|
||||
* style injection and Radix; revisit when Tailwind v4 ships a
|
||||
* nonce story.
|
||||
* - 'unsafe-eval' on script-src is dev-only — Next dev uses eval for
|
||||
* HMR. Production drops it.
|
||||
* - connect-src allows ws/wss for Socket.IO and https: for outgoing
|
||||
* fetches; tighten in prod via per-port branding URLs once we move
|
||||
* the s3 image references into a known allowlist.
|
||||
* - img-src https: is wide because port branding pulls from
|
||||
* s3.portnimara.com plus per-port image URLs configured at runtime.
|
||||
*/
|
||||
// Dev-only allow-list: react-grab (the in-page click-to-source devtool)
|
||||
// is fetched from unpkg, so script/style/connect must allow it. Strip
|
||||
// these entries in prod via the conditional below.
|
||||
const devScriptHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
|
||||
const devConnectHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
`connect-src 'self' ws: wss: https:${devConnectHosts}`,
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join('; ');
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: 'Content-Security-Policy', value: csp },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
|
||||
...(isProd
|
||||
? [{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
serverExternalPackages: [
|
||||
@@ -18,6 +68,20 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
outputFileTracingIncludes: {
|
||||
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
|
||||
// runtime in the standalone build. Reading via fs.readFile from
|
||||
// process.cwd() requires the file to be traced explicitly.
|
||||
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -4,6 +4,10 @@ proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
# Defense-in-depth for CVE-2025-29927: strip the header attackers use to
|
||||
# skip Next.js middleware. Patched in next>=15.2.3, but neutralizing the
|
||||
# input at the edge means a future regression cannot reopen the bypass.
|
||||
proxy_set_header X-Middleware-Subrequest "";
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
|
||||
29
package.json
29
package.json
@@ -2,6 +2,7 @@
|
||||
"name": "port-nimara-crm",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build && pnpm build:server",
|
||||
@@ -14,6 +15,15 @@
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/lib/db/seed.ts",
|
||||
"db:seed:realistic": "tsx src/lib/db/seed.ts",
|
||||
"db:seed:synthetic": "tsx src/lib/db/seed-synthetic.ts",
|
||||
"db:reset": "tsx scripts/db-reset.ts --confirm",
|
||||
"db:reseed:realistic": "pnpm db:reset && pnpm db:seed:realistic",
|
||||
"db:reseed:synthetic": "pnpm db:reset && pnpm db:seed:synthetic",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:smoke": "playwright test --project=smoke",
|
||||
"test:e2e:exhaustive": "playwright test --project=exhaustive",
|
||||
"test:e2e:destructive": "playwright test --project=destructive",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -48,6 +58,8 @@
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-query-devtools": "^5.62.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"archiver": "^7.0.1",
|
||||
"better-auth": "^1.2.0",
|
||||
"bullmq": "^5.25.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -57,33 +69,43 @@
|
||||
"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",
|
||||
"next": "15.1.0",
|
||||
"next": "15.2.9",
|
||||
"next-themes": "^0.4.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"openai": "^6.27.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.18.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.0",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"recharts": "^3.8.0",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"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",
|
||||
@@ -91,16 +113,17 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"esbuild": "^0.25.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "15.1.0",
|
||||
"eslint-config-next": "15.2.9",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^15.2.0",
|
||||
"postcss": "^8.4.0",
|
||||
"prettier": "^3.4.0",
|
||||
"react-grab": "^0.1.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e/smoke',
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
@@ -22,17 +22,77 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /global-setup\.ts/,
|
||||
testMatch: /smoke\/global-setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: 'smoke',
|
||||
testMatch: /\d{2}-.*\.spec\.ts/,
|
||||
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'exhaustive',
|
||||
testMatch: /exhaustive\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'destructive',
|
||||
testMatch: /destructive\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Real-API tests hit live external services (Documenso, IMAP, etc.).
|
||||
// Opt-in only: pnpm exec playwright test --project=realapi
|
||||
name: 'realapi',
|
||||
testMatch: /realapi\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
timeout: 120_000,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Visual regression baselines. Regenerate with --update-snapshots after
|
||||
// intentional UI changes; otherwise pnpm exec playwright test --project=visual
|
||||
// diffs against the committed PNGs.
|
||||
name: 'visual',
|
||||
testMatch: /visual\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Mobile / tablet audit — visits every page in headed Chromium at iPhone
|
||||
// viewports (portrait), screenshots full-page to .audit/mobile/<viewport>/,
|
||||
// and writes an index.md. Depends on `setup` for seeded admin + port-role.
|
||||
name: 'mobile-audit',
|
||||
testMatch: /audit\/mobile\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
// Single test walks 4 viewports × ~45 routes sequentially with slowMo;
|
||||
// 30 min headroom keeps us well under the wall-clock cost.
|
||||
timeout: 1_800_000,
|
||||
use: {
|
||||
headless: false,
|
||||
launchOptions: { slowMo: 200 },
|
||||
screenshot: 'off',
|
||||
video: 'off',
|
||||
trace: 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Don't start the dev server — we expect it to already be running
|
||||
|
||||
1284
pnpm-lock.yaml
generated
1284
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 654 B |
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 688 B |
BIN
public/icon-512-maskable.png
Normal file
BIN
public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
30
public/manifest.json
Normal file
30
public/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Port Nimara CRM",
|
||||
"short_name": "Port Nimara",
|
||||
"description": "Marina/port management CRM",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f2f2f2",
|
||||
"theme_color": "#0f172a",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
188
scripts/audit-permissions.ts
Normal file
188
scripts/audit-permissions.ts
Normal 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);
|
||||
});
|
||||
135
scripts/backfill-legacy-lead-source.ts
Normal file
135
scripts/backfill-legacy-lead-source.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* One-shot: backfill `interests.source` for legacy NocoDB-imported rows.
|
||||
*
|
||||
* Why this exists: the legacy NocoDB Interests table left the `Source`
|
||||
* column null for ~95 % of rows. The migration mapped null → null, so the
|
||||
* Lead Source Attribution chart shows them as "Unspecified". Per the
|
||||
* operator's best knowledge, almost all of those legacy rows came in
|
||||
* through the website (web form / portal) — the few that didn't are the
|
||||
* ones that already carry an explicit `Source` value (Form / portal /
|
||||
* External). Defaulting null → 'website' is therefore the closest
|
||||
* truth we can reconstruct without per-row sales notes review.
|
||||
*
|
||||
* Idempotent: only updates rows where `source IS NULL` AND the row has a
|
||||
* `migration_source_links` entry tying it back to the legacy NocoDB import,
|
||||
* so net-new manually-created interests with null source aren't touched.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug port-nimara [--dry-run]
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { eq, and, isNull, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { migrationSourceLinks } from '@/lib/db/schema/migration';
|
||||
|
||||
interface CliArgs {
|
||||
portSlug: string | null;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = { portSlug: null, dryRun: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||
else if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
console.log(
|
||||
'Usage: pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug <slug> [--dry-run]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
if (!args.portSlug) {
|
||||
console.error('Missing required --port-slug');
|
||||
process.exit(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const [port] = await db
|
||||
.select({ id: ports.id, name: ports.name })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, args.portSlug!))
|
||||
.limit(1);
|
||||
if (!port) {
|
||||
console.error(`No port found with slug "${args.portSlug}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[backfill] target: ${port.name} (${port.id})`);
|
||||
|
||||
// Pull every interest id this port owns that has a NULL source.
|
||||
const candidateInterests = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, port.id), isNull(interests.source)));
|
||||
|
||||
console.log(`[backfill] interests with NULL source in this port: ${candidateInterests.length}`);
|
||||
|
||||
if (candidateInterests.length === 0) {
|
||||
console.log('Nothing to backfill.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to ONLY those that came in via the legacy migration — preserves
|
||||
// null on net-new rows where the operator hasn't picked a source yet.
|
||||
const candidateIds = candidateInterests.map((r) => r.id);
|
||||
const legacyLinks = await db
|
||||
.select({ targetEntityId: migrationSourceLinks.targetEntityId })
|
||||
.from(migrationSourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
|
||||
eq(migrationSourceLinks.targetEntityType, 'interest'),
|
||||
inArray(migrationSourceLinks.targetEntityId, candidateIds),
|
||||
),
|
||||
);
|
||||
|
||||
const legacyIds = new Set(legacyLinks.map((l) => l.targetEntityId));
|
||||
const toUpdate = candidateIds.filter((id) => legacyIds.has(id));
|
||||
|
||||
console.log(
|
||||
`[backfill] of those, ${toUpdate.length} are legacy migration rows (will set source='website')`,
|
||||
);
|
||||
console.log(
|
||||
`[backfill] ${candidateInterests.length - toUpdate.length} are net-new rows (left untouched)`,
|
||||
);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('[backfill] --dry-run set; no writes.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (toUpdate.length === 0) {
|
||||
console.log('Nothing to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update in chunks of 500 to keep query size sane.
|
||||
const CHUNK = 500;
|
||||
let updated = 0;
|
||||
for (let i = 0; i < toUpdate.length; i += CHUNK) {
|
||||
const chunk = toUpdate.slice(i, i + CHUNK);
|
||||
// Belt-and-suspenders: re-assert `source IS NULL` in the WHERE so
|
||||
// a concurrent process that set source on one of these rows
|
||||
// between SELECT and UPDATE doesn't get its value clobbered.
|
||||
const result = await db
|
||||
.update(interests)
|
||||
.set({ source: 'website' })
|
||||
.where(and(inArray(interests.id, chunk), isNull(interests.source)))
|
||||
.returning({ id: interests.id });
|
||||
updated += result.length;
|
||||
}
|
||||
console.log(`[backfill] updated ${updated} rows.`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL', err);
|
||||
process.exit(1);
|
||||
});
|
||||
144
scripts/backfill-phone-e164.ts
Normal file
144
scripts/backfill-phone-e164.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Backfill `client_contacts.value_e164` from `value` for phone / whatsapp
|
||||
* contacts where it's null or empty.
|
||||
*
|
||||
* The legacy seed (and pre-normalization production data) stored phone
|
||||
* numbers in `value` as free text — "+33 4 93 00 0002" — but `value_e164`
|
||||
* is what every UI surface and dedup matcher reads. This script runs the
|
||||
* raw `value` through libphonenumber-js (via the script-safe wrapper to
|
||||
* avoid the Node 25 metadata-loader bug) and writes the canonical E.164
|
||||
* form back.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts # dry-run report
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts --apply # actually write
|
||||
*
|
||||
* The dry-run report prints, for each unparseable row, the contact id +
|
||||
* raw value so you can hand-clean before re-running.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientContacts } from '@/lib/db/schema/clients';
|
||||
import { parsePhoneScriptSafe } from '@/lib/dedup/phone-parse';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
interface PhoneRow {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string | null;
|
||||
valueCountry: string | null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Phone E.164 backfill — ${APPLY ? 'APPLY MODE' : 'dry-run'}`);
|
||||
console.log('');
|
||||
|
||||
// Find candidate rows: phone or whatsapp contacts with a `value` set but
|
||||
// `value_e164` null/empty.
|
||||
const rows: PhoneRow[] = await db
|
||||
.select({
|
||||
id: clientContacts.id,
|
||||
channel: clientContacts.channel,
|
||||
value: clientContacts.value,
|
||||
valueCountry: clientContacts.valueCountry,
|
||||
})
|
||||
.from(clientContacts)
|
||||
.where(
|
||||
and(
|
||||
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||||
or(isNull(clientContacts.valueE164), eq(clientContacts.valueE164, '')),
|
||||
sql`${clientContacts.value} IS NOT NULL AND ${clientContacts.value} <> ''`,
|
||||
),
|
||||
);
|
||||
|
||||
console.log(` found ${rows.length} candidate rows`);
|
||||
|
||||
let parsedFull = 0;
|
||||
let parsedE164Only = 0;
|
||||
let unparseable = 0;
|
||||
const updates: Array<{
|
||||
id: string;
|
||||
valueE164: string;
|
||||
valueCountry: CountryCode | null;
|
||||
}> = [];
|
||||
const fails: Array<{ id: string; value: string; reason: string }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.value) continue;
|
||||
const defaultCountry = (row.valueCountry as CountryCode | null) ?? undefined;
|
||||
const parsed1 = parsePhoneScriptSafe(row.value, defaultCountry);
|
||||
|
||||
if (parsed1.e164 && parsed1.country) {
|
||||
// Both e164 + country resolved — best case.
|
||||
updates.push({ id: row.id, valueE164: parsed1.e164, valueCountry: parsed1.country });
|
||||
parsedFull++;
|
||||
} else if (parsed1.e164) {
|
||||
// E.164 came back but country didn't (e.g. UK +44 7700 900xxx
|
||||
// fictional/reserved range — libphonenumber returns the e164 form
|
||||
// but refuses to assign a country). Still safe to write — the e164
|
||||
// is canonical. Country stays null.
|
||||
updates.push({
|
||||
id: row.id,
|
||||
valueE164: parsed1.e164,
|
||||
valueCountry: (row.valueCountry as CountryCode | null) ?? null,
|
||||
});
|
||||
parsedE164Only++;
|
||||
} else {
|
||||
fails.push({
|
||||
id: row.id,
|
||||
value: row.value,
|
||||
reason: row.value.trim().startsWith('+')
|
||||
? 'has + prefix but parse failed'
|
||||
: 'no leading + and no country hint',
|
||||
});
|
||||
unparseable++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' ✓ parsed cleanly (e164 + country)', parsedFull);
|
||||
console.log(' ✓ parsed e164 only (no country) ', parsedE164Only);
|
||||
console.log(' ✗ unparseable ', unparseable);
|
||||
console.log('');
|
||||
|
||||
if (fails.length > 0) {
|
||||
console.log('Failures (first 10):');
|
||||
for (const f of fails.slice(0, 10)) {
|
||||
console.log(` [${f.id}] "${f.value}" — ${f.reason}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('Dry-run only. Re-run with --apply to write the updates.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log('No updates to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Writing ${updates.length} updates...`);
|
||||
|
||||
for (const u of updates) {
|
||||
await db
|
||||
.update(clientContacts)
|
||||
.set({
|
||||
valueE164: u.valueE164,
|
||||
valueCountry: u.valueCountry,
|
||||
})
|
||||
.where(eq(clientContacts.id, u.id));
|
||||
}
|
||||
|
||||
console.log(` ✓ wrote ${updates.length} rows`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
51
scripts/backup/minio-mirror.sh
Normal file
51
scripts/backup/minio-mirror.sh
Normal 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"
|
||||
63
scripts/backup/pg-backup.sh
Normal file
63
scripts/backup/pg-backup.sh
Normal 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
121
scripts/backup/restore.sh
Normal 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."
|
||||
97
scripts/db-reset.ts
Normal file
97
scripts/db-reset.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Wipe all data from the database, preserving schema + drizzle migration
|
||||
* history. Run before swapping seed fixtures.
|
||||
*
|
||||
* pnpm tsx scripts/db-reset.ts (refuses without --confirm)
|
||||
* pnpm tsx scripts/db-reset.ts --confirm
|
||||
*
|
||||
* Truncates every table in the `public` schema except the drizzle
|
||||
* migration tracker, then resets sequences. Wraps the loop in a single
|
||||
* transaction so a mid-wipe failure rolls back cleanly.
|
||||
*
|
||||
* Refuses to run when DATABASE_URL points at anything that doesn't look
|
||||
* like a local/dev host. Override with --i-know-what-im-doing.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const url: string = process.env.DATABASE_URL ?? '';
|
||||
if (!url) {
|
||||
console.error('DATABASE_URL is not set; aborting.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
if (!args.has('--confirm')) {
|
||||
console.error('Refusing to wipe without --confirm');
|
||||
console.error('Run again as: pnpm tsx scripts/db-reset.ts --confirm');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Best-effort safety: refuse for anything that doesn't look like a local DB.
|
||||
function looksLocal(u: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return (
|
||||
parsed.hostname === 'localhost' ||
|
||||
parsed.hostname === '127.0.0.1' ||
|
||||
parsed.hostname === '::1' ||
|
||||
parsed.hostname.endsWith('.local') ||
|
||||
parsed.hostname.endsWith('.internal') ||
|
||||
parsed.hostname === 'host.docker.internal' ||
|
||||
// Docker compose service names commonly used here
|
||||
parsed.hostname === 'postgres' ||
|
||||
parsed.hostname === 'db'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!looksLocal(url) && !args.has('--i-know-what-im-doing')) {
|
||||
console.error(
|
||||
`DATABASE_URL host doesn't look local. Refusing to wipe a remote DB without --i-know-what-im-doing.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
async function main() {
|
||||
console.log('Resetting database...');
|
||||
console.log(` url: ${url.replace(/:[^:@]*@/, ':***@')}`);
|
||||
|
||||
const tables = await sql<{ tablename: string }[]>`
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename NOT LIKE 'drizzle_%'
|
||||
AND tablename != '__drizzle_migrations'
|
||||
`;
|
||||
|
||||
if (tables.length === 0) {
|
||||
console.log(' no user tables found, nothing to do.');
|
||||
await sql.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Single TRUNCATE … CASCADE is faster than per-table loops and handles
|
||||
// FK ordering for us. Quote table names defensively.
|
||||
const tableList = tables.map((t) => `"public"."${t.tablename}"`).join(', ');
|
||||
|
||||
console.log(` truncating ${tables.length} tables...`);
|
||||
await sql.unsafe(`TRUNCATE ${tableList} RESTART IDENTITY CASCADE`);
|
||||
console.log(' done.');
|
||||
|
||||
await sql.end();
|
||||
console.log('');
|
||||
console.log('Database reset complete. Run a seed script next:');
|
||||
console.log(' pnpm db:seed # realistic NocoDB-shaped fixture');
|
||||
console.log(' pnpm db:seed:synthetic # one client per pipeline stage');
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Reset failed:', err);
|
||||
await sql.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
102
scripts/dev-create-crm-user.ts
Normal file
102
scripts/dev-create-crm-user.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Dev-only helper: create (or upsert) a CRM better-auth user and mark them
|
||||
* super_admin. Idempotent — re-running with the same email will reset the
|
||||
* password.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import postgres from 'postgres';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { env } from '@/lib/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function main() {
|
||||
const [email, password, displayNameArg] = process.argv.slice(2);
|
||||
if (!email || !password) {
|
||||
console.error(
|
||||
'Usage: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = displayNameArg ?? email.split('@')[0] ?? 'User';
|
||||
const sql = postgres(env.DATABASE_URL);
|
||||
|
||||
try {
|
||||
// 1. Check if better-auth user already exists.
|
||||
const existing = await sql<{ id: string }[]>`
|
||||
SELECT id FROM "user" WHERE email = ${email} LIMIT 1
|
||||
`;
|
||||
|
||||
let userId: string;
|
||||
|
||||
if (existing.length > 0) {
|
||||
const row = existing[0];
|
||||
if (!row) throw new Error('unreachable');
|
||||
userId = row.id;
|
||||
console.log(`User ${email} exists (id=${userId}); resetting password.`);
|
||||
// Use better-auth's internal context to hash and update the credential.
|
||||
const ctx = await auth.$context;
|
||||
const hash = await ctx.password.hash(password);
|
||||
await sql`
|
||||
UPDATE account
|
||||
SET password = ${hash}, updated_at = NOW()
|
||||
WHERE user_id = ${userId} AND provider_id = 'credential'
|
||||
`;
|
||||
} else {
|
||||
console.log(`Creating better-auth user ${email}…`);
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: { email, password, name: displayName },
|
||||
});
|
||||
userId = result.user.id;
|
||||
console.log(`Created user_id=${userId}`);
|
||||
}
|
||||
|
||||
// 2. Upsert user_profiles entry as super admin.
|
||||
const profile = await db
|
||||
.select()
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (profile.length === 0) {
|
||||
await db.insert(userProfiles).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl: null,
|
||||
phone: null,
|
||||
isSuperAdmin: true,
|
||||
isActive: true,
|
||||
lastLoginAt: null,
|
||||
preferences: {},
|
||||
});
|
||||
console.log(`Created super_admin profile for ${userId}`);
|
||||
} else {
|
||||
await db
|
||||
.update(userProfiles)
|
||||
.set({ displayName, isSuperAdmin: true, isActive: true })
|
||||
.where(eq(userProfiles.userId, userId));
|
||||
console.log(`Updated profile for ${userId} (super_admin=true)`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`✓ Done. Sign in at http://localhost:3000/login with`);
|
||||
console.log(` email: ${email}`);
|
||||
console.log(` password: ${password}`);
|
||||
} finally {
|
||||
await sql.end();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
66
scripts/dev-imap-probe.ts
Normal file
66
scripts/dev-imap-probe.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Dev diagnostic: connect to IMAP and print the most recent ~10 messages,
|
||||
* showing TO/FROM/subject/date so we can see what the dev mailbox is
|
||||
* actually receiving.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-imap-probe.ts
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { simpleParser } from 'mailparser';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const host = process.env.IMAP_HOST!;
|
||||
const port = Number(process.env.IMAP_PORT ?? 993);
|
||||
const user = process.env.IMAP_USER!;
|
||||
const pass = process.env.IMAP_PASS!;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
throw new Error('IMAP_HOST / IMAP_USER / IMAP_PASS not set');
|
||||
}
|
||||
|
||||
console.log(`Connecting to ${user}@${host}:${port}…`);
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
port,
|
||||
secure: port === 993,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
console.log('Connected. Inbox status:');
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const status = await client.status('INBOX', { messages: true, recent: true });
|
||||
console.log(' total:', status.messages, '| recent:', status.recent);
|
||||
|
||||
// Pull the last 10 by UID
|
||||
const since = new Date(Date.now() - 30 * 60 * 1000); // last 30 min
|
||||
const result = await client.search({ since });
|
||||
const uids = Array.isArray(result) ? result.slice(-10).reverse() : [];
|
||||
console.log(`Found ${uids.length} messages in last 30min:`);
|
||||
for (const uid of uids) {
|
||||
const msg = await client.fetchOne(String(uid), { source: true, envelope: true });
|
||||
if (!msg || !msg.source) continue;
|
||||
const parsed = await simpleParser(msg.source);
|
||||
const tos = (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : [])
|
||||
.flatMap((a) => a.value.map((v) => v.address ?? ''))
|
||||
.join(', ');
|
||||
console.log(
|
||||
` uid=${uid} date=${parsed.date?.toISOString()} from=${parsed.from?.text} to=${tos} subject=${parsed.subject}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
console.log('Done.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Probe failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
25
scripts/dev-list-users.ts
Normal file
25
scripts/dev-list-users.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import postgres from 'postgres';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
async function main() {
|
||||
const sql = postgres(env.DATABASE_URL);
|
||||
const users =
|
||||
await sql`SELECT id, email, name, email_verified, created_at FROM "user" ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- user ---');
|
||||
console.log(JSON.stringify(users, null, 2));
|
||||
const profiles =
|
||||
await sql`SELECT user_id, display_name, is_super_admin, is_active FROM user_profiles ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- user_profiles ---');
|
||||
console.log(JSON.stringify(profiles, null, 2));
|
||||
const accounts =
|
||||
await sql`SELECT user_id, provider_id, account_id FROM account ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- account ---');
|
||||
console.log(JSON.stringify(accounts, null, 2));
|
||||
await sql.end();
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
83
scripts/dev-open-browser.ts
Normal file
83
scripts/dev-open-browser.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Launch a headed Chromium with NO viewport override so it adopts the
|
||||
* host monitor's natural size — useful when you want to drive the CRM
|
||||
* manually and have full-screen real estate.
|
||||
*
|
||||
* Pre-fills the login form for the synthetic admin (admin@portnimara.test
|
||||
* / SuperAdmin12345!) but does not submit; press Enter when ready.
|
||||
*
|
||||
* The script keeps running until the browser window is closed by the
|
||||
* user or until you Ctrl-C.
|
||||
*
|
||||
* pnpm tsx scripts/dev-open-browser.ts # super_admin
|
||||
* pnpm tsx scripts/dev-open-browser.ts sales_agent
|
||||
* pnpm tsx scripts/dev-open-browser.ts viewer
|
||||
* pnpm tsx scripts/dev-open-browser.ts --no-prefill
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
// @playwright/test re-exports the same chromium driver and is already
|
||||
// installed as a dev dep; using it avoids needing to add the standalone
|
||||
// `playwright` package as a separate dependency.
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const USERS: Record<string, { email: string; password: string }> = {
|
||||
super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' },
|
||||
sales_agent: { email: 'agent@portnimara.test', password: 'SalesAgent12345!' },
|
||||
viewer: { email: 'viewer@portnimara.test', password: 'ViewerUser12345!' },
|
||||
};
|
||||
|
||||
const BASE_URL = process.env.DEV_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const noPrefill = args.includes('--no-prefill');
|
||||
const role =
|
||||
args.find((a) => !a.startsWith('--')) && USERS[args.find((a) => !a.startsWith('--'))!]
|
||||
? args.find((a) => !a.startsWith('--'))!
|
||||
: 'super_admin';
|
||||
const user = USERS[role]!;
|
||||
|
||||
console.log(`Launching headed Chromium → ${BASE_URL}`);
|
||||
console.log(` role: ${role} (${user.email})`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
args: ['--start-maximized'],
|
||||
});
|
||||
|
||||
// viewport: null lets the page fill the OS window. Combined with
|
||||
// --start-maximized this matches the host monitor's natural size.
|
||||
const context = await browser.newContext({ viewport: null });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
|
||||
if (!noPrefill) {
|
||||
try {
|
||||
await page.waitForSelector('#email', { timeout: 5000 });
|
||||
await page.fill('#email', user.email);
|
||||
await page.fill('#password', user.password);
|
||||
console.log(' Login form pre-filled — press Enter in the browser to submit.');
|
||||
} catch {
|
||||
console.log(' Could not find login form (page may have redirected).');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log("Browser is open. Close it when you're done; the script will exit.");
|
||||
console.log('Or Ctrl-C here to force-quit.');
|
||||
|
||||
// Keep the process alive until the browser window is closed.
|
||||
await new Promise<void>((resolve) => {
|
||||
browser.on('disconnected', () => resolve());
|
||||
});
|
||||
|
||||
await browser.close().catch(() => undefined);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Open-browser failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
52
scripts/dev-recommender-smoke.ts
Normal file
52
scripts/dev-recommender-smoke.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Dev-only smoke check for the berth recommender. Resolves the first
|
||||
* port-nimara interest (with desired dims set) and prints the top-N
|
||||
* recommendations.
|
||||
*
|
||||
* pnpm tsx scripts/dev-recommender-smoke.ts
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { eq, isNotNull, and } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { recommendBerths } from '@/lib/services/berth-recommender.service';
|
||||
|
||||
async function main() {
|
||||
const [port] = await db
|
||||
.select({ id: ports.id })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, 'port-nimara'))
|
||||
.limit(1);
|
||||
if (!port) throw new Error('port-nimara not found');
|
||||
|
||||
const [interest] = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, port.id), isNotNull(interests.desiredLengthFt)))
|
||||
.limit(1);
|
||||
if (!interest) throw new Error('No interest with desired dims set');
|
||||
|
||||
console.log(`> Recommending berths for interest ${interest.id} on port ${port.id}…`);
|
||||
const recs = await recommendBerths({
|
||||
interestId: interest.id,
|
||||
portId: port.id,
|
||||
});
|
||||
|
||||
console.log(`> ${recs.length} recommendations:`);
|
||||
for (const r of recs) {
|
||||
console.log(
|
||||
` ${r.mooringNumber.padEnd(5)} tier=${r.tier} fit=${r.fitScore} ` +
|
||||
`${r.lengthFt}×${r.widthFt}×${r.draftFt} ft buf=${r.sizeBufferPct}% ` +
|
||||
`${r.reasons.dimensional}; ${r.reasons.pipeline}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
40
scripts/dev-set-password.ts
Normal file
40
scripts/dev-set-password.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Dev helper: set a user's password directly (bypasses email reset).
|
||||
* Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { hashPassword } from 'better-auth/crypto';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { user, account } from '@/lib/db/schema/users';
|
||||
|
||||
async function main() {
|
||||
const [, , email, password] = process.argv;
|
||||
if (!email || !password) {
|
||||
console.error('Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const u = await db.query.user.findFirst({ where: eq(user.email, email) });
|
||||
if (!u) {
|
||||
console.error(`User not found: ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
const result = await db
|
||||
.update(account)
|
||||
.set({ password: hash, updatedAt: new Date() })
|
||||
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
|
||||
.returning({ id: account.id });
|
||||
|
||||
if (result.length === 0) {
|
||||
console.error(`No credential account row for ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Updated password for ${email} (account id ${result[0]?.id}).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
44
scripts/dev-trigger-crm-invite.ts
Normal file
44
scripts/dev-trigger-crm-invite.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Dev-only helper: issue a CRM admin invite and send the activation email.
|
||||
* The email gets routed via EMAIL_REDIRECT_TO if that's set, so it always
|
||||
* lands in the dev inbox.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { createCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const email = args[0];
|
||||
if (!email) {
|
||||
console.error('Usage: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]');
|
||||
process.exit(1);
|
||||
}
|
||||
const isSuperAdmin = args.includes('--super');
|
||||
const name = args.find((a, i) => i > 0 && !a.startsWith('--'));
|
||||
|
||||
// Dev script runs out-of-band (no HTTP request, no session). The service's
|
||||
// super-admin gate requires `invitedBy.isSuperAdmin === true` for super
|
||||
// invites; the script bypasses that with a synthetic caller identity.
|
||||
const { inviteId, link } = await createCrmInvite({
|
||||
email,
|
||||
name,
|
||||
isSuperAdmin,
|
||||
invitedBy: { userId: 'cli-script', isSuperAdmin: true },
|
||||
});
|
||||
console.log(`✓ Invite created (id=${inviteId})`);
|
||||
console.log(` email: ${email}`);
|
||||
console.log(` super_admin: ${isSuperAdmin}`);
|
||||
console.log(` activation link: ${link}`);
|
||||
console.log('');
|
||||
console.log('Email sent (subject permitting via EMAIL_REDIRECT_TO).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
59
scripts/dev-trigger-portal-invite.ts
Normal file
59
scripts/dev-trigger-portal-invite.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Dev-only helper: pick an existing client and trigger a portal-invite email.
|
||||
* The activation email gets routed to EMAIL_REDIRECT_TO (set in .env) regardless
|
||||
* of the per-portal-user `email` field — so we can use any throwaway address
|
||||
* here without conflicting with seed data.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-trigger-portal-invite.ts
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { portalUsers } from '@/lib/db/schema/portal';
|
||||
import { createPortalUser } from '@/lib/services/portal-auth.service';
|
||||
import { env } from '@/lib/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!env.EMAIL_REDIRECT_TO) {
|
||||
throw new Error(
|
||||
'EMAIL_REDIRECT_TO is not set — refusing to send a real activation email to a real client.',
|
||||
);
|
||||
}
|
||||
console.log(`EMAIL_REDIRECT_TO is set: ${env.EMAIL_REDIRECT_TO}`);
|
||||
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.portId, '294c8240-49a7-403e-92e8-fc3a524c00b4'),
|
||||
});
|
||||
if (!client) throw new Error('No client found in port-nimara');
|
||||
|
||||
// Use the redirect target as the portal user's actual email, so the
|
||||
// tester can sign in with the same address that received the activation mail.
|
||||
const portalEmail = env.EMAIL_REDIRECT_TO;
|
||||
console.log(
|
||||
`Creating portal user for client ${client.fullName} (${client.id}) with email ${portalEmail}…`,
|
||||
);
|
||||
|
||||
// Clear any prior dev-script seed so uniqueness checks don't trip.
|
||||
await db.delete(portalUsers).where(eq(portalUsers.clientId, client.id));
|
||||
await db.delete(portalUsers).where(eq(portalUsers.email, portalEmail));
|
||||
|
||||
const result = await createPortalUser({
|
||||
clientId: client.id,
|
||||
portId: client.portId,
|
||||
email: portalEmail,
|
||||
name: client.fullName,
|
||||
createdBy: 'dev-script',
|
||||
});
|
||||
|
||||
console.log('Portal user created:', result);
|
||||
console.log(`Activation email enqueued — should arrive at ${portalEmail}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Script failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
409
scripts/import-berths-from-nocodb.ts
Normal file
409
scripts/import-berths-from-nocodb.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Idempotent NocoDB Berths → CRM `berths` import.
|
||||
*
|
||||
* Re-running picks up NocoDB additions/edits without clobbering CRM-side
|
||||
* overrides: rows where `updated_at > last_imported_at` are treated as
|
||||
* human-edited and skipped (use `--force` to override). Map Data JSON
|
||||
* is validated and upserted into `berth_map_data` as a separate step.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug port-nimara]
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug port-nimara]
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --force
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --update-snapshot
|
||||
*
|
||||
* Edge cases mitigated (see plan §14.1):
|
||||
* - Mooring collisions : unique (port_id, mooring_number) on the table.
|
||||
* - Concurrent runs : pg_advisory_xact_lock on a stable key.
|
||||
* - Numeric-with-units : parseDecimalWithUnit() strips trailing units.
|
||||
* - Metric drift : NocoDB metric formula columns are ignored;
|
||||
* metric values are recomputed from imperial.
|
||||
* - Map Data shape : zod-validated; failures are skipped silently
|
||||
* rather than aborting the whole import.
|
||||
* - Status enum : NocoDB display strings → CRM snake_case.
|
||||
* - NocoDB row deleted : reported as "orphaned in CRM"; not auto-deleted.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
||||
import { fetchAllRows, loadNocoDbConfig, NOCO_TABLES } from '@/lib/dedup/nocodb-source';
|
||||
import {
|
||||
buildPlan,
|
||||
mapRow,
|
||||
type Action,
|
||||
type ImportedBerth,
|
||||
type PlanEntry,
|
||||
type ExistingBerthRow,
|
||||
} from '@/lib/services/berth-import';
|
||||
|
||||
// ─── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CliArgs {
|
||||
dryRun: boolean;
|
||||
apply: boolean;
|
||||
portSlug: string;
|
||||
force: boolean;
|
||||
updateSnapshot: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
dryRun: false,
|
||||
apply: false,
|
||||
portSlug: 'port-nimara',
|
||||
force: false,
|
||||
updateSnapshot: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '--port-slug') args.portSlug = argv[++i] ?? 'port-nimara';
|
||||
else if (a === '--force') args.force = true;
|
||||
else if (a === '--update-snapshot') args.updateSnapshot = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!args.dryRun && !args.apply) {
|
||||
console.error('Must specify either --dry-run or --apply.');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage:
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug <slug>]
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug <slug>] [--force] [--update-snapshot]
|
||||
|
||||
Flags:
|
||||
--dry-run Read NocoDB + diff vs CRM. No writes.
|
||||
--apply Apply the plan to the DB.
|
||||
--port-slug <slug> Target port slug (default: port-nimara).
|
||||
--force Overwrite rows where CRM updated_at > last_imported_at.
|
||||
--update-snapshot Rewrite src/lib/db/seed-data/berths.json after apply.
|
||||
-h, --help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
// ─── Stable advisory lock key ───────────────────────────────────────────────
|
||||
// 64-bit BIGINT - first 4 bytes spell "BRTH" so it's grep-able in pg_locks.
|
||||
const BERTH_IMPORT_LOCK_KEY = 0x4252544800000001n;
|
||||
|
||||
// ─── Apply ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ApplyResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
mapDataWritten: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
async function apply(
|
||||
portId: string,
|
||||
plan: PlanEntry[],
|
||||
orphans: ExistingBerthRow[],
|
||||
importedAt: Date,
|
||||
): Promise<ApplyResult> {
|
||||
const result: ApplyResult = {
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
mapDataWritten: 0,
|
||||
warnings: [],
|
||||
};
|
||||
for (const orphan of orphans) {
|
||||
result.warnings.push(
|
||||
`Orphan: CRM has mooring="${orphan.mooringNumber}" but NocoDB no longer does (id=${orphan.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Stable lock so two simultaneous --apply runs serialize.
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(${BERTH_IMPORT_LOCK_KEY})`);
|
||||
|
||||
for (const entry of plan) {
|
||||
if (entry.action === 'skip-edited' || entry.action === 'noop') {
|
||||
result.skipped += 1;
|
||||
result.warnings.push(`Skipped ${entry.imported.mooringNumber}: ${entry.reason ?? 'no-op'}`);
|
||||
continue;
|
||||
}
|
||||
const i = entry.imported;
|
||||
const n = i.numerics;
|
||||
const baseValues = {
|
||||
portId,
|
||||
mooringNumber: i.mooringNumber,
|
||||
area: i.area,
|
||||
status: i.status,
|
||||
lengthFt: n.lengthFt != null ? String(n.lengthFt) : null,
|
||||
widthFt: n.widthFt != null ? String(n.widthFt) : null,
|
||||
draftFt: n.draftFt != null ? String(n.draftFt) : null,
|
||||
lengthM: n.lengthM != null ? String(n.lengthM) : null,
|
||||
widthM: n.widthM != null ? String(n.widthM) : null,
|
||||
draftM: n.draftM != null ? String(n.draftM) : null,
|
||||
widthIsMinimum: i.widthIsMinimum,
|
||||
nominalBoatSize: n.nominalBoatSize != null ? String(n.nominalBoatSize) : null,
|
||||
nominalBoatSizeM: n.nominalBoatSizeM != null ? String(n.nominalBoatSizeM) : null,
|
||||
waterDepth: n.waterDepth != null ? String(n.waterDepth) : null,
|
||||
waterDepthM: n.waterDepthM != null ? String(n.waterDepthM) : null,
|
||||
waterDepthIsMinimum: i.waterDepthIsMinimum,
|
||||
sidePontoon: i.sidePontoon,
|
||||
powerCapacity: n.powerCapacity != null ? String(n.powerCapacity) : null,
|
||||
voltage: n.voltage != null ? String(n.voltage) : null,
|
||||
mooringType: i.mooringType,
|
||||
cleatType: i.cleatType,
|
||||
cleatCapacity: i.cleatCapacity,
|
||||
bollardType: i.bollardType,
|
||||
bollardCapacity: i.bollardCapacity,
|
||||
access: i.access,
|
||||
price: n.price != null ? String(n.price) : null,
|
||||
priceCurrency: 'USD' as const,
|
||||
bowFacing: i.bowFacing,
|
||||
berthApproved: i.berthApproved,
|
||||
statusOverrideMode: i.statusOverrideMode,
|
||||
lastImportedAt: importedAt,
|
||||
updatedAt: importedAt,
|
||||
};
|
||||
|
||||
let berthId: string;
|
||||
if (entry.action === 'insert') {
|
||||
const [inserted] = await tx
|
||||
.insert(berths)
|
||||
.values({ ...baseValues, tenureType: 'permanent' })
|
||||
.returning({ id: berths.id });
|
||||
berthId = inserted!.id;
|
||||
result.inserted += 1;
|
||||
} else {
|
||||
await tx.update(berths).set(baseValues).where(eq(berths.id, entry.existing!.id));
|
||||
berthId = entry.existing!.id;
|
||||
result.updated += 1;
|
||||
}
|
||||
|
||||
if (i.mapData) {
|
||||
const mapValues = {
|
||||
berthId,
|
||||
svgPath: i.mapData.path ?? null,
|
||||
x: i.mapData.x != null ? String(i.mapData.x) : null,
|
||||
y: i.mapData.y != null ? String(i.mapData.y) : null,
|
||||
transform: i.mapData.transform ?? null,
|
||||
fontSize: i.mapData.fontSize != null ? String(i.mapData.fontSize) : null,
|
||||
updatedAt: importedAt,
|
||||
};
|
||||
await tx
|
||||
.insert(berthMapData)
|
||||
.values(mapValues)
|
||||
.onConflictDoUpdate({
|
||||
target: berthMapData.berthId,
|
||||
set: {
|
||||
svgPath: mapValues.svgPath,
|
||||
x: mapValues.x,
|
||||
y: mapValues.y,
|
||||
transform: mapValues.transform,
|
||||
fontSize: mapValues.fontSize,
|
||||
updatedAt: importedAt,
|
||||
},
|
||||
});
|
||||
result.mapDataWritten += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Snapshot writer (for seed-data refresh) ────────────────────────────────
|
||||
|
||||
async function writeSnapshot(imported: ImportedBerth[]): Promise<string> {
|
||||
// Ordering: idx 0..4 available (small), 5..9 under_offer (medium),
|
||||
// 10..11 sold (large), then everything else by mooring number. The
|
||||
// first 12 indexes feed `seed-data.ts` interest/reservation stubs.
|
||||
const sortByLength = (a: ImportedBerth, b: ImportedBerth) =>
|
||||
(a.numerics.lengthFt ?? 0) - (b.numerics.lengthFt ?? 0);
|
||||
const available = imported
|
||||
.filter((b) => b.status === 'available')
|
||||
.sort(sortByLength)
|
||||
.slice(0, 5);
|
||||
const underOffer = imported
|
||||
.filter((b) => b.status === 'under_offer')
|
||||
.sort(sortByLength)
|
||||
.slice(0, 5);
|
||||
const sold = imported
|
||||
.filter((b) => b.status === 'sold')
|
||||
.sort((a, b) => -sortByLength(a, b))
|
||||
.slice(0, 2);
|
||||
const featured = new Set([...available, ...underOffer, ...sold].map((b) => b.mooringNumber));
|
||||
const rest = imported
|
||||
.filter((b) => !featured.has(b.mooringNumber))
|
||||
.sort((a, b) => a.mooringNumber.localeCompare(b.mooringNumber, 'en', { numeric: true }));
|
||||
const ordered = [...available, ...underOffer, ...sold, ...rest];
|
||||
|
||||
const payload = ordered.map((b) => ({
|
||||
legacyId: b.legacyId,
|
||||
mooringNumber: b.mooringNumber,
|
||||
area: b.area,
|
||||
status: b.status,
|
||||
lengthFt: b.numerics.lengthFt,
|
||||
widthFt: b.numerics.widthFt,
|
||||
draftFt: b.numerics.draftFt,
|
||||
lengthM: b.numerics.lengthM,
|
||||
widthM: b.numerics.widthM,
|
||||
draftM: b.numerics.draftM,
|
||||
widthIsMinimum: b.widthIsMinimum,
|
||||
nominalBoatSize: b.numerics.nominalBoatSize,
|
||||
nominalBoatSizeM: b.numerics.nominalBoatSizeM,
|
||||
waterDepth: b.numerics.waterDepth,
|
||||
waterDepthM: b.numerics.waterDepthM,
|
||||
waterDepthIsMinimum: b.waterDepthIsMinimum,
|
||||
sidePontoon: b.sidePontoon,
|
||||
powerCapacity: b.numerics.powerCapacity,
|
||||
voltage: b.numerics.voltage,
|
||||
mooringType: b.mooringType,
|
||||
cleatType: b.cleatType,
|
||||
cleatCapacity: b.cleatCapacity,
|
||||
bollardType: b.bollardType,
|
||||
bollardCapacity: b.bollardCapacity,
|
||||
access: b.access,
|
||||
price: b.numerics.price,
|
||||
bowFacing: b.bowFacing,
|
||||
berthApproved: b.berthApproved,
|
||||
statusOverrideMode: b.statusOverrideMode,
|
||||
}));
|
||||
|
||||
const target = path.resolve(process.cwd(), 'src/lib/db/seed-data/berths.json');
|
||||
await fs.writeFile(target, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
||||
return target;
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const config = loadNocoDbConfig();
|
||||
|
||||
const [port] = await db
|
||||
.select({ id: ports.id, slug: ports.slug })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, args.portSlug))
|
||||
.limit(1);
|
||||
if (!port) {
|
||||
console.error(`No port found with slug "${args.portSlug}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`> Fetching NocoDB Berths…`);
|
||||
const rows = await fetchAllRows(NOCO_TABLES.berths, config);
|
||||
console.log(` fetched ${rows.length} rows from NocoDB`);
|
||||
|
||||
const imported: ImportedBerth[] = [];
|
||||
let skippedMalformed = 0;
|
||||
for (const r of rows) {
|
||||
const m = mapRow(r);
|
||||
if (m) imported.push(m);
|
||||
else skippedMalformed += 1;
|
||||
}
|
||||
if (skippedMalformed > 0) {
|
||||
console.warn(` ${skippedMalformed} rows skipped (missing Mooring Number)`);
|
||||
}
|
||||
|
||||
// De-dup against any same-mooring twins surfacing from NocoDB
|
||||
// (defensive — the Berths table is keyed on Mooring Number in NocoDB).
|
||||
const seen = new Set<string>();
|
||||
const dedup: ImportedBerth[] = [];
|
||||
for (const b of imported) {
|
||||
if (seen.has(b.mooringNumber)) {
|
||||
console.warn(` duplicate mooring "${b.mooringNumber}" in NocoDB — keeping first`);
|
||||
continue;
|
||||
}
|
||||
seen.add(b.mooringNumber);
|
||||
dedup.push(b);
|
||||
}
|
||||
|
||||
console.log(`> Reading current CRM berths for port "${port.slug}"…`);
|
||||
const existingRows = await db
|
||||
.select({
|
||||
id: berths.id,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
updatedAt: berths.updatedAt,
|
||||
lastImportedAt: berths.lastImportedAt,
|
||||
})
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, port.id));
|
||||
console.log(` ${existingRows.length} existing rows`);
|
||||
|
||||
const existingByMooring = new Map(existingRows.map((r) => [r.mooringNumber, r]));
|
||||
const { plan, orphans } = buildPlan(dedup, existingByMooring, args.force);
|
||||
|
||||
const counts = plan.reduce(
|
||||
(acc, e) => {
|
||||
acc[e.action] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ insert: 0, update: 0, 'skip-edited': 0, noop: 0 } as Record<Action, number>,
|
||||
);
|
||||
|
||||
console.log(`> Plan:`);
|
||||
console.log(` insert : ${counts.insert}`);
|
||||
console.log(` update : ${counts.update}`);
|
||||
console.log(` skip-edited : ${counts['skip-edited']}`);
|
||||
console.log(` no-op : ${counts.noop}`);
|
||||
console.log(` orphans (CRM): ${orphans.length}`);
|
||||
|
||||
if (counts['skip-edited'] > 0) {
|
||||
console.log(` ↳ Skipped (CRM-edited; pass --force to overwrite):`);
|
||||
for (const e of plan.filter((p) => p.action === 'skip-edited').slice(0, 10)) {
|
||||
console.log(` - ${e.imported.mooringNumber} ${e.reason}`);
|
||||
}
|
||||
if (counts['skip-edited'] > 10) console.log(` …and ${counts['skip-edited'] - 10} more`);
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
console.log(` ↳ Orphans (in CRM but missing from NocoDB):`);
|
||||
for (const o of orphans.slice(0, 10)) console.log(` - ${o.mooringNumber}`);
|
||||
if (orphans.length > 10) console.log(` …and ${orphans.length - 10} more`);
|
||||
}
|
||||
|
||||
// Snapshot write is independent of DB writes — even in --dry-run mode
|
||||
// a rep may want to refresh the seed JSON to capture the latest NocoDB
|
||||
// shape without committing to the DB import. The original gate dropped
|
||||
// this silently when --dry-run was passed; audit caught it.
|
||||
if (args.updateSnapshot) {
|
||||
const written = await writeSnapshot(dedup);
|
||||
console.log(`> Wrote ${dedup.length} rows to ${path.relative(process.cwd(), written)}`);
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(`\n[dry-run] no DB writes performed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`> Applying…`);
|
||||
const result = await apply(port.id, plan, orphans, new Date());
|
||||
console.log(` inserted : ${result.inserted}`);
|
||||
console.log(` updated : ${result.updated}`);
|
||||
console.log(` skipped : ${result.skipped}`);
|
||||
console.log(` map data writes : ${result.mapDataWritten}`);
|
||||
if (result.warnings.length) {
|
||||
console.log(` warnings :`);
|
||||
for (const w of result.warnings.slice(0, 20)) console.log(` - ${w}`);
|
||||
if (result.warnings.length > 20) console.log(` …and ${result.warnings.length - 20} more`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err: unknown) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
251
scripts/migrate-from-nocodb.ts
Normal file
251
scripts/migrate-from-nocodb.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* One-shot migration: legacy NocoDB Interests → new client/interest split.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run
|
||||
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
|
||||
* writes a report to .migration/<timestamp>/. NO database writes.
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug port-nimara
|
||||
* Same, but tags the planned writes with the named port (matters for
|
||||
* the apply phase — every client/interest belongs to one port).
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
|
||||
* Re-fetches NocoDB, re-transforms, then writes the planned rows
|
||||
* into the target port via the idempotent `migration_source_links`
|
||||
* ledger. Re-runs are safe — already-imported source IDs are skipped.
|
||||
* REQUIRES `EMAIL_REDIRECT_TO` to be set in env (safety net) unless
|
||||
* `--unsafe-skip-redirect-check` is also passed.
|
||||
*
|
||||
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { applyPlan } from '@/lib/dedup/migration-apply';
|
||||
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
||||
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
|
||||
|
||||
interface CliArgs {
|
||||
dryRun: boolean;
|
||||
apply: boolean;
|
||||
portSlug: string | null;
|
||||
reportDir: string | null;
|
||||
unsafeSkipRedirectCheck: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
dryRun: false,
|
||||
apply: false,
|
||||
portSlug: null,
|
||||
reportDir: null,
|
||||
unsafeSkipRedirectCheck: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||
else if (a === '--report') args.reportDir = argv[++i] ?? null;
|
||||
else if (a === '--unsafe-skip-redirect-check') args.unsafeSkipRedirectCheck = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage:
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug <slug>]
|
||||
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
|
||||
No database writes.
|
||||
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
|
||||
Re-fetches NocoDB, re-transforms, writes via migration_source_links
|
||||
ledger. Idempotent — safe to re-run. Requires EMAIL_REDIRECT_TO set
|
||||
(unless --unsafe-skip-redirect-check is also passed).
|
||||
|
||||
Flags:
|
||||
--dry-run Read NocoDB, write report only.
|
||||
--apply Actually write rows to the DB.
|
||||
--port-slug <slug> Port slug to attach to all imported
|
||||
entities. Defaults to the first
|
||||
available port if omitted.
|
||||
--report <dir> Path to a previously-generated report
|
||||
dir (only used by --apply).
|
||||
--unsafe-skip-redirect-check Skip the EMAIL_REDIRECT_TO precondition
|
||||
check. Only use in production cutover.
|
||||
-h, --help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target port: use the slug if provided, otherwise the first
|
||||
* port found. Errors out cleanly if the slug doesn't match any port.
|
||||
*/
|
||||
async function resolvePort(slug: string | null): Promise<{ id: string; slug: string }> {
|
||||
if (slug) {
|
||||
const [p] = await db
|
||||
.select({ id: ports.id, slug: ports.slug })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, slug))
|
||||
.limit(1);
|
||||
if (!p) {
|
||||
console.error(`No port found with slug "${slug}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { id: p.id, slug: p.slug };
|
||||
}
|
||||
const [first] = await db.select({ id: ports.id, slug: ports.slug }).from(ports).limit(1);
|
||||
if (!first) {
|
||||
console.error('No ports exist in the target DB. Seed at least one port before applying.');
|
||||
process.exit(1);
|
||||
}
|
||||
return { id: first.id, slug: first.slug };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.dryRun && !args.apply) {
|
||||
console.error('Must specify --dry-run or --apply');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
|
||||
// operator explicitly opts out (production cutover).
|
||||
if (args.apply && !process.env.EMAIL_REDIRECT_TO && !args.unsafeSkipRedirectCheck) {
|
||||
console.error(
|
||||
'--apply requires EMAIL_REDIRECT_TO to be set in the environment as a safety net.',
|
||||
);
|
||||
console.error('See docs/operations/outbound-comms-safety.md for the rationale.');
|
||||
console.error(
|
||||
'If you are running the production cutover and have read that doc, add ' +
|
||||
'--unsafe-skip-redirect-check to override.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ── Fetch + transform (shared by dry-run and apply) ──────────────────────
|
||||
|
||||
console.log('[migrate] Loading NocoDB config…');
|
||||
const config = loadNocoDbConfig();
|
||||
console.log(`[migrate] Source: ${config.url}`);
|
||||
|
||||
console.log('[migrate] Fetching snapshot from NocoDB…');
|
||||
const start = Date.now();
|
||||
const snapshot = await fetchSnapshot(config);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths.`,
|
||||
);
|
||||
|
||||
console.log('[migrate] Running transform + dedup pipeline…');
|
||||
const plan = transformSnapshot(snapshot);
|
||||
|
||||
// Resolve output paths relative to the worktree root.
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const generatedAt = new Date().toISOString();
|
||||
const paths = resolveReportPaths(repoRoot);
|
||||
|
||||
console.log(`[migrate] Writing report to ${paths.rootDir}…`);
|
||||
await writeReport(paths, plan, generatedAt);
|
||||
|
||||
// ── Plan summary ─────────────────────────────────────────────────────────
|
||||
const s = plan.stats;
|
||||
console.log('');
|
||||
console.log('=== Migration Plan Summary ===');
|
||||
console.log(
|
||||
` Input: ${s.inputInterestRows} interests, ${s.inputResidentialRows} residential interests`,
|
||||
);
|
||||
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
|
||||
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
|
||||
console.log(
|
||||
` ${s.outputDocuments} EOI documents, ${s.outputDocumentSigners} signers`,
|
||||
);
|
||||
console.log(
|
||||
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
|
||||
);
|
||||
console.log(
|
||||
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
||||
);
|
||||
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
|
||||
console.log('');
|
||||
console.log(` Full report: ${paths.summaryPath}`);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('');
|
||||
console.log('Dry-run complete. Re-run with --apply to write rows.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Apply path ───────────────────────────────────────────────────────────
|
||||
|
||||
const port = await resolvePort(args.portSlug);
|
||||
const applyId = randomUUID();
|
||||
|
||||
console.log('');
|
||||
console.log(`[migrate] Applying to port "${port.slug}" (id=${port.id})`);
|
||||
console.log(`[migrate] Apply id: ${applyId}`);
|
||||
console.log('[migrate] Inserting…');
|
||||
|
||||
const applyStart = Date.now();
|
||||
const result = await applyPlan(plan, { port, applyId });
|
||||
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
|
||||
|
||||
console.log('');
|
||||
console.log('=== Apply Result ===');
|
||||
console.log(` Time: ${applyElapsed}s`);
|
||||
console.log(
|
||||
` Clients: ${result.clientsInserted} inserted, ${result.clientsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Contacts: ${result.contactsInserted} inserted`);
|
||||
console.log(` Addresses: ${result.addressesInserted} inserted`);
|
||||
console.log(` Yachts: ${result.yachtsInserted} inserted`);
|
||||
console.log(
|
||||
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
|
||||
);
|
||||
console.log(
|
||||
` Documents: ${result.documentsInserted} inserted, ${result.documentsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Signers: ${result.documentSignersInserted} inserted`);
|
||||
console.log(
|
||||
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('');
|
||||
console.log('Warnings:');
|
||||
for (const w of result.warnings.slice(0, 20)) {
|
||||
console.log(` - ${w}`);
|
||||
}
|
||||
if (result.warnings.length > 20) {
|
||||
console.log(` … ${result.warnings.length - 20} more`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[migrate] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
29
scripts/migrate-storage.ts
Normal file
29
scripts/migrate-storage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Storage backend migration CLI — see §4.7a + §14.9a of
|
||||
* docs/berth-recommender-and-pdf-plan.md.
|
||||
*
|
||||
* pnpm tsx scripts/migrate-storage.ts --from s3 --to filesystem [--dry-run]
|
||||
* pnpm tsx scripts/migrate-storage.ts --from filesystem --to s3
|
||||
*
|
||||
* The actual migration logic lives in `src/lib/storage/migrate.ts` so the
|
||||
* admin UI's "Switch backend" button can run the exact same code path. This
|
||||
* file is a thin CLI wrapper.
|
||||
*/
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
import { parseArgs, runMigration } from '@/lib/storage/migrate';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
logger.info({ args }, 'Starting storage migration');
|
||||
const result = await runMigration(args);
|
||||
logger.info({ result }, 'Storage migration complete');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error({ err }, 'Storage migration failed');
|
||||
console.error(err);
|
||||
process.exit(2);
|
||||
});
|
||||
106
scripts/smoke-test-redirect.ts
Normal file
106
scripts/smoke-test-redirect.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Live smoke test for EMAIL_REDIRECT_TO.
|
||||
*
|
||||
* Actually calls `sendEmail()` (the centralized helper used by every
|
||||
* outbound email path in the app) with a fake real-client address. The
|
||||
* SMTP transporter is monkey-patched to capture the message instead of
|
||||
* actually delivering it, so this is safe to run anywhere.
|
||||
*
|
||||
* Prints the captured `to` + `subject` so the operator can see with their
|
||||
* own eyes that the redirect happened. Exits non-zero if the redirect
|
||||
* failed for any reason.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/smoke-test-redirect.ts
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
|
||||
async function main() {
|
||||
const expectedRedirect = process.env.EMAIL_REDIRECT_TO;
|
||||
if (!expectedRedirect) {
|
||||
console.error('FAIL: EMAIL_REDIRECT_TO is not set in env. Set it before running this test.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[smoke] EMAIL_REDIRECT_TO = ${expectedRedirect}`);
|
||||
console.log('');
|
||||
|
||||
// Monkey-patch nodemailer's createTransport so we capture the call
|
||||
// without actually delivering. This is the same pattern the unit
|
||||
// tests use, but at the live import-time level so we're testing the
|
||||
// exact code path that runs in production.
|
||||
const nodemailer = await import('nodemailer');
|
||||
const captured: Array<{ to: unknown; subject: unknown; from: unknown }> = [];
|
||||
const originalCreateTransport = nodemailer.default.createTransport;
|
||||
nodemailer.default.createTransport = (() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sendMail: async (msg: any) => {
|
||||
captured.push({ to: msg.to, subject: msg.subject, from: msg.from });
|
||||
return { messageId: '<smoke@test>', accepted: [msg.to], rejected: [] };
|
||||
},
|
||||
})) as unknown as typeof nodemailer.default.createTransport;
|
||||
|
||||
// Now import sendEmail (gets the patched transporter).
|
||||
const { sendEmail } = await import('@/lib/email');
|
||||
|
||||
const realClientEmail = 'real-client-DO-NOT-EMAIL@example.test';
|
||||
const realSubject = 'Important: Your contract is ready';
|
||||
|
||||
console.log('[smoke] calling sendEmail(...) with:');
|
||||
console.log(` to: ${realClientEmail}`);
|
||||
console.log(` subject: "${realSubject}"`);
|
||||
console.log('');
|
||||
|
||||
await sendEmail(realClientEmail, realSubject, '<p>Body unused for this smoke.</p>');
|
||||
|
||||
// Restore the original transport (be a good citizen).
|
||||
nodemailer.default.createTransport = originalCreateTransport;
|
||||
|
||||
console.log('[smoke] captured outbound message:');
|
||||
console.log(` to: ${captured[0]?.to}`);
|
||||
console.log(` subject: "${captured[0]?.subject}"`);
|
||||
console.log(` from: ${captured[0]?.from}`);
|
||||
console.log('');
|
||||
|
||||
// Assertions
|
||||
let pass = true;
|
||||
|
||||
if (captured.length !== 1) {
|
||||
console.error(`FAIL: expected exactly 1 sendMail call, got ${captured.length}`);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (captured[0]?.to !== expectedRedirect) {
|
||||
console.error(
|
||||
`FAIL: outbound "to" was "${captured[0]?.to}", expected the redirect address "${expectedRedirect}"`,
|
||||
);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof captured[0]?.subject !== 'string' ||
|
||||
!captured[0].subject.startsWith(`[redirected from ${realClientEmail}]`)
|
||||
) {
|
||||
console.error(
|
||||
`FAIL: subject did not get the [redirected from <orig>] prefix. Got: "${captured[0]?.subject}"`,
|
||||
);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (pass) {
|
||||
console.log('PASS: EMAIL_REDIRECT_TO is intercepting outbound email correctly.');
|
||||
console.log(
|
||||
' The "to" header matches the redirect, and the original recipient is preserved in the subject.',
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('');
|
||||
console.error('Smoke test FAILED. Do not import production data until this is fixed.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
42
scripts/test-currency-api.ts
Normal file
42
scripts/test-currency-api.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Quick verification: live Frankfurter API → DB upsert → getRate read.
|
||||
* Run with `pnpm tsx scripts/test-currency-api.ts`.
|
||||
*/
|
||||
import { refreshRates, getRate, convert } from '@/lib/services/currency';
|
||||
|
||||
async function main() {
|
||||
console.log('1. Fetching live rates from Frankfurter…');
|
||||
await refreshRates();
|
||||
|
||||
console.log('2. Reading round-trip rates from DB:');
|
||||
const usdEur = await getRate('USD', 'EUR');
|
||||
const eurUsd = await getRate('EUR', 'USD');
|
||||
const usdGbp = await getRate('USD', 'GBP');
|
||||
const eurGbp = await getRate('EUR', 'GBP');
|
||||
const usdUsd = await getRate('USD', 'USD');
|
||||
|
||||
console.log(` USD→EUR: ${usdEur}`);
|
||||
console.log(` EUR→USD: ${eurUsd}`);
|
||||
console.log(` USD→GBP: ${usdGbp}`);
|
||||
console.log(` EUR→GBP: ${eurGbp ?? '(no direct row, expected)'}`);
|
||||
console.log(` USD→USD: ${usdUsd}`);
|
||||
|
||||
console.log('3. Convert sample amounts:');
|
||||
const c1 = await convert(1000, 'USD', 'EUR');
|
||||
console.log(` $1000 → ${c1?.result} EUR @ ${c1?.rate}`);
|
||||
const c2 = await convert(500, 'EUR', 'USD');
|
||||
console.log(` €500 → $${c2?.result} @ ${c2?.rate}`);
|
||||
|
||||
// Sanity: EUR→USD should be ≈ 1 / (USD→EUR), within rounding
|
||||
if (usdEur && eurUsd) {
|
||||
const drift = Math.abs(eurUsd - 1 / usdEur);
|
||||
console.log(`4. Inverse-rate drift: ${drift.toFixed(6)} (≤0.001 = healthy)`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Currency test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,14 +8,5 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center wave-watermark"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<div className="w-full max-w-md px-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import { toast } from 'sonner';
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
@@ -55,18 +55,14 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Marina CRM</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Port Nimara CRM</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -77,18 +73,13 @@ export default function LoginPage() {
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Link href="/reset-password" className="text-xs text-[#007bff] hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
@@ -97,22 +88,20 @@ export default function LoginPage() {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const resetSchema = z.object({
|
||||
@@ -49,35 +49,26 @@ export default function ResetPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Reset your password</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">We'll email you a link</p>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground">Check your email</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If an account exists for that email address, we have sent a password reset link.
|
||||
Please check your inbox and spam folder.
|
||||
<p className="font-medium text-gray-900">Check your email</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
If an account exists for that email address, we have sent a password reset link. Please
|
||||
check your inbox and spam folder.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -85,33 +76,28 @@ export default function ResetPasswordPage() {
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.email && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Sending…' : 'Send reset link'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Remember your password?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
<Link href="/login" className="text-[#007bff] hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle2, Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
const MIN_LENGTH = 9;
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(12, 'Must be at least 12 characters')
|
||||
.regex(/[A-Z]/, 'Must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Must contain a number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
|
||||
password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
@@ -31,25 +27,11 @@ const passwordSchema = z
|
||||
|
||||
type SetPasswordFormData = z.infer<typeof passwordSchema>;
|
||||
|
||||
type Requirement = {
|
||||
label: string;
|
||||
test: (value: string) => boolean;
|
||||
};
|
||||
|
||||
const requirements: Requirement[] = [
|
||||
{ label: 'At least 12 characters', test: (v) => v.length >= 12 },
|
||||
{ label: 'Uppercase letter', test: (v) => /[A-Z]/.test(v) },
|
||||
{ label: 'Lowercase letter', test: (v) => /[a-z]/.test(v) },
|
||||
{ label: 'Number', test: (v) => /[0-9]/.test(v) },
|
||||
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
|
||||
];
|
||||
|
||||
function SetPasswordInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [passwordValue, setPasswordValue] = useState('');
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -61,7 +43,7 @@ function SetPasswordInner() {
|
||||
|
||||
async function onSubmit(data: SetPasswordFormData) {
|
||||
if (!token) {
|
||||
toast.error('Invalid or missing reset token. Please request a new password reset link.');
|
||||
toast.error('Invalid or missing reset token. Please request a new link.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,7 +57,7 @@ function SetPasswordInner() {
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
toast.error(body.message ?? 'Failed to set password. Please try again.');
|
||||
toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,66 +70,47 @@ function SetPasswordInner() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Set your password</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!token ? (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
Invalid or missing token. Please request a new password reset link.
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center space-y-3">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Please use the link from the email we sent you. If the link is broken, ask your
|
||||
administrator for a new one.
|
||||
</p>
|
||||
) : (
|
||||
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Set your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('password', {
|
||||
onChange: (e) => setPasswordValue(e.target.value),
|
||||
})}
|
||||
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-1 pt-1">
|
||||
{requirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<li
|
||||
key={req.label}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
met ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{req.label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
|
||||
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
@@ -163,27 +126,21 @@ function SetPasswordInner() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Setting password…' : 'Set password'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<BrandedAuthShell>{null}</BrandedAuthShell>}>
|
||||
<SetPasswordInner />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
135
src/app/(dashboard)/[portSlug]/admin/ai/page.tsx
Normal file
135
src/app/(dashboard)/[portSlug]/admin/ai/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import Link from 'next/link';
|
||||
import { Bot, Receipt, FileText, Brain, ExternalLink } from 'lucide-react';
|
||||
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
const MASTER_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'ai_enabled',
|
||||
label: 'AI features enabled',
|
||||
description:
|
||||
'Master switch. When OFF, every AI surface (receipt OCR fallback, berth-PDF AI parse, future embedding-driven recommendations) is bypassed. Provider keys stay configured but unused.',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'ai_monthly_token_cap',
|
||||
label: 'Monthly token cap (this port)',
|
||||
description:
|
||||
'Soft cap on total AI tokens consumed per calendar month across every feature. When exceeded, AI features fall back to non-AI paths and surface a banner. Set 0 for no cap.',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const PROVIDER_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'openai_api_key',
|
||||
label: 'OpenAI API key',
|
||||
description:
|
||||
'Used by Receipt OCR fallback and (future) berth-PDF AI parse. Stored AES-encrypted at rest; the field shows blank after save.',
|
||||
type: 'password',
|
||||
placeholder: 'sk-…',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'openai_default_model',
|
||||
label: 'Default OpenAI model',
|
||||
description: 'Used when a feature does not specify an explicit model.',
|
||||
type: 'select',
|
||||
defaultValue: 'gpt-4o-mini',
|
||||
options: [
|
||||
{ value: 'gpt-4o-mini', label: 'gpt-4o-mini — cheap, fast, vision-capable' },
|
||||
{ value: 'gpt-4o', label: 'gpt-4o — full-strength multimodal' },
|
||||
{ value: 'gpt-4-turbo', label: 'gpt-4-turbo — legacy text reasoning' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface FeatureLink {
|
||||
href: string;
|
||||
icon: typeof Bot;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FEATURE_LINKS: FeatureLink[] = [
|
||||
{
|
||||
href: '../ocr',
|
||||
icon: Receipt,
|
||||
title: 'Receipt OCR settings',
|
||||
description:
|
||||
'Provider, model, and confidence thresholds for the receipt scanner. AI fallback only runs when the on-device parser is uncertain.',
|
||||
},
|
||||
{
|
||||
href: '../berth-pdf-parser',
|
||||
icon: FileText,
|
||||
title: 'Berth PDF parser',
|
||||
description:
|
||||
'Three-tier AcroForm → OCR → AI pipeline. The AI pass costs tokens; reps invoke it manually when OCR confidence is low.',
|
||||
},
|
||||
{
|
||||
href: '../recommender',
|
||||
icon: Brain,
|
||||
title: 'Berth recommender',
|
||||
description:
|
||||
'Rule-based today; future versions will optionally use embeddings for soft preference matching. AI use is gated by the master switch above.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AiAdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="AI configuration"
|
||||
description="One place to manage every AI-using feature. Provider credentials and the master AI switch live here; per-feature thresholds remain in their dedicated pages, linked below."
|
||||
eyebrow="ADMIN"
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Master controls"
|
||||
description="Hard kill switch + budget guardrails covering every AI surface in this port."
|
||||
fields={MASTER_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Provider credentials"
|
||||
description="Shared API keys used by AI-enabled features. Per-feature pages can override the model on a feature-by-feature basis."
|
||||
fields={PROVIDER_FIELDS}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" /> Per-feature settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Feature-specific tuning lives on each feature's admin page. They all read the
|
||||
master switch + provider credentials configured above.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURE_LINKS.map((f) => (
|
||||
<Link
|
||||
key={f.href}
|
||||
href={f.href as never}
|
||||
className="rounded-md border bg-card p-3 hover:border-primary transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<f.icon className="h-4 w-4 text-muted-foreground" />
|
||||
{f.title}
|
||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function BackupManagementPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
|
||||
<p className="text-muted-foreground">Manage system backups and restoration</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Backup & Restore"
|
||||
eyebrow="ADMIN"
|
||||
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
|
||||
/>
|
||||
<BackupAdminPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
92
src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
Normal file
92
src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
const DEFAULT_EMAIL_HEADER_HTML = `<!-- Optional pre-body header -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding:16px 0;">
|
||||
<a href="https://example.com" style="text-decoration:none;color:#1e293b;font-family:Arial,sans-serif;font-size:14px;font-weight:600;">
|
||||
Your brand name
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
const DEFAULT_EMAIL_FOOTER_HTML = `<!-- Optional sub-body footer -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px 0;color:#64748b;font-family:Arial,sans-serif;font-size:12px;">
|
||||
© ${new Date().getFullYear()} Your Company ·
|
||||
<a href="https://example.com" style="color:#64748b;">Visit our website</a> ·
|
||||
<a href="mailto:hello@example.com" style="color:#64748b;">hello@example.com</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'branding_app_name',
|
||||
label: 'App name',
|
||||
description: 'Shown in the email subject prefix and the in-app header.',
|
||||
type: 'string',
|
||||
placeholder: 'Port Nimara CRM',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'branding_logo_url',
|
||||
label: 'Logo',
|
||||
description:
|
||||
'Used in email headers and the branded auth shell. Recommended: square PNG with transparent background.',
|
||||
type: 'image-upload',
|
||||
imageAspect: 1,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'branding_primary_color',
|
||||
label: 'Primary color',
|
||||
description: 'Used for buttons and links in transactional email templates.',
|
||||
type: 'color',
|
||||
defaultValue: '#1e293b',
|
||||
},
|
||||
{
|
||||
key: 'branding_email_header_html',
|
||||
label: 'Email header HTML',
|
||||
description:
|
||||
'Optional HTML rendered above each email body. Leave blank to use the default. Tap "Insert default" to start from the baseline template.',
|
||||
type: 'html',
|
||||
defaultValue: '',
|
||||
defaultTemplate: DEFAULT_EMAIL_HEADER_HTML,
|
||||
},
|
||||
{
|
||||
key: 'branding_email_footer_html',
|
||||
label: 'Email footer HTML',
|
||||
description: 'Optional HTML rendered at the very bottom of each email (above the signature).',
|
||||
type: 'html',
|
||||
defaultValue: '',
|
||||
defaultTemplate: DEFAULT_EMAIL_FOOTER_HTML,
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandingSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Branding"
|
||||
description="Logo, primary color, app name, and email header/footer HTML used by the branded auth shell and outgoing email templates."
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="Identity"
|
||||
description="App name, logo, and primary color."
|
||||
fields={FIELDS.slice(0, 3)}
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="Email branding"
|
||||
description="HTML fragments rendered around every transactional email."
|
||||
fields={FIELDS.slice(3)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx
Normal file
21
src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
|
||||
|
||||
/**
|
||||
* Per-port admin page for managing brochures (Phase 7 §5.8).
|
||||
*
|
||||
* Lists brochures, lets per-port admins upload new versions via direct-to-
|
||||
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
|
||||
* body-size limit — see §11.1), and toggle the default flag.
|
||||
*/
|
||||
export default function BrochuresAdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Brochures"
|
||||
description="Port-wide marketing PDFs available to the sales send-out flow. The default brochure is the one /clients picker pre-selects."
|
||||
/>
|
||||
<BrochuresAdminPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx
Normal file
183
src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
const API_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'documenso_api_url_override',
|
||||
label: 'API URL override',
|
||||
description: 'Optional. Falls back to DOCUMENSO_API_URL env when blank.',
|
||||
type: 'string',
|
||||
placeholder: 'https://documenso.example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_api_key_override',
|
||||
label: 'API key override',
|
||||
description: 'Optional. Falls back to DOCUMENSO_API_KEY env when blank. Stored in plain text.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_api_version_override',
|
||||
label: 'API version',
|
||||
description:
|
||||
'Which Documenso REST API to call against this port. v1 supports Documenso 1.x (per-field PIXEL placement, /api/v1/templates and /api/v1/documents). v2 unlocks the envelope/embed endpoints introduced in Documenso 2.x. Use the test-connection button below after switching to confirm the chosen version actually works against this port’s instance.',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'v1', label: 'v1 — Documenso 1.x (legacy stable)' },
|
||||
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope + embedded signing)' },
|
||||
],
|
||||
defaultValue: 'v1',
|
||||
},
|
||||
];
|
||||
|
||||
const SIGNER_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'documenso_developer_name',
|
||||
label: 'Developer signer — name',
|
||||
description:
|
||||
'The party who signs after the client (typically the marina developer or owner). Used as the static "developer" recipient in templated documents (EOI). Was hardcoded as "David Mizrahi" in the legacy single-tenant system.',
|
||||
type: 'string',
|
||||
placeholder: 'David Mizrahi',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_developer_email',
|
||||
label: 'Developer signer — email',
|
||||
description: 'Email used to send the developer signing request via Documenso.',
|
||||
type: 'string',
|
||||
placeholder: 'dm@portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_approver_name',
|
||||
label: 'Approver — name',
|
||||
description:
|
||||
'The final approver who signs after the developer (typically a sales/legal lead). Was hardcoded as "Abbie May" in the legacy system.',
|
||||
type: 'string',
|
||||
placeholder: 'Abbie May',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_approver_email',
|
||||
label: 'Approver — email',
|
||||
description: 'Email used to route the final approval signing request.',
|
||||
type: 'string',
|
||||
placeholder: 'sales@portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
const EOI_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'documenso_eoi_template_id',
|
||||
label: 'EOI Documenso template ID',
|
||||
description: 'Numeric template ID used by the Documenso EOI pathway.',
|
||||
type: 'string',
|
||||
placeholder: '12345',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'eoi_default_pathway',
|
||||
label: 'Default EOI pathway',
|
||||
description:
|
||||
'Which pathway is used when an EOI is generated without an explicit choice. Documenso = signed via Documenso, In-app = filled locally with pdf-lib.',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'documenso-template', label: 'Documenso template' },
|
||||
{ value: 'inapp', label: 'In-app (pdf-lib)' },
|
||||
],
|
||||
defaultValue: 'documenso-template',
|
||||
},
|
||||
{
|
||||
key: 'eoi_send_mode',
|
||||
label: 'Initial signing-invitation email behaviour',
|
||||
description:
|
||||
'Auto = the system sends our branded "please sign" email immediately when an EOI/contract/reservation is generated. Manual = the document is generated and the signing URL appears in the UI; a rep clicks "Send invitation" to dispatch. Auto is the lower-friction option for high-volume teams; manual lets reps review before sending. Applies to all document types, not just EOI.',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'manual', label: 'Manual (rep clicks Send after generation)' },
|
||||
{ value: 'auto', label: 'Auto (send branded email on generate)' },
|
||||
],
|
||||
defaultValue: 'manual',
|
||||
},
|
||||
];
|
||||
|
||||
const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'documenso_contract_template_id',
|
||||
label: 'Contract Documenso template ID (optional)',
|
||||
description:
|
||||
'Numeric template ID for sales contract generation. Leave blank to use the per-deal upload-and-place-fields flow instead (the typical path for contracts, since they are usually drafted custom per client).',
|
||||
type: 'string',
|
||||
placeholder: '',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_reservation_template_id',
|
||||
label: 'Reservation agreement Documenso template ID (optional)',
|
||||
description:
|
||||
'Numeric template ID for reservation agreements. Same logic — leave blank to upload per deal.',
|
||||
type: 'string',
|
||||
placeholder: '',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
const EMBED_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'embedded_signing_host',
|
||||
label: 'Embedded signing host',
|
||||
description:
|
||||
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
|
||||
type: 'string',
|
||||
placeholder: 'https://portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocumensoSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Documenso & EOI"
|
||||
description="API credentials, signer identities, and document generation behaviour. Use the test-connection button to verify a saved configuration before relying on it."
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Documenso API"
|
||||
description="Per-port API credentials. Leave blank to use the global env defaults."
|
||||
fields={API_FIELDS}
|
||||
extra={<DocumensoTestButton />}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Signers (developer + approver)"
|
||||
description="Identity of the static signers in your Documenso templates. The client is always pulled from the interest's linked client record; these values fill the developer (signing order 2) and approver (signing order 3) slots."
|
||||
fields={SIGNER_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="EOI generation"
|
||||
description="Default pathway, template, and email behaviour when an interest's EOI is generated."
|
||||
fields={EOI_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Contract & reservation templates (optional)"
|
||||
description="Most ports leave these blank because contracts/reservations are drafted per deal and uploaded for signing. Set a template ID only if you have a standardised contract/reservation Documenso template."
|
||||
fields={CONTRACT_RESERVATION_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Embedded signing"
|
||||
description="Where the public-facing branded signing pages live. The CRM rewrites Documenso signing URLs to point here when sending invitation and reminder emails."
|
||||
fields={EMBED_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DuplicatesReviewQueue } from '@/components/admin/duplicates/duplicates-review-queue';
|
||||
|
||||
export default function DuplicatesAdminPage() {
|
||||
return <DuplicatesReviewQueue />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EmailTemplatesAdmin } from '@/components/admin/email-templates-admin';
|
||||
|
||||
export default function EmailTemplatesPage() {
|
||||
return <EmailTemplatesAdmin />;
|
||||
}
|
||||
101
src/app/(dashboard)/[portSlug]/admin/email/page.tsx
Normal file
101
src/app/(dashboard)/[portSlug]/admin/email/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
||||
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'email_from_name',
|
||||
label: 'From name',
|
||||
description: 'Display name shown in the From: header on outgoing email.',
|
||||
type: 'string',
|
||||
placeholder: 'Port Nimara',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_from_address',
|
||||
label: 'From address',
|
||||
description: 'Sender email address. Falls back to SMTP_FROM env when blank.',
|
||||
type: 'string',
|
||||
placeholder: 'noreply@example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_reply_to',
|
||||
label: 'Reply-to address',
|
||||
description: 'Optional Reply-To: header for replies (e.g. sales@example.com).',
|
||||
type: 'string',
|
||||
placeholder: 'sales@example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_signature_html',
|
||||
label: 'Default signature (HTML)',
|
||||
description: 'Appended to the bottom of system-generated emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p>-<br>The Port Nimara team</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_footer_html',
|
||||
label: 'Email footer (HTML)',
|
||||
description: 'Legal/contact footer rendered at the very bottom of all emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p style="font-size:11px;color:#888;">© Port Nimara · ul. ...</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'smtp_host_override',
|
||||
label: 'SMTP host override',
|
||||
description: 'Optional. Falls back to SMTP_HOST env when blank.',
|
||||
type: 'string',
|
||||
placeholder: 'mail.example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'smtp_port_override',
|
||||
label: 'SMTP port override',
|
||||
description: 'Optional. Falls back to SMTP_PORT env when blank.',
|
||||
type: 'number',
|
||||
placeholder: '587',
|
||||
defaultValue: null,
|
||||
},
|
||||
{
|
||||
key: 'smtp_user_override',
|
||||
label: 'SMTP username override',
|
||||
description: 'Optional. Falls back to SMTP_USER env when blank.',
|
||||
type: 'string',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'smtp_pass_override',
|
||||
label: 'SMTP password override',
|
||||
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
export default function EmailSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Email Settings"
|
||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank."
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="From address & signature"
|
||||
description="Identity headers and shared HTML used by system-generated emails."
|
||||
fields={FIELDS.slice(0, 5)}
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="SMTP transport overrides"
|
||||
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
|
||||
fields={FIELDS.slice(5)}
|
||||
/>
|
||||
<SalesEmailConfigCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx
Normal file
246
src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, Copy, Wrench } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Route } from 'next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { ErrorEvent } from '@/lib/db/schema/system';
|
||||
import type { LikelyCulprit } from '@/lib/error-classifier';
|
||||
|
||||
interface DetailResponse {
|
||||
data: ErrorEvent & { likelyCulprit: LikelyCulprit | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail view for a single captured error. Shows everything an admin
|
||||
* needs to triage:
|
||||
*
|
||||
* - Request shape: method, path, status, duration, who fired it
|
||||
* - Error: name, message, full stack head, (sanitized) request body
|
||||
* - Likely-culprit hint: heuristic-driven plain-English root-cause
|
||||
* - Raw metadata: pg SQLSTATE codes, internal-message debug strings
|
||||
*/
|
||||
export default function ErrorEventDetailPage() {
|
||||
const params = useParams<{ portSlug: string; requestId: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const requestId = params?.requestId ?? '';
|
||||
|
||||
const query = useQuery<DetailResponse>({
|
||||
queryKey: ['admin', 'error-events', requestId],
|
||||
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
|
||||
enabled: Boolean(requestId),
|
||||
});
|
||||
|
||||
function copy(text: string, label: string) {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
||||
void navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied`);
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const event = query.data?.data;
|
||||
if (!event) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
Error event not found. It may have been pruned or you may not have access.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/${portSlug}/admin/errors` as Route}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back to error list
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}…</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
event.statusCode >= 500
|
||||
? 'border-destructive/40 text-destructive'
|
||||
: 'border-amber-300 text-amber-800'
|
||||
}
|
||||
>
|
||||
{event.statusCode}
|
||||
</Badge>
|
||||
{event.likelyCulprit && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Wrench className="h-3 w-3" />
|
||||
{event.likelyCulprit.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => copy(requestId, 'Reference ID')}>
|
||||
<Copy className="mr-1.5 h-3 w-3" />
|
||||
Copy ID
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{event.likelyCulprit && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4" /> Likely culprit
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
<p className="font-medium">{event.likelyCulprit.label}</p>
|
||||
<p className="text-muted-foreground mt-1">{event.likelyCulprit.hint}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Subsystem: <code className="font-mono">{event.likelyCulprit.subsystem}</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* If the captured error has a registered code on its metadata,
|
||||
* surface the canonical user-facing message + status from the
|
||||
* registry so the admin can compare what the user saw to what
|
||||
* the system actually did. */}
|
||||
{(() => {
|
||||
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||
const code = typeof meta.code === 'string' ? meta.code : null;
|
||||
if (!code || !isErrorCode(code)) return null;
|
||||
const def = ERROR_CODES[code];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Error code</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{def.status}</Badge>
|
||||
<code className="font-mono text-xs font-semibold">{code}</code>
|
||||
</div>
|
||||
<p className="mt-2">{def.userMessage}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Compare to the message the user saw in their toast.{' '}
|
||||
<Link
|
||||
href={`/${portSlug}/admin/errors/codes` as Route}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
All codes →
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Request</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<KV label="Method" value={event.method} />
|
||||
<KV label="Path" value={event.path} mono />
|
||||
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
|
||||
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '—'} />
|
||||
<KV label="Port" value={event.portId ?? '(none)'} mono />
|
||||
<KV label="User" value={event.userId ?? '(none)'} mono />
|
||||
<KV label="IP" value={event.ipAddress ?? '—'} mono />
|
||||
<KV label="User agent" value={event.userAgent ?? '—'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<KV label="Name" value={event.errorName ?? '—'} mono />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Message</p>
|
||||
<p className="mt-0.5 font-mono whitespace-pre-wrap break-words">
|
||||
{event.errorMessage ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
{event.errorStack && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Stack (truncated)</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copy(event.errorStack ?? '', 'Stack')}
|
||||
>
|
||||
<Copy className="mr-1.5 h-3 w-3" /> Copy
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="mt-1 max-h-96 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap break-words">
|
||||
{event.errorStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{event.requestBodyExcerpt && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Request body (sanitized, max 1 KB)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap break-words">
|
||||
{event.requestBodyExcerpt}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{event.metadata !== null &&
|
||||
typeof event.metadata === 'object' &&
|
||||
Object.keys(event.metadata as Record<string, unknown>).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Metadata</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="overflow-auto rounded bg-muted p-2 text-xs font-mono">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KV({ label, value, mono }: { label: string; value: string | null; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '—'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx
Normal file
134
src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ArrowLeft, BookOpen, Search } from 'lucide-react';
|
||||
|
||||
import type { Route } from 'next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ERROR_CODES } from '@/lib/error-codes';
|
||||
|
||||
/**
|
||||
* Error-code reference page surfaced inside the admin section so an
|
||||
* admin investigating a captured error_events row can flip to this
|
||||
* tab, look up the code the user reported, and read the canonical
|
||||
* plain-language meaning + status code without leaving the app.
|
||||
*
|
||||
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
|
||||
* automatically — adding an entry to the registry adds a row here.
|
||||
*/
|
||||
export default function ErrorCodeReferencePage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = Object.entries(ERROR_CODES) as Array<
|
||||
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
|
||||
>;
|
||||
if (!search.trim()) return all;
|
||||
const q = search.trim().toLowerCase();
|
||||
return all.filter(
|
||||
([code, def]) => code.toLowerCase().includes(q) || def.userMessage.toLowerCase().includes(q),
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
// Group by domain prefix (the part before the first underscore) so
|
||||
// the table reads naturally — Expenses, Berths, Storage, etc.
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
const prefix = entry[0].split('_')[0] ?? 'OTHER';
|
||||
const bucket = groups.get(prefix) ?? [];
|
||||
bucket.push(entry);
|
||||
groups.set(prefix, bucket);
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [entries]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/${portSlug}/admin/errors` as Route}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back to error inspector
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" /> Error code reference
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Every error code the platform can return, with its HTTP status and the plain-language
|
||||
message a user sees. Codes are stable identifiers — once shipped, they never get
|
||||
renamed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search code or message…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
No codes match "{search}".
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{grouped.map(([prefix, items]) => (
|
||||
<Card key={prefix}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{prefix}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{items.map(([code, def]) => (
|
||||
<div key={code} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
def.status >= 500
|
||||
? 'border-destructive/40 text-destructive'
|
||||
: def.status >= 400
|
||||
? 'border-amber-300 text-amber-800'
|
||||
: 'border-muted'
|
||||
}
|
||||
>
|
||||
{def.status}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-xs font-semibold">{code}</p>
|
||||
<p className="text-sm mt-0.5">{def.userMessage}</p>
|
||||
{'hint' in def && typeof def.hint === 'string' && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{def.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/app/(dashboard)/[portSlug]/admin/errors/page.tsx
Normal file
157
src/app/(dashboard)/[portSlug]/admin/errors/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { AlertTriangle, BookOpen, Search, Wrench } from 'lucide-react';
|
||||
|
||||
import type { Route } from 'next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { classifyError } from '@/lib/error-classifier';
|
||||
import type { ErrorEvent } from '@/lib/db/schema/system';
|
||||
|
||||
interface ListResponse {
|
||||
data: ErrorEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Super-admin error inspector.
|
||||
*
|
||||
* Shows the most recent captured 5xx errors with: when, where (HTTP
|
||||
* method + path), what (error name + message), and a heuristic
|
||||
* "likely culprit" badge driven by `classifyError`. Click into any
|
||||
* row for the full stack + body excerpt + raw metadata.
|
||||
*/
|
||||
export default function AdminErrorsPage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
const query = useQuery<ListResponse>({
|
||||
queryKey: ['admin', 'error-events', { statusFilter }],
|
||||
queryFn: () => {
|
||||
const search = new URLSearchParams();
|
||||
if (statusFilter) search.set('statusCode', statusFilter);
|
||||
return apiFetch<ListResponse>(
|
||||
`/api/v1/admin/error-events${search.toString() ? `?${search.toString()}` : ''}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const events = query.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Error inspector"
|
||||
description="Captured 5xx errors. Click any row for the full stack, request body excerpt, and likely culprit."
|
||||
actions={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/${portSlug}/admin/errors/codes` as Route}>
|
||||
<BookOpen className="mr-1.5 h-4 w-4" />
|
||||
Code reference
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Search className="h-4 w-4" /> Filters
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="status">
|
||||
Status code
|
||||
</label>
|
||||
<Input
|
||||
id="status"
|
||||
placeholder="e.g. 500"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value.replace(/\D/g, ''))}
|
||||
className="h-8 w-32"
|
||||
/>
|
||||
</div>
|
||||
{statusFilter && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => setStatusFilter('')}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="No captured errors"
|
||||
description="Nothing has hit a 5xx in the selected window. That's a good thing."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border divide-y">
|
||||
{events.map((event) => {
|
||||
const culprit = classifyError(event);
|
||||
return (
|
||||
<Link
|
||||
key={event.requestId}
|
||||
href={`/${portSlug}/admin/errors/${event.requestId}` as Route}
|
||||
className="flex items-start gap-3 p-3 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
event.statusCode >= 500
|
||||
? 'border-destructive/40 text-destructive'
|
||||
: 'border-amber-300 text-amber-800'
|
||||
}
|
||||
>
|
||||
{event.statusCode}
|
||||
</Badge>
|
||||
<span className="text-xs font-mono uppercase text-muted-foreground">
|
||||
{event.method}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">{event.path}</span>
|
||||
{culprit && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Wrench className="h-3 w-3" />
|
||||
{culprit.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{event.errorName ? `${event.errorName}: ` : ''}
|
||||
{event.errorMessage ?? '(no message)'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(new Date(event.createdAt), { addSuffix: true })} ·{' '}
|
||||
{format(new Date(event.createdAt), 'MMM d HH:mm:ss')} · ID{' '}
|
||||
<code className="font-mono">{event.requestId.slice(0, 12)}…</code>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,5 @@
|
||||
import { FormTemplateList } from '@/components/admin/forms/form-template-list';
|
||||
|
||||
export default function FormTemplatesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Form Templates</h1>
|
||||
<p className="text-muted-foreground">Create and manage intake form templates</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <FormTemplateList />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,75 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default function DataImportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
|
||||
<p className="text-muted-foreground">Import data from external sources</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
<PageHeader
|
||||
title="Data import"
|
||||
description="What you can import today and what an in-app importer will look like."
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 mt-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Available imports today</CardTitle>
|
||||
<CardDescription>Run from the command line until the UI catches up.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p>
|
||||
<strong>Berths from NocoDB:</strong>
|
||||
</p>
|
||||
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Idempotent. Skips rows where <code>updated_at > last_imported_at</code> unless
|
||||
you pass <code>--force</code>. Add <code>--update-snapshot</code> to also rewrite{' '}
|
||||
<code>src/lib/db/seed-data/berths.json</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<strong>Storage backend migration:</strong>
|
||||
</p>
|
||||
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
|
||||
pnpm tsx scripts/migrate-storage.ts
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Run after switching <code>system_settings.storage_backend</code> in System Settings.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<strong>Seed (rebuild dev fixtures):</strong>
|
||||
</p>
|
||||
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
|
||||
pnpm db:seed
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>What this page will become</CardTitle>
|
||||
<CardDescription>Planned UI for self-serve imports.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Drag-and-drop CSV / XLSX upload with column-mapping UI.</li>
|
||||
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
|
||||
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
|
||||
<li>Per-port import history with rollback.</li>
|
||||
<li>Templates for clients, yachts, companies, berths, reservations, expenses.</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground pt-2">
|
||||
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial
|
||||
failures don’t leave the database half-loaded.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
|
||||
|
||||
export default function InquiriesPage() {
|
||||
return <InquiryInbox />;
|
||||
}
|
||||
14
src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx
Normal file
14
src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function InvitationsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Invitations"
|
||||
description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
|
||||
/>
|
||||
<InvitationsManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
|
||||
/**
|
||||
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
|
||||
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
|
||||
*/
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, session.user.id),
|
||||
});
|
||||
|
||||
if (!profile?.isSuperAdmin) {
|
||||
redirect(`/${portSlug}/dashboard`);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||
|
||||
export default function OcrSettingsPage() {
|
||||
return <OcrSettingsForm />;
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import { OnboardingChecklist } from '@/components/admin/onboarding-checklist';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Onboarding</h1>
|
||||
<p className="text-muted-foreground">Guided setup for new port configurations</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Port onboarding"
|
||||
description="Bring a new port live. Each step links to the right admin page; checks update automatically once you've configured the underlying setting."
|
||||
/>
|
||||
<OnboardingChecklist />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
313
src/app/(dashboard)/[portSlug]/admin/page.tsx
Normal file
313
src/app/(dashboard)/[portSlug]/admin/page.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Bell,
|
||||
Briefcase,
|
||||
Database,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Inbox,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
Palette,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Shield,
|
||||
Sliders,
|
||||
Tag,
|
||||
Upload,
|
||||
Users,
|
||||
UsersRound,
|
||||
Webhook,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
interface AdminSection {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Settings;
|
||||
}
|
||||
|
||||
interface AdminGroup {
|
||||
title: string;
|
||||
description: string;
|
||||
sections: AdminSection[];
|
||||
}
|
||||
|
||||
const GROUPS: AdminGroup[] = [
|
||||
{
|
||||
title: 'Access',
|
||||
description: 'Who can sign in and what they can do once they do.',
|
||||
sections: [
|
||||
{
|
||||
href: 'users',
|
||||
label: 'Users',
|
||||
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
href: 'invitations',
|
||||
label: 'Invitations',
|
||||
description: 'Send invitations, track pending invites, and resend or revoke them.',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'roles',
|
||||
label: 'Roles & Permissions',
|
||||
description: 'Default permission sets and per-port role overrides.',
|
||||
icon: Shield,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Configuration',
|
||||
description: 'Branding, integrations, and per-port settings.',
|
||||
sections: [
|
||||
{
|
||||
href: 'email',
|
||||
label: 'Email Settings',
|
||||
description: 'From address, signatures, and per-port SMTP overrides.',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'documenso',
|
||||
label: 'Documenso & EOI',
|
||||
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
href: 'reminders',
|
||||
label: 'Reminders',
|
||||
description: 'Default reminder behaviour and the daily-digest delivery window.',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
href: 'branding',
|
||||
label: 'Branding',
|
||||
description: 'App name, logo, primary color, and email header/footer HTML.',
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
href: 'settings',
|
||||
label: 'System Settings',
|
||||
description: 'Generic key/value configuration store for advanced flags.',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
href: 'webhooks',
|
||||
label: 'Webhooks',
|
||||
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
|
||||
icon: Webhook,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Content',
|
||||
description: 'Forms, templates, and labels that users see.',
|
||||
sections: [
|
||||
{
|
||||
href: 'forms',
|
||||
label: 'Forms',
|
||||
description: 'Form templates used by client-facing inquiry and intake flows.',
|
||||
icon: Sliders,
|
||||
},
|
||||
{
|
||||
href: 'templates',
|
||||
label: 'Document Templates',
|
||||
description: 'PDF + email templates with merge-field placeholders.',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
href: 'email-templates',
|
||||
label: 'Email Templates',
|
||||
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'tags',
|
||||
label: 'Tags',
|
||||
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
|
||||
icon: Tag,
|
||||
},
|
||||
{
|
||||
href: 'custom-fields',
|
||||
label: 'Custom Fields',
|
||||
description: 'Tenant-defined fields for clients, yachts, and reservations.',
|
||||
icon: Key,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Data Quality',
|
||||
description: 'Cleanup, imports, and the audit trail.',
|
||||
sections: [
|
||||
{
|
||||
href: 'inquiries',
|
||||
label: 'Inquiry Inbox',
|
||||
description:
|
||||
'Submissions captured from the public marketing site (berth, residence, contact).',
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
href: 'sends',
|
||||
label: 'Send Log',
|
||||
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'duplicates',
|
||||
label: 'Duplicates',
|
||||
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
|
||||
icon: UsersRound,
|
||||
},
|
||||
{
|
||||
href: 'import',
|
||||
label: 'Bulk Import',
|
||||
description: 'CSV-driven imports for clients, yachts, and reservations.',
|
||||
icon: Upload,
|
||||
},
|
||||
{
|
||||
href: 'audit',
|
||||
label: 'Audit Log',
|
||||
description: 'Searchable log of every authenticated mutation in the system.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Operations',
|
||||
description: 'Health checks and disaster recovery.',
|
||||
sections: [
|
||||
{
|
||||
href: 'reports',
|
||||
label: 'Reports',
|
||||
description: 'Saved analytics views and ad-hoc query results.',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
href: 'monitoring',
|
||||
label: 'Queue Monitoring',
|
||||
description: 'BullMQ queue health, throughput, and retry diagnostics.',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
href: 'backup',
|
||||
label: 'Backup & Restore',
|
||||
description: 'Backup posture + retention policy (read-only).',
|
||||
icon: HardDrive,
|
||||
},
|
||||
{
|
||||
href: 'storage',
|
||||
label: 'Storage Backend',
|
||||
description:
|
||||
'Choose between S3-compatible object store or local filesystem; migrate between them.',
|
||||
icon: HardDrive,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tenancy',
|
||||
description: 'Multi-port and multi-install scaffolding.',
|
||||
sections: [
|
||||
{
|
||||
href: 'ports',
|
||||
label: 'Ports',
|
||||
description: 'Manage the marinas/ports this installation serves.',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
href: 'onboarding',
|
||||
label: 'Onboarding checklist',
|
||||
description: 'Setup checklist for fresh ports (read-only references).',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
description: 'Third-party providers wired into the app.',
|
||||
sections: [
|
||||
{
|
||||
href: 'ai',
|
||||
label: 'AI configuration',
|
||||
description:
|
||||
'Master switch + provider credentials shared by every AI surface (OCR, berth-PDF parser, future recommender embeddings).',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
href: 'ocr',
|
||||
label: 'Receipt OCR (per-feature)',
|
||||
description: 'Provider, model, and confidence thresholds for the receipt scanner.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
href: 'website-analytics',
|
||||
label: 'Website analytics (Umami)',
|
||||
description: 'Per-port Umami URL, API token, and Website ID.',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
href: 'residential-stages',
|
||||
label: 'Residential pipeline stages',
|
||||
description:
|
||||
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default async function AdminLandingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Administration"
|
||||
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
|
||||
/>
|
||||
{GROUPS.map((group) => (
|
||||
<section key={group.title} className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{group.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{group.sections.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Link
|
||||
key={s.href}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/admin/${s.href}` as any}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{s.label}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>{s.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx
Normal file
74
src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
const DEFAULT_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'reminder_default_enabled',
|
||||
label: 'Enable reminders by default on new interests',
|
||||
description:
|
||||
'When on, newly-created interests inherit reminderEnabled=true. Users can still toggle it on a per-interest basis.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'reminder_default_days',
|
||||
label: 'Default inactivity days',
|
||||
description:
|
||||
"Default value for an interest's reminderDays field. Reminders fire after this many days of no contact.",
|
||||
type: 'number',
|
||||
placeholder: '7',
|
||||
defaultValue: 7,
|
||||
},
|
||||
];
|
||||
|
||||
const DIGEST_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'reminder_digest_enabled',
|
||||
label: 'Batch reminders into a daily digest',
|
||||
description:
|
||||
'Off (default): reminders fire as soon as the threshold is hit. On: pending reminders are accumulated and delivered once per day at the digest time.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'reminder_digest_time',
|
||||
label: 'Digest delivery time',
|
||||
description: '24-hour HH:MM in the digest timezone.',
|
||||
type: 'string',
|
||||
placeholder: '09:00',
|
||||
defaultValue: '09:00',
|
||||
},
|
||||
{
|
||||
key: 'reminder_digest_timezone',
|
||||
label: 'Digest timezone',
|
||||
description: 'IANA timezone name used to interpret the delivery time.',
|
||||
type: 'timezone',
|
||||
defaultValue: 'Europe/Warsaw',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ReminderSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Reminders"
|
||||
description="Default reminder behaviour for new interests and the optional daily-digest delivery window. Individual users can still configure their own digest preferences in Notifications → Preferences."
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Defaults for new interests"
|
||||
description="Applied when an interest is created without an explicit reminder configuration."
|
||||
fields={DEFAULT_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Daily digest"
|
||||
description="Optional batching window so reminder notifications go out once per day instead of as they fire."
|
||||
fields={DIGEST_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,5 @@
|
||||
export default function ScheduledReportsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Scheduled Reports</h1>
|
||||
<p className="text-muted-foreground">Configure and manage automated report delivery</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import { ReportsDashboard } from '@/components/admin/reports-dashboard';
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
return <ReportsDashboard />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ResidentialStagesAdmin } from '@/components/admin/residential-stages-admin';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function ResidentialStagesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Residential pipeline stages"
|
||||
eyebrow="ADMIN"
|
||||
description="Configure the stages residential interests flow through. Removing a stage that still has interests prompts you to reassign them before saving."
|
||||
/>
|
||||
<ResidentialStagesAdmin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/admin/sends/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/sends/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SendsLog } from '@/components/admin/sends-log';
|
||||
|
||||
export default function SendsPage() {
|
||||
return <SendsLog />;
|
||||
}
|
||||
7
src/app/(dashboard)/[portSlug]/admin/storage/page.tsx
Normal file
7
src/app/(dashboard)/[portSlug]/admin/storage/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { StorageAdminPanel } from '@/components/admin/storage-admin-panel';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function StorageAdminPage() {
|
||||
return <StorageAdminPanel />;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -36,14 +38,18 @@ export default function WebhooksPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [regenerating, setRegenerating] = useState<string | null>(null);
|
||||
const [newSecret, setNewSecret] = useState<{ webhookId: string; secret: string; masked: string } | null>(null);
|
||||
const [newSecret, setNewSecret] = useState<{
|
||||
webhookId: string;
|
||||
secret: string;
|
||||
masked: string;
|
||||
} | null>(null);
|
||||
|
||||
const loadWebhooks = useCallback(async () => {
|
||||
try {
|
||||
const result = await apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks');
|
||||
setWebhooks(result.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load webhooks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -58,9 +64,10 @@ export default function WebhooksPage() {
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/webhooks/${deleteTarget.id}`, { method: 'DELETE' });
|
||||
setDeleteTarget(null);
|
||||
toast.success('Webhook deleted');
|
||||
void loadWebhooks();
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete webhook');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +80,8 @@ export default function WebhooksPage() {
|
||||
);
|
||||
setNewSecret({ webhookId, secret: result.data.secret, masked: result.data.secretMasked });
|
||||
void loadWebhooks();
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to regenerate secret');
|
||||
} finally {
|
||||
setRegenerating(null);
|
||||
}
|
||||
@@ -86,9 +93,10 @@ export default function WebhooksPage() {
|
||||
method: 'PATCH',
|
||||
body: { isActive: !webhook.isActive },
|
||||
});
|
||||
toast.success(webhook.isActive ? 'Webhook disabled' : 'Webhook enabled');
|
||||
void loadWebhooks();
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to toggle webhook');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,15 +106,20 @@ export default function WebhooksPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Webhooks</h1>
|
||||
<p className="text-muted-foreground">Configure outgoing webhook integrations</p>
|
||||
</div>
|
||||
<Button onClick={() => { setEditTarget(null); setFormOpen(true); }}>
|
||||
<PageHeader
|
||||
title="Webhooks"
|
||||
description="Configure outgoing webhook integrations"
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditTarget(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
@@ -116,7 +129,13 @@ export default function WebhooksPage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Add a webhook to receive real-time notifications of CRM events.
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => { setEditTarget(null); setFormOpen(true); }}>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setEditTarget(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
@@ -141,17 +160,16 @@ export default function WebhooksPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleToggleActive(webhook)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleToggleActive(webhook)}>
|
||||
{webhook.isActive ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setEditTarget(webhook); setFormOpen(true); }}
|
||||
onClick={() => {
|
||||
setEditTarget(webhook);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -163,11 +181,7 @@ export default function WebhooksPage() {
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(webhook.id)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleExpand(webhook.id)}>
|
||||
{expandedId === webhook.id ? 'Collapse' : 'Details'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -228,18 +242,26 @@ export default function WebhooksPage() {
|
||||
onSuccess={loadWebhooks}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Delete "{deleteTarget?.name}"? This will also delete all delivery history. This action
|
||||
cannot be undone.
|
||||
Delete "{deleteTarget?.name}"? This will also delete all delivery history.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test-button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
/**
|
||||
* Per-port Umami credentials. We deliberately keep all three values
|
||||
* port-scoped (per the operator decision) so different ports can point at
|
||||
* different Umami instances if needed. The /website-analytics dashboard
|
||||
* page reads these settings via the umami.service layer at request time.
|
||||
*/
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'umami_api_url',
|
||||
label: 'Umami API URL',
|
||||
description:
|
||||
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
|
||||
type: 'string',
|
||||
placeholder: 'https://analytics.portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_api_token',
|
||||
label: 'API token',
|
||||
description:
|
||||
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_username',
|
||||
label: 'Username',
|
||||
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
|
||||
type: 'string',
|
||||
placeholder: 'admin',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_password',
|
||||
label: 'Password',
|
||||
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_website_id',
|
||||
label: 'Website ID',
|
||||
description:
|
||||
'UUID of this port’s website inside Umami. Find it in Umami → Settings → Websites → Edit → Website ID.',
|
||||
type: 'string',
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WebsiteAnalyticsSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Website analytics (Umami)"
|
||||
description="Connect this port to its Umami website to display traffic, top pages, referrers, and conversion data on the Website Analytics dashboard."
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Umami connection"
|
||||
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
|
||||
fields={FIELDS}
|
||||
extra={<UmamiTestButton />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/alerts/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/alerts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
||||
|
||||
export default function AlertsPage() {
|
||||
return <AlertsPageShell />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ReservationDetail } from '@/components/reservations/reservation-detail';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ portSlug: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ReservationDetailPage({ params }: PageProps) {
|
||||
const { portSlug, id } = await params;
|
||||
return <ReservationDetail reservationId={id} portSlug={portSlug} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user