13 Commits

Author SHA1 Message Date
mAi
288dfb31e8 feat(f/[slug]): render description as sanitized markdown
Cherry-pick of b49f2e0 from mai/iris/hotfix-render onto main. iris's
hotfix branched off 538419f (May 6) to avoid Phase 2, but prod was
actually already on c13d84d (Phase 2 deployed May 7-8), so deploying
her branch directly would have regressed Phase 2. Cherry-pick onto
current main resolves cleanly: package.json + +page.svelte
auto-merged; bun.lock regenerated via bun install.

Replaces the bare <p>{data.description}</p> on the participant page
with a marked + isomorphic-dompurify pipeline so admins can author
descriptions with categorized link lists. Scoped .fb-description
styles restore list bullets, give h1-h6 sensible scale below the page
title, and use the existing --color-primary / dark-mode tokens.

Both deps land in dependencies (not devDependencies) because the
render runs SSR-first.

Hotfix for the UPC Deadlines training (HL PA, 2026-05-28) — m wants a
curated Resources/Links block above the form.
2026-05-27 21:09:10 +02:00
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
mAi
fe33c8f577 feat(questions): single_choice + multi_choice modules (registry slots 5-6/7)
Two choice types share the editor (option list with add/remove rows) and
the results bar chart. The participant input differs (radio vs checkbox)
and the answer shape differs (string vs string[]) — those are per-module.

Files added:

- single_choice.ts + multi_choice.ts — schemas (each with options array
  ≥2 items), defaultStubs (Option A / Option B), per-type isAnswerEmpty,
  ingest with per-option counting + other_count for choices that don't
  match (handles renamed options between form versions)
- single_choice.input.svelte — radio-button rows
- multi_choice.input.svelte — checkbox rows + toggle helper
- choice.builder.svelte — shared option editor (add / remove with the
  ≥2 minimum invariant); both modules import this slot
- choice.results.svelte — shared bar chart per option + an
  "other / dropped" muted row when other_count > 0
- choice.test.ts — 11 cases covering schema (≥2 options invariant),
  isAnswerEmpty (per-type rules), ingest (option counting + other_count),
  CSV (single-string vs pipe-joined), adminCellSummary (per-type)

Both modules registered in QUESTION_MODULES (between long_text and
scale per the picker order).

101 server tests pass (was 91). svelte-check clean.
2026-05-07 20:18:16 +02:00
mAi
913e505b02 feat(questions): scale module (registry slot 4/7)
- scale.ts — schema (extends base with min/max/min_label/max_label),
  defaultStub (1..5), isAnswerEmpty (any finite number), full ingest +
  finalise + private ScaleStatsWip accumulator (mean = _sum / count,
  drops _sum on finalise — same pattern the legacy results.ts uses,
  now owned by the type module)
- scale.input.svelte — N-button rating row with active-state class +
  optional min/max captions below
- scale.builder.svelte — min/max numeric inputs + min_label/max_label
  text inputs (4 fields, same as today's FormBuilder branch)
- scale.results.svelte — mean + count meta line + per-bucket
  histogram bars
- scale.test.ts — 11 cases covering schema accept/reject (includes
  the boundary case that schemas don't enforce min<max — that's a
  form-level invariant), isAnswerEmpty (non-numeric / NaN / finite
  numbers), ingest + finalise (histogram, mean, garbage rejection,
  null mean for empty count, _sum drop verification), CSV +
  adminCellSummary

91 server tests pass (was 80). svelte-check + bun run build clean.
2026-05-07 20:16:09 +02:00
mAi
5f345eaf4b feat(questions): short_text + long_text modules (registry slots 2-3/7)
Two text-input types share the same shape — a short input vs. a multi-line
textarea is the only material difference at the participant level. Same
results rendering, same CSV expansion, same admin summary, same
sanitise-for-public strip-and-keep-count rule.

Files added:

- short_text.ts + long_text.ts — module data + schemas (each extends the
  shared base with a `placeholder` field)
- short_text.input.svelte — single-line <input>
- long_text.input.svelte — multi-line <textarea> with rows=4
- short_text.builder.svelte — placeholder-text editor (shared between
  both modules; long_text.ts re-imports it)
- text.results.svelte — shared answer-list / count-only results block
- text.test.ts — 11 cases covering schema accept/reject, isAnswerEmpty
  (blank/whitespace/non-string vs. real text), ingest order + filter,
  sanitizeForPublic strip-text-keep-count, CSV passthrough,
  adminCellSummary

Both modules registered in QUESTION_MODULES.

types.ts fix: StatsForType<T> now uses intersection (`QuestionStats &
{ type: T }`) instead of `Extract<QuestionStats, { type: T }>`. The
existing TextStats variant declares `type: 'short_text' | 'long_text'`
as a single union, and `Extract<T, U>` narrows to `never` when T's
discriminator is a union and U narrows it; intersection works correctly.

80 server tests pass (was 70). svelte-check + bun run build clean.
2026-05-07 20:14:19 +02:00
mAi
bfb6dc8a2c feat(questions): boolean module (registry slot 1/7)
First per-type module — establishes the file layout for the remaining six.

- lib/questions/boolean.ts — schema (re-uses FeedbackQuestionBaseSchema +
  z.literal('boolean')), defaultStub, isAnswerEmpty, emptyStats, ingest,
  finalise, sanitizeForPublic, csvColumns, csvCellFor, adminCellSummary,
  plus the three .svelte component slots.
- lib/questions/boolean.input.svelte — Yes/Nein radio pair, exactly the
  same markup the participant page renders today (will be the receiver
  when /f/[slug] flips to the registry dispatcher in commit 12).
- lib/questions/boolean.builder.svelte — empty placeholder; boolean has
  no type-specific fields beyond the base. Slot exists so the registry
  shape stays uniform across all seven types.
- lib/questions/boolean.results.svelte — count + percent bars, same as the
  current Results.svelte branch.
- lib/questions/boolean.test.ts — 17 cases covering schema accept/reject,
  isAnswerEmpty (true/false/undefined/null/non-bool), ingest+finalise
  (yes/no counts, garbage ignored), CSV (single column, true/false/empty),
  adminCellSummary (Yes/No/em-dash).
- lib/questions/_base.ts — FeedbackQuestionBaseSchema extracted for use
  by the per-type modules. (Currently duplicates the private schema in
  schemas.ts; commit 11 will flip schemas.ts to read from the registry
  and drop the duplication.)

Registry: BooleanQuestion is the first entry in QUESTION_MODULES. The
legacy `q.type === 'boolean'` strips in FormBuilder / participant page /
Results.svelte / results.ts / submit / export still own dispatch — the
wiring step at the end of Phase 2 flips them.

70 server tests pass (was 58). svelte-check + bun run build clean.
2026-05-07 20:11:37 +02:00
mAi
390bd76287 feat(questions): registry shape + types — empty, awaiting per-type modules
Establishes the contract every per-question-type module must satisfy:

- lib/questions/types.ts — QuestionTypeModule<T> interface (schema,
  defaultStub, isAnswerEmpty, emptyStats, ingest, finalise,
  sanitizeForPublic, csvColumns, csvCellFor, adminCellSummary,
  ParticipantInput, BuilderEditor, ResultsBlock). Plus the helper aliases
  QuestionForType<T> and StatsForType<T> (extract the discriminated-union
  variant for type T) and the shared Svelte component prop shapes.
- lib/questions/registry.ts — QUESTION_MODULES (empty for now), getQuestion
  (throws on unknown), hasQuestion, listQuestionTypes.
- lib/questions/registry.test.ts — locks the contract: getQuestion throws
  with a helpful error pointing at lib/questions/<type>.ts when a module is
  missing, hasQuestion returns false for nonsense, listQuestionTypes
  matches the modules array.

Registry is intentionally empty in this commit — the legacy `q.type === '...'`
strips in FormBuilder, participant page, Results.svelte, results.ts, submit,
and export keep working. Per-type modules land in the next commits; the
final commit flips callers to use getQuestion(q.type) and the legacy strips
disappear.

Svelte component slots accept broadly-typed props and narrow internally on
question.type. The dispatch is sound at the call site (caller looked up by
matching type) but TypeScript can't prove the cross-component relationship
without significant generic gymnastics — runtime narrowing inside each
component is cheaper and keeps the registry literal simple.

58 server tests + 2 component tests pass. svelte-check clean.
2026-05-07 20:06:15 +02:00
mAi
a0765c9bf7 test: vitest + jsdom for Svelte component tests
Sets up the runtime split for §3.A's per-question-type modules: each type's
ParticipantInput / BuilderEditor / ResultsBlock will live in its own .svelte
file and want testable input handling. The pure logic (schema, isAnswerEmpty,
ingest, csvColumns, etc.) stays on `bun test`; the Svelte components run on
vitest.

Why two runners:

- Bun test doesn't apply the `browser` export condition when resolving ESM,
  so it picks Svelte 5's `index-server.js` and @testing-library/svelte's
  mount() throws lifecycle_function_unavailable.
- Vitest reuses the existing vite-plugin-svelte and applies the right
  conditions natively. Run via `bun --bun vitest` so vitest itself executes
  on bun (Node 18 is too old for vitest 4's node:util.styleText usage).

Files:

- New vitest.config.ts (jsdom env, svelte plugin, browser conditions, picks
  up src/**/*.svelte.test.ts files only)
- New src/test-setup/vitest.ts — afterEach cleanup so consecutive render()
  calls don't pollute each other's getByTestId lookups
- New src/lib/components/SmokeTest.svelte + .svelte.test.ts — sanity check
  that the runner actually mounts a Svelte 5 component and reads props
- package.json scripts split: `test:server` (bun, 5 server files),
  `test:components` (vitest), `test` runs both
- Pinned @sveltejs/vite-plugin-svelte to ^5.0.0 (v7 needs Node 22+ for
  node:util.styleText; ours is on Node 18)

devDeps added (test-only): vitest, @testing-library/svelte,
@testing-library/jest-dom, jsdom.

54 server tests + 2 component tests pass. svelte-check + build clean.
2026-05-07 20:04:10 +02:00
mAi
993fc84e19 refactor(server): findExistingSubmission helper + extract pure feedback helpers
§3.D of docs/plans/architecture-improvements.md.

The single-submission lookup query (session_id OR IP+UA, ordered by
created_at desc, limit 1) was inlined verbatim in three places: the
participant page server load, the public GET endpoint, and the submit gate.
Extracting it concentrates the priority rule (session-first, IP+UA fallback)
in one helper.

Splits feedback.ts in two so the pure parts are unit-testable. Existing
rate-limit.test.ts already noted that bun:test can't resolve SvelteKit's
$env/dynamic/private through the supabase.ts → fdb.ts chain, so anything
DB-aware can't be tested directly. The extraction follows the same pattern
used for admin-route in the previous commit.

Files:

- New lib/server/feedback-pure.ts — generateSlug, RATE_LIMIT, clampUserAgent,
  parseFormDefinition, lookupPlan (the strategy planner for findExistingSubmission),
  FeedbackInstance / LookupKeys / LookupStrategy types. No env imports.
- lib/server/feedback.ts — re-exports the pure helpers (existing callers
  unaffected) and now hosts findExistingSubmission + getInstanceBy{Slug,Id}.
- New lib/server/feedback-pure.test.ts — 22 cases covering generateSlug
  (length / alphabet / 5000-element collision smoke), clampUserAgent
  (null / passthrough / truncate-at-500), parseFormDefinition (encoded
  string / already-decoded / null / preserves other fields — locks the
  supabase-js JSONB-as-encoded-string contract), lookupPlan (8 rows
  covering empty / session-only / ip+ua-only / both / partial ip-only /
  partial ua-only / empty-string sessionId / overlong sessionId).

Call sites rewired:

- routes/f/[slug]/+page.server.ts — IP+UA only (sessionId lives in
  LocalStorage, not in the request — server can't see it on first paint)
- routes/api/public/feedback/[slug]/+server.ts GET — session_id (from
  query string) + IP+UA fallback
- routes/api/public/feedback/[slug]/submit/+server.ts POST — same, the
  single-submission gate

Behaviour unchanged. 47 tests pass. svelte-check + bun run build clean.
2026-05-07 19:47:57 +02:00
mAi
8b0f453022 refactor(server): withOwnedInstance wrapper for admin /feedback/[id] endpoints
§3.C of docs/plans/architecture-improvements.md.

Lifts the auth + ownership + try/catch preamble that was inlined across
four admin endpoints into a single wrapper. Each endpoint now:

  export const POST = withOwnedInstance(async ({ inst, event }) => {
    // inst is guaranteed valid + owned, errors caught + tagged
  }, 'admin feedback X');

Files:

- New lib/server/admin-route.ts — runtime wiring (requireAuth, getInstanceById,
  handleApiError, Response helpers).
- New lib/server/admin-route-decision.ts — pure ownership decision branch.
  Lives in its own module so bun:test can exercise it without pulling in
  $env/dynamic/private through the feedback.ts → supabase.ts chain (same
  constraint as the existing rate-limit.test.ts comment).
- New lib/server/admin-route.test.ts — 4-row decision-table test
  (anonymous → 401, missing instance → 404, foreign owner → 401, owner → ok).

Endpoints rewired (auth+ownership boilerplate removed):

- /api/admin/feedback/[id]/+server.ts (GET / PATCH / DELETE — local `ownerOf`
  helper deleted, was only used here)
- /api/admin/feedback/[id]/posts/[post_id]/hide/+server.ts
- /api/admin/feedback/[id]/share/+server.ts
- /api/admin/feedback/[id]/export/+server.ts

The list endpoint /api/admin/feedback/+server.ts has the auth half but no
ownership half (it lists by owner_user_id = userId), so it stays unchanged.

Behaviour unchanged. 29 tests pass. svelte-check + bun run build clean.
2026-05-07 19:44:44 +02:00
mAi
439b030471 mAi: #1 - server: date_ranked_choice aggregation + export + tests
- `aggregateResults` ingests rating maps, tallies per-option counts +
  histogram (1-5 buckets) + running sum, then `finalise` computes per-option
  means and sorts options by mean desc with tiebreaks (count of 5s, then
  4s, then total count, then id). Question-level `count` reflects
  submissions that rated at least one option.
- Out-of-range, fractional, and non-integer ratings are silently dropped —
  the aggregator never trusts user data, schema validates it on submit.
- CSV export expands a date_ranked_choice question into one column per
  option named `<qid>[<optid>]`. JSON export is unchanged (it serialises
  the rating map directly).
- New `results.test.ts` covers: per-option counts and means, histogram
  tallying, mean-with-tiebreak ordering, ignoring bad ratings, and missing
  answers. Wires the file into the `bun test` script.

Refs m/fdbck#1.
2026-05-06 14:13:11 +02:00
mAi
f5992ebc5b schemas + rate-limit + feedback helpers + tests
- src/lib/server/schemas.ts: feedback Zod schemas (Question discriminated union + FormDefinition + Instance create/update + Submission/Post/Hide + SignIn).
- src/lib/server/rate-limit.ts (+ test): in-memory token bucket — direct port from flexsiebels.
- src/lib/server/feedback.ts: generateSlug (32-char base62), getInstanceBySlug/ById via fdb(), RATE_LIMIT constants, clampUserAgent.
- src/lib/server/public-scope.test.ts: gate behaviour tests (allowlist coverage + 6 evaluatePolicy cases). Adapted for fdbck's allowlist (no /api/share, no /api/gotify-public).
- @types/bun added so svelte-check resolves bun:test imports — clean baseline (no 'Cannot find bun:test' tech debt that the flexsiebels project carries).

bun run check: 0 errors, 0 warnings.
bun run test: 20/20 pass.
2026-05-05 11:32:23 +02:00
mAi
ae2984088a skeleton: SvelteKit fullstack app (msbls.de pattern, fdbck variant)
Bootstrap from /home/m/dev/web/msbls.de template:
- SvelteKit 2.15 + Svelte 5 + adapter-node + bun + vite 6
- Deps trimmed: @supabase/supabase-js, postgres, zod (+ dev: kit, vite-plugin-svelte, svelte-check, typescript)
- No mbrian-core submodule (irrelevant for fdbck)
- src/app.html minimal (no fonts, no theme toggler)
- src/app.d.ts declares App.Locals { userId: string | null }
- robots.txt Disallow: / (whole app is naked, per-link or auth-only)
- .env.example: Supabase + PUBLIC_SITE_URL + optional COOKIE_DOMAIN

Initial mai init scaffolding (.claude, .m, .mcp.json, AGENTS.md) bundled in
this first commit since the repo was empty before bootstrap.

Spawned from m/flexsiebels.de#63 pivot — see docs/plans/feedback-feature.md
for the full spec (copied in next commit).
2026-05-05 11:27:59 +02:00