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