252 lines
9.5 KiB
Markdown
252 lines
9.5 KiB
Markdown
|
|
# Post-Audit Implementation Spec — 2026-05-18
|
|||
|
|
|
|||
|
|
Captures the design decisions from the post-audit conversation so the
|
|||
|
|
implementation can start without re-litigating the trade-offs. Each
|
|||
|
|
section ends with an Effort estimate.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. EOI document field overrides
|
|||
|
|
|
|||
|
|
### Goal
|
|||
|
|
|
|||
|
|
When generating an EOI, the rep should be able to override pre-filled
|
|||
|
|
field values (contact info, addresses, yacht details) while preserving
|
|||
|
|
the canonical record. Manual entries persist as tracked secondary
|
|||
|
|
values so future EOIs can pick them up from a dropdown.
|
|||
|
|
|
|||
|
|
### Design
|
|||
|
|
|
|||
|
|
**Client contact channels (email, phone):**
|
|||
|
|
|
|||
|
|
- The EOI form's email/phone fields render as a dropdown of every
|
|||
|
|
`client_contacts` row for the linked client, defaulting to the primary
|
|||
|
|
for each channel.
|
|||
|
|
- Rep types a brand-new value → on EOI save, a new `client_contacts`
|
|||
|
|
row is created with `is_primary=false`, `source='eoi-custom-input'`,
|
|||
|
|
`source_document_id=<doc-id>`. Labelled `[EOI]` on the client detail
|
|||
|
|
page contacts panel.
|
|||
|
|
- The current EOI uses the new value; future EOIs default to primary
|
|||
|
|
unless the rep explicitly picks the new row from the dropdown.
|
|||
|
|
- A "Set as default for future documents" toggle on the EOI form
|
|||
|
|
promotes the new value to `is_primary=true` (demoting the prior
|
|||
|
|
primary).
|
|||
|
|
|
|||
|
|
**Client addresses:** Same pattern via `client_addresses` (which is
|
|||
|
|
already multi-value per CLAUDE.md).
|
|||
|
|
|
|||
|
|
**Yacht name + dimensions:** Yachts are single-valued; rep needs a
|
|||
|
|
different yacht → opens a "Create yacht" modal inline, fills in name +
|
|||
|
|
dims for the new yacht record, linked to the same client/interest, tagged
|
|||
|
|
`eoi-generated`. The EOI uses the new yacht. The original yacht is
|
|||
|
|
unchanged. (No yacht_aliases / yacht_dimension_overrides table.)
|
|||
|
|
|
|||
|
|
**Interest-specific fields (rare):** Same dropdown pattern via the
|
|||
|
|
existing fields on the interest record. Custom entries promote-or-stay
|
|||
|
|
following the toggle.
|
|||
|
|
|
|||
|
|
**Audit trail:** Every override action (create-non-primary, promote-to-
|
|||
|
|
primary, create-yacht-from-eoi) emits an audit_log row with action
|
|||
|
|
`eoi_field_override` and metadata identifying the source document.
|
|||
|
|
|
|||
|
|
**Per-document override (no record-side write):** Doc-level overrides
|
|||
|
|
remain available as a checkbox — when ticked, the value lives only on
|
|||
|
|
the doc and never touches client_contacts. Default is unchecked.
|
|||
|
|
|
|||
|
|
### Schema additions
|
|||
|
|
|
|||
|
|
- `client_contacts.source text` — extend the existing enum: `'manual'`,
|
|||
|
|
`'imported'`, `'eoi-custom-input'`.
|
|||
|
|
- `client_contacts.source_document_id text references documents(id)
|
|||
|
|
on delete set null` — surfaces the originating EOI.
|
|||
|
|
- `client_addresses.source` + `source_document_id` (mirror).
|
|||
|
|
- `yachts.source` + `source_document_id` (mirror; nullable so existing
|
|||
|
|
records aren't disturbed).
|
|||
|
|
- `audit_actions` enum gains `eoi_field_override` + `promote_to_primary`.
|
|||
|
|
|
|||
|
|
### UI
|
|||
|
|
|
|||
|
|
- EOI Generate drawer: each editable field becomes either a `<Combobox>`
|
|||
|
|
(when multi-value) or `<Input>` + "Save as new …" hint (yacht).
|
|||
|
|
- Below each field: `[ ] Use only for this EOI` checkbox (default off)
|
|||
|
|
- `[ ] Set as default for future docs` checkbox (default off).
|
|||
|
|
- Client + Yacht detail panels: `[EOI]` badge on non-primary rows;
|
|||
|
|
"Set as primary" action on each.
|
|||
|
|
|
|||
|
|
### Effort
|
|||
|
|
|
|||
|
|
~1–1.5 weeks. Bundle the schema + EOI form + client/yacht detail UI
|
|||
|
|
into one PR (user picked "All at once").
|
|||
|
|
|
|||
|
|
### Open implementation questions
|
|||
|
|
|
|||
|
|
- The yacht-creation inline modal needs the existing YachtForm wired in;
|
|||
|
|
on save it tags the new yacht with the eoi-generated marker. Tag the
|
|||
|
|
yacht via `tags`? Or a dedicated `source` column? Recommend column
|
|||
|
|
for queryability.
|
|||
|
|
- Should `[EOI]` badges fade out after a TTL or stay forever? Recommend
|
|||
|
|
forever — the rep deliberately chose this label.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Reminders
|
|||
|
|
|
|||
|
|
### Goal
|
|||
|
|
|
|||
|
|
Reps can: per-interest follow-up cadence with note + time, standalone
|
|||
|
|
tasks (no entity), assignable-to-another-rep tasks. The existing rich
|
|||
|
|
`reminders` table holds the canonical data; the per-interest cadence
|
|||
|
|
on the `interests` row stays for backward compat as a quick-tick.
|
|||
|
|
|
|||
|
|
### Design
|
|||
|
|
|
|||
|
|
**Per-interest cadence (kept):**
|
|||
|
|
|
|||
|
|
- `interests.reminderEnabled` + `interests.reminderDays` retained.
|
|||
|
|
- New: `interests.reminderNote text NULL` — surfaced in the
|
|||
|
|
notification body + the inbox row.
|
|||
|
|
- The cadence fires a row into `reminders` on each tick (with
|
|||
|
|
`interest_id` set) instead of the current ad-hoc notification flow,
|
|||
|
|
unifying the inbox.
|
|||
|
|
|
|||
|
|
**Standalone tasks (new):**
|
|||
|
|
|
|||
|
|
- Rich `reminders` table already has every column we need (title, note,
|
|||
|
|
priority, due_at, assigned_to, snoozed_until, google_calendar_event_id).
|
|||
|
|
- Two UI surfaces (both submit to the same dialog component):
|
|||
|
|
- RemindersInbox top-right `[+ New task]` button.
|
|||
|
|
- Per-entity detail page (interest, client, berth, yacht): `[+ Task]`
|
|||
|
|
button inside the existing Reminders section. Linked-entity field
|
|||
|
|
pre-filled and locked.
|
|||
|
|
- The dialog: Title (required), Note (optional), Due date+time,
|
|||
|
|
Priority, Assign to (default = current rep), Linked entity
|
|||
|
|
(optional dropdown for inbox surface; locked for per-entity).
|
|||
|
|
|
|||
|
|
**Time-of-day:**
|
|||
|
|
|
|||
|
|
- New user-settings field: `digest_time_of_day time, default '09:00'`.
|
|||
|
|
Stored in user_profiles.
|
|||
|
|
- Per-reminder override: each reminder's `due_at` carries the exact
|
|||
|
|
firing moment (existing column). The dialog defaults the time picker
|
|||
|
|
to the user's `digest_time_of_day` but lets them override per row.
|
|||
|
|
- Worker scheduler: a 15-min cron tick scans `reminders` for rows whose
|
|||
|
|
`due_at <= now() AND fired_at IS NULL`, fires the notification, sets
|
|||
|
|
`fired_at`.
|
|||
|
|
|
|||
|
|
**Assignment:**
|
|||
|
|
|
|||
|
|
- `reminders.assigned_to` (existing). Dialog has an "Assign to" picker
|
|||
|
|
(port users via /api/v1/admin/users/picker), defaults to current user.
|
|||
|
|
- Inbox shows the assignee chip when not me; filter `[Mine | All my port]`.
|
|||
|
|
|
|||
|
|
### Schema additions
|
|||
|
|
|
|||
|
|
- `interests.reminder_note text NULL`
|
|||
|
|
- `user_profiles.digest_time_of_day time NOT NULL DEFAULT '09:00'`
|
|||
|
|
- `reminders.fired_at timestamptz NULL` (new — drives the worker idempotency)
|
|||
|
|
- No new tables. The existing `reminders` table covers standalone tasks.
|
|||
|
|
|
|||
|
|
### UI
|
|||
|
|
|
|||
|
|
- `<CreateReminderDialog>` component (shared).
|
|||
|
|
- RemindersInbox: `[+ New task]` button → dialog (linked entity blank).
|
|||
|
|
- Interest / client / berth / yacht detail pages: existing Reminders
|
|||
|
|
section gains `[+ Task]` button → dialog (linked entity pre-filled,
|
|||
|
|
field disabled).
|
|||
|
|
- Settings page: time picker for "default reminder time" → writes
|
|||
|
|
`user_profiles.digest_time_of_day`.
|
|||
|
|
|
|||
|
|
### Effort
|
|||
|
|
|
|||
|
|
~3–4 days. Schema migration + dialog component + 4 entity-page wires
|
|||
|
|
|
|||
|
|
- worker scheduler refactor + inbox filter.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. Supplemental info form — per-port setting
|
|||
|
|
|
|||
|
|
### Goal
|
|||
|
|
|
|||
|
|
The "Send supplemental info form" link in the auto-email should resolve
|
|||
|
|
to the marketing site when configured; fall back to a CRM-hosted route
|
|||
|
|
otherwise. Confirmed: per-port setting.
|
|||
|
|
|
|||
|
|
### Design
|
|||
|
|
|
|||
|
|
- New system_settings key: `supplemental_form_url` (per-port, optional,
|
|||
|
|
text). Defaults to NULL.
|
|||
|
|
- Link generator in the email service:
|
|||
|
|
```ts
|
|||
|
|
const url = cfg.supplementalFormUrl
|
|||
|
|
? `${cfg.supplementalFormUrl}?token=${raw}`
|
|||
|
|
: `${env.APP_URL}/supplemental/${raw}`;
|
|||
|
|
```
|
|||
|
|
- Existing `/supplemental/[token]` CRM route stays as the fallback. Add
|
|||
|
|
a "Loading…" skeleton + dual-mode copy ("If you don't see your
|
|||
|
|
details, contact your rep").
|
|||
|
|
- Admin UI: add the field to `/admin/email/page.tsx` (or a new
|
|||
|
|
`/admin/supplemental/page.tsx`) — single text input with the help
|
|||
|
|
hint "Leave blank to use the built-in CRM page."
|
|||
|
|
|
|||
|
|
### Effort
|
|||
|
|
|
|||
|
|
~2 hours (single setting + 1 admin field + link resolver).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. Documenso phases 2 → 7 → 5 (you picked Phase 7 first)
|
|||
|
|
|
|||
|
|
### Phase 7 — Project Director RBAC (~1h)
|
|||
|
|
|
|||
|
|
- Add "Linked to CRM user" dropdown in `/admin/documenso/page.tsx`
|
|||
|
|
pointing at the existing `developer_user_id` + `approver_user_id`
|
|||
|
|
settings.
|
|||
|
|
- Auto-fill name/email from the selected user (read via
|
|||
|
|
/api/v1/admin/users/picker).
|
|||
|
|
- Webhook handler in `src/app/api/webhooks/documenso/route.ts`: when an
|
|||
|
|
event arrives for the developer or approver, also fire an in-CRM
|
|||
|
|
`documenso:signed` notification routed to the linked user's CRM
|
|||
|
|
notifications inbox.
|
|||
|
|
|
|||
|
|
### Phase 2 — Webhook handler enhancement (~3–4h)
|
|||
|
|
|
|||
|
|
- Cascading "your turn" emails: when signer N completes, fire an
|
|||
|
|
invitation email to signer N+1 (sequential signing only).
|
|||
|
|
- On-completion PDF distribution: when status flips to COMPLETED,
|
|||
|
|
email the signed PDF to all `documents.completion_cc_emails`.
|
|||
|
|
- Token-based recipient matching: prefer `signing_token` over email
|
|||
|
|
for webhook → signer resolution (handles aliased emails).
|
|||
|
|
- Idempotency lock: replace the current body-hash dedup with a
|
|||
|
|
composite `(documensoDocumentId, recipientEmail, eventType)` unique
|
|||
|
|
constraint on documentEvents.
|
|||
|
|
- Schema is already in place from Phase 1 — this is pure handler logic.
|
|||
|
|
|
|||
|
|
### Phase 5 — Embedded signing URL verification (~1–2h)
|
|||
|
|
|
|||
|
|
- Confirm the marketing site's `/sign/<type>/<token>` page handles
|
|||
|
|
every signer-role × documentType combo.
|
|||
|
|
- Update `signerMessages` map in the signing-invitation email template
|
|||
|
|
to surface role-specific copy.
|
|||
|
|
- Apply nginx CORS block from the integration audit (constrain
|
|||
|
|
Documenso webhook origin).
|
|||
|
|
|
|||
|
|
### Effort total
|
|||
|
|
|
|||
|
|
~6–7h across the three phases. Phase 4 (field placement UI, 10–14h)
|
|||
|
|
stays deferred — covered separately by the PDF template editor work
|
|||
|
|
you picked Phases 1+2 for.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## What I'll build first
|
|||
|
|
|
|||
|
|
Per your sequencing:
|
|||
|
|
|
|||
|
|
1. Documenso Phase 7 (~1h) — unblock the linked-user signing UX.
|
|||
|
|
2. Supplemental form per-port setting (~2h) — small win.
|
|||
|
|
3. Documenso Phase 2 (~3–4h) — meaningful UX improvement.
|
|||
|
|
4. Documenso Phase 5 (~1–2h) — security + role copy.
|
|||
|
|
5. EOI field overrides + reminders (~1.5 weeks combined) — the big
|
|||
|
|
ones, picked up after the Documenso quick wins land.
|