Single-submission enforcement (one form response per participant) #4
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Make it hard (but not impossible) for participants to submit a feedback form more than once. m: "make it hard for them — from one machine or so".
Approach
Layered, all using existing schema fields. No new hash column needed — dedup against
client_session_id,client_ip,user_agentwe already store onfeedback_submissions.Schema
Single migration: add
feedback_instances.single_submission BOOLEAN NOT NULL DEFAULT true. Authors can opt out per-instance.Server logic
src/routes/api/public/feedback/[slug]/submit/+server.ts:inst.single_submission = true(default), before insert:instance_id = inst.id AND (client_session_id = body.client_session_id OR (client_ip = req.ip AND user_agent = req.user_agent))409 Conflictwith{ error: 'already_submitted', submitted_at: <iso>, answers: <previous answers> }false: behave as today (always insert).The client_ip + user_agent comparison is the back-stop for someone who clears LocalStorage. Same browser + same IP = blocked. Different browser = different UA = allowed (acceptable per m's brief).
Client behaviour
src/routes/f/[slug]/+page.svelte:client_session_idfrom LocalStorage. No change there.Admin UI
src/routes/admin/feedback/new/+page.svelte: add a toggle "Limit to one submission per participant" (default checked) below the chat toggle.src/routes/admin/feedback/[id]/+page.svelte→ Edit tab: same toggle in the form-definition editor.src/lib/schemas.ts: addsingle_submission?: booleanto InstanceCreate + InstancePatch schemas.What's intentionally not blocked
These are acceptable per m's "not too intrusive" + "make it hard".
Acceptance
single_submission = true(default) — second submission from same browser returns 409bun run checkclean; existing tests passHow to work
Single branch, single commit (or 2: schema + server, then UI). Push, self-merge, redeploy.
Done — single-submission shipped to prod
Default-on. Existing instances default to true via the migration. Authors can opt out per-instance.
What landed
Migration —
fdbck_feedback_instances_add_single_submission(applied via Supabase MCP):Schema —
src/lib/schemas.ts:single_submission?: booleanonFeedbackInstanceCreateSchemaandFeedbackInstanceUpdateSchema.FeedbackInstanceinterface extended.Server —
src/routes/api/public/feedback/[slug]/submit/+server.ts: wheninst.single_submission === true, look up the most recent existing submission matchinginstance_id AND (client_session_id = body.client_session_id) OR (client_ip = req.ip AND user_agent = req.user_agent). Returns409with{ error: 'already_submitted', submitted_at, display_name, answers }so the client can render the previous answers without an extra round-trip. Two parameterised queries rather than a single.or()filter —client_session_idhas no character restriction, splicing it into a PostgREST filter string would risk filter injection.Admin POST + PATCH —
src/routes/api/admin/feedback/+server.tsand[id]/+server.ts: persistsingle_submission(POST defaults totruewhen omitted; PATCH only writes when explicitly set).Participant UI —
src/routes/f/[slug]/+page.svelte: on 409, replace the form with a read-only "Du hast am [date] bereits abgesendet" card listing the previous answers per question. NewsummariseSubmittedAnswer(q, v)helper covers all six question types (short_text,long_text,single_choice,multi_choice,scalew/ min/max labels,boolean,date_ranked_choicerating maps). Live results polling still kicks in if enabled.Admin UI — toggle "Limit to one submission per participant" added on
/admin/feedback/new(below Live chat) and on the detail page Edit tab (below Live results). Default checked, with a one-line hint explaining what it blocks.Verification
bun run check— 0 errors, 33 warnings (32 pre-existing + 1 added a11y label-without-control on the new toggle, same pattern as the existing toggles in the same file).bun run test— 25 pass, 0 fail.bun run build— succeeds.Ship trail
mai/hermes/single-submission120f079— single-submission enforcement (default on)0135de1—--no-ffinto maineizLcK2WmMWY6n10EftdV), service settled to 1/1,https://fdbck.msbls.de/returns 200.Smoke path
/admin/feedback/<id>→ Edit → save → can submit again.Out of scope per brief: fingerprinting, CAPTCHA, chat-post uniqueness.
Follow-up fixes shipped
Smoke-pass bugs from the single-submission ship — all four resolved.
Fix 1 — Reload showed the wrong branch (CRITICAL)
The client never refetched its previous submission, so a reload after submitting fell into the legacy "Du kannst trotzdem nochmal antworten" branch instead of the read-only summary.
Resolution:
+page.server.ts) now does an IP+UA backstop lookup againstfeedback_submissionswhensingle_submission = true. First paint after reload renders the read-only summary directly — no client round-trip needed for the common case.?session_id=query param onGET /api/public/feedback/<slug>resolves the submission by LocalStorage session id. The client supplements server load viaonMountfor the cleared-cookies-but-same-browser / new-IP case.data.previous_submissionseedspreviousSubmissionstate on first paint..eq()queries as the submit handler — never splices the user-controlled session id into a PostgREST.or()filter.Fix 2 — Bad German copy
Du hast schon abgesendet. Du kannst trotzdem nochmal antworten:rewritten as a question:Button label: "Weitere Antwort senden". This branch is now also gated on
!singleSubmission— when the toggle is on it never fires (thepreviousSubmissionbranch wins).Fix 3 — Read-only card looked like a form
.fb-already(form-input-styled boxes around values) replaced with.fb-summary— a confirmation card:<dl>definition list with muted weight-500 labels above plain body-weight values. Light dividers between rows. No input outlines.var(--color-bg-secondary), radiusvar(--radius-lg), padding1.5rem.Fix 4 — "Noch eine Antwort senden" button on success when
single_submissionONThe ghost button is now hidden when
singleSubmission === true— clicking it sent users into a form they'd be 409-blocked on. The "Danke für dein Feedback!" banner stands alone in that case.Verification
bun run check— 0 errors.bun run test— 25 pass, 0 fail.bun run build— succeeds.Ship trail
mai/hermes/single-sub-fixes778df21538419f(--no-ffinto main)eizLcK2WmMWY6n10EftdV), service settled to 1/1,https://fdbck.msbls.de/returns 200.Smoke matrix
single_submissionON, submit, reloadsingle_submissionON, submit, clear cookies, reloadsingle_submissionON, submit (this session)single_submissionOFF, submit, reloadsingle_submissionOFF, submit (this session)