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.
This commit is contained in:
mAi
2026-05-07 20:06:15 +02:00
parent a0765c9bf7
commit 390bd76287
4 changed files with 196 additions and 1 deletions

View File

@@ -9,7 +9,7 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"start": "node build/index.js",
"test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts",
"test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts",
"test:components": "bun --bun vitest run --config vitest.config.ts",
"test": "bun run test:server && bun run test:components"
},

View File

@@ -0,0 +1,28 @@
import { describe, test, expect } from 'bun:test';
import { QUESTION_MODULES, getQuestion, hasQuestion, listQuestionTypes } from './registry';
// Once per-type modules land, this file's expectations grow to:
// - all 7 types present, in the documented picker order
// - schemas registry assembles a working discriminatedUnion
// - cross-type smoke (round-trip a tiny form through the registry)
//
// For now, the registry is empty by design (legacy paths still own dispatch).
// These cases lock the contract that getQuestion throws on unknown types.
describe('registry shape', () => {
test('QUESTION_MODULES is a readonly array', () => {
expect(Array.isArray(QUESTION_MODULES)).toBe(true);
});
test('hasQuestion returns false for unknown types', () => {
expect(hasQuestion('definitely_not_a_real_type')).toBe(false);
});
test('getQuestion throws a helpful error for missing modules', () => {
expect(() => getQuestion('boolean')).toThrow(/lib\/questions\/<type>\.ts/);
});
test('listQuestionTypes returns the array of registered type literals', () => {
expect(listQuestionTypes()).toEqual(QUESTION_MODULES.map((m) => m.type));
});
});

View File

@@ -0,0 +1,46 @@
/**
* Central question-type registry.
*
* One entry per type. The schemas index, the FormBuilder's "+ Add" picker,
* the participant input dispatcher, the results aggregator, and the CSV
* export all read from here. Adding a new question type = create
* `lib/questions/<type>.ts` + add the import + push into QUESTION_MODULES.
*
* Order in the array matters for the FormBuilder picker — that's the order
* "+ Add" buttons render.
*/
import type { FeedbackQuestion } from '$lib/schemas';
import type { AnyQuestionTypeModule } from './types';
// Per-type modules will be imported here as they land in the next commits.
// QUESTION_MODULES intentionally starts empty — the wiring step (final
// commit of Phase 2) flips the legacy callers over to the registry once all
// seven types are in place. Until then, both code paths coexist: the legacy
// `q.type === '...'` strips in FormBuilder / participant page / Results.svelte
// / results.ts / submit / export keep working, and per-type tests exercise
// the modules' pure logic in isolation.
export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = [];
/** Look up the module for a question type. Throws on unknown — every type
* in `FeedbackQuestion['type']` must have a module registered. */
export function getQuestion<T extends FeedbackQuestion['type']>(type: T): AnyQuestionTypeModule {
const mod = QUESTION_MODULES.find((m) => m.type === type);
if (!mod) {
throw new Error(
`Unknown question type: ${type}. Add a module under lib/questions/<type>.ts and register it in lib/questions/registry.ts.`,
);
}
return mod;
}
/** Test if a question type has a registered module. Used by the wiring
* step's runtime sanity check. */
export function hasQuestion(type: string): type is FeedbackQuestion['type'] {
return QUESTION_MODULES.some((m) => m.type === type);
}
/** Ordered list of registered type literals — drives the FormBuilder's
* "+ Add" picker order. */
export function listQuestionTypes(): FeedbackQuestion['type'][] {
return QUESTION_MODULES.map((m) => m.type);
}

121
src/lib/questions/types.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* Shape of a per-question-type module. One such module lives in
* `lib/questions/<type>.ts` for each kind of question fdbck supports.
*
* The registry (`./registry.ts`) holds the seven concrete modules. Anywhere
* that used to dispatch on `q.type === '...'` now calls
* `getQuestion(q.type).method(q, ...)`. Adding a new type = creating one
* file + one line in the registry array.
*
* The Svelte component slots (`ParticipantInput`, `BuilderEditor`,
* `ResultsBlock`) accept broadly-typed props and narrow internally on
* `question.type`. The dispatch is always sound (callers look up via
* `getQuestion(q.type)` so the module already matches the question), but
* TypeScript can't prove the cross-component relationship without a lot of
* generic gymnastics — the runtime check inside each component is cheaper
* and it makes the registry literal stay simple.
*/
import type { ZodTypeAny } from 'zod';
import type { Component } from 'svelte';
import type { FeedbackQuestion } from '$lib/schemas';
import type { QuestionStats, QuestionResult } from '$lib/server/results';
/** Pull the variant of FeedbackQuestion that has the given `type` literal. */
export type QuestionForType<T extends FeedbackQuestion['type']> = Extract<
FeedbackQuestion,
{ type: T }
>;
/** Pull the variant of QuestionStats that has the given `type` literal. */
export type StatsForType<T extends FeedbackQuestion['type']> = Extract<
QuestionStats,
{ type: T }
>;
export interface CsvColumn {
/** Column header in the exported CSV (e.g. `q1` or `kickoff_when[opt1]`). */
header: string;
/** Question id this column belongs to. */
qid: string;
/** Option id, only set for multi-column types like date_ranked_choice. */
optId?: string;
}
export interface ParticipantInputProps {
question: FeedbackQuestion;
answer: unknown;
setAnswer(value: unknown): void;
}
export interface BuilderEditorProps {
question: FeedbackQuestion;
update(patch: Partial<FeedbackQuestion>): void;
}
export interface ResultsBlockProps {
question: QuestionResult;
stats: QuestionStats;
}
export interface QuestionTypeModule<T extends FeedbackQuestion['type']> {
/** Discriminator literal — matches the question's `type` field. */
readonly type: T;
/** Human-readable name for the FormBuilder type picker. */
readonly label: string;
/** Zod schema for this question's shape. The schemas registry assembles
* the discriminated union from all modules' schemas. */
readonly schema: ZodTypeAny;
/** Build a fresh question of this type for the FormBuilder "+ Add" button.
* The caller will overwrite `id` to a fresh uid. */
defaultStub(): QuestionForType<T>;
/** Empty / required-violation answer test. The single source of truth for
* "is this answer missing?" — used by both the client-side validator on
* /f/[slug] AND the server-side gate in /api/.../submit. */
isAnswerEmpty(question: QuestionForType<T>, answer: unknown): boolean;
/** Initial aggregator state for this question. */
emptyStats(question: QuestionForType<T>): StatsForType<T>;
/** Fold one answer into the aggregator. Mutates `stats` in place. */
ingest(
stats: StatsForType<T>,
question: QuestionForType<T>,
answer: unknown,
createdAt: string,
): void;
/** Aggregator close-out: compute means, sort options, drop accumulators. */
finalise(stats: StatsForType<T>): void;
/** Strip PII / contributor-identifying answer text for the public results
* endpoint that anonymous participants see after submitting. */
sanitizeForPublic(stats: StatsForType<T>): StatsForType<T>;
/** CSV column expansion. Most types return one column; date_ranked_choice
* returns one column per option. */
csvColumns(question: QuestionForType<T>): CsvColumn[];
/** CSV cell value for a given column (used after `csvColumns` to fill rows). */
csvCellFor(
question: QuestionForType<T>,
answer: unknown,
col: CsvColumn,
): string;
/** One-line cell summary for the admin /[id] submissions table. */
adminCellSummary(question: QuestionForType<T>, answer: unknown): string;
/** Svelte components — see ParticipantInputProps / BuilderEditorProps /
* ResultsBlockProps for the shared shapes. */
ParticipantInput: Component<ParticipantInputProps>;
BuilderEditor: Component<BuilderEditorProps>;
ResultsBlock: Component<ResultsBlockProps>;
}
/** Erased shape — the registry stores modules at this level since the
* per-type generic only matters at the call site. */
export type AnyQuestionTypeModule = QuestionTypeModule<FeedbackQuestion['type']>;