feat(uat-batch-23): supplemental-info — separate Generate link + Send by email

The single-button "Request more info" conflated link generation with
email send. Once tokens became reusable until expiry (PR15), the
two-step UX makes more sense — reps often need to copy the link and
share it via WhatsApp / iMessage instead of letting SMTP route it.

- API: POST /supplemental-info-request now accepts an optional
  `{ sendEmail?: boolean }` body (defaults true for back-compat).
  Generate-only callers pass `{ sendEmail: false }`.
- UI: two buttons replace the single CTA — "Generate link" (always
  generates, never emails) + "Send by email" (the original
  full-blow behaviour). Re-clicking "Generate link" with a token
  already issued mints a fresh one (labeled "Regenerate link").
- Email body copy: drop "can only be used once" since PR15 made the
  link reusable until expiry.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 18:55:39 +02:00
parent d97a08bf5f
commit a4e30ea16c
2 changed files with 34 additions and 9 deletions

View File

@@ -14,9 +14,22 @@ import { getPortEmailConfig } from '@/lib/services/port-config';
* Generates a one-shot token + emails the client the public form URL.
*/
export const POST = withAuth(
withPermission('interests', 'edit', async (_req: NextRequest, ctx, params) => {
withPermission('interests', 'edit', async (req: NextRequest, ctx, params) => {
try {
const interestId = params.id as string;
// Two-step UX: rep can generate a link without firing the email
// (so they can copy + share manually through WhatsApp etc.), then
// come back and send the templated email separately. Default stays
// `true` for back-compat. The body is optional — older callers
// POST with no body and still trigger the email.
let shouldSendEmail = true;
try {
const body = (await req.clone().json()) as { sendEmail?: boolean };
if (typeof body?.sendEmail === 'boolean') shouldSendEmail = body.sendEmail;
} catch {
// No JSON body — keep the default.
}
const result = await issueToken({
interestId,
portId: ctx.portId,
@@ -32,7 +45,7 @@ export const POST = withAuth(
? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}`
: `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
if (result.clientEmail) {
if (shouldSendEmail && result.clientEmail) {
const html = `
<p>Hello ${escapeHtml(result.clientName)},</p>
<p>Before we draft your Expression of Interest, we need to confirm a few details.
@@ -45,7 +58,7 @@ export const POST = withAuth(
</a>
</p>
<p style="color:#64748b;font-size:12px">
This link expires on ${result.expiresAt.toUTCString()} and can only be used once.
This link expires on ${result.expiresAt.toUTCString()}.
If you didn't expect this email, please let us know.
</p>
`;
@@ -63,7 +76,7 @@ export const POST = withAuth(
data: {
link,
expiresAt: result.expiresAt.toISOString(),
emailSent: !!result.clientEmail,
emailSent: shouldSendEmail && !!result.clientEmail,
},
});
} catch (error) {