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:
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user