Flips the four client-side per-type strips to registry dispatch.
FormBuilder.svelte (438L → 122L):
- Replace seven per-type editor branches with a single
`<Editor question={q} update={...} />` mount where Editor =
getQuestion(q.type).BuilderEditor.
- Replace the static TYPES array + TYPE_LABELS map with iteration over
QUESTION_MODULES (preserves picker order; module label is the source
of truth for the picker text).
- Replace the local defaultQuestion(type) factory with
getQuestion(type).defaultStub() — the changeType + add helpers route
through the registry too.
- Remove the inline date helpers (isoToLocalInput, localInputToIso,
defaultStartIso, optUid) — they now live in
date_ranked_choice.builder.svelte alongside the only callers.
routes/f/[slug]/+page.svelte (participant page):
- Replace seven per-type input branches with a single ParticipantInput
mount: `<Input question={q} answer={...} setAnswer={...} />`.
- submitForm validation calls `getQuestion(q.type).isAnswerEmpty(q,
answer)` for the required gate. The date_ranked_choice
`allow_partial: false` "rate every option" rule stays as a
client-only UX nudge — it's not a security gate, the server treats
partial answers as valid.
- summariseSubmittedAnswer (used by the previousSubmission summary view)
delegates to the registry's adminCellSummary for everything except
boolean (German "Ja/Nein" vs registry's English "Yes/No") and
date_ranked_choice (the participant summary uses a per-option
breakdown with formatted dates that the registry's terse "X avg
(N rated)" string doesn't carry).
Results.svelte (375L → 25L):
- Replace four per-type results branches with a single ResultsBlock
mount: `<Block question={q} stats={q.stats} />`.
- Per-type ResultsBlocks updated to match the existing German labels
(Schnitt / Antworten / Andere [frühere Versionen]) so the visible
output is byte-compatible with what's deployed today.
- All helpers — buildCalendar, cellTitle, colorForRating, colorForMean,
fmtTimeRange, fmtDateOption, fmtMean, mixHex, weekdayFmt, etc. — are
now per-type (most live in date_ranked_choice.results.svelte where
they're actually used).
routes/admin/feedback/[id]/+page.svelte:
- Replace local summarizeAnswer (type-agnostic, used the wrong rendering
for date_ranked_choice — averaged across all values instead of just
numeric ratings) with `getQuestion(q.type).adminCellSummary(q, answer)`.
- answerCellFor now takes the question (for type) instead of just qid.
- Per-type rendering for the submissions table is now correct for every
type — previously the JS-typeof dispatch produced garbage for some
shapes.
123 server tests pass. svelte-check + bun run build clean. No new
warnings — Phase 2 actually dropped 14 a11y warnings (FormBuilder's
unattached labels are gone with the per-type editor extraction).
After this commit, there are zero `q.type === '...'` strips in the
codebase outside the per-type modules themselves. Adding a new question
type is one file plus one line in registry.ts.
fdbck.msbls.de
Per-link feedback forms and live-chat masks. Anonymous, slug-gated, no auth required for participants.
Spun out from m/flexsiebels.de issue #63 — full design at docs/plans/feedback-feature.md.
Stack
SvelteKit 5 + Svelte 5 + bun + @sveltejs/adapter-node. Postgres + Supabase auth. Schema: fdbck.feedback_{instances,submissions,posts} on supa.flexsiebels.de (msupabase).
Run locally
cp .env.example .env # fill SUPABASE_*
bun install
bun run dev
Test + check
bun run test # rate-limit + public-scope unit tests
bun run check # svelte-check (type errors / a11y)
bun run build # adapter-node production build → ./build
Deploy
Dockerfile uses oven/bun:latest. Dokploy app: fdbck.msbls.de. DNS via Hostinger (handled out of band).
Structure
src/
hooks.server.ts — auth + public-scope policy gate
lib/server/
auth.ts — cookie JWT + Supabase refresh
fdb.ts — Postgres `fdbck` schema accessor
feedback.ts — slug generator + DB helpers + rate-limit constants
public-scope.ts — anonymous-DB-access fail-closed gate
rate-limit.ts — in-memory token bucket
schemas.ts — Zod request validation
supabase.ts — admin + anon client singletons
routes/
+page.svelte — landing
f/[slug]/ — public participant page (form + live chat)
admin/feedback/ — m's admin (list + detail + create)
api/
auth/ — sign-in / sign-out
public/feedback/ — anonymous slug-gated endpoints
admin/feedback/ — owner-scoped admin endpoints
Data model (canonical: design doc §5)
fdbck.feedback_instances— slug, title, description, owner_user_id, form_definition (jsonb), chat_enabled, status (open|closed), closed_atfdbck.feedback_submissions— instance_id, display_name (nullable = anonymous), client_session_id, answers (jsonb), client_ip, user_agentfdbck.feedback_posts— instance_id, display_name, client_session_id, body, hidden (m soft-moderate), client_ip, user_agent
Anti-abuse layers
- 32-char base62 slugs (~190 bits entropy)
- in-memory rate-limit (30 posts / 5 min, 10 submits / 5 min, per IP+slug)
- honeypot field on forms + chat (silently dropped)
- body length caps + closing kill-switch
- noindex meta +
robots.txtDisallow: /
Out of scope (v1)
Drag-drop form-builder · post reactions · realtime/SSE · CAPTCHA · trusted-tier owner sharing · branding/theming · auto-notifications. All have a clean upgrade path on the existing schema.
Issue origin
m/flexsiebels.de#63 — m PWA-voice 2026-05-05: "Im Wesentlichen quasi Microsoft Forms und Teams-Feedback in einem auf einer Webseite."