Adds DOCUMENSO_API_VERSION env (default v1) plus per-port override.
Introduces placeFields, placeDefaultSignatureFields, and voidDocument
that hide v1 (per-field POST, pixel coords) vs v2 (bulk POST, percent +
fieldMeta) differences. cancelDocument now voids in Documenso first and
treats transient void failures as recoverable so the CRM stays the
system of record. 16 unit specs cover dispatch, layout math, idempotent
404, and v1 pixel conversion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Phase A data model deltas to documents/templates and the new
document_watchers table. Introduces createFromWizard/createFromUpload
stubs, getDocumentDetail aggregator, cancelDocument flow, signed-doc
email composer, reservation agreement context, and notifyDocumentEvent
fan-out. Validator update accepts new template formats with html-only
bodyHtml requirement. EOI cadence backfilled to 1 day to preserve
current effective behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yachts list page rendered each row's Current Owner via OwnerLink, which
fired its own /api/v1/clients/{id} or /companies/{id} fetch — N+1 round-
trips per page load (12+ for the harbor-royale fixture). Worse, until
those fetches resolved each cell showed "Client c68da7..." style raw IDs.
Fix: listYachts now resolves the polymorphic currentOwnerName in two
batched in-array queries after the page query (mirrors the listClients
yachtCount/companyCount pattern), and OwnerLink accepts an optional
preloadedName prop that suppresses the per-row fetch when supplied.
Topbar: show real user name + avatar initial from session/profile, and
expand the My-Account dropdown header to include the user's email.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SettingsFormCard
- Parent components pass `FIELDS.slice(...)` inline, so the prop reference
changes on every render. The fetch callback's useCallback re-created
itself, useEffect re-fired, and loading flicker meant the form never
rendered. Capture fields in a ref so the callback is stable.
Sidebar
- Show real user name + avatar initial from session/profile, replacing
the hardcoded "User Name" / "U" placeholder.
- Default the admin-section to expanded so its items are reachable on
first page load (was collapsed behind a chevron).
Dashboard layout
- Pass {name, email} from the session/profile through to <Sidebar />.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marks pending form_submissions whose expires_at has passed
as 'expired'. Logs the count of rows transitioned each run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
listCompanies returns memberCount (active companyMemberships)
and yachtCount (yachts where currentOwnerType=company), each
fetched as a parallel grouped count after the main page query.
Two new badge columns in company-columns render them between
the tax-id and status columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The documenso-template pathway was returning 201 with documensoId=null
because Documenso 2.x renamed `id` → `documentId` and recipient `id` →
`recipientId` in its API responses. Our DocumensoDocument interface
still expected the legacy v1.13 shape, so destructuring silently yielded
undefined and the documents row got NULL'd.
- Add normalizeDocument() in documenso-client that reads either field
name and surfaces the legacy `id` form downstream consumers expect
- Apply normalization at every callsite that returns DocumensoDocument
(createDocument, generateDocumentFromTemplate, sendDocument, getDocument)
- New realapi Playwright project (opt-in: --project=realapi) targeting
tests/e2e/realapi/, with 2-min timeout for real-network calls
- New spec: documenso-real-api.spec.ts seeds client/yacht/berth/interest
via the v1 API, fires generate-and-sign through the documenso-template
pathway, asserts the response carries a documensoId, then GETs the
document directly from Documenso to confirm it exists with PENDING
status and recipients populated
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New PortalAuthShell component: blurred Port Nimara overhead background +
circular logo + white rounded card, used by /portal/login,
/portal/activate, /portal/reset-password
- New email/templates/portal-auth.ts: table-based, responsive (max-width
600px / width 100%), matching the existing legacy inquiry templates;
replaces the inline templates that lived in portal-auth.service
- EMAIL_REDIRECT_TO env override: when set, sendEmail routes every
outbound message to that address regardless of recipient and tags the
subject with "[redirected from <original>]". Dev/test safety net only;
unset in production
- Portal password minimum length 12 → 9 (service + both API routes +
client-side form)
- Dev helper script scripts/dev-trigger-portal-invite.ts: seeds a portal
user against the first port-nimara client and uses EMAIL_REDIRECT_TO
as the stored email so the tester can sign in with the address that
received the activation mail
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documenso authenticates outbound webhooks via the X-Documenso-Secret
header carrying the plaintext secret (no HMAC). The previous receiver
verified an HMAC against a non-existent x-documenso-signature header
and switched on parsed.type, neither of which Documenso emits — so
every real delivery was being silently rejected.
- Read X-Documenso-Secret, compare timing-safe to env secret
- Switch on parsed.event with uppercase normalization for both v1.13
(DOCUMENT_SIGNED) and 2.x (lowercase-dotted UI labels) wire formats
- Alias DOCUMENT_RECIPIENT_COMPLETED to DOCUMENT_SIGNED (same
semantics across versions)
- Handle DOCUMENT_OPENED / DOCUMENT_REJECTED / DOCUMENT_CANCELLED in
addition to the existing DOCUMENT_SIGNED + DOCUMENT_COMPLETED paths
- Bypass session middleware for /api/webhooks/* (signature is the auth)
Verified end-to-end against signatures.letsbe.solutions: real
DOCUMENT_RECIPIENT_COMPLETED + DOCUMENT_COMPLETED deliveries now pass
secret verification, dispatch correctly, and the handler updates
state (or warns gracefully when the documensoId is unknown).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The client portal no longer uses passwordless / magic-link sign-in. Each
client now has a `portal_users` row with a scrypt-hashed password,
created by an admin from the client detail page; the admin's invite
mails an activation link that the client uses to set their own password.
Forgot-password is wired through the same token mechanism.
Schema (migration `0009_outgoing_rumiko_fujikawa.sql`):
- `portal_users` — one per client account, separate from the CRM
`users` table (better-auth) so the auth realms stay isolated. Email
is globally unique, password is null until activation.
- `portal_auth_tokens` — single-use activation / reset tokens. Stores
only the SHA-256 hash so a DB compromise never leaks live tokens.
Services:
- `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps;
uses node:crypto), token mint+hash helpers.
- `src/lib/services/portal-auth.service.ts` — createPortalUser,
resendActivation, activateAccount, signIn (timing-safe),
requestPasswordReset, resetPassword. Auth failures throw the new
UnauthorizedError (401); enumeration-safe behaviour everywhere.
Routes:
- POST /api/portal/auth/sign-in — sets the existing portal JWT cookie.
- POST /api/portal/auth/forgot-password — always 200.
- POST /api/portal/auth/reset-password — token + new password.
- POST /api/portal/auth/activate — token + initial password.
- POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`).
- Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link).
UI:
- /portal/login — replaced email-only magic-link form with email +
password + "forgot password" link.
- /portal/forgot-password, /portal/reset-password, /portal/activate — new.
- New shared `PasswordSetForm` component used by activate + reset.
- New `PortalInviteButton` rendered on the client detail header.
Email send:
- `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are
set (gmail app-password or marina-server creds, configured via env).
- `SMTP_FROM` env var lets the sender address be overridden without
pinning it to `noreply@${SMTP_HOST}`.
Tests:
- Smoke spec 17 (client-portal) updated to the new flow: 7/7 green.
- Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to
match the post-refactor client + invoice forms (drop companyName,
use OwnerPicker + billingEmail).
- Vitest 652/652 still green; type-check clean.
Drops the dead `requestMagicLink` from portal.service.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts the MERGE_FIELDS catalog out of the document-templates service
into src/lib/templates/merge-fields.ts so the Zod validator can import
it without circular deps. createTemplateSchema now refines mergeFields
against VALID_MERGE_TOKENS — unknown tokens (including the deprecated
`{{client.yachtName}}` / `{{client.companyName}}` family) are rejected
at template creation time with a message naming the offenders.
Adds the missing `eoi` value to templateType enum so seeded EOI rows
round-trip through the validator. Drops the historical "Removed (PR 11):"
comment from the catalog (per project convention against `// removed`
markers).
6 new validator unit tests; 652/652 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The EOI dialog now lists "Documenso Standard EOI" (default) plus any
seeded in-app EOI templates and routes the submit to the dual-path
generate-and-sign endpoint with the correct pathway:
- "documenso-template" sentinel id → pathway: documenso-template
- any other template id → pathway: inapp
Signers are derived server-side from EoiContext for both pathways when
the template type is EOI (interest's client + hardcoded developer +
approver), so the dialog doesn't collect them. Non-EOI templates still
require explicit signers.
Drops the legacy `client.yachtLengthFt` prerequisite check (yacht is now
a first-class entity) and replaces it with hasYacht based on
interest.yachtId. Tests updated; 646/646 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the in-app pathway is used for EOI templates, we now load the same
source PDF that the Documenso template uploads and fill its AcroForm
fields with values from EoiContext via pdf-lib. Field names mirror the
Documenso template's formValues keys exactly (Name, Email, Address,
Yacht Name, Length, Width, Draft, Berth Number + Lease_10 / Purchase
checkboxes), so both pathways produce equivalent legal documents — only
the renderer differs.
The form is left interactive (not flattened) so a recipient can still
adjust values before signing. Non-EOI templates (welcome letters,
acknowledgments, etc.) keep using the existing HTML→pdfme path.
Adds:
- pdf-lib direct dep
- src/lib/pdf/fill-eoi-form.ts — load + fill helpers, EOI_TEMPLATE_PDF_PATH
env override
- assets/ + README documenting the expected source PDF
- next.config outputFileTracingIncludes so the asset is bundled in the
standalone build
Tests: 8 new (4 fill-form unit + 2 source-PDF route + 2 fallback);
645/645 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
generateAndSign now accepts a `pathway` parameter:
- `inapp` (existing): resolve in-app template -> pdfme -> MinIO -> Documenso
createDocument + sendDocument.
- `documenso-template` (new): build EOI context from interestId, assemble
the Documenso template payload, and call Documenso's
/api/v1/templates/{id}/generate-document. Documenso owns the PDF; we
still record a documents row for tracking.
Adds generateDocumentFromTemplate helper to the Documenso client and new
env vars (DOCUMENSO_TEMPLATE_ID_EOI + client/developer/approval recipient
IDs) with defaults matching the legacy flow. Covered by 6 new integration
tests (637/637 green).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 11.4. Extends resolveTemplate to use buildEoiContext when interestId
is provided, populating the new yacht.*, company.*, owner.* token scopes
from the shared EOI context. Legacy non-EOI templates still resolve via
direct client/berth/port lookups. Deprecated client.yachtName /
client.companyName / client.yacht*Ft tokens are removed from the catalog;
PR 12 will drop the backing columns. berth.mooringNumber is relaxed to
required:false so welcome-letter-style templates without a berth context
no longer trip the required-merge-field check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new per-port document_templates row of type 'eoi' containing an
HTML EOI / Letter of Intent body with {{section.field}} merge tokens
that mirror the EoiContext shape. Enables the in-app pdfme PDF path as
an alternative to the Documenso template flow.
- New getStandardEoiTemplateHtml() returns the Letter-sized HTML body
with Applicant / Yacht / Owner / Berth / Interest / Signatures blocks
- STANDARD_EOI_MERGE_FIELDS exported for resolveTemplate wiring (11.4)
- seed-data.ts inserts one document_templates row per port inside the
existing withTransaction block, between ownership transfers and
interests, using SEED_USER_ID for audit consistency
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the billingEntityType/billingEntityId columns (added in PR 1) through
the invoice validator and service. Clients can now be billed as either a
client or a company; clientName becomes a snapshot derived from the entity
at create time.
- createInvoiceSchema: replace clientName with billingEntity {type,id}
- listInvoicesSchema: add billingEntityType/billingEntityId filters
- createInvoice: resolveBillingEntity helper (tenant-scoped; tx-aware)
falls back to entity primary email/address when not supplied
- listInvoices: honor new billing-entity filters
- updateInvoice: unchanged — billing entity is fixed after create
- invoice wizard step 1: temporary billing-entity id input (Task 10.2
replaces this with a proper picker)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch recommendations engine to read yacht dimensions (lengthFt, widthFt,
draftFt) from the yachts table via interest.yachtId instead of from the
deprecated client fields. Cross-tenant safety is maintained by scoping the
yacht lookup to the same portId. Falls back gracefully to null dimensions
when interest.yachtId is null or yacht is not found.
- Modified: src/lib/services/recommendations.ts — replaced client.yacht*Ft
fields with yacht table lookups via interest.yachtId
- Created: tests/integration/recommendations-yacht-dims.test.ts — 4 tests
covering happy path, null-yacht fallback, cross-tenant safety, and
dimension-based scoring
All 594 tests passing, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the global search service to include yacht and company results
using ILIKE matching on name, hull number, registration, legal name,
and tax ID. Results are tenant-scoped and exclude archived rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures the public interest endpoint to create the yacht as a
first-class row (owned by the new client, or by a newly upserted
company when a company block is provided) and writes the yacht_id
onto the new interest. All writes now run inside a single
transaction instead of the previous unwrapped sequence.
The public validator gains structured `yacht` (required) and
`company` (optional) sub-objects; legacy flat fields remain in the
schema for backward compatibility but are silently ignored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add yachtId (optional) to createInterestSchema + listInterestsSchema
(updateInterestSchema inherits it via partial() automatically).
- Add assertYachtBelongsToClient helper that accepts direct client
ownership OR company-represented clients with an active membership
in the owning company.
- createInterest + updateInterest validate yacht ownership whenever
yachtId is supplied/changed.
- changeInterestStage rejects moving out of stage=open with yachtId
null (ValidationError).
- listInterests filter supports yachtId.
- Integration tests cover all 7 paths; validator test for yachtId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ClientData in client-detail.tsx now reflects the stripped shape from
Task 8.2 (drop companyName/isProxy/proxy*/yacht*/berthSizeDesired) and
gains yachts / companies / activeReservations arrays.
- client-tabs.tsx: Overview trimmed (personal, contacts, source, tags);
three new count-badged tabs (Yachts, Companies, Reservations).
- New client-yachts-tab.tsx renders owned yachts + Add yacht CTA (TODO:
YachtForm preset-owner wiring for v2).
- New client-companies-tab.tsx renders memberships with Primary badge and
since-date; management still lives on the company detail page.
- New client-reservations-tab.tsx maps activeReservations into ReservationRow
shape and delegates to <ReservationList showBerth />.
- client-columns.tsx drops companyName column (TODO: add Yachts count +
Primary company once list endpoint joins those).
- client-filters.tsx drops isProxy filter.
- Wire realtime invalidations for yacht:ownership_transferred,
company_membership:added/ended, and berth_reservation:*.
Remove deprecated companyName, isProxy, proxyType, actualOwnerName, yacht
dimensions, and berthSizeDesired fields from createClientSchema and the
isProxy filter from listClientsSchema. First step of PR 8; cascading TS
errors in clients.service.ts and client-form.tsx are addressed in 8.2/8.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Task 5.3 stub with a real YachtTransferDialog backed by
OwnerPicker, a date input, reason select, and notes textarea. Submits to
POST /api/v1/yachts/{id}/transfer, invalidates yacht + ownership-history
queries on success, and surfaces API errors (same-owner 400, cross-tenant
404, no-permission 403) as form-level messages. Transfer button is now
gated by PermissionGate resource="yachts" action="transfer".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Task 5.3: server page passes yachtId to a client YachtDetail,
which fetches via TanStack Query and renders the shared DetailLayout with
Overview / Ownership History / Interests / Reservations / Notes / Tags
tabs. Header shows name, dimensions, polymorphic owner link, status badge,
and Edit / Transfer / Archive actions. Transfer is a stub dialog pending
Task 5.5; Notes tab is a placeholder because NotesList does not yet support
entityType='yachts'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sheet-based react-hook-form + zod component for yacht CRUD.
CREATE mode uses OwnerPicker to set the yacht's owner (required
by createYachtSchema). EDIT mode hides the picker and shows a
notice directing users to the Transfer button, matching the
service-layer guard that blocks owner mutation via PATCH.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 5.1 of the data-model refactor. Adds:
- OwnerPicker: polymorphic combobox that toggles between client and
company autocomplete via a type switch inside the popover. Uses
/api/v1/clients/options (search=) and /api/v1/companies/autocomplete
(q=).
- YachtPicker: yacht autocomplete against /api/v1/yachts/autocomplete
with optional ownerFilter prop to scope to a given client/company.
Both components use TanStack Query with debounced (300ms) input via the
existing use-debounce hook, and apiFetch which attaches X-Port-Id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Split seed into orchestrator (seed.ts) + per-port fixture builder
(seed-data.ts). Creates three ports (Port Nimara, Marina Azzurra,
Harbor Royale) and seeds each with a realistic multi-cardinality
dataset: 12 berths (5 available / 5 reserved / 2 sold), 8 clients
with contacts and primary addresses, 3 companies (2 active / 1
dissolved) with billing addresses, memberships exercising dual-
company ownership and ended state, 12 yachts (7 client-owned /
5 company-owned) plus matching open ownership-history rows, 3
completed ownership transfers per port (client <-> company), 15
interests spanning all pipeline stages, and 8 reservations (5
active on distinct berths / 2 ended / 1 cancelled). Seed wraps
per-port work in withTransaction and is idempotent: re-running
detects existing company rows and skips.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add Task 3.6 routes:
- POST /api/v1/berths/:id/reservations — creates a pending reservation;
the URL berthId is authoritative and any body-supplied berthId is
ignored.
- GET /api/v1/berths/:id/reservations — list filtered by URL berthId.
- GET /api/v1/berth-reservations/:id — fetch scoped to tenant.
- PATCH /api/v1/berth-reservations/:id — action-based dispatch
(activate | end | cancel) via a discriminated union. Because the
required permission depends on the action, PATCH is wrapped with
withAuth only and calls requirePermission inside the handler.
- DELETE /api/v1/berth-reservations/:id — alias for cancel (204).
Cross-tenant berths return 404 on both POST and GET via an explicit
pre-check.
Tests cover happy paths, invalid transitions, 404/400/403 cases, the
URL-vs-body berthId precedence, and per-action permission gating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>