feat(supplemental-info): pre-EOI public form flow

Lets a sales rep send a client a one-shot link to fill out the
information we need before drafting the EOI (intent, dimensions,
signatory, timeline). Token-keyed: single-use, soft-expiring, scoped
to one interest + client. Public POST endpoint accepts the form
submission; CRM endpoint mints tokens for rep-initiated requests;
portal page renders the form for the recipient.

Schema: supplemental_form_tokens table (migration 0061) with port_id
+ interest_id + client_id refs, unique token, consumed_at marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:36:56 +02:00
parent e11529ffcc
commit 0fe3e984d1
7 changed files with 858 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { loadByToken, applySubmission } from '@/lib/services/supplemental-forms.service';
import { errorResponse } from '@/lib/errors';
/**
* Public — no auth. Loads the prefill data for the form. The token in
* the URL is the only credential; rejects expired / unknown tokens with
* 404 (deliberately conflated to avoid leaking which tokens exist).
*/
export async function GET(
_req: NextRequest,
ctx: { params: Promise<{ token: string }> },
): Promise<NextResponse> {
try {
const { token } = await ctx.params;
const data = await loadByToken(token);
if (!data) {
return NextResponse.json({ error: 'Link not found or expired' }, { status: 404 });
}
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}
const submissionSchema = z.object({
fullName: z.string().min(1).max(200),
address: z.string().max(500).nullable().optional(),
country: z.string().length(2).nullable().optional(),
email: z.string().email().nullable().optional(),
phoneE164: z
.string()
.regex(/^\+[1-9]\d{1,14}$/)
.nullable()
.optional(),
phoneCountry: z.string().length(2).nullable().optional(),
yachtName: z.string().max(200).nullable().optional(),
yachtLengthFt: z.number().positive().nullable().optional(),
yachtWidthFt: z.number().positive().nullable().optional(),
yachtDraftFt: z.number().positive().nullable().optional(),
});
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ token: string }> },
): Promise<NextResponse> {
try {
const { token } = await ctx.params;
const body = submissionSchema.parse(await req.json());
await applySubmission(token, {
fullName: body.fullName,
address: body.address ?? null,
country: body.country ?? null,
email: body.email ?? null,
phoneE164: body.phoneE164 ?? null,
phoneCountry: body.phoneCountry ?? null,
yachtName: body.yachtName ?? null,
yachtLengthFt: body.yachtLengthFt ?? null,
yachtWidthFt: body.yachtWidthFt ?? null,
yachtDraftFt: body.yachtDraftFt ?? null,
});
return NextResponse.json({ data: { success: true } });
} catch (error) {
return errorResponse(error);
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { issueToken } from '@/lib/services/supplemental-forms.service';
import { sendEmail } from '@/lib/email';
import { env } from '@/lib/env';
/**
* POST /api/v1/interests/[id]/supplemental-info-request
*
* Auth: requires `interests.edit` so any rep working the deal can fire it.
* Generates a one-shot token + emails the client the public form URL.
*/
export const POST = withAuth(
withPermission('interests', 'edit', async (_req: NextRequest, ctx, params) => {
try {
const interestId = params.id as string;
const result = await issueToken({
interestId,
portId: ctx.portId,
issuedBy: ctx.userId,
});
const link = `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
if (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.
The form below is pre-filled with what we have on file — please review, correct
anything that's wrong, and add what's missing.</p>
<p style="text-align:center;margin:24px 0">
<a href="${link}"
style="background:#1e3a8a;color:#fff;text-decoration:none;padding:12px 24px;border-radius:6px;display:inline-block">
Open the form
</a>
</p>
<p style="color:#64748b;font-size:12px">
This link expires on ${result.expiresAt.toUTCString()} and can only be used once.
If you didn't expect this email, please let us know.
</p>
`;
await sendEmail(
result.clientEmail,
'Please complete a few details before we draft your EOI',
html,
undefined,
undefined,
ctx.portId,
);
}
return NextResponse.json({
data: {
link,
expiresAt: result.expiresAt.toISOString(),
emailSent: !!result.clientEmail,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}