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:
@@ -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've pre-filled what we have on file. Please review, correct anything that's
|
We've pre-filled what we have on file. Please review, correct anything that's
|
||||||
wrong, and add what's missing.
|
wrong, and add what'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'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">
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
Reference in New Issue
Block a user