fb4a09e2ecd2027af6d6096e55091539c616ea78
249 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| fb4a09e2ec |
feat(reminders): Phase 4 partial — schema + service + validators
Migration 0072 — reminders/interests expansion: - interests.reminder_note: optional cadence note for the existing reminderEnabled+reminderDays flow. Surfaces in notification body + inbox row. - reminders.yacht_id (+ FK + relation): fourth entity link so yacht-scoped tasks have a typed home alongside client/interest/berth. - reminders.fired_at: worker idempotency. Partial index idx_reminders_due_unfired drives the scan. Service + validator updates: - createReminderSchema / updateReminderSchema accept yachtId. - assertReminderFksInPort validates yacht ownership against the caller's port — defense-in-depth, same shape as other entity FKs. - createReminder / updateReminder thread yachtId through. Worker scheduler + CreateReminderDialog yachtId UI deferred. The existing reminders/reminder-form.tsx already covers the dialog contract — Phase 4b extends it with yachtId + the per-user digest_time_of_day picker. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 918c23fc0b |
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin
Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 0f99f054b3 |
feat(post-audit): batch A+B quick-wins + audit-side residuals
Bundles the user-prioritised follow-ups from the post-audit punch-list.
Batch A — pipeline + EOI safety:
- §1.1 timeline buildAuditDescription renders diff fields ("leadCategory → hot_lead").
- §4.13 EOI rejection cascade: notification to assigned rep + audit row + rose banner.
- §4.10b finish doc-detail: SigningProgress reuse, linked-entity names (server-resolved),
per-event icons + tooltips + show-more in activity panel.
- §7.2 stage guidance card replaces empty Payments slot pre-reservation.
- §4.15 deal-pulse trigger audit (docs/deal-pulse-trigger-audit.md).
Batch B — UX consistency + docs:
- §1.4 quick log-contact button on interest header.
- §2.1 contact-log compose: Dialog → Sheet.
- §7.1 docs/deal-pulse explainer page; /docs/ in PUBLIC_PATHS.
- DocumentStatus now includes 'rejected' + 'declined' across constants, labels, tone maps.
Audit-side residuals:
- M-NEW-1 /me/ports skips port-context requirement.
- M-AU03 audit log CSV export endpoint + UI button.
- M-IN03 dead receipt-scanner.ts deleted; live path already per-port.
- M-P01 pg_trgm GIN indexes (migration 0071).
- §10.1 webhook tests verified passing (was stale).
Deferred per user direction:
- §11.3 email copy refactor (needs old-CRM reference).
- M-EM03 IMAP bounce-to-interest linking.
Tests: 1374/1374. tsc + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 4b5f85cb7d |
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 98211066a5 |
fix(legacy-stage): purge 9-stage enum keys from rank tables and stale copy
L-001 hunt landed these:
- src/lib/services/clients.service.ts — stageRank used pre-refactor
9-stage names exclusively (`contract_signed`, `deposit_10pct`, …).
Every modern 7-stage interest fell to rank 0, making client-list
"most-progressed deal" sort effectively random. Modern values now
own the canonical ranks; legacy aliases map to their 7-stage
equivalents so historical audit data still sorts.
- src/lib/services/berth-recommender.service.ts — STAGE_ORDER had
the same 9-stage shape. LATE_STAGE_THRESHOLD pointed at the (now
nonexistent) `deposit_10pct` slot. Reworked to the 7-stage scale;
threshold now at `deposit_paid` (5).
- Stale comments referencing `deposit_10pct` in schema (clients,
financial) and client-archive services updated to current copy.
- Smart-archive dialog rendered `i.pipelineStage` as raw enum; now
routes through `stageLabelFor` (the new helper added with A2).
Test fixture updates: berth-recommender.test.ts numeric inputs
re-mapped to the new 7-stage scale (eoi_signed=5 → eoi=3, etc.).
1373/1373 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 0d9208a052 |
fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep
Knocks out 10 of the 13 known issues from yesterday's Playwright audit. A4 — Client form silently rejected submit when a contact row had an empty value. The F19 filter ran in mutationFn after zod's handleSubmit had already short-circuited on min(1). Now wraps the onSubmit to prune empty rows BEFORE handleSubmit/zod sees them. A16 — File upload to documents hub root 400'd because FormData.get returns null for absent fields and zod's .optional() rejects null. Route handler now coerces null/empty → undefined before parse. A17 — Added /api/v1/me/ports endpoint that any authenticated user can hit; client.ts now uses it as the bootstrap port-slug→port-id resolver. Eliminates the wasteful 400s sales-reps and viewers were firing on every page load against the super-admin-gated /admin/ports. A1 — Filter permission_denied actions from the dashboard activity feed. Still in the audit log; just not noise on the dashboard. A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor helpers in lib/constants. Activity-feed maps legacy 9-stage enum values (deposit_10pct, contract_sent, etc.) to their 7-stage labels on the way out, so historical audit rows read as "Deposit Paid" not "Deposit 10Pct". A19 — Same-stage write now returns 204 No Content. Service returns a STAGE_NOOP sentinel; the route handler translates it. A9 — Catch-up wizard now derives stage from berth status (under_offer → EOI, sold → contract) with a stageOverride state for explicit user picks. Avoids the set-state-in-effect rule violation. A20 — OwnerPicker shows a "Client / Company" hint chip on the trigger when no value is set, so users know the trigger opens a two-tab picker instead of just a client list. A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'` to NULL so the column lives at strictly 3 states. A6 — file-preview-dialog gets a screen-reader DialogDescription so the Radix "Missing aria-describedby" warning stops firing on every preview. A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist (Next returns 404); /api/v1/admin/audit exists and 403s. A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate pass — both are dev-only cosmetic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 7d33e73eef |
feat(berths): manual status catch-up wizard + reconciliation queue (#67)
Wires the long-dormant berths.status_override_mode column into a closed
loop so reps can reconcile berths flipped to under_offer/sold without a
backing interest.
Phase 1 — Status source tracking:
- updateBerthStatus() stamps 'manual' on every user-facing write
- berth-rules-engine.ts stamps 'automated' on auto-rule writes
- new clearBerthOverride() helper nulls the field and stamps the
reason "Reconciled via interest <id>" — only the wizard calls it
Phase 2 — Visual indicator:
- Amber "Manual" chip on berth-list rows where statusOverrideMode='manual'
AND no active linked interest (the candidates for catch-up)
Phase 3 — Reconciliation queue:
- new service listManualReconcileBerths() with cross-port-safe
NOT-EXISTS against activeInterestsWhere
- GET /api/v1/berths/reconcile-queue
- new page /[portSlug]/admin/berths/reconcile listing the queue,
each row linking to the catch-up wizard
Phase 4 — Catch-up wizard:
- POST /api/v1/berths/[id]/reconcile orchestrates create-client
(optional quick-create), create-interest with primary berth link,
and clearBerthOverride — composed via existing service helpers
- <CatchUpWizard> dialog: existing-client or quick-create, optional
yacht link, stage picker scoped to the current berth status, with
contract auto-setting outcome=won
Phase 5 — Entry points:
- sidebar Admin > "Reconcile berths" link
- berth-list row action menu shows "Catch up…" on flagged rows
Doc upload + payment recording (spec phases 4.4 / 4.5) are deferred —
once the interest exists, the rep uses the standard interest detail
page surfaces for those follow-ups. The wizard's MVP responsibility is
to take a manual berth to "interest exists, override cleared" in one
round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 84468386d9 |
fix(ux): T4 polish wave — empty-contact filter, redirect-on-create, friendly stage errors
F19: client form drops empty-value contacts on submit; auto-promotes first remaining row to primary if none flagged. F20: new-interest dialog redirects to the detail page on create instead of bouncing back to the list. F21: stage-transition validation errors render with STAGE_LABELS — "Yacht is required before leaving the Enquiry stage." (was "yachtId is required before leaving stage=enquiry"). F22: blocked-stage marker swapped from the ⚑ unicode glyph to a Lucide AlertTriangle with aria-label. F25: documents-hub folder selection moves to ?folder=<id> querystring so deep-link / browser-back / refresh round-trip the current folder. F26: reopen-outcome action now toasts "Outcome cleared — interest is open again." F27: stage PATCH where target === current short-circuits to a no-op return; downstream callers don't see a phantom stage_change audit row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 98fe295675 |
fix: cascade-archive client's open interests — F10
Pre-audit, archiving a client set `clients.archived_at` but left their
in-flight `interests.archived_at = NULL`. Active-interest queries kept
surfacing those interests with a shadowed client — breadcrumbs broke,
detail-page drill-ins silent-404'd, and the dashboard double-counted.
Now `archiveClient()` runs in a transaction:
1. Set archived_at on the client.
2. Cascade-archive every interest where the client is the owner AND
the interest is currently active (archived_at IS NULL AND
outcome IS NULL).
Won/lost/cancelled interests are explicitly NOT touched — those are
historical records of closed business and should stay queryable.
The audit-log entry's newValue carries the list of cascaded interest
IDs so /admin/audit shows exactly which deals got swept up. Socket
`interest:archived` events fire per-id so any open list views invalidate.
Verified live: archived Olivia Sinclair, her active interest archived
too in the same call. 1373/1373 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 025648c40b |
fix(P1): soft-archive berths instead of hard-delete — F5
Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which
permanently dropped the row, cascade-vanished `interest_berths` links,
broke historical audit references, and could 404 the public feed mid-
customer-inquiry. The `berths.archived_at` column existed in the schema
but was never written.
Changes:
- `archiveBerth(id, portId, { reason }, meta)` is the new canonical
soft-archive. Requires a reason (min 5 chars). Blocks when an
active interest still depends on the berth (forces the rep to
resolve the deal first). Audit-logs the old status + reason.
- `restoreBerth(...)` reverses it.
- DELETE route now accepts `{ reason }` and routes to archiveBerth.
- New POST /api/v1/berths/[id]/restore.
- `getBerthOptions` + dashboard occupancy / status-distribution
queries gain `isNull(berths.archivedAt)` so archived moorings
don't show up in pickers or skew metrics.
- Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth
so import sites we haven't migrated still work — labeled @deprecated.
Verified live:
- DELETE w/o reason → 400 (validation)
- DELETE w/ "x" → 400 "Reason must be ≥ 5 characters"
- DELETE w/ proper reason → 204, row archived, reason persisted
- DELETE twice → 409 "Berth is already archived"
- POST /restore → 204, archived_at cleared
Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts,
alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules-
engine.ts. The current set covers the visible surfaces; the rest are
secondary aggregators.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| e469b2b6a6 |
fix(P1): GDPR export + Redis eviction policy
F3: BullMQ 5.x rejects custom job IDs containing `:` (collides with internal Redis-key namespacing). GDPR export crashed with "Custom Id cannot contain :". Switched to dash separator. GDPR Article 15 right-to-access now functional. F4: Redis was configured with `allkeys-lru` eviction in both docker-compose.yml and docker-compose.prod.yml. BullMQ explicitly requires `noeviction` — otherwise queue keys can be evicted under memory pressure and jobs vanish silently. Switched to noeviction with comment pointing at the audit finding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 446342aa69 |
fix: P0 — bootstrap proxy + interest detail Date crash
Two pre-deploy blockers found during click-testing: 1. /api/v1/bootstrap/status returned 401 to anonymous visitors because /api/v1/bootstrap/ was not in proxy.ts's PUBLIC_PATHS allow-list. Fresh VPS deploys couldn't bootstrap their first super-admin via /setup — the page reads bootstrap status to decide whether to render the form and got no signal back. The route handlers self-protect via hasAnySuperAdmin(). 2. getInterestById() crashed every interest detail request with `CONNECT_TIMEOUT` / "string argument must be of type string or Buffer" because the contact-log count query passed a raw Date through a sql template fragment. postgres-js's Bind step can't serialize a Date that way. Switched to drizzle's gte() operator which routes the value through the column-aware serializer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 3c2826635d |
feat(portal-auth): URL fragment for activation/reset tokens
Step 8 per PRE-DEPLOY-PLAN § 1.2.5. Activation + password-reset links now carry the token in the URL fragment (`#token=…`) instead of the query string (`?token=…`). URL fragments are client-side only — the token never hits the server, never lands in proxy logs, never sits in the Referer header, and is invisible to upstream CDN/cache layers. The form still POSTs the token in the request body to authenticate. Changes: - portal-auth.service.ts URL builders for activation + reset switch to `#token=`. Inline comments cite the security rationale. - password-set-form.tsx reads the token via useSyncExternalStore so the SSR snapshot returns `null` and the client snapshot reads window.location.hash post-hydration (no set-state-in-effect Compiler violation). Helper prefers the fragment but falls back to the legacy `?token=` search param for the back-compat TTL window — so links sent before the switchover still work for their remaining lifetime. Component renders a "Loading…" placeholder during the pre-hydration null state. No DB changes; tokens themselves unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 66869c9a90 |
feat(dashboard): berth-heat widget + investor-default surfacing
Step 6 minimal-but-functional per PRE-DEPLOY-PLAN § 1.6. Berth Heat — new widget showing top 15 berths by active interest count via the interest_berths junction (non-primary links included so multi-berth deals warm every berth in their bundle). Investor-friendly demand-pressure view; the ranked-table shape exports cleanly to PDF/ CSV. Future heatmap viz reads the same shape via /api/v1/dashboard/ berth-heat. Defaults flipped for investor-friendliness: - kpi_pipeline_value → defaultVisible (currency-aware headline number). - source_conversion → defaultVisible (conversion funnel by source; reads the inquiry → client linkage from Step 3). - berth_heat → defaultVisible. Pipeline-velocity-over-time + true heatmap viz deferred. pipeline_funnel covers snapshot stage breakdowns; over-time velocity warrants its own design pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 709ef350ff |
feat(bulk-berths): 2-step wizard for new-port setup
Step 5 per PRE-DEPLOY-PLAN § 1.4.13.
Service: bulkAddBerths(portId, inputs, meta) — input-level dedup
catches in-batch duplicates, then a single SELECT against existing
port rows rejects with ConflictError on first collision. All inserts
in one round-trip; audit log + realtime alert.
Validator: bulkAddBerthsSchema with min(1) max(500) per call.
Route: POST /api/v1/berths/bulk-add gated on berths.create.
Wizard UI (/[portSlug]/admin/berths/bulk-add):
Step 1 — dock letter A-E, range start+end mooring numbers, tenure
default. Generates N empty rows.
Step 2 — editable table with per-row dimensions / pontoon / pricing.
"Apply to all" inputs in the header row copy a value down every
row at once (covers the "every row is 40ft × 15ft at €125k" case
in two clicks). Per-row remove button.
Drag-fill deferred. Server-side mooring uniqueness check is canonical;
client-side dedup is a pre-flight courtesy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 4182652d49 |
feat(externally-signed): mark contract/reservation as signed without file
Step 4 second slice. Adds the "Mark as signed without file" action to
contract + reservation tabs per PRE-DEPLOY-PLAN § 1.5.14.
Service: `markExternallySigned(interestId, portId, docType, reason)`
flips the relevant doc-status column ('contract_doc_status' /
'reservation_doc_status' / 'eoi_doc_status') to 'signed', writes an
audit log entry with `metadata.type='externally_signed'` capturing
the optional reason, and fires the appropriate berth-rule trigger
(eoi_signed / contract_signed) so downstream automation (berth
status flips, notifications) treats it identically to a Documenso-
signed completion.
Route: POST /api/v1/interests/[id]/mark-externally-signed gated on
interests.edit. Validates docType against the canonical 3-value enum.
UI: <MarkExternallySignedDialog> AlertDialog with optional reason
textarea + per-docType copy. Wired into EmptyContractState and
EmptyReservationState empty-state buttons. The action sits alongside
"Upload draft for signing" and "Upload paper-signed copy" as a third
option for reps whose canonical paper lives elsewhere.
EOI not yet wired into a UI surface — the eoi flow already has a
full upload pipeline. Service supports it for completeness.
Followup: quick brochure/PDF download buttons + per-user reminder
digest schedule still pending in Step 4 backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| a77b3c670a |
feat(ux): P-4.5 inquiry linkage + docs N+1 parallelization
Step 4 (in progress) — first slice of UX features. P-4.5: inquiry → client linkage now survives the triage conversion. - inquiry-inbox.tsx adds `?create=1` to the redirect so the new-client sheet auto-opens (the existing prefill_* params were already being written but the form never opened). - client-list.tsx reads prefill_name / prefill_email / prefill_phone / prefill_source / prefill_inquiry_id from useSearchParams and passes them to ClientForm via a typed `prefill` prop. - ClientForm hydrates the create-flow initial values from the prefill AND threads `sourceInquiryId` through to the createClient mutation. - createClientSchema accepts `sourceInquiryId`; the existing service spread already passes it to drizzle's insert. Net effect: a website inquiry that gets converted now lands as a client row with `clients.source_inquiry_id` populated. The conversion funnel-by-source chart (Step 6) can attribute the win back to the originating inquiry. Documents tab N+1: `listInflightWorkflowsAggregatedByEntity` previously walked direct + every company + every yacht + every related client sequentially. On a busy client (~25 related entities) this was ~50 sequential round-trips with cumulative latency. Replaced with a single `Promise.all` over the four lookup groups + nested Promise.all over the per-entity queries within each group. Same query count, but wall- clock collapses from "sum of every query" to "max single round-trip" (typically <100ms now vs >1s before). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| e933e32dbd |
feat(schema): berths.archived_at + clients.source_inquiry_id + email_bounces
Step 3 schema additions per PRE-DEPLOY-PLAN § 1.4. berths.archived_at (+ archived_by, archive_reason) — soft-delete column so retired moorings can be hidden from the public feed and admin lists without losing historical interest joins. Partial index `idx_berths_active` on (port_id) WHERE archived_at IS NULL keeps the active-only list path fast. Already wired: - /api/public/berths and /api/public/berths/[mooringNumber] now filter out archived rows. - berths.service.listBerths defaults to active-only with an ?includeArchived=true escape hatch for the archive bin. clients.source_inquiry_id — text column with ON DELETE SET NULL FK to website_submissions(id). Preserves the linkage from a website inquiry to the client that came out of the "Convert to client" triage flow (P-4.5). Drives the conversion-funnel-by-source chart (Step 6). The Drizzle column ships without `.references()` to avoid the cross-file circular import; the FK lives in the migration SQL. email_bounces table — bounce-monitoring storage. The DSN poller worker (forthcoming, depends on this table existing) writes one row per parsed bounce; consumers join via (original_send_type, original_send_id). Three secondary indexes cover the expected access patterns (port + recent bounces; lookup by bounced address; lookup by original send). Schema additions plus the migration SQL are ready for `pnpm db:push` (or the migration runner once its journal is backfilled — separate concern, journal currently stops at 0042 despite migrations through 0065 existing on disk). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| fd2c7d6b12 |
feat(send-dialog): surface per-port attachment threshold in preview UI
Per PRE-DEPLOY-PLAN § 1.3.9. Adds an informational banner to the SendDocumentDialog explaining the size cutoff at which the attachment switches from inline to a 24h signed-link download. Threshold sourced from the existing `email_attach_threshold_mb` setting, plumbed through the previewBody return shape so rep-facing dialogs don't need to call the admin-only sales-config endpoint. Bounce monitoring deferred to land alongside the email_bounces table in Step 3 (schema additions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| d556bb88f7 |
feat(email-routing): per-category send-from routing infra + admin matrix
Per PRE-DEPLOY-PLAN § 1.3.7. Lays the foundation for admin-configurable routing of every outbound email category to either the noreply or sales sender account. Pieces shipped: - `src/lib/services/email-routing.ts` — EmailCategory enum (17 categories covering every shipped surface), DEFAULT_CATEGORY_ROUTING map (auth/notifications/EOI-invite → noreply; brochure/PDF/sales send-outs → sales), `resolveSenderForCategory()` + a graceful fallback to noreply when the resolved sender is sales but creds aren't configured. - `GET / PATCH /api/v1/admin/email/routing` endpoints — gated on `admin.manage_settings`. Returns the routing + sales-availability flag + canonical category list. - `EmailRoutingCard` — matrix UI dropped into /admin/email below the sales-email-config card. Per-category dropdown auto-disables the `sales` option when the port has no sales SMTP creds; explains the state in an amber callout. Save-on-change with toast + "Reset to defaults" button. Setting persisted as `system_settings.email_routing` (JSONB blob). Followup: opportunistic migration of existing dispatchers (sendEmail, createSalesTransporter callers) to use `resolveSenderForCategory()` — the defaults preserve current behavior so this is non-blocking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| bded8b21f1 |
feat(reporting): money-math sweep — Step 1 PRE-DEPLOY-PLAN
Single coherent commit completing § 1.1 (hot-path correctness) plus
§ 1.1.4.5 (multi-berth EOI mooring fix). Numbers users see are now
self-consistent across dashboard / kanban / hot deals / PDF reports.
## Active-interest sweep (canonical predicate everywhere)
Routed every "active interest" filter through `activeInterestsWhere`
(commit
|
|||
| 81d4e64f69 |
refactor(interests): drop pipelineStage='completed' sentinel convention
`outcome` is the canonical terminal-state signal. Pre-2026-05-14
`setInterestOutcome` also forced `pipelineStage='completed'` (a value
outside the 7-stage canon) which:
- broke `safeStage()` (silently coerced to 'enquiry' downstream)
- prevented analytics from answering "what stage was the deal at when
it closed?" because every closed deal looked identical
- forced belt-and-suspenders filters everywhere ('outcome=won' AND
'pipeline_stage=completed') that became redundant after migration 0062
Changes:
- `setInterestOutcome` no longer touches pipelineStage. Deal stays at
whatever stage it was on when the outcome was recorded; outcome is
the terminal signal. Audit log + websocket emit now carry
`stageAtOutcome` instead of the stale `oldStage`.
- `clearInterestOutcome` smarter reopen-stage logic: if current stage
is the legacy 'completed' sentinel (pre-existing rows from before
this commit), default to 'qualified'. Otherwise preserve the stage
the deal was at, so reopening drops the rep back where they were.
Explicit data.reopenStage still wins.
- `/api/v1/admin/dashboard-stats` route reworked: per-stage breakdown
now filters `outcome IS NULL` (only active rows count per stage);
`closedTotal` derives from a new `outcome IS NOT NULL` count query;
`completed30d` switches from `pipelineStage='completed' AND updatedAt`
to `outcome IS NOT NULL AND outcomeAt` (avoids long-closed deals
leaking into the window on unrelated edits).
- `berth-interests-tab.tsx` "active" filter switches from
`pipelineStage !== 'completed'` to `!outcome && !archivedAt` — the
legacy check stopped matching post-refactor.
- Socket event type `interest:outcomeSet` renames `oldStage` →
`stageAtOutcome` with a doc-comment explaining the semantics shift.
PIPELINE_STAGES canon is now the only valid pipeline_stage value range
for newly-set outcomes. Legacy rows still carry 'completed' until they
naturally churn through reopen + re-close, at which point they enter
the new convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 465650957b |
fix(pipeline-refactor): purge stale 9-stage name references
Audit of every '*_sent' / '*_signed' / 'in_communication' / 'details_sent' / 'deposit_10pct' / 'completed' literal under src/ caught four genuinely broken sites that migration 0062 collapsed away but the runtime code never followed through on: 1. alert-rules.ts: `interest.stale` matched 'details_sent' / 'in_communication' / 'eoi_sent' — none of which exist post-migration. The alert never fired. Updated to the new mid-funnel canon (enquiry / qualified / nurturing). 2. berth-recommender.service.ts: TWO copies of the same stage-rank CASE (one for active history, one for fallthrough scoring) referenced the full legacy 8-stage ladder. Every WHEN missed → MAX(...) returned 0 → tier-ladder + heat-score logic collapsed silently. Rebuilt both against the 7-stage canon mirroring getHotDeals. 3. interests.service.ts: clearInterestOutcome reopen default was the dead 'in_communication'. Switched to 'qualified' (closest analog; rep can still override via data.reopenStage). Pre-fix, any reopened deal fell through safeStage() to 'enquiry'. 4. report-generators.ts: revenue-PDF "total completed" filter intersected pipeline_stage='completed' AND outcome='won'. The stage filter is redundant today (setInterestOutcome always writes 'completed' for terminal outcomes) and is brittle to the upcoming sentinel-stage cleanup. Dropped the stage filter — outcome='won' is the canonical money-changed-hands signal. Follow-up flagged: setInterestOutcome still writes pipeline_stage = 'completed' as a sentinel, which is non-canonical under the new 7-stage type (PIPELINE_STAGES doesn't include 'completed'). Migration 0062's intent is `outcome` carries terminal state forward; pipeline_stage stays in-canon. Cleaning up requires sweeping every consumer of pipeline_stage='completed' as a terminal marker — separate commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| b966d8106d |
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 233129f91a |
feat(qualification-criteria): dnd reordering with whole-list PATCH
The chevron up/down buttons rewrote a single row's display_order, which didn't actually swap positions since the neighbouring rows kept their original orders. Replaced with a proper drag-handle (dnd-kit sortable, matching the waiting-list-manager pattern) backed by a new POST /admin/qualification-criteria/reorder endpoint that rewrites display_order = index for every row in a transaction. The service rejects partial / extraneous id lists so a stale UI can't silently drop a criterion. Optimistic local-cache update keeps the row in position during the round-trip; rollback on error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 6b28459c45 |
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.
Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
three doc-status columns, two documenso-id columns, and
date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
interest_qualifications (per-interest state), payments (deposit /
balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
the new stage + doc-status + outcome shape.
Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).
v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
the contact-log compose dialog (useVoiceTranscription hook).
- C: berth-rules-engine wraps state writes in pg_advisory_xact_lock
with an idempotent re-read; emits rule_evaluated audit traces.
- D: Documenso webhook: reservation/contract sub-status stamping
moved out of the PDF-download try-block so a download failure
no longer swallows the stamp. New integration test coverage.
- E: /admin/qualification-criteria CRUD page + admin component.
- F: default_new_interest_owner exposed in System Settings.
- G: recentActivityCount + active_engagement deal-pulse signal
surfaced as a chip on interests + hot-deals card.
- H: interest_assigned notification on assignedTo change (skips
self-assign, uses a dedupe key).
Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.
Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| b10bf9bf8e |
fix(bootstrap): include missing bootstrap.service helper
The route handlers in
|
|||
| 0fe3e984d1 |
feat(supplemental-info): pre-EOI public form flow
Lets a sales rep send a client a one-shot link to fill out the information we need before drafting the EOI (intent, dimensions, signatory, timeline). Token-keyed: single-use, soft-expiring, scoped to one interest + client. Public POST endpoint accepts the form submission; CRM endpoint mints tokens for rep-initiated requests; portal page renders the form for the recipient. Schema: supplemental_form_tokens table (migration 0061) with port_id + interest_id + client_id refs, unique token, consumed_at marker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| adebd5f91d |
feat(documenso-phase-6): activity badges + per-document invitation message
Two of the six Phase 6 polish items shipped in one commit because they
share the data + plumbing path (per-doc message uses the signing-
progress UI's existing layout).
1) Signing-progress activity badges
- Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all
populated by Phase 1+2 webhook handlers) per signer in the
existing progress widget. Each badge renders as
"Invited 2 hours ago / Opened yesterday / Reminded 3 days ago"
via Intl.RelativeTimeFormat.
- Resend button: was silent on success/failure; now uses
useMutation + toast so the rep sees whether the reminder fired
or fell into a cadence cooldown. Honours the existing
sendReminderIfAllowed return shape (`{sent, reason}`).
- Title-tooltips on each badge show the exact ISO timestamp.
2) Per-document custom invitation message
- New `documents.invitation_message` column (migration 0060;
applied via psql per the dev-flow note in CLAUDE.md).
- Textarea in UploadForSigningDialog step 2 (recipient configurator),
1000-char cap, placeholder text shows the expected tone.
- custom-document-upload.service accepts `invitationMessage`,
trims + stores on the documents row.
- sendCascadingInviteForNextSigner now reads
doc.invitationMessage and passes as customMessage so every
cascaded recipient (developer / approver / witness) sees the
same note — not just the first signer.
- send-invitation route (manual resend path) reads the same
column → customMessage so manual reminders match.
- The email template's existing customMessage rendering does
the XSS escape; no other plumbing needed.
Phase 6 items still deferred (each ~2-3h, mostly independent):
- Auto-send delay (`eoi_send_delay_minutes` setting + scheduled
BullMQ job — needs a scheduler hook).
- Document expiration (`documents.expires_at` + Documenso
`expiresAt` passthrough — needs Documenso v2 endpoint shape
verification).
- Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs
an admin page with Replay button).
Tests: 1340 → 1350 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 4d1fbcd469 |
feat(documenso-phase-5): pin transformSigningUrl + document website-side coordination
Phase 5 is mostly coordination + verification rather than a code build — the embedded signing pages live in a different repo. What lands here: 1. transformSigningUrl hardening — routes through extractSigningToken so a bare URL like `https://sig.example.com` no longer produces the malformed `<host>/sign/<role>/sig.example.com`. The token validator (≥8 URL-safe chars) rejects malformed tails so the function falls back to returning the raw URL. 2. 10 unit tests pin the role-segment mapping so a future refactor can't silently break the contract with the marketing website's /sign/[type]/[token] page. Covers: - all five SignerRole → URL segment mappings - trailing-slash normalization on the host - null host fallback (single-tenant / staging) - rejection of non-token-shaped tails 3. docs/documenso-integration-audit.md updated with: - Phase 2/3/4/7 landed-work summary (replacing the old "deferred" list that was now stale) - Phase 5 coordination tracker for the marketing-website side (the four edits the website team needs to make — listed here so the CRM stays the source of truth on the contract) - Phase 6 polish backlog (auto-send delay, document expiration, per-document message, reminder display, failed-webhook UI, field metadata panel, zoom controls, recipient drag-reorder) Tests: 21 new transformSigningUrl + signers tests across two files; full suite 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| b1dfec09a0 |
feat(documenso-phase-7): Project Director RBAC binding
Admin UI binding for the developer + approver user-id fields that
Phase 1 schema'd but left unwired. Surfaces four new fields in the
Documenso settings card so admins can:
- Set per-port display labels for the developer/approver slots
(documenso_developer_label / approver_label) — drives email
subjects + signer-progress UI copy. Defaults to "Developer" /
"Approver" when blank.
- Link each slot to a CRM user (documenso_developer_user_id /
approver_user_id) — UUID from /admin/users.
Webhook side-effect:
- handleRecipientSigned's cascade now fires an in-CRM notification
for the next pending signer when their signerRole matches a
configured developer_user_id / approver_user_id. The branded
email is the primary channel; the notification is a defense-in-
depth nudge for users who live in the CRM all day.
- New notification type `document_signing_your_turn` with dedupeKey
`document:<id>:your-turn:<signerId>` so duplicate webhook
deliveries don't re-notify.
- Falls back silently when the binding isn't set or the signer
isn't a developer/approver — preserves the existing flow.
Out of scope (build plan flags as out-of-scope for v1):
- Auto-fill name/email when a user is selected: needs a typeahead
field type the SettingsFormCard doesn't have yet. Admin reads the
user's UUID from /admin/users and pastes; minor friction for a
one-time per-port config.
- Webhook handler reading the linked user's email and matching
against the inbound recipient: today the developer/approver email
settings already drive the matching; the user-id is purely a
notification target.
Tests: 1340/1340 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 7bf587de90 |
feat(documenso-phase-4): recipient configurator + field placement UI
Phase 4 lands the visual half of the Documenso build — the upload-
for-signing dialog the Contract + Reservation tabs hand off to. Four
files of new code; the existing tab placeholders point at it.
Files added:
- lib/services/document-field-detector.ts — Phase 4c auto-detect
scanner. Uses pdfjs-dist to extract per-page text + positions, then
matches anchor patterns (Signature, Date, Initials, Email, Name,
underscore-runs) and produces percent-coordinate DetectedField
rows. Recipient label inference walks ±100pt of each match for
Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the
PDF is image-only; UI falls back to manual placement without an
error. 6 unit tests pin the matching + coordinate math.
- app/api/v1/documents/auto-detect-fields/route.ts — multipart POST
endpoint that delegates to detectFields(). Permission-gated by
documents.send_for_signing.
- app/api/v1/documents/signing-defaults/route.ts — GET endpoint that
surfaces just the per-port developer + approver display name/email
+ sendMode flag. No secrets exposed; lets the dialog prefill the
recipient configurator without an admin-scoped settings read.
- components/documents/upload-for-signing-dialog.tsx — the Phase 4
UI. Three-step state machine inside a single Dialog:
1. select-file: drop/click PDF picker + title input
2. configure-recipients: client + developer + approver prefilled,
rep can add/remove/reorder + change role (SIGNER/APPROVER/CC)
3. place-fields: react-pdf renders the source PDF; auto-detect
runs in the background on file load and seeds the overlay;
rep places, drags, resizes, deletes, reassigns fields via the
palette + side panel. Native DOM drag (no dnd-kit dependency
added — the coordinate math stays obvious).
Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3
service); success toast reflects port sendMode (auto fires the
invite immediately, manual leaves it for the rep).
Files modified:
- components/interests/interest-contract-tab.tsx + reservation-tab.tsx:
swap the ComingSoonDialog placeholder for the real
UploadForSigningDialog with the matching documentType prop. The
placeholder ComingSoonDialog helper is deleted from both.
- scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp
staged-only tsconfig so side-effect CSS imports (e.g.
react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing
declare-module shim. Without this fix the staged compile reports
TS2882 even though the full tsc --noEmit pass passes.
Design choices noted in code comments:
- Native drag over dnd-kit: the field overlay's percent-based
coordinate math is short enough that adding a drag library adds
complexity without saving lines.
- Auto-detect on file-load (not on demand): runs immediately so the
rep doesn't have to click a second button — empty result drops
back to manual placement silently.
- Per-recipient color swatches indexed by signingOrder.
- Recipient seed via useMemo + user-event handler instead of
useEffect → setRecipients (Wave 3 set-state-in-effect avoidance).
Server-side, Phase 3 plumbing handles the rest: tenant guard, magic-
byte verify, Documenso round-trip with per-port v1/v2 routing,
recipient signingToken capture for Phase 2 webhook cascade, auto-
send when port.sendMode === 'auto'.
Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean.
Deferred polish (Phase 6):
- Per-field metadata side panel for DROPDOWN/RADIO option lists
- Pinch-zoom + zoom-out controls on the field-placement canvas
- Recipient drag-reorder via dnd-kit
- Required toggle per field
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 33d0426911 |
feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.
Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
full PDF → Documenso → local-state-update flow:
1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
same posture as berth-pdf + brochures).
2. Stores the source PDF via getStorageBackend(), works on s3 +
filesystem backends. Auto-files into the client's entity folder
when resolvable.
3. Inserts the documents row (status=draft → sent), with the file
FK + interest link + clientId snapshot.
4. Documenso round-trip via createDocument → sendDocument →
placeFields. Per-port apiVersion drives v1 vs v2 (existing
client handles both — v1: /api/v1/documents; v2: envelope/create
multipart). meta.signingOrder + redirectUrl flow through.
5. Captures recipient signingUrl + token into document_signers so
the Phase 2 cascade picks them up.
6. Auto-send first invitation when port.eoi_send_mode === 'auto';
stamps invitedAt to suppress duplicate cascades.
7. Advances pipeline stage to contract_sent.
- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
size (≤50MB), all 11 Documenso field types. Permission-gated by
documents.send_for_signing + interests.edit (matches the
external-eoi precedent — the auto-advance side-effect is
interest-mutating).
Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.
Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.
The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.
Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.
Tests: 1326 → 1334 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 3dc4c6ff14 |
feat(documenso-phase-2): webhook handler enhancement — cascade + completion fan-out
Closes the silence after the first signing invitation. Three real improvements on top of the existing webhook plumbing, all aligned with the Documenso v1.32 + v2 webhook payload shape (verified against the official OpenAPI spec + Context7 docs): 1. Cascading "your turn" emails — when DOCUMENT_SIGNED / DOCUMENT_ RECIPIENT_COMPLETED / RECIPIENT_SIGNED fires for a recipient, handleRecipientSigned now resolves the next pending signer in signing order and sends them the branded sendSigningInvitation() email with the embedded-host-wrapped URL. Stamps invitedAt so a duplicate webhook retry doesn't re-send. 2. On-completion PDF distribution — handleDocumentCompleted now re- reads the just-committed signedFileId, resolves all signers, and fires sendSigningCompleted() to every recipient with the signed PDF attached. resolveAttachments in lib/email already pulls bytes through getStorageBackend() so this works under both the s3/minio and filesystem backends without changes. Failures fall through to logger.error rather than throwing — the document is already marked completed and the admin can re-trigger manually. 3. Token-based recipient matching — Documenso v1 + v2 webhook recipients carry a `token` field (per the OpenAPI spec); same token appears in the document-create response. Captured at send time into the existing document_signers.signing_token column (already in schema from Phase 1) and used by handleRecipientSigned + handleDocumentOpened before falling back to email match. Robust against the case where one email serves multiple roles on a contract — which is the documented gap in the legacy nocodb-based handler. Supporting changes: - New helper module lib/services/documenso-signers.ts with extractSigningToken() (URL-tail fallback), DOC_TYPE_LABEL map, and nextPendingSigner() picker. 11 unit tests cover the token-regex, the helper picks the lowest pending signing-order, and rejects declined/signed correctly. - documenso-client normalizeDocument now reads `token` from both `recipients[]` and the legacy capital-R `Recipient[]` array Documenso v1.32 sometimes ships in webhooks. - documents.service signer-update at send time prefers the explicit token field, falling back to extractSigningToken(signingUrl) for any v2 deployment whose distribute response omits it. Out of scope for Phase 2 (per the build plan): - Custom-doc upload-to-Documenso path (Phase 3) - Recipient + field-placement UI (Phase 4) - DNS-rebinding hardening + circuit-breaker (deferred-refactor list) - Auto-reminder cron — manual "Send reminder" button + auto-reminder toggle remain manual until Phase 6 polish Tests: 1315/1315 vitest ✅ + 11 new tests for documenso-signers ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| ebdd8408bf |
fix(audit-wave-11): dossier sweep — error-ux + webhook + storage + search + maintainability
Final pass over the unaddressed AUDIT-2026-05-12 dossiers, taking the
tractable Critical/High items from each:
error-ux-auditor (5 items)
- C2: 17 toast.error(err.message) sites swept to toastError(err, …) so
every user-visible failure carries a copy-paste Reference ID
- C3: apiFetch synthesizes a client-side correlation id when a 5xx
comes back with a non-JSON body (reverse-proxy HTML pages); message
becomes "The server is unreachable. Please try again." with code
UPSTREAM_UNREACHABLE
- C4: checkRateLimit fails OPEN when Redis is unavailable so an outage
no longer 500s login + portal sign-in; logged at warn so monitoring
catches it
- H2: StorageTimeoutError (name='TimeoutError') replaces the plain
Error throw in s3.ts withTimeout — error-classifier hints fire now
- H5: errorResponse() adopted across /api/storage/[token],
/api/public/website-inquiries, and the Documenso webhook body (drops
the "Invalid secret" reconnaissance string)
outbound-webhook-auditor (5 items)
- C1: signature is now HMAC(secret, `${ts}.${body}`) with the
timestamp surfaced as X-Webhook-Timestamp so receivers can reject
replays outside a freshness window
- C3: dead-letter with reason missing_signing_secret when secret is
null (defence-in-depth against DB tampering / future migration
mistakes)
- H2: webhooks queue bumped to maxAttempts=8 with 30 s base
exponential backoff so a 30 s receiver blip during a deploy no
longer dead-letters every in-flight event; per-queue
backoffDelayMs added to QUEUE_CONFIGS
- M1: SSRF denylist gains Oracle Cloud metadata 192.0.0.192
- M2: dispatch-time https:// assertion before fetch, so a bad DB edit
can't slip plaintext through
storage-pathing-auditor (2 items)
- H1: berth-PDF presigned-upload keys now `${portSlug}/berths/…/…`
with portSlug threaded into backend.presignUpload — engages the
filesystem-proxy port-binding `p` token verifier
- H2: presignDownloadUrl auto-derives portSlug from the key's first
segment when callers don't pass it, so all 8 download sites engage
the `p`-token guard without per-site plumbing
search-auditor (1 item)
- H3: removed dead void wantEmail; void wantPhone; pair plus the
unused looksLikeEmail helper — the bucket-reorder it was scaffolded
for was never wired
maintainability-auditor (1 item)
- M2: swept seven abandoned `void <symbol>` markers and their dead
imports across clients/bulk, interests/bulk, admin/email-templates,
admin/website-submissions, alert-rules, and notes.service
Deferred to future work (substantial refactors, schema migrations, or
multi-file UI work):
- error-ux M3-M8 (global-error.tsx, per-route loading.tsx coverage,
ErrorBanner component, /api/ready route, worker DLQ admin surface)
- maintainability C1-C4 (documents/search/notes service splits,
interest-tabs split — multi-hour refactors)
- currency C1-H5 (mixed-currency dashboard aggregation, FX history
table, rounding policy) — wait for second non-USD port
- outbound-webhook C2 (deliveries reaper job), H1 (DNS-rebind TOCTOU
with undici Agent), H3 (circuit-breaker), H5 (presigned-post-policy)
- storage-pathing C2 (orphan reaper), H3-H5 (streaming + content-type
binding)
Tests: 1315/1315 vitest ✅ ; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| b4e502fedd |
fix(audit-wave-11): BullMQ jobId plumbing for natural dedup
concurrency-auditor C-2: every queue.add(...) site previously enqueued without a stable jobId, so a double-dispatch (webhook retry, double- click on Send, scheduler tick collision) would create two queue jobs and the downstream worker would deliver twice. BullMQ rejects a duplicate jobId while the original is still queued or active, so a stable per-entity key gives at-most-once semantics naturally. Added jobIds across all 10 enqueue sites: - email send-invoice → `send-invoice:<invoiceId>` - notifications invoice-overdue-notify → keyed per UTC day so dupes collapse intra-day but tomorrow's run can re-notify if unpaid - export gdpr-export → keyed on the exportId (unique per request) - webhooks deliver (3 sites: dispatch, retry, test) → keyed on the webhook_deliveries row UUID - maintenance expense-dedup-scan → keyed on expenseId - notifications send-notification-email → keyed on notification id - email send-inquiry-confirmation → keyed on interestId (1 per submission) - email send-inquiry-sales-notification → keyed on interestId+email (1 per recipient per submission) - reports generate-report → keyed on the generated_reports row id Pure refactor — no UX impact. Closes the BullMQ dedup gap that was the second half of the concurrency-auditor's CRITICAL-tier findings. Test fixture update: gdpr-export integration test now asserts the jobId option on the queue.add call. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 2496911dc4 |
fix(audit-wave-11): asset hygiene + datetime correctness
**asset-auditor C1+C2+H1+H3 — image normalization**
Add `src/lib/services/image-normalize.ts` and wire it into
`uploadFile()` so every accepted image is re-encoded via sharp before
hitting storage:
- Strips EXIF (GPS coords, device serial, photographer) so uploaded
photos don't leak per-pixel PII to anyone with a download URL (C1).
- Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})`
so a 30000×30000 palette PNG can't decompression-bomb a downstream
sharp decode (C2).
- Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat
the prefix-only magic-byte check) (H1).
- Freezes animated GIFs to first frame (H3).
Avatar route already funnels through uploadFile so it's covered by
the single change.
**asset-auditor M2 — sanitizeFilename strips RTL/zero-width**
Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069)
+ zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`.
Closes the classic Windows-icon-spoof vector
(`invoice_fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing
collision spoofs.
**datetime-auditor C1 — reminder dueAt drift on every save**
The `<input type="datetime-local">` round-trip in reminder-form.tsx
used `iso.slice(0,16)` (load) and `new Date(value).toISOString()`
(submit). The slice drops the `Z` so a UTC instant is mis-interpreted
as local on load, then converted back to UTC on save — every save
of an existing Warsaw reminder drifted backwards by 2h (CEST). After
two saves the reminder appears at 06:00 instead of 10:00.
Add `toLocalDatetimeLocal(d: Date)` helper that builds the local
YYYY-MM-DDTHH:MM string from getter methods so the round-trip is
TZ-safe. snooze-dialog already did this correctly; the contact-log
dialog also uses the correct localIsoString pattern.
**datetime-auditor C2 — BullMQ cron in UTC, not port-local**
`upsertJobScheduler` defaulted `tz` to UTC. Patterns like
`0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter
/ 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`.
Sub-hourly / hourly patterns are TZ-invariant and stay UTC.
**datetime-auditor C3 — report-scheduler never advanced next_run_at**
The minutely scheduler selected `nextRunAt <= now()` and enqueued
generate-report — but never bumped nextRunAt. For weekly/monthly
reports this meant the job re-fired every single minute until a
human zeroed the row out, flooding recipients with dupes.
Now uses `cron-parser` (added as a dep) to compute the next fire
from `report.schedule` and UPDATEs the row BEFORE the enqueue.
Malformed cron expressions disable the row instead of re-attempting
every minute.
Tests 1315/1315. Migration 0058 applied via psql.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 72237a0191 |
fix(audit-wave-11): authz hardening — caller-superset on role assign
authz-auditor C-1 second half: while the permission-overrides PUT route already enforces caller-superset (prior wave), the `updateUser` role-reassignment path didn't. A port admin holding only \`admin.manage_users\` could PATCH a peer's roleId to a sales-director- equivalent and have the colleague execute permissions the granter didn't hold. \`updateUser\` now takes optional `callerPermissions` + `callerIsSuperAdmin` parameters and, when both are supplied (every interactive admin route), walks the new role's effective permission tree and refuses any \`true\` leaf the caller doesn't already hold. Super admins bypass by definition. Wired \`ctx.permissions\` + \`ctx.isSuperAdmin\` through the single caller (`/api/v1/admin/users/[id]` PATCH). Legacy callers that omit the args (none currently) would silently skip the check; if any future system job calls \`updateUser\` it should pass `callerPermissions=ctx.permissions` explicitly. Other authz items confirmed resolved by earlier work or by-design: - C-1 (permission-overrides PUT): caller-superset already shipped in an earlier wave; verified by reading the route. - H-1 (alerts GET ungated): already gated on \`admin.view_audit_log\` per the auditor's tier-4 recommendation. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| b2c8ed2ff1 |
fix(audit-wave-11): auth-flow hardening (auth-flow-auditor)
Address the two CRITICAL items from auth-flow-auditor plus the
high-impact M10 open-redirect.
**C1 — Password reset doesn't revoke existing sessions**
CRM side: Better Auth has a built-in
`emailAndPassword.revokeSessionsOnPasswordReset` flag — flip it on.
Verified by reading password.mjs in node_modules/better-auth: this
calls `internalAdapter.deleteSessions(userId)` after the password
update commits. One-line fix, closes the canonical session-bumping
gap on the CRM forgot-password flow.
Portal side: the portal uses JWT sessions (not DB-side rows) so
there's no `deleteSessions` to call. Add a per-user
`password_changed_at` watermark column on `portal_users` and have
`verifyPortalToken` reject any token whose `iat` predates the
watermark. Updated on `resetPassword`, `changePortalPassword`, and
`activateAccount` so every password mutation revokes outstanding
cookies. Token shape gains a required `portalUserId` claim so the
verify step can do the watermark lookup without an email-based join;
legacy tokens (pre-Wave-11) lack it and are rejected → forces one
re-login per portal user post-deploy (24h max delay since portal
tokens already self-expire at 24h).
Migration `0058_portal_password_revocation.sql` stamps existing
rows to `now()` so no current session is invalidated by the schema
change itself.
**M10 — Portal login `?next=` open redirect**
`portal/login/page.tsx` did `router.replace(next as never)` against
unvalidated `searchParams.get('next')`. An attacker could send a
victim to `/portal/login?next=https://evil.example` and the post-sign-in
redirect would navigate cross-site. Add `safeNextPath()` that requires
`/portal/...` prefix and rejects protocol-relative URLs; everything
else falls back to `/portal/dashboard`.
**Other auth-flow items confirmed resolved by earlier waves:**
- H6 resolve-identifier enumeration: endpoint deleted in Wave 1
(replaced with sign-in-by-identifier which keeps the synthetic
email behind a server-side proxy)
Tests updated: portal-auth integration test mocks `db` so the new
DB-watermark lookup in `verifyPortalToken` stays unit-pure.
Tests 1315/1315 after `psql ALTER TABLE` to apply migration locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| ecf49be18c |
fix(audit-wave-10): concurrency hardening (concurrency-auditor)
Close the CRITICAL + HIGH-tractable race conditions the concurrency-auditor flagged. The wide-impact items (BullMQ jobId plumbing — C-2; webhook outbound retry idempotency keys; etc.) span too many call sites for a single contained wave and stay deferred. **C-1 — handleDocumentCompleted concurrent-retry orphan-blob** Wave 1 fixed the compensating-delete on single-process failure but the idempotency gate at line 1110 reads `doc.status` outside any row lock. Two webhook deliveries arriving in parallel both pass the gate, both storage.put + db.insert(files), and the losing files row orphans its blob since documents.signed_file_id only points at one. Now the transaction at line 1176 SELECTs the document `FOR UPDATE` and re-checks the gate; if a concurrent worker already completed, throws a sentinel `DocumentAlreadyCompletedError` which the outer catch recognizes and runs the compensating storage.delete at info level (not error). Net effect: at-most-once signed-PDF persistence even under Documenso 5xx-then-retry storms. **H-1 — moveFolder cycle check race** Two concurrent folder moves (A → B and B → A) in READ COMMITTED can each pass the cycle check against pre-state and both commit, leaving A↔B in the tree. Add a per-port `pg_advisory_xact_lock` at the top of the move transaction so the walk-and-write is atomic per port. Lock auto-releases on tx end; no impact on cross-port folder ops. **H-3 — upsertInterestBerth 23505 → generic 500** Two concurrent `setPrimaryBerth` calls hit `idx_interest_berths_one_primary` and the loser surfaced as a generic 500. Catch the 23505 + constraint name and remap to ConflictError so the UI gets a "Another rep changed the primary berth at the same time. Refresh and try again." toast. **M-2 — username uniqueness 23505 → generic 500** Same TOCTOU shape: pre-check at me/route.ts:132 says "available", the UPDATE then fails at the partial unique index. Catch 23505 + `idx_user_profiles_username_unique` and remap to ConflictError. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| f183f58b0c |
fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson
Address the CRITICAL + high-leverage HIGH items from the types-auditor:
**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.
**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.
**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.
**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each
document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| a8dec0bada |
fix(audit-wave-9): onboarding + first-run UX fixes (onboarding-auditor)
Address the CRITICAL and high-leverage HIGH items from the onboarding-auditor report: **C1 — checklist auto-checks were reading the wrong setting keys** A port that had actually been configured still showed three steps as incomplete, permanently capping the checklist at < 70 %. - email step: `sales_email_smtp_host` → `smtp_host_override` (the key the email admin page actually persists). - documenso step: `documenso_api_url` → compound gate `documenso_api_url_override` + `documenso_developer_email` + `documenso_approver_email` + `documenso_eoi_template_id`. All four are required for `buildDocumensoPayload` not to error out; checking only the URL falsely greenlit the step until a rep tried to send an EOI and Documenso 404'd. - settings step: `recommender_top_n_default` → `heat_weight_recency`. The defaults are layered (port > global > built-in), so a port using the built-ins never writes the `top_n_default` row — old key was an unreachable green. heat_weight_recency genuinely means "admin tuned the recommender". **C2 — forms step href was broken** `STEPS[8].href = '../'` resolved through the Link template to the dashboard, not `/admin/forms`. Fixed to `'forms'`. **C3 — EOI signer-identity gate** Folded into the new compound-gate logic on the documenso step (see C1). Now matches what the EOI pipeline actually requires before it can send. **C4 — ensureSystemRoots failure mode poisoned port creation** `ports.service.createPort` awaited `ensureSystemRoots` after the port row had committed, so a throw bubbled out as a 500 even though the inline comment said "non-fatal if this throws". Wrap in try/catch + logger.warn — the row stays live, the next admin action self-heals via `ensureEntityFolder`, and the operator doesn't retry into a 409. **H5 — berth-list empty-state copy misleads fresh ports** "Berths are imported from external sources. Adjust your filters..." implied data existed but was hidden. Branch on whether any filter is active: with none, suggest running `import-berths-from-nocodb.ts`; with filters, the original "adjust filters" message. **M4 — admin-sections-browser description was wrong** "Setup checklist for fresh ports (read-only references)" implied the page was read-only when it has working manual-completion checkboxes and discouraged clicking in. Reworded. Additionally, the OnboardingStep type gains an optional `autoCheckSettingKeysAll` field for compound gates (used by the documenso step), and the auto-detected hint shows all keys when the gate is compound. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| eab30c194a |
fix(audit-wave-9): PDF correctness + brand asset hardening (pdf-auditor)
Address the pdf-auditor findings that survived the 2026-05-12 PDF stack overhaul (pdfme → react-pdf). Items C-2/C-3 (tiptap-to-pdfme bugs) were resolved when that 571-LOC bridge was deleted; remaining items: - **M-7 wrong-port brand fallback** — replace `'Port Nimara'` defaults in PDF-rendering services. `reports.service` and `expense-export` throw when the port row is missing (the job is FK-keyed on a real port, so absence = broken state, must not stamp a competitor brand). `record-export` uses `'(port)'` as the visible placeholder. - **M-2 silent field drift in fill-eoi-form** — promote the always-silent catch in `setText` / `setCheckbox` to log a structured warning per missing field (mirroring the existing `setBerthRange` pattern). A re-cut template with drifted AcroForm field names now surfaces in ops logs instead of shipping with empty values. - **M-3 form not flattened** — `fillEoiFormFields` now flattens the AcroForm before save. Documenso pathway flattens server-side; this brings the in-app pathway to parity, so the signer can't edit pre-filled yacht dimensions / address / berth number after the fact. - **M-1 PDF metadata** — set Title / Author / Subject / Lang / Producer / Creator on the generated EOI PDF for downstream readers and a11y tooling. - **M-4 noisy berth-range warnings** — downgrade per-mooring warn to debug; emit a single summary warn per call when any passthrough occurred. Multi-berth EOIs with archived/legacy moorings no longer spam the log on every render. - **M-6 source PDF sha pinning** — pin `assets/eoi-template.pdf` sha256 via `EXPECTED_EOI_SHA256` (exported for tests); `loadEoiTemplatePdf` warns once per process when the bytes drift without an explicit hash bump. Documented the intentional-update workflow in `assets/README.md`. Tests updated in `tests/unit/pdf/fill-eoi-form.test.ts` to reflect flatten + metadata (form fields are gone after flatten; pdf-lib has no getLanguage so we assert the other setters round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 4233aa3ac3 |
fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to Sheet side=right so every detail-preview surface uses the same primitive. Document the doctrine: Sheet for side panels on both desktop and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX (currently just MoreSheet). Closes ui/ux M11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| bb9b5bb1a3 |
fix(audit-wave-1): orphan-blob window in handleDocumentCompleted
Closes Wave 1.3 (CRITICAL). The previous storage.put → files.insert
→ documents.update sequence had two real failure modes:
1. **Orphan blob.** If storage.put succeeded but the files.insert or
documents.update failed, the blob lived forever in MinIO with no
DB pointer. Re-runs re-uploaded a new blob without cleaning up
the previous one.
2. **Zombie completed state.** The catch block at the end ran
`documents.update({status: 'completed'})` with NO signedFileId
on any failure path. The idempotency early-return at the top
requires BOTH status='completed' AND signedFileId, so retries
*did* still re-attempt — but reps saw a "completed" document
with no signed file, hiding the failure.
Fix:
- Track `putStoragePath` outside the try. After storage.put lands,
the variable holds the path; cleared once the DB commit succeeds.
- files.insert + documents.update + reservation contract mirror all
run in a single `db.transaction(...)`. Atomic commit-or-rollback.
- Catch block: compensating `storage.delete(putStoragePath)` if the
DB commit didn't land. Logs at error level on compensating-delete
failure so a human can clean up.
- Catch block no longer sets `status='completed'`. The doc stays
in its prior state; Documenso's retry (or our poll-worker) re-
attempts the full sequence safely thanks to the unchanged
idempotency gate.
Verified: tsc clean, documents-completion-auto-deposit tests all
pass (5/5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 28c788ff41 |
feat(deps): p-retry around Documenso fetch + p-queue installed
p-retry wraps every Documenso API call with 3 attempts (1 + 2 retries), exponential backoff (1s → 4s with jitter). AbortError short-circuits on: - 401/403 — auth failures won't fix themselves on retry - 4xx other than 429 — Documenso rejected the payload; retrying hurts more than it helps 5xx + 429 (rate-limit) go through the retry path with backoff so we politely re-attempt after delay. Recovers the single-connection-blip scenario the audit's services pass flagged. p-queue installed too (audit §36.A.1 companion to p-limit). No concrete land site today — we don't bulk-fan-out to Documenso, and existing pLimit covers our internal mass-op fan-outs. Available for future rate-per-second scenarios. Verified: tsc clean, vitest 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 100beb9974 |
feat(deps): papaparse for expense CSV export
Replaces the hand-rolled `[fields].map(v => \`"\${v}"\`).join(',')`
pattern in expense-export.tsx with papaparse's Papa.unparse.
The previous version didn't handle:
- commas inside fields (would split rows mid-record)
- newlines inside fields (would terminate rows early)
- BOM for Excel-friendly encoding
- numeric/null normalization
Papa.unparse handles all of those + accepts a keyed-object row shape
that lets us define column order and get matching headers for free.
Verified: tsc clean, vitest 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| f3aae61ad8 |
feat(utils): formatDate helper + sample sweep through PDF + template paths
Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat
(no new dep — built into Node 18+ + every supported browser). Replaces 96
ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the
codebase.
src/lib/utils/format-date.ts (new):
formatDate(value, preset?, options?) — primary helper
formatDateRange(start, end, options?) — collapsed range strings
formatRelative(value, options?) — "3 hours ago" / "in 2 days"
Presets (named so callers don't memorize Intl options shape):
date.short 12 May
date.medium 12 May 2026
date.long Monday, 12 May 2026
date.iso 2026-05-12 (TZ-aware ISO date, no time)
datetime.short 12 May 14:30
datetime.medium 12 May 2026 14:30
datetime.long Monday, 12 May 2026 at 14:30 UTC
datetime.iso 2026-05-12T14:30:00.000Z
time 14:30
Defensive defaults:
- null/undefined/Invalid Date → '—' (overridable via { fallback })
- locale defaults to en-GB (settles audit-flagged en-US/en-GB drift)
- tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name)
Sample sweep (3 sites — proves the pattern; remaining 93 sites can be
migrated opportunistically when files are touched):
src/lib/services/expense-pdf.service.ts:608 default subheader
src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}}
src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}}
The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule:
"replace as you touch the file" — gives compounding cleanup without
a single risky 90-file commit.
tests/unit/format-date.test.ts (new) — 17 tests:
- fallback handling (null/undefined/invalid/explicit)
- date.iso correctness in UTC + non-UTC timezones
- datetime.iso = full ISO string
- en-GB locale-formatted output
- timezone respect across NY/UTC
- time-only preset
- Date/string/epoch ms inputs all accepted
- formatDateRange same-year collapse, different-year keep, missing ends
- formatRelative: just-now / minutes / hours / days / future / invalid
1315/1315 vitest green (+17 new from format-date.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 9fac84658a |
perf(services): p-limit fan-outs on berth-pdf, custom-fields, notifications
Phase 6 — bounds three remaining unbounded Promise.all fan-outs that the audit flagged as potential prod-incident vectors. Same pattern proven by email-compose (4 concurrent S3 reads) and document-signing-emails (3 concurrent SMTP sends) in earlier commits. berth-pdf.service.ts:574 — presignDownload S3 round-trips bound: pLimit(8). A 20-version berth used to issue 20 simultaneous presigns. ~1× round-trip latency preserved on typical 5-15-version berths; pathological 100-version case no longer saturates the keep-alive pool. custom-fields.service.ts:327 — pg upserts on bulk field-value writes bound: pLimit(8). Port admin stacking 50+ field definitions on one client would have burst 50 concurrent upserts at the pg pool. notifications.service.ts:344 — createNotification fan-out across watchers bound: pLimit(8). Hot pipeline items can accumulate many watchers; a document event used to fan out N notification inserts + N socket emits in one burst. Audit also flagged brochures.service.ts and backup.service.ts as candidates — verified neither actually has an unbounded fan-out, just sequential queries. No change needed; speculative entries removed from BACKLOG implicitly. 1298/1298 vitest green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| ba921d3865 |
refactor(search): ts-pattern for exhaustive type dispatch + fix missing 'notes' bucket
Phase 5 — converts the two switches in search.service.ts from `switch`
to ts-pattern's `match().with().exhaustive()`. The conversion exposed
a real bug: the single-bucket dispatch handled 15 of 16 SearchResults
buckets and silently dropped `type=notes` to the default empty-results
fall-through. `searchNotes()` has existed since the federated-notes
audit but was never wired into the runSingleBucket() dispatch. Calling
/api/v1/search?type=notes returned empty even with seeded note data.
The .exhaustive() switch now requires every SearchResults bucket. New
buckets fail the build until they get a dispatch case — same guarantee
the Documenso webhook conversion gives.
Notes:
- labelForSource (4 trivial label cases) — converted to ts-pattern
for visual consistency with the larger switch in the same file.
- The 3 other switches the audit flagged (client-restore.service.ts,
recently-viewed/route.ts, custom-fields/[entityId]/route.ts) operate
on tagged-union internal types where TypeScript already enforces
exhaustiveness via control-flow narrowing — converting them adds
noise without changing safety. Documented in docs/BACKLOG.md as
"TS-narrowing already exhaustive; deferred indefinitely."
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|