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>
This commit is contained in:
775
docs/superpowers/specs/2026-04-28-documents-hub-design.md
Normal file
775
docs/superpowers/specs/2026-04-28-documents-hub-design.md
Normal file
@@ -0,0 +1,775 @@
|
||||
# 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):
|
||||
|
||||
```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 `<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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```sql
|
||||
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
|
||||
|
||||
```ts
|
||||
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.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.
|
||||
Reference in New Issue
Block a user