Merge mai/hermes/single-submission: single-submission enforcement (m/fdbck#4)

This commit is contained in:
mAi
2026-05-06 15:32:26 +02:00
9 changed files with 201 additions and 2 deletions

View File

@@ -85,6 +85,7 @@ export const FeedbackInstanceCreateSchema = z.object({
form_definition: FeedbackFormDefinitionSchema.nullable().optional(), form_definition: FeedbackFormDefinitionSchema.nullable().optional(),
chat_enabled: z.boolean().optional(), chat_enabled: z.boolean().optional(),
live_results_enabled: z.boolean().optional(), live_results_enabled: z.boolean().optional(),
single_submission: z.boolean().optional(),
}).refine( }).refine(
(v) => v.form_definition != null || v.chat_enabled === true, (v) => v.form_definition != null || v.chat_enabled === true,
{ message: 'Either form_definition or chat_enabled must be set' }, { message: 'Either form_definition or chat_enabled must be set' },
@@ -96,6 +97,7 @@ export const FeedbackInstanceUpdateSchema = z.object({
form_definition: FeedbackFormDefinitionSchema.nullable().optional(), form_definition: FeedbackFormDefinitionSchema.nullable().optional(),
chat_enabled: z.boolean().optional(), chat_enabled: z.boolean().optional(),
live_results_enabled: z.boolean().optional(), live_results_enabled: z.boolean().optional(),
single_submission: z.boolean().optional(),
status: z.enum(['open', 'closed']).optional(), status: z.enum(['open', 'closed']).optional(),
}); });

View File

@@ -33,6 +33,7 @@ export interface FeedbackInstance {
form_definition: unknown | null; form_definition: unknown | null;
chat_enabled: boolean; chat_enabled: boolean;
live_results_enabled: boolean; live_results_enabled: boolean;
single_submission: boolean;
status: 'open' | 'closed'; status: 'open' | 'closed';
closed_at: string | null; closed_at: string | null;
short_url: string | null; short_url: string | null;

View File

@@ -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 */ /* date_ranked_choice — view toggle + calendar + bars */
.fb-results__drc-tabs { .fb-results__drc-tabs {

View File

@@ -24,6 +24,7 @@
let editDescription = $state(inst.description ?? ''); let editDescription = $state(inst.description ?? '');
let editChatEnabled = $state(inst.chat_enabled); let editChatEnabled = $state(inst.chat_enabled);
let editLiveResults = $state(inst.live_results_enabled); let editLiveResults = $state(inst.live_results_enabled);
let editSingleSubmission = $state(inst.single_submission ?? true);
let shareSlugInput = $state(''); let shareSlugInput = $state('');
let shareInFlight = $state(false); let shareInFlight = $state(false);
@@ -183,6 +184,7 @@
description: editDescription || null, description: editDescription || null,
chat_enabled: editChatEnabled, chat_enabled: editChatEnabled,
live_results_enabled: editLiveResults, live_results_enabled: editLiveResults,
single_submission: editSingleSubmission,
form_definition: parsedForm, form_definition: parsedForm,
}), }),
}); });
@@ -588,6 +590,14 @@
<input type="checkbox" bind:checked={editLiveResults} /> <input type="checkbox" bind:checked={editLiveResults} />
</label> </label>
<label class="fb-toggle">
<span class="fb-toggle__text">
<span class="fb-toggle__label">Limit to one submission per participant</span>
<span class="fb-toggle__hint">Blocks repeats from the same browser or IP + user-agent.</span>
</span>
<input type="checkbox" bind:checked={editSingleSubmission} />
</label>
<div class="fb-question" style="margin-top: var(--space-5);"> <div class="fb-question" style="margin-top: var(--space-5);">
<div class="fb-edit-questions-head"> <div class="fb-edit-questions-head">
<span class="fb-question__label" style="margin: 0;">Questions</span> <span class="fb-question__label" style="margin: 0;">Questions</span>

View File

@@ -11,6 +11,7 @@
let title = $state(''); let title = $state('');
let description = $state(''); let description = $state('');
let chatEnabled = $state(true); let chatEnabled = $state(true);
let singleSubmission = $state(true);
// Question authoring — mirrors the detail-page Edit tab. editForm is the // Question authoring — mirrors the detail-page Edit tab. editForm is the
// canonical structured form; editFormJson is its textarea-friendly // canonical structured form; editFormJson is its textarea-friendly
@@ -115,6 +116,7 @@
description: description || undefined, description: description || undefined,
form_definition: parsedForm, form_definition: parsedForm,
chat_enabled: chatEnabled, chat_enabled: chatEnabled,
single_submission: singleSubmission,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -179,6 +181,14 @@
<input type="checkbox" bind:checked={chatEnabled} /> <input type="checkbox" bind:checked={chatEnabled} />
</label> </label>
<label class="fb-toggle">
<span class="fb-toggle__text">
<span class="fb-toggle__label">Limit to one submission per participant</span>
<span class="fb-toggle__hint">Blocks repeats from the same browser or IP + user-agent. Off = anyone can submit multiple times.</span>
</span>
<input type="checkbox" bind:checked={singleSubmission} />
</label>
<details class="fb-question fb-new-advanced"> <details class="fb-question fb-new-advanced">
<summary>Add questions now (advanced)</summary> <summary>Add questions now (advanced)</summary>

View File

@@ -81,6 +81,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
form_definition: stampedForm, form_definition: stampedForm,
chat_enabled: body.chat_enabled === true, chat_enabled: body.chat_enabled === true,
live_results_enabled: body.live_results_enabled === true, live_results_enabled: body.live_results_enabled === true,
single_submission: body.single_submission ?? true,
}).select().single(); }).select().single();
if (error) throw error; if (error) throw error;

View File

@@ -114,6 +114,7 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (body.description !== undefined) update.description = body.description; if (body.description !== undefined) update.description = body.description;
if (body.chat_enabled !== undefined) update.chat_enabled = body.chat_enabled; 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.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) { if (body.status !== undefined) {
update.status = body.status; update.status = body.status;
update.closed_at = body.status === 'closed' ? new Date().toISOString() : null; update.closed_at = body.status === 'closed' ? new Date().toISOString() : null;

View File

@@ -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({ const { error } = await fdb().from('feedback_submissions').insert({
instance_id: inst.id, instance_id: inst.id,
display_name: body.display_name, display_name: body.display_name,
@@ -53,7 +99,7 @@ export const POST: RequestHandler = async ({ params, request, getClientAddress }
answers: body.answers, answers: body.answers,
form_snapshot: formDef, form_snapshot: formDef,
client_ip: ip, client_ip: ip,
user_agent: clampUserAgent(request.headers.get('user-agent')), user_agent: ua,
}); });
if (error) throw error; if (error) throw error;

View File

@@ -23,6 +23,13 @@
let submitInFlight = $state(false); let submitInFlight = $state(false);
let answers = $state<Record<string, unknown>>({}); let answers = $state<Record<string, unknown>>({});
interface PreviousSubmission {
answers: Record<string, unknown>;
display_name: string | null;
submitted_at: string;
}
let previousSubmission = $state<PreviousSubmission | null>(null);
interface ChatPost { interface ChatPost {
id: string; id: string;
display_name: string | null; display_name: string | null;
@@ -252,6 +259,22 @@
submitError = 'Zu viele Antworten in kurzer Zeit. Bitte kurz warten.'; submitError = 'Zu viele Antworten in kurzer Zeit. Bitte kurz warten.';
return; return;
} }
if (res.status === 409) {
const j = (await res.json().catch(() => ({}))) as Partial<PreviousSubmission> & { 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) { if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string }; const j = (await res.json().catch(() => ({}))) as { error?: string };
submitError = j.error ?? `Fehler ${res.status}`; submitError = j.error ?? `Fehler ${res.status}`;
@@ -272,10 +295,62 @@
function resetSubmissionUi(): void { function resetSubmissionUi(): void {
submitSuccess = false; submitSuccess = false;
alreadySubmitted = false; alreadySubmitted = false;
previousSubmission = null;
answers = {}; answers = {};
saveSession(); 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<string, unknown>;
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 { function isMine(p: ChatPost): boolean {
return p.client_session_id === sessionId; return p.client_session_id === sessionId;
} }
@@ -431,7 +506,22 @@
<div class="fb-participant"> <div class="fb-participant">
{#if formDef} {#if formDef}
<section class="fb-participant__col fb-participant__col--form" aria-label="Formular"> <section class="fb-participant__col fb-participant__col--form" aria-label="Formular">
{#if submitSuccess} {#if previousSubmission}
<div class="fb-banner">
Du hast am <strong>{fmtSubmittedAt(previousSubmission.submitted_at)}</strong> bereits abgesendet. Eine erneute Antwort ist für dieses Formular nicht vorgesehen.
</div>
<div class="fb-already">
{#if previousSubmission.display_name}
<div class="fb-already__name">Name: <strong>{previousSubmission.display_name}</strong></div>
{/if}
{#each formDef.questions as q (q.id)}
<div class="fb-already__row">
<div class="fb-already__label">{q.label}</div>
<div class="fb-already__value">{summariseSubmittedAnswer(q, previousSubmission.answers?.[q.id])}</div>
</div>
{/each}
</div>
{:else if submitSuccess}
<div class="fb-banner fb-form-banner--success"> <div class="fb-banner fb-form-banner--success">
Danke für dein Feedback! Danke für dein Feedback!
</div> </div>