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:
Matt Ciaccio
2026-05-02 00:19:55 +02:00
parent ba5fb6db5e
commit dbbd03fd22
5 changed files with 205 additions and 38 deletions

View File

@@ -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<string, unknown>).name === 'string' &&
typeof (v as Record<string, unknown>).email === 'string' &&
!!(v as Record<string, string>).name &&
!!(v as Record<string, string>).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<EoiSignerConfig> {
const row = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'eoi_signers'), eq(systemSettings.portId, portId)),
});
const value = row?.value as Record<string, unknown> | 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(', ');

View File

@@ -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,
});