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 @@