diff --git a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts index f5959670..4f323759 100644 --- a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts +++ b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts @@ -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 = `

Hello ${escapeHtml(result.clientName)},

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) { diff --git a/src/components/admin/bulk-add-berths-wizard.tsx b/src/components/admin/bulk-add-berths-wizard.tsx index 85b10cf5..a7a30119 100644 --- a/src/components/admin/bulk-add-berths-wizard.tsx +++ b/src/components/admin/bulk-add-berths-wizard.tsx @@ -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('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() {

- + {/* 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. */} +
+ {COMMON_DOCK_LETTERS.map((l) => ( + + ))} + 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" + /> +
+

+ Any uppercase letter sequence. Common ports use A-E; mark a custom letter when + expanding to F+ or letter-pairs like AA. +

@@ -265,11 +310,31 @@ export function BulkAddBerthsWizard() { return ( - Step 2 - Fill in each row - - Per-row dimensions, pricing, pontoon. Use the “Apply to all” inputs in the - header to copy a value down every row at once. - +
+
+ Step 2 - Fill in each row + + Per-row dimensions, pricing, pontoon. Use the “Apply to all” inputs in the + header to copy a value down every row at once. + +
+ {/* 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. */} + +
{remainingDuplicates.length > 0 ? ( @@ -306,13 +371,13 @@ export function BulkAddBerthsWizard() { Mooring - Length (ft) + Length ({dimUnit}) - Width (ft) + Width ({dimUnit}) - Draft (ft) + Draft ({dimUnit}) Side pontoon diff --git a/src/components/interests/supplemental-info-request-button.tsx b/src/components/interests/supplemental-info-request-button.tsx index 2eb98d50..fc7e63ac 100644 --- a/src/components/interests/supplemental-info-request-button.tsx +++ b/src/components/interests/supplemental-info-request-button.tsx @@ -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(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(`/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 ( {/* 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'} + {resendableToken ? ( + + ) : null} {link ? ( <> @@ -112,6 +166,41 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props) ) : null}
+ + {/* 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 ? ( +
+
+ Issuance history +
+ +
+ ) : null} ); diff --git a/src/lib/services/supplemental-forms.service.ts b/src/lib/services/supplemental-forms.service.ts index 84bcb9bb..a7ac8471 100644 --- a/src/lib/services/supplemental-forms.service.ts +++ b/src/lib/services/supplemental-forms.service.ts @@ -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 { + 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 { const row = await db.query.supplementalFormTokens.findFirst({ where: eq(supplementalFormTokens.token, token),