Locked decisions from brainstorming: report-level empty states across
Sales/Operational/Financial gated on a window-independent hasData flag;
Operational gains an Area-only berth-scope filter (Status dropped as a
light filter in this report); rep/source confirmed not applicable to
Operational.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sales/Operational/Financial are built + verified; Marketing is blocked
on the website cutover (launch-readiness Init 1b), not on code. Rather
than hide the whole reports surface behind a module toggle, keep it live
for beta and 404 the one unbuilt kind so a hand-typed /reports/marketing
URL can't reach the "in development" placeholder. The landing page
already advertises only the three live reports + Custom.
Remove the UNAVAILABLE_NEW_KINDS entry when the Marketing report ships.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.
Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
(inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
ledger
- every money figure normalised to port currency via a shared
resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)
UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.
Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.
TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the two cross-cutting filter gaps in launch-readiness (rep
multi-select + source multi-select). The Sales detail tables can now be
narrowed by assigned rep and lead source alongside the existing stage /
lead-category / outcome filters.
- service: thread `assignedTo` + `sources` through the 5 filtered Sales
queries (rep-performance, stalled, closing-this-month, recent-wins,
lost-reason); add `getRepFilterOptions` for the rep dropdown's stable
option list (distinct assigned reps port-wide, window-independent).
- route: extract param parsing into a pure, unit-tested
`parseSalesFilters` helper (source allowlisted against SOURCES;
assignedTo passed through as free user-id list); return `repOptions`
in the payload.
- ui: static Source filter (SOURCES) + dynamic "Assigned to" filter
(from payload repOptions, hidden until loaded); decouple the query
builder from dynamic options via a stable FILTER_KEYS list.
TDD: 8 new parseSalesFilters unit tests (allowlist drop, free-list
passthrough, combine). tsc clean; 12/12 reports unit tests; browser-
verified both filters fire `source=`/`assignedTo=` → 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Third importer increment — the write path, fully testable without UI.
- commit.ts: commitBatch streams classified rows, applies insert/update per
the conflict policy via the adapter (each row in its own try/catch so valid
rows still land), records every action in import_batch_rows, and keeps live
counts on the batch header. undoBatch hard-deletes a batch's inserted rows
(port-scoped); a delete blocked by a dependent FK is reported, not forced,
and the batch flips to `undone` only when every inserted row was removed.
- import worker: replaced the no-op placeholder with the real processor —
loads the batch, re-reads the uploaded file from storage, parses, and runs
commitBatch under the batch's mapping + policy. Marks the batch failed on
error. Concurrency 1 so imports don't race each other's dedup lookups.
Tests: commit (skip/insert/error counts + per-row ledger + real inserted
entity), undo (removes exactly the inserted row, flips status), and
update-matches overwrite. 2 passing.
Engine is now functional end-to-end at the service layer: parse → map →
dry-run → commit → undo. Remaining: 4 FK adapters, API routes + permission,
wizard UI + history.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First increment of the importer (docs/superpowers/specs/2026-06-01-bulk-import-design.md):
the three port-scoped tables, no changes to entity tables.
- import_batches — one row per run: entity_type, filename, storage_key,
status, conflict_policy, mapping_json, live counts, created_by, timestamps.
- import_batch_rows — per-row action ledger (inserted/updated/skipped/errored)
with entity_id + error; partial index on inserted rows powers Undo.
- import_mappings — saved column mappings, unique per (port, entity, name).
Migration 0090 applied via psql; schema re-exported from the index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three polish items so the legacy seed is one-shot and complete:
- backfill-documents: recover the ~10 pre-Documenso "LOI process" EOIs
whose signed PDF lives only as a NocoDB attachment in the `database`
MinIO bucket (the pipeline keys EOI-doc creation off documensoID, so it
never created rows for them). Reads EOI_Document attachment metadata
from the local nocodb_legacy dump, pulls the PDF (read-only) from the
`database` bucket, and CREATES the document + file + folder, linking the
signed PDF. Idempotent via a `nocodb_eoi_document` ledger entry.
- connect-berth-links: refactored into an exported connectBerthLinks()
and folded into migrate-from-nocodb --apply (best-effort; skips with a
warning if the local dump isn't restored) so the multi-berth junction is
reconnected as part of the one-shot seed, not a separate manual step.
- migration-apply: contactless legacy clients (no email/phone across the
whole dedup cluster) get a per-port "Needs contact info" tag so staff
can filter + chase them, instead of being dropped.
The current dev DB's 29 contactless clients were tagged via a one-off
mirroring the pipeline logic. EOI recovery code is ready but the actual
run needs LEGACY_MINIO_* read creds supplied at the command line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both berth-detail surfaces were stubbed/hidden behind a comment in
berth-tabs.tsx. Their backing schema already existed; this wires the UI
and fills the service gaps.
Maintenance Log (was ~60% built: schema/migration/add+get service/route):
- new edit + delete: updateMaintenanceLog / deleteMaintenanceLog service
(port-scoped tenant guard), PATCH/DELETE at maintenance/[logId], plus
updateMaintenanceLogSchema. add schema now accepts null for cost /
responsibleParty so the shared add+edit dialog sends one body shape.
- BerthMaintenanceTab: list (newest first) + add/edit dialog + delete
confirm, realtime invalidation. New berth:maintenanceUpdated/Removed
socket events.
Waiting List (un-hide the orphaned manager + next-in-line notify):
- getWaitingList now left-joins the client so the queue renders names,
not raw ids.
- WaitingListManager rewritten: ClientPicker instead of free-text id,
client names, manage_waiting_list gating on add/reorder/remove, and a
"Next in line" marker on position 1.
- notifyWaitlistNextInLine: when a berth transitions to available,
surface the #1 client to staff who hold berths.manage_waiting_list
(mirrors the interest-based notifyNextInLine; dedupeKey-suppressed).
Hooked into updateBerthStatus on any -> available transition.
Tests: maintenance add/get/update/delete + cross-port guard; waitlist
notify recipient-resolution / payload / empty + no-permission no-ops.
Verified end-to-end in the browser (create/render/delete for both).
Also adds scripts/dev-reset-admin-pw.ts (reset a synthetic user's
password via the better-auth hasher after a dev reseed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Reservation and Contract tabs reused ExternalEoiUploadDialog, but the
service hard-coded the EOI document type, status columns, stage target, and
berth rule. A signed contract uploaded from the Contract tab filed as an
`eoi`, flipped `eoi_status`, and advanced the stage to `eoi` - wrong doc
kind, wrong sub-state, wrong stage.
- external-eoi.service: UPLOAD_CONFIG keyed off docType (eoi | reservation
| contract) parameterises documentType, file category, storage prefix,
doc-status column, signed-date column, target stage, advance-from set,
and berth rule. eoi_status is written only for docType=eoi.
- route: parse docType from the form (default eoi).
- dialog: docType prop; generalised copy; EOI-only UI (active-EOI replace
banner, public-map flip, cancelActiveDocumentId) gated to docType=eoi.
- reservation/contract tabs: pass docType; drop the coming-soon comments.
- test: docType routing cases (reservation -> reservation_agreement +
reservation cols; contract -> contract + contract cols; eoi_status stays
null on both; contract idempotent at/past contract stage).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reconcile-migration.ts: read-only cross-check of EVERY migrated record vs its
legacy source (via the ledger) — coverage (nothing dropped), field fidelity
(independently re-derives stage/eoiStatus/documensoId/berth/email), and
relationship integrity (orphans, dangling FKs).
connect-berth-links.ts: the dedup pipeline migrated only the single per-interest
Berth Number text field and missed the legacy _nc_m2m_Berths_Interests junction
(multi-berth deals) — 57 deals were missing links. Reads the junction from the
nocodb_legacy snapshot, resolves interest + berth via the ledger, inserts the
missing interest_berths rows (idempotent; respects the one-primary partial
unique index). Inserted 74 links, 51 new primaries.
After the fix: reconciliation = 0 discrepancies across all 255 deals, 165
expenses, 45 residential.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read-only audit of migrated data:
- EOI PDF ↔ person: extracts each attached signed-EOI PDF text (unpdf), confirms
the linked client name appears, flags any PDF where a different client name
appears. Result: 35/35 strong match, 0 mismatches (visually spot-checked 2).
- Berth PDF ↔ mooring: soft text check; moorings render as graphics so the
filename→mooring attachment is authoritative (113/113; A1 visually confirmed).
- Per-person completeness: 0 deals missing stage, 0 clients without a deal,
29 clients without contact info (inherited legacy data gaps).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
backfill-documents.ts pulls signed EOI PDFs + berth spec PDFs from the legacy
MinIO (client-portal bucket; read-only via dedicated LEGACY_MINIO_* creds) and
deposits them into the CRM (getStorageBackend), linking:
- berth PDFs → berth_pdf_versions + berths.current_pdf_version_id (mooring from
filename; 113/113 matched)
- signed EOIs → documents.signed_file_id + status=completed + a files row filed
into the client folder (exact name + conservative lev<=2 fuzzy; 33 linked)
Idempotent (skips when signedFileId / current_pdf_version_id already set).
Strictly prod-READ-only; all writes local (dev storage_backend=filesystem).
Unmatched EOIs reported (mostly in-flight deals w/ no signed PDF yet + old-LOI
docs in the NocoDB attachment bucket).
Adds probe-minio.ts (read-only bucket inventory).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single idempotent --apply now seeds the full legacy dataset:
- Expenses: fetch the separate "Expenses" NocoDB base (mxfcefkk4dqs6uq),
transform (price→amount+currency, payment status, receipt marker), apply to
the expenses table under a new nocodb_expenses ledger tag.
- Interest EOI display state: set interests.eoiStatus/eoiDocStatus from the
legacy EOI Status / LOI process so deals show signed / awaiting-signature
(in-flight) state, not only a separate documents row.
- Runner reports expenses + tags createdBy with the seeded super-admin id.
Validated via --apply on the dev DB: 239 clients (multi-deal grouping intact),
255 interests (qualified 171/eoi 51/nurturing 30/reservation 2/contract 1),
48 signed + 3 in-flight EOIs, 165 expenses (5 currencies), 41 docs + 119
signers, 45 residential. tsc clean; 67 dedup unit tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2026-05-03 migration pipeline (src/lib/dedup/*) predates the 9→7
pipeline-stage refactor; its STAGE_MAP emitted invalid stages
(open/details_sent/eoi_sent/…) that would write bad pipeline_stage values
on --apply. Remap to the current PIPELINE_STAGES (enquiry/qualified/
nurturing/eoi/reservation/deposit_paid/contract) + a deposit-received →
deposit_paid override. Frozen-fixture test expectations updated (17/17 pass).
Validated: live --dry-run = 239 clients / 255 interests / 41 EOI docs
(matches independent snapshot analysis; pipeline is more conservative and
flags 3 borderline pairs for review).
Adds the migration design spec (source map, scope lock to Port Nimara +
Expenses bases, EOI coverage 48/48, in-flight Documenso state, remaining
gaps: interest eoiStatus, expenses, doc-blob backfill).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ship-what's-done prep ahead of the prod cutover (launch ~today):
- Hide Financial + Marketing report cards from the reports landing
(both were "Builder in development" placeholders gated on unbuilt
data sources). Sales/Operational/Custom + templates/scheduling/
exports remain live.
- Trim the Custom-report card copy to match the shipped basic builder
(no group-by/filters yet; the builder page header was already honest).
- Hide the Bulk Import mockup from search-nav-catalog + the admin
sections browser; /admin/import is now unreachable from the UI.
- Correct client-facing doc over-claims (waiting-list "next-in-line
notification", Import) in features-list.md + new-system-feature-summary.md.
- Un-stale BACKLOG.md (Documenso phases 2-7 confirmed shipped).
- Log decisions + deferred work (full importer, full custom-builder,
waiting-list, maintenance-log, paper-upload bug) to launch-readiness.md.
Deferred-importer design spec added at
docs/superpowers/specs/2026-06-01-bulk-import-design.md.
Verified: tsc --noEmit clean, eslint clean on changed files,
1512/1519 vitest pass (7 failures are Redis-down, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "Compare to prior period" toggle to the Sales report header.
When on, the API recomputes the KPI window for the equal-length window
immediately preceding the selected range (previousPeriodBounds) behind
`?compare=1`, and the five window-derived KPI 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) have no
prior-window analogue and intentionally show no delta. The prior-window
query runs in parallel with the main batch and resolves to null when the
toggle is off (zero cost). Toggle state persists in the saved-template
config.
Closes the spec's "period comparison on every report" gap for Sales;
Operational already rendered period-start deltas.
Pure helpers TDD'd: previousPeriodBounds (range.ts) +
computeSalesKpiComparison (sales-comparison.ts), 7 unit tests. tsc +
lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a `residential_module_enabled` port setting (default ON) that
hides/disables the entire Residential surface when an admin turns it
off, mirroring the Tenancies / Invoices / Expenses module-toggle
pattern. Disabling is a soft hide — residential clients/interests are
preserved and reappear on re-enable.
Surfaces gated:
- Route guard: new residential/layout.tsx renders ModuleDisabledPage
(covers all 5 residential pages)
- Sidebar "Residential" section + mobile more-sheet tile (SSR-resolved
residentialModuleByPort threaded layout → app-shell → sidebar)
- Global search: residential client/interest buckets early-return at
the shared chokepoint so disabled-port records don't dead-end
- Public intake: /api/public/residential-inquiries 404s when off
- Admin Switch in settings-manager (writes via settings PUT)
Service TDD'd (residential-module.test.ts, 6 tests) plus a
disabled-port rejection test on the public endpoint. tsc + lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.
UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
Aggregated helpers in notes.service mirror the listFor*Aggregated
symmetric-reach joins. yacht-tabs + company-tabs render the
badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
`width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
fixed inset-0 pin so long forms scroll naturally). Form picks up
port branding (logoUrl + backgroundUrl + appName) via
loadByToken. Address fields completed (street + city + region +
postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
emits toast.success with action link to the destination entity
or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
(5 types visible at once).
Launch infra:
- UTM column wiring (Init 1b step 4): migration
0089_website_submissions_utm.sql adds utm_source/medium/campaign/
term/content + composite index (port_id, utm_source, received_at)
for per-campaign rollups. website-inquiries intake accepts the
five fields. Residential intake intentionally untouched per audit
scope.
- Invoicing module gate (Init 1c spike): new
invoices-module.service + invoices layout guard + registry entry
invoices_module_enabled (default false). Audit conclusion in
launch-readiness.md: payments table is canonical money path;
/invoices flow is parallel infrastructure now hidden by default.
Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
New route-labels.ts + use-smart-back hook +
navigation-history-tracker so back falls through to the parent
route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
breadcrumb-store kept for back-compat consumers but the
breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
upload-receipts, reports kind, tenancies detail, analytics
metric, client detail) migrated.
Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
the reports gap audit (cross-cutting filter set, Marketing +
Financial blockers, custom builder remaining entities, scheduled
CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
(each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Activity-feed: shared formatting module
(src/components/shared/activity-formatting.ts) centralises action
verbs, badge variants, entity-type labels, enum-value normalisation,
shortValue, and buildDiffLine. The dashboard widget feed and the
per-entity audit feed now both consume it - duplicate ~250 lines
collapsed, vocabularies aligned, badge palette unified.
- Signing order setting becomes tri-state. The new
TEMPLATE_DEFAULT value (the new default) skips overriding the
template's own signingOrder so each Documenso template's stored
setting wins. PARALLEL / SEQUENTIAL keep forcing the override.
- Admin Documenso page now ships a Webhook health card backed by
/api/v1/admin/documenso-webhook/health (secret status,
expected URL, last received event, recent secret rejections) and
a "Test now" button that fires a synthetic DOCUMENT_OPENED through
/api/v1/admin/documenso-webhook/test against the local receiver
to verify the full pipeline without driving a real Documenso event.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- StageStepper renders now carry tag chips next to the progress bar
(client interest cards, pipeline summary, preview sheet).
- Notes tab badge on the interest detail aggregates note counts across
the interest, the linked client, the linked yacht, and any companies
the client is an active member of - reps see the full surface area
at a glance.
- Admin Settings: Tenancies Module toggle wired into the Feature Flags
card. Disabling hides nav/tabs without deleting any rows; re-enabling
brings them back. Service layer was already complete; this surfaces
the control on the operations page.
- HubRoot recent-files rows now show folder breadcrumb + entity badge
(Interest/Client/Yacht/Company) so reps can tell at a glance where a
file lives. Backed by listFiles enrichment (5 batched lookups per
page; no per-row queries).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of the active UAT sweep wraps the inheritance/polish bucket.
- BerthOccupancyChip: new shared component that surfaces the competing
active interest on a non-available berth as a colour-coded chip with
a stage badge. Adopted in LinkedBerthRowItem, BerthRecommenderPanel
recommendation card, and InterestBerthStatusBanner; the banner aligns
query keys with the chip so React Query dedupes the network call.
- OverviewTab inheritance: getInterestById now ships a yachtDimensions
block when the interest is linked to a yacht with dimensions. The
Berth Requirements rows render a "↩ <value> from yacht" pill when
the desired field is blank; clicking the pill copies the value into
the interest. After a manual edit, a toast offers to write the new
value back to the yacht record so the canonical truth stays in sync.
- Map-flip inheritance: ExternalEoiUploadDialog and UploadForSigningDialog
now expose a single "Mark berth(s) as Under Offer on the public map"
checkbox that defaults ON when any in-bundle berth already has
is_specific_interest=true. On submit, PATCHes the in-bundle berths
that don't already match; sister surface to the EOI generate
dialog's per-berth picker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of the comprehensive UAT round. Implements the Automate
Signing feature per the 2026-05-26 locked decisions.
P3.1 — documents.automation_mode schema
Migration 0088 adds the column with a CHECK constraint enforcing
the three-value enum: manual / sequential_auto / concurrent_auto.
Drizzle schema picks it up; default 'manual' preserves existing
behaviour.
P3.2 — Automate Signing orchestrator service
New src/lib/services/signing-automation.service.ts. enableSigningAutomation
resolves the mode from the envelope's signing order (SEQUENTIAL ->
sequential_auto fires first signer only; PARALLEL -> concurrent_auto
fires all signers in one parallel dispatch), updates documents.automationMode,
and dispatches invitations via the same sendSigningInvitation path
the manual route uses (so the email a recipient sees is identical
regardless of trigger). ensureSigningUrls recovers v2 signing URLs
if they're missing on the local signer rows. Hard guards: envelope
must exist, status in {draft, sent, partially_signed}, ≥2 signers.
disableSigningAutomation reverts to manual; idempotent.
P3.3 — Webhook cascade
The existing sendCascadingInviteForNextSigner in documents.service.ts
already fires the next pending signer on every recipient_signed event
(mode-independent). handleDocumentCompleted already sends the signed
PDF to all recipients via sendSigningCompleted on completion. So
"automate" really means "kick off the first invitation"; the rest
is mode-independent existing behaviour. Doc comment in the new
service explains the interaction.
P3.4 — ActiveEoiCard Automate signing button + banner
- DocumentRow type extended with automationMode + documensoId.
- New automateMutation hits POST /api/v1/documents/[id]/automate;
pauseAutomationMutation hits DELETE.
- "Automate signing" button visible when totalCount ≥ 2 AND doc has
documensoId AND envelope is in-flight AND mode === 'manual'.
- "Automating sequentially/concurrently · N of M signed" banner
renders when automation is active, with a Pause button that
reverts to manual.
- Per-row Send invitation / Send reminder buttons in SigningProgress
stay visible per the locked decision (manual override during auto).
P3.5 — Automate Signing API route + tests
- POST /api/v1/documents/[id]/automate (enables) + DELETE (disables).
- Permission: documents.send_for_signing (mirrors the manual
send-invitation route).
- vitest covering: NotFound on missing doc, Conflict on missing
envelope, Conflict on completed status, Conflict on already-
automated, Conflict on <2 signers, disable is idempotent when
already manual. All 7 cases pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of the comprehensive UAT round. Locked decisions from the
2026-05-26 question round (see docs/superpowers/audits/active-uat.md
"Decisions locked" block).
P2.1 — drop the inapp template pathway
Removed the dead pathway dropdown. Generate-from-template flow is
now exclusively documenso-template; the inapp (pdf-lib + CRM-render)
branch was never surfaced as a deliberate choice and was a config
trap. Server-side route still accepts pathway='inapp' for backcompat
with older clients - wizard now always sends 'documenso-template'.
P2.2 — delete the wizard's upload branch
Reps who want to upload a finished PDF go through the New-document
dropdown -> "Upload & send for signature" (UploadForSigningDialog,
the proper field-placement flow) instead of the wizard's
half-implemented upload sub-form. Wizard's Source section becomes
a one-line explainer + the template picker; no more redundant
radio-then-pathway-then-template layering.
P2.3 — per-port doc-type template defaults
New GET /api/v1/documents/template-defaults endpoint returns
{ eoi, contract, reservation_agreement } template ids from
getPortDocumensoConfig. Settings registry keys already existed for
contract + reservation; config + resolver already plumbed them.
CreateDocumentWizard now fetches the map on mount and auto-sets
templateId whenever documentType changes (empty picker OR currently
showing a different doc-type's default both get re-aligned). Admin
override via the picker still works.
P2.4 — surface flow 3 (mark signed offline) from the dropdown
NewDocumentMenu gains a 4th item: "Mark as signed (offline)".
Opens a small dialog that asks for the interest + doc type
(eoi/reservation/contract), then navigates to the matching
per-interest tab with ?tab=...&action=upload-signed query param.
Per-interest tabs are the single source of truth for the
pipeline-stage + doc-status side effects of the mark-signed flow;
the hub-level dropdown just routes the rep to the right place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of the comprehensive Documenso upload audit per the
2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md.
P1.1 — persist documensoId immediately after create
Was set only at the late `status: 'sent'` commit. Any throw between
documensoCreate and the late update left an orphaned Documenso
envelope the CRM had no link to. Now the UPDATE runs right after
documensoCreate succeeds; rollback paths can find and void the
envelope.
P1.2 — pre-flight validation hard-blocks Submit
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 mirrors the same email + name checks so
direct API hits reject early. No override path per locked decision.
P1.3 — cancel/delete affordance audit + sweep
Document-list per-row Delete + Send for Signing actions now:
- Wrapped in PermissionGate (documents.delete + send_for_signing).
- Surface toast on success + toastError on failure (were silently
swallowing errors).
- Use a broader predicate-based query invalidation so every doc
list across the app refreshes, not just the local key.
EOI tab Regenerate + Cancel EOI buttons + reservation/contract
tab Cancel buttons wrapped in PermissionGate (documents.edit, the
cancel route's auth check).
P1.4 — Documenso webhook URL auto-PATCH (env-gated)
scripts/update-documenso-webhook.ts written. Reads
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise
no-op). Lists every webhook on the Documenso instance via v2 (with
v1 fallback), identifies webhooks pointing at trycloudflare.com
hosts OR /api/webhooks/documenso paths, PATCHes them to the new
tunnel URL. scripts/tunnel-url.sh chains the script after the URL
print so a re-tunnel auto-rotates the webhook (when flag set).
P1.5 — state-machine refactor with rollbackTo() helper
custom-document-upload.service.ts:
- Single try around create → send → place steps.
- state.step tracks which step is current; state.documensoDocId
records the envelope id once we have it.
- rollbackTo(reason) composes the recovery: status='cancelled' on
the CRM row, documensoVoidSafe on the envelope when applicable.
Idempotent — calling twice is safe.
- Removes three independent try/catches.
P1.6 — recipient ↔ Documenso identity reconciliation
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 message names the missing emails for the rep.
P1.7 — vitest extension + per-failure audit-log entries
- 5 new vitest cases (blank email, whitespace email, malformed
email, blank name, duplicate-emails-OK semantic).
- rollbackTo writes a structured audit_log entry with failedStep,
documensoEnvelopeId, errorClass, errorMessage. Post-mortem
investigation has structured data instead of just logger lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User answered 11 blocking + clarifying questions across the audit doc.
Decisions inlined as a summary block in the audit doc prelude so any
session reading the doc sees the answers up front before drilling into
individual findings.
Highlights:
- Documenso comprehensive audit ships as 5 discrete sub-PRs.
- Pre-flight validation hard-blocks Submit; no override path.
- `/documents/new` wizard refactor: delete upload branch, drop inapp
pathway, per-port doc-type template defaults, surface flow 3 from
dropdown, drop the route entirely.
- Automate Signing: pick-up on mid-flow enable; broadcast to all
recipients; single combined mode; manual override stays visible.
- Webhook URL auto-PATCH env-flag-gated.
- documenso_signing_order becomes a tri-state setting.
- OverviewTab inheritance writes to interest, prompt to also update
yacht record.
- Public-map flag inheritance applies across every map-flip dialog.
- Cancel/Delete affordance audit sweeps EVERY remove route.
- Orphan-scan script deferred; dev DB nuke acceptable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compendium of polish + small-fix work captured during the 2026-05-26
live UAT session. Every change has a corresponding entry in
docs/superpowers/audits/active-uat.md with file:line evidence + root
cause + alternatives considered.
Dialog primitive width
- DialogContent default bumped from sm:max-w-lg (512px) to
sm:max-w-xl + lg:max-w-3xl so every consumer gets a sane desktop
default. Confirm dialogs override DOWN, content-heavy dialogs
override UP.
- FilePreviewDialog full-viewport via w-[min(95vw,1400px)] +
h-[85vh] so PDFs render at usable width on real desktops.
Recommender card
- Heat badge now a Popover with the score (X/100), the formula in
plain English, the four component breakdowns (recency / furthest
stage / interest count / EOI count), and a pointer to the admin
weight tuning page.
- Area letter span dropped from the card header - mooring number
already prefixes it.
- BerthRecommenderPanel + the dedicated "Berth Recommendations" tab
both hidden when interest.desiredLengthFt is null. The empty
guidance card was reading as noise. interest-tabs.tsx computes
hasDesiredDims once and gates the inline mount + tab strip
spread off it.
BerthPicker
- Drop area suffix from row labels. Mooring number already carries
the area letter prefix; group heading conveys the same context.
Same fix flows to every BerthPicker consumer (tenancy
create/renew/transfer, interest form, linked-berths picker).
CreateDocumentWizard
- DOCUMENT_TYPE_LABELS constant added to constants.ts. Wizard reads
from the map instead of naive replace(/_/g, ' '): "EOI",
"Contract", "NDA", "Reservation Agreement", "Other".
- "Other" option surfaces a hint pointing the rep at the Title
field so they describe what the doc actually is.
InterestForm inline client + yacht create
- ClientForm gains an onCreated(clientId) callback. Mutation
returns { id } in create mode so onSuccess can forward.
- InterestForm renders an "Add new" Button next to the Client label
(create mode only - hidden on edit), opens ClientForm, auto-
selects the new client into the draft. Mirrors the existing
inline yacht-create pattern.
- Reset path includes source: 'manual' alongside the other create-
mode defaults; the manual flow was dropping back to a blank
source dropdown on reopen.
Tenancy list
- ClientTenanciesTab activeTenancies query now includes status
IN ('pending', 'active'). Was filtering to active-only; pending
rows from manual create + webhook auto-create were invisible on
the client detail's Tenancies tab.
- TenancyList rows are now keyboard- and click-navigable to the
tenancy detail page (Enter/Space included). Inner links + buttons
stop propagation so per-cell navigation works.
NotesList source badge
- Aggregated-mode source badge ("Yacht / Test Yacht") is now a Link
to the source entity's detail page. New sourceLinkFor helper
centralises the URL mapping across clients/companies/yachts/
interests + residential variants.
Yacht transfer audit log
- transferOwnership emits a distinct 'transfer' AuditAction (added
to AuditAction union in src/lib/audit.ts) with old/new owner
names resolved at write time. EntityActivityFeed renders
"Matt transferred owner to Jane Smith" instead of "Matt updated
this record." formatValueForField unwraps the { name } shape so
the audit_logs Record<string, unknown> typing stays clean.
- yacht-transfer-dialog copy: dropped "atomic" jargon. Reads "The
change is logged in the audit history" instead.
Companies autocomplete
- /api/v1/companies/autocomplete now returns the 10 most-recently-
updated companies when the query string is empty. Was returning
[]. CompanyPicker popover opens with results to scan instead of a
blank dropdown.
DocumentsHub FlatFolderListing
- Uploaded files (the files table) now merge into the documents
table view via a parallel /api/v1/files?folderId=X query +
client-side merge into a unified row list. listFiles service
honours the folderId filter that was already accepted by the
validator. New renderFileRow renders file rows with an "Uploaded
file" type pill + "Stored" status pill, links the filename to
the download URL. Existing FolderDropZone invalidation covers
the new query, so drag-drop and New-document-menu uploads
refresh the list without a page reload.
- FlatFolderListing wrapped in a vertically-spaced container so
subfolders / search row / list have consistent gap.
- Per-row chevron only renders when totalSigners > 0; empty
placeholder column kept so grid alignment doesn't jump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documenso reliability + signer-UX bundle from the 2026-05-26 live UAT.
Each piece detailed in docs/superpowers/audits/active-uat.md with full
file:line + root cause + alternatives.
Webhook + poll convergence
- DocumensoRecipient (webhook payload type) gains rejectionReason +
declineReason. The DOCUMENT_REJECTED / DOCUMENT_DECLINED handler
coalesces them at the boundary so downstream code sees one stable
field. Empty/whitespace normalised to null.
- DocumensoDocument.recipients[] (normalized client output) gains
rejectionReason. normalizeDocument coalesces v2 + v1 field names the
same way so poller consumers see identical shape.
- handleDocumentRejected signature gains rejectionReason. Stored on
document_events.eventData, persisted in audit_logs metadata, quoted
inline in the in-CRM rep notification (truncated 120 chars; full
reason still on the audit row). New 'transfer' AuditAction added
alongside.
- signature-poll job now handles REJECTED / DECLINED. Previously only
SIGNED / COMPLETED / EXPIRED were reconciled, so a missed rejection
webhook (stale tunnel URL is the typical dev cause) left documents
stuck in 'sent' forever. The 5-min poll cycle now closes that gap —
webhook becomes an optimisation, not a correctness requirement.
placeFields rollback gap
- custom-document-upload.service moved the synchronous field-placement
map() INSIDE the same try/catch that wraps placeFields(). Previously
the map's throw bubbled past the catch-and-rollback block, leaving
Documenso with a live envelope + recipients but no fields, and the
CRM document row stuck in 'sent' with no signing UI for the signers.
Logger captures looked-up email + map keys on miss for diagnosis.
- Comment documents Documenso's by-email dedupe semantic so future
readers don't reintroduce the per-recipient-row map assumption.
UploadForSigningDialog recipient UX
- New RECIPIENT_ROLE_META + RecipientRoleBadge helpers. Placement-step
sidebar list rebuilt as a two-line layout (name + role badge / email
on its own line) so duplicate-named recipients are visually
distinguishable. FieldSidePanel dropdown SelectItem mirrors the same
stacked shape.
- "Recipient" label renamed to "Assign this field to" with an explainer
paragraph below.
SigningProgress copy-link parity
- Copy-link button now always renders for pending signers (disabled +
explainer tooltip when signingUrl not yet issued). Reps can copy
even when the URL hasn't been distributed via email yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every UAT finding the user surfaces during a live walkthrough now lands
in docs/superpowers/audits/active-uat.md regardless of which session
captured it. Persists across sessions until the user explicitly says to
wrap a round and archive (rename to YYYY-MM-DD-uat.md, start fresh).
CLAUDE.md's "Manual UAT" guidance updated to point at the new file +
documents the status-tag taxonomy and the append-protocol detail level
(file:line, React-grab anchor, root cause, fix proposal walking each
layer, effort estimate, alternatives + rejection reasons, open
questions, bundle-with notes, cross-refs, acceptance criteria). Historical
alpha-uat-master.md retained as the previous master through 2026-05-26.
This commit seeds the doc with the full body of findings captured during
this live session — Documenso reliability work, dialog width sweep,
recipient UX, recommender card polish, tenancy + notes plumbing, the
larger Documenso upload audit and Automate Signing feature specs. Each
entry follows the detail contract documented in the file footer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drain the long-tail audit queue captured in alpha-uat-master.md.
- next-intl ripped out (zero useTranslations callers ever existed):
package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: 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 JSX className literals.
Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
label enabled at warn; 4 empty <th>/<td> action placeholders gain
sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
"Payments - Not received yet" or "Payments - \$X received - N payments
- Expand"; per-interest collapse state persists in localStorage; the
RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
text-muted-foreground/{60,70,80} hits dropped to plain
text-muted-foreground for AA contrast on muted bg. Icon-only
(aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
rewritten with cumulative state through today. Items genuinely still
open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
pixel E2E verification, and website-cutover work parked here so
they don't get lost in the CRM audit doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Webhook auto-create on signed Reservation Agreement was gating itself on
isTenanciesModuleEnabled, but autoCreatePendingTenancies never enabled
the module — so the very first tenancy on a fresh port was unreachable
even though the row-exists fallback in isTenanciesModuleEnabled was
designed exactly for this lazy auto-surface case. Drop the gate; the
inserted row now flips the module on automatically via the fallback.
docs/tenancies-design.md §"When disabled" and the P3 PR-table row
updated to reflect the new contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Interest Documents tab on the berth detail page listed deal docs
read-only with only an "Open" link to the interest detail page —
forced reps to navigate away just to see the PDF. Now every row whose
backing PDF exists opens the existing FilePreviewDialog inline.
- Service: listDealDocumentsForBerth now joins files and returns
fileId (COALESCE(signedFileId, fileId) so completed envelopes
prefer the signed PDF), fileName, mimeType. Drafts without a blob
yet still appear, just non-clickable.
- UI: row title area is a button that triggers FilePreviewDialog;
Eye affordance on hover. Falls back to a "no file yet" hint when
the document has no backing blob. "Open" link stays as the
secondary "go to interest" action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
useBerthPatch invalidated ['berths', berthId] (plural+id), but the
berth detail page reads ['berth', berthId] (singular+id). Cache key
never matched, so the PATCH landed in the DB but the visible field
reverted to its pre-edit value on re-render. Realtime invalidation
covered for it via 'berth:updated', but Socket.IO is unavailable
in some dev environments.
Switch to the correct singular key + keep the plural-list invalidation
so list views (BerthList, bulk-edit sheet) also refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the UI half of the bulk-price feature (backend already shipped).
Reps with berths.update_prices can retune pricing without unlocking the
rest of the berth schema, both one-at-a-time and in bulk.
- berth-columns: PriceCell wraps InlineEditableField, gated by
can('berths', 'update_prices'). Click → input → save through
PATCH /api/v1/berths/[id]/price. stopPropagation so row click
doesn't navigate while editing.
- bulk-price-edit-sheet: right-side Sheet listing selected berths from
the React Query cache. Per-row price + currency inputs with dirty-
highlight. "Set all to" + "Adjust by %" shortcuts. Diff-only POST to
/bulk-update-prices reports updated/unchanged/missing. Body is keyed
on the selection so useState initializes fresh per open.
- berth-list: new "Update prices" bulk action gated by the same
permission, sits between Remove tag and Archive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per docs/superpowers/audits/alpha-uat-master.md Bucket 3 #1. When a
yacht is linked to the interest the rep can flip a per-interest toggle
so the berth recommender reads dimensions off the yacht record instead
of the rep-entered desired_* columns.
- Migration 0087 + interests.useYachtDimensions boolean (default false).
- Validator (createInterestSchema) accepts the new field; service insert
+ update paths spread it through automatically.
- berth-recommender.service.loadInterestInput dual-source resolution:
when toggle=true AND yachtId is set AND the yacht has at least one
measurement on file, the recommender uses the yacht's length / width /
draft instead of the desired_* values. Falls back to the desired
columns whenever any precondition fails (no yacht link, toggle off,
or the yacht carries no measurements). Returned InterestInput gains
a `dimensionsSource: 'interest' | 'yacht'` trace field.
- Interest form: under the "Berth size desired" section, when a yacht
is linked, a checkbox surfaces — "Use the linked yacht's dimensions
for the recommender". When checked, the three dimension inputs grey
out (DimensionInput gains a `disabled` prop) so the rep can't
accidentally edit the now-overridden values. Hint text spells out
the fallback behaviour.
Verified: tsc clean, 1493/1493 vitest, migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DashboardReportBuilder grows an optional Cover-page brand picker
surfaced only when can('admin', 'manage_settings') AND the user has
access to >1 port. Pulls ports from PortContext; default option is
"Use active port brand", remaining options are the other ports the
user can reach. Choice persists in config.coverBrandPortId; threaded
through preview, download (/reports/generate), and queue
(/reports/runs) payloads.
- render-report.service.ts: when run.config.coverBrandPortId resolves
to an accessible port, the cover-page logo + portName come from THAT
port's brand kit. Falls back to the source port silently when the
override port is missing or stale. Source-port DATA stays — only the
cover branding swaps. Useful for cross-port leadership decks.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- renewTenancy service:
- permanent / fee_simple / strata_lot → mutate-in-place (startDate
moves forward, endDate may extend or null out)
- fixed_term / seasonal → end the current row at its existing endDate
+ mint a successor with previousTenancyId chain. newEndDate required.
- transferTenancy service: end-and-spawn — end current row at
transferDate, mint fresh active row with transferredFromTenancyId
pointing back. New client + yacht cross-validated against port +
ownership constraint (assertClientOwnsOrRepresentsYacht).
- POST /api/v1/tenancies/[id]/renew + /transfer routes gated on
tenancies.manage + module-enabled.
- TenancyRenewDialog (tenure-aware copy explains in-place vs successor),
TenancyTransferDialog (ClientPicker + YachtPicker with owner-scoped
filter). Both mounted on tenancy-detail.tsx alongside Edit + End.
- Validators: renewTenancySchema + transferTenancySchema in
src/lib/validators/tenancies.ts.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Migration 0086: berth_tenancies.previous_tenancy_id +
transferred_from_tenancy_id self-FKs + partial indexes. Per
docs/tenancies-design.md these chain renewal / transfer successors
to predecessors for fixed-term and seasonal lineage. Schema mirrored
in tenancies.ts with AnyPgColumn typed-import.
- POST /api/v1/tenancies (generic create): accepts berthId in the
body so client + yacht tab entry points don't have to bounce through
/api/v1/berths/[id]/tenancies. Same createPending service helper.
- TenancyCreateDialog: <TenancyCreateDialog open clientId? yachtId?
berthId? /> with all three pickers; pre-fills the carrier from the
parent entity. POSTs to /api/v1/tenancies; "Create" and
"Create and activate" CTAs both wire to the new endpoint.
- Mounted on ClientTenanciesTab + YachtTenanciesTab behind
<PermissionGate resource="tenancies" action="manage"> so reps can
mint tenancies directly from those tabs without bouncing through
the berth page.
- TenancyEditDialog: edit metadata only (start/end dates, tenure type,
notes) via the new action='update' branch on the [id] PATCH route.
Status transitions stay on activate/end/cancel. Wired into the
tenancy detail page header. Outer wrapper unmounts on close so the
form re-initialises from current row data without setState-in-effect.
- updateTenancy service helper + PATCH action='update' branch added.
Audit-logged + emits berth_tenancy:activated to invalidate detail
query caches.
Renew + Transfer dialogs deferred — both need lineage UX decisions
(tenure-aware mutate-in-place vs new-row spawn; client/yacht swap
semantics) and the self-FK columns this commit lands are the
underpinning. Next sub-task.
Verified: tsc clean, 1493/1493 vitest, migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PlacedField gains optional defaultValue + fieldMeta carriers. The
field-placement submit threads fieldMeta verbatim through the FormData
payload (only when populated), where the API route + service +
Documenso client already accepted it (v2 field/create-many honours
fieldMeta per row).
- FieldSidePanel grows a FieldMetaSubPanel that renders per-type
controls in the right rail:
- TEXT — default text, label, required toggle
- NUMBER — format string, min, max, required
- CHECKBOX — multi-select option editor with per-option `checked`
- RADIO — single-select option editor (mutually-exclusive default)
- DROPDOWN — single-select option editor
Each writes shallowly into field.fieldMeta so Documenso v2's
create-many endpoint receives the shape it expects. SIGNATURE /
INITIALS / DATE / EMAIL / NAME render nothing (no per-instance
config today).
- ChoiceMetaEditor extracted as a top-level component so the option
list doesn't recreate its DOM subtree on every keystroke
(react-hooks/static-components rule).
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DashboardReportBuilder gains an optional Subtitle input alongside
Title. Persisted in the config payload sent to /api/v1/reports/runs
+ /api/v1/reports/generate + threaded through the preview payload's
useMemo dep list so live preview reflects the override.
- Cover-page brand picker (admin-only) — deferred. Today the renderer
uses the active port's brand kit; cross-port branding swap needs a
permission gate, port-pick UI, and a renderer override and is queued
for a follow-up. Subtitle alone covers the most common ad-hoc need
(custom cover-page subtext like "Board pack — March 2026").
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- report-render.service.ts: KindRenderer now carries a per-kind toCsv
serializer alongside the PDF renderer. renderReportRun branches on
run.outputFormat — 'pdf' (existing path), 'csv' (new), 'png' (throws
with a clear "deferred" message so the run lands as 'failed' without
a partial blob). Storage path, mime type, filename + extension all
pick up the output-format suffix; the file row mirror records the
matching mime so the standard download surface serves it correctly.
- csvCell / rowsToCsv helpers: RFC-4180 escaping (always double-quoted,
doubles internal quotes, CRLF newlines).
- 4 per-kind serializers:
- dashboard: stage-count + top-interests + meta as 3-col CSV
- clients: activity log rows (id/createdAt/action/entityType/entityId/userId)
- berths: occupancy metrics (totalBerths + occupancyRate + status counts)
- interests: revenue metrics (completed + forecast + per-stage breakdown)
- DashboardReportBuilder + SimpleReportBuilder gain an Output-format
toggle (PDF | CSV). DashboardReportBuilder threads it into the queued-
run POST; SimpleReportBuilder threads it directly. Synchronous PDF
download path (Dashboard "Download PDF" button) stays PDF-only since
/api/v1/reports/generate returns a blob, not a run row.
PNG remains deferred — flagged with a follow-up TODO inside the render
branch + the builder selector deliberately omits PNG so reps don't pick
it and watch a run fail.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P4 — landing + builder:
- /[portSlug]/reports — new landing page with 4 build-kind cards
(dashboard / clients / berths / interests), 3 library cards
(Templates / Runs / Schedules), and the pre-P4 reports list
preserved under "Legacy library" so historical PDFs stay accessible.
- /[portSlug]/reports/[kind] — kind-aware builder route.
- dashboard: refactored the existing export dialog body into
DashboardReportBuilder (page-mounted; same widget grouping +
date-range + SavedTemplatesPicker + preview). New "Queue + go to
Runs" CTA enqueues a report_runs row via /api/v1/reports/runs
(Reports P3 path); "Download PDF" keeps the synchronous /generate
fallback for ad-hoc one-shots.
- clients / berths / interests: SimpleReportBuilder — date-range +
enqueue to /api/v1/reports/runs. Kind-specific filters land
alongside dedicated renderers in P6+.
- Dashboard "Export as PDF" button rewired: no longer opens an
in-dashboard Dialog. Becomes a Link → /reports/dashboard?from=...&to=...
carrying the currently-active range through search params so the
builder pre-fills it. Removes the dialog body (~290 lines) from the
button file; the same UI lives in DashboardReportBuilder.
- ?from=YYYY-MM-DD&to=YYYY-MM-DD search params pass the range into the
builder page.
P5 — sub-pages (functional, backed by P2 CRUD endpoints):
- /reports/runs — paginated table of report_runs with status badges,
auto-polls every 5s while any row is pending/rendering, per-row
Download (file by storageKey) + Re-run actions.
- /reports/templates — saved template grid. Clicking the name links to
the builder with ?templateId=… so it pre-applies.
- /reports/schedules — schedule table with cadence labels (weekly /
monthly / quarterly), next-run timestamps, recipient counts, and a
per-row enable Switch (PATCH /api/v1/reports/schedules/[id]).
Verified: tsc clean, 1493/1493 vitest, dev-server compile clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MUST-FIX:
- src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:70 — the
PUT allowlist still gated `reservations: {view,create,activate,cancel}`.
Stale: would reject valid `tenancies.{view,manage,cancel}` writes and
silently accept ghost `reservations.*` writes that never land. Replaced.
- src/lib/services/alert-rules.ts:68 — `reservation.no_agreement` alert
emitted `entityType: 'reservation'`. Every other tenancy-related
audit/socket/dashboard label is `'berth_tenancy'`. Inconsistent dedupe
+ activity-feed label miss.
- tests/e2e/exhaustive/08-portal.spec.ts:6 — hardcoded /portal/my-reservations
navigates to a 404 every run.
- tests/e2e/exhaustive/03-reservations.spec.ts — entire spec renamed to
03-tenancies.spec.ts; tab + button locators updated to match renamed UI.
SHOULD-FIX (consistency):
- src/components/clients/client-detail.tsx — useRealtimeInvalidation only
caught 3 of the 4 berth_tenancy:* events; added the `:created` listener.
- src/lib/services/client-merge.service.ts — MergeResult.movedRows.reservations
+ snapshot.reservations + local loserReservations / movedReservations
renamed to tenancies / loserTenancies / movedTenancies. No external
consumers grep-confirmed.
- src/lib/services/gdpr-bundle-builder.ts — GdprBundle.reservations field
renamed to .tenancies; user-facing HTML section "Reservations" → "Tenancies";
local reservationRows → tenancyRows.
- 6 UI copy strings: gdpr-export-button, bulk-archive-wizard,
bulk-hard-delete-dialog, hard-delete-dialog, admin-sections-browser ×2,
admin/import/page, won-status-panel — all "reservations" prose updated
to "tenancies" (occupancy-record sense).
- tests/integration/api/tenancies.test.ts — handler import aliases
`createReservationHandler` etc renamed to `createTenancyHandler` etc.
- tests/unit/services/berth-tenancies.test.ts — local helper makeReservation
→ makeTenancyLocal (avoids shadow of the renamed factory).
- scripts/audit-permissions.ts — stale allowlist entry for
/berth-reservations/[id]/route.ts removed (path no longer exists).
- docs/runbooks/permission-audit.md — stale row for same path removed.
- docs/tenancies-design.md — fixed factual error
("tenancies.service.ts" → "berth-tenancies.service.ts").
Verified: tsc clean, 1493/1493 vitest.
Dev-server note: the running `next dev` process started before P2 and
shows Turbopack cached compile errors against the renamed schema files.
Source is correct (./tenancies); restart `next dev` to clear the cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- new report-render.service.ts: renderReportRun(reportRunId) +
emailReportRun(reportRunId). Render path fetches the run row,
advances status to 'rendering', resolves the kind→fetcher+template
pair from REPORT_RENDER_MAP (dashboard→pipeline, clients→activity,
berths→occupancy, interests→revenue), generates the PDF, uploads to
storage, mirrors onto `files` so the standard download/attachment
surfaces serve it, and stamps storageKey + sizeBytes + status='complete'.
Failure path stamps 'failed' + errorMessage + compensating
storage.delete to keep blobs from orphaning. Email path resolves the
schedule's recipients + the rendered file via the standard
resolveAttachments port-isolation check, sends one message per
recipient via the existing sendEmail helper, and stamps emailedAt.
- reports worker (src/lib/queue/workers/reports.ts) gains 3 jobs:
- 'report-schedules-poll': scans report_schedules where enabled=true
AND nextRunAt <= now, mints a report_runs row per due schedule via
createReportRun (triggeredBy='schedule'), advances next_run_at via
nextRunFor() BEFORE enqueue so a downstream failure doesn't pin the
schedule on the same tick, then enqueues report-run-render.
- 'report-run-render': calls renderReportRun + auto-cascades into
report-run-email when the run was schedule-triggered.
- 'report-run-email': calls emailReportRun.
These coexist with the legacy 'report-scheduler' + 'generate-report'
jobs operating on scheduled_reports/generated_reports.
- scheduler.ts registers 'report-schedules-poll' on a 1-minute cron so
the system catches due schedules even when no API event nudges them.
- POST /api/v1/reports/runs now enqueues 'report-run-render' after
createReportRun. Enqueue failures are logged + swallowed so the API
still returns 201; the schedule poll picks pending rows up as a
safety net.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- tenancy-reports.service.ts: 4 read-only query functions backing the
widgets. Heatmap uses a months×areas SQL grid with date-range overlap;
renewals-at-risk filters active tenancies whose end_date is inside a
90d window with NO successor pending/active row already minted on the
same berth; revenue forecast buckets active tenancies by their
end-date quarter; tenure breakdown is a simple GROUP BY status='active'.
- 4 new API routes under /api/v1/dashboard/tenancy-*:
- tenancy-occupancy (heatmap)
- tenancy-renewals (at-risk list)
- tenancy-revenue (forecast)
- tenancy-tenure (breakdown)
Each prepended with assertTenanciesModuleEnabled so a port without
the module gets 404 instead of an empty payload.
- 4 widget components:
- TenancyOccupancyHeatmapWidget — areas × months table with shaded
cells (5-tier emerald ramp by occupancy %)
- TenancyRenewalsAtRiskWidget — top-10 list, 30-day urgency badge
- TenancyRevenueForecastWidget — horizontal bar list by quarter,
currency-formatted totals
- TenancyByTenureTypeWidget — proportional bars, color-coded per
tenure type
- WidgetIntegration union extended with 'tenancies_module'; the
useDashboardIntegrations hook reads it off PortProvider (no extra
fetch). All four widgets register with selfGates=true +
requires='tenancies_module' so the picker AND render path filter
them out when the module is off.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PortProvider exposes tenanciesModuleByPort + a useTenanciesModuleEnabled()
hook that returns the flag for the currently-active port. Synchronous
read off context (server-resolved in the dashboard layout), so no
fetch latency / hydration flicker when the rep flips ports.
- buildBerthTabs / getClientTabs / getYachtTabs gain a
tenanciesModuleEnabled option. When false, the Tenancies tab is
filtered out entirely. When true, it slots into the entity-specific
position (after Interests on berth + yacht; after Companies on client).
- BerthDetail / ClientDetail / YachtDetail pass the hook value through.
Hook call ordered above the early-return so React's rules-of-hooks
stays satisfied. Existing read-only tab content (Active tenancy card
+ History + the berth-side BerthReserveDialog "Create tenancy" CTA
from P2) stays untouched — it just becomes visible when the module
is on.
Deferred (separate ship): generic TenancyCreateDialog that pre-fills
clientId / yachtId from the parent entity context, so client / yacht
tabs can mint a tenancy without bouncing through the berth detail page.
Today client/yacht Tenancies tabs are read-only (the create entry-point
is the berth tab); the generic dialog will land alongside the Edit /
Renew / Transfer / End dialogs (design § P6 sub-tasks).
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dashboard layout resolves tenanciesModuleByPort server-side (one
isTenanciesModuleEnabled call per port the user has access to) and
passes the map through AppShell → Sidebar. Atomic SSR — no
flicker of the nav entry in/out after hydration.
- Sidebar gains NavItemGated.requiresTenanciesModule. The Tenancies
entry (KeyRound icon, immediately below Berths) only renders when
the currently-active port has the flag flipped on. Per-port live
switch fires when the rep toggles ports without reload.
- /[portSlug]/tenancies + /[portSlug]/tenancies/[id] both call
isTenanciesModuleEnabled and notFound() when disabled — guards
against direct URL access even when the sidebar is hidden.
- API routes (/api/v1/tenancies, /[id], /berths/[id]/tenancies)
prepended with assertTenanciesModuleEnabled — matches design §
"All routes ... return 404 when off". NotFoundError maps to 404.
- Existing tenancy API tests get a makePortWithTenancies() helper
(calls enableTenanciesModule after makePort) so the gate is
satisfied. Affects 2 test files (16 tests retargeted).
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>