feat(sales): admin-configurable EOI signers + richer timeline events
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, unknown> | null),
|
||||
description: buildAuditDescription(
|
||||
row.action,
|
||||
row.newValue as Record<string, unknown> | null,
|
||||
(row.metadata as Record<string, unknown>) ?? {},
|
||||
row.userId,
|
||||
),
|
||||
userId: row.userId,
|
||||
createdAt: row.createdAt,
|
||||
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
||||
}));
|
||||
|
||||
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<string, unknown>) ?? {},
|
||||
}));
|
||||
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<string, unknown>) ?? {},
|
||||
};
|
||||
});
|
||||
|
||||
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<string, unknown> | null,
|
||||
metadata: Record<string, unknown>,
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user