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>
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]/documentshub 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]/dataprefill endpoint,/api/webhook/document-signedcallback 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
.docxtemplate 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.reminderEnabledgating 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 pathscreateFromUpload(portId, data, meta)— new upload-driven path; calls DocumensocreateDocument, stores file in MinIO viafilesservice, mirrors todocuments+documentSigners, optionally callssendDocumentifsendImmediatelycancelDocument(documentId, portId, meta)— user-initiated cancel; calls Documenso void, updates DB status, logs eventcomposeSignedDocEmail(documentId, portId)— returns prefilled{ to, cc, subject, body, attachments, defaultSenderType }for the composergetDocumentDetail(id, portId)— single-roundtrip aggregator returning doc + signers + events + watchers + linked-entity summary
-
document-templates.ts:generateAndSignextended for newtemplate_formatvaluesfillAcroForm(sourceFile, fieldMapping, mergeContext)— pdf-lib AcroForm filldrawOverlay(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 placementplaceDefaultSignatureFields(docId, recipientIds, portId?)— auto-position one SIGNATURE per recipient at footervoidDocument(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 optionalsignerIdandauto: booleanprocessReminderQueue(portId)— query rewritten arounddocuments.reminder_cadence_override ?? template.reminder_cadence_days; dropsinterests.reminderEnabledgating
-
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()withportId+ attachments; logsdocumentEventsrowsigned_doc_emailed; skipsemail_messages/email_threadswrites - User path: existing flow, with attachments resolution from
filestable - Port-isolation: cross-port
fileIdreturns 403
- Validator extended:
-
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
documentSignersrow matchingsigner_email = current user emailANDstatus = 'pending' - Completed —
status IN ('completed','signed') - Expired —
status = 'expired'OR (status IN ('sent','partially_signed')ANDexpires_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_viewstable
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 watcherautocomplete - 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]— copiessigningUrl(hosted Documenso); overflow offers "Copy embed link" ifembeddedUrlpresent (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.signerEmailfor the doc - Cc: empty; "Cc watchers" toggle adds users from
document_watchers - Subject:
"Signed {document type} — {document title}" - Body: from
signed_doc_completionper-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; writesdocumentEventsrow; skips email_messages/threads writes (no IMAP sync expected) - User path:
email-compose.service.tsexisting flow; writes email_messages + thread; subject toallowPersonalAccountSendsgate (server-side enforces 403 on user senderType when toggle off)
Backend additions
POST /api/v1/documents/[id]/cancel— callscancelDocumentservice; service calls Documenso void via new client functionPOST /api/v1/documents/[id]/remind— accepts optional{ signerId }; passesauto: falseto serviceGET /api/v1/documents/[id]/watchers— listPOST /api/v1/documents/[id]/watchers— add{ userId }DELETE /api/v1/documents/[id]/watchers/[userId]— removePOST /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}/fieldsper field; pixel coordinates; requires page dimension lookup - v2:
POST /api/v2/envelope/field/create-manybulk; percentage 0-100 coordinates; richfieldMeta - 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_FIELDScatalog) - 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_agreementtemplate on port creation (HTML format; admins can switch to AcroForm/overlay later); template stored atassets/templates/reservation-agreement-default.html - Webhook handler extension:
handleDocumentCompleteddetectsdocumentType='reservation_agreement'and setsberth_reservations.contractFileId = doc.signedFileIdfor 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
- Eligibility gated by
interests.reminderEnabled— reservation agreements, NDAs, ad-hoc upload docs (no interest link) never auto-remind - Hardcoded 24h cooldown — effective cadence is 1 day; can't slow down for low-urgency docs
- Always reminds lowest-pending signer — parallel-signing docs can't nudge a specific signer
- 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
signerIdprovided 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#1e2844for sidebar) - Maritime accents:
sage,mint,teal,purplewith light/default/dark variants - Semantic
success/warningwith 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 getbg-gradient-brandwith 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.ts—isReminderDuemath; manual-vs-auto window/cooldown bypassdocumenso-place-fields.test.ts— v1/v2 dispatch (mocked HTTP); coord normalization; default field staggering for 1/2/3/5 recipientsemail-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—handleDocumentCompletedmirrorssignedFileIdtoberth_reservations.contractFileIdonly forreservation_agreementtype - 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_agreementtemplate (HTML format) - Seed default
signed_doc_completiontemplate - Seed one in-flight EOI doc with two pending signers (for hub-tab tests)
- Seed one
berth_reservationwithstatus='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 --noEmitandpnpm lintclean- 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
realapispec 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]/documentspage - 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.