mAi 1ab6ef7f22 feat(questions): date_ranked_choice module — closes the validation gap
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.
2026-05-07 20:22:52 +02:00
2026-05-05 11:38:11 +02:00

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_at
  • fdbck.feedback_submissions — instance_id, display_name (nullable = anonymous), client_session_id, answers (jsonb), client_ip, user_agent
  • fdbck.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.txt Disallow: /

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."

Description
fdbck.msbls.de — standalone anonymous feedback (forms + live chat) per-link app
Readme 344 KiB
Languages
TypeScript 48%
Svelte 37.9%
CSS 13.5%
HTML 0.3%
Dockerfile 0.2%