feat(uat-batch-15): supplemental-info link reusable until expiry

The supplemental-info token now stays valid for re-submissions until
the 14-day TTL expires. Previously the link was single-use:
`applySubmission` required `consumedAt IS NULL`, which locked clients
out of correcting a typo or finishing a partial submission.

- Service: drops the `isNull(consumedAt)` filter; TTL is the sole
  validity check. `consumedAt` is still stamped on each submit so the
  rep / loader can see "last submitted at" context.
- Public form: the "already submitted" lockout screen is removed.
  Instead, when the token has been used before, the form renders with
  the prefill (already reflecting the latest data) plus a soft amber
  banner noting that changes overwrite the previous submission.
- Drive-by em-dash fix on the post-submit thank-you copy (matches the
  Wave-1 lint guard).

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 18:23:44 +02:00
parent 4d3d7489bf
commit b74fc56a3b
2 changed files with 19 additions and 20 deletions

View File

@@ -157,26 +157,17 @@ export default function SupplementalInfoPage({ params }: PageProps) {
); );
} }
if (data?.token.consumed) { // Tokens are now reusable until expiry — the consumed flag is kept
return ( // so the form can show a soft "you've submitted this before" banner
<BrandedAuthShell> // (and prefill the entered values) without locking the recipient out
<div className="text-center space-y-3 py-6"> // of updating their details.
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
<h1 className="text-lg font-semibold">Thanks we already have your details</h1>
<p className="text-sm text-muted-foreground">
This form was already submitted. Your sales contact will be in touch shortly.
</p>
</div>
</BrandedAuthShell>
);
}
if (submitted) { if (submitted) {
return ( return (
<BrandedAuthShell> <BrandedAuthShell>
<div className="text-center space-y-3 py-6"> <div className="text-center space-y-3 py-6">
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" /> <CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
<h1 className="text-lg font-semibold">Thanks got it</h1> <h1 className="text-lg font-semibold">Thanks, got it</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Your details have been sent to the team. Watch your inbox for your EOI document shortly. Your details have been sent to the team. Watch your inbox for your EOI document shortly.
</p> </p>
@@ -194,6 +185,12 @@ export default function SupplementalInfoPage({ params }: PageProps) {
We&apos;ve pre-filled what we have on file. Please review, correct anything that&apos;s We&apos;ve pre-filled what we have on file. Please review, correct anything that&apos;s
wrong, and add what&apos;s missing. wrong, and add what&apos;s missing.
</p> </p>
{data?.token.consumed ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
You&apos;ve submitted this form before. Feel free to update any details. Changes
overwrite the previous submission.
</div>
) : null}
</div> </div>
<fieldset className="space-y-4"> <fieldset className="space-y-4">

View File

@@ -8,7 +8,7 @@
* updates, consume token. All inside one transaction. * updates, consume token. All inside one transaction.
*/ */
import { and, eq, isNull } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
@@ -193,14 +193,16 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
} }
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
// Reusable-until-expiry: the link stays valid for repeat
// submissions until it expires. `consumedAt` is still stamped on
// first submit so the rep / loader can show "last submitted at
// <time>" context, but it no longer gates the submission. The TTL
// gate below is the sole validity check.
const row = await tx.query.supplementalFormTokens.findFirst({ const row = await tx.query.supplementalFormTokens.findFirst({
where: and( where: eq(supplementalFormTokens.token, token),
eq(supplementalFormTokens.token, token),
isNull(supplementalFormTokens.consumedAt),
),
}); });
if (!row) { if (!row) {
throw new ConflictError('This link has already been used or is no longer valid.'); throw new ConflictError('This link is no longer valid.');
} }
if (row.expiresAt.getTime() < Date.now()) { if (row.expiresAt.getTime() < Date.now()) {
throw new ConflictError('This link has expired.'); throw new ConflictError('This link has expired.');