diff --git a/docs/POST-AUDIT-SPEC-2026-05-18.md b/docs/POST-AUDIT-SPEC-2026-05-18.md new file mode 100644 index 00000000..e217303e --- /dev/null +++ b/docs/POST-AUDIT-SPEC-2026-05-18.md @@ -0,0 +1,251 @@ +# 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=`. 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 `` + (when multi-value) or `` + "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 + +- `` 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//` 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. diff --git a/docs/deal-pulse-trigger-audit.md b/docs/deal-pulse-trigger-audit.md new file mode 100644 index 00000000..50febc6a --- /dev/null +++ b/docs/deal-pulse-trigger-audit.md @@ -0,0 +1,134 @@ +# Deal Pulse & Pipeline Trigger Audit — 2026-05-18 + +Per MANUAL-TESTING-BACKLOG-2026-05-15 §4.15: map every place that +moves an interest's pipeline stage OR contributes to the deal-pulse +score, and call out the gaps. + +--- + +## 1. Pipeline-stage auto-advance — call-site map + +`advanceStageIfBehind(interestId, portId, target, meta, reason?)` is +the canonical "advance if not already past target" helper. The +`*Gated` variant honours the per-port `stage_advance_rules` setting +(auto / suggest / off). + +| Trigger | Caller | Target | File:line | Gated? | +| ------------------------------------ | ----------------------------- | --------------------------------------------------------- | --------------------------------------- | -------------------------------- | +| EOI sent (manual rep generate) | `generateAndSign` | `eoi` | `documents.service.ts:843` | gated (eoi_sent) | +| EOI signed (all parties via webhook) | `handleDocumentCompleted` | `reservation` | `documents.service.ts:1610` | gated (eoi_signed) | +| Reservation signed | `handleDocumentCompleted` | `reservation` (no change, stage stays + status sub-flips) | `documents.service.ts:1640` | gated (reservation_signed) | +| Deposit received in full | `recordPayment` | `deposit_paid` | `payments.service.ts:134` | gated (deposit_received) | +| Sales contract signed | `handleDocumentCompleted` | `contract` | `documents.service.ts:1671` | gated (contract_signed) | +| Deposit invoice paid (alt path) | `markInvoicePaid` | `deposit_paid` | `invoices.ts:684` | gated (deposit_received) | +| Custom document upload | `confirmCustomDocumentUpload` | document-type-specific (eoi/reservation/contract) | `custom-document-upload.service.ts:354` | **NOT gated** (uses base helper) | +| External-eoi mark-as-signed | inline in handler | `reservation` | `documents.service.ts:859` | **NOT gated** | +| Externally-signed contract | inline in handler | `contract` | `documents.service.ts:971` | **NOT gated** | +| Manual stage move | `changeInterestStage` | any (with override) | `interests.service.ts:840` | manual / not gated | + +### Gaps flagged + +- **External-signed paths bypass the per-port rules.** A port set to + `suggest` for `eoi_signed` still gets an auto-advance when the rep + marks the doc externally signed. Decision needed: should the rules + table also gate the external-signed paths? Argument for yes: the + rep's intent ("I just want to mark this signed") is the same as + the webhook case. Argument for no: the rep is explicitly choosing + to bypass the digital flow, so an auto-advance is what they expect. +- **Custom document upload is not gated.** Same trade-off as above. +- **No stage rollback on rejection.** When a signer declines an EOI + (`handleDocumentRejected`), the doc flips to `rejected` but the + interest stays at `eoi`. Confirm: this is correct — the deal + isn't dead, the EOI is. Rep should regenerate. **Verdict: keep + as-is.** +- **No stage rollback on cancel.** When the rep cancels an in-flight + EOI, the doc flips to `cancelled` and the interest stays at `eoi`. + Decision needed: should the interest roll back to `qualified` + when the only EOI is cancelled with no replacement? + **Recommendation: NO** — keeps history honest; a cancel is the + rep's deliberate signal that they're regenerating, not retreating. + +--- + +## 2. Deal-pulse signals — `computeDealHealth` map + +Source: `src/lib/services/deal-health.ts`. Each `signals.push` site +documented with its trigger condition + score delta: + +| Signal | Delta | Condition | File:line | +| ------------------- | -------------------- | --------------------------------------------------- | ------------------ | +| `active_engagement` | +5 | Any contact-log entries in last 7 days | deal-health.ts:101 | +| `contact_recent` | +20 | `dateLastContact <= 7 days` ago | deal-health.ts:115 | +| `contact_warm` | +10 | `dateLastContact <= 14 days` (else of above) | deal-health.ts:122 | +| `contact_stale` | -15 | `dateLastContact >= 30 days` | deal-health.ts:129 | +| `stage_progress` | +10/+20/+30 (capped) | Per pipelineStage index | deal-health.ts:142 | +| `stuck_top_funnel` | -10 | `firstDays >= 30` AND stage in {enquiry, qualified} | deal-health.ts:157 | +| `eoi_awaiting` | -10 | `eoiSentDays >= 14` AND not signed | deal-health.ts:173 | +| `deposit_pending` | -10 | reservation signed >= 21d AND no deposit | deal-health.ts:184 | +| `contract_awaiting` | -10 | contract sent >= 14d AND not signed | deal-health.ts:200 | + +### Positive signals that are MISSING (gaps) + +- **EOI sent** — no `eoi_sent_recent` signal. Sending an EOI is the + single biggest "this deal just got serious" moment but the score + doesn't move when it happens. **Recommendation: +15 at < 7 days.** +- **Deposit received** — same gap. A deposit landing should bump the + score significantly. **Recommendation: +20, decays over 30 days.** +- **Contract signed** — terminal positive event; should ladder the + deal to its max. **Recommendation: +30 at < 14 days.** + +### Negative signals that are MISSING (gaps) + +- **Signer declined / EOI rejected** — when the §4.13 rejection path + fires, the score should drop noticeably (the deal is suddenly at + risk). **Recommendation: -25, decays over 14 days.** +- **Interest archived-and-unarchived cycle** — zombie deals that + bounce in and out should be flagged. Detect via the audit-log + archive/restore pattern. **Recommendation: -10 if archived+restored + within last 30 days.** +- **Reservation cancelled** — similar to EOI rejected; signals the + deal is at risk. **Recommendation: -20.** +- **Berth status flipped to sold-to-other** — the deal's primary + berth was sold to a different interest. **Recommendation: -30 + (catastrophic).** +- **Signer engagement** — Documenso fires `RECIPIENT_VIEWED` + webhooks (we store `openedAt`). A signer who opened but didn't + sign in 7+ days = stalling. **Recommendation: -5 per stalling + signer.** + +### Cadence escalation (currently flat) + +- `eoi_awaiting` and `contract_awaiting` both apply a flat -10 at + the 14-day threshold. **Recommendation: ladder to -20 at 21d, -30 + at 30d** so prolonged stalling shows up more visibly. + +--- + +## 3. Heat tooltip explainer copy + +The DealPulseChip popover (`src/components/interests/deal-pulse-chip.tsx`) +references signals by name. With the gaps above closed, the +tooltip's enumerated list needs the new signals added so the in-app +copy matches the computation. + +The new `/docs/deal-pulse` explainer page (shipped this wave, §7.1) +should also be kept in sync with the signal set. + +--- + +## 4. Suggested fix wave (decisions needed from Matt) + +Per the doc structure, these are the punch-list items in priority order: + +1. **Ship the positive signals (eoi_sent, deposit_received, contract_signed).** + Biggest visible win. ~1.5h. +2. **Ship the rejection / risk signals (eoi_rejected, reservation_cancelled, berth_sold_to_other).** + Pairs naturally with the §4.13 rejection cascade we shipped this + wave. ~2h. +3. **Ship the cadence escalation (eoi_awaiting / contract_awaiting laddered scoring).** + ~30 min. +4. **Decide on the external-signed-paths gating question.** +5. **Decide on the cancel-stage-rollback question.** + +Each is small individually; combined the deal-pulse model gets meaningfully +more accurate. Suggest bundling 1–3 into one PR for review economy. diff --git a/src/app/api/v1/admin/audit/export/route.ts b/src/app/api/v1/admin/audit/export/route.ts new file mode 100644 index 00000000..2606cee4 --- /dev/null +++ b/src/app/api/v1/admin/audit/export/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { searchAuditLogs } from '@/lib/services/audit-search.service'; + +/** + * M-AU03 — CSV export of audit log search results. + * + * Accepts the same query-string filters as `GET /api/v1/admin/audit` + * (q, userId, action, entityType, entityId, severity, source, from, to) + * and streams up to 10 000 rows back as a CSV download. The 10k cap + * keeps the response under a couple of megabytes; reps wanting deeper + * history should narrow the filter or run multiple exports. + * + * Permission gate matches the read endpoint: `admin.view_audit_log`. + */ +export const GET = withAuth( + withPermission('admin', 'view_audit_log', async (req, ctx) => { + try { + const url = new URL(req.url); + const params = url.searchParams; + const parseDate = (v: string | null): Date | undefined => { + if (!v) return undefined; + const d = new Date(v); + return Number.isFinite(d.getTime()) ? d : undefined; + }; + + // Cap the export at 10 000 rows. Anyone needing deeper history + // can scroll through the paginated UI or narrow the date range. + const HARD_CAP = 10_000; + + let collected: Awaited>['rows'] = []; + let cursor: { createdAt: Date; id: string } | undefined; + // Run a small loop so we paginate through the cursor-based search + // service to fill up to HARD_CAP rows. + while (collected.length < HARD_CAP) { + const remaining = HARD_CAP - collected.length; + const page = await searchAuditLogs({ + portId: ctx.portId, + q: params.get('q') ?? undefined, + userId: params.get('userId') ?? undefined, + action: params.get('action') ?? undefined, + entityType: params.get('entityType') ?? undefined, + entityId: params.get('entityId') ?? undefined, + severity: params.get('severity') ?? undefined, + source: params.get('source') ?? undefined, + from: parseDate(params.get('from')), + to: parseDate(params.get('to')), + limit: Math.min(remaining, 500), + cursor, + }); + collected = collected.concat(page.rows); + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + + const csv = buildCsv(collected); + const filename = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; + return new NextResponse(csv, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); + +/** + * RFC 4180 CSV serializer. Escapes embedded quotes by doubling them and + * wraps any field containing comma / quote / newline in double-quotes. + * Trailing CRLF terminator per spec. + */ +function buildCsv(rows: Awaited>['rows']): string { + const headers = [ + 'createdAt', + 'id', + 'portId', + 'userId', + 'action', + 'entityType', + 'entityId', + 'severity', + 'source', + 'ipAddress', + 'userAgent', + 'metadata', + 'oldValue', + 'newValue', + ]; + + const escape = (v: unknown): string => { + if (v === null || v === undefined) return ''; + const s = typeof v === 'object' ? JSON.stringify(v) : String(v); + if (/[",\n\r]/.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; + }; + + const lines = [headers.join(',')]; + for (const r of rows) { + lines.push( + [ + r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt, + r.id, + r.portId, + r.userId, + r.action, + r.entityType, + r.entityId, + r.severity, + r.source, + r.ipAddress, + r.userAgent, + r.metadata, + r.oldValue, + r.newValue, + ] + .map(escape) + .join(','), + ); + } + return lines.join('\r\n') + '\r\n'; +} diff --git a/src/app/api/v1/interests/[id]/timeline/route.ts b/src/app/api/v1/interests/[id]/timeline/route.ts index c1d7b694..8af93bcd 100644 --- a/src/app/api/v1/interests/[id]/timeline/route.ts +++ b/src/app/api/v1/interests/[id]/timeline/route.ts @@ -208,6 +208,77 @@ function buildAuditDescription( if (action === 'update' && newValue?.pipelineStage) { return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`; } - if (action === 'update') return 'Interest updated'; + if (action === 'update') { + // §1.1: surface which field(s) changed instead of a generic + // "Interest updated". We have the new-value bag in audit_logs; + // human-friendly labels for the most common fields. + return describeUpdateDiff(newValue); + } return action; } + +/** + * Render a "leadCategory: hot_lead, source: website" style description from + * an audit log's newValue payload. Filters out audit-internal fields, + * passes through human-friendly labels for known fields, falls back to + * the raw key name when the field isn't in the catalog. + */ +function describeUpdateDiff(newValue: Record | null): string { + if (!newValue) return 'Interest updated'; + + // Audit-internal / housekeeping fields skipped from the timeline copy. + const SKIP = new Set(['updatedAt', 'createdAt', 'id', 'portId']); + + const FIELD_LABELS: Record = { + leadCategory: 'lead category', + source: 'source', + assignedTo: 'owner', + yachtId: 'yacht', + berthId: 'primary berth', + eoiDocStatus: 'EOI status', + reservationDocStatus: 'reservation status', + contractDocStatus: 'contract status', + dateEoiSent: 'EOI sent date', + dateEoiSigned: 'EOI signed date', + dateReservationSigned: 'reservation signed date', + dateContractSent: 'contract sent date', + dateContractSigned: 'contract signed date', + depositExpectedAmount: 'expected deposit', + depositExpectedCurrency: 'deposit currency', + desiredLengthFt: 'desired length', + desiredWidthFt: 'desired width', + desiredDraftFt: 'desired draft', + desiredLengthM: 'desired length (m)', + desiredWidthM: 'desired width (m)', + desiredDraftM: 'desired draft (m)', + reminderEnabled: 'follow-up reminder', + reminderDays: 'reminder cadence', + reminderNote: 'reminder note', + outcome: 'outcome', + }; + + const changed: string[] = []; + for (const [key, value] of Object.entries(newValue)) { + if (SKIP.has(key)) continue; + if (key === 'pipelineStage') continue; // handled by the earlier branch + const label = FIELD_LABELS[key] ?? key; + const formatted = formatDiffValue(value); + changed.push(formatted ? `${label} → ${formatted}` : label); + } + + if (changed.length === 0) return 'Interest updated'; + if (changed.length === 1) return `Updated ${changed[0]}`; + if (changed.length <= 3) return `Updated ${changed.join(', ')}`; + return `Updated ${changed.slice(0, 3).join(', ')} and ${changed.length - 3} more`; +} + +function formatDiffValue(v: unknown): string { + if (v === null || v === undefined) return 'cleared'; + if (typeof v === 'boolean') return v ? 'on' : 'off'; + if (typeof v === 'number') return String(v); + if (typeof v === 'string') { + // Truncate verbose strings so the timeline line stays one row. + return v.length > 40 ? `${v.slice(0, 37)}…` : v; + } + return ''; +} diff --git a/src/app/api/v1/me/ports/route.ts b/src/app/api/v1/me/ports/route.ts index 1456cdbc..66e1ff05 100644 --- a/src/app/api/v1/me/ports/route.ts +++ b/src/app/api/v1/me/ports/route.ts @@ -1,23 +1,42 @@ import { NextResponse } from 'next/server'; +import { headers } from 'next/headers'; import { eq } from 'drizzle-orm'; -import { withAuth } from '@/lib/api/helpers'; +import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { ports as portsTable } from '@/lib/db/schema/ports'; import { userPortRoles, userProfiles } from '@/lib/db/schema/users'; import { errorResponse } from '@/lib/errors'; -// A17: bootstrap-friendly ports list for the calling user — sales-reps -// and viewers can hit this without the super-admin gate that blocks -// `/api/v1/admin/ports`. Returns only the ports the user actually has -// access to (super-admin sees every active port). -export const GET = withAuth(async (_req, ctx) => { +/** + * Bootstrap-friendly ports list for the calling user. + * + * M-NEW-1: this endpoint INTENTIONALLY skips `withAuth`'s port-context + * requirement. Callers hit /me/ports specifically to LEARN which ports + * they have access to — they can't have selected one yet, so the + * X-Port-Id header is by definition absent on the first call. Pre-fix + * this meant non-super-admins got a 400 "Port context required" and + * the client had to special-case the response shape. + * + * Auth is still enforced (session check); permissions logic skipped + * because the endpoint exposes only IDs+slugs+names of ports the user + * is already a member of — same surface area as a `me` profile read. + */ +export async function GET() { try { - const profile = await db.query.userProfiles.findFirst({ - where: eq(userProfiles.userId, ctx.userId), - }); + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } - if (profile?.isSuperAdmin) { + const profile = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.userId, session.user.id), + }); + if (!profile || !profile.isActive) { + return NextResponse.json({ error: 'Account disabled' }, { status: 403 }); + } + + if (profile.isSuperAdmin) { const all = await db.query.ports.findMany({ where: eq(portsTable.isActive, true), orderBy: portsTable.name, @@ -27,7 +46,7 @@ export const GET = withAuth(async (_req, ctx) => { } const memberships = await db.query.userPortRoles.findMany({ - where: eq(userPortRoles.userId, ctx.userId), + where: eq(userPortRoles.userId, profile.userId), with: { port: { columns: { id: true, slug: true, name: true } } }, }); const data = memberships.map((m) => m.port); @@ -35,4 +54,4 @@ export const GET = withAuth(async (_req, ctx) => { } catch (error) { return errorResponse(error); } -}); +} diff --git a/src/app/docs/deal-pulse/page.tsx b/src/app/docs/deal-pulse/page.tsx new file mode 100644 index 00000000..d8362559 --- /dev/null +++ b/src/app/docs/deal-pulse/page.tsx @@ -0,0 +1,145 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Deal Pulse & Heat — Port Nimara CRM', + description: + 'How the deal pulse chip + heat score work: signals, calibration, and what to do when a deal goes cold.', +}; + +/** + * §7.1 — public explainer page for the Deal Pulse + Heat scoring model. + * Linked from the popover in `deal-pulse-chip.tsx` ("Full guide"). Kept + * intentionally text-heavy + jargon-free so a new sales rep can read + * once and internalize the model without bouncing back to the kanban. + * + * Public route (no auth) so external docs links and email signatures + * resolve cleanly. The route lives outside (dashboard) for that reason + * — middleware lets `/docs/...` through. + */ +export default function DealPulseDocsPage() { + return ( +
+
+

+ Port Nimara CRM · Sales playbook +

+

Deal Pulse & Heat

+

+ What the chip on every interest card actually means, how it's scored, and how to act + on it. +

+
+ +
+

The chip in one sentence

+

+ The colored chip on each interest is a fast read of{' '} + how hot the deal is right now based on what's been happening on it + lately — not a prediction, not an AI score, just a mechanical rollup of recent activity. +

+
+ +
+

Levels

+
+
+
Hot
+
+ Recent inbound contact, multiple touches in the last week, or a milestone (EOI sent, + deposit received) inside the last 7 days. Default-sort lists put these at the top. +
+
+
+
Warm
+
+ Activity in the last 14–30 days. The deal isn't neglected but the cadence has + slowed — usually means a follow-up reminder is the right next action. +
+
+
+
Cold
+
+ No movement in 30+ days. Time to either re-engage with intent or close the deal as + lost so the kanban stays honest. +
+
+
+
+ +
+

What feeds the score

+
    +
  • + Recency of last contact log entry. Inbound (client replied) counts more + than outbound (rep reached out). +
  • +
  • + Furthest pipeline stage reached. A deal at EOI scores higher than one + at Enquiry, all else equal. +
  • +
  • + Count of related interests on the same client. Volume signals intent. +
  • +
  • + Count of EOIs sent. Multiple EOIs = multiple berths under serious + consideration. +
  • +
  • + Time at current stage. Stagnation drags the score down even if other + signals look good — a deal stuck at Reservation for six weeks should not read hot. +
  • +
+

+ The exact weights live in system_settings under + heat_weight_* keys and can be tuned per-port from the + admin Settings page. +

+
+ +
+

What to do with each level

+
    +
  • + Hot: Don't lose the momentum. Send the next document or schedule + the in-person visit while they're engaged. +
  • +
  • + Warm: Log a follow-up contact attempt; set a reminder if you're + waiting on them. +
  • +
  • + Cold: Either "Reopen with a fresh hook" (price drop, new + inventory, event invite) or close the deal so the pipeline reflects reality. +
  • +
+
+ +
+

FAQ

+
+ Does this use AI? +

+ No. It's a deterministic SQL rollup of contact logs, milestones, and stage + transitions. The same inputs always produce the same chip color. +

+
+
+ + Can I override the chip on a specific deal? + +

+ Not directly — the chip is a read-only summary. To change it, change the inputs: log a + contact, advance a stage, or close the deal. +

+
+
+ How often does it refresh? +

+ On every interest write. The kanban + list pages query a live materialized rollup, so + you should see the chip move within a few seconds of any update. +

+
+
+
+ ); +} diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index 09a2513e..51742bd5 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; import { formatDistanceToNow } from 'date-fns'; -import { History, Search, X } from 'lucide-react'; +import { Download, History, Search, X } from 'lucide-react'; import { toast } from 'sonner'; import { DataTable } from '@/components/shared/data-table'; @@ -548,8 +548,31 @@ export function AuditLogList() { /> + {/* M-AU03: CSV export inherits the current filter set. The + endpoint streams up to 10 000 rows; reps wanting deeper + history narrow the filter first. */} + {hasActiveFilter ? ( - diff --git a/src/components/documents/document-detail.tsx b/src/components/documents/document-detail.tsx index aa42e3df..cde42c4c 100644 --- a/src/components/documents/document-detail.tsx +++ b/src/components/documents/document-detail.tsx @@ -5,7 +5,23 @@ import Link from 'next/link'; import type { Route } from 'next'; import { useRouter } from 'next/navigation'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { ArrowLeft, Bell, Download, Mail, Send, Trash2, UserPlus, X } from 'lucide-react'; +import { + ArrowLeft, + Bell, + CheckCircle2, + ChevronDown, + Clock, + Download, + Eye, + FileText, + Mail, + Send, + Trash2, + UserPlus, + X, + XCircle, +} from 'lucide-react'; +import { format, formatDistanceToNowStrict } from 'date-fns'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -15,7 +31,7 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useConfirmation } from '@/hooks/use-confirmation'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; -import { cleanSignerName } from '@/components/documents/signing-progress'; +import { SigningProgress } from '@/components/documents/signing-progress'; import { Select, SelectContent, @@ -76,12 +92,20 @@ interface DetailWatcher { addedAt: string; } +interface DetailLinked { + interest: { id: string; clientName: string | null } | null; + client: { id: string; fullName: string } | null; + yacht: { id: string; name: string } | null; + company: { id: string; name: string } | null; +} + interface DetailResponse { data: { document: DetailDoc; signers: DetailSigner[]; events: DetailEvent[]; watchers: DetailWatcher[]; + linked: DetailLinked; }; } @@ -98,12 +122,6 @@ const STATUS_PILL_MAP: Record = { declined: 'declined', }; -const SIGNER_PILL_MAP: Record = { - pending: 'pending', - signed: 'signed', - declined: 'declined', -}; - interface DocumentDetailProps { documentId: string; portSlug: string; @@ -111,7 +129,6 @@ interface DocumentDetailProps { export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { const router = useRouter(); - const queryClient = useQueryClient(); const [isCancelling, setIsCancelling] = useState(false); const { confirm, dialog: confirmDialog } = useConfirmation(); @@ -162,36 +179,13 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { ); } - const { document: doc, signers, events, watchers } = data.data; + const { document: doc, signers, events, watchers, linked } = data.data; - const handleRemind = async (signerId: string) => { - try { - await apiFetch(`/api/v1/documents/${documentId}/remind`, { - method: 'POST', - body: { signerId }, - }); - toast.success('Reminder sent'); - queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] }); - } catch (err) { - toastError(err); - } - }; - - // #67: state-aware action button. When a signer has no `invitedAt` - // they've never been mailed — fire the initial invitation (the same - // route the EOI tab uses; handles v2 distribute-or-self-heal). - const handleSendInvitation = async (signerId: string) => { - try { - await apiFetch(`/api/v1/documents/${documentId}/send-invitation`, { - method: 'POST', - body: { signerId }, - }); - toast.success('Invitation sent'); - queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] }); - } catch (err) { - toastError(err); - } - }; + // #67: signer-row "Send invitation" / "Send reminder" handlers used + // to live here on the doc-detail page directly. The Signers section + // now reuses , which owns those handlers internally + // (calls the same /remind and /send-invitation routes). The wrappers + // were intentionally removed in the doc-detail polish wave. const handleCancel = async () => { const ok = await confirm({ @@ -227,17 +221,47 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { const isInFlight = ['sent', 'partially_signed'].includes(doc.status); const isComplete = ['completed', 'signed'].includes(doc.status); - const subjectLink = doc.reservationId - ? { href: `/${portSlug}/berth-reservations/${doc.reservationId}`, label: 'Reservation' } - : doc.interestId - ? { href: `/${portSlug}/interests/${doc.interestId}`, label: 'Interest' } - : doc.clientId - ? { href: `/${portSlug}/clients/${doc.clientId}`, label: 'Client' } - : doc.yachtId - ? { href: `/${portSlug}/yachts/${doc.yachtId}`, label: 'Yacht' } - : doc.companyId - ? { href: `/${portSlug}/companies/${doc.companyId}`, label: 'Company' } - : null; + // #67: linked-entity rows now show the entity TYPE + NAME (resolved + // server-side in getDocumentDetail) so the card reads "Interest — + // Matt Ciaccio" instead of "Interest →". Multiple linked entities + // render as a chip row; nothing renders when there's nothing to + // link. + const linkedRows: Array<{ href: string; label: string; sub: string | null }> = []; + if (doc.reservationId) { + linkedRows.push({ + href: `/${portSlug}/berth-reservations/${doc.reservationId}`, + label: 'Reservation', + sub: null, + }); + } + if (linked.interest) { + linkedRows.push({ + href: `/${portSlug}/interests/${linked.interest.id}`, + label: 'Interest', + sub: linked.interest.clientName, + }); + } + if (linked.client) { + linkedRows.push({ + href: `/${portSlug}/clients/${linked.client.id}`, + label: 'Client', + sub: linked.client.fullName, + }); + } + if (linked.yacht) { + linkedRows.push({ + href: `/${portSlug}/yachts/${linked.yacht.id}`, + label: 'Yacht', + sub: linked.yacht.name, + }); + } + if (linked.company) { + linkedRows.push({ + href: `/${portSlug}/companies/${linked.company.id}`, + label: 'Company', + sub: linked.company.name, + }); + } return (
@@ -295,93 +319,35 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { {signers.length === 0 ? (

No signers attached.

) : ( -
    - {signers.map((signer, idx) => ( -
  • -
    - {idx + 1} -
    -
    -
    -
    - {/* #67 cleanup: strip `(was: …)` / `(placeholder)` - email-redirect leak suffixes that the EOI tab - already scrubs on its own SigningProgress card. */} - {cleanSignerName(signer.signerName) || signer.signerEmail} -
    - - {capFirst(signer.status)} - -
    -
    - {signer.signerEmail} · {capFirst(signer.signerRole)} -
    -
    - {signer.signedAt - ? `Signed ${new Date(signer.signedAt).toLocaleDateString('en-GB')}` - : signer.invitedAt - ? `Invited ${new Date(signer.invitedAt).toLocaleDateString('en-GB')}` - : 'Not yet invited'} -
    - {signer.status === 'pending' && doc.documensoId && isInFlight ? ( -
    - {/* #67 state-aware CTA: invited yet? remind. else: send. */} - {signer.invitedAt ? ( - - ) : ( - - )} - {signer.signingUrl ? ( - - ) : null} -
    - ) : null} -
    -
  • - ))} -
+ // #67 visual parity: re-use the EOI tab's + // so the doc-detail card matches every other surface that + // shows signer state (cleaned names, status-tinted card, + // state-aware action button, signing-link copy, role chip). + )} - {subjectLink ? ( + {linkedRows.length > 0 ? (

- Linked entity + Linked {linkedRows.length === 1 ? 'entity' : 'entities'}

- - {subjectLink.label} → - +
+ {linkedRows.map((row) => ( + + + {row.label} + + + {row.sub ?? `Open ${row.label.toLowerCase()}`} + + + ))} +
) : null}
@@ -390,27 +356,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
-
-

- Activity -

- {events.length === 0 ? ( -

No events yet.

- ) : ( -
    - {events.slice(0, 12).map((e) => ( -
  • -
    - {e.eventType.replace(/_/g, ' ')} -
    -
    - {new Date(e.createdAt).toLocaleString('en-GB')} -
    -
  • - ))} -
- )} -
+
{confirmDialog} @@ -418,6 +364,114 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { ); } +// #67 activity panel polish: per-event icons + actor-aware copy + +// precise tooltip + reverse-chronological order. Same data the +// previous flat list rendered, just legible. +const EVENT_META: Record< + string, + { label: (data?: Record) => string; icon: typeof Clock; tone: string } +> = { + created: { label: () => 'Created', icon: FileText, tone: 'text-slate-500' }, + sent: { + label: (d) => (d?.recipientEmail ? `Sent to ${d.recipientEmail}` : 'Sent for signing'), + icon: Send, + tone: 'text-sky-600', + }, + viewed: { + label: (d) => (d?.recipientEmail ? `${d.recipientEmail} opened` : 'Opened'), + icon: Eye, + tone: 'text-amber-600', + }, + signed: { + label: (d) => (d?.recipientEmail ? `${d.recipientEmail} signed` : 'Signed'), + icon: CheckCircle2, + tone: 'text-emerald-600', + }, + reminder_sent: { + label: (d) => (d?.recipientEmail ? `Reminder → ${d.recipientEmail}` : 'Reminder sent'), + icon: Bell, + tone: 'text-amber-700', + }, + completed: { label: () => 'All parties signed', icon: CheckCircle2, tone: 'text-emerald-700' }, + rejected: { + label: (d) => (d?.recipientEmail ? `${d.recipientEmail} declined` : 'Declined'), + icon: XCircle, + tone: 'text-rose-600', + }, + declined: { + label: (d) => (d?.recipientEmail ? `${d.recipientEmail} declined` : 'Declined'), + icon: XCircle, + tone: 'text-rose-600', + }, + expired: { label: () => 'Expired', icon: Clock, tone: 'text-rose-500' }, + cancelled: { label: () => 'Cancelled', icon: X, tone: 'text-slate-500' }, + deleted: { label: () => 'Deleted', icon: Trash2, tone: 'text-slate-500' }, +}; + +function ActivityCard({ events }: { events: DetailEvent[] }) { + const [showAll, setShowAll] = useState(false); + // Server returns oldest-first; reverse so most recent reads top of card. + const sorted = [...events].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + const visible = showAll ? sorted : sorted.slice(0, 8); + + return ( +
+

+ Activity +

+ {sorted.length === 0 ? ( +

No events yet.

+ ) : ( + <> +
    + {visible.map((e) => { + const meta = EVENT_META[e.eventType] ?? { + label: () => e.eventType.replace(/_/g, ' '), + icon: Clock, + tone: 'text-muted-foreground', + }; + const Icon = meta.icon; + const data = (e.eventData ?? {}) as Record; + const label = meta.label(data); + const when = new Date(e.createdAt); + return ( +
  • + +
    +
    {label}
    + +
    +
  • + ); + })} +
+ {sorted.length > 8 ? ( + + ) : null} + + )} +
+ ); +} + /** * #67 watcher Add UI. The watchers list previously displayed only * user-id stubs (truncated UUID) with a delete button and no way to diff --git a/src/components/interests/interest-contact-log-tab.tsx b/src/components/interests/interest-contact-log-tab.tsx index 6530c387..463627ff 100644 --- a/src/components/interests/interest-contact-log-tab.tsx +++ b/src/components/interests/interest-contact-log-tab.tsx @@ -23,14 +23,19 @@ import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +// §2.1: contact-log compose surface migrated from Dialog to Sheet so it +// matches the side-panel doctrine used by every other compose surface in +// the app (ClientForm, InterestForm, YachtForm, EOI Generate). The +// dialog name `ComposeDialog` is kept for git-blame continuity but the +// component now renders . import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; import { DropdownMenu, DropdownMenuContent, @@ -298,9 +303,13 @@ function EmptyState({ onAdd }: { onAdd: () => void }) { ); } -// ─── Compose / edit dialog ─────────────────────────────────────────────────── +// ─── Compose / edit sheet ─────────────────────────────────────────────────── -function ComposeDialog(props: { +// Exported for §1.4 — interest-detail-header.tsx mounts this sheet +// directly via a "Log contact" quick-action button (sibling to the +// Email / Call / WhatsApp pills) so the rep doesn't have to navigate +// to the Contact log tab first. +export function ComposeDialog(props: { interestId: string; existing?: ContactLogEntry; open: boolean; @@ -416,15 +425,15 @@ function ComposeDialogBody({ }); return ( - - - - {isEdit ? 'Edit contact log entry' : 'Log a contact'} - + + + + {isEdit ? 'Edit contact log entry' : 'Log a contact'} + Record the channel, the direction, and what was discussed. Optionally schedule a follow-up — a reminder will be created automatically. - - + +
{/* Quick-template buttons. Tap one to seed the channel + direction @@ -594,7 +603,7 @@ function ComposeDialogBody({
- + - -
-
+ + +
); } diff --git a/src/components/interests/interest-contract-tab.tsx b/src/components/interests/interest-contract-tab.tsx index 6884f147..f0fbb04b 100644 --- a/src/components/interests/interest-contract-tab.tsx +++ b/src/components/interests/interest-contract-tab.tsx @@ -66,6 +66,8 @@ const STATUS_TONES: Record = { completed: 'bg-emerald-100 text-emerald-700', expired: 'bg-rose-100 text-rose-700', cancelled: 'bg-slate-200 text-slate-600', + rejected: 'bg-rose-100 text-rose-700', + declined: 'bg-rose-100 text-rose-700', }; const ACTIVE_STATUSES = DOCUMENT_STATUS_ACTIVE; diff --git a/src/components/interests/interest-detail-header.tsx b/src/components/interests/interest-detail-header.tsx index dde26d06..aa1c99b5 100644 --- a/src/components/interests/interest-detail-header.tsx +++ b/src/components/interests/interest-detail-header.tsx @@ -11,10 +11,12 @@ import { XCircle, RefreshCcw, Mail, + MessageSquarePlus, Phone, AlarmClock, User, } from 'lucide-react'; +import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab'; import { WhatsAppIcon } from '@/components/icons/whatsapp'; import Link from 'next/link'; @@ -125,6 +127,10 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade const [editOpen, setEditOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false); const [outcomeDialog, setOutcomeDialog] = useState(null); + // §1.4: Quick log-contact button next to the Email/Call/WhatsApp pills. + // Opens the same Sheet the Contact log tab uses without forcing the rep + // to tab-navigate first. + const [logContactOpen, setLogContactOpen] = useState(false); // (Upload-paper-signed-EOI dialog moved to the EOI tab.) const isArchived = !!interest.archivedAt; @@ -389,6 +395,16 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade ) : null} + ) : null} @@ -524,6 +540,12 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade }} isLoading={archiveMutation.isPending || restoreMutation.isPending} /> + + ); } diff --git a/src/components/interests/interest-eoi-tab.tsx b/src/components/interests/interest-eoi-tab.tsx index 0e36e4dd..29a5059b 100644 --- a/src/components/interests/interest-eoi-tab.tsx +++ b/src/components/interests/interest-eoi-tab.tsx @@ -76,6 +76,8 @@ const STATUS_TONES: Record = { completed: 'bg-emerald-100 text-emerald-700', expired: 'bg-rose-100 text-rose-700', cancelled: 'bg-slate-200 text-slate-600', + rejected: 'bg-rose-100 text-rose-700', + declined: 'bg-rose-100 text-rose-700', }; const ACTIVE_STATUSES = DOCUMENT_STATUS_ACTIVE; @@ -247,8 +249,17 @@ function ActiveEoiCard({ 'document:signer:opened': [['documents', doc.id, 'signers']], 'document:completed': [['documents', doc.id, 'signers'], ['documents']], 'document:signer:rejected': [['documents', doc.id, 'signers'], ['documents']], + 'document:rejected': [['documents', doc.id, 'signers'], ['documents']], }); + // §4.13: surface the rejection callout in a high-visibility banner — + // status pill alone doesn't communicate that the doc is dead and the + // rep must take action. + const isRejected = doc.status === 'rejected' || doc.status === 'declined'; + const rejectedSigner = isRejected + ? signers.find((s) => s.status === 'declined' || s.status === 'rejected') + : null; + const remindAllMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }), onSuccess: () => { @@ -259,7 +270,30 @@ function ActiveEoiCard({ }); return ( -
+
+ {isRejected ? ( +
+ +
+

+ EOI declined + {rejectedSigner + ? ` by ${rejectedSigner.signerName ?? rejectedSigner.signerEmail}` + : ''} +

+

+ The document is no longer valid. Cancel and regenerate, or reach out to the signer + before re-sending. +

+
+
+ ) : null}
diff --git a/src/components/interests/interest-reservation-tab.tsx b/src/components/interests/interest-reservation-tab.tsx index 787ebaaf..e38a20d5 100644 --- a/src/components/interests/interest-reservation-tab.tsx +++ b/src/components/interests/interest-reservation-tab.tsx @@ -66,6 +66,8 @@ const STATUS_TONES: Record = { completed: 'bg-emerald-100 text-emerald-700', expired: 'bg-rose-100 text-rose-700', cancelled: 'bg-slate-200 text-slate-600', + rejected: 'bg-rose-100 text-rose-700', + declined: 'bg-rose-100 text-rose-700', }; const ACTIVE_STATUSES = DOCUMENT_STATUS_ACTIVE; diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 29970322..98879b7d 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -49,6 +49,7 @@ import { InterestEoiTab } from '@/components/interests/interest-eoi-tab'; import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab'; import { QualificationChecklist } from '@/components/interests/qualification-checklist'; import { PaymentsSection } from '@/components/interests/payments-section'; +import { StageGuidanceCard } from '@/components/interests/stage-guidance-card'; import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner'; import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner'; import { InterestContractTab } from '@/components/interests/interest-contract-tab'; @@ -836,12 +837,20 @@ function OverviewTab({ stage: no deposit is expected yet, so the empty card is just noise — the next-milestone card carries the actionable copy instead. */} - {showPaymentsSection && ( + {showPaymentsSection ? ( + ) : ( + // §7.2: replace the empty Payments slot with a stage-aware + // "next step" card on pre-reservation stages so the rep gets + // an actionable prompt instead of dead space. + 0} + /> )} {/* Sales-process milestones — phase-aware so the user only sees diff --git a/src/components/interests/stage-guidance-card.tsx b/src/components/interests/stage-guidance-card.tsx new file mode 100644 index 00000000..b000da14 --- /dev/null +++ b/src/components/interests/stage-guidance-card.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { Sparkles, ArrowRight, Anchor, Send, ClipboardCheck, FileSignature } from 'lucide-react'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import type { PipelineStage } from '@/lib/constants'; + +interface StageGuidanceCardProps { + /** Current pipeline stage. Drives the per-stage copy + actions. */ + stage: PipelineStage; + /** Callbacks for each shortcut action. Optional — if absent, the + * corresponding button is hidden. */ + onScrollToRecommender?: () => void; + onOpenEoiGenerate?: () => void; + onOpenQualification?: () => void; + /** True when berth_interest milestone is satisfied (≥1 linked berth); + * used to hide the "Add a berth" prompt when the rep has already + * done it. */ + hasLinkedBerth?: boolean; +} + +interface StageCopy { + title: string; + description: string; + next: string; + actionLabel?: string; + actionIcon?: typeof Anchor; + onAction?: 'recommender' | 'eoi' | 'qualification'; +} + +/** + * §7.2 — Stage-aware "what to do next" prompt that sits on the Overview + * tab in the spot the Payments section occupies post-reservation. Each + * pre-deposit stage gets a one-card prompt + a shortcut button that + * jumps the rep to the right surface. + * + * Replaces the previously-empty Payments slot on enquiry / qualified / + * nurturing / eoi stages with an actionable hint. + */ +function copyFor(stage: PipelineStage, ctx: { hasLinkedBerth?: boolean }): StageCopy | null { + switch (stage) { + case 'enquiry': + return { + title: 'Next: qualify the interest', + description: ctx.hasLinkedBerth + ? 'Confirm vessel details, walk the rep through the qualification checklist, then move to Qualified.' + : 'Link a berth (or capture desired dimensions) and run the qualification checklist before moving on.', + next: 'qualified', + actionLabel: ctx.hasLinkedBerth ? 'Open qualification checklist' : 'Recommend a berth', + actionIcon: ctx.hasLinkedBerth ? ClipboardCheck : Anchor, + onAction: ctx.hasLinkedBerth ? 'qualification' : 'recommender', + }; + case 'qualified': + return { + title: 'Next: send the EOI', + description: + 'Generate the Expression of Interest with all parties and send for signing. Auto-advances to EOI on send.', + next: 'eoi', + actionLabel: 'Generate EOI', + actionIcon: Send, + onAction: 'eoi', + }; + case 'nurturing': + return { + title: 'Stay in touch', + description: + 'Deal is in nurture — schedule a follow-up reminder or log a contact when the prospect re-engages, then move them back to Qualified.', + next: 'qualified', + }; + case 'eoi': + return { + title: 'Waiting on signatures', + description: + 'All signers will get a notification once each completes; the deal auto-advances to Reservation when everyone signs.', + next: 'reservation', + }; + default: + // Reservation onwards have their own dedicated sections (Payments, + // Documents) so the guidance card doesn't render. + return null; + } +} + +export function StageGuidanceCard({ + stage, + onScrollToRecommender, + onOpenEoiGenerate, + onOpenQualification, + hasLinkedBerth, +}: StageGuidanceCardProps) { + const copy = copyFor(stage, { hasLinkedBerth }); + if (!copy) return null; + + const ActionIcon = copy.actionIcon ?? FileSignature; + const action = + copy.onAction === 'recommender' && onScrollToRecommender + ? onScrollToRecommender + : copy.onAction === 'eoi' && onOpenEoiGenerate + ? onOpenEoiGenerate + : copy.onAction === 'qualification' && onOpenQualification + ? onOpenQualification + : null; + + return ( + + + + + Next step + + + +
+

{copy.title}

+

{copy.description}

+
+ {copy.actionLabel && action ? ( + + ) : null} +
+
+ ); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c6b7bf7f..af2c84c6 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -393,6 +393,11 @@ export const DOCUMENT_STATUSES = [ 'completed', 'expired', 'cancelled', + // Documenso writes both 'rejected' and 'declined' depending on which + // webhook path fires; we mirror that on the document row. Surface + // both so DocumentStatus checks against either spelling type-check. + 'rejected', + 'declined', ] as const; export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number]; diff --git a/src/lib/db/migrations/0071_pg_trgm_search_indexes.sql b/src/lib/db/migrations/0071_pg_trgm_search_indexes.sql new file mode 100644 index 00000000..67e57846 --- /dev/null +++ b/src/lib/db/migrations/0071_pg_trgm_search_indexes.sql @@ -0,0 +1,48 @@ +-- M-P01: pg_trgm GIN indexes on the leading-wildcard ILIKE search +-- columns used by `buildListQuery` (src/lib/db/query-builder.ts:67). +-- +-- Without these, every `ILIKE '%term%'` predicate sequential-scans +-- the entire table. The fix isn't to rewrite the SQL — Postgres will +-- transparently use a `gin_trgm_ops` index for ILIKE patterns once +-- one exists on the column. +-- +-- The pg_trgm extension is a one-time install; CREATE EXTENSION IF NOT +-- EXISTS is idempotent. Each CREATE INDEX CONCURRENTLY runs outside +-- a transaction (the db-migrate runner detects CONCURRENTLY and +-- splits the statement out of the wrapping tx). + +CREATE EXTENSION IF NOT EXISTS pg_trgm; +--> statement-breakpoint + +-- ─── Clients ────────────────────────────────────────────────────────────── +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_clients_full_name_trgm + ON clients USING gin (full_name gin_trgm_ops); +--> statement-breakpoint + +-- ─── Yachts ────────────────────────────────────────────────────────────── +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_yachts_name_trgm + ON yachts USING gin (name gin_trgm_ops); +--> statement-breakpoint + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_yachts_builder_trgm + ON yachts USING gin (builder gin_trgm_ops); +--> statement-breakpoint + +-- ─── Companies ─────────────────────────────────────────────────────────── +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_companies_name_trgm + ON companies USING gin (name gin_trgm_ops); +--> statement-breakpoint + +-- ─── Berths ────────────────────────────────────────────────────────────── +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_berths_mooring_number_trgm + ON berths USING gin (mooring_number gin_trgm_ops); +--> statement-breakpoint + +-- ─── Residential clients (parallel client surface) ─────────────────────── +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_residential_clients_full_name_trgm + ON residential_clients USING gin (full_name gin_trgm_ops); +--> statement-breakpoint + +-- ─── Tags (filter-bar autocomplete) ────────────────────────────────────── +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tags_name_trgm + ON tags USING gin (name gin_trgm_ops); diff --git a/src/lib/labels/document-status.ts b/src/lib/labels/document-status.ts index 0b5105da..3483ae7d 100644 --- a/src/lib/labels/document-status.ts +++ b/src/lib/labels/document-status.ts @@ -19,7 +19,12 @@ export type DocumentStatus = | 'partially_signed' | 'completed' | 'expired' - | 'cancelled'; + | 'cancelled' + // §4.13: Documenso writes 'rejected' (DOCUMENT_REJECTED webhook) and + // some legacy paths use 'declined' for the same outcome. Surface both + // so the rejection-banner check in interest-eoi-tab type-checks. + | 'rejected' + | 'declined'; /** * Human label rendered in CRM UI (staff-facing). Use the portal-specific @@ -34,6 +39,8 @@ export const DOCUMENT_STATUS_LABELS: Record = { completed: 'Signed', expired: 'Expired', cancelled: 'Cancelled', + rejected: 'Declined', + declined: 'Declined', }; /** @@ -48,6 +55,8 @@ export const DOCUMENT_STATUS_LABELS_PORTAL: Record = { completed: 'Signed', expired: 'Expired', cancelled: 'Cancelled', + rejected: 'Declined', + declined: 'Declined', }; /** @@ -62,6 +71,8 @@ export const DOCUMENT_STATUS_PILL: Record = { completed: 'completed', expired: 'expired', cancelled: 'cancelled', + rejected: 'rejected', + declined: 'declined', }; /** diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 05693f32..a7b0d4e3 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -1891,6 +1891,56 @@ export async function handleDocumentRejected(eventData: { documentId: doc.id, signerEmail: eventData.recipientEmail ?? null, }); + + // §4.13: rejection cascade. When any signer declines: + // 1. Notify the interest's assigned rep in-CRM (drives the EOI tab + // banner via the realtime invalidation + the bell). + // 2. Audit-log so the timeline surfaces the rejection. + // Email cascade to the other signers is intentionally NOT fired — + // the legal flow is "this EOI is dead, regenerate"; messaging the + // co-signers would create noise. The rep handles outreach manually. + if (doc.interestId) { + const interest = await db.query.interests.findFirst({ + where: and(eq(interests.id, doc.interestId), eq(interests.portId, doc.portId)), + columns: { assignedTo: true, clientId: true }, + }); + const targetUserId = interest?.assignedTo ?? null; + if (targetUserId) { + const { createNotification } = await import('@/lib/services/notifications.service'); + void createNotification({ + portId: doc.portId, + userId: targetUserId, + type: 'document_rejected', + title: 'EOI declined', + description: eventData.recipientEmail + ? `${eventData.recipientEmail} declined to sign — review and regenerate.` + : 'A signer declined the EOI — review and regenerate.', + link: `/interests/${doc.interestId}?tab=eoi`, + entityType: 'document', + entityId: doc.id, + dedupeKey: `document:${doc.id}:rejected`, + }).catch(() => { + // Notification failure shouldn't block the webhook handler. + }); + } + } + + // Audit verb so the rep's timeline surfaces the rejection with a + // distinct icon/copy rather than a generic document_event row. + const { createAuditLog } = await import('@/lib/audit'); + void createAuditLog({ + userId: 'system', + portId: doc.portId, + action: 'update', + entityType: 'document', + entityId: doc.id, + metadata: { + type: 'document_declined', + signerEmail: eventData.recipientEmail ?? null, + }, + ipAddress: '', + userAgent: '', + }); } export async function handleDocumentCancelled(eventData: { @@ -1934,11 +1984,25 @@ export interface DocumentDetailWatcher { addedAt: Date; } +/** + * #67 linked-entity resolution: resolve each polymorphic FK on the + * document to a human-readable name so the doc-detail "Linked entity" + * card can render "Interest — Matt Ciaccio" instead of "Interest →". + * Each side is null when the FK is null or the row was deleted. + */ +export interface DocumentDetailLinkedEntities { + interest: { id: string; clientName: string | null } | null; + client: { id: string; fullName: string } | null; + yacht: { id: string; name: string } | null; + company: { id: string; name: string } | null; +} + export interface DocumentDetail { document: typeof documents.$inferSelect; signers: (typeof documentSigners.$inferSelect)[]; events: (typeof documentEvents.$inferSelect)[]; watchers: DocumentDetailWatcher[]; + linked: DocumentDetailLinkedEntities; } /** @@ -1968,7 +2032,53 @@ export async function getDocumentDetail(id: string, portId: string): Promise rows[0] ?? null) + : Promise.resolve(null), + document.clientId + ? db.query.clients.findFirst({ + where: and(eq(clients.id, document.clientId), eq(clients.portId, portId)), + columns: { id: true, fullName: true }, + }) + : Promise.resolve(undefined), + document.yachtId + ? db.query.yachts.findFirst({ + where: and(eq(yachts.id, document.yachtId), eq(yachts.portId, portId)), + columns: { id: true, name: true }, + }) + : Promise.resolve(undefined), + document.companyId + ? db.query.companies.findFirst({ + where: and(eq(companies.id, document.companyId), eq(companies.portId, portId)), + columns: { id: true, name: true }, + }) + : Promise.resolve(undefined), + ]); + + const linked: DocumentDetailLinkedEntities = { + interest: interestRow + ? { id: interestRow.id, clientName: interestRow.clientName ?? null } + : null, + client: clientRow ? { id: clientRow.id, fullName: clientRow.fullName } : null, + yacht: yachtRow ? { id: yachtRow.id, name: yachtRow.name } : null, + company: companyRow ? { id: companyRow.id, name: companyRow.name } : null, + }; + + return { document, signers, events, watchers, linked }; } /** diff --git a/src/lib/services/receipt-scanner.ts b/src/lib/services/receipt-scanner.ts deleted file mode 100644 index 38201cef..00000000 --- a/src/lib/services/receipt-scanner.ts +++ /dev/null @@ -1,68 +0,0 @@ -import OpenAI from 'openai'; -import { logger } from '@/lib/logger'; -import { env } from '@/lib/env'; - -// M-IN02: lazy-instantiate so a missing/invalid OPENAI_API_KEY doesn't -// fail boot — the receipt-scan path is opt-in and only some ports -// will have OCR configured. Cached after first construction so we -// don't pay the cost on every scan. -let openaiClient: OpenAI | null = null; -function getOpenAI(): OpenAI { - if (!openaiClient) { - if (!env.OPENAI_API_KEY) { - throw new Error( - 'OPENAI_API_KEY is not configured — receipt OCR is unavailable. Set the key in /admin/ai or .env.', - ); - } - openaiClient = new OpenAI({ apiKey: env.OPENAI_API_KEY }); - } - return openaiClient; -} - -interface ScanResult { - establishment: string | null; - date: string | null; - amount: number | null; - currency: string | null; - lineItems: Array<{ description: string; amount: number }>; - confidence: number; -} - -export async function scanReceipt(imageBuffer: Buffer, mimeType: string): Promise { - try { - const base64 = imageBuffer.toString('base64'); - const response = await getOpenAI().chat.completions.create({ - model: 'gpt-4o', - messages: [ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Extract receipt data as JSON: { establishment, date (ISO), amount (number), currency (3-letter code), lineItems: [{ description, amount }], confidence (0-1) }. Return ONLY valid JSON.', - }, - { - type: 'image_url', - image_url: { url: `data:${mimeType};base64,${base64}` }, - }, - ], - }, - ], - max_tokens: 1000, - }); - - const content = response.choices[0]?.message?.content ?? '{}'; - const cleaned = content.replace(/```json\n?|\n?```/g, '').trim(); - return JSON.parse(cleaned) as ScanResult; - } catch (err) { - logger.error({ err }, 'Receipt scan failed'); - return { - establishment: null, - date: null, - amount: null, - currency: null, - lineItems: [], - confidence: 0, - }; - } -} diff --git a/src/proxy.ts b/src/proxy.ts index aee2491c..b0f94fd5 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -63,6 +63,10 @@ const PUBLIC_PATHS: string[] = [ '/setup', '/api/v1/bootstrap/', '/scan', + // §7.1: public sales-playbook docs (deal pulse, etc) so the "Full + // guide" link inside the in-app popover is reachable without a + // session — and shareable to external collaborators. + '/docs/', // M-R01: portal allowlist narrowed from blanket `/portal/` to the // unauthenticated entry-point routes only. Other `/portal/*` paths // now flow through the middleware backstop below which redirects to