Files
fdbck/src/routes/admin/feedback/[id]/+page.svelte
mAi 120f0798cd mAi: #4 - single-submission enforcement (default on)
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/<slug>/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/<slug>): on 409, replace the form with a read-only "already
submitted on <date>" 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.
2026-05-06 15:32:20 +02:00

694 lines
21 KiB
Svelte

<script lang="ts">
import '$lib/styles/feedback.css';
import { onMount, onDestroy } from 'svelte';
import { goto, invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import { FeedbackFormDefinitionSchema, type FeedbackFormDefinition } from '$lib/schemas';
import type { AggregatedResults } from '$lib/server/results';
import Results from '$lib/components/Results.svelte';
import FormBuilder from '$lib/components/FormBuilder.svelte';
import Icon from '$lib/components/Icon.svelte';
let { data }: { data: PageData } = $props();
let inst = $state(data.instance);
let submissions = $state(data.submissions);
let posts = $state(data.posts);
let results = $state<AggregatedResults | null>(data.results);
let activeTab = $state<'chat' | 'submissions' | 'results' | 'edit'>('chat');
let actionError = $state<string | null>(null);
let actionInFlight = $state(false);
let editTitle = $state(inst.title);
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);
let shareError = $state<string | null>(null);
let shareCopied = $state(false);
let editMode = $state<'visual' | 'json'>('visual');
let editForm = $state<FeedbackFormDefinition | null>(
inst.form_definition ? (inst.form_definition as FeedbackFormDefinition) : null,
);
let editFormJson = $state(inst.form_definition ? JSON.stringify(inst.form_definition, null, 2) : '');
// Optimistic status pill — same pattern as the list page.
let optimisticStatus = $state<'open' | 'closed' | null>(null);
const effectiveStatus = $derived<'open' | 'closed'>(optimisticStatus ?? inst.status);
function syncJsonFromVisual(): void {
editFormJson = editForm ? JSON.stringify(editForm, null, 2) : '';
}
function syncVisualFromJson(): boolean {
const trimmed = editFormJson.trim();
if (!trimmed) {
editForm = null;
return true;
}
try {
const parsed = JSON.parse(trimmed);
editForm = FeedbackFormDefinitionSchema.parse(parsed);
return true;
} catch (err) {
actionError = `JSON ungültig: ${err instanceof Error ? err.message : 'parse error'}`;
return false;
}
}
function switchEditMode(next: 'visual' | 'json'): void {
if (next === editMode) return;
actionError = null;
if (next === 'json') syncJsonFromVisual();
else if (!syncVisualFromJson()) return;
editMode = next;
}
function ensureBuilderForm(): void {
if (!editForm) editForm = { questions: [{ id: 'q1', label: 'Question 1', type: 'short_text', required: false }] };
}
let pollHandle: ReturnType<typeof setInterval> | null = null;
const formDef = $derived(inst.form_definition as FeedbackFormDefinition | null);
const questions = $derived(formDef?.questions ?? []);
const formVersion = $derived(formDef?.version ?? null);
async function refresh(): Promise<void> {
try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const j = (await res.json()) as {
instance: typeof inst;
submissions: typeof submissions;
posts: typeof posts;
results: AggregatedResults | null;
};
inst = j.instance;
submissions = j.submissions;
posts = j.posts;
results = j.results;
} catch {
// transient
}
}
async function toggleHide(postId: string, currentlyHidden: boolean): Promise<void> {
actionError = null;
try {
const res = await fetch(`/api/admin/feedback/${inst.id}/posts/${postId}/hide`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !currentlyHidden }),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Error ${res.status}`;
return;
}
await refresh();
} catch (e) {
actionError = e instanceof Error ? e.message : 'Network error';
}
}
async function toggleStatus(): Promise<void> {
if (actionInFlight) return;
const current = effectiveStatus;
const next: 'open' | 'closed' = current === 'open' ? 'closed' : 'open';
optimisticStatus = next;
actionInFlight = true;
actionError = null;
try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: next }),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Error ${res.status}`;
optimisticStatus = null;
return;
}
await refresh();
optimisticStatus = null;
} catch (e) {
actionError = e instanceof Error ? e.message : 'Network error';
optimisticStatus = null;
} finally {
actionInFlight = false;
}
}
async function saveEdits(): Promise<void> {
actionError = null;
actionInFlight = true;
let parsedForm: FeedbackFormDefinition | null = null;
if (editMode === 'json') {
const trimmed = editFormJson.trim();
if (trimmed) {
try {
parsedForm = FeedbackFormDefinitionSchema.parse(JSON.parse(trimmed));
} catch (err) {
actionError = `Invalid JSON: ${err instanceof Error ? err.message : 'parse error'}`;
actionInFlight = false;
return;
}
}
} else {
parsedForm = editForm;
if (parsedForm) {
try {
parsedForm = FeedbackFormDefinitionSchema.parse(parsedForm);
} catch (err) {
actionError = `Invalid questions: ${err instanceof Error ? err.message : 'parse error'}`;
actionInFlight = false;
return;
}
}
}
try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: editTitle,
description: editDescription || null,
chat_enabled: editChatEnabled,
live_results_enabled: editLiveResults,
single_submission: editSingleSubmission,
form_definition: parsedForm,
}),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
actionError = j.error ?? `Error ${res.status}`;
if (j.details) actionError += ' — ' + JSON.stringify(j.details);
return;
}
await refresh();
editForm = inst.form_definition ? (inst.form_definition as FeedbackFormDefinition) : null;
syncJsonFromVisual();
activeTab = inst.form_definition ? 'results' : (inst.chat_enabled ? 'chat' : 'submissions');
} catch (e) {
actionError = e instanceof Error ? e.message : 'Network error';
} finally {
actionInFlight = false;
}
}
async function destroy(): Promise<void> {
const ok = confirm(`Delete "${inst.title}" and all its responses and messages? This cannot be undone.`);
if (!ok) return;
actionInFlight = true;
try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, { method: 'DELETE' });
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Error ${res.status}`;
return;
}
await goto('/admin/feedback');
} catch (e) {
actionError = e instanceof Error ? e.message : 'Network error';
} finally {
actionInFlight = false;
}
}
async function copyLink(): Promise<void> {
try {
await navigator.clipboard.writeText(`${location.origin}/f/${inst.slug}`);
} catch {
// silent
}
}
async function copyShortUrl(): Promise<void> {
if (!inst.short_url) return;
try {
await navigator.clipboard.writeText(inst.short_url);
shareCopied = true;
setTimeout(() => (shareCopied = false), 1500);
} catch {
// silent
}
}
async function copyShareStripUrl(): Promise<void> {
if (inst.short_url) {
await copyShortUrl();
} else {
await copyLink();
shareCopied = true;
setTimeout(() => (shareCopied = false), 1500);
}
}
async function createShareLink(): Promise<void> {
shareError = null;
shareInFlight = true;
try {
const slug = shareSlugInput.trim();
const body: { customSlug?: string } = {};
if (slug) body.customSlug = slug;
const res = await fetch(`/api/admin/feedback/${inst.id}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
shareError = j.error ?? `Error ${res.status}`;
if (j.details) shareError += ' — ' + JSON.stringify(j.details);
return;
}
const j = (await res.json()) as { instance: typeof inst };
inst = j.instance;
shareSlugInput = '';
} catch (e) {
shareError = e instanceof Error ? e.message : 'Network error';
} finally {
shareInFlight = false;
}
}
async function exportCsv(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=csv`;
}
async function exportJson(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=json`;
}
function fmtDateTime(iso: string): string {
return new Date(iso).toLocaleString();
}
function summarizeAnswer(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
if (Array.isArray(v)) return v.join(', ');
if (typeof v === 'object') {
// date_ranked_choice answers are { optId: 1..5 | null } — terse summary for the table cell.
const ratings = Object.values(v as Record<string, unknown>).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
);
if (ratings.length === 0) return '—';
const avg = ratings.reduce((a, b) => a + b, 0) / ratings.length;
const fmt = avg.toFixed(1).replace(/\.0$/, '');
return `${fmt} avg (${ratings.length} rated)`;
}
return String(v);
}
function answerCellFor(qid: string, sub: { answers: Record<string, unknown> }): string {
return summarizeAnswer(sub.answers?.[qid]);
}
// Click-outside-to-close + Escape for any open <details class="fb-menu">.
function onDocClick(e: MouseEvent): void {
const target = e.target as Node | null;
if (!target) return;
document.querySelectorAll<HTMLDetailsElement>('details.fb-menu[open]').forEach((d) => {
if (!d.contains(target)) d.open = false;
});
}
function onDocKey(e: KeyboardEvent): void {
if (e.key !== 'Escape') return;
document.querySelectorAll<HTMLDetailsElement>('details.fb-menu[open]').forEach((d) => {
d.open = false;
});
}
onMount(() => {
pollHandle = setInterval(refresh, 5000);
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', onDocKey);
return () => {
if (pollHandle) clearInterval(pollHandle);
};
});
onDestroy(() => {
if (pollHandle) clearInterval(pollHandle);
if (typeof document !== 'undefined') {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onDocKey);
}
void invalidateAll;
});
</script>
<svelte:head>
<title>{inst.title} — Feedback admin</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<div class="fb-shell">
<a href="/admin/feedback" class="fb-back-link">
<Icon name="arrow-left" /> Forms
</a>
<header class="fb-header">
<div class="fb-detail-head">
<div class="fb-detail-head__title">
<h1>{inst.title}</h1>
{#if inst.description}<p>{inst.description}</p>{/if}
</div>
<div class="fb-detail-head__actions">
<button
type="button"
class="fb-status-pill fb-status-pill--{effectiveStatus}"
disabled={actionInFlight}
onclick={toggleStatus}
aria-label={effectiveStatus === 'open' ? 'Open — click to close' : 'Closed — click to reopen'}
title={effectiveStatus === 'open' ? 'Click to close' : 'Click to reopen'}
>
<span class="fb-status-pill__dot"></span>{effectiveStatus}
</button>
<details class="fb-menu">
<!-- svelte-ignore a11y_no_redundant_roles -->
<summary class="fb-menu__btn" role="button" aria-label="More actions"></summary>
<div class="fb-menu__panel" role="menu">
<button type="button" class="fb-menu__item" onclick={copyLink}>
<Icon name="copy" /> Copy /f/{inst.slug}
</button>
<button type="button" class="fb-menu__item" onclick={exportCsv}>
<Icon name="download" /> Export CSV
</button>
<button type="button" class="fb-menu__item" onclick={exportJson}>
<Icon name="download" /> Export JSON
</button>
<hr class="fb-menu__divider" />
<button
type="button"
class="fb-menu__item fb-menu__item--danger"
disabled={actionInFlight}
onclick={destroy}
>
<Icon name="trash" /> Delete
</button>
</div>
</details>
</div>
</div>
<div class="fb-share-strip">
{#if inst.short_url}
<a
class="fb-share-strip__url"
href={inst.short_url}
target="_blank"
rel="noopener"
>{inst.short_url}</a>
{:else}
<a
class="fb-share-strip__url"
href={`/f/${inst.slug}`}
target="_blank"
rel="noopener"
>/f/{inst.slug}</a>
{/if}
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={copyShareStripUrl}>
<Icon name="copy" /> {shareCopied ? 'Copied' : 'Copy'}
</button>
<a
class="fb-btn fb-btn--ghost fb-btn--sm"
href={inst.short_url ?? `/f/${inst.slug}`}
target="_blank"
rel="noopener"
>
<Icon name="external-link" /> Open
</a>
</div>
<details class="fb-share-strip__replace">
<summary>{inst.short_url ? 'Replace short link' : 'Create memorable short link'}</summary>
<div>
<label class="fb-question__label" for="fb-share-slug">Custom slug (optional)</label>
<div class="fb-save-row" style="margin-top: 0.4rem;">
<input
id="fb-share-slug"
class="fb-input"
maxlength="64"
placeholder="e.g. vote, session-feedback"
bind:value={shareSlugInput}
style="max-width: 320px; flex: 1;"
/>
<button
type="button"
class="fb-btn"
disabled={shareInFlight}
onclick={createShareLink}
>
{shareInFlight ? 'Creating…' : (inst.short_url ? 'Replace' : 'Create')}
</button>
</div>
<p class="fb-question__help" style="margin-top: 0.4rem;">
Leave blank for a random short code, or pick a memorable slug like
<code>vote</code> or <code>session-feedback</code>.
</p>
{#if shareError}
<div class="fb-inline-error">{shareError}</div>
{/if}
</div>
</details>
</header>
{#if actionError}
<div class="fb-banner fb-banner--error">{actionError}</div>
{/if}
<div class="fb-tabs">
{#each [
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },
{ key: 'results', label: `Results${results ? ` (${results.total_submissions})` : ''}`, show: !!inst.form_definition },
{ key: 'submissions', label: `Responses (${submissions.length})`, show: !!inst.form_definition },
{ key: 'edit', label: 'Edit', show: true },
] as t (t.key)}
{#if t.show}
<button
type="button"
class="fb-tab"
class:fb-tab--active={activeTab === t.key}
onclick={() => (activeTab = t.key as typeof activeTab)}
>
{t.label}
</button>
{/if}
{/each}
</div>
{#if activeTab === 'chat'}
<section class="fb-section fb-tab-body">
{#if posts.length === 0}
<p class="fb-empty">No messages yet.</p>
{:else}
<div class="fb-detail-list">
{#each posts as p (p.id)}
<div class="fb-chat__post {p.hidden ? 'fb-chat__post--hidden' : ''}">
<div class="fb-chat__meta">
<span class="fb-chat__name">{p.display_name ?? 'anonymous'}</span>
<span>{fmtDateTime(p.created_at)}</span>
<span style="font-size: 0.75rem;">session: {p.client_session_id.slice(0, 8)}</span>
<button
type="button"
class="fb-btn fb-btn--ghost fb-btn--sm"
style="margin-left: auto;"
onclick={() => toggleHide(p.id, p.hidden)}
>
{#if p.hidden}<Icon name="eye" /> Show{:else}<Icon name="eye-off" /> Hide{/if}
</button>
</div>
<div class="fb-chat__body" style="text-decoration: {p.hidden ? 'line-through' : 'none'};">
{p.body}
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'results'}
<section class="fb-section fb-tab-body">
{#if results}
<Results {results} />
{:else}
<p class="fb-empty">No questions configured.</p>
{/if}
</section>
{:else if activeTab === 'submissions'}
<section class="fb-section fb-tab-body">
{#if submissions.length === 0}
<p class="fb-empty">No responses yet.</p>
{:else}
<div class="fb-detail-table-wrap">
<table class="fb-detail-table">
<thead>
<tr>
<th>When</th>
<th>Name</th>
{#each questions as q (q.id)}
<th>{q.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each submissions as s (s.id)}
<tr>
<td class="fb-detail-table__date">{fmtDateTime(s.created_at)}</td>
<td>{s.display_name ?? 'anonymous'}</td>
{#each questions as q (q.id)}
<td class="fb-detail-table__cell">{answerCellFor(q.id, s)}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{:else if activeTab === 'edit'}
<section class="fb-section fb-tab-body">
{#if submissions.length > 0}
<p class="fb-question__help" style="margin-bottom: var(--space-4);">
{submissions.length} responses already received. Saving will automatically
bump the version — earlier responses keep their original snapshot.
</p>
{/if}
<div class="fb-question">
<label class="fb-question__label" for="fb-edit-title">Title</label>
<input id="fb-edit-title" class="fb-input" maxlength="200" bind:value={editTitle} />
</div>
<div class="fb-question">
<label class="fb-question__label" for="fb-edit-desc">Description</label>
<textarea id="fb-edit-desc" class="fb-textarea" maxlength="2000" rows="2" bind:value={editDescription}></textarea>
</div>
<label class="fb-toggle">
<span class="fb-toggle__text">
<span class="fb-toggle__label">Live chat</span>
<span class="fb-toggle__hint">Let participants post messages in real time.</span>
</span>
<input type="checkbox" bind:checked={editChatEnabled} />
</label>
<label class="fb-toggle">
<span class="fb-toggle__text">
<span class="fb-toggle__label">Live results</span>
<span class="fb-toggle__hint">Show participants the live aggregate after they submit.</span>
</span>
<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>
<div class="fb-segment" role="tablist" aria-label="Editor mode">
<button
type="button"
class="fb-segment__btn"
class:fb-segment__btn--active={editMode === 'visual'}
onclick={() => switchEditMode('visual')}
>Visual</button>
<button
type="button"
class="fb-segment__btn"
class:fb-segment__btn--active={editMode === 'json'}
onclick={() => switchEditMode('json')}
>JSON</button>
</div>
</div>
{#if editMode === 'visual'}
{#if editForm}
<FormBuilder bind:value={editForm as FeedbackFormDefinition} />
{:else}
<p class="fb-empty">No questions configured.</p>
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" onclick={ensureBuilderForm}>
<Icon name="plus" /> Add questions
</button>
{/if}
{:else}
<textarea
id="fb-edit-form"
class="fb-textarea"
rows="14"
bind:value={editFormJson}
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.85rem;"
></textarea>
{/if}
</div>
<div class="fb-save-row">
<button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}>
<Icon name="check" /> {actionInFlight ? 'Saving…' : 'Save'}
</button>
{#if formVersion !== null}
<span class="fb-version-note">Current version: v{formVersion}</span>
{/if}
</div>
</section>
{/if}
</div>
<style>
.fb-detail-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.fb-detail-table-wrap {
overflow-x: auto;
}
.fb-detail-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.fb-detail-table thead tr {
border-bottom: 1px solid var(--color-border-primary);
}
.fb-detail-table tbody tr {
border-bottom: 1px solid var(--color-border-primary);
}
.fb-detail-table th,
.fb-detail-table td {
text-align: left;
padding: 0.5rem 0.4rem;
}
.fb-detail-table__date {
white-space: nowrap;
color: var(--color-text-muted);
}
.fb-detail-table__cell {
max-width: 320px;
word-break: break-word;
}
.fb-edit-questions-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
flex-wrap: wrap;
}
</style>