From dbbd03fd22ac647a3d907dac5e200726b61f6cf7 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sat, 2 May 2026 00:19:55 +0200 Subject: [PATCH] feat(sales): admin-configurable EOI signers + richer timeline events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Per-port EOI signer config - New `eoi_signers` system_settings key (JSON: { developer, approver }, each `{ name, email }`). Settings UI exposes it under Admin → Settings. - getPortEoiSigners(portId) reads the setting with a typed validator; falls back to the legacy David Mizrahi / Abbie May defaults if the row is missing or malformed (so older ports keep working until an admin saves a value). - Both EOI generation pathways now read from the helper instead of hardcoded constants: * documenso-template path (generateAndSignViaDocumensoTemplate) * in-app PDF-fill path (generateAndSignViaInApp) 2. Timeline upgrades The interest detail Activity tab now distinguishes the new automation events that arrived with sessions 1+2: - Stage auto-advances (userId='system') get a small "Auto" pill and carry their reason into the description (e.g. "Stage advanced to EOI Signed (auto-advanced — EOI signed via Documenso)"). - outcome_set events show "Marked as Won" / "Marked as Lost — went to another marina" with optional reason; trophy/X icons. - outcome_cleared events show "Reopened to {stage}" with a refresh icon. - Document events humanized: "Document 'X' fully signed" instead of "Document X: completed". - Stage labels run through stageLabel() so the timeline shows the human label, not the enum key. - Timestamps switched to relative-time with full-date tooltip. - "by system" is rendered plainly (no longer the literal user-id). tsc clean. vitest 832/832 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/v1/interests/[id]/timeline/route.ts | 84 +++++++++++++++---- .../admin/settings/settings-manager.tsx | 11 +++ .../interests/interest-timeline.tsx | 83 ++++++++++++++---- src/lib/services/documenso-payload.ts | 43 ++++++++++ src/lib/services/document-templates.ts | 22 ++++- 5 files changed, 205 insertions(+), 38 deletions(-) diff --git a/src/app/api/v1/interests/[id]/timeline/route.ts b/src/app/api/v1/interests/[id]/timeline/route.ts index 55e90d7..7020065 100644 --- a/src/app/api/v1/interests/[id]/timeline/route.ts +++ b/src/app/api/v1/interests/[id]/timeline/route.ts @@ -7,6 +7,25 @@ import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { auditLogs } from '@/lib/db/schema/system'; import { documents, documentEvents } from '@/lib/db/schema/documents'; +import { stageLabel } from '@/lib/constants'; + +const OUTCOME_LABELS: Record = { + won: 'Won', + lost_other_marina: 'Lost — went to another marina', + lost_unqualified: 'Lost — unqualified', + lost_no_response: 'Lost — no response', + cancelled: 'Cancelled', +}; + +const DOC_EVENT_LABELS: Record = { + sent: 'sent for signing', + completed: 'fully signed', + signed: 'signed by recipient', + rejected: 'rejected', + expired: 'expired', + cancelled: 'cancelled', + reminder_sent: 'reminder sent', +}; interface TimelineEvent { id: string; @@ -33,12 +52,7 @@ export const GET = withAuth( const auditRows = await db .select() .from(auditLogs) - .where( - and( - eq(auditLogs.entityType, 'interest'), - eq(auditLogs.entityId, interestId), - ), - ) + .where(and(eq(auditLogs.entityType, 'interest'), eq(auditLogs.entityId, interestId))) .orderBy(desc(auditLogs.createdAt)) .limit(50); @@ -72,21 +86,30 @@ export const GET = withAuth( id: row.id, type: 'audit', action: row.action, - description: buildAuditDescription(row.action, row.newValue as Record | null), + description: buildAuditDescription( + row.action, + row.newValue as Record | null, + (row.metadata as Record) ?? {}, + row.userId, + ), userId: row.userId, createdAt: row.createdAt, metadata: (row.metadata as Record) ?? {}, })); - const docEvents: TimelineEvent[] = docEventRows.map((row) => ({ - id: row.id, - type: 'document_event', - action: row.eventType, - description: `Document "${docTitles[row.documentId] ?? row.documentId}": ${row.eventType}`, - userId: null, - createdAt: row.createdAt, - metadata: (row.eventData as Record) ?? {}, - })); + const docEvents: TimelineEvent[] = docEventRows.map((row) => { + const title = docTitles[row.documentId] ?? row.documentId; + const action = DOC_EVENT_LABELS[row.eventType] ?? row.eventType; + return { + id: row.id, + type: 'document_event', + action: row.eventType, + description: `Document "${title}" ${action}`, + userId: null, + createdAt: row.createdAt, + metadata: (row.eventData as Record) ?? {}, + }; + }); const allEvents = [...auditEvents, ...docEvents]; allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); @@ -101,12 +124,39 @@ export const GET = withAuth( function buildAuditDescription( action: string, newValue: Record | null, + metadata: Record, + userId: string | null, ): string { if (action === 'create') return 'Interest created'; if (action === 'archive') return 'Interest archived'; if (action === 'restore') return 'Interest restored'; + + const type = metadata.type; + + if (type === 'outcome_set') { + const outcomeKey = (newValue?.outcome as string | undefined) ?? ''; + const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed'; + const reason = (newValue?.reason as string | undefined) ?? ''; + return reason ? `Marked as ${label} — ${reason}` : `Marked as ${label}`; + } + + if (type === 'outcome_cleared') { + const stage = (newValue?.pipelineStage as string | undefined) ?? ''; + return stage ? `Reopened to ${stageLabel(stage)}` : 'Reopened'; + } + + if (type === 'stage_change' && newValue?.pipelineStage) { + const stage = stageLabel(newValue.pipelineStage as string); + const reason = (newValue.reason as string | undefined) ?? ''; + const auto = userId === 'system'; + if (auto) { + return reason ? `${stage} (auto-advanced — ${reason})` : `Stage advanced to ${stage}`; + } + return reason ? `Stage changed to ${stage} — ${reason}` : `Stage changed to ${stage}`; + } + if (action === 'update' && newValue?.pipelineStage) { - return `Stage changed to "${newValue.pipelineStage}"`; + return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`; } if (action === 'update') return 'Interest updated'; return action; diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index 050ab9f..3bbfb61 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -107,6 +107,17 @@ const KNOWN_SETTINGS: Array<{ type: 'json', defaultValue: [], }, + { + key: 'eoi_signers', + label: 'EOI Signers', + description: + 'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.', + type: 'json', + defaultValue: { + developer: { name: 'David Mizrahi', email: 'dm@portnimara.com' }, + approver: { name: 'Abbie May', email: 'sales@portnimara.com' }, + }, + }, ]; export function SettingsManager() { diff --git a/src/components/interests/interest-timeline.tsx b/src/components/interests/interest-timeline.tsx index f2a51c3..52ac13e 100644 --- a/src/components/interests/interest-timeline.tsx +++ b/src/components/interests/interest-timeline.tsx @@ -1,8 +1,19 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { format } from 'date-fns'; -import { Pencil, FileText, Clock, PlusCircle, Archive, RotateCcw } from 'lucide-react'; +import { format, formatDistanceToNowStrict } from 'date-fns'; +import { + Pencil, + FileText, + Clock, + PlusCircle, + Archive, + RotateCcw, + Trophy, + XCircle, + RefreshCcw, + Bot, +} from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; @@ -20,15 +31,37 @@ interface InterestTimelineProps { interestId: string; } +const LOST_OUTCOMES = new Set([ + 'lost_other_marina', + 'lost_unqualified', + 'lost_no_response', + 'cancelled', +]); + function eventIcon(event: TimelineEvent) { - if (event.type === 'document_event') return ; + const type = event.metadata?.type as string | undefined; + + if (type === 'outcome_set') { + const outcome = (event.metadata as Record).outcome as string | undefined; + if (outcome === 'won') return ; + if (outcome && LOST_OUTCOMES.has(outcome)) return ; + return ; + } + if (type === 'outcome_cleared') return ; + if (event.type === 'document_event') return ; if (event.action === 'create') return ; if (event.action === 'archive') return ; if (event.action === 'restore') return ; - if (event.metadata?.type === 'stage_change') return ; + if (type === 'stage_change') return ; return ; } +function actorLabel(userId: string | null): string | null { + if (!userId) return null; + if (userId === 'system') return 'system'; + return userId; +} + export function InterestTimeline({ interestId }: InterestTimelineProps) { const { data, isLoading } = useQuery<{ data: TimelineEvent[] }>({ queryKey: ['interest-timeline', interestId], @@ -66,22 +99,36 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) { {/* Vertical line */}
- {events.map((event, _idx) => ( -
- {/* Icon */} -
- {eventIcon(event)} -
+ {events.map((event) => { + const actor = actorLabel(event.userId); + const isAuto = event.userId === 'system'; + return ( +
+ {/* Icon */} +
+ {eventIcon(event)} +
-
-

{event.description}

-

- {format(new Date(event.createdAt), 'MMM d, yyyy HH:mm')} - {event.userId && ` · by ${event.userId}`} -

+
+

+ {event.description} + {isAuto ? ( + + + Auto + + ) : null} +

+

+ + {actor ? · by {actor} : null} +

+
-
- ))} + ); + })}
); } diff --git a/src/lib/services/documenso-payload.ts b/src/lib/services/documenso-payload.ts index 9235ed9..9c30036 100644 --- a/src/lib/services/documenso-payload.ts +++ b/src/lib/services/documenso-payload.ts @@ -1,3 +1,7 @@ +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { systemSettings } from '@/lib/db/schema/system'; import type { EoiContext } from '@/lib/services/eoi-context'; export interface DocumensoTemplatePayload { @@ -52,6 +56,45 @@ const DEFAULT_APPROVER_NAME = 'Abbie May'; const DEFAULT_APPROVER_EMAIL = 'sales@portnimara.com'; const DEFAULT_REDIRECT_URL = 'https://portnimara.com'; +export interface EoiSignerConfig { + developer: { name: string; email: string }; + approver: { name: string; email: string }; +} + +const DEFAULT_EOI_SIGNERS: EoiSignerConfig = { + developer: { name: DEFAULT_DEVELOPER_NAME, email: DEFAULT_DEVELOPER_EMAIL }, + approver: { name: DEFAULT_APPROVER_NAME, email: DEFAULT_APPROVER_EMAIL }, +}; + +function isSignerEntry(v: unknown): v is { name: string; email: string } { + return ( + !!v && + typeof v === 'object' && + typeof (v as Record).name === 'string' && + typeof (v as Record).email === 'string' && + !!(v as Record).name && + !!(v as Record).email + ); +} + +/** Read the per-port `eoi_signers` setting, fall back to legacy hardcoded + * defaults if missing or malformed. The fallback exists to keep older + * ports working until an admin saves the setting; once saved, the DB row + * always wins. */ +export async function getPortEoiSigners(portId: string): Promise { + const row = await db.query.systemSettings.findFirst({ + where: and(eq(systemSettings.key, 'eoi_signers'), eq(systemSettings.portId, portId)), + }); + const value = row?.value as Record | undefined; + if (value && isSignerEntry(value.developer) && isSignerEntry(value.approver)) { + return { + developer: value.developer, + approver: value.approver, + }; + } + return DEFAULT_EOI_SIGNERS; +} + function formatAddress(address: EoiContext['client']['address']): string { if (!address) return ''; return [address.street, address.city, address.country].filter(Boolean).join(', '); diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index bd6087f..5b1da63 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -23,7 +23,7 @@ import { sendDocument as documensoSend, generateDocumentFromTemplate as documensoGenerateFromTemplate, } from '@/lib/services/documenso-client'; -import { buildDocumensoPayload } from '@/lib/services/documenso-payload'; +import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload'; import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form'; import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields'; import { buildEoiContext } from '@/lib/services/eoi-context'; @@ -766,6 +766,7 @@ async function generateAndSignViaInApp( ); } const eoiCtx = await buildEoiContext(context.interestId, portId); + const signers = await getPortEoiSigners(portId); resolvedSigners = [ { name: eoiCtx.client.fullName, @@ -773,8 +774,18 @@ async function generateAndSignViaInApp( role: 'signer', signingOrder: 1, }, - { name: 'David Mizrahi', email: 'dm@portnimara.com', role: 'signer', signingOrder: 2 }, - { name: 'Abbie May', email: 'sales@portnimara.com', role: 'approver', signingOrder: 3 }, + { + name: signers.developer.name, + email: signers.developer.email, + role: 'signer', + signingOrder: 2, + }, + { + name: signers.approver.name, + email: signers.approver.email, + role: 'approver', + signingOrder: 3, + }, ]; } if (!resolvedSigners || resolvedSigners.length === 0) { @@ -859,12 +870,17 @@ async function generateAndSignViaDocumensoTemplate( } const eoiContext = await buildEoiContext(context.interestId, portId); + const signers = await getPortEoiSigners(portId); const payload = buildDocumensoPayload(eoiContext, { interestId: context.interestId, clientRecipientId: env.DOCUMENSO_CLIENT_RECIPIENT_ID, developerRecipientId: env.DOCUMENSO_DEVELOPER_RECIPIENT_ID, approvalRecipientId: env.DOCUMENSO_APPROVAL_RECIPIENT_ID, + developerName: signers.developer.name, + developerEmail: signers.developer.email, + approverName: signers.approver.name, + approverEmail: signers.approver.email, redirectUrl: env.APP_URL, });