Registry slot 7/7. The big one. Closes the server-side required-validation
gap the audit (§3.A) flagged: legacy submit/+server.ts only matched empty
strings and empty arrays, so a date_ranked_choice answer of `{}` (object
exists, but no options rated) passed the gate even when the question was
required. The client-side validator caught it; the server didn't.
THE source of truth for the gap is now `isAnswerEmpty(q, answer)`:
- undefined / null → empty
- empty object → empty (this is the case the legacy gate missed)
- object where every value is null → empty
- object with at least one finite integer 1..5 → not empty
- out-of-range / non-integer / wrong-shape → empty
After the wiring step (commit 11) flips submit/+server.ts to call
`getQuestion(q.type).isAnswerEmpty(q, answer)`, the gap closes by
construction — one rule, two callers, no drift.
Files added:
- date_ranked_choice.ts — schema (extends base with options[2..50] of
{id, start, end?, label?}, optional scale.{min,max}_label, optional
allow_partial), defaultStub (two slots starting at the next hour
+ 24h), isAnswerEmpty (the gap closer above), emptyStats with
per-option OptStatsWip accumulators, ingest (per-option counting,
range-checking, _sum), finalise (mean = _sum/count, sort by mean
desc with tiebreaks on 5-count / 4-count / count / id), CSV (one
column per option, header format <qid>[<optId>]), adminCellSummary
("X avg (N rated)").
- date_ranked_choice.input.svelte — per-option row with date display,
1..5 rating buttons, skip button. Same markup the participant page
renders today.
- date_ranked_choice.builder.svelte — date/time options list with add /
remove, scale labels (rating-1 / rating-5 captions), allow_partial
toggle. Includes the renamed `setDateRankedScaleLabel` from the
papercut commit. Date-handling helpers (isoToLocalInput,
localInputToIso, defaultStartIso, optUid) live here.
- date_ranked_choice.results.svelte — full calendar + bars view with
view-toggle (Kalender / Balken). All helper logic — buildCalendar,
cellTitle, colorForRating, colorForMean, fmtTimeRange, fmtDateOption,
fmtMean, mixHex — is now per-module instead of in Results.svelte.
- date_ranked_choice.test.ts — 22 cases covering schema (duplicate ids,
fewer than 2 options, malformed ISO, disallowed id chars), the seven
isAnswerEmpty rules above, ingest+finalise (sort, _sum drop, range
filtering, missing-answer handling), CSV (one column per option,
cell extraction, wrong-shape passthrough), adminCellSummary.
123 server tests pass (was 101). svelte-check + bun run build clean.
All seven types now in QUESTION_MODULES. The wiring step (next commit)
flips the legacy callers — schemas.ts assembles its discriminated union
from the registry, FormBuilder mounts BuilderEditor by type, participant
page mounts ParticipantInput, Results.svelte mounts ResultsBlock, the
submit endpoint calls isAnswerEmpty per type, the export endpoint calls
csvColumns + csvCellFor per type. After that, the legacy `q.type === '...'`
strips disappear.
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."