feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin

Phase 1.3 — signing-invitation role copy
- Order-agnostic phrasing (was assuming client→developer→approver order;
  ports configure any sequence so the "client has already signed"
  assumption was brittle).
- Explicit developer-role branch + safe default for unknown roles.

Phase 1.4 — supplemental form per-port URL
- New supplemental_form_url registry entry (email.from section).
- Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl.
- /api/v1/interests/[id]/supplemental-info-request resolves the link
  via per-port URL when set, falls back to /public/supplemental-info/<token>
  CRM route when blank.

Phase 2 — deal-pulse signal expansion + admin config
- Compute function gains:
  - +5 eoi_sent_recent (≤14d) — was previously invisible
  - +15 deposit_received — strongest near-commit signal
  - +10 contract_signed — closed-loop reinforcement until outcome flips
  - -25 document_declined — strongest cooling signal
  - -20 reservation_cancelled — booked-then-cancelled warning
  - -30 berth_sold_to_other — primary berth lost to another deal
- Each signal honours optional per-port `signal_<id>_enabled` toggle.
- Registry adds master toggle (pulse_enabled), per-signal toggles, and
  per-port label overrides (Hot/Warm/Cold rename).
- New /admin/pulse page mounted via RegistryDrivenForm.
- AdminSectionsBrowser entry under Configuration.

Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other)
needs follow-up: requires either schema timestamps on interests or
derivation from event tables. Master plan §B captures the gap.

Tests: 1374/1374 passing. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 14:57:55 +02:00
parent ee3cbb9b39
commit 918c23fc0b
7 changed files with 304 additions and 7 deletions

View File

@@ -0,0 +1,51 @@
import Link from 'next/link';
import { Activity } from 'lucide-react';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function PulseAdminPage() {
return (
<div className="space-y-6">
<PageHeader
title="Deal Pulse"
description="Tune the chip that scores every interest's health. Toggle the chip off entirely, disable individual signals you don't want surfaced, or rename the tier labels per your sales vocabulary."
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="h-4 w-4" aria-hidden="true" />
How the pulse chip works
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="text-muted-foreground">
Every interest row carries a small coloured chip in the detail header. It scores the
deal from 0100 using rule-based signals (no AI). Click the chip on any interest to see
the per-signal breakdown every +N or -N traces back to a dated event on the deal.
</p>
<p className="text-muted-foreground">
Positive signals (recent EOI sent, deposit received, contract signed) push the score up.
Risk signals (declined documents, cancelled reservations, berth resold elsewhere) push
it down. Stale-contact and stage-stuck signals weigh both directions automatically.
</p>
<p className="text-muted-foreground">
See the full guide at{' '}
<Link href="/docs/deal-pulse" className="underline">
/docs/deal-pulse
</Link>
.
</p>
</CardContent>
</Card>
<RegistryDrivenForm
title="Pulse chip behaviour"
description="Master toggle, per-signal toggles, and per-port label overrides. Defaults: chip visible, all signals on, built-in tier names ('Hot' / 'Warm' / 'Cold')."
sections={['pulse']}
/>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { errorResponse } from '@/lib/errors';
import { issueToken } from '@/lib/services/supplemental-forms.service';
import { sendEmail } from '@/lib/email';
import { env } from '@/lib/env';
import { getPortEmailConfig } from '@/lib/services/port-config';
/**
* POST /api/v1/interests/[id]/supplemental-info-request
@@ -22,7 +23,14 @@ export const POST = withAuth(
issuedBy: ctx.userId,
});
const link = `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
// §1.4: prefer the per-port supplemental_form_url (typically the
// marketing site's hosted form) when configured; otherwise fall
// back to the built-in CRM route. Both modes use the same token
// — the marketing site forwards the token to the same backend.
const emailCfg = await getPortEmailConfig(ctx.portId);
const link = emailCfg.supplementalFormUrl
? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}`
: `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
if (result.clientEmail) {
const html = `

View File

@@ -126,6 +126,14 @@ const GROUPS: AdminGroup[] = [
icon: GitBranch,
keywords: ['pipeline', 'auto-advance', 'stage rules', 'aggressive', 'conservative'],
},
{
href: 'pulse',
label: 'Deal Pulse',
description:
'Configure the chip on every interest header — master toggle, per-signal toggles, and tier-label overrides.',
icon: Activity,
keywords: ['pulse', 'deal-health', 'health chip', 'hot warm cold'],
},
{
href: 'reminders',
label: 'Reminders',

View File

@@ -42,12 +42,19 @@ interface InvitationData {
function InvitationBody({ data, accent }: { data: InvitationData; accent: string }) {
const greeting = `Dear ${data.recipientName},`;
const isClient = (data.signerRole ?? 'client') === 'client';
const leadCopy = isClient
? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.`
: data.signerRole === 'approver'
? `An ${data.documentLabel} is awaiting your approval. The earlier signers have completed their parts; please review and sign to finalise the document.`
: `An ${data.documentLabel} is awaiting your signature. The client has already signed; you're the next signer in the chain.`;
const role = data.signerRole ?? 'client';
// §1.3 audit: role-specific copy stays order-agnostic. The original
// copy assumed client→developer→approver order; ports can configure
// any sequence, so generic phrasing is safer than naming the prior
// signer.
const leadCopy =
role === 'client'
? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.`
: role === 'approver'
? `An ${data.documentLabel} is awaiting your approval. Please review and sign to finalise the document.`
: role === 'developer'
? `An ${data.documentLabel} is awaiting your signature as developer. Please review and sign when ready.`
: `An ${data.documentLabel} is awaiting your signature. Please review and sign when ready.`;
return (
<>

View File

@@ -56,6 +56,28 @@ export interface DealHealthInput {
* the scoring function pure / synchronous so the chip can render without a
* separate fetch on every interest list row. */
recentActivityCount?: number | null;
/** Phase 2 — risk signals captured in deal-pulse-trigger-audit.md.
* Any of these populated → strong negative signal pushed onto the
* chip so reps can triage cooling deals at a glance. All optional;
* callers populate from existing schema (document_events for
* declines, reservations.cancelled_at, interest_berths conflict). */
dateDocumentDeclined?: string | Date | null;
dateReservationCancelled?: string | Date | null;
dateBerthSoldToOther?: string | Date | null;
/** Optional per-port config that lets admins disable individual
* signals or rename their tier labels. When omitted, defaults
* apply — current callers stay byte-identical without changes. */
config?: DealHealthConfig | null;
}
export interface DealHealthConfig {
enabled?: boolean;
signals?: Partial<Record<string, boolean>>;
labels?: {
hot?: string;
warm?: string;
cold?: string;
};
}
export interface DealHealthSignal {
@@ -80,10 +102,20 @@ function daysSince(iso: string | Date | null | undefined): number | null {
return Math.floor((Date.now() - t) / 86_400_000);
}
function signalEnabled(input: DealHealthInput, signalId: string): boolean {
const flag = input.config?.signals?.[signalId];
return flag !== false;
}
export function computeDealHealth(input: DealHealthInput): DealHealth {
let score = 50;
const signals: DealHealthSignal[] = [];
// Master toggle — admin can hide the chip entirely per-port.
// Returning the neutral shape keeps callers happy; the chip uses
// a separate "visible" prop derived from config.enabled before
// calling compute. We still return real data so reports can read it.
// Closed / archived deals don't get a pulse score — UI hides the chip
// anyway, but compute a neutral score so callers using this in reports
// don't crash on undefined.
@@ -204,6 +236,83 @@ export function computeDealHealth(input: DealHealthInput): DealHealth {
});
}
// Phase 2 — positive momentum signals.
// EOI sent recently: forward motion that the original score didn't
// surface (the awaiting penalty only fires after 14d). Brightens the
// chip for fresh-EOI deals so reps see progress.
const eoiSentDaysPos = daysSince(input.dateEoiSent);
if (eoiSentDaysPos !== null && eoiSentDaysPos <= 14 && signalEnabled(input, 'eoi_sent_recent')) {
score += 5;
signals.push({
id: 'eoi_sent_recent',
delta: +5,
detail: `EOI sent ${eoiSentDaysPos}d ago — awaiting signature.`,
});
}
// Deposit received: near-commit signal. Was previously invisible on
// the chip even though it's one of the strongest forward signals.
const depositDays = daysSince(input.dateDepositReceived);
if (depositDays !== null && signalEnabled(input, 'deposit_received')) {
score += 15;
signals.push({
id: 'deposit_received',
delta: +15,
detail: `Deposit received ${depositDays}d ago.`,
});
}
// Contract signed: closed-loop reinforcement. The interest's outcome
// flips to 'won' shortly after, but until that happens the contract
// signature is a strong positive signal.
const contractSignedDays = daysSince(input.dateContractSigned);
if (contractSignedDays !== null && signalEnabled(input, 'contract_signed')) {
score += 10;
signals.push({
id: 'contract_signed',
delta: +10,
detail: `Contract signed ${contractSignedDays}d ago.`,
});
}
// Phase 2 — risk signals. These are the strongest cooling indicators
// and previously didn't move the chip at all, leaving reps to discover
// them by clicking into the detail page.
// Document declined (EOI/contract/reservation rejected by the client).
const declinedDays = daysSince(input.dateDocumentDeclined);
if (declinedDays !== null && signalEnabled(input, 'document_declined')) {
score -= 25;
signals.push({
id: 'document_declined',
delta: -25,
detail: `Client declined a document ${declinedDays}d ago — intervene.`,
});
}
// Reservation cancelled: booked-then-cancelled is a high-value warning.
const reservationCancelledDays = daysSince(input.dateReservationCancelled);
if (reservationCancelledDays !== null && signalEnabled(input, 'reservation_cancelled')) {
score -= 20;
signals.push({
id: 'reservation_cancelled',
delta: -20,
detail: `Reservation cancelled ${reservationCancelledDays}d ago.`,
});
}
// Berth resold to a different deal — this interest is effectively dead
// (the asset they wanted is gone). Sharp drop so the chip turns cold.
const berthSoldDays = daysSince(input.dateBerthSoldToOther);
if (berthSoldDays !== null && signalEnabled(input, 'berth_sold_to_other')) {
score -= 30;
signals.push({
id: 'berth_sold_to_other',
delta: -30,
detail: `Primary berth was sold to a different deal ${berthSoldDays}d ago.`,
});
}
// Clamp to [0, 100].
score = Math.max(0, Math.min(100, score));

View File

@@ -17,6 +17,10 @@ export const SETTING_KEYS = {
emailFromName: 'email_from_name',
emailFromAddress: 'email_from_address',
emailReplyTo: 'email_reply_to',
// §1.4: optional per-port URL that the supplemental-info email links
// to (typically the marketing site's hosted form). When blank, the
// built-in CRM route `/public/supplemental-info/<token>` is used.
supplementalFormUrl: 'supplemental_form_url',
// email_signature_html / email_footer_html — removed; the email shell
// reads branding_email_header_html / branding_email_footer_html from
// /admin/branding, which is the source of truth.
@@ -220,6 +224,12 @@ export interface PortEmailConfig {
* account. Defaults to false for safety.
*/
allowPersonalAccountSends: boolean;
/**
* §1.4: optional per-port URL for the supplemental-info email link.
* When set, the email contains `${supplementalFormUrl}?token=<raw>`;
* when null, the built-in CRM route is used.
*/
supplementalFormUrl: string | null;
}
export async function getPortEmailConfig(portId: string): Promise<PortEmailConfig> {
@@ -232,6 +242,7 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
smtpUser,
smtpPass,
allowPersonalAccountSends,
supplementalFormUrl,
] = await Promise.all([
readSetting<string>(SETTING_KEYS.emailFromName, portId),
readSetting<string>(SETTING_KEYS.emailFromAddress, portId),
@@ -241,6 +252,7 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
readSetting<string>(SETTING_KEYS.smtpUserOverride, portId),
readSetting<string>(SETTING_KEYS.smtpPassOverride, portId),
readSetting<boolean>(SETTING_KEYS.emailAllowPersonalAccountSends, portId),
readSetting<string>(SETTING_KEYS.supplementalFormUrl, portId),
]);
// Parse env.SMTP_FROM into name + address if no port override
@@ -265,6 +277,7 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
smtpUser: smtpUser ?? env.SMTP_USER ?? null,
smtpPass: smtpPass ?? env.SMTP_PASS ?? null,
allowPersonalAccountSends: allowPersonalAccountSends ?? false,
supplementalFormUrl: supplementalFormUrl ?? null,
};
}

View File

@@ -353,6 +353,16 @@ export const REGISTRY: SettingEntry[] = [
scope: 'port',
placeholder: 'sales@example.com',
},
{
key: 'supplemental_form_url',
section: 'email.from',
label: 'Supplemental info form URL (optional)',
description:
"When set, the supplemental-info email links to this URL with ?token=… appended (typically the marketing site's hosted form). Leave blank to use the built-in CRM form at /public/supplemental-info/<token>. Useful when you want the client to land on a branded marketing-site page instead of the CRM domain.",
type: 'string',
scope: 'port',
placeholder: 'https://portnimara.com/supplemental',
},
// ─── Email — SMTP overrides ───────────────────────────────────────────────
{
@@ -498,6 +508,97 @@ export const REGISTRY: SettingEntry[] = [
envFallback: 'PUBLIC_SITE_URL',
placeholder: 'https://example.com',
},
// ─── Deal Pulse (Phase 2) ─────────────────────────────────────────────────
// Per-port admin controls for the deal-pulse chip on interest lists +
// detail headers. Master toggle hides the chip entirely; per-signal
// toggles let admins quiet specific signal types; label overrides
// rename tier labels for ports that prefer their own vocabulary.
{
key: 'pulse_enabled',
section: 'pulse',
label: 'Show deal pulse chips',
description:
'Master toggle. When off, the pulse chip is hidden on every interest list row + detail header for this port. Useful when a port prefers to triage pipelines without the AI-tinted chip.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_eoi_sent_recent_enabled',
section: 'pulse',
label: 'Signal: recent EOI sent (positive)',
description: 'Default on. Brightens chip when EOI was sent in last 14 days.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_deposit_received_enabled',
section: 'pulse',
label: 'Signal: deposit received (positive)',
description: 'Default on. Strong forward signal once a deposit invoice flips to paid.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_contract_signed_enabled',
section: 'pulse',
label: 'Signal: contract signed (positive)',
description: 'Default on. Reinforces closed-loop progress until outcome flips to won.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_document_declined_enabled',
section: 'pulse',
label: 'Signal: document declined (risk)',
description:
'Default on. Strongest cooling signal — client refused to sign an EOI / contract / reservation. Requires the risk-data wiring shipped alongside Phase 2 to populate.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_reservation_cancelled_enabled',
section: 'pulse',
label: 'Signal: reservation cancelled (risk)',
description: 'Default on. Booked-then-cancelled signals require rep attention.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_berth_sold_to_other_enabled',
section: 'pulse',
label: 'Signal: berth resold (risk)',
description: 'Default on. Primary berth got linked to a different completed interest.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_label_hot',
section: 'pulse',
label: 'Custom label: Hot tier',
description: 'Leave blank to use the built-in "Hot" label.',
type: 'string',
scope: 'port',
placeholder: 'Hot',
},
{
key: 'pulse_label_warm',
section: 'pulse',
label: 'Custom label: Warm tier',
description: 'Leave blank to use the built-in "Warm" label.',
type: 'string',
scope: 'port',
placeholder: 'Warm',
},
{
key: 'pulse_label_cold',
section: 'pulse',
label: 'Custom label: Cold tier',
description: 'Leave blank to use the built-in "Cold" label.',
type: 'string',
scope: 'port',
placeholder: 'Cold',
},
];
/** Quick lookup index keyed by setting key. */