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

@@ -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.');