Files
pn-new-crm/docs/superpowers/specs/2026-04-28-documents-hub-design.md
Matt Ciaccio d8ac62f6f4 docs(spec): documents hub + reservation agreements + visual polish (Phase A)
Captures the brainstorm output covering:
- Documents hub at /[port]/documents replacing existing list
- Document detail page with vertical signers panel, watchers, timeline
- Generalised create-document wizard (HTML / PDF AcroForm / PDF overlay /
  Documenso-rendered + ad-hoc PDF upload)
- Reservation agreements as a doc type with new CRM-side detail page
- Email composer attachments + System-vs-User From selector (admin-gated)
- Reminder framework polish (per-template cadence, per-doc override, per-doc
  disable, per-signer manual reminders); drops interests.reminderEnabled gating
- Documenso v1.13.1/v2.x version-aware abstraction for field placement + void
- System-wide visual polish (token additions, primitive components, sweep)
- Test plan including click-everything sweep + expanded realapi round-trip
- Build sequence: 11 PRs, ~3.5 weeks critical path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:51:41 +02:00

42 KiB

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 (<StatusPill>, <KPITile>, <EmptyState>, polished <PageHeader>), 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):

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 <select>
  • Filters collapse behind [Filters] button into a sheet
  • Rows stack as cards: title + status + age, expand to show signers
  • "+ New document" floats as FAB bottom-right

Document detail page

New /[port]/documents/[id] page. No detail page exists today.

Layout

[ Breadcrumb: All documents ]

[ Header strip with gradient: title (editable inline), type pill, status pill, subtitle (subject link, creator, age) ]

[ Action bar — context-aware ]

[ Two-column body:
    Left (2fr):
      Signers panel (vertical list, replaces existing horizontal SigningProgress)
      Linked entity card
    Right (1fr):
      Watchers panel (chips + add)
      Activity timeline (from documentEvents)
      Notes (auto-saving editable text)
      Preview (PDF; tabbed Original/Signed when completed)
]

Action bar by status

  • draft[Send for signing] [Edit signers] [Delete]
  • sent | partially_signed[Send reminder to all] [Resend invite] [Cancel]
  • completed[Download signed PDF] [Email signed PDF to all signatories]
  • cancelled | rejected | expired[Duplicate]
  • Always [...] overflow: Duplicate, Move to other entity, View Documenso URL, Audit log

Signers panel (vertical, replaces horizontal stepper)

Per-row:

  • Numbered status circle (pending grey, signed green, declined red)
  • Name, email, role
  • Sent age, last-reminded age, signed timestamp
  • [Remind] button — disabled with countdown if cooldown active (24h-or-cadence) for auto mode; bypassed in manual mode
  • [Copy signing link] — copies signingUrl (hosted Documenso); overflow offers "Copy embed link" if embeddedUrl present (used by website embed at /sign/[type]/[token])
  • [...] overflow: Resend invite, View signing history, Replace email (draft only)
  • Sequential mode: only current pending signer's [Remind] active; others greyed with tooltip

Send-signed-PDF email flow

Action visible only when status='completed' AND signedFileId IS NOT NULL.

Click opens email composer drawer prefilled:

  • From: dropdown defaulting to System (port-config noreply identity); Personal accounts available only when port admin enables email.allowPersonalAccountSends
  • To: union of documentSigners.signerEmail for the doc
  • Cc: empty; "Cc watchers" toggle adds users from document_watchers
  • Subject: "Signed {document type} — {document title}"
  • Body: from signed_doc_completion per-port template (new template type; default seeded for new ports)
  • Attachments: signed PDF auto-attached from documents.signedFileId (chip with filename + size; removable)

Send dispatch:

  • System path: lib/email/index.ts → sendEmail() with portId + attachments; writes documentEvents row; skips email_messages/threads writes (no IMAP sync expected)
  • User path: email-compose.service.ts existing flow; writes email_messages + thread; subject to allowPersonalAccountSends gate (server-side enforces 403 on user senderType when toggle off)

Backend additions

  • POST /api/v1/documents/[id]/cancel — calls cancelDocument service; service calls Documenso void via new client function
  • POST /api/v1/documents/[id]/remind — accepts optional { signerId }; passes auto: false to service
  • GET /api/v1/documents/[id]/watchers — list
  • POST /api/v1/documents/[id]/watchers — add { userId }
  • DELETE /api/v1/documents/[id]/watchers/[userId] — remove
  • POST /api/v1/documents/[id]/compose-completion-email — returns prefilled draft

Create-document wizard

Replaces <EoiGenerateDialog>. Single drawer/dialog, three steps.

Step 1 — Type and source

Render:  ●  Generate the PDF here (using template format below)
         ○  Use a Documenso-stored template (Documenso renders + signs)

Format (when "Generate the PDF here" selected):
         ●  HTML (write inline)
         ○  PDF (AcroForm fillable upload)
         ○  PDF (overlay positioning)

Template:  [ pick from port's templates of selected format ]
   OR
Upload PDF:  [ drop or pick file; preview renders inline ]

Document type: [ auto-derived from template, or picked from DOCUMENT_TYPES enum ]

Signing destination is always Documenso. The "Render in CRM" vs "Render in Documenso" axis is about PDF generation only.

Step 2 — Recipients

Attached to: [ Interest #142 — Smith family  Change ]
   ↑ pre-filled if launched from a detail page

Signers: (hidden for documenso-render path; signers embedded in template)
   ① name  email  role       [✕]
   ② name  email  role       [✕]
   [+ Add signer] (autocomplete from clients/companies/users; or manual entry)
   Drag to reorder; signing-order assigned by row position

Signing mode: ● Sequential   ○ Parallel

Watchers (optional): [chips] [+ Add watcher] (CRM users)

Reminder cadence:
   ●  Use template default (every 7 days)
   ○  Override:  [_____] days
   ○  Disable for this document

[ For upload path only ]
☑  Auto-place signature fields at footer (default; refine later in Documenso)

Step 3 — Review and send

Title: [ EOI — Smith family ____________ ]   (editable; default rendered from merge tokens)
Notes (internal): [_____________]
Preview: [ rendered PDF inline · 4 pages · scrollable ]
Signing-order banner (multi-signer in-app/upload only): "Sequential — Carol must sign before Bob" [Switch to parallel]
[← Back] [Save as draft] [Send →]

Save as draft → status='draft'; [Send for signing] available later from detail page. Send → calls Documenso, status='sent', socket event fires.

Documenso version-aware field placement

For upload path, placeDefaultSignatureFields auto-positions one SIGNATURE per recipient at last-page footer (staggered to avoid overlap). User can refine in Documenso via "Customize fields in Documenso" link on detail page.

placeFields and placeDefaultSignatureFields in documenso-client.ts hide v1/v2 differences:

  • v1: POST /api/v1/documents/{id}/fields per field; pixel coordinates; requires page dimension lookup
  • v2: POST /api/v2/envelope/field/create-many bulk; percentage 0-100 coordinates; rich fieldMeta
  • Caller passes percentage; abstraction converts for v1 using cached page dimensions

createDocumentSchema extension

export const createDocumentSchema = z.object({
  source: z.enum(['template', 'upload']),
  templateId: z.string().uuid().optional(),
  uploadedFileId: z.string().uuid().optional(),

  documentType: z.enum(DOCUMENT_TYPES),
  title: z.string().min(1).max(200),
  notes: z.string().optional(),

  // Subject (exactly one required)
  interestId: z.string().uuid().optional(),
  reservationId: z.string().uuid().optional(),
  clientId: z.string().uuid().optional(),
  companyId: z.string().uuid().optional(),
  yachtId: z.string().uuid().optional(),

  // Signers (required when render=in-app or source=upload)
  signers: z.array(z.object({
    signerName: z.string().min(1),
    signerEmail: z.string().email(),
    signerRole: z.enum(['client', 'sales', 'approver', 'developer', 'other']),
    signingOrder: z.number().int().min(1),
  })).optional(),
  signingMode: z.enum(['sequential', 'parallel']).default('sequential'),

  pathway: z.enum(['documenso-template', 'inapp', 'upload']).optional(),

  watchers: z.array(z.string().uuid()).optional(),

  reminderCadenceOverride: z.number().int().min(1).max(365).nullable().optional(),
  remindersDisabled: z.boolean().default(false),

  autoPlaceFields: z.boolean().default(true),

  sendImmediately: z.boolean().default(true),
}).refine(...one-subject-FK-required...);

Template formats

Authoring paths

Format Authoring Merge fields Best for
HTML (existing) Inline rich-text editor with merge tokens Server-side substitution, rendered to PDF via pdfme Welcome letters, acknowledgments, correspondence
PDF (AcroForm fillable) Admin uploads fillable PDF; UI scans AcroForm field names; admin maps each to a merge token pdf-lib fills form at gen time EOI, Reservation Agreement, NDA
PDF (overlay positioning) Admin uploads any PDF; UI specifies merge token positions per page+x+y+fontSize pdf-lib draws text over PDF at positions Quick wins where preparing AcroForm is overkill
Documenso template reference Admin enters Documenso template ID + label None in CRM; Documenso owns it Documenso-rendered signing flows

Generator dispatch

switch (template.template_format) {
  case 'html':            generatePdf(template.body_html, mergeContext);
  case 'pdf_form':        fillAcroForm(template.source_file_id, template.field_mapping, mergeContext);
  case 'pdf_overlay':     drawOverlay(template.source_file_id, template.overlay_positions, mergeContext);
  case 'documenso_render': documenso.generateDocumentFromTemplate(template.documenso_template_id, ...);
}

All four formats end at Documenso for signing — only PDF generation location differs. Non-signature templates (welcome letters etc.) skip the upload-to-Documenso step entirely; they render to PDF then get emailed.

Admin template editor extension

Format picker added to /admin/templates editor:

  • For PDF (AcroForm): file upload field, then two-column mapping UI (AcroForm field names ↔ merge tokens autocomplete from existing MERGE_FIELDS catalog)
  • For PDF (overlay): file upload, then per-token form with page/x/y/fontSize inputs (visual placement editor deferred)
  • For Documenso template: single text input + Test connection button calling getDocumensoTemplate
  • For HTML: existing inline editor unchanged

Word (.docx) deferred

Reasons: LibreOffice headless adds significant install/memory/security surface; CloudConvert adds paid dependency and third-party data exposure; docxtemplater merge syntax incompatible with existing {{token}} convention; field placement still needs PDF flow afterwards. If marinas push back, the feasible path is .docx → server-side conversion → PDF → existing AcroForm/overlay flow. Not worth the engineering until requested.

Reservation agreements as a doc type

What differs from EOI's pattern

Aspect EOI Reservation Agreement
Subject FK interestId reservationId
Default template Documenso EOI per port Documenso reservation_agreement per port (seeded)
Default signers client + sales/approver client + port admin
Trigger Manual on interest detail Manual on reservation detail
Lifecycle integration None Active reservations without an agreement get flagged in dashboard alert
Final-PDF storage documents.signedFileId only documents.signedFileId AND mirrored to berth_reservations.contractFileId on completion

New CRM-side reservation detail page

/[port]/berth-reservations/[id] doesn't exist today (only the portal's /portal/my-reservations). Phase A builds it.

Layout:

[ Header: "Reservation #88 · M/Y Tate"  status pill  subtitle: berth, client, dates, tenure ]
[ Action bar: Activate | Generate agreement | Cancel | ... ]
[ Two columns:
    Left:  Reservation details card
           Linked interest card
           Activity timeline
    Right: Agreement card (state-dependent: no agreement / in-flight / completed)
]

Agreement card states:

  • No agreement yet: warning + [Generate agreement →]
  • In-flight (sent/partially_signed): "X/Y signed", per-signer status, [View document →] [Send reminder] [Cancel]
  • Completed: "Completed YYYY-MM-DD", [Download signed PDF] [Email to all signatories], "Signed contract attached to reservation."

Generate-agreement button launches the wizard with prefills:

  • documentType='reservation_agreement'
  • templateId=<port's default>
  • reservationId=<current>
  • Default signers from linked client + configurable port-admin user
  • Wizard step 1 pre-validated; user lands on step 2

Backend additions

  • Merge field catalog extended in src/lib/templates/merge-fields.ts:
    • {{reservation.startDate}} {{reservation.endDate}} {{reservation.tenureType}} {{reservation.termSummary}} {{reservation.signedDate}}
  • New service reservation-agreement-context.ts.buildReservationAgreementContext(reservationId, portId)
  • New seeder for default reservation_agreement template on port creation (HTML format; admins can switch to AcroForm/overlay later); template stored at assets/templates/reservation-agreement-default.html
  • Webhook handler extension: handleDocumentCompleted detects documentType='reservation_agreement' and sets berth_reservations.contractFileId = doc.signedFileId for the linked reservation
  • Dashboard alert query: active reservations without a completed agreement (LEFT JOIN against documents filtered on type+status); rows surface as a warning card

Trade-off

berth_reservations.contractFileId becomes a denormalized convenience pointer duplicated with documents.signedFileId for the linked reservation. Updating it on completion costs one extra UPDATE. Benefit: anyone querying reservations directly (portal "My Reservations") doesn't need to join through documents to know which file is the contract.

Reminder framework polish

Problems with today's logic

  1. Eligibility gated by interests.reminderEnabled — reservation agreements, NDAs, ad-hoc upload docs (no interest link) never auto-remind
  2. Hardcoded 24h cooldown — effective cadence is 1 day; can't slow down for low-urgency docs
  3. Always reminds lowest-pending signer — parallel-signing docs can't nudge a specific signer
  4. No per-doc disable

New eligibility logic

function isReminderDue(doc, template, lastReminderAt) {
  if (!['sent','partially_signed'].includes(doc.status)) return false;
  if (doc.documenso_id == null) return false;
  if (doc.reminders_disabled) return false;

  const effectiveCadence = doc.reminder_cadence_override ?? template.reminder_cadence_days;
  if (effectiveCadence === null) return false;

  if (lastReminderAt == null) return true;
  return (now - lastReminderAt) >= effectiveCadence * 24h;
}

processReminderQueue query rewritten:

SELECT d.* FROM documents d
LEFT JOIN document_templates t ON t.id = d.template_id
WHERE d.port_id = $1
  AND d.status IN ('sent','partially_signed')
  AND d.documenso_id IS NOT NULL
  AND d.reminders_disabled = false
  AND COALESCE(d.reminder_cadence_override, t.reminder_cadence_days) IS NOT NULL;

interests.reminderEnabled is dropped from the gating logic but the column stays for now (no migration). Future cleanup PR can drop the column.

sendReminderIfAllowed extended signature

export async function sendReminderIfAllowed(
  documentId: string,
  portId: string,
  options: {
    auto?: boolean; // true = cron; false (default) = manual
    signerId?: string; // optional — target a specific pending signer
  } = {},
): Promise<{ sent: boolean; reason?: string; signerId?: string }>;

Behaviour matrix:

Mode 9-16 window Cadence cooldown Manual cooldown
auto: true enforced enforced n/a
auto: false bypassed bypassed 30s client-side debounce

Per-signer logic:

  • If signerId provided in sequential-mode doc, signer must be the lowest-pending signer (otherwise reason='Signer is not next in sequence')
  • In parallel-mode doc, any pending signer can be reminded independently
  • Returns { sent, reason } so caller can show toast on skip

Admin and per-doc UI

Admin /admin/templates editor:

Auto-reminders for this template:
  ☑  Enabled    Cadence: every [_____] days  (1-365; default 7)
  ☐  Disabled (manual reminders only)

Doc detail page (Section 3) "Reminders" panel under signers, with edit drawer for per-doc override.

Visual polish system

Token additions

--radius-sm: 0.375rem (existing)
--radius-md: 0.5rem (NEW — default cards)
--radius-lg: 0.625rem (NEW — sheets, dialogs)
--radius-xl: 0.875rem (NEW — KPI tiles, hero strips)

--shadow-xs: 0 1px 2px 0 rgb(15 23 42 / 0.04)
--shadow-sm: 0 2px 4px -1px rgb(15 23 42 / 0.06)
--shadow-md: 0 4px 12px -2px rgb(15 23 42 / 0.08)
--shadow-lg: 0 12px 32px -8px rgb(15 23 42 / 0.12)
--shadow-glow: 0 0 0 4px rgb(58 123 200 / 0.12)

--gradient-brand:        linear-gradient(135deg, #3a7bc8 0%, #2f6ab5 100%)
--gradient-brand-soft:   linear-gradient(135deg, #d8e5f4 0%, #ffffff 100%)
--gradient-success:      linear-gradient(135deg, #e8f5e9 0%, #ffffff 100%)
--gradient-warning:      linear-gradient(135deg, #fef3c7 0%, #ffffff 100%)

--ease-spring:  cubic-bezier(0.34, 1.56, 0.64, 1)
--ease-smooth:  cubic-bezier(0.4, 0, 0.2, 1)
--duration-fast: 150ms
--duration-base: 200ms
--duration-slow: 300ms

All exposed as Tailwind utilities.

Existing token foundation (already in place; not changing)

  • Full HSL shadcn token system (primary, secondary, muted, accent, destructive, border, input, ring, popover, card)
  • Brand palette brand (50-700, default #3a7bc8)
  • Navy palette navy (50-600, default #1e2844 for sidebar)
  • Maritime accents: sage, mint, teal, purple with light/default/dark variants
  • Semantic success / warning with bg+border
  • Recharts chart-1 through chart-6 token system
  • Dark mode wired
  • Sidebar tokens separate from main palette

New primitive components

  • <StatusPill status="..."> — colored-by-state pill (pending grey, sent brand, partial teal, completed success, expired warning, rejected destructive, cancelled muted-darker, active success, archived muted)
  • <KPITile title value delta sparkline?> — rounded-xl, shadow-sm, gradient-brand-soft border-top accent stripe; recharts mini sparkline using --chart-1
  • <EmptyState icon title body actions> — large icon in brand-soft circle, title, body, action buttons
  • <PageHeader> polished — gradient-brand-soft background, eyebrow optional, KPI sub-line, primary action right-aligned

Component pattern updates

  • List rows: hover gradient (subtle brand-soft 4% opacity), shadow-xs lift, animation transition-all duration-base ease-smooth; row-update from socket events animates 1s fade-in highlight
  • Detail pages: two-column responsive grammar (header strip → 2fr main + 1fr side; cards stack vertical < 768px)
  • Sidebar (already dark navy): active item gets 4px brand left-edge stripe instead of bg shift; section headers smaller-caps + brand-200 text
  • Topbar: search inset shadow + brand focus ring; "+ New" trigger gets bg-gradient-brand; notification bell gets badge spring animation; user avatar gets shadow-sm + 2px white ring
  • Forms: focus ring uses --shadow-glow; primary submit buttons get bg-gradient-brand with hover scale-1.01; inline validation gets destructive-bg pill with caret pointing up

Loading skeleton system

  • List pages: 8 skeleton rows matching column widths with subtle pulse
  • Detail pages: header strip skeleton + 2-column section skeletons
  • Dashboard: KPI tile skeletons + chart skeletons
  • Replaces today's mix of "Loading..." text and spinners

Mobile responsive (full sweep)

Breakpoints:

  • < 640px (phone): single column, sticky bottom action bar, sheet overlays for filters
  • 640-1024px (tablet): single column with wider gutters, side column under main
  • ≥ 1024px (desktop): full two-column

Per-page rules:

  • List tables → card stack < 768px
  • Detail page header collapses subtitle to "Show more"
  • Tabs collapse to <select> < 640px
  • Sidebar slides over content < 1024px
  • Primary "+ New" actions float as FAB bottom-right < 640px

Test plan

Unit (tests/unit/)

  • document-reminders-cadence.test.tsisReminderDue 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.tshandleDocumentCompleted 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.