Repository navigation
Spec 07 Domain Model
Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next
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.
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.
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.
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.
Generated from the repository's documentation on every push to develop.
Start here
Product
Implementation plan
- 00 Architecture overview
- 10 Phase 0: Scaffold and contract
- 11 Phase 1: Auth, join, shells
- 12 Phase 2: Admin authoring
- 13 Phase 3: Assign and take
- 14 Phase 4: Grade and results
- 15 Phase 5: Hardening
- 16 Phase 4.5: The student at a desk
- 17 Word import: milestone plan
- 18 Word import: teacher workflow
- 19 Versioned content contract
- 20 Data model
- 30 Risk register
- 40 Open items
- 50 Non-functional requirements
- 60 Backend architecture
- 91 September 2026 issue remediation
- 92 Student layouts and interaction rules
Redesign (Phase R)
- 70 Redesign overview
- 71 R1: Foundations (v0.7.0)
- 72 R2: Access (v0.8.0)
- 73 R3: Student console (v0.9.0)
- 74 R4: Teacher workspace (v0.10.0)
- 74d D4: Deck update (v0.11.0)
- 74e E1: Moving a question across a group
- 75 R5: Admin console and email (v0.12.0)
- 76 R6: Question types and scoring (v0.13.0)
- 77 R7: Collaboration (v0.14.0)
- 78 R8: Schedule (v0.15.0)
- 79 R9: Insights (v0.16.0)
- 80 R10: Learn (v0.17.0)
- 81 R11: Landing, leads, v1.0 (v1.0.0)
Setup and operations
- CI
- Deploying
- DNS for quizzivy.com
- Google OAuth client
- Production operations
- Cloudflare R2 bucket
- Word import worker
- R4 rehearsal on a Neon branch
Design
Team
- Agent team
- Verification environment
- Team ledger
- R4 readiness
- R4 verification strategy
- T-R4.51 browser verification
Code READMEs