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 = `