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

@@ -102,6 +102,92 @@ export interface PrefillData {
* `consumed: true` when it's already been used so the form can render
* a friendly "already submitted" state.
*/
// ─── List + fetch by id (rep-facing history) ────────────────────────────────
export interface SupplementalTokenHistoryRow {
id: string;
token: string;
createdAt: string;
expiresAt: string;
consumedAt: string | null;
issuedBy: string | null;
/** True when expiresAt is in the past and the token hasn't been consumed. */
expired: boolean;
}
/**
* Lists supplemental-info-request issuances for an interest, newest first.
* Used by the rep-facing "issuance history" surface on the interest page —
* shows when each token was generated, whether it's been consumed, and
* lets the rep re-send the latest active token without minting a fresh
* one.
*/
export async function listTokensForInterest(
portId: string,
interestId: string,
): Promise<SupplementalTokenHistoryRow[]> {
const { desc } = await import('drizzle-orm');
const rows = await db
.select()
.from(supplementalFormTokens)
.where(
and(
eq(supplementalFormTokens.portId, portId),
eq(supplementalFormTokens.interestId, interestId),
),
)
.orderBy(desc(supplementalFormTokens.createdAt))
.limit(20);
const now = Date.now();
return rows.map((r) => ({
id: r.id,
token: r.token,
createdAt: r.createdAt.toISOString(),
expiresAt: r.expiresAt.toISOString(),
consumedAt: r.consumedAt ? r.consumedAt.toISOString() : null,
issuedBy: r.issuedBy,
expired: !r.consumedAt && r.expiresAt.getTime() < now,
}));
}
/**
* Resolve a specific token id back into a snapshot suitable for re-emailing.
* Reuses the same shape that issueToken returns so the calling route can
* fire `sendEmail` without per-call divergence. Throws NotFoundError when
* the token doesn't exist or is cross-port (mirrors the
* enumeration-prevention behaviour on the issue path).
*/
export async function getTokenForResend(
portId: string,
interestId: string,
tokenId: string,
): Promise<{
token: string;
expiresAt: Date;
clientEmail: string | null;
clientName: string;
}> {
const row = await db.query.supplementalFormTokens.findFirst({
where: and(
eq(supplementalFormTokens.id, tokenId),
eq(supplementalFormTokens.portId, portId),
eq(supplementalFormTokens.interestId, interestId),
),
});
if (!row) throw new NotFoundError('supplemental token');
const client = await db.query.clients.findFirst({ where: eq(clients.id, row.clientId) });
if (!client) throw new NotFoundError('client');
const emailContact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'email')),
});
return {
token: row.token,
expiresAt: row.expiresAt,
clientEmail: emailContact?.value ?? null,
clientName: client.fullName ?? client.id,
};
}
export async function loadByToken(token: string): Promise<PrefillData | null> {
const row = await db.query.supplementalFormTokens.findFirst({
where: eq(supplementalFormTokens.token, token),