Plan 80 R10 - SekiroKenjii/Quizzivy GitHub Wiki
The release that adds learning between tests. Teachers get Vocabulary (word lists, words with pronunciation, flashcard assignments to classes) and Courses (units, lessons of five kinds, release rules, progress). Students get Learn, the Course and Lesson pages, a Flashcards session with spaced repetition, and the Practice card on Home. Word lists and courses join R7's sharing, and a course share carries read and assign access to what the course uses.
R10 runs after R7 (77-r7.md: content_shares, the share dialog, Shared with me, make-a-copy,
the email channel and its scheduler) and R9 (79-r9.md), whose read models it narrows.
Vocabulary lands before Courses inside the release, because a flashcards lesson points at a word
list (70 Β§2 rule 7). Screens are compared with docs/design/deck/Quizzivy Teacher.dc.html
(Courses, course detail, Vocabulary, word list) and Quizzivy Student.dc.html (learn,
course, lesson, flash, and Home's Practice card). Branch work/redesign-r10 is cut from
develop.
The work touches three high-risk areas named in AGENTS: media (a new kind and new references),
attempts (test lessons start through the lesson gate) and assignments (test lessons are
assignments). Every task that touches them runs the module's unit tests and the five canaries on
its branch point and on its last commit:
server/internal/modules/tests/application/tests/publish_snapshot_test.goweb/tests/units/media/audio-player.test.tsxserver/internal/modules/attempts/application/tests/events_test.goweb/tests/units/api/client.refresh.test.tsweb/tests/integration/router-chunks.test.ts
Deliverable: word lists, words and pronunciation; flashcard assignments with a new-words allowance and a daily reminder; an SM-2 variant scheduler with streaks; video as a media kind; courses with units, reading, video, listening, flashcards and test lessons, release rules, pace and progress; test lessons delivered as assignments; sharing and transitive access for both kinds; the student's Learn, Course, Lesson and Flashcards pages and the Home Practice card; and learner payloads that carry no answer key.
Exit criteria:
- Every new teacher and student screen matches the deck at 360, 768, 1024, 1280 and 1440, light and dark (T-R10.21). Lesson kinds the deck does not draw are listed for DG-94 sign-off.
- The scheduler's intervals match the deck's labels for a new card and follow the formula in this file's decisions after it, pinned by unit tests.
- A student can finish a course end to end: a reading lesson, a video lesson, a flashcards lesson and a test lesson whose attempt runs through the normal engine. A locked lesson refuses its content and its test.
- R2's isolation suite covers every new operation, including the shared-course paths. E2E 9
walks every new
/app/*operation with its key list unchanged. - The migrations pass goose up/down/up and are rehearsed on a Neon branch.
- The five canaries pass untouched; every CI step of 70 Β§8 is green locally, including
pnpm e2e:live. - Released to production as
v0.17.0.
Schema changes (numbers assigned at merge; new tables are empty when created, so their indexes
are inline; indexes on populated tables are in their own -- +goose NO TRANSACTION files):
| File | Task | Notes |
|---|---|---|
NNNNN_create_word_lists.sql |
T-R10.1 | |
NNNNN_create_words.sql |
T-R10.1 | audio composite FK to media_assets (id, kind)
|
NNNNN_create_flashcard_assignments.sql |
T-R10.1 | |
NNNNN_create_word_reviews.sql |
T-R10.1 | |
NNNNN_create_practice_days.sql |
T-R10.1 | |
NNNNN_add_media_kind_video.sql |
T-R10.2 | enum value only; Down leaves an unused value in place |
NNNNN_allow_video_media_assets.sql |
T-R10.2 | checks replaced NOT VALID, then validated |
NNNNN_create_courses.sql |
T-R10.3 | |
NNNNN_create_course_units.sql |
T-R10.3 | |
NNNNN_create_course_lessons.sql |
T-R10.3 | |
NNNNN_create_course_classes.sql |
T-R10.3 | |
NNNNN_create_lesson_progress.sql |
T-R10.3 | append-only for the app role |
NNNNN_add_assignments_course_lesson.sql |
T-R10.3 | nullable, FK NOT VALID then validated |
NNNNN_index_assignments_course_lesson.sql |
T-R10.3 | NO TRANSACTION, CONCURRENTLY |
NNNNN_add_practice_reminder_event.sql |
T-R10.6 | R7's notification_preferences_event_check widened |
NNNNN_grant_maintenance_learn.sql |
T-R10.8a |
SELECT on word_lists and courses for D16's owner check |
NNNNN_add_content_shares_word_list.sql |
T-R10.10 | plain FK; R7's three CHECKs replaced (T-R7.1's recipe) |
NNNNN_index_content_shares_word_list.sql |
T-R10.10 | NO TRANSACTION, CONCURRENTLY |
NNNNN_add_content_shares_course.sql |
T-R10.10 | plain FK; R7's three CHECKs replaced (T-R7.1's recipe) |
NNNNN_index_content_shares_course.sql |
T-R10.10 | NO TRANSACTION, CONCURRENTLY |
New operations, each declaring its x-permission (70 Β§4.1: word lists map to
content.questions.write, courses and lessons to content.tests.write):
| Operation | x-permission |
Task |
|---|---|---|
GET, POST /teacher/word-lists; GET, PATCH, DELETE /teacher/word-lists/{id}
|
content.questions.write |
T-R10.4 |
POST /teacher/word-lists/import |
content.questions.write |
T-R10.4 |
GET, POST /teacher/word-lists/{id}/words; PATCH, DELETE /teacher/words/{id}; POST /teacher/word-lists/{id}/words/bulk
|
content.questions.write |
T-R10.4 |
GET /teacher/word-lists/{id}/stats |
people.students.read |
T-R10.4 |
PUT /teacher/word-lists/{id}/classes |
teaching.assignments.write |
T-R10.5 |
GET /app/flashcards, GET /app/flashcards/session, POST /app/flashcards/reviews
|
learning.take_tests |
T-R10.5 |
GET, POST /teacher/courses; GET, PATCH, DELETE /teacher/courses/{id}; PUT /teacher/courses/{id}/outline
|
content.tests.write |
T-R10.8a |
POST /teacher/courses/{id}/units; PATCH, DELETE /teacher/units/{id}
|
content.tests.write |
T-R10.8a |
POST /teacher/units/{id}/lessons; GET, PATCH, DELETE /teacher/lessons/{id}
|
content.tests.write |
T-R10.8a |
GET /teacher/courses/{id}/preview |
content.tests.write |
T-R10.9 |
PUT /teacher/courses/{id}/classes; DELETE /teacher/courses/{id}/classes/{classId}
|
teaching.assignments.write |
T-R10.8b |
GET /teacher/courses/{id}/progress |
people.students.read |
T-R10.9 |
GET /app/courses, GET /app/courses/{id}, GET /app/lessons/{id}, POST /app/lessons/{id}/complete
|
learning.take_tests |
T-R10.9 |
No public operation is added.
Order of work. Schema first (T-R10.1βT-R10.3). Then the vocabulary backend (T-R10.4βT-R10.6) and the media work (T-R10.7), then courses (T-R10.8a, T-R10.8b, T-R10.9), then sharing (T-R10.10) and the leak suite (T-R10.11). Each screen waits for its backend; T-R10.22, a teacher screen added later, sits after T-R10.14. At most four agents at a time.
Decisions taken in this file, worth a second look.
-
The scheduler is an SM-2 variant whose first intervals are the deck's labels. A card's state
is ease (starts 2.5, clamped to 1.3β3.0), interval in days, repetitions and lapses. On a card
never reviewed, or on the review after an Again: Again is due in 1 minute, Hard in 1 day, Good in
3 days and Easy in 7 days. After that, with interval I and ease E: Hard gives
max(1, round(I Γ 1.2)), Goodmax(3, round(I Γ E))and Easymax(7, round(I Γ E Γ 1.3)), all capped at 365. Ease changes by β0.20 (Again), β0.15 (Hard), 0 (Good) and +0.15 (Easy). Again counts a lapse and the card is re-shown later in the same session. There is no random fuzz: a deterministic schedule can be tested and explained to a student. - The grade buttons show the card's actual next interval. For a new card they read exactly as the deck draws them ("Again < 1 min", "Hard 1 day", "Good 3 days", "Easy 1 week"). For a reviewed card they show what the button will do ("Good 8 days"), because a static label would be wrong from the second review on.
- A word is "known" when its interval is at least seven days and its last grade was not Again. One definition serves the student's "15 of 28 known", the teacher's "Students know it" and "Mastered on average". Seven days means the student answered Easy once or Good twice. The hardest word is the one with the highest lapse rate (lapses over repetitions, summed across students), among words with at least three repetitions.
- New words arrive at the class's daily allowance, in list order. A student in two classes with the same list gets the larger allowance, not the sum. A flashcards lesson ignores the allowance for its own list, because a lesson asks the student to meet every word in it. Due reviews are never capped.
-
Streaks count local days. A day counts when the student grades at least one card between
00:00 and 24:00 in their time zone (R4's profile setting). The streak is the run of such days
ending today or yesterday.
practice_daysholds one row per student and day. - Pronunciation is an uploaded audio file per word, falling back to the browser's speech synthesis (DG-95). No server-side text-to-speech: it would be a new third-party dependency sending word lists off-site for a feature the browser already has. The Listen button is hidden when neither is available.
-
Video is a new media kind, uploaded like audio and never transcoded (DG-94). MP4 only, up to
95 MiB and 30 minutes, sniffed from the
ftypbox, with the duration read by the existing MP4 probe. The size cap comes from the edge:api.quizzivy.comis proxied by Cloudflare, whose Free plan refuses a request body over 100 MB (docs/setup/dns.md), so a larger cap would answer 413 before the API saw the file. 95 MiB holds about 13 minutes at 1 Mbit/s (a 720p screen recording) and about 6.5 minutes at 2 Mbit/s, so for most files the size binds before the 30 minutes. It goes through the existing streaming upload route with its read and write deadlines raised for the upload (the server's 120 sWriteTimeoutwould otherwise cut it off), one video upload at a time per API process, so two teachers cannot fill the machine's disk. Presigned direct upload stays out of v1 (15-phase-5.md); anything larger is O-R10.1. WebM is refused: MP4 plays in every browser students use, and a second container would need a second probe. It plays in<video controls preload="metadata">from a signed URL; the CSP'smedia-srcalready allows R2. -
Lesson bodies are the existing content model, edited with the existing editor, saved
explicitly (DG-94). Reading, video notes and listening notes are
semantic_v1documents (server/internal/shared/content), edited withContentEditorand rendered withContentView. The lesson editor saves with a button and a dirty guard rather than autosave. A lesson has no publish snapshot to protect, and explicit save avoids the two flushes AGENTS requires of every debounced autosave. - A course's test and flashcards lessons may point only at the course owner's tests and word lists. The command checks it under the course row lock. This keeps the whole course manageable by its owner, makes transitive access through a course share well defined, and lets R5's ownership transfer move a teacher's courses, tests and word lists together. Media is exempt: assets are immutable and may be shared by reference, as R7's copies already are, but a word or lesson may bind only an asset the actor can read (T-R7.7's readable-asset definition).
-
A test lesson is delivered as an ordinary assignment, created through the assignments
Createport. There is one assignment per test lesson per assigning teacher, targeting that teacher's classes that have the course, marked byassignments.course_lesson_id. A partial unique index on(course_lesson_id, created_by)makes the sync idempotent, and the sync takes a per-course advisory lock (the registry in20-data-model.md). Attempts, autosave, integrity, grading and the leak guarantees are reused unchanged. Timing, attempts, integrity and review rules come from the assigning teacher's assignment defaults (R4). The window opens at assignment and closes 365 days later. R7's in-API notification scheduler (T-R7.14) gains anextend-course-windowsrule that extends a window by another 365 days when its course is still assigned to that teacher's classes and fewer than 30 days remain, audited as System, under the scheduler's advisory lock(73819, 42). Its next due time joins the scheduler's candidates, so it adds no wake of its own. It is not acmd/maintenancestep: R1's window commands move deadlines only when an operator runs them, and R5's nightly sweep is D16's retention job, which applies only onceRETENTION_SWEEP_ENABLEDis set, so an extension there could stop silently. Release rules are enforced by the lesson gate, not by the window. -
The lesson gate runs in the attempt start path, through a port.
StartOrResumeasks the courses module whether the lesson is open for the student, only when the assignment has acourse_lesson_id, and answers409 LESSON_LOCKEDotherwise. Resuming an attempt is never refused, following R1's maintenance rule. -
Course-lesson assignments stay out of places that describe the class's test schedule:
Home's Coming up, due-time notifications, R7's due-soon, closing and
after_closeresult-release emails, R8's teacher calendar and the student's week, the dashboard's Today, "Closing in 24 h", taking now and submissions, the teacher summary's live assignments, Gradebook, Reports and student Grades (O-R9.2). They stay in Grading and in the teacher's Assignments list, labelled "{course} Β· lesson {n}", because someone has to grade them. The Settings tab shows "Managed by the course" on Test & timing and Window. -
Release rules are evaluated per student when read, never stored and never timed. A unit is
open when its rule holds (open now; every published lesson of the previous unit done; or its
date reached), when the course has started for the student's class, and, with the "One unit a
week" pace, when
starts_on + (n β 1) weekshas passed. Inside an open unit, lessons open in order, one after the previous is done. No timer flips a flag, so nothing wakes Neon. -
Test-lesson completion is derived; other completions are stored. Reading, video and
listening lessons complete on "Mark as done", and a flashcards lesson when every word of its
list has been reviewed once. These write
lesson_progress. A test lesson is complete when the student has handed in an attempt on its assignment. Deriving that fromattemptskeepssubmit.go, a high-risk path, free of a courses dependency. - A course share carries read and assign access to what the course uses, never edit access. With a Can use or Can edit grant on a course, a teacher can read and assign the course, preview its lessons, see the published versions of its lesson tests, and read its word lists and media. Editing a lesson test or a word list still needs a grant on that test or list. Revoking the course grant does not change classes that already have the course: students keep learning in it, and the teacher who assigned it can still unassign their own class.
- Copying a course copies what it points at. R7's make-a-copy for a course copies the course, its units and lessons, every word list its flashcards lessons use, and every test its test lessons use (as new draft tests through R7's test copy, which copies bank questions on write). Media is referenced by id. A copied test lesson stays a draft until its copied test is published. The invariant above rules out a copy that points at someone else's tests.
- "Reset progress" resets the actor's students only. A word list shared with another teacher may be practised in their classes too. Resetting deletes the review state of the chosen words for students in the actor's classes that have the list assigned, and nobody else's.
- "Import words" on the Vocabulary page creates a new list named after the file. The deck's dialog has a file and a header toggle and no target list. The teacher renames the list afterwards.
-
Deleting follows what students lose. A word is deleted outright, with its review state, as
the deck's confirm says ("Students lose their progress on them"). A lesson is deleted outright:
its stored completions go with it, and its test lesson's assignments are closed early and
detached (
course_lesson_idset NULL), so attempts and grades survive as a closed assignment. Word lists and courses are soft-deleted, and refused with409while a class has them or a lesson uses them. -
Deleting a class removes its flashcard and course assignments, not students' progress.
flashcard_assignments.class_idandcourse_classes.class_idcascade: they are the class's configuration, and without the cascade R2's 409 on class delete would refuse every class that ever practised. -
Learner payloads are their own Go types.
StudentLesson,StudentCourseandFlashCardshare nothing with the teacher types, so a new teacher field cannot reach a student by accident. A listening lesson has no script field; its text isbody, which is never namedtranscript(E2E 9's forbidden key). -
Stats that describe named students need
people.students.read. The words list, the vocabulary cards and the course cards carryknownPercent,masteredPercentoraverageCompletiononly when the caller holds that key, and null otherwise, as R4's summary does. The stats and progress operations declare it outright. -
The daily practice reminder needs both switches, and cards due. It is sent when the teacher
kept "Daily reminder at 19:00" on for the class, the student kept "Daily practice reminder" on,
cards are due and the student has not practised today. The in-app item materialises on read,
like R4's due-time items. The email goes out from R7's scheduler once a day at 19:00 in
APP_TIMEZONE: one wake a day, not one per student.
Depends on: β
Touches: migrations/NNNNN_create_word_lists.sql, migrations/NNNNN_create_words.sql, migrations/NNNNN_create_flashcard_assignments.sql, migrations/NNNNN_create_word_reviews.sql, migrations/NNNNN_create_practice_days.sql (all new), server/internal/platform/db/tests/{constraints,migrate,privileges}_test.go, server/internal/modules/identity/repositories/delete.go (T-R2.13's referencedBy map) and its integration test, docs/plan/20-data-model.md
Size: M
Done when:
-
create_word_lists: -id uuid PK DEFAULT uuidv7(),owner_idFK usersRESTRICT-name text1β120,level textin R4's level set (pre_a1β¦c2, DG-62) or NULL,description textβ€ 500 -created_by,updated_by(FK users,SET NULLforupdated_by),created_at,updated_at(theapp.set_updated_at()trigger),deleted_at- index(owner_id, updated_at DESC) WHERE deleted_at IS NULL -
create_words: -id,list_idFK word_listsCASCADE,ordinal integer,term text1β120,part_of_speech text CHECK IN ('noun','verb','adjective','adverb','phrasal_verb')or NULL,meaning textβ€ 300,example textβ€ 500 -audio_asset_id uuid,audio_asset_kind app.media_kind, a composite FK tomedia_assets (id, kind)RESTRICT, withCHECK ((audio_asset_id IS NULL) = (audio_asset_kind IS NULL))andCHECK (audio_asset_kind IS NULL OR audio_asset_kind = 'audio')(D-05's pattern) -created_at,updated_at- unique index(list_id, lower(term));UNIQUE (list_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE(D-13); index(audio_asset_id) WHERE audio_asset_id IS NOT NULL -
create_flashcard_assignments: -id,word_list_idFKRESTRICT,class_idFK classesCASCADE-new_words_per_day smallint NOT NULL DEFAULT 10 CHECK IN (5,10,15,20),daily_reminder boolean NOT NULL DEFAULT true-assigned_byFK usersRESTRICT,assigned_at-UNIQUE (word_list_id, class_id); index(class_id) -
create_word_reviews: -student_idFK usersCASCADE,word_idFK wordsCASCADE, PK(student_id, word_id)-ease numeric(3,2) NOT NULL CHECK (ease BETWEEN 1.30 AND 3.00),interval_days smallint NOT NULL CHECK (interval_days BETWEEN 0 AND 365),repetitions integer,lapses integer, both β₯ 0 -due_at timestamptz NOT NULL,last_grade smallint CHECK (last_grade BETWEEN 1 AND 4),last_review_id uuid NOT NULL,first_reviewed_at,last_reviewed_at- index(student_id, due_at) -
create_practice_days:(student_id FK users CASCADE, day date, reviews integer NOT NULL CHECK (reviews > 0), PRIMARY KEY (student_id, day)). - Grants: the app role gets
SELECT, INSERT, UPDATE, DELETEon the four content and review tables, andSELECT, INSERT, UPDATEonpractice_days. Every Down drops its table. - The users FKs that raise 23503 on a user delete join T-R2.13's constraint-name map in
identity/repositories/delete.go:word_lists_owner_id_fkeyandword_lists_created_by_fkeyasreferencedBy: owned_content, andflashcard_assignments_assigned_by_fkeyasreferencedBy: assignments. AdeleteUserintegration test on a user who owns a word list answers 409 with that value, never a 500. - Tests:
constraints_test.gorefuses a video asset on a word, a duplicate term in a list (case-folded), and out-of-range ease and interval;privileges_test.gopins the grants; goose up/down/up. -
20-data-model.mdgains the five tables, their reasons, and a deviation row for review state kept per student and word instead of a review log.
Depends on: β
Touches: migrations/NNNNN_add_media_kind_video.sql, migrations/NNNNN_allow_video_media_assets.sql (both new), server/internal/modules/media/{domain/asset.go,domain/manager.go,application/command/upload.go,http/media.go}, server/internal/platform/probe/{probe.go,mp4.go}, server/internal/platform/probe/testdata/ (a short MP4, new), server/internal/core/adapters/probe.go, api/openapi.yaml (MediaKind, the upload operation's x-max-body-bytes), generated code, docs/plan/20-data-model.md
Size: M
Done when:
-
add_media_kind_videorunsALTER TYPE app.media_kind ADD VALUE IF NOT EXISTS 'video'and nothing else, because a value added in a transaction cannot be used before it commits. Down raises while any row usesvideo, and otherwise leaves the value in place: an unused enum value is harmless, and rebuilding the type would break the composite FKs tomedia_assets (id, kind)(00011,00017,00037) and the CHECKs that cast toapp.media_kind(00014,00017,00037).IF NOT EXISTSlets the second Up of goose up/down/up pass.20-data-model.mdΒ§12 records the deviation. -
allow_video_media_assetsreplaces, each addedNOT VALIDand then validated: - the MIME check, addingvideo/mp4- the kind/MIME check, with one arm per kind (audio/%,image/%,video/%) - the duration check:duration_msis required for audio and video, with at most 300,000 ms for audio (spec Β§11.1) and 1,800,000 ms for video - R4's per-kind byte check, adding video at up to 99,614,720 bytes (95 MiB, under Cloudflare's 100 MB request-body limit) - on every table that referencesmedia_assets (id, kind)from a question or group (questions,test_version_questionsand the group asset and recording tables of00037/00039; the migration names each), a check that the kind is notvideoDown restores the previous checks and refuses while a video row exists. -
Upload: a body is video when it starts with an ISO BMFF
ftypbox whose major brand isisom,iso2,mp41,mp42oravc1. The duration comes from the existingmvhdwalk. The route's read and write deadlines are both raised to 15 minutes for this kind throughhttp.ResponseController(SetReadDeadlineandSetWriteDeadline), since the server's 120 sWriteTimeoutalso bounds a long upload's handler. A process-wide semaphore of 1 guards video uploads, and a second concurrent one answers429 UPLOAD_BUSYwithRetry-After. The bytes stream to the temp file as today, never into memory. -
MediaKindgainsvideo. The media library list acceptskind=video. The upload operation'sx-max-body-bytesbecomes 95 MiB plus the multipart allowance, still under Cloudflare's 100 MB. -
Tests: - probe unit: the MP4 fixture's duration; WebM, a renamed MP3, a truncated box and a
moovloop are refused - integration: the size and duration caps, the semaphore - constraints: a video asset on a question is refused - goose up/down/up, with the enum Down raising while a video row exists -
The media unit tests and the five canaries run before and after (high-risk area).
Depends on: T-R10.1, T-R10.2
Touches: migrations/NNNNN_create_courses.sql, migrations/NNNNN_create_course_units.sql, migrations/NNNNN_create_course_lessons.sql, migrations/NNNNN_create_course_classes.sql, migrations/NNNNN_create_lesson_progress.sql, migrations/NNNNN_add_assignments_course_lesson.sql, migrations/NNNNN_index_assignments_course_lesson.sql (all new), server/internal/platform/db/tests/{constraints,migrate,privileges}_test.go, server/internal/modules/identity/repositories/delete.go (T-R2.13's referencedBy map) and its integration test, docs/plan/20-data-model.md
Size: M
Done when:
-
create_courses:id,owner_idFK usersRESTRICT,title1β160,level(R4's set) or NULL,descriptionβ€ 500,status text NOT NULL DEFAULT 'draft' CHECK IN ('draft','published'),created_by,updated_by, timestamps,deleted_at; index(owner_id, updated_at DESC) WHERE deleted_at IS NULL. -
create_course_units:id,course_idFKCASCADE,ordinal smallint,title1β160,release_rule text CHECK IN ('open','after_previous','on_date'),release_on date,CHECK ((release_rule = 'on_date') = (release_on IS NOT NULL)), timestamps;UNIQUE (course_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE. -
create_course_lessons: -id,unit_idFKCASCADE,ordinal smallint,UNIQUE (unit_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE-kind text CHECK IN ('reading','video','listening','flashcards','test'),title1β160,status text NOT NULL DEFAULT 'draft' CHECK IN ('draft','published'),minutes smallint CHECK (minutes BETWEEN 1 AND 240)-body jsonb(asemantic_v1document, validated by the application), with a CHECK thatbodyis NULL for flashcards and test lessons -media_asset_id,media_asset_kind(composite FK tomedia_assets (id, kind)RESTRICT),word_list_idFKRESTRICT,test_idFK testsRESTRICT- per-kind checks: a video lesson may hold only avideoasset, a listening lesson only anaudioasset, a flashcards lesson onlyword_list_id, a test lesson onlytest_id, and a reading lesson none of them; a published lesson must hold the one its kind needs - partial indexes onmedia_asset_id,word_list_idandtest_idwhere not NULL -
create_course_classes:course_idFKRESTRICT,class_idFK classesCASCADE,starts_on date NOT NULL,pace text NOT NULL CHECK IN ('self_paced','unit_per_week'),assigned_byFK usersRESTRICT,assigned_at; PK(course_id, class_id); index(class_id). -
create_lesson_progress:lesson_idFKCASCADE,student_idFK usersCASCADE,completed_at timestamptz NOT NULL DEFAULT now(), PK(lesson_id, student_id), index(student_id). The app role getsSELECT, INSERTonly (append-only, likeattempt_events);privileges_test.goasserts noUPDATEorDELETE. -
add_assignments_course_lessonaddscourse_lesson_id uuid(nullable, no default: the R9 binary's inserts leave it NULL, which is correct) and a FK tocourse_lessonsON DELETE SET NULL, addedNOT VALIDand validated in the same file. Down drops the column. -
index_assignments_course_lessonis-- +goose NO TRANSACTION:CREATE UNIQUE INDEX CONCURRENTLY assignments_course_lesson_teacher_key ON app.assignments (course_lesson_id, created_by) WHERE course_lesson_id IS NOT NULL. Down drops itCONCURRENTLY. - Every other Down drops its table.
- The users FKs that raise 23503 on a user delete join T-R2.13's constraint-name map in
identity/repositories/delete.go:courses_owner_id_fkeyandcourses_created_by_fkeyasreferencedBy: owned_content, andcourse_classes_assigned_by_fkeyasreferencedBy: assignments. AdeleteUserintegration test on a user who owns a course answers 409 with that value, never a 500. - Tests: each per-kind check; a published test lesson without a test refused; the deferred ordinal swap; an assignment inserted with the R9 column list (the old-binary insert test); goose up/down/up.
-
20-data-model.mdgains the tables and deviation rows for test lessons delivered as assignments and forlesson_progressbeing append-only.
Depends on: T-R10.1
Touches: server/internal/modules/vocabulary/ (new: domain/{list,word,commands,errors,repository}.go, application/{app.go,command/{create_list,update_list,delete_list,add_word,update_word,delete_word,bulk_words,import_words}.go,query/{lists,list,words,stats}.go}, repositories/{lists,words,stats}.go, http/{lists,words}.go), server/internal/core/wiring/vocabulary.go (new), server/internal/core/router/{modules.go,server.go}, server/internal/platform/tabular/, api/openapi.yaml (tag teacher-vocabulary), generated code
Size: L
Done when:
- The package comment names the model: word lists owned by a teacher, their words, and each student's review state.
- Lists (
content.questions.write): -GET /teacher/word-lists?q=&page=β{items: [{id, name, level, wordCount, classes: [{id, name}], masteredPercent | null, updatedAt, access: owner|edit|use, owner: {id, name}}], page, total}-POST {name, level?}β 201 -GET /{id}β the list, itsaccess,ownerand the actor's classes that have it -PATCH /{id} {name?, level?, description?}: owner or edit grant -DELETE /{id}: owner only; soft delete;409 WORD_LIST_IN_USEwhile a class has it or a lesson points at it - Words:
-
GET /teacher/word-lists/{id}/words?page=&limit=&band=(limit 10 by default, as the deck pages;band=mastered|learning|hardkeeps the words whoseknownPercentis 70 or more, 50 to 69, or under 50, andpageandtotalfollow it) β{items: [{id, term, partOfSpeech, meaning, example, audioAssetId, knownPercent | null}], page, total}-POST {term, partOfSpeech?, meaning?, example?, audioAssetId?};PATCH,DELETE /teacher/words/{id}: owner or edit grant; a duplicate term answers409 WORD_EXISTS-POST /teacher/word-lists/{id}/words/bulk {action: move|reset_progress|delete, wordIds (β€ 200), targetListId?}. Move keeps review state and needs a target list with the same owner. Reset deletes review state for students in the actor's classes that have the list. Delete removes the words and their state. The result reports per-word failures, for R4'sBulkActions. - Import:
POST /teacher/word-lists/import, multipart{file (β€ 2 MB), firstRowIsHeader}: - creates a list named after the file, without its extension - columns: word, part of speech, meaning, example; parts of speech accepted in vi and en - at most 2,000 rows; duplicates in the file or the list are skipped - β{listId, added, skipped, rejected: [{row, reason}]}(β€ 100 rejected rows listed) - CSV throughencoding/csvwith BOM handling. XLSX through R5'stabularreader, with excelize'sUnzipSizeLimitandUnzipXMLSizeLimitset and the streaming row reader. Cell text is stored as typed and never evaluated. - synchronous in the API: 2 MB and 2,000 rows with no hashing is not a heavy job under 70's rule -
GET /teacher/word-lists/{id}/stats(people.students.read) β{studentsPractising, studentsAssigned, masteredPercent, hardestWord: {id, term} | null, bands {mastered, learning, hard}}over active students of the actor's classes that have the list.bandscounts the list's words in each of the threebandranges above, which v2's progress card draws and filters by (74drow V-19); a word with no review data is in none. -
knownPercentandmasteredPercentare null unless the caller holdspeople.students.read. - Reads follow R7's share-aware scope (owner, grant, or
scope.all). A write needs owner or edit; delete needs owner. Other teachers' lists answer 404, and a use grantee's writes answer 403. Every operation joins R2's isolation suite. - Every operation declares
x-resourcein T-R2.16's per-parameter form, and this PR adds the kindsword-listandwordto the enum: the path{id}(word-listorword), the bulk body's/wordIds(word) and/targetListId(word-list), and/audioAssetId(media).GET /teacher/word-listsand the words list declarex-resource-list. - Writes are audited through the data-modifying CTE pattern (
word_list.*,word.*). Audio references go through T-R10.7's lock. AnaudioAssetIdmust be a readable asset under the media module's definition (T-R7.7: own,scope.all, or referenced by content the actor can read, including shares and T-R10.10's transitive arm); anything else gives the same not-found validation error as a missing asset. - Tests:
- unit: import parsing (header toggle, vi and en parts of speech, duplicates, a row with
=CMD()stored literally) - integration: CRUD, bulk move and reset scope, delete refused in use, 409 on a duplicate term - the XLSX zip-bomb fixture refused by the unzip limits - contract and isolation
Depends on: T-R10.4
Touches: server/internal/modules/vocabulary/{domain/{schedule,session}.go,application/command/{assign_classes,review}.go,application/query/{student_home,session}.go,repositories/{assignments,reviews}.go,http/{assignments,student}.go}, api/openapi.yaml (tag student-flashcards), generated code
Size: L
Done when:
-
domain/schedule.goholds the scheduler exactly as decided in this file, as a pure functionNext(state, grade, now) (state, dueAt), plusPreview(state, now), which gives the four intervals for the buttons. -
PUT /teacher/word-lists/{id}/classes {classIds, newWordsPerDay, dailyReminder}(teaching.assignments.write) sets which of the actor's classes have the list: - listed classes are upserted - the actor's unlisted classes are unassigned; students keep their state - other teachers' classes are untouched, and everyclassIdmust be in the actor's class scope (404 otherwise) - reading needs only a read grant, so a use grantee can assign a shared list (D4) -
GET /app/flashcards(learning.take_tests) β{dueToday, minutes, lists: [{id, name, class: {id, name}, words, known, due}], streak: {current, week: [{date, practised}]}}: - lists come from the student's classes' flashcard assignments -dueTodaycounts reviews due before the end of the student's local day, plus today's remaining new words -minutesisceil(cards Γ 20 s Γ· 60)- the week runs Monday to Sunday in the student's zone -
GET /app/flashcards/session?listId=&lessonId=β{cards: [{wordId, term, partOfSpeech, meaning, example, hasAudio, intervals: {again, hard, good, easy}}], remaining}: - due cards first (oldest due), then new cards within the allowance, at most 50 -lessonId(a released flashcards lesson, T-R10.9) lifts the allowance for that list - a list the student cannot reach answers 404 -
POST /app/flashcards/reviews {reviewId, wordId, grade: again|hard|good|easy, lessonId?}β{dueAt, intervalDays, known}: - locks the(student, word)row - idempotent onreviewId: a replay returns the stored outcome and changes nothing - upsertspractice_daysin the same transaction - withlessonId, insertslesson_progresswhen that grade completes the lesson's list (every word reviewed once) - the client never sends an interval - Student payloads are the
FlashCardandFlashcardsHometypes, used by nothing else. -
x-resource(T-R2.16's form):PUT β¦/classesdeclares its path{id}(word-list) and body/classIds(class); the session declares querylistId(word-list) andlessonId(lesson); the review declares body/wordId(word),/lessonId(lesson) and/reviewId(none, a client idempotency key). Whichever of this task and T-R10.8a merges first adds thelessonkind. - Tests:
- unit: the scheduler table for new cards, a lapse, the ease floor and ceiling, the
365-day cap, previews
- integration: the allowance with two classes (the larger wins), lesson mode, the local-day
boundary in
Asia/Ho_Chi_Minh, the streak across a missed day, review idempotency under two concurrent posts, an unreachable word refused - isolation forPUT β¦/classes, including A's class in B'sclassIds; the three student operations in T-R2.16's two-student pair; E2E 9's walk gains them too (T-R10.11)
Depends on: T-R10.5, R4 T-R4.10a/b, R7's email channel
Touches: migrations/NNNNN_add_practice_reminder_event.sql (new), api/openapi.yaml (NotificationKind, the preference event enum), generated code, server/internal/modules/notifications/{application/command/materialise_due.go,repositories/due.go}, server/internal/modules/vocabulary/application/query/reminders.go (new), R7's email scheduler rules (77-r7.md), the email templates (vi and en)
Size: S
Done when:
-
add_practice_reminder_eventreplaces the check R7'swiden_notification_preference_eventsleft under the namenotification_preferences_event_check(T-R7.13's seven keys) with one that addspractice.reminder, addedNOT VALIDand validated. Down first deletespractice.reminderrows, then restores R7's seven-key check exactly as that file writes it. - The deck's default is on. The student's Notifications switch covers in-app and email (DG-82).
- In-app:
MaterialiseDueinsertspractice.reminder(dedupepractice:{studentId}:{local date}, params{due}) once 19:00 local has passed, when the student has cards due, has not practised today, and at least one of their classes has the list withdaily_reminderon. - Email: one daily rule on R7's scheduler at 19:00 in
APP_TIMEZONE(R5's config) selects the same students, with the switch on and an email address, and enqueues one templated email each ("{n} words to review today"), linking to/app/learn?tab=flashcards. It runs under the scheduler's(73819, 42)key in20-data-model.md's advisory-lock registry, and 19:00 joins the scheduler's next-due candidates. No per-student timer. - Tests: the four conditions, each off; the dedupe; the template parity test (vi and en keys).
Depends on: T-R10.2, T-R10.3, T-R10.4
Touches: server/internal/modules/media/{repositories/references.go,repositories/delete.go,repositories/replace.go,application/command/delete.go,application/query/{mint_for_student,signed_url}.go,application/ports/ports.go}, R4's media replace command, server/internal/core/adapters/media.go, server/internal/modules/{vocabulary,courses}/application/query/asset_reach.go (new), server/internal/modules/media/application/tests/versionref_test.go
Size: M
Done when:
- Every insert or update that names an asset on a word, or on a lesson (its media, or an
asset referenced from its
body), callsmedia.LockForVersionUsefirst, so it contends with a concurrent delete on the same row.TestLockForVersionUseSerialisesAgainstDeletegains two siblings, one for a lesson and one for a word, which drive both halves concurrently. - The delete guard counts lessons (not deleted) and words, and
409 MEDIA_IN_USEnames them ("{lesson} in {course}", "{word} in {list}"). The usage view (R4) lists them. - R4's replace re-points words and lessons under the owner filter T-R7.7 gives it for bank
questions: only words of lists, and lessons of courses, owned by the asset's owner or
editable by the actor. Other owners' words and lessons keep the old asset, which stays
served, and the response counts what it left. Published test versions are untouched
(DG-09);
publish_snapshot_test.gopasses untouched. - Student reachability: a student may mint a URL for an asset used by a word of a list they
can reach (T-R10.5), or by a lesson open to them (T-R10.9, including images in its body).
Media asks through two ports typed as the vocabulary and courses query handlers, wired in
core/adapters/media.go. A draft lesson's asset, a locked lesson's asset and another class's course asset answer 404. - Teacher reachability is the media module's readable-asset definition (T-R7.7), whose "referenced by content the actor can read" arm gains words and lessons, including through a course share (T-R10.10). The same definition checks every binding on a word (T-R10.4) and on a lesson's media and body (T-R10.8a).
- Tests: the two lock races, the guard, replace (including another owner's copied word and lesson left on the old asset), each reachability case; the media unit tests and the five canaries before and after.
Depends on: T-R10.3, T-R10.4, T-R10.7
Touches: server/internal/modules/courses/ (new: domain/{course,unit,lesson,outline,commands,errors,repository}.go, application/{app.go,command/{create_course,update_course,delete_course,save_outline,add_unit,update_unit,delete_unit,add_lesson,update_lesson,delete_lesson}.go,query/{courses,course,lesson}.go}, repositories/{courses,units,lessons}.go, http/{courses,units,lessons}.go), server/internal/core/wiring/courses.go (new), server/internal/core/router/{modules.go,server.go}, api/openapi.yaml (tag teacher-courses), generated code, migrations/NNNNN_grant_maintenance_learn.sql (new), R5's server/internal/core/maintenance/anonymize.go, server/internal/core/maintenance/tests/retention_test.go, server/internal/platform/db/tests/privileges_test.go
Size: L
Done when:
- The package comment names the model: a course, owned by a teacher, is units of ordered lessons that open by rule, and its progress per student.
- Courses (
content.tests.write): -GET /teacher/courses?status=&page=β{items: [{id, title, level, status, units, lessons, classes: [{id, name}], averageCompletion | null, access, owner}], counts: {all, published, draft}, page, total}-POST {title, level?, description?}β 201, as a draft -GET /{id}β the course, itsaccessandowner, andunits: [{id, title, releaseRule, releaseOn, lessons: [{id, kind, title, status, minutes, wordCount, questionCount}]}]-PATCH /{id} {title?, level?, description?, status?}: publishing needs at least one published lesson -DELETE /{id}: owner only; soft delete;409 COURSE_ASSIGNEDwhile a class has it - Outline:
PUT /teacher/courses/{id}/outline {units: [{id, lessonIds}]}reorders units and lessons, and moves lessons between units, in one transaction under the course row lock. The deferrable ordinal uniques absorb the swap. Every existing unit and lesson must appear exactly once (422 OUTLINE_MISMATCHotherwise). - Units:
POST /teacher/courses/{id}/units {title, releaseRule, releaseOn?}(appended);PATCH,DELETE /teacher/units/{id}. Delete removes the unit's lessons, with T-R10.8b's detach for test lessons. - Lessons:
-
POST /teacher/units/{id}/lessons {kind, title}β a draft -GET /teacher/lessons/{id}β the full lesson withbodyand its reference -PATCH {title?, body?, minutes?, mediaAssetId?, wordListId?, testId?, status?}-DELETE, which closes and detaches its assignments (T-R10.8b) in the same transaction - Lesson rules:
-
bodyvalidates as asemantic_v1document; its asset references go through T-R10.7's lock -mediaAssetIdand every asset referenced frombodymust be a readable asset under the media module's definition (T-R7.7, widened by T-R10.7); anything else gives the same not-found validation error as a missing asset - a reading lesson'sminutesdefaults to its word count at 200 per minute; a video or listening lesson's comes from the asset's duration, rounded up -wordListIdandtestIdmust belong to the course owner (422 LESSON_REFERENCE_NOT_OWNED), and a test needs a published default version - checked under the course row lock - Writes need owner or an edit grant, and delete needs the owner. Others answer 404, use
grantees 403. Audited (
course.*,course_unit.*,course_lesson.*). Every operation joins R2's isolation suite. - Every operation declares
x-resourcein T-R2.16's per-parameter form, and this PR adds the kindscourse,unitandlessonto the enum: the path{id}(course,unitorlesson), the outline body's unit ids (unit) and lesson ids (lesson), and a lesson's/mediaAssetId(media),/wordListId(word-list) and/testId(test).GET /teacher/coursesdeclaresx-resource-list. - R5's ownership transfer moves a user's courses and word lists in the same transaction as their tests (the invariant holds after it). Grants whose grantee becomes the owner are removed first, as R5 does for tests. R5's data export gains the ten new tables, formula escaped.
- D16 anonymisation:
core/maintenance/anonymize.go's owner check (R5's "skipping users who own classes or content", widened by R7 to question sets) gainsword_lists.owner_idandcourses.owner_id, counted the way R5 counts its own content tables.grant_maintenance_learngrantsquizzivy_maintenanceSELECTonword_listsandcourses, as R5's rule for a widenedcmd/maintenancecommand requires, andprivileges_test.gopins it. Down revokes it. - Tests:
- unit: the outline validator, minutes
- integration: CRUD, the owner-only reference rule, publish requires a lesson, the outline
move across units, delete refused while assigned, the transfer
-
retention_test.go: a disabled account that owns only a word list, or only a course, is skipped and counted - contract and isolation
Depends on: T-R10.8a
Touches: server/internal/modules/courses/{application/command/{assign_classes,unassign_class,sync_lesson_assignments,extend_course_windows}.go,application/ports/assignments.go (new),repositories/assignment_sync.go (new),http/classes.go (new)}, server/internal/modules/assignments/{domain/commands.go,domain/assignment.go,repositories/write.go,repositories/assignments.go,repositories/student.go,application/query/{list,for_student}.go}, server/internal/modules/attempts/{application/ports/lesson_gate.go (new),application/command/start_or_resume.go}, server/internal/modules/notifications/repositories/{due.go,weekly.go}, R7's server/internal/core/jobs/notify_schedule.go and server/internal/core/jobs/tests/notify_schedule_test.go, server/internal/modules/timetable/application/query/{calendar,student_week}.go (R8), server/internal/modules/dashboard/{application/query/nav.go,repositories/postgres.go}, server/internal/modules/gradebook/repositories/{gradebook,reports,grades}.go, server/internal/core/wiring/{courses,attempts,assignments}.go, api/openapi.yaml (Assignment.courseLesson, the student assignment's courseLesson), generated code, docs/plan/20-data-model.md (the advisory-lock registry)
Size: L
Done when:
-
PUT /teacher/courses/{id}/classes {classIds, startsOn, pace}(teaching.assignments.write) sets the actor's classes that have the course: - the course must be published and readable by the actor (owner, grant, orscope.all) - each class must be in the actor's class scope - unlisted classes of the actor are unassigned; other teachers' classes are untouched -
DELETE /teacher/courses/{id}/classes/{classId}unassigns one of the actor's classes. It works after a revoked share, because the class is the actor's. -
x-resource(T-R2.16's form): both operations declare the path{id}(course);PUTdeclares body/classIds(class) andDELETEthe path{classId}(class), so A's class is substituted into each alone. -
domain.WriteInputgainsCourseLessonID, settable only through the port and never through the HTTP contract. The courses module declaresAssignmentCreatorandAssignmentUpdaterports typed as the assignmentsCreateandUpdatecommand handlers (AGENTS: a cross-module need is a port typed as the other module's handler). -
SyncLessonAssignments(courseID)runs after an assign, an unassign, a test lesson's publish and a lesson's delete. Underpg_advisory_xact_lock(hashtextextended('course:' || course_id::text, 0)), the per-course key this task adds to20-data-model.md's advisory-lock registry (the single-bigintkey space, which never overlaps the registry's(73819, n)pairs), for each published test lesson and each teacher with classes on the course, it ensures one assignment: -created_bythat teacher, targeting exactly their classes that have the course - the test's default version - rules from the teacher'spreferences.assignmentDefaults- window from now to now + 365 days, extended byextend-course-windowsbelowThe unique index makes a lost race an idempotent no-op. A teacher with no classes left gets their assignment closed early. -
extend-course-windows(courses/application/command/extend_course_windows.go) is a rule of R7's notify scheduler (core/jobs/notify_schedule.go), not acmd/maintenancestep: - it runs under the scheduler'spg_try_advisory_xact_lock(73819, 42), then each course's sync key above - each course-lesson assignment with fewer than 30 days left, whose course is still assigned to one of its teacher's classes, getscloses_at+ 365 days through theAssignmentUpdaterport (the managed-field 409 below guards the HTTP contract, not the port), audited as System - its next due time, the earliest suchcloses_atβ 30 days, joins the scheduler's candidates, so it adds no wake of its own (PR-7) - a second run changes nothing; it needs no maintenance-role grant, since it runs as the app role -
A course-lesson assignment refuses changes to Test & timing and Window with
409 ASSIGNMENT_MANAGED_BY_COURSE. Extend, close, reopen, Integrity and Results work as for any assignment. -
The lesson gate:
attempts/application/ports/lesson_gate.godeclaresLessonGatetyped as the courses query handlerLessonOpenFor(studentID, lessonID).StartOrResumeconsults it only for assignments with acourse_lesson_id, before creating a new attempt, and answers409 LESSON_LOCKED. Resume is never refused. It is wired inwiring/attempts.go. -
Exclusions (
course_lesson_id IS NULL) in: - the student's assignment lists (R3'sForStudent, so Home's Coming up) - R4'sMaterialiseDue- R7's scheduler candidates indue.goandweekly.go: the student's due-soon at close β 24 h and β 1 h, the teacher's closing email, and theafter_closeresult release - R8's/teacher/calendarassignment events (opens and closes) and/app/weekdue items - R4's dashboard Today, "Closing in 24 h",takingNowandsubmissions, and/teacher/summary'sliveAssignments(nav.go) - R9's gradebook, reports and student gradesThe teacher's Assignments list keeps them, with `courseLesson: {courseId, courseTitle, lessonNo}`. The student's assignment detail carries `courseLesson: {courseId, lessonId}`, so the intro's back link returns to the lesson. -
Tests: - integration: the sync's idempotency under two concurrent calls, the targets after assign and unassign, a grantee's own assignment, publish then sync, the managed-field 409 -
notify_schedule_test.go:extend-course-windowsextends at 29 days left, leaves 31 days alone, leaves a course no longer assigned alone, and a second run changes nothing; its next due time is computed - the gate: locked start refused, resume allowed, an ordinary assignment unaffected - one test per exclusion above, each with an ordinary assignment beside the course lesson - the attempts and assignments unit tests, and the five canaries, before and after (high-risk areas)
Depends on: T-R10.8b, T-R10.5
Touches: server/internal/modules/courses/{domain/release.go (new),domain/progress.go (new),application/query/{student_courses,student_course,student_lesson,progress,preview,lesson_open}.go,application/command/complete_lesson.go,repositories/{student,progress}.go,http/{student,progress}.go}, api/openapi.yaml (tag student-courses), generated code
Size: L
Done when:
-
domain/release.gois a pure function over the course outline, the class'sstarts_onandpace, the student's completions and "today" in the student's zone. It returns each unit's state (done,open,lockedwith reasonafter_unit,on_dateorpace, and the opening date) and each lesson's state (done,current,available,locked). It is the only implementation: the student reads, the gate and media reachability all call it. - Completion: stored rows in
lesson_progress, plus test lessons with a handed-in attempt on their assignment for this student. Only published lessons of published courses count. A student reaches a course through their classes; with several classes on it, the earlieststarts_onapplies. - Student operations (
learning.take_tests): -GET /app/coursesβ[{id, title, class: {id, name}, level, done, total, upNext: {lessonId, unitNo, unitTitle, lessonNo, lessonsInUnit} | null}]-GET /app/courses/{id}β{id, title, class, teacherName, done, total, units: [{id, no, title, state, lockReason, opensOn, done, total, lessons: [{id, kind, title, minutes, words, questions, state}]}]}-GET /app/lessons/{id}βStudentLesson {id, courseId, unit: {no, title}, lessonNo, lessonsInUnit, kind, title, minutes, body | null, media: {assetId, kind} | null, wordList: {id, words} | null, test: {assignmentId, status: not_started|in_progress|submitted|graded, score | null} | null, done, next: {id, title} | null}. A locked lesson answers409 LESSON_LOCKED {reason, opensOn}with no body. A draft lesson or an unreachable course answers 404. -POST /app/lessons/{id}/completefor reading, video and listening lessons only: idempotent, and returns{done, total, next} -
test.scoreappears only when the student's result page would show it (R9's rule). - Teacher operations:
-
GET /teacher/courses/{id}/progress?classId=(people.students.read) β{students, averageCompletion, currentUnitForMost, lastActivityAt, fallingBehind: [{student, reason: not_started|stuck|behind_pace, lesson, days, unitNo, done, total}]}, for the actor's classes. Not started: no completion three days afterstarts_on. Stuck: the current lesson unchanged for three days or more. Behind pace: the expected unit is ahead of the current one. Capped at five, most days first. -GET /teacher/courses/{id}/preview(content.tests.write) β the student shape with every published lesson available and each test lesson'stestIdand default version, for R4's test preview - Tests:
- unit:
release_test.gocovers each rule, the pace floor, sequential lessons, a date rule in the student's zone, a lesson inserted before the current one, and an unpublished lesson skipped - integration: completions stored and derived, the earliest start across two classes, locked 409 with no body, complete idempotency, progress and falling-behind reasons - isolation, and the student cross-class 404 in T-R2.16's two-student pair;x-resourcedeclares the path{id}(courseorlesson) and progress's queryclassId(class)
Depends on: T-R10.4, T-R10.8a, R7's sharing module
Touches: migrations/NNNNN_add_content_shares_word_list.sql, migrations/NNNNN_index_content_shares_word_list.sql, migrations/NNNNN_add_content_shares_course.sql, migrations/NNNNN_index_content_shares_course.sql (all new), server/internal/modules/sharing/, R7's share-aware scope predicates, server/internal/core/adapters/sharing.go, api/openapi.yaml (ShareKind), generated code, R2's isolation suite
Size: L
Done when:
-
Each
add_content_shares_*migration follows the recipe in T-R7.1'screate_content_sharesheader (content_sharesstores no owner): -word_list_id uuid REFERENCES app.word_lists ON DELETE CASCADE(orcourse_id uuid REFERENCES app.courses ON DELETE CASCADE), nullable, the FK addedNOT VALIDand then validated; no owner column and no composite FK -content_shares_one_resourcereplaced withnum_nonnulls(test_id, question_set_id, word_list_id) = 1in the word-list file, andnum_nonnulls(test_id, question_set_id, word_list_id, course_id) = 1in the course file -content_shares_kind_checkreplaced withkind IN ('test','question_set','word_list'), thenkind IN ('test','question_set','word_list','course')-content_shares_kind_matchesreplaced with one equality per kind,(kind = 'test') = (test_id IS NOT NULL) AND (kind = 'question_set') = (question_set_id IS NOT NULL) AND β¦through the file's new kind - each replacement CHECK addedNOT VALIDand then validatedDown refuses while a share of that kind exists, and otherwise restores the three previous CHECKs and drops the column. -
Each
index_content_shares_*file is-- +goose NO TRANSACTION:CREATE UNIQUE INDEX CONCURRENTLY β¦ (word_list_id, grantee_id) WHERE word_list_id IS NOT NULL(and the course twin). Down drops itCONCURRENTLY. -
ShareKindgainsword_listandcourse. R7's grant, change, revoke, remove-from-my-list, Shared with me and notification paths accept them. Make a copy copies a word list (words and audio references), and a course as decided above. -
GET /teacher/shared-with-meacceptskind=word_list|course, and itscountsgainwordListandcourse. The web side is T-R10.22. -
Access on read models: word list and course reads return
access: owner|edit|useandowner. A use grant reads, assigns and copies; an edit grant also edits words, units and lessons; neither shares further or deletes (D4). -
Transitive access: R7's readable predicate for tests and for word lists gains an arm, "used by a published lesson of a course the actor can read". It allows reading and assigning (published versions only for tests) and never writing: write predicates are unchanged. Media reachability follows (T-R10.7).
-
An edit grantee adding a test or flashcards lesson may pick only the owner's tests and lists that the grantee can read (the owner-only rule of T-R10.8a).
-
The isolation suite gains the shared-course paths: - teacher C, with no grant, gets 404 on the course, on its lesson test through the test operations, and on its word list - teacher B, with a use grant, can read and assign, and gets 403 on writes to the course, the test and the list - after revocation, B gets 404 on the course, while B's class keeps the course and its students can still start an open test lesson
-
Tests: the four migrations (up/down/up, rehearsed on a Neon branch), including a share whose kind disagrees with its column refused; the kinds and counts in Shared with me; copy of a course with a word list and a test; the transitive arm; the isolation cases above.
Depends on: T-R10.5, T-R10.9, T-R10.7
Touches: web/tests/units/contract/openapi-contract.test.ts (the static walk of every schema reachable from /app/*), web/tests/e2e/payload-leak.live.spec.ts (E2E 9), web/tests/e2e/support/live.ts, seed/04-dev-e2e.sql
Size: M
Done when:
- The static walk covers every schema reachable from the new
/app/*operations and finds none ofisCorrect,sampleAnswer,acceptedAnswersortranscriptat any depth. The key list is unchanged. - E2E 9 seeds a course with each lesson kind and an assigned word list, then walks
/app/courses,/app/courses/{id}, every/app/lessons/{id},/app/flashcards, the session and a review response. It asserts: - a test lesson exposes onlyassignmentId,statusand a releasedscore, and its questions arrive only through/app/attempts/*- a locked lesson's response has nobody,mediaorwordList- a draft lesson and a draft course are absent - another class's course and list answer 404 - the meaning on a card is present, as the card's back by design - The PR carries the leak review of each new student operation.
Depends on: T-R10.4, T-R10.5, T-R10.10, D4 in production (the AI panel, T-D4.34, and the
ai module, T-D4.30 to T-D4.32)
Touches: api/openapi.yaml (generateWords), generated code, server/internal/modules/ai/{domain/prompts/words.go,application/query/generate_words.go,http/}, server/internal/core/router/{ratelimits.go,tests/testdata/permissions.golden}, server/tests/isolation_cases_test.go, server/internal/modules/ai/application/tests/operations_test.go, web/src/features/ai/kinds/vocab.ts (new), web/src/features/vocabulary/ (new: pages/teacher/{VocabularyPage,WordListPage}.tsx, components/{WordListCard,WordTable,WordDialog,ImportWordsDialog,AssignFlashcardsDialog,NewWordListDialog,PronounceButton,MoveWordsDialog}.tsx, api.ts, input.ts, speech.ts), web/src/app/router.tsx, web/src/layouts/teacherNav.ts, web/src/features/search/CommandPalette.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/vocabulary/ (new), web/tests/units/contract/teacher-nav.test.ts
Reuses: CardGrid, DataTable, Pager, BulkActions, RowMenu, FormDialog, ProgressBar, R4's media picker, R7's share dialog and access banner
Size: L
Done when:
- Routes
/teacher/vocabularyand/teacher/vocabulary/:id, lazy, with crumbs "Vocabulary" and "Vocabulary βΊ {list}". The Content group gains Vocabulary (layers) after Courses, as the deck orders them, withcontent.questions.write. The palette gains "Vocabulary". - Vocabulary: "Vocabulary" / "Word lists students practise as flashcards.", with Import words (secondary) and New word list (primary). Cards show level, updated, name, "{n} words Β· {classes}" (or "Not assigned") and a Mastered bar ("β" when null).
- New word list: Name (required), Level Pre-A1β¦C2 (DG-62). Import words: the file (CSV or XLSX), "First row is a header"; then a result dialog ("{added} words added, {skipped} skipped" with the rejected rows) that opens the new list.
- Word list: header with name, level and assigned classes; "Generate from text" (
sparkles, before Add word, shown only while the AI entry points are,useAiAvailable), Add word, Assign flashcards (primary), Share (R7's dialog, DG-90), and "β¦" (Rename, Delete list with the 409 message). - Progress: one card in place of the fourth export's four tiles (
74drow V-19), shown only withpeople.students.read: "{p}% mastered on average"; a three-segment bar of mastered (--success, a word 70% or more of students know), learning (--warning, 50 to 69) and hard (--danger, under 50) fromstats.bands; legend buttons with their counts, which are toggle buttons witharia-pressedthat filter the word table through?band=and reset the page; and, after a divider, "Practising {a} / {b}". The hardest word is no longer drawn. - "Generate from text" opens D4's AI panel in its
vocabkind: "Generate words from a text" / "Picks the words your students need to understand a passage, with meanings and new example sentences."; Text (7 rows, up to 8,000 characters), How many words 8 / 12 / 20, Studentsβ level A2 / B1 / B2, Meanings in TiαΊΏng Viα»t | English. Each suggestion shows the term, its part of speech and level as tags, the meaning, and the example as a note; the primary "Add {n} to {list}" calls the add-word write once for each accepted word, and aWORD_EXISTSis counted in the toast, not shown as an error. The panel, its states and its credit note are D4's (T-D4.34); this task adds the kind. -
generateWords(POST /teacher/ai/words/generate,ai.use):{text, count, level, meaningLanguage}β{items: [{term, partOfSpeech, level, meaning, example}], usage}. It is a suggestion and writes nothing. It runs on the fast tier, reserves 20 credits and settles the metered usage (74dsection G: about 2 credits), takes 10 a minute and 100 an hour per actor, answers 501 while the adapter is nil, sends the text, count, level and language and nothing else (D20), and has its isolation-suite entry;permissions.goldengains the operation in this pull request.operations_test.godrives it through the fakeCompleterand asserts the request holds no name, email or id. - Table: Word (pronounce button, part of speech), Meaning, Example (from 880 content width), "Students know it" (from 600) with a bar in danger below 50, warning below 70, else success; "1β10 of {n} words".
- Row menu: Edit, Play pronunciation, Delete. Bulk: Move to list, Reset progress ("Students start these words again from the beginning."), Delete ("Students lose their progress on them.").
- Word dialog: Word (required), Part of speech, Meaning, Example sentence, and Pronunciation (upload or pick audio, R4's picker). Pronunciation is extrapolated from deck parts and listed for DG-95 sign-off.
- Pronounce: plays the word's audio when present, else
speechSynthesiswith an English voice. The button is hidden when neither is available. - Assign flashcards: Classes chips (required, "Pick at least one class"), New words a day 5/10/15/20 (default 10), "Daily reminder at 19:00" (on). It is prefilled with the current assignment.
- A use grant hides every edit control and shows "Make a copy". An edit grant shows R7's banner ("Changes you make are saved to {owner}'s list").
- States (DG-83): skeletons; "No word lists yet." with New word list; "No words in this list yet." with Add word; errors with retry.
- Unit tests: the column drops at 880 and 600 (content width), bars, the progress card's bands and legend filters, bulk confirms, use and edit modes, the pronounce fallback, the assign prefill, the Generate button hidden while AI is off, the add of accepted words with one duplicate, zod assertions against the generated types.
- Compared with the deck's Vocabulary and word list at the five widths, light and dark. Keys vi first and listed.
Depends on: T-R10.8a, T-R10.8b, T-R10.9, T-R10.10, D4's T-D4.20 (StickyAside)
Touches: web/src/features/courses/ (new: pages/teacher/{CoursesPage,CourseDetailPage}.tsx, components/{CourseCard,UnitAccordion,LessonRow,AddUnitDialog,AddLessonDialog,AssignCourseDialog,CoursePreviewDialog,ProgressAside,FallingBehindAside,NewCourseDialog}.tsx, outline/{useOutlineDnd,OutlineSortable}.tsx, api.ts, input.ts, releaseLabel.ts), web/src/app/router.tsx, web/src/layouts/teacherNav.ts, web/src/features/search/CommandPalette.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/courses/ (new), web/tests/integration/router-chunks.test.ts
Reuses: CardGrid, the tabs, FormDialog, ProgressBar, Avatar, R7's share dialog, @dnd-kit (already a dependency)
Size: L
Done when:
- Routes
/teacher/coursesand/teacher/courses/:id, lazy. The Content group gains Courses (book-open), withcontent.tests.write, and the palette gains "Courses". - Courses: "Courses" / "Units and lessons your classes work through in order."; tabs "All {n} Β· Published {n} Β· Drafts {n}"; cards with status, level, title, "{u} units Β· {l} lessons Β· {classes}" and "Average completion". New course: name (required), Level, Short description, "Starts as a draft. Add units and lessons, then assign it.", toast "Course created as a draft".
- Course detail header: title, status, "{level} Β· {classes}", Preview as student, and Assign to class (while draft, Publish takes its place, extrapolated and listed for DG-94), plus Share and "β¦".
- Units as an accordion:
- number, title and release label ("Open now", "After Unit {n}", "Opens {date}" formatted
per locale, DG-94)
- lesson rows: kind tile (Reading
book-open-textinfo, Videocircle-playdanger, Listeningheadphoneswarning, Flashcardslayersaccent, Testfile-textmuted), title, meta ("8 min", "18 words", "10 questions"), a Draft chip, and "{done}/{n}" - "Add lesson" per unit, "Add unit" at the end - a row opens the lesson editor - Reorder by drag and by keyboard (DG-94):
@dnd-kitsortable for units and for lessons within and across units, saving throughPUT β¦/outline, with a rollback and a toast on error.router-chunks.test.tskeeps@dnd-kitinside the teacher graph: the builder's assertion is widened to the course detail and still asserts its absence from the student, public and entry graphs. - Add unit: Unit name; Opens (Open now | After previous unit | On a date, default After previous unit), with Date when needed. Add lesson: Type (Reading, Video, Listening, Flashcards, Test) and Title; it creates a draft and opens the editor.
- Assign course: "Students see it in Learn. Units open by their rules."; Classes chips (required), Start date, Pace (Self-paced | One unit a week); prefilled from the actor's current assignment.
- Preview as student: a dialog, "Lessons in the order students see them.", with kicker "Unit {i} Β· {unit} Β· {kind}". Reading, video and listening lessons render as the student sees them; a test lesson's "Start test" opens R4's test preview.
- Asides from a content width of 1000 (below, after the units):
- Progress: Students, Average completion, Current unit for most, Last activity
- Falling behind: a row per student with the reason; "Send a reminder" opens R7's class
announcement composer
- both need
people.students.read- both sit in D4'sStickyAside(T-D4.20,74drow V-02): sticky at 16px, capped atcalc(100vh β 88px)and scrolling inside themselves, from a content width of 800 - Use and edit grants as in T-R10.12.
- States (DG-83): skeletons; "No courses yet." with New course; an empty course ("Add a unit to start."); errors.
- Unit tests: release labels in vi and en, the outline payload after a cross-unit drag, the keyboard reorder, the rollback, the Publish/Assign swap.
- Compared with the deck's Courses and course detail at the five widths, light and dark. Keys vi first and listed.
Depends on: T-R10.13, T-R10.2
Touches: web/src/features/courses/{pages/teacher/LessonEditorPage.tsx,components/{LessonKindFields,VideoField,ReferencePicker}.tsx} (new), web/src/features/media/ (R4's picker gains video), web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/courses/lesson-editor.test.tsx (new), web/tests/e2e/lesson-video.live.spec.ts (new)
Reuses: ContentEditor, R4's media picker and upload, FormDialog, ConfirmDialog
Size: M
Done when:
- Route
/teacher/courses/:id/lessons/:lessonId, crumbs "Courses βΊ {course} βΊ {lesson}". The screen is extrapolated from deck parts and listed for DG-94 sign-off. - Title, plus per kind:
- Reading: the body in
ContentEditor, and Minutes (the estimate, editable) - Video: a video from the library or an upload (MP4, up to 95 MB and 30 minutes, with progress; a larger file is refused in the browser before it is sent), a<video controls>preview, and notes inContentEditor- Listening: an audio file, a plain<audio controls>preview, and notes - Flashcards: a word list select (the owner's lists) with its word count - Test: a test select (the owner's tests with a published default version) with its question count - Footer: Save (enabled when dirty), Publish or Unpublish, and Delete lesson (confirm; for a
test lesson the confirm says its assignment closes and results stay). A leave guard holds
unsaved changes. A
422 LESSON_REFERENCE_NOT_OWNEDshows its message. - Rich editors stay out of learner graphs (the router-chunks assertion is unchanged).
-
lesson-video.live.spec.ts(live project): uploads a real short MP4, so the sniff and the duration probe run on both halves (AGENTS: the live suite is for claims about the two halves meeting), and rejects a WebM. - Unit tests: fields per kind, dirty state and guard, publish requires a reference. Keys vi first and listed.
Depends on: T-R10.10, T-R10.12, T-R10.13
Touches: web/src/features/sharing/pages/teacher/SharedWithMePage.tsx (R7), web/src/features/sharing/components/ShareDialog.tsx (R7), web/src/features/notifications/kinds.ts, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/sharing/, web/tests/units/notifications/
Size: S
Done when:
- Tabs gain Word lists and Courses, counted from
counts.wordListandcounts.course(T-R10.10), and the sub-line becomes the deck's full one, which T-R7.17 held back until these kinds shipped. - Open goes to
/teacher/vocabulary/:idfor a word list and/teacher/courses/:idfor a course, in the use or edit mode of T-R10.12 and T-R10.13. - Make a copy toasts "Copied to your Vocabulary" or "Copied to your Courses".
-
notifications/kinds.tsroutescontent.sharedfor both kinds to the same pages. - The share dialog's explainer copy covers both kinds.
- Unit tests: the tabs and counts, each Open route, both toasts, the bell route. Compared with the deck's Shared with me at the five widths, light and dark (T-R10.21). Keys vi first and listed.
Depends on: T-R10.5, T-R10.9
Touches: web/src/features/learn/ (new: pages/LearnPage.tsx, components/{CourseCard,FlashcardsSummary,DeckCard}.tsx, api.ts), web/src/app/router.tsx, web/src/app/modules.ts, web/src/layouts/StudentLayout.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/{learn,shell}.test.tsx
Size: M
Done when:
-
modules.tsturns Learn on. The top nav and the bottom tab bar gain Learn (book-open) after Classes, completing the deck's five tabs. - Route
/app/learn?tab=courses|flashcards(DG-81), a root destination, max width 960. "Learn" / "Courses from your teachers and words to practise." Tabs Courses and Flashcards, the latter with a badge for words due. - Courses tab: cards with "{class} Β· {level}", title, "Unit {n} Β· {unit} Β· lesson {a} of {b}", a progress bar with its percentage, and Continue (to the up-next lesson, else the course).
- Flashcards tab: the summary "{n} words due today", "About {m} minutes Β· from {k} lists", and Start review. Deck cards show class, "{n} due", name, and "{known} of {words} known" with a bar. A list with nothing due shows the toast "All words in this list are known. Nothing due." when every word is known, else "Nothing due today. Come back tomorrow." (the second is extrapolated).
- States (DG-83): skeleton; no courses ("No courses yet. Your teacher adds them to your class."); no lists; error with retry. Nothing polls.
- Unit tests: tabs in the URL, the badge, Continue's target, both toasts, the nav with the module flag.
- Compared with the deck's
learnat the five widths, light and dark. Keys vi first and listed.
Depends on: T-R10.15
Touches: web/src/features/learn/{pages/{CoursePage,LessonPage}.tsx,components/{UnitCard,LessonStateRow,LessonBody,MarkDoneBar}.tsx} (new), web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/{course,lesson}.test.tsx (new)
Reuses: ContentView, the student result score display
Size: M
Done when:
-
/app/learn/courses/:idis a detail route (handle.detail: "Course", back to/app/learn), max width 820. It shows the back link "Learn" from 768, "{class} Β· {teacher}", the title, and "{done} of {total} lessons" with a bar. - Unit cards: the number badge (success when complete, lime when open, a lock when locked); meta "Completed", "{a} of {b} lessons done", "Opens after Unit {n}" or "Opens {date}".
- Lesson rows: a state dot (a success check when done; a lime play ring on a lime row with the "Up next" pill when current; hollow otherwise), the kind icon and meta. A locked lesson gives the toast "Finish the lessons before this one first" (or "Opens {date}").
-
/app/learn/lessons/:idis a detail route ("Lesson", back to its course), max width 720. It shows the pill "{kind} Β· {n} min", the h1, "Unit {n} Β· lesson {a} of {b}", and: - reading:ContentView- video:<video controls preload="metadata" playsinline>from a signed URL, then the notes - listening:<audio controls>, then the notes - flashcards: "Practise these {n} words", opening the session withlessonId- test: the status, the score when released, and "Start test", opening/app/assignments/:assignmentId, whose intro links back to the lesson - The sticky "Mark as done" (reading, video, listening) gives the toast "Lesson done Β· {done}
of {total}", then turns into "Next: {title}". A
409 LESSON_LOCKEDopened by URL shows the reason and a link back to the course. - States (DG-83): skeletons; errors with retry. Only the reading kind is drawn in the deck; video, listening, flashcards and test are extrapolated and listed for DG-94.
- Unit tests: each unit state and reason, the toasts, mark as done, the next link, locked by
URL, back targets at
viewport("phone"). - Compared with the deck's
courseandlessonat the five widths, light and dark. Keys vi first and listed.
Depends on: T-R10.5
Touches: web/src/features/learn/{pages/FlashcardsPage.tsx,components/{Flashcard,GradeButtons,SessionDone}.tsx,useReviewQueue.ts} (new), web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/flashcards.test.tsx (new)
Size: M
Done when:
-
/app/learn/flashcards?list=&lesson=is a detail route ("Flashcards", back to Learn, or to the lesson whenlessonis set), max width 560. - Progress "{done} done Β· {left} left".
- Card (radius 18): - front: part of speech in italics, the term at 34px, "Tap to see the meaning Β· or press Space", and the Listen pill (the word's audio, else speech synthesis, else hidden) - back: the meaning, and the example in quotes
- Grade buttons after the flip: Again (danger-soft), Hard, Good, Easy (success-soft), each with the server's preview interval ("< 1 min", "1 day", "3 days", "1 week", or the computed one). Keys 1β4 after the flip; Space flips.
- Again puts the card back three cards later (or last).
- Each grade posts at once with a client
reviewId. A failure retries in order with backoff and a quiet "Not saved yet" line, and the done screen waits for the queue. - Done: "Done for today", "{n} words reviewed. {m} needed another look. Next review {when}.", and "Back to Learn".
- Under
prefers-reduced-motionthere is no flip animation. The card is a button witharia-pressedfor the flipped state, and the back is announced politely. - States: skeleton; "Nothing due" (for a direct URL); error.
- Unit tests: the key map, requeue after Again, a replay after a failed post keeps its
reviewId, reduced motion, the done summary. - Compared with the deck's
flashat the five widths, light and dark. Keys vi first and listed.
Depends on: T-R10.5, T-R10.6
Touches: web/src/features/assignments/pages/StudentHomePage.tsx, web/src/features/learn/components/PracticeCard.tsx (new), R3's student Settings page (Notifications), web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/home.test.tsx
Size: S
Done when:
- Home shows the Practice card when the student has any assigned list:
- the layers tile, "{n} words to review", "{list} Β· about {m} min" (or "from {k} lists")
- the Monday-to-Sunday strip: practised days lime, today in the border colour, future days
muted; tooltips "Practised" and "Today"
- it opens
/app/learn?tab=flashcards- with nothing due: "All caught up" and the next review day - Recent results and Practice sit side by side at a container width of 820 or more (1.4fr / 1fr), as R3 deferred until Practice exists.
- Student Settings βΊ Notifications gains "Daily practice reminder" (R4's switch pattern, both channels, DG-82).
- Unit tests: the card with and without lists, the strip, the split at 820, the switch. Compared with the deck's Home at the five widths, light and dark.
Depends on: T-R10.9, R9 T-R9.10 and T-R9.11
Touches: seed/volume/volume.sql, server/tests/perf_flow_test.go, docs/perf/explain-<date>.md, docs/plan/50-nfr-registry.md
Size: S
Done when:
- The volume seed gains 60 word lists (3,000 words), flashcard assignments, 120,000
word_reviews, 100,000practice_days, 12 courses (60 units, 300 lessons), course classes and 50,000lesson_progressrows. - p95 budgets at volume:
/app/flashcards< 150 ms, the session < 150 ms, a review < 100 ms,/app/courses/{id}< 150 ms,/app/lessons/{id}< 100 ms, course progress < 300 ms, word list stats < 300 ms. Statement counts are fixed. The capture is committed. - Any index added is in its own
NO TRANSACTION,CONCURRENTLYfile.
Depends on: the tasks whose behaviour each part describes
Touches: docs/quizzivy-spec-v0.3.md (Β§7, Β§8, Β§9, Β§11, Β§13, Β§15; version bumped per Β§18), AGENTS.md, docs/plan/20-data-model.md, docs/plan/30-risks.md, docs/plan/40-open-items.md, docs/plan/70-redesign-overview.md, docs/setup/dns.md (the Cloudflare body limit now binds video uploads)
Size: S
Done when:
- Spec: Β§7 word lists, courses and lessons; Β§8 the teacher screens with their rules (scheduler, known, release rules, the owner-only reference rule, transitive access); Β§9 the student's Learn, Course, Lesson and Flashcards; Β§11 video as a media kind with its limits; Β§13 and Β§15 the tables and operations. The "Availability: R10" lines are flipped.
- AGENTS.md:
- the repository map gains
modules/{vocabulary,courses}andfeatures/{vocabulary, courses,learn}- the high-risk paragraph adds that anything inserting a media reference on a word or a lesson must callmedia.LockForVersionUsefirst, naming the two new race tests - a note thatStartOrResumeconsults the lesson gate only for course-lesson assignments -
20-data-model.mdis complete for R10, with its deviations.30-risks.mdgains the lesson-gate risk and its tests.40-open-items.mdholds this file's open items. The R10 row in 70 Β§2 is marked shipped at release. - Every PR that changed behaviour earlier in the release already changed its own lines; this task checks the set.
Depends on: T-R10.12 β¦ T-R10.18, T-R10.22
Touches: seed/NN-dev-learn.sql (new, development only, numbered at merge), web/tests/e2e/fixtures/ (a short MP4 for development)
Size: M
Done when:
- The development seed holds the deck's shapes: - the five word lists with the travel words and the environment cards - the four courses, with "IELTS Reading Foundations" carrying the deck's four units (one done, one open, one after Unit 2, one on a date) and a lesson of every kind - two classes with the course at different starts - students at different points, including one per falling-behind reason
- Every new route is compared with
design-deckon 5175 at 360, 768, 1024, 1280 and 1440, light and dark:/teacher/vocabulary(/:id),/teacher/courses(/:id), the lesson editor,/app/learn(both tabs),/app/learn/courses/:id,/app/learn/lessons/:id(each kind), the flashcards session (front, back, done), Home with Practice, and/teacher/shared-with-mewith its Word lists and Courses tabs. The dev server is confirmed fresh, and sizes are measured withgetBoundingClientRect. - Deviations are fixed in the owning task's branch or filed as issues. Vite and the API are stopped afterwards.
- Every task is ticked on
work/redesign-r10.developis merged into it, and verification is re-run after that merge. - Every CI step runs locally, in order, judged by exit code (70 Β§8.1):
-
make lint,make gen-check-make test-api(afterdocker compose up -d db minio), and goose up/down/up -pnpm lint,pnpm typecheck,pnpm format:check-pnpm test:unit,pnpm test:integration,pnpm build-pnpm e2e,pnpm e2e:content-pnpm e2e:live, required because media, take-test (the lesson gate) and the leak walk changed; it includeslesson-video.live.spec.ts-node scripts/check-design-deck.mjs - The perf job is green on the release commit, and its capture is committed.
- The five canaries pass, and their files are untouched against
develop. The two new lock race tests pass. - Security: the isolation suite (with the shared-course paths) passes; E2E 9 passes with its key list unchanged; no public operation was added.
- All twenty migrations are rehearsed on a Neon branch of production, including the enum
addition, the maintenance-role grant, the three
CONCURRENTLYindexes and eachVALIDATE. The timings go in the release PR. - The deploy window is outside exam windows (query production assignments). The API and the worker run the same binary.
- Deploy and smoke:
- Thuong's go, then
release/0.17.0βmain- deploy the API (the release command migrates), then Pages - smoke/healthz,/livez, and sign-in as each role - in the owner's test class: create a word list and assign it; create a course with a reading, a video (upload a short MP4), a flashcards and a test lesson; assign it; as a student, finish it and review the cards - upload an MP4 just under 95 MiB throughapi.quizzivy.com, so Cloudflare's body limit and the raised deadlines are exercised on the real path, then delete it - back-merge todevelop, and tagv0.17.0 - Release notes, vi first: - Vocabulary and flashcards, with how the review intervals work - courses and lessons, and how units open - video uploads (MP4, up to 95 MB and 30 minutes, not converted) - test lessons appear under Assignments and in Grading, not in the Gradebook - sharing word lists and courses, and what a course share lets a colleague do - the daily practice reminder, and how to turn it off
- T-R10.20's docs are in the release.
- DG-94. The lesson editor, the Publish control on a draft course, unit rename and delete, and the student render of video, listening, flashcards and test lessons are extrapolated from deck parts and need the design team's sign-off.
- DG-95. The per-word pronunciation upload in the word dialog is extrapolated; the speech synthesis fallback is interim.
- DG-90. Share entry points for word lists and courses follow the test's share dialog until the deck draws them.
-
O-R10.1 β Video limits (Thuong). 95 MiB, 30 minutes, MP4 only, and one upload at a time are
this file's defaults. The 95 MiB comes from Cloudflare's 100 MB request-body limit on the Free
plan in front of
api.quizzivy.com(docs/setup/dns.md); it holds about 13 minutes of 720p at 1 Mbit/s. Longer recorded lessons need one of: presigned direct upload to R2 (outside v1,15-phase-5.md), an upload host that Cloudflare does not proxy, or a Cloudflare plan with a larger body limit. Each changes the edge, the cost or the security posture, so Thuong chooses. Default: 95 MiB through the proxied route, revisited with real usage. - O-R10.2 β Course test lessons in the Gradebook. They are kept out of Insights (O-R9.2). Default: out.
-
O-R10.3 β Listening scripts. A listening lesson has notes but no separate script, so no
field collides with E2E 9's
transcriptkey. If teachers want a revealable script, it needs a name, a review rule and a leak-test change. Default: none; the teacher writes it in the notes. - O-R10.4 β Levels as ranges. The deck's fixtures show "B1βB2" on course cards; lists and courses store one level, as the dialogs draw. Default: one level.
- DG-83. The empty, loading and error states of every new screen ship from deck primitives and are listed for sign-off.
Generated from docs/plan/80-r10.md. Edit that file in the repository; changes made on this page are overwritten.