Merge mai/hermes/single-submission: single-submission enforcement (m/fdbck#4)
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 @@
|
||||
<input type="checkbox" bind:checked={editLiveResults} />
|
||||
</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-edit-questions-head">
|
||||
<span class="fb-question__label" style="margin: 0;">Questions</span>
|
||||
|
||||
@@ -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 @@
|
||||
<input type="checkbox" bind:checked={chatEnabled} />
|
||||
</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">
|
||||
<summary>Add questions now (advanced)</summary>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
let submitInFlight = $state(false);
|
||||
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 {
|
||||
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<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) {
|
||||
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<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 {
|
||||
return p.client_session_id === sessionId;
|
||||
}
|
||||
@@ -431,7 +506,22 @@
|
||||
<div class="fb-participant">
|
||||
{#if formDef}
|
||||
<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">
|
||||
Danke für dein Feedback!
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user