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:
51
src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx
Normal file
51
src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx
Normal 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 0–100 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = `
|
||||
|
||||
Reference in New Issue
Block a user