41737fa950b7dd4edbfceec507c96bc3b19ce2b0
398 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 9f5786890e |
feat(post-audit): Phase 3/6/7 schema foundations + bounce parser
Phase 3 — EOI override foundation (migration 0073): - client_contacts/addresses/yachts get source + source_document_id with FK SET NULL on doc deletion. CHECK constraints enforce the allow-list of source values (manual/imported/eoi-custom-input or manual/imported/eoi-generated for yachts). - documents.override_client_* + override_yacht_* columns mirror the AcroForm field set per docs/eoi-documenso-field-mapping.md. When NULL the canonical record value flows; when set, this document uses the override without touching the underlying record. - Drizzle schema mirrors all new columns; numeric import added to documents schema for the yacht-dimensions override columns. Phase 6 — IMAP bounce foundation (migration 0074): - document_sends.bounce_status / bounce_reason / bounce_detected_at with bounce_status CHECK constraint (hard/soft/ooo). - Partial index for the "show bounced sends" UI filter. - New src/lib/email/bounce-parser.ts library — handles RFC 3464 DSN + Outlook NDR shapes + OOO auto-replies. Returns null recipient + 'unknown' class when shape isn't recognizable. Cron worker deferred to Phase 6b. Phase 7 — PDF editor field-map types: - New src/lib/templates/field-map.ts defines FieldMap shape with percent-coord positioning so placements survive page-size changes. - Zod schemas for API boundary validation. - validateFieldMapAgainstPageCount helper for the "new PDF upload" warning. - No schema migration needed — existing document_templates. overlay_positions JSONB column accepts the new shape; the editor migrates legacy absolute-coord entries on first save. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 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>
|
|||
| 2d0a49e0d1 |
fix(P1): input validation hardening for client API — F6
Pre-audit /api/v1/clients accepted:
- contacts[].value='not-an-email' with channel='email' → silent bounce
- fullName=' ' (whitespace-only) → blank-chip renders everywhere
- fullName='Hidden<ZWSP>Char<ZWSP>Name' (zero-width chars) → search blind spot
This commit:
1. New `humanTextSchema()` helper in src/lib/validators/text.ts that
strips invisible/bidi/control chars, trims, then length-checks.
2. `fullName` switched to `humanTextSchema({ min: 1, max: 200 })`.
3. `contactSchema` gains a `superRefine` requiring valid email format
when `channel === 'email'`.
Verified live:
- invalid email → 400 "Must be a valid email address." (field-scoped)
- whitespace name → 400 "Too small: expected string to have >=1 characters"
- zero-width chars → stored as cleaned "HiddenCharName"
- valid baseline → 201
Followup tasks (deferred): apply `humanTextSchema` to yachts/companies/
interests/notes/reminders names; audit render paths for XSS-via-stored-
HTML (default React escaping is safe; pdfme/email-merge surfaces need a
spot-check).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 27f8db4c67 |
fix(P1): rate-limit auth endpoints — F7
Pre-audit: 20 rapid wrong-password attempts all returned 401 with no lockout. Brute-force open. Post-fix: better-auth's built-in rate limiter caps /sign-in/email at 5 attempts per 60s. Verified live — attempts 1-5 return 401, attempt 6+ returns 429 "Too many requests". Same tight cap applied to /sign-up/email, /forget-password, /reset-password. Default 120/min for everything else so legitimate multi-widget dashboards aren't hampered. Memory storage in this commit (resets on restart). Production multi-replica swap to `storage: 'database'` planned for a follow-up once the rateLimit migration is run. Also: in production, trust X-Forwarded-For / X-Real-IP so the IP that rate-limit + audit logging see is the real client, not the proxy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 2c57082d8d |
fix(P1): postgres-js pool reliability — F8
During the audit the dev server twice entered a stuck state where every query 500'd with `write CONNECT_TIMEOUT` while the DB was healthy (1/100 connections used, queryable from psql immediately). The Docker bridge can silently drop TCP sockets and postgres-js holds the stale handles until max_lifetime expires. - connect_timeout: 10 → 5 (fail fast) - max_lifetime: 30min → 10min (recycle before staleness accumulates) - onnotice: surface NOTICE/WARNING for visibility Reduces the window of stuck state. Full recovery still requires a restart if the pool hard-fails. pgbouncer in production is the proper long-term answer; this is the safe one-file change. 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> |
|||
| 905852b8a5 |
feat(permissions): carve out dedicated payments resource
Payments (deposit / balance / refund records on an interest) used to
share `invoices.record_payment`, which forces a port that doesn't
issue invoices at all to still navigate the invoicing permission
group to grant its sales reps payment-recording rights. Splitting
the resource lets admins gate the two surfaces independently.
The new resource has three actions:
- view — gates the UI affordance (API reads still go through
`interests.view`)
- record — POST / PATCH a payment
- delete — DELETE a payment record
Seed maps updated for all six system roles; existing role rows +
per-user permission overrides are backfilled by migration 0064 so
upgrades don't silently lose access. Two call sites (POST /interests/
[id]/payments, PATCH /payments/[id]) → payments.record; one
(DELETE /payments/[id]) → payments.delete. The PermissionGates on the
payments-section UI swap to the new keys.
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> |
|||
| 12e22d9be3 |
fix(ui+auth): origin-forwarding for sign-in + disable dark mode + center dialog
Three related cleanups while QA-testing on iPad:
1. Origin-forwarding bug on /api/auth/sign-in-by-identifier
- The custom identifier-sign-in route forwarded to better-auth's
/sign-in/email handler but did NOT preserve the inbound Origin +
Referer headers. Better-auth's CSRF check then 403'd every login
with MISSING_OR_NULL_ORIGIN — and the UI showed a generic
"Invalid credentials" toast even when the password was right.
- Fix: pass through req.headers.get('origin') and
req.headers.get('referer') when constructing forwardReq.
- Affects: every login attempt from any device (this isn't dev-
only); discovered testing from 192.168.1.17 → app on the same
LAN IP. Production users hit the same path.
2. Dark mode disabled
- Drop the Sun/Moon toggle from user-menu, the documentElement
class flip, darkMode from ui-store, darkMode from the user-
preferences validator. Hardcode sonner theme="light" (was
reading next-themes which isn't actually wired anywhere else).
- The 10 stray `dark:` Tailwind utilities are left alone — they're
inactive without the `dark` class on <html> so they don't ship
anything that renders, just dead CSS.
3. Center dialog animation
- Dialog content was sliding in from the top-right corner (slide-
in-from-left-1/2 + slide-in-from-top-[48%]) which felt jarring.
Drop the slide directions, keep just zoom-in-95 + the base
fade-in/out so dialogs appear in place with a subtle scale-up.
4. Login placeholder
- Removed the "you@example.com or yourname" placeholder so the
field reads as a clean empty input below the "Email or username"
label.
No tests added (the 1340 vitest suite passes); changes are surface-
level UI tweaks + the origin-header fix where a unit-test of the
custom route would mostly be testing better-auth's behaviour.
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>
|
|||
| 93399ea27e |
fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch
**mobile-pwa-auditor H4 — mobile shell uses min-h-screen** `min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE viewport height (URL bar collapsed). On first paint the page renders ~75–100px taller than visible, and reps see a blank strip past the bottom tab bar until the URL bar collapses on first scroll. Swap `min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner layout already does this correctly. **multi-port-auditor C1 — port-switcher race / cross-port bleed** `apiFetch` previously preferred Zustand for the X-Port-Id header and only consulted the URL slug as a fallback. Zustand lags by one render behind `PortProvider`'s reconcile effect; clicking from /port-A to /port-B fired the first round of queries with X-Port-Id = port-A while the page chrome rendered port-B → silent cross-port data bleed in the UI. Make the URL slug authoritative: read it first via `window.location.pathname` + `resolvePortIdFromSlug`, fall back to Zustand only on global routes (/dashboard) without a port slug. **multi-port-auditor C3 — defaultPortId silently stripped** `withAuth` reads `preferences.defaultPortId` as the X-Port-Id fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS allow-list silently dropped the key on every write. The fallback was therefore dead — super-admins always landed alphabetically-first. Add `defaultPortId: z.string().uuid().optional()` to the strict schema and include it in ALLOWED_PREF_KEYS so super-admins can persist their last-picked port. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 7370b2cd7d |
fix(audit-wave-11): file-lifecycle hardening — avatar leak + files FK
**file-lifecycle-auditor C1 — avatar replace leaks rows + blobs** `POST /api/v1/me/avatar` overwrote `userProfiles.avatarFileId` without reading or deleting the previous file id. Every "Replace photo" leaked one `files` row + one S3 blob, untethered (no client/yacht/company FK) and invisible to every existing UI sweep. Now captures the prior id BEFORE the UPDATE, then best-effort `deleteFile()` on the old row (handles ref-check + blob delete + audit) after the new id is committed. Failure is logged at warn — a stale blob shouldn't block the user from setting a new avatar. **file-lifecycle-auditor M1 — files.client_id missing ON DELETE** `files.client_id` was the only entity FK on the polymorphic `files` table that defaulted to `NO ACTION` (yacht_id + company_id were `SET NULL` per migration 0042). Any future bulk-client-delete that bypassed `hardDeleteClient`'s explicit FK-nullify pre-step would FK-violate. Migration `0059_files_client_id_onDelete_setnull.sql` brings it to parity; the explicit nullify in client-hard-delete is kept as defense in depth. Tests 1315/1315. 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> |
|||
| 0ea8d94d26 |
fix(audit-wave-10): build-auditor fixes — CSP, server externals, healthcheck
Address the highest-leverage CRITICAL/HIGH/MEDIUM items from the
build-auditor that weren't already covered by Wave 1 (EMAIL_REDIRECT_TO
production guard) or the existing `.dockerignore`.
**C3 — socket.io in standalone trace**
- Add socket.io + @socket.io/redis-adapter to serverExternalPackages
in next.config so the build system sees the dependency (the custom
server is the only importer, no Next route touches it).
- Belt-and-braces: COPY both from the deps stage into the runner stage
of Dockerfile, mirroring the audit's suggested fix.
**H1 — CSP `'unsafe-inline'` in prod**
- Audit recommends nonce-based scripts. Implementing nonces requires
middleware that emits a per-request nonce + threading it through
Next's RSC bootstrap + Server Actions. Out of scope for this wave;
documented the rationale at the CSP definition so the next pass
knows where to start, and noted that the in-the-wild XSS surfaces
are already closed via escapeHtml/escapeUrl in the email + webhook
pipelines.
**H2 — NEXT_PUBLIC_APP_URL validation**
- Add `NEXT_PUBLIC_APP_URL: z.string().url()` to the env schema so a
missing build-time value fails validation instead of silently
inlining the empty string into the client bundle and breaking
multi-origin deploys.
**M3 — serverExternalPackages completeness**
- Add imapflow, mailparser, pdf-lib, sharp, tesseract.js,
@react-pdf/renderer, unpdf — all heavy native/CJS-leaning
server-only deps that should not be route-traced.
**H5 — healthcheck PORT templatization**
- docker-compose.{,prod.}yml: replace hardcoded
`http://localhost:3000/api/health` with `${PORT:-3000}` so
overriding PORT via .env doesn't put the container into a
restart loop.
**M9 — NODE_ENV=production in builder**
- Dockerfile builder stage now sets NODE_ENV=production above
`RUN pnpm build` so the prod-only branches in next.config
(CSP, etc.) compile deterministically.
**M7 — HEALTHCHECK directive in image**
- Add image-level HEALTHCHECK to the app Dockerfile (mirrors the
one in Dockerfile.worker for Redis) so the image is
self-describing for non-compose orchestrators.
Items already addressed prior to this wave:
- C1 (.dockerignore exists, comprehensive)
- C2 (EMAIL_REDIRECT_TO production refusal — Wave 1)
- H4 (compose resource + log limits — already in prod compose)
Tests 1315/1315 throughout.
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>
|