# Documents Hub, Reservation Agreements, and Visual Polish (Phase A) **Status:** Draft — awaiting final review **Date:** 2026-04-28 **Phase:** A of D (B = Insights & Alerts; C = Website integration; D = Pre-prod ops) ## Overview Phase A delivers a unified Documents Hub that tracks every signature-based document (EOI, Reservation Agreements, NDAs, ad-hoc uploads), generalises the existing single-purpose EOI dialog into a multi-format create-document wizard, builds the missing CRM-side reservation detail page with an end-to-end agreement workflow, polishes the reminder framework so non-EOI docs auto-remind correctly, and applies a system-wide visual upgrade to the polished-SaaS aesthetic the project already has tokens for. The project already ships a usable CRM with auth, multi-tenancy, full client/yacht/company/interest/berth/reservation data model, an EOI dual-path (Documenso template + in-app PDF), socket-driven real-time updates, and 130 smoke specs. What's missing for the next release: a single place to see what documents need signing and chase the people who haven't signed. ## Scope boundaries ### In scope (this spec) - New `/[port]/documents` hub page replacing the existing list - New `/[port]/documents/[id]` document detail page - Generalised create-document wizard supporting four template formats (HTML, PDF AcroForm fillable, PDF overlay-positioned, Documenso-rendered) plus ad-hoc PDF upload - New `/[port]/berth-reservations/[id]` reservation detail page with agreement-generation flow - Reservation Agreement as a first-class document type with default template seeded - Email composer extended with attachments and a System-vs-User From selector (admin-gated) - Reminder framework: per-template cadence, per-doc override, per-doc disable, per-signer manual reminders - Documenso version-aware abstraction layer covering field placement and document voiding across v1.13.1 and v2.x - System-wide visual polish: shadow scale, gradient layer, animation tokens, primitive components (``, ``, ``, polished ``), applied across all list and detail pages - Mobile-responsive sweep across every page touched - Comprehensive test coverage: unit, integration, smoke, exhaustive click-through, real-API round-trips, visual baseline regeneration ### Explicitly out of scope (deferred to later phases) - Analytics dashboard, alert framework, interests-by-berth view, expense duplicate detection (Phase B) - Website-side integration: `/api/form/[token]/data` prefill endpoint, `/api/webhook/document-signed` callback receiver, public-endpoint shape compat (Phase C) - NocoDB to Postgres data migration, email deliverability (DKIM/SPF/DMARC), Sentry error reporting, audit log retention, performance baseline at 5k clients / 50k interests, backup/restore automation, production deploy readiness (Phase D) - Native in-CRM PDF field-placement editor (deferred until upload-path pain emerges; Phase A v1 ships with auto-placed footer signature fields and a "Customize fields in Documenso" link) - Word `.docx` template upload (deferred; PDF prioritized because Word adds LibreOffice/CloudConvert toolchain dependency without saving the field-placement step) - Per-interest "silence all reminders" toggle (was implicit in old `interests.reminderEnabled` gating which this spec drops; can be re-added as a bulk action if anyone misses it) ## Information architecture ### URL surface ``` /[port]/documents hub (replaces existing list) /[port]/documents/[id] document detail (new) /[port]/documents/new create-document wizard (new) /[port]/berth-reservations/[id] reservation detail (new) /[port]/admin/templates existing; extended for new template formats /[port]/admin/email existing; one new toggle ``` ### Schema deltas ``` documents — additions: + reservation_id text null references berth_reservations(id) + reminders_disabled boolean default false + reminder_cadence_override int null document_templates — additions: + reminder_cadence_days int null (null = no auto-reminders) + template_format text default 'html' ('html'|'pdf_form'|'pdf_overlay'|'documenso_render') + source_file_id text null references files(id) + documenso_template_id text null + field_mapping jsonb default '{}' (pdf_form: { acroFieldName: mergeToken }) + overlay_positions jsonb default '[]' (pdf_overlay: [{token, page, x, y, fontSize}]) document_templates.body_html — relax to nullable (only required when template_format='html') document_watchers — new table: document_id text not null references documents(id) on delete cascade user_id text not null references users(id) added_by text not null references users(id) added_at timestamptz default now() primary key (document_id, user_id) documents indexes — additions: + idx_docs_reservation on (reservation_id) + idx_docs_status_port on (port_id, status) — powers tab counts cheaply document_watchers indexes: + idx_doc_watchers_doc on (document_id) + idx_doc_watchers_user on (user_id) documents.documentType enum — already includes 'reservation_agreement'; no migration needed documents.status enum — already accepts 'expired'; no migration needed documentSigners.status enum — pending|signed|declined; no migration needed ``` Backfill (one statement, safe to run in same migration): ```sql UPDATE document_templates SET reminder_cadence_days = 1 WHERE template_type = 'eoi'; ``` This preserves the existing 1-day-effective reminder cadence for existing EOI templates. Admins can edit per-template later. After running migration on a dev/staging server, restart `next dev` to flush postgres.js prepared-statement cache (existing project convention). ### Polymorphic ownership pattern Documents already use the multi-FK pattern (`interest_id`, `client_id`, `yacht_id`, `company_id` as separate nullable columns). Adding `reservation_id` matches this. No conversion to polymorphic discriminator columns despite yachts and invoices using that pattern; staying consistent with the existing documents shape avoids a destructive migration. ### Service-layer changes - `documents.service.ts`: - `createFromWizard(portId, data, meta)` — dispatches across template/upload paths - `createFromUpload(portId, data, meta)` — new upload-driven path; calls Documenso `createDocument`, stores file in MinIO via `files` service, mirrors to `documents` + `documentSigners`, optionally calls `sendDocument` if `sendImmediately` - `cancelDocument(documentId, portId, meta)` — user-initiated cancel; calls Documenso void, updates DB status, logs event - `composeSignedDocEmail(documentId, portId)` — returns prefilled `{ to, cc, subject, body, attachments, defaultSenderType }` for the composer - `getDocumentDetail(id, portId)` — single-roundtrip aggregator returning doc + signers + events + watchers + linked-entity summary - `document-templates.ts`: - `generateAndSign` extended for new `template_format` values - `fillAcroForm(sourceFile, fieldMapping, mergeContext)` — pdf-lib AcroForm fill - `drawOverlay(sourceFile, overlayPositions, mergeContext)` — pdf-lib text-draw at positions - Documenso-render path uses existing `generateDocumentFromTemplate` - `documenso-client.ts`: - `placeFields(docId, fields, portId?)` — version-aware bulk field placement - `placeDefaultSignatureFields(docId, recipientIds, portId?)` — auto-position one SIGNATURE per recipient at footer - `voidDocument(docId, portId?)` — version-aware doc void/delete - Coordinate normalization helpers (caller passes percent 0-100; converted to pixels for v1 using cached page dimensions) - `document-reminders.ts`: - `sendReminderIfAllowed(documentId, portId, options?)` — extended signature with optional `signerId` and `auto: boolean` - `processReminderQueue(portId)` — query rewritten around `documents.reminder_cadence_override ?? template.reminder_cadence_days`; drops `interests.reminderEnabled` gating - `notifications.service.ts`: - `notifyDocumentEvent(docId, eventType)` — fans out to creator + entity-assignee + watchers; existing socket events keep firing - New: `reservation-agreement-context.ts`: - `buildReservationAgreementContext(reservationId, portId)` — joins reservation -> client + yacht + berth -> port; returns context shape for template merge - `email-compose.service.ts`: - Validator extended: `{ senderType: 'system'|'user', accountId? (when user), attachments[] }` - System path: calls `lib/email/index.ts → sendEmail()` with `portId` + attachments; logs `documentEvents` row `signed_doc_emailed`; skips `email_messages`/`email_threads` writes - User path: existing flow, with attachments resolution from `files` table - Port-isolation: cross-port `fileId` returns 403 - `lib/email/index.ts`: - `SendEmailOptions.attachments?: Array<{ fileId, filename? }>` — fetches files from MinIO, passes to nodemailer ## Documents hub page Replaces existing `/[port]/documents` list. ### Layout ``` [ Header strip: title, KPI sub-line, "+ New document" button ] [ Tabs: All | Awaiting them (count) | Awaiting me (count) | Completed | Expired ] [ Search · Type · Status · Sent · Watcher filter chips · saved-view selector · overflow ] [ Table: checkbox | Document | Type pill | Subject pill | Status (X/Y signed + dot) | Sent ▾ expand row inline to show signers + watchers strip ] [ Sticky bulk-action bar appears when ≥1 row checked: "N selected" | Remind unsigned | Cancel | Export | pagination ] ``` ### Tab queries - All — every document in port - Awaiting them — `status IN ('sent','partially_signed')` AND has pending signer != current user - Awaiting me — at least one `documentSigners` row matching `signer_email = current user email` AND `status = 'pending'` - Completed — `status IN ('completed','signed')` - Expired — `status = 'expired'` OR (`status IN ('sent','partially_signed')` AND `expires_at < now()`) Counts run cheap thanks to `idx_docs_status_port`. ### Filters and saved views - Search: fuzzy match on title, subject name, signer email - Type: multi-select doc types - Status: multi-select status enum - Sent: date-range chips (Today, 7d, 30d, custom) - Watcher: filter by watching user - "Signature-based only" chip defaults to ON; toggle off to see non-signed docs (welcome letters etc.) as well, rendered with a "Delivered" pill - Saved-view integration: filter combos save to existing `saved_views` table ### Row anatomy - Collapsed: name (links to detail), type pill (colored per type), subject pill (links to entity), status indicator (X/Y signed with progress dot), sent age - Expanded: per-signer rows with email, status pill, sent timestamp, signed timestamp, `[Remind]` and overflow `[...]` (resend invite, copy signing link, skip — skip is UI-only flag, not implemented in v1) - Watchers strip at bottom of expansion: chips + `+ Add watcher` autocomplete - Hover: row gets soft brand-soft gradient bg ### Real-time Subscribes to existing `documents.service.ts`-emitted socket events: `document:created`, `document:updated`, `document:deleted`, `document:sent`, `document:completed`, `document:expired`, `document:cancelled`, `document:rejected`, `document:signer:signed`, `document:signer:opened`. All already fire today. ### Empty states - No docs yet: illustration + 1-line explanation + `[+ New document]` CTA - Filtered empty: "No docs match these filters. Clear filters?" ### Mobile (< 768px) - Tabs collapse into `` < 640px - Sidebar slides over content < 1024px - Primary "+ New" actions float as FAB bottom-right < 640px ## Test plan ### Unit (`tests/unit/`) - `document-reminders-cadence.test.ts` — `isReminderDue` math; manual-vs-auto window/cooldown bypass - `documenso-place-fields.test.ts` — v1/v2 dispatch (mocked HTTP); coord normalization; default field staggering for 1/2/3/5 recipients - `email-attachments-resolver.test.ts` — fileId → MinIO buffer; cross-port 403; 10 MB cap warning ### Integration (`tests/integration/`) - Extend `document-templates-generate-and-sign.test.ts` — new template formats (`pdf_form`, `pdf_overlay`, `documenso_render`); upload-path test - New `document-watchers.test.ts` — add/remove endpoints; notification fan-out; port isolation - New `document-cancel.test.ts` — user-initiated cancel; mocked Documenso void; status + event log; reject 409 if completed - New `reservation-agreement-contract-mirror.test.ts` — `handleDocumentCompleted` mirrors `signedFileId` to `berth_reservations.contractFileId` only for `reservation_agreement` type - New `reminder-cron-cadence.test.ts` — seed varied templates; simulated time advance; assert correct docs reminded ### E2E smoke (`tests/e2e/smoke/`) - Extend `04-documents.spec.ts` — hub tabs, expand row, per-signer remind with cooldown, type/status filters, saved-view round-trip, bulk-remind with per-row toast reasons - Extend `05-eoi-generate.spec.ts` — wizard invocation prefills (template, interest); existing flow regression - New `27-document-create-wizard.spec.ts` — template path full flow; upload path full flow; watcher addition; reminder-override radios produce correct DB state - New `28-reservation-agreements.spec.ts` — reservation detail → Generate agreement → wizard prefilled → Send → agreement section state transitions; post-completion contract attached + email button visible - New `29-email-attachments.spec.ts` — system path send (documentEvents row, no email_messages); user path send when toggle on (email_messages with attachment_file_ids); cross-port 403 ### E2E exhaustive (`tests/e2e/exhaustive/`) — click-everything sweep - New `10-documents-hub.spec.ts` — crawl each tab, filter dropdowns, saved-view, expand row, signer-row buttons, bulk-action bar - New `11-document-detail.spec.ts` — crawl in three states (draft/sent/completed); watcher add/remove; notes auto-save; preview download; "Email signed PDF" launch - New `12-document-create-wizard.spec.ts` — crawl each wizard step under both template and upload paths; picker dropdowns, signer add/remove, drag-handle, reminder-cadence radios - New `13-reservation-detail.spec.ts` — crawl in three states (pending no agreement / agreement-in-flight / agreement-completed); Activate/Cancel/Generate buttons; inline notes - New `14-email-composer.spec.ts` — crawl composer drawer with attachments; From dropdown; attach button; recipient chips - Extend exhaustive `05-eoi-generate.spec.ts` — parallel-mode + signing-order edge cases (greyed-out reminder buttons; out-of-order remind rejection) ### E2E real-API (`tests/e2e/realapi/`) Each spec gates on env vars; clean skip if missing. - Extend `documenso-real-api.spec.ts`: - Generate from Documenso template (real send) and assert in real Documenso - Generate from in-app PDF AcroForm fill, upload to real Documenso, assert - Generate from upload path with auto-placed signature fields, assert fields visible in Documenso - v1 and v2 explicit version-flag tests (via `DOCUMENSO_API_VERSION`) - Manually sign in real Documenso (or simulate webhook) and assert local DB updates - Cancel real in-flight doc, assert local + remote state - Send reminder via real Documenso, assert HTTP + documentEvents row - New `smtp-system-send.spec.ts` — system-path send → IMAP fetch → assert subject + attachment; verify port-config from-identity; cleanup via IMAP delete - New `smtp-user-send.spec.ts` — user-path send (requires connected account, allowPersonalAccountSends=true) → IMAP fetch → email_messages row with attachment_file_ids - New `minio-file-lifecycle.spec.ts` — upload, list, preview, download (byte-equal), delete; port isolation; mime-type validation - New `documenso-webhook-ingress.spec.ts` — requires cloudflared tunnel; configure tunnel URL as Documenso webhook target; trigger doc completion; assert webhook fires + handler updates DB; verify timing-safe secret check rejects wrong secret with 401; verify event normalisation (uppercase enum + lowercase-dotted both accepted) - New `email-attachments-roundtrip.spec.ts` — compose with fileId attachment; SMTP send; IMAP fetch; assert attachment bytes match; reject cross-port fileId with 403 before SMTP touched ### Visual baselines (`tests/e2e/visual/`) `snapshots.spec.ts-snapshots/` regenerated as polish ships per page; one PR per surface group, baselines reviewed in PR diff. New baselines added: documents hub, doc detail, create-document wizard (each step), reservation detail, email composer with attachments. ### Test data fixtures `global-setup.ts` extended with: - Seed default `reservation_agreement` template (HTML format) - Seed default `signed_doc_completion` template - Seed one in-flight EOI doc with two pending signers (for hub-tab tests) - Seed one `berth_reservation` with `status='active'` and no agreement (for lifecycle alert query) ### CI vs local runs | Project | When | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `setup` + `smoke` (~14 min) | Every PR via CI | | `exhaustive` (with new click-everything specs) | Every PR via CI; ~25 min budget | | `visual` | Every PR; baselines reviewed in PR diffs | | `realapi` | Locally before merging touch-points; pre-release; not on CI (avoids burning Documenso quota and SMTP costs) | ## Build sequence | # | Title | Effort | Depends on | | ----- | ------------------------------------------------- | ------ | -------------- | | 1 | Data model + service skeletons | 1d | — | | 2 | Documenso v1/v2 abstraction layer | 1d | — | | 3 | Visual primitives + token additions | 1.5d | — | | 4 | Documents hub page | 2d | 1, 3 | | 5 | Document detail page | 2d | 1, 3 | | 6 | Create-document wizard + new template formats | 2.5d | 1, 2, 3 | | 7 | Reservation detail + agreement flow | 1.5d | 1, 6 | | 8 | Email composer attachments + From selector | 1d | 1, 3 | | 9 | Reminder framework polish | 1d | 1 | | 10a-e | Visual polish sweep (5 PRs across surface groups) | 3-4d | 3 | | 11 | Real-API integration tests | 1.5d | 2, 4-9 shipped | ### Critical path ``` 1 → 2 → 6 → 7 (data model → Documenso → wizard → reservation) 1 → 3 → 4 → 5 → 9 (data model → primitives → hub → detail → reminders) 1 → 8 (composer) 3 → 10a-e (sweep) all → 11 (realapi) ``` Wall-clock minimum ~9 days; realistic with overhead ~17 days; calendar ~3.5-5 weeks. ### Acceptance gates per PR - `pnpm tsc --noEmit` and `pnpm lint` clean - Vitest unit + integration green - Playwright smoke green for surface touched - Visual baselines regenerated and reviewed in PR diff - For PRs touching external integrations (2, 6 upload, 7 contract mirror, 8 SMTP, 11): relevant `realapi` spec verified locally before merge ### Risk register | Risk | Mitigation | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Documenso v2 endpoint shape drifts from docs | PR2 validates against real v2 instance during dev; realapi spec re-runs nightly post-ship | | Visual polish scope creeps | One PR per surface group (10a-e), each independently shippable | | Cron migration changes effective behaviour | Backfill sets EOI cadence to 1 day matching today's effective; run on staging first | | Mobile responsive regressions | Visual baselines include phone-viewport snapshots; PR10e is the responsive sweep | | EOI dialog → wizard migration breaks "Generate EOI" button | Wizard launched with prefills from interest detail; PR6 includes regression spec | | AcroForm template format confuses non-technical admins | HTML default; inline help; default templates seeded | | Phase A wall-clock past 5 weeks | Tier-2 sweep items + optional realapi specs deferrable to follow-up release | ## Glossary - **Documenso** — open-source document signing service, self-hosted instance at `signatures.portnimara.dev` - **EOI** — Expression of Interest, a pre-reservation signed document - **Reservation Agreement** — contract signed when a berth reservation is committed - **Hub** — the new `/[port]/documents` page - **Watcher** — a CRM user added to a doc to receive notifications on signature events without being a signer themselves - **Signing order** — sequential index across signers; sequential mode requires lower order to sign first; parallel mode lets all sign concurrently - **Cadence** — interval in days between auto-reminders to unsigned signers - **System send / User send** — email dispatch identity: System uses port-config noreply SMTP; User uses connected personal email account (gated by admin toggle) - **Render location** — where the PDF is generated (CRM-local via HTML/AcroForm/overlay, or in Documenso). Signing is always Documenso; render location is independent.