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) {
|
||||
|
||||
@@ -38,8 +38,15 @@ import { toastError } from '@/lib/api/toast-error';
|
||||
import { useVocabulary } from '@/hooks/use-vocabulary';
|
||||
import { CurrencySelect } from '@/components/shared/currency-select';
|
||||
|
||||
const DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const;
|
||||
type DockLetter = (typeof DOCK_LETTERS)[number];
|
||||
// Common dock-letter shorthand. Wizard accepts any uppercase letter
|
||||
// sequence matching the canonical mooring regex (`^[A-Z]+$`) — these
|
||||
// five are the most-frequently-used; reps add new ones via the
|
||||
// "Custom" input below.
|
||||
const COMMON_DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const;
|
||||
// The custom flow widens DockLetter beyond the shortlist; any uppercase
|
||||
// string the rep types is valid as long as it matches the canonical
|
||||
// `^[A-Z]+$` letter portion of a mooring number.
|
||||
type DockLetter = string;
|
||||
|
||||
interface RowDraft {
|
||||
mooringNumber: string;
|
||||
@@ -83,6 +90,18 @@ export function BulkAddBerthsWizard() {
|
||||
const sidePontoonOptions = useVocabulary('berth_side_pontoon_options');
|
||||
|
||||
const [step, setStep] = useState<'sequence' | 'edit'>('sequence');
|
||||
// Unit the rep is entering dims in. Persisted only for the wizard's
|
||||
// lifetime; the underlying `RowDraft` always stores the value as a
|
||||
// raw string in this unit. Conversion to canonical feet happens
|
||||
// once at submit (1 m = 3.28084 ft).
|
||||
const [dimUnit, setDimUnit] = useState<'ft' | 'm'>('ft');
|
||||
const FT_PER_M = 3.28084;
|
||||
const inputToFt = (v: string): number | undefined => {
|
||||
if (!v) return undefined;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return dimUnit === 'm' ? n * FT_PER_M : n;
|
||||
};
|
||||
|
||||
// Step 1 state
|
||||
const [letter, setLetter] = useState<DockLetter>('A');
|
||||
@@ -98,6 +117,13 @@ export function BulkAddBerthsWizard() {
|
||||
const [checkingDups, setCheckingDups] = useState(false);
|
||||
|
||||
async function handleGenerate() {
|
||||
// Validate the dock letter — must be one or more uppercase letters per
|
||||
// the canonical mooring regex. Custom-input path normalises to upper
|
||||
// already, but guard against an empty input.
|
||||
if (!letter || !/^[A-Z]+$/.test(letter)) {
|
||||
toast.error('Dock letter must be one or more uppercase letters (e.g. A, B, AA).');
|
||||
return;
|
||||
}
|
||||
const s = parseInt(rangeStart, 10);
|
||||
const e = parseInt(rangeEnd, 10);
|
||||
if (!Number.isFinite(s) || !Number.isFinite(e) || s < 0 || e < s) {
|
||||
@@ -167,9 +193,9 @@ export function BulkAddBerthsWizard() {
|
||||
area: r.area,
|
||||
status: r.status,
|
||||
tenureType: r.tenureType,
|
||||
lengthFt: r.lengthFt ? Number(r.lengthFt) : undefined,
|
||||
widthFt: r.widthFt ? Number(r.widthFt) : undefined,
|
||||
draftFt: r.draftFt ? Number(r.draftFt) : undefined,
|
||||
lengthFt: inputToFt(r.lengthFt),
|
||||
widthFt: inputToFt(r.widthFt),
|
||||
draftFt: inputToFt(r.draftFt),
|
||||
price: r.price ? Number(r.price) : undefined,
|
||||
priceCurrency: r.priceCurrency || undefined,
|
||||
sidePontoon: r.sidePontoon || undefined,
|
||||
@@ -202,18 +228,37 @@ export function BulkAddBerthsWizard() {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Dock letter</Label>
|
||||
<Select value={letter} onValueChange={(v) => setLetter(v as DockLetter)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCK_LETTERS.map((l) => (
|
||||
<SelectItem key={l} value={l}>
|
||||
{l}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Common dock letters as quick-pick chips; "Custom…" reveals
|
||||
a free-text input for ports whose dock layout extends
|
||||
beyond A-E (rare but supported). Canonical mooring regex
|
||||
is `^[A-Z]+\d+$`, so any uppercase letter sequence is
|
||||
valid as the prefix. */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{COMMON_DOCK_LETTERS.map((l) => (
|
||||
<Button
|
||||
key={l}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={letter === l ? 'default' : 'outline'}
|
||||
className="h-9 w-9 p-0 font-mono"
|
||||
onClick={() => setLetter(l)}
|
||||
>
|
||||
{l}
|
||||
</Button>
|
||||
))}
|
||||
<Input
|
||||
value={(COMMON_DOCK_LETTERS as readonly string[]).includes(letter) ? '' : letter}
|
||||
onChange={(e) => setLetter(e.target.value.toUpperCase().replace(/[^A-Z]/g, ''))}
|
||||
placeholder="Other…"
|
||||
aria-label="Custom dock letter"
|
||||
maxLength={4}
|
||||
className="h-9 w-20 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Any uppercase letter sequence. Common ports use A-E; mark a custom letter when
|
||||
expanding to F+ or letter-pairs like AA.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Range start</Label>
|
||||
@@ -265,11 +310,31 @@ export function BulkAddBerthsWizard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Step 2 - Fill in each row</CardTitle>
|
||||
<CardDescription>
|
||||
Per-row dimensions, pricing, pontoon. Use the “Apply to all” inputs in the
|
||||
header to copy a value down every row at once.
|
||||
</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Step 2 - Fill in each row</CardTitle>
|
||||
<CardDescription>
|
||||
Per-row dimensions, pricing, pontoon. Use the “Apply to all” inputs in the
|
||||
header to copy a value down every row at once.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{/* Dimension-unit toggle. The wizard stores values as-entered;
|
||||
conversion to canonical feet (1 m = 3.28084 ft) happens once
|
||||
at submit. Switching mid-edit leaves existing inputs as
|
||||
numeric strings — the rep is responsible for re-entering if
|
||||
the unit interpretation just changed under them. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDimUnit(dimUnit === 'ft' ? 'm' : 'ft')}
|
||||
aria-label={`Switch dimension entry to ${dimUnit === 'ft' ? 'metres' : 'feet'}`}
|
||||
title={`Entering dimensions in ${dimUnit === 'ft' ? 'feet' : 'metres'} — click to switch`}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{dimUnit}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{remainingDuplicates.length > 0 ? (
|
||||
@@ -306,13 +371,13 @@ export function BulkAddBerthsWizard() {
|
||||
Mooring
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-2">
|
||||
Length (ft)
|
||||
Length ({dimUnit})
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-2">
|
||||
Width (ft)
|
||||
Width ({dimUnit})
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-2">
|
||||
Draft (ft)
|
||||
Draft ({dimUnit})
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-2">
|
||||
Side pontoon
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { ClipboardCopy, Mail } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle2, ClipboardCopy, Clock, Mail, Send } from 'lucide-react';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -23,9 +24,20 @@ interface IssueResponse {
|
||||
link: string;
|
||||
expiresAt: string;
|
||||
emailSent: boolean;
|
||||
resent?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenHistoryRow {
|
||||
id: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
consumedAt: string | null;
|
||||
issuedBy: string | null;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click "Request more info" action. Fires the supplemental-info-
|
||||
* request endpoint, which emails the client a public form pre-filled
|
||||
@@ -37,23 +49,39 @@ interface IssueResponse {
|
||||
* sense before the signed EOI freezes the data into the contract path.
|
||||
*/
|
||||
export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
|
||||
// History query — the latest 20 issuances. Refetched after every
|
||||
// mutation so the rep sees the just-generated row appear immediately.
|
||||
const history = useQuery({
|
||||
queryKey: ['supplemental-info', interestId, 'history'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: TokenHistoryRow[] }>(
|
||||
`/api/v1/interests/${interestId}/supplemental-info-request`,
|
||||
),
|
||||
enabled: eoiStatus !== 'signed',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (vars: { sendEmail: boolean }) =>
|
||||
mutationFn: (vars: { sendEmail: boolean; tokenId?: string }) =>
|
||||
apiFetch<IssueResponse>(`/api/v1/interests/${interestId}/supplemental-info-request`, {
|
||||
method: 'POST',
|
||||
body: vars,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLink(res.data.link);
|
||||
if (res.data.emailSent) {
|
||||
if (res.data.resent) {
|
||||
toast.success('Email re-sent using the existing link.');
|
||||
} else if (res.data.emailSent) {
|
||||
toast.success('Email sent. Link also shown below for sharing manually.');
|
||||
} else {
|
||||
toast.message(
|
||||
'Link generated. Click "Send by email" to mail it, or copy it to share manually.',
|
||||
);
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['supplemental-info', interestId, 'history'] });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to generate the form link.'),
|
||||
@@ -61,6 +89,14 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
|
||||
|
||||
if (eoiStatus === 'signed') return null;
|
||||
|
||||
// Pick the latest unconsumed + unexpired token, if any. That's the
|
||||
// candidate for "Resend" — the rep wants the same link to land in the
|
||||
// client's inbox again. Older or consumed tokens stay in history but
|
||||
// can't be resent (consumed = form already submitted; expired = link
|
||||
// dead).
|
||||
const tokens = history.data?.data ?? [];
|
||||
const resendableToken = tokens.find((t) => !t.consumedAt && !t.expired) ?? null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` because it
|
||||
@@ -83,17 +119,35 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
|
||||
onClick={() => mutation.mutate({ sendEmail: false })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Generating…' : link ? 'Regenerate link' : 'Generate link'}
|
||||
{mutation.isPending
|
||||
? 'Generating…'
|
||||
: resendableToken
|
||||
? 'Regenerate link'
|
||||
: 'Generate link'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={resendableToken ? 'outline' : 'default'}
|
||||
onClick={() => mutation.mutate({ sendEmail: true })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<Mail className="mr-1.5 size-3.5" aria-hidden />
|
||||
{link ? 'Send by email' : 'Generate + email'}
|
||||
{resendableToken ? 'New link + email' : 'Generate + email'}
|
||||
</Button>
|
||||
{resendableToken ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => mutation.mutate({ sendEmail: true, tokenId: resendableToken.id })}
|
||||
disabled={mutation.isPending}
|
||||
title="Re-email the existing active link to the client. No new token is created."
|
||||
>
|
||||
<Send className="mr-1.5 size-3.5" aria-hidden />
|
||||
Resend current
|
||||
</Button>
|
||||
) : null}
|
||||
{link ? (
|
||||
<>
|
||||
<Input value={link} readOnly className="h-8 text-xs font-mono flex-1 min-w-[260px]" />
|
||||
@@ -112,6 +166,41 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Issuance history — every past supplemental link for this
|
||||
interest, newest first. Lets the rep see whether a previous
|
||||
link is still outstanding (so they can Resend rather than
|
||||
mint a fresh one) and confirm whether the client ever
|
||||
submitted. Hidden when the list is empty. */}
|
||||
{tokens.length > 0 ? (
|
||||
<div className="space-y-1 border-t pt-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Issuance history
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{tokens.map((t) => {
|
||||
const created = new Date(t.createdAt);
|
||||
const status = t.consumedAt
|
||||
? { label: 'Submitted', tone: 'text-emerald-700', icon: CheckCircle2 }
|
||||
: t.expired
|
||||
? { label: 'Expired', tone: 'text-muted-foreground', icon: Clock }
|
||||
: { label: 'Active', tone: 'text-amber-700', icon: Clock };
|
||||
const StatusIcon = status.icon;
|
||||
return (
|
||||
<li key={t.id} className="flex items-center justify-between gap-2 py-1.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon className={`size-3 ${status.tone}`} aria-hidden />
|
||||
<span className={`font-medium ${status.tone}`}>{status.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDistanceToNowStrict(created, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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