Flips three callers from inline per-type strips to registry dispatch.
THE main payoff: the date_ranked_choice required-validation gap that the
audit doc flagged is now closed by construction.
submit/+server.ts:
- Replace the inline empty-check loop with
`getQuestion(q.type).isAnswerEmpty(q, body.answers[q.id])`.
- The legacy rule matched only string/array empties; date_ranked_choice's
`{}` answer (object exists but no options rated) passed even when the
question was required. ONE source of truth, two callers (client +
server), no drift possible.
results.ts:
- aggregateResults walks the form definition and routes each question
through `getQuestion(q.type).{emptyStats, ingest, finalise}`.
- All seven per-type emptyStats / ingest / finalise functions deleted
(~150 lines).
- publicResults delegates to each module's sanitizeForPublic. The
hardcoded text-strip rule moves into short_text/long_text modules.
- ScaleStatsWip / DateRankedOptionStatsWip private types deleted —
each module now owns its own accumulator shape.
export/+server.ts:
- Replace the per-type CSV column expansion (the date_ranked_choice
one-column-per-option special case + the otherwise-one-column-per-question
fallthrough) with a uniform `getQuestion(q.type).csvColumns(q)` walk.
- Replace the per-cell answer extraction with
`getQuestion(q.type).csvCellFor(q, answer, col)`.
schemas.ts:
- Per-question-type schemas deleted (~40 lines). FeedbackQuestionSchema
imports each module's schema directly and assembles the discriminated
union. Adding a new type means: create lib/questions/<type>.ts +
register in registry.ts + add one import + one tuple entry here.
(Kept the explicit tuple because TypeScript can't infer a properly-
narrowed discriminated union from a runtime-built array — the inferred
FeedbackQuestion type would lose specificity at every call site.)
123 server tests pass — all existing assertions still hold against the
registry-routed aggregator. svelte-check + bun run build clean.
Client-side wiring (FormBuilder, participant page, Results.svelte, admin
detail submissions table) lands in the next commit.
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."