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(),
|
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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user