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) {
return (
<BrandedAuthShell>
<div className="text-center space-y-3 py-6">
<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>
);
}
// Tokens are now reusable until expiry — the consumed flag is kept
// so the form can show a soft "you've submitted this before" banner
// (and prefill the entered values) without locking the recipient out
// of updating their details.
if (submitted) {
return (
<BrandedAuthShell>
<div className="text-center space-y-3 py-6">
<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">
Your details have been sent to the team. Watch your inbox for your EOI document shortly.
</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
wrong, and add what&apos;s missing.
</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>
<fieldset className="space-y-4">

View File

@@ -8,7 +8,7 @@
* 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 { db } from '@/lib/db';
@@ -193,14 +193,16 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
}
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({
where: and(
eq(supplementalFormTokens.token, token),
isNull(supplementalFormTokens.consumedAt),
),
where: eq(supplementalFormTokens.token, token),
});
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()) {
throw new ConflictError('This link has expired.');