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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user