From 120f0798cd8fcf5cf7b0db4fd45122772080e86b Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 6 May 2026 15:32:20 +0200 Subject: [PATCH] mAi: #4 - single-submission enforcement (default on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block repeat submissions per participant by default. No new fingerprint column — dedup against the existing client_session_id and (client_ip, user_agent) we already store on feedback_submissions. Schema migration `fdbck_feedback_instances_add_single_submission` adds single_submission BOOLEAN NOT NULL DEFAULT true (applied via Supabase MCP). Existing instances default to true. Author opts out per-instance via the new toggle on /admin/feedback/new and the detail Edit tab. Server (POST /api/public/feedback//submit): when inst.single_submission is 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). Two separate parameterised queries instead of a single PostgREST `.or()` filter — the user-controlled session id has no character restriction in the schema, so splicing it into a filter string would risk PostgREST filter injection. Returns 409 with { error: 'already_submitted', submitted_at, display_name, answers } so the client can render the previous answers without an extra round-trip. Client (/f/): on 409, replace the form with a read-only "already submitted on " card listing the previous answers per question. Reuses question shape via a small summariseSubmittedAnswer() helper covering all six question types including date_ranked_choice rating maps. No submit button on the read-only view; live results polling still kicks off. Admin schemas (InstanceCreate + InstanceUpdate) accept the new single_submission boolean. POST/PATCH endpoints persist it. bun check 0 errors, bun test 25 pass, bun build OK. --- src/lib/schemas.ts | 2 + src/lib/server/feedback.ts | 1 + src/lib/styles/feedback.css | 38 ++++++++ src/routes/admin/feedback/[id]/+page.svelte | 10 ++ src/routes/admin/feedback/new/+page.svelte | 10 ++ src/routes/api/admin/feedback/+server.ts | 1 + src/routes/api/admin/feedback/[id]/+server.ts | 1 + .../public/feedback/[slug]/submit/+server.ts | 48 +++++++++- src/routes/f/[slug]/+page.svelte | 92 ++++++++++++++++++- 9 files changed, 201 insertions(+), 2 deletions(-) diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 3cbf10b..d1001f9 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -85,6 +85,7 @@ export const FeedbackInstanceCreateSchema = z.object({ form_definition: FeedbackFormDefinitionSchema.nullable().optional(), chat_enabled: z.boolean().optional(), live_results_enabled: z.boolean().optional(), + single_submission: z.boolean().optional(), }).refine( (v) => v.form_definition != null || v.chat_enabled === true, { message: 'Either form_definition or chat_enabled must be set' }, @@ -96,6 +97,7 @@ export const FeedbackInstanceUpdateSchema = z.object({ form_definition: FeedbackFormDefinitionSchema.nullable().optional(), chat_enabled: z.boolean().optional(), live_results_enabled: z.boolean().optional(), + single_submission: z.boolean().optional(), status: z.enum(['open', 'closed']).optional(), }); diff --git a/src/lib/server/feedback.ts b/src/lib/server/feedback.ts index f110cf2..ed368c3 100644 --- a/src/lib/server/feedback.ts +++ b/src/lib/server/feedback.ts @@ -33,6 +33,7 @@ export interface FeedbackInstance { form_definition: unknown | null; chat_enabled: boolean; live_results_enabled: boolean; + single_submission: boolean; status: 'open' | 'closed'; closed_at: string | null; short_url: string | null; diff --git a/src/lib/styles/feedback.css b/src/lib/styles/feedback.css index 88ff82d..68d46b7 100644 --- a/src/lib/styles/feedback.css +++ b/src/lib/styles/feedback.css @@ -899,6 +899,44 @@ body { min-height: 100vh; } } } +/* "Already submitted" read-only summary card on /f/[slug]. */ + +.fb-already { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-top: 0.6rem; +} + +.fb-already__name { + font-size: 0.9rem; + color: var(--fb-muted); +} + +.fb-already__row { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-bg-primary); +} + +.fb-already__label { + font-size: 0.78rem; + color: var(--fb-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.fb-already__value { + font-size: 0.95rem; + color: var(--color-text-primary); + white-space: pre-wrap; + word-break: break-word; +} + /* date_ranked_choice — view toggle + calendar + bars */ .fb-results__drc-tabs { diff --git a/src/routes/admin/feedback/[id]/+page.svelte b/src/routes/admin/feedback/[id]/+page.svelte index 7930777..f685787 100644 --- a/src/routes/admin/feedback/[id]/+page.svelte +++ b/src/routes/admin/feedback/[id]/+page.svelte @@ -24,6 +24,7 @@ let editDescription = $state(inst.description ?? ''); let editChatEnabled = $state(inst.chat_enabled); let editLiveResults = $state(inst.live_results_enabled); + let editSingleSubmission = $state(inst.single_submission ?? true); let shareSlugInput = $state(''); let shareInFlight = $state(false); @@ -183,6 +184,7 @@ description: editDescription || null, chat_enabled: editChatEnabled, live_results_enabled: editLiveResults, + single_submission: editSingleSubmission, form_definition: parsedForm, }), }); @@ -588,6 +590,14 @@ + +
Questions diff --git a/src/routes/admin/feedback/new/+page.svelte b/src/routes/admin/feedback/new/+page.svelte index 5a85aaa..6be198f 100644 --- a/src/routes/admin/feedback/new/+page.svelte +++ b/src/routes/admin/feedback/new/+page.svelte @@ -11,6 +11,7 @@ let title = $state(''); let description = $state(''); let chatEnabled = $state(true); + let singleSubmission = $state(true); // Question authoring — mirrors the detail-page Edit tab. editForm is the // canonical structured form; editFormJson is its textarea-friendly @@ -115,6 +116,7 @@ description: description || undefined, form_definition: parsedForm, chat_enabled: chatEnabled, + single_submission: singleSubmission, }), }); if (!res.ok) { @@ -179,6 +181,14 @@ + +
Add questions now (advanced) diff --git a/src/routes/api/admin/feedback/+server.ts b/src/routes/api/admin/feedback/+server.ts index ba285e0..96bfcbc 100644 --- a/src/routes/api/admin/feedback/+server.ts +++ b/src/routes/api/admin/feedback/+server.ts @@ -81,6 +81,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { form_definition: stampedForm, chat_enabled: body.chat_enabled === true, live_results_enabled: body.live_results_enabled === true, + single_submission: body.single_submission ?? true, }).select().single(); if (error) throw error; diff --git a/src/routes/api/admin/feedback/[id]/+server.ts b/src/routes/api/admin/feedback/[id]/+server.ts index ad0817d..44cf181 100644 --- a/src/routes/api/admin/feedback/[id]/+server.ts +++ b/src/routes/api/admin/feedback/[id]/+server.ts @@ -114,6 +114,7 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { if (body.description !== undefined) update.description = body.description; if (body.chat_enabled !== undefined) update.chat_enabled = body.chat_enabled; if (body.live_results_enabled !== undefined) update.live_results_enabled = body.live_results_enabled; + if (body.single_submission !== undefined) update.single_submission = body.single_submission; if (body.status !== undefined) { update.status = body.status; update.closed_at = body.status === 'closed' ? new Date().toISOString() : null; diff --git a/src/routes/api/public/feedback/[slug]/submit/+server.ts b/src/routes/api/public/feedback/[slug]/submit/+server.ts index a4d9288..b7165d4 100644 --- a/src/routes/api/public/feedback/[slug]/submit/+server.ts +++ b/src/routes/api/public/feedback/[slug]/submit/+server.ts @@ -46,6 +46,52 @@ export const POST: RequestHandler = async ({ params, request, getClientAddress } } } + const ua = clampUserAgent(request.headers.get('user-agent')); + + // Single-submission gate. Default-on per migration. Match either the LocalStorage-backed + // session id or (client_ip + user_agent) — clearing storage alone shouldn't bypass it. + // Two parameterised queries (vs. a single `.or()`) so user-controlled session_id can't + // inject into the PostgREST filter string. + if (inst.single_submission) { + const cols = 'id, answers, display_name, created_at'; + const bySession = await fdb() + .from('feedback_submissions') + .select(cols) + .eq('instance_id', inst.id) + .eq('client_session_id', body.client_session_id) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (bySession.error) throw bySession.error; + + let existing = bySession.data; + if (!existing && ua) { + const byIpUa = await fdb() + .from('feedback_submissions') + .select(cols) + .eq('instance_id', inst.id) + .eq('client_ip', ip) + .eq('user_agent', ua) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (byIpUa.error) throw byIpUa.error; + existing = byIpUa.data; + } + + if (existing) { + return new Response( + JSON.stringify({ + error: 'already_submitted', + submitted_at: existing.created_at, + display_name: existing.display_name, + answers: existing.answers, + }), + { status: 409, headers: { 'Content-Type': 'application/json' } }, + ); + } + } + const { error } = await fdb().from('feedback_submissions').insert({ instance_id: inst.id, display_name: body.display_name, @@ -53,7 +99,7 @@ export const POST: RequestHandler = async ({ params, request, getClientAddress } answers: body.answers, form_snapshot: formDef, client_ip: ip, - user_agent: clampUserAgent(request.headers.get('user-agent')), + user_agent: ua, }); if (error) throw error; diff --git a/src/routes/f/[slug]/+page.svelte b/src/routes/f/[slug]/+page.svelte index ca9ce69..5a65b66 100644 --- a/src/routes/f/[slug]/+page.svelte +++ b/src/routes/f/[slug]/+page.svelte @@ -23,6 +23,13 @@ let submitInFlight = $state(false); let answers = $state>({}); + interface PreviousSubmission { + answers: Record; + display_name: string | null; + submitted_at: string; + } + let previousSubmission = $state(null); + interface ChatPost { id: string; display_name: string | null; @@ -252,6 +259,22 @@ submitError = 'Zu viele Antworten in kurzer Zeit. Bitte kurz warten.'; return; } + if (res.status === 409) { + const j = (await res.json().catch(() => ({}))) as Partial & { error?: string }; + if (j.error === 'already_submitted' && j.answers && j.submitted_at) { + previousSubmission = { + answers: j.answers, + display_name: j.display_name ?? null, + submitted_at: j.submitted_at, + }; + alreadySubmitted = true; + saveSession(); + startResultsPolling(); + return; + } + submitError = j.error ?? `Fehler ${res.status}`; + return; + } if (!res.ok) { const j = (await res.json().catch(() => ({}))) as { error?: string }; submitError = j.error ?? `Fehler ${res.status}`; @@ -272,10 +295,62 @@ function resetSubmissionUi(): void { submitSuccess = false; alreadySubmitted = false; + previousSubmission = null; answers = {}; saveSession(); } + const submittedAtFmt = new Intl.DateTimeFormat([], { + dateStyle: 'medium', + timeStyle: 'short', + }); + + function fmtSubmittedAt(iso: string): string { + try { + return submittedAtFmt.format(new Date(iso)); + } catch { + return iso; + } + } + + function summariseSubmittedAnswer(q: FeedbackQuestion, v: unknown): string { + if (v === undefined || v === null) return '—'; + if (q.type === 'short_text' || q.type === 'long_text') { + return typeof v === 'string' ? v : String(v); + } + if (q.type === 'single_choice') { + return typeof v === 'string' ? v : String(v); + } + if (q.type === 'multi_choice') { + if (Array.isArray(v)) return v.length === 0 ? '—' : v.join(', '); + return String(v); + } + if (q.type === 'scale') { + if (typeof v !== 'number') return String(v); + const labels: string[] = []; + if (q.min_label && v === q.min) labels.push(q.min_label); + if (q.max_label && v === q.max) labels.push(q.max_label); + return labels.length ? `${v} — ${labels.join(', ')}` : String(v); + } + if (q.type === 'boolean') { + return v === true ? 'Ja' : v === false ? 'Nein' : '—'; + } + if (q.type === 'date_ranked_choice') { + if (!v || typeof v !== 'object' || Array.isArray(v)) return '—'; + const map = v as Record; + const lines: string[] = []; + for (const opt of q.options) { + const r = map[opt.id]; + if (typeof r !== 'number') continue; + const when = fmtDateOption(opt.start, opt.end); + const label = opt.label ? ` (${opt.label})` : ''; + lines.push(`${when}${label}: ${r}/5`); + } + return lines.length ? lines.join('\n') : '—'; + } + return String(v); + } + function isMine(p: ChatPost): boolean { return p.client_session_id === sessionId; } @@ -431,7 +506,22 @@
{#if formDef}
- {#if submitSuccess} + {#if previousSubmission} +
+ Du hast am {fmtSubmittedAt(previousSubmission.submitted_at)} bereits abgesendet. Eine erneute Antwort ist für dieses Formular nicht vorgesehen. +
+
+ {#if previousSubmission.display_name} +
Name: {previousSubmission.display_name}
+ {/if} + {#each formDef.questions as q (q.id)} +
+
{q.label}
+
{summariseSubmittedAnswer(q, previousSubmission.answers?.[q.id])}
+
+ {/each} +
+ {:else if submitSuccess}
Danke für dein Feedback!