Skip to content

Spec 07 Domain Model

github-actions[bot] edited this page Oct 15, 2026 · 3 revisions

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next

7. Domain model (frontend types)

Mirrors §13. IDs are UUID strings; timestamps ISO 8601 UTC.

interface User {
  id; email; fullName;
  displayName?: string;
  avatarUrl?: string;
  hasPassword: boolean;                 // false for Google-only accounts
  linkedProviders: ('google')[];
  mustChangePassword: boolean;
  createdAt;
}

type PermissionKey = 'content.tests.write' | /* … */ 'system.leads';  // the 22 keys of 70 §4.1, in catalogue order
type Workspace = 'teacher' | 'admin' | 'app';

interface CurrentUser extends User {    // the signed-in user only; a payload about anyone else carries User
  permissions: PermissionKey[];         // the role's effective keys, in catalogue order
  workspaces: Workspace[];              // derived from permissions; the web guards read these (§5.4)
  phone?: string;
  locale?: 'vi' | 'en';
  timeZone?: string;
  preferences?: UserPreferences;
}

interface UserPreferences {
  theme?: 'light' | 'dark' | 'system';
  compactTables?: boolean;
  largerTestText?: boolean;
  assignmentDefaults?: {
    durationMinutes?: number;
    shuffleQuestions?: boolean;
    showScore?: boolean;
    blockCopyPaste?: boolean;
    requireFullscreen?: boolean;
  };
}

interface Class {
  id; name; description?;
  scheduleLabel?: string | null;        // 1 to 120 characters
  room?: string | null;                 // 1 to 60
  studentCount: number;
  selfJoinEnabled: boolean;
  joinCode?: { hint: string; expiresAt: string; maxUses: number | null; usesCount: number } | null; // teacher responses only; the code itself comes from getJoinCode, or from listClasses with withCodes (§6.4)
  averageScore?: AttemptScore | null;   // teacher responses only (T-R4.18)
  createdAt;
}

interface MediaAsset {
  id; kind: 'image' | 'audio';
  url: string;                          // short-lived signed URL
  mimeType: string; bytes: number;
  durationMs?: number;                  // audio only
  originalFilename: string;
}

type QuestionType =
  | 'single_choice' | 'multiple_choice' | 'true_false'
  | 'fill_blank' | 'short_answer';

type QuestionLevel = 'pre_a1' | 'a1' | 'a2' | 'b1' | 'b2' | 'c1' | 'c2';
type QuestionSkill = 'grammar' | 'vocabulary' | 'reading' | 'listening' | 'writing' | 'speaking';

interface AudioPolicy {
  maxPlays: number | null;              // null = unlimited
  allowSeek: boolean;                   // default false for listening
  showTranscriptAfterSubmit: boolean;
  allowPause?: boolean;                 // default true; the server always sends it (§11.1)
}

// What a student receives: the same four fields, all present.
interface StudentAudioPolicy extends Required<AudioPolicy> {}

interface Question {
  id; type: QuestionType;
  level: QuestionLevel | null;
  skill: QuestionSkill | null;
  prompt: string;                       // Markdown, rendered sanitized
  media?: MediaAsset;
  mediaAlt?: string;                    // image only; frozen at publish; read to students by a screen reader
  audio?: AudioPolicy;                  // present iff media.kind === 'audio'
  transcript?: string;                  // admin-authored; student sees it only per policy
  options?: { id; text; isCorrect: boolean }[];
  blanks?: { id; ordinal: number; acceptedAnswers: string[]; caseSensitive: boolean }[];
  points: number;
  explanation?: string;
  sampleAnswer?: string;                // short_answer; ADMIN ONLY, never in a student payload
  tags: string[];
}

interface Section { id; title; instructions?; questionIds: string[] }

interface Test {
  id; title; description?;
  sections: Section[];
  totalPoints: number;                  // server-computed
  status: 'draft' | 'published' | 'archived';
  currentVersion: number;
  nextVersion: number;                  // server-computed: the number the next publish takes (last published + 1), whatever was deleted
  createdAt; updatedAt;
}

interface IntegrityPolicy {
  requireFullscreen: boolean;
  blockCopyPaste: boolean;
  maxFocusLoss: number;                 // -1 = none allowed; 0 = unlimited
  onLimitExceeded: 'warn' | 'flag' | 'auto_submit';
}

interface Assignment {
  id; testId; testVersionId; testVersion: number;
  targets: { classIds: string[]; studentIds: string[] };
  window: { opensAt: string; closesAt: string };
  durationMinutes: number;              // server-enforced
  maxAttempts: number;
  shuffleQuestions: boolean; shuffleOptions: boolean;
  review: {
    showScore: boolean; showCorrectAnswers: boolean; showExplanations: boolean;
    release: 'on_submit' | 'after_close';   // when a result is released; on a write, omitted keeps the stored value
    showClassAverage: boolean;              // whether the result may carry classAverage; omitted on a write keeps the stored value
  };
  integrity: IntegrityPolicy;
  studentNote: string | null;           // the teacher's note on the Test intro; plain text, trimmed, at most 500 characters (counted before trimming on a write); null clears
  status: 'scheduled' | 'open' | 'closed';
}

type AttemptStatus = 'in_progress' | 'submitted' | 'timed_out' | 'graded' | 'voided';

interface Attempt {
  id; assignmentId; studentId; testVersionId; attemptNo: number;
  status: AttemptStatus;
  startedAt; deadlineAt; submittedAt?; gradedAt?;
  answers: Record<string, Answer>;
  audioPlays: Record<string /*questionId*/, number>;   // server-authoritative (§11.4)
  score?: { earned: number; total: number; pendingManual: number };
  integrity?: { focusLossCount: number; flagged: boolean };
}

type Answer =
  | { type: 'choice'; optionIds: string[] }
  | { type: 'true_false'; value: boolean }
  | { type: 'fill_blank'; values: Record<string, string> }
  | { type: 'text'; value: string };

An option or a blank keeps its id across saves of its question: a write that sends an id the question holds updates that row, one that omits the id or sends an id the question does not hold creates a new one, and one that leaves a row out deletes it. An id read from a question therefore stays valid until its option or blank is removed.

The engine writes a true/false answer as choice, with the id of the option the student chose. { type: 'true_false'; value: boolean } is the legacy form: the server still accepts and grades it (true is the first option), but no screen sends it.

Invariant: an attempt references testVersionId. Editing a published test creates a new version; in-flight attempts keep rendering the version they started with. In the student flow, test content is fetched only via GET /app/attempts/:id.


7.1 Word milestone content contract (foundation)

Every question write and publication uses the same interaction invariants: nonempty prompt and option text, supported type, choice/key cardinality, nonempty accepted blank answers, unique blank ordinals matching the prompt, media/audio consistency and content projection validity. Single-choice and true/false require exactly one correct option; true/false has exactly two options. Points must be greater than zero, no greater than 999999.99 and have no more than two decimal places. Do not silently round source or teacher-entered points. Publication requires a nonempty exam and an exact total within numeric(8,2).

Teacher question metadata is nullable: level is pre_a1, a1, a2, b1, b2, c1 or c2; skill is grammar, vocabulary, reading, listening, writing or speaking. Bank and shared-group authoring requests cap choice arrays at eight; existing stored arrays remain readable, copyable, publishable and restorable. The stored editable contract is separate from the capped request contract. Import assessment also refuses an included question above the authoring cap; excluded questions do not prevent commit. Publishing freezes both metadata fields, including grouped questions, and restoring a version preserves them. Draft test-list skills are distinct, sorted and omit null values.

Question-bank filtering is OR within level or skill and AND across dimensions. tagMatch=any|all preserves today's any default. Each metadata facet omits only its own dimension while retaining the other filters and normal owner scope; tag enumeration omits tag filtering. These reads do not promise a new atomic cross-query snapshot or measured performance.

ContentDocument is an application-owned discriminated union: legacy_markdown_v1 retains the exact historical Markdown string; semantic_v1 contains typed paragraphs, three heading levels, lists, tables, asset references and inline text/marks, safe links, line breaks and stable gaps. Its normative structural schema is in api/openapi.yaml; aggregate and cross-node rules are described in the content contract and enforced by both domain validators. Unknown fields, answer metadata, raw HTML nodes and editor-specific JSON are refused. Text is not rewritten or Unicode-normalized during validation.

The general content components and value object provide the foundation for the inline option and question prose profiles below. Each new profile needs matching persistence, publication, restoration and delivery before enabling its writes. Media/group integration is pending. The renderer never resolves an asset ID without an authorized media binding. Existing question payloads above remain the active contract with the additive option and prose rollouts below.

Inline option rollout (W-05a). Choice options may carry optional content using the OptionContent subset: exactly one semantic paragraph of text marks and line breaks. Existing option text is literal, not Markdown, and stays exact. text is the deterministic plain projection when content is present. Preserve both fields in bank copies, version snapshots, restored drafts, previews, attempts, results and teacher review. The document cannot contain links, gaps, assets, source metadata or answer keys; grading remains in normalized columns. An omitted document on update may retain existing formatting only when the current option ID and text match. Stale or changed legacy writes fail atomically; explicit content: null removes formatting. Options are plain inputs, as the deck draws them; VITE_RICH_OPTION_EDITOR=true (off in production) enables the pilot affordance to format plain options; readers and editing existing rich options remain available. This is not an import release or final editor acceptance. Grouped content and shared audio remain on their existing paths until their separate integration gates pass.

Question prose rollout (W-05b). Since R4 (v0.10.0) a prompt and an explanation are written in the rich editor by every teacher, in the builder, the bank's question editor, the group composer and the import review; the opt-in VITE_RICH_QUESTION_EDITOR is gone. Optional promptContent and explanationContent support semantic paragraphs, headings, lists and tables with text marks, breaks and safe links. QuestionContent excludes assets and gaps; QuestionPromptContent additionally allows bound gaps for fill-blank questions. prompt/explanation are exact plain projections when their document exists; otherwise they retain legacy Markdown semantics. Rich fill-blank prompts require a bijection between gap node IDs and blank gapId values. Labels never bind answers. Legacy {{n}} prompts retain ordinal binding; rich explanations support all five types. Snapshots, restoration, bank duplication and all relevant readers preserve these fields. Active attempts and learner previews never contain explanations; results release both explanation fields only when review.showExplanations permits it. Legacy writes omitting a rich field must leave its companion string unchanged; otherwise the update fails atomically. Explicit null clears a document. A field is written in the form it is stored in: a document in the rich editor, a Markdown string in the Markdown editor; a field with no text is rich text, and so is the builder's and the group composer's starter prompt, which opens empty under the placeholder while its text is still the starter. The form changes only through "Switch to Markdown" (which keeps text, bold, italic, strikethrough, headings, lists, tables and links, and drops underline, superscript and subscript) and "Apply conversion", each after its confirmation. "Markdown" is unavailable while a fill-in-the-blank prompt holds a gap, because the gaps bind the accepted answers. Markdown, for the teacher and the student, reads GitHub's tables and ~~strikethrough~~ and no other GFM syntax. Conversion from Markdown is explicit, validates the supported subset and refuses unsupported structures (for example a table's column alignment, raw HTML, code, images, quotations and links that are not HTTPS) without changing the original.

Formatted clipboard content follows the same principle: parse locally into the allowlisted semantic vocabulary, preview the complete resulting field, and apply only after confirmation. Preserve supported marks, list starts, table spans and safe links; adapt fonts, colors and spacing to the application's design. Reject files, active/hidden content, unbound gaps, unsupported styles or incomplete structure as one paste. The one exception is an image inside copied content (img, picture, svg, video, canvas): it is left out without being loaded, and the preview says how many were left out; a paste holding nothing else is refused like a file. No fallback to text without the teacher explicitly using plain-text paste. The converter cannot infer answer keys from visual formatting. Cancel/stale preview leaves current edits unchanged; one undo reverses insertion. Field profiles and aggregate budgets apply to the whole resulting document.

7.2 Word milestone group ordering (approved, not yet enabled)

For the new group-aware delivery version, shuffleQuestions shuffles complete question groups and standalone questions as units within each section. Sections retain their authored order. Members of a shared passage/listening/cloze group retain their authored order even when the groups move. With shuffle disabled, all units retain authored order. These rules were approved by Thuong as D-03.

Snapshot group membership and member order at publication. Persist the delivery algorithm version with the assigned snapshot/attempt so reload, takeover and restoration cannot choose a new deal; preserve stable answer IDs. Historical versions/attempts continue using their existing section-scoped algorithm and seeds. test_versions.delivery_version stores this marker: existing standalone versions default to section_v1; prerelease group snapshots are classified as group_v1 without changing their content or identities. New publications explicitly write group_v1, whose standalone ordering matches section_v1. Rollback refuses to remove the marker while group-aware snapshots exist. A choice member with semantic references to its option labels declares optionOrder: fixed; assignment with option shuffling is rejected for that member. Other members use shuffle; this never changes the member order inside a group.

7.3 Word milestone group graph (contract foundation, not yet enabled)

QuestionGroup owns ordered members and materials, optional rich instructions, and explicit recording bindings. A material's gap targets either a choice member or the stable blankGapId of a rich fill-blank member. Printed labels and mutable answer-row IDs cannot bind responses. Each material gap has exactly one target; a response appears only once across that group's material gaps. Gap names are local to their material or question. Empty groups are valid drafts but cannot publish; existing sections are not reinterpreted as groups.

The complete resolved context is bounded to 200 members, 16 materials, 16 shared recordings and 4 MiB, with per-document content limits still enforced. Materials may repeat an audio asset but resolve it to one recording binding in the group; the same asset cannot also grant a per-question allowance inside that group. Distinct member recordings can keep their existing per-question policy. Asset kind, existence, authorization and deletion locks remain required at persistence.

Copying remaps group, question, material, answer, gap and recording identities, preserving all grading data, labels, order and content. Immutable media IDs are reused through new protected bindings. The teacher-only group API and independent bank editor and mixed builder are available on the milestone branch; release still requires import and pilot acceptance gates. Relational ownership uses a nullable section owner for each group (null means an independent bank group) and explicit ownership/order on its member questions. Section units distinguish standalone questions from groups. Material gaps and media references have relational bindings; cross-group response/playback links are rejected. Owned children cannot be archived separately. Restrictive owner foreign keys require explicit whole-graph cleanup; existing section deletion cannot leave questions detached from their context. Internal graph creation and copy materialization are atomic with their audits. Legacy question operations hide or refuse owned children; active and archived groups protect referenced media under the same asset lock used by deletion. Full-group editing checks an aggregate revision; section groups also check the enclosing test revision. Edits replace content and member order atomically. Bank archive/restore/delete checks the same revision; permanent deletion requires archival and keeps audit history. Removing a section-owned group deletes its complete draft graph and compacts unit order. Copy materialization checks an observed source revision and creates new editable identities. Draft/snapshot/delivery readers preserve the complete graph. The builder inserts bank groups as complete independent copies into an explicitly chosen section, and can save the current group graph as an independent bank copy.

A question crosses a group's boundary without leaving its test's draft. A standalone question dragged into a section's group, or moved there from its menu, becomes a member copy: the group gets a new question with new option and blank ids and shuffled options, and the section's reference to the bank question is dropped. The bank question is not touched, because other drafts may still reference it; the builder says so after the drop. A member dragged out of its group becomes a unit of the section at the drop position. Normally it keeps its id and becomes an ordinary question of the bank, owned by the test's owner and so listed, editable and protected by the usual reference check. A member that is an unedited copy of a bank question that is still in the bank goes back as that original instead: the copy remembers the question it was made from, it is removed, and the original takes the drop position, so a round trip leaves one bank question, not two. A copy edited since stays a new bank question, and the builder says so; one whose original is deleted, belongs to another teacher or is in the test already is not restored. A member also moves to another group of the same test, or to another place in its own, in place and under its own id, and keeps the memory of its original. A member that a gap of its group's shared material is bound to does not leave its group until the teacher unbinds it in Shared content. A group left with no member stays as an empty draft and cannot publish. A bank group is neither a source nor a destination. One transaction does the move under the test's revision and the revision of every group it touches, so a stale tab meets STALE_WRITE; it is not optimistic, and the builder goes on from the revisions in the answer. No published version, deal or attempt is touched. The next publish freezes the new structure, and a version diff against the draft reads a copy into a group as one question removed and one added, not as a move.

Draft totals and tag filters include owned members alongside standalone questions. Listening counts count each question once when it has its own audio or a shared group recording. Independent bank groups are not part of a test's totals. The legacy question-only whole-outline writer refuses a draft containing groups with GROUP_OUTLINE_REQUIRED before changing metadata or structure; metadata-only updates remain supported. The group-aware writer accepts complete mixed units and moves whole groups transactionally. The builder keeps new section client identities until server IDs arrive, preserving edits and order during acknowledgement. The outline distinguishes group and standalone IDs, moves empty groups as units and keeps continuous learner numbering across member questions. Group and outline writes share a serialized enclosing-test revision. Only acknowledged own moves advance the active group's revision baseline; external conflicts remain visible. Preview and publication flush pending content and outline writes. Group findings open the owning group editor instead of the standalone question endpoint.

The independent group bank supports search, remembered URL filters, newest-first updates, inline/menu duplication and bulk archive/restore/permanent deletion. Copies keep the teacher on the list with a small indicator. The group editor has one active rich editor, ordered materials/members, explicit gap-target selectors, authorized image/audio picking and upload, HTTPS links and shared recording policy. Historical Markdown material remains editable. Material preview preserves undo history; full desktop/phone preview uses the learner renderer without answer keys, explanations or transcripts. Archived groups must be restored before editing.

Full-group autosave serializes writes with the observed revision, keeps newer keystrokes when an earlier write is acknowledged, and flushes before route exit. Incomplete edits remain locally recoverable. Quota/storage errors are visible; only a server acknowledgement marks the matching revision saved. Recovery from a stale revision never overwrites current data: the teacher can edit the recovered content and save a fully remapped independent bank copy.

Publication freezes the entire group into version-owned rows, including ordered units, member order, materials, stable gap targets and explicit recording policy. Frozen context points only to frozen question/blank identities and immutable media; editing or deleting a source group cannot alter it. The app role cannot update these new snapshot graph rows. Deleting an unreferenced version removes its owned graph together. Restoring a version replaces the current owned draft graph with fresh editable copies and remaps both ends of material/blank gap links. Duplicating a draft preserves mixed unit order and independently copies every group; standalone bank references retain their existing duplication semantics. Permanent test deletion removes its owned draft groups after the assigned-version reference checks pass. Published previews resolve the selected default when no version is specified, read one coherent frozen version, and expose ordered sections/groups plus safe material bindings. Their projection never selects answer keys or transcripts; media URLs come only from relational bindings on that version. Group material appears before its first member, and material gaps link to the corresponding question. Teacher playback consumes no student allowance. The builder's preview, Test detail and the import's "Preview and create" draw the take-test engine in a frame, Computer or a 390px phone, laid out by the frame's width rather than the window's (T-D4.16, DG-188): the paper can be answered, and what is chosen is kept only while the preview is open; nothing is saved, recorded for integrity or counted as a play, no time is shown, because a time limit is the assignment's, and Finish says that students submit there. The question group's preview dialog and Assignment detail keep the read-only paper. Existing version questions and historical attempts are unchanged.

Archived tests must be restored before creating a draft from a version or changing their default version. Both flat and grouped tests return TEST_ARCHIVED without rewriting content. An unused, non-current version may still be deleted while its parent is archived; existing reference checks continue protecting assigned history.

Start, resume and read-attempt responses include the same safe frozen group projection. Membership comes from the attempt's version, regardless of the test's current default. The tests module owns the reader, invoked through an application port after attempt authorization. Shared media is reachable only through protected relational bindings on a version the learner has an attempt on; assignment targeting alone grants no media access. A missing context reader fails explicitly instead of serving grouped questions without their materials. Learner material is drawn by the renderer the teacher's preview shares: in a passage pane beside the question from 768px, and below 768px in a drawer from the left that lies over the question and opens from a handle at the screen's edge or by a swipe (§9). A shared recording's card is drawn at its audio node in the stimulus pane, in reading order, the drawer below 768px; with several recordings each card is headed by its node's label, and a recording no node names is drawn after the materials. The pane stays mounted while the student moves between the group's questions and across 768px, so a recording plays on behind a closed drawer. Stable gap targets navigate to the question or blank input without losing pending answers. Group players stay mounted across child navigation.

Learner results retain the frozen material when filtering questions and expose only transcripts released by each recording's showTranscriptAfterSubmit flag. The score, answer-key and explanation flags do not override that release policy. Teacher paper review includes the complete shared transcript and that attempt's recording counts. Grading by question includes only the selected question's group, without an aggregate playback count across students. Material links focus the matching question where navigation is available. Review playback is unlimited and does not append attempt playback events or change recorded counts. Legacy flat results/reviews keep their existing payload and require no group reader.


Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next


Generated from docs/quizzivy-spec-v0.3.md. Edit that file in the repository; changes made on this page are overwritten.

Home

Start here

Product

Implementation plan

Redesign (Phase R)

Setup and operations

Design

Team

Code READMEs

Clone this wiki locally