Single-submission enforcement (one form response per participant) #4

Open
opened 2026-05-06 13:26:50 +00:00 by mAi · 2 comments

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_agent we already store on feedback_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:

  • If inst.single_submission = true (default), before insert:
    • Query for an existing submission matching instance_id = inst.id AND (client_session_id = body.client_session_id OR (client_ip = req.ip AND user_agent = req.user_agent))
    • If found: return 409 Conflict with { error: 'already_submitted', submitted_at: <iso>, answers: <previous answers> }
  • If 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:

  • Already sends client_session_id from LocalStorage. No change there.
  • On 409: show a friendly card replacing the form: "You already submitted on [date]. Here's what you said:" with the previous answers in read-only form (re-render the questions with their values, no submit button). Use the existing question renderers. No error toast.
  • Smooth UX, no flash of the empty form.

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: add single_submission?: boolean to InstanceCreate + InstancePatch schemas.

What's intentionally not blocked

  • Different browser on same machine (different UA) → can submit again
  • Different network / mobile data / VPN (different IP, same UA still triggers if session_id was cleared but UA stable; but combined with new IP+UA, blocked)
  • Incognito + new IP → unblocked (no LocalStorage match, hash mismatch on at least IP)

These are acceptable per m's "not too intrusive" + "make it hard".

Acceptance

  • New instance with single_submission = true (default) — second submission from same browser returns 409
  • Participant on second visit sees "already submitted" card with their previous answers
  • Author can toggle off via admin UI to allow multiple
  • Toggle in Edit tab persists; existing instances default to true after migration
  • bun run check clean; existing tests pass

How to work

Single branch, single commit (or 2: schema + server, then UI). Push, self-merge, redeploy.

## 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_agent` we already store on `feedback_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`: - If `inst.single_submission = true` (default), before insert: - Query for an existing submission matching `instance_id = inst.id AND (client_session_id = body.client_session_id OR (client_ip = req.ip AND user_agent = req.user_agent))` - If found: return `409 Conflict` with `{ error: 'already_submitted', submitted_at: <iso>, answers: <previous answers> }` - If `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`: - Already sends `client_session_id` from LocalStorage. No change there. - On 409: show a friendly card replacing the form: \"You already submitted on [date]. Here's what you said:\" with the previous answers in read-only form (re-render the questions with their values, no submit button). Use the existing question renderers. No error toast. - Smooth UX, no flash of the empty form. ## 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`: add `single_submission?: boolean` to InstanceCreate + InstancePatch schemas. ## What's intentionally not blocked - Different browser on same machine (different UA) → can submit again - Different network / mobile data / VPN (different IP, same UA still triggers if session_id was cleared but UA stable; but combined with new IP+UA, blocked) - Incognito + new IP → unblocked (no LocalStorage match, hash mismatch on at least IP) These are acceptable per m's \"not too intrusive\" + \"make it hard\". ## Acceptance - New instance with `single_submission = true` (default) — second submission from same browser returns 409 - Participant on second visit sees \"already submitted\" card with their previous answers - Author can toggle off via admin UI to allow multiple - Toggle in Edit tab persists; existing instances default to true after migration - `bun run check` clean; existing tests pass ## How to work Single branch, single commit (or 2: schema + server, then UI). Push, self-merge, redeploy.
Author

Done — single-submission shipped to prod

Default-on. Existing instances default to true via the migration. Authors can opt out per-instance.

What landed

Migrationfdbck_feedback_instances_add_single_submission (applied via Supabase MCP):

ALTER TABLE fdbck.feedback_instances
  ADD COLUMN IF NOT EXISTS single_submission BOOLEAN NOT NULL DEFAULT true;

Schemasrc/lib/schemas.ts: single_submission?: boolean on FeedbackInstanceCreateSchema and FeedbackInstanceUpdateSchema. FeedbackInstance interface extended.

Serversrc/routes/api/public/feedback/[slug]/submit/+server.ts: when inst.single_submission === true, look up the most recent existing submission matching instance_id AND (client_session_id = body.client_session_id) OR (client_ip = req.ip AND user_agent = req.user_agent). Returns 409 with { 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_id has no character restriction, splicing it into a PostgREST filter string would risk filter injection.

Admin POST + PATCHsrc/routes/api/admin/feedback/+server.ts and [id]/+server.ts: persist single_submission (POST defaults to true when omitted; PATCH only writes when explicitly set).

Participant UIsrc/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. New summariseSubmittedAnswer(q, v) helper covers all six question types (short_text, long_text, single_choice, multi_choice, scale w/ min/max labels, boolean, date_ranked_choice rating 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

  • Branch: mai/hermes/single-submission
  • Commit: 120f079 — single-submission enforcement (default on)
  • Merge: 0135de1--no-ff into main
  • Pushed to main, Dokploy redeploy triggered (app eizLcK2WmMWY6n10EftdV), service settled to 1/1, https://fdbck.msbls.de/ returns 200.

Smoke path

  1. Create a form (default = single-submission ON) → submit answers → second submit attempt from the same browser shows the read-only "already submitted" card with the original answers.
  2. Toggle off in /admin/feedback/<id> → Edit → save → can submit again.
  3. Different browser / different network → can submit (intentional, per brief: "make it hard, not impossible").

Out of scope per brief: fingerprinting, CAPTCHA, chat-post uniqueness.

## 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): ```sql ALTER TABLE fdbck.feedback_instances ADD COLUMN IF NOT EXISTS single_submission BOOLEAN NOT NULL DEFAULT true; ``` **Schema** — `src/lib/schemas.ts`: `single_submission?: boolean` on `FeedbackInstanceCreateSchema` and `FeedbackInstanceUpdateSchema`. `FeedbackInstance` interface extended. **Server** — `src/routes/api/public/feedback/[slug]/submit/+server.ts`: when `inst.single_submission === true`, look up the most recent existing submission matching `instance_id AND (client_session_id = body.client_session_id) OR (client_ip = req.ip AND user_agent = req.user_agent)`. Returns `409` with `{ 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_id` has no character restriction, splicing it into a PostgREST filter string would risk filter injection. **Admin POST + PATCH** — `src/routes/api/admin/feedback/+server.ts` and `[id]/+server.ts`: persist `single_submission` (POST defaults to `true` when 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. New `summariseSubmittedAnswer(q, v)` helper covers all six question types (`short_text`, `long_text`, `single_choice`, `multi_choice`, `scale` w/ min/max labels, `boolean`, `date_ranked_choice` rating 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 - Branch: `mai/hermes/single-submission` - Commit: [`120f079`](https://mgit.msbls.de/m/fdbck/commit/120f079) — single-submission enforcement (default on) - Merge: [`0135de1`](https://mgit.msbls.de/m/fdbck/commit/0135de1) — `--no-ff` into main - Pushed to main, Dokploy redeploy triggered (app `eizLcK2WmMWY6n10EftdV`), service settled to 1/1, `https://fdbck.msbls.de/` returns 200. ### Smoke path 1. Create a form (default = single-submission ON) → submit answers → second submit attempt from the same browser shows the read-only "already submitted" card with the original answers. 2. Toggle off in `/admin/feedback/<id>` → Edit → save → can submit again. 3. Different browser / different network → can submit (intentional, per brief: "make it hard, not impossible"). Out of scope per brief: fingerprinting, CAPTCHA, chat-post uniqueness.
Author

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 load (+page.server.ts) now does an IP+UA backstop lookup against feedback_submissions when single_submission = true. First paint after reload renders the read-only summary directly — no client round-trip needed for the common case.
  • New optional ?session_id= query param on GET /api/public/feedback/<slug> resolves the submission by LocalStorage session id. The client supplements server load via onMount for the cleared-cookies-but-same-browser / new-IP case.
  • data.previous_submission seeds previousSubmission state on first paint.
  • Same parameterised .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:

Du hast bereits abgesendet. Möchtest du eine weitere Antwort senden?

Button label: "Weitere Antwort senden". This branch is now also gated on !singleSubmission — when the toggle is on it never fires (the previousSubmission branch 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:

  • Header: ✓-circle + "Antwort gesendet" + dimmer "am [date]" subtitle.
  • Body: <dl> definition list with muted weight-500 labels above plain body-weight values. Light dividers between rows. No input outlines.
  • ≥560px the rows become a two-column grid (label + value).
  • Card bg = var(--color-bg-secondary), radius var(--radius-lg), padding 1.5rem.

Fix 4 — "Noch eine Antwort senden" button on success when single_submission ON

The 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

  • Branch: mai/hermes/single-sub-fixes
  • Commit: 778df21
  • Merge: 538419f (--no-ff into main)
  • Pushed to main, Dokploy redeploy triggered (app eizLcK2WmMWY6n10EftdV), service settled to 1/1, https://fdbck.msbls.de/ returns 200.

Smoke matrix

Scenario Expected
single_submission ON, submit, reload Summary card with previous answers (no FOUC if same IP+UA)
single_submission ON, submit, clear cookies, reload Summary card after onMount session_id lookup completes
single_submission ON, submit (this session) "Danke" banner, no "Noch eine Antwort senden" button
single_submission OFF, submit, reload Legacy "Möchtest du eine weitere Antwort senden?" + button
single_submission OFF, submit (this session) "Danke" banner + "Noch eine Antwort senden" button
## 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 load (`+page.server.ts`) now does an IP+UA backstop lookup against `feedback_submissions` when `single_submission = true`. First paint after reload renders the read-only summary directly — no client round-trip needed for the common case. - New optional `?session_id=` query param on `GET /api/public/feedback/<slug>` resolves the submission by LocalStorage session id. The client supplements server load via `onMount` for the cleared-cookies-but-same-browser / new-IP case. - `data.previous_submission` seeds `previousSubmission` state on first paint. - Same parameterised `.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: > Du hast bereits abgesendet. Möchtest du eine weitere Antwort senden? Button label: "Weitere Antwort senden". This branch is now also gated on `!singleSubmission` — when the toggle is on it never fires (the `previousSubmission` branch 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: - Header: ✓-circle + "Antwort gesendet" + dimmer "am [date]" subtitle. - Body: `<dl>` definition list with muted weight-500 labels above plain body-weight values. Light dividers between rows. No input outlines. - ≥560px the rows become a two-column grid (label + value). - Card bg = `var(--color-bg-secondary)`, radius `var(--radius-lg)`, padding `1.5rem`. ### Fix 4 — "Noch eine Antwort senden" button on success when `single_submission` ON The 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 - Branch: `mai/hermes/single-sub-fixes` - Commit: [`778df21`](https://mgit.msbls.de/m/fdbck/commit/778df21) - Merge: [`538419f`](https://mgit.msbls.de/m/fdbck/commit/538419f) (`--no-ff` into main) - Pushed to main, Dokploy redeploy triggered (app `eizLcK2WmMWY6n10EftdV`), service settled to 1/1, `https://fdbck.msbls.de/` returns 200. ### Smoke matrix | Scenario | Expected | |---|---| | `single_submission` ON, submit, reload | Summary card with previous answers (no FOUC if same IP+UA) | | `single_submission` ON, submit, clear cookies, reload | Summary card after onMount session_id lookup completes | | `single_submission` ON, submit (this session) | "Danke" banner, **no** "Noch eine Antwort senden" button | | `single_submission` OFF, submit, reload | Legacy "Möchtest du eine weitere Antwort senden?" + button | | `single_submission` OFF, submit (this session) | "Danke" banner + "Noch eine Antwort senden" button |
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/fdbck#4
No description provided.