feat(uat-batch): Groups D + E — wizard polish + supplemental-info history

D24 + D25 + E26 from the 2026-05-21 plan. All three shipped.

Shipped now:
  D24  BulkAddBerthsWizard ft/m toggle. Step 2 header gets a small
       monospaced ft/m button that flips the dimension entry unit
       wizard-wide. Cell values stay as-typed; on submit a single
       `inputToFt(v)` helper converts m→ft (1 m = 3.28084 ft) before
       posting the canonical feet payload. Column headers update
       Length/Width/Draft labels to reflect the active unit.
  D25  BulkAddBerthsWizard dock-letter expansion. Replaced the
       Select-of-A–E with a chip group + free-text "Other…" input.
       Common letters (A-E) are quick-pick chips; reps can type any
       uppercase letter sequence (AA, BB, F, …) for ports whose dock
       layout extends past the five-letter shortlist. New
       `handleGenerate` validation rejects empty / non-uppercase
       inputs with a toast. Custom-input path uppercases + strips
       non-letters as the rep types so the canonical
       `^[A-Z]+\d+$` mooring regex always matches.
  E26  Supplemental-info Regenerate / Resend / history.
       Service: new `listTokensForInterest(portId, interestId)`
       returns the latest 20 issuances with expired/consumed flags;
       new `getTokenForResend(portId, interestId, tokenId)` snapshots
       a specific token back into the issue-shape so the route can
       re-email without minting a fresh token.
       Route: GET lists the issuances (gated on `interests.view`);
       POST accepts an optional `tokenId` for the Resend branch
       (forces `sendEmail=true` since the rep clicked with intent)
       and returns `resent: true/false` on the success payload.
       UI: button card now shows three actions — Generate /
       Regenerate link, Generate + email (or "New link + email"
       when a usable token exists), and Resend current (only when
       there's an active unconsumed unexpired token). Issuance
       history list shows Active / Submitted / Expired per row.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:30:22 +02:00
parent 991e2223c7
commit 431375d794
4 changed files with 322 additions and 45 deletions

View File

@@ -2,7 +2,11 @@ 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 {
getTokenForResend,
issueToken,
listTokensForInterest,
} from '@/lib/services/supplemental-forms.service';
import { sendEmail } from '@/lib/email';
import { env } from '@/lib/env';
import { getPortEmailConfig } from '@/lib/services/port-config';
@@ -13,28 +17,56 @@ import { getPortEmailConfig } from '@/lib/services/port-config';
* 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.
*/
/**
* GET — list past issuances for the interest. Lets the rep see when each
* token was generated + which one is currently active, so they can choose
* Resend (re-email the existing token) over Regenerate (mint a fresh one)
* when the same client is still working through the existing form.
*/
export const GET = withAuth(
withPermission('interests', 'view', async (_req, ctx, params) => {
try {
const rows = await listTokensForInterest(ctx.portId, params.id!);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
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.
// Three flows surface here today:
// 1. New token + email (default).
// 2. New token, no email (rep copies + shares manually).
// 3. Resend an existing token via email (no new token minted).
// `tokenId` in the body opts into (3); otherwise (1)/(2) apply
// based on `sendEmail`. Older callers POST with no body and still
// trigger (1).
let shouldSendEmail = true;
let resendTokenId: string | null = null;
try {
const body = (await req.clone().json()) as { sendEmail?: boolean };
const body = (await req.clone().json()) as {
sendEmail?: boolean;
tokenId?: string;
};
if (typeof body?.sendEmail === 'boolean') shouldSendEmail = body.sendEmail;
if (typeof body?.tokenId === 'string' && body.tokenId.length > 0) {
resendTokenId = body.tokenId;
}
} catch {
// No JSON body — keep the default.
}
const result = await issueToken({
interestId,
portId: ctx.portId,
issuedBy: ctx.userId,
});
const result = resendTokenId
? await getTokenForResend(ctx.portId, interestId, resendTokenId)
: await issueToken({
interestId,
portId: ctx.portId,
issuedBy: ctx.userId,
});
// §1.4: prefer the per-port supplemental_form_url (typically the
// marketing site's hosted form) when configured; otherwise fall
@@ -45,7 +77,11 @@ export const POST = withAuth(
? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}`
: `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
if (shouldSendEmail && result.clientEmail) {
// Resend implies "email me again" — the rep clicked the action with
// intent. Force the email path on for resends regardless of the
// `sendEmail` body flag.
const willSendEmail = resendTokenId ? true : shouldSendEmail;
if (willSendEmail && 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.
@@ -76,7 +112,8 @@ export const POST = withAuth(
data: {
link,
expiresAt: result.expiresAt.toISOString(),
emailSent: shouldSendEmail && !!result.clientEmail,
emailSent: willSendEmail && !!result.clientEmail,
resent: !!resendTokenId,
},
});
} catch (error) {