Plan 77 R7 - SekiroKenjii/Quizzivy GitHub Wiki
Three capabilities the deck draws and the backend lacks. Sharing (D4): an owner shares a test or a question set with other teachers, each recipient getting Can use or Can edit, and a recipient finds it under "Shared with me". Messages (D17): one announcement channel per class and direct messages between a teacher and a student. Email (D9): the notification rules R4 built in-app gain their email channel, with the first in-API notification scheduler.
Screens are compared with docs/design/deck/Quizzivy Teacher.dc.html (the share dialog sh.*,
Shared with me shr.*, r.messages, r.settings βΊ Notifications) and
Quizzivy Student.dc.html (messages, the Classes card's message button, Settings βΊ
Notifications). The deck draws sharing for tests only and draws no question set, so DG-90's
interim answer governs the question-set surfaces. DG-91 settles the messaging questions, and
DG-08 the admin boundary on direct messages.
R7 needs:
- R2:
owner_idon tests and bank questions,access.Scope, the permission middleware and the two-teacher isolation suite - R3: the student shell's availability map (
web/src/app/modules.ts) - R4: the teacher workspace, the notifications module (
Notify, preferences with their unusedemailcolumn,/me/summary,/teacher/summary),useIdlePolling, the builder'stestUpdatedAtautosave base,DataTableand the other composites - R5 (
75-r5.md):- the email platform (T-R5.2):
email_outboxwithdedupe_keyanddeliver_after, the dispatcher,APP_URL, and templates inplatform/email - content transfer (T-R5.10, DG-56)
- the data export (T-R5.15)
- daily housekeeping (T-R5.16)
- the D16 retention sweep under
quizzivy_maintenance(T-R5.17a)
- the email platform (T-R5.2):
- R6: the question duplicate path that copies match items and rubric criteria
R6 hands no contract step to R7 (76-r6.md). Branch work/redesign-r7 is cut from develop,
and feature branches are feature/t-r7-<nn>-<slug>.
Deliverable:
- Tests and question sets are shared per recipient as Can use or Can edit. Every shared read model
carries the caller's
accessand itsowner. - A Can-use recipient gets a read-only builder and question editor. A Can-edit recipient co-edits under the stale-write guard.
- An edit to a bank question the owner uses elsewhere forks it (copy-on-write).
- Make a copy, Remove from my list and Stop sharing work as the deck draws them. Revoking keeps assignments working.
- Class announcement channels and teacherβstudent direct messages. Direct messages are readable by their two participants only. Staff can attach files, which are sniffed like media.
- Unread counts, read pointers, "Seen by", and polling that stops when the user is idle.
- Email for flagged attempts, extensions, shares, grouped submissions, new messages, due-soon reminders, closing assignments, released results and the weekly summary. Each email follows the recipient's switches.
- Hard bounces and complaints reported by Resend stop further mail to that address (R5's O-R5.2).
Exit criteria:
- The isolation suite covers every new operation under no grant, use, edit and a revoked grant.
- A copy-on-write test shows that test B's draft is untouched when an edit grantee changes a question in test A.
- Live E2E runs the share round trip and the message round trip.
- E2E 9 walks
/app/messages/*. - The five canaries and
TestLockForDraftUseSerialisesAgainstDeletepass untouched. - Every new screen matches the deck at 360, 768, 1024, 1280 and 1440, in light and dark.
- Neon compute hours are measured for the week after the release (PR-7).
- The release ships to production as
v0.14.0.
Schema changes. Numbers are assigned at merge. No file builds an index on a populated table,
so none is -- +goose NO TRANSACTION. The only columns R7 adds to populated tables are nullable
and unconstrained, so the R6 binary keeps inserting during the rolling deploy without a fill
trigger.
| File | Task |
|---|---|
NNNNN_create_question_sets.sql |
T-R7.1 |
NNNNN_create_content_shares.sql |
T-R7.1 |
NNNNN_add_co_editing_provenance.sql |
T-R7.1 |
NNNNN_create_message_attachments.sql |
T-R7.9 |
NNNNN_create_conversations.sql |
T-R7.9 |
NNNNN_create_messages.sql |
T-R7.9 |
NNNNN_create_conversation_reads.sql |
T-R7.9 |
NNNNN_widen_notification_preference_events.sql |
T-R7.13 |
NNNNN_create_email_suppressions.sql |
T-R7.15 |
Order of work. The work runs on three tracks:
- sharing: T-R7.1 β T-R7.8
- messaging: T-R7.9 β T-R7.12
- email: T-R7.13 β T-R7.15
The tracks are independent until T-R7.13, which needs a producer from each of the other two. At most four agents run at a time. The screens (T-R7.16βT-R7.23) wait for the backend task each names. Close-out (T-R7.24βT-R7.27) comes last.
Copy-on-write (T-R7.6), Make a copy (T-R7.7) and the builder's access modes (T-R7.18) touch
test_section_questions and the builder's autosave, and T-R7.11's sniffing commit touches media's
upload path, all high-risk. Each runs the canaries and the module's unit tests on its branch point
and on its last commit, together with TestLockForDraftUseSerialisesAgainstDelete, both runs in
the PR.
Decisions taken in this file, worth a second look.
- A question set is a named, ordered selection of the owner's standalone bank questions (DG-90, interim). It holds references, not copies, and it is made from the bank's bulk bar. Tags and saved filters were rejected as the definition, because a recipient would silently gain whatever the owner tags later. Passage and cloze groups are left out: in a draft they are test-owned copies, so they travel with the tests that use them. Adding questions to a set is the owner's job. An edit grantee changes the set's questions and removes items, but does not add them (open item).
-
content_sharesstores no owner. The owner is always the resource's current owner, reached through a typed nullable foreign key per kind (test_id,question_set_id) plus akindcolumn and CHECKs. A grant therefore cannot disagree with its resource. R5's ownership transfer moves grants without a cascade. R10 widens the table withword_list_idandcourse_id, each a plainON DELETE CASCADEFK with no owner column, by replacing the three CHECKs (content_shares_kind_check,content_shares_one_resource,content_shares_kind_matches). A polymorphicresource_idwas rejected, following the house preference for real foreign keys (D-05, D-17). - Grantees see what the owner sees: the draft and every version. Can use is read-only. Make a copy copies the current draft. Hiding unpublished work from Can-use recipients would give the builder a second data source, and the owner already chose to share the test.
-
A draft only references questions its test's owner owns. R2 enforces this (T-R2.12a), and
R7 adds the "copy-on-add" R2 left for it. When an edit grantee creates a question in a shared
test, adds one from their own bank, or copies in a group, the result lands in the owner's bank,
with
created_byrecording the grantee. So the owner can always manage every question in their own drafts, andLockForDraftUsestays sufficient. The rule applies to anyone who is not the test's owner, admins included. - Copy-on-write is decided per edit, not per share. A non-owner who edits a bank question in the context of a test or set they can edit gets a fork when anything else references the question: another draft, or another set. The fork belongs to the question's owner and is created by the editor. Only that context is re-pointed to it, and the decision is taken under the question's row lock. The owner's own edits never fork, and change every draft that uses the question, as today. Without the fork, an edit grant on test A would silently change test B, which was never shared.
- Revoking deletes the grant row and nothing else. A grantee's assignment references an immutable version, and every read that starts from an assignment (monitor, grading, results, export) reaches that version through the assignment's scope, never through a share. The grantee's students' results stay in the grantee's classes, as the deck's footnote promises.
- A disabled owner's grants stay valid. Recipients keep teaching with what they were given. Shared with me marks the owner "Account disabled", and R5's transfer is how the content changes hands.
- Share notifications have no recipient switch. The deck draws none, and DG-90 says the message arrives in-app and by email. The sharer's "Send a notification" decides.
-
A direct message is addressed by its counterpart, and its conversation row is created by the
first post. Routes and the contract speak of
/students/{id}and/teachers/{id}, so the Students page, class detail and the student's class card can open a thread without a create step. Class channels are listed for every class in scope, whether or not anyone has posted yet. -
scope.allnever opens a direct message (D17, DG-08). Direct messages are visible to their two participants only. Class channels are class content and follow class scope, admins included. Admins get counts in the data export, never contents. A password reset cannot open them either: a teacher's reset, single or bulk, or a new email, is allowed only when no one else reaches the student, by T-R2.13's rule (72-r2.md), and anything else answers 403STUDENT_SHAREDand goes to a holder ofpeople.users.managein the Admin console (T-R2.13, T-R4.20). The email change is covered because Google sign-in links by email, the same route into the thread. Without that, teacher B could take over a student shared with teacher A and read the Aβstudent thread. -
Messages are append-only for the app role. Nothing can edit or delete a message, as with
attempt_eventsandaudit_log: the log of teacherβstudent contact protects both sides, and most students are minors. Deletion happens in one place only, when D16's anonymisation removes an account's direct conversations under the maintenance role. -
Message posts, reads and attachment uploads are not audited. The append-only
messagestable is the record, readable only by its participants (D17). An audit row, even with ids only, would showsystem.audit.readholders who wrote to whom and when, and would outlive the direct conversations D16 anonymisation deletes, becauseaudit_logis append-only. - Attempt-event cards are computed when a staff member reads a thread; they are not stored as messages. A staff member's direct thread shows the student's flagged attempts on assignments that staff member can read, from 24 hours before the thread began. The student projection is a separate Go type without the field (DG-91: teacher-only). Storing them would create threads out of flags and repeat the flagged notification in two places.
-
Students cannot attach files. The student deck draws no paperclip. Staff attach PDF, PNG,
JPEG, MP3 or M4A up to 20 MiB, sniffed by content as Β§11.1 does for media and served as a
download through a five-minute signed URL. WebP is refused: every image attachment must pass
image.DecodeConfig's dimension cap, the standard library has no WebP decoder, and a new dependency for one format is not worth it. -
Polling, not a push channel. An open thread polls every 10 seconds through R4's
useIdlePolling, which stops after ten idle minutes. Unread counts ride on the 60-second summaries R4 already polls. There is no ETag cache: R2's principal cache removes the per-request permission read, and an active poll only runs while someone is using the app. The compute hours are measured after release (PR-7). A push channel over SSE would first needLISTEN/NOTIFYacross machines and heartbeats under Cloudflare's idle timeout. - An email never carries a message body. It names the sender and the class and links to the thread, so D17's contents stay inside the app. A new-message email waits ten minutes, is sent only if the thread is still unread, and goes at most once per thread per 30 minutes. A share email carries the sharer's optional message, as DG-90 says.
- One scheduler in the API process wakes only when an email is due, or after six hours. It computes the next due time from the database and sleeps until then. Assignment writes nudge it, and an advisory lock plus outbox dedupe keys make it safe on two machines. A fixed-interval ticker would keep Neon awake (#134, #143). R4's in-app due items keep being created when the reader asks; the scheduler handles email and the weekly in-app summary only.
-
Student switches cover both channels (DG-82), so students start receiving email in R7. Test
due soon, Result ready and Messages from teachers were in-app only in R4, and in R7 they email
too. The release note says so. A per-event kill switch,
NOTIFY_EMAIL_EVENTS, turns off any event's email without a code change if volume or bounces demand it (open item on the Resend plan). - Bounces and complaints get a signed webhook and a suppression list now, not later. R5 deferred them "until R7 raises email volume" (O-R5.2), and R7 is where students' typed addresses start receiving mail. Repeated sends to dead addresses cost the domain its reputation. The webhook is a new public operation, so it is rate-limited and leak-reviewed in its own PR. It accepts only Resend's signed deliveries and answers nothing a caller could use.
Depends on: R3's contract step (R2's owner_id columns validated NOT NULL)
Touches: migrations/NNNNN_create_question_sets.sql, migrations/NNNNN_create_content_shares.sql, migrations/NNNNN_add_co_editing_provenance.sql (all new), server/internal/platform/db/tests/{constraints,privileges,migrate}_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_question_setscreates two tables. Down drops both. -app.question_sets: -id uuid PRIMARY KEY DEFAULT uuidv7()-owner_idβ usersON DELETE RESTRICT-name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 120)-description text(β€500) -created_byβ usersRESTRICT, andupdated_byβ usersON DELETE SET NULL-created_at,updated_at(with theapp.set_updated_attrigger) anddeleted_at- the index(owner_id, updated_at DESC) WHERE deleted_at IS NULL-app.question_set_items: -set_idβ question_setsON DELETE CASCADE-question_idβ questionsON DELETE RESTRICT-ordinal integer NOT NULL CHECK (ordinal >= 0)-added_byβ usersRESTRICT, andadded_at-PRIMARY KEY (set_id, question_id)-UNIQUE (set_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE, the D-13 reorder pattern - an index on(question_id), which serves the RESTRICT FK and T-R7.6's reference count - The app role gets SELECT, INSERT and UPDATE on sets (delete is soft), and SELECT, INSERT, UPDATE and DELETE on items. -
create_content_sharescreatesapp.content_shares. Down drops it. - Columns: -id uuid PRIMARY KEY DEFAULT uuidv7()-kind text NOT NULL, withCONSTRAINT content_shares_kind_check CHECK (kind IN ('test','question_set'))-test_idβ testsON DELETE CASCADE-question_set_idβ question_setsON DELETE CASCADE-grantee_idβ usersON DELETE CASCADE-access text NOT NULL CHECK (access IN ('use','edit'))-granted_byβ usersON DELETE RESTRICT-message text(1β500 when present) -created_atandupdated_at, with the trigger -content_shares_one_resource CHECK (num_nonnulls(test_id, question_set_id) = 1)-content_shares_kind_matches CHECK ((kind = 'test') = (test_id IS NOT NULL))-UNIQUE (test_id, grantee_id)andUNIQUE (question_set_id, grantee_id). NULLs are distinct, so each constraint binds only its own kind, and each serves the owner-side lookup by resource. - An index on(grantee_id, created_at DESC)for Shared with me. - The app role gets SELECT, INSERT, UPDATE and DELETE. - The file's header comment says how R10 widens the table (T-R10.10): - addword_list_id uuid REFERENCES app.word_lists ON DELETE CASCADEandcourse_id uuid REFERENCES app.courses ON DELETE CASCADE, each FKNOT VALIDthenVALIDATE; no owner column and no composite FK - replacecontent_shares_one_resourcewithnum_nonnulls(test_id, question_set_id, word_list_id, course_id) = 1- replacecontent_shares_kind_checkwithkind IN ('test','question_set','word_list','course')- replacecontent_shares_kind_matcheswith one equality per kind - each replacement CHECKNOT VALIDthenVALIDATE - The users FKs that are
RESTRICT(question_sets_owner_id_fkey,question_sets_created_by_fkey,question_set_items_added_by_fkey,content_shares_granted_by_fkey) join T-R2.13's constraint-name map inidentity/repositories/delete.goasreferencedBy: owned_content. AdeleteUserintegration test on a user who owns a set answers 409 with that value, never a 500. -
add_co_editing_provenanceaddstests.updated_byandquestions.updated_by(uuid REFERENCES app.users(id) ON DELETE SET NULL, nullable). - Each FK is addedNOT VALIDand validated in the same file, R6's pattern. The columns are all NULL, so the validation has nothing to check. - There is no fill trigger. The R6 binary leaves the column NULL, which a stale-write message reads as "someone", and nothing else depends on it. - Down drops both columns. -
constraints_test.goshows each rule firing: - a share naming two resources - akindthat disagrees with its column - a duplicate grant - a set item's ordinal reorder inside one transaction passes (deferred) - a hard purge of a question that a set item holds fails -
privileges_test.goasserts the grants above; the app role has no DELETE onquestion_sets. - A test and a question inserted with the v0.13.0 column lists still insert (the old binary's way).
- goose up/down/up passes.
-
20-data-model.mdgains the R7 section, with its Β§12 deviation rows: - typed nullable FKs per kind instead of a polymorphic resource - no owner column on grants -updated_byleft unfilled for the old binary
Depends on: T-R7.1
Touches: api/openapi.yaml, server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/questions/domain/set.go (new), server/internal/modules/questions/application/command/{create_set,update_set,delete_set,add_to_set,remove_from_set}.go (new), server/internal/modules/questions/application/query/{list_sets,get_set}.go (new), server/internal/modules/questions/application/command/delete.go, server/internal/modules/questions/repositories/sets.go (new), server/internal/modules/questions/repositories/{search.go,store.go}, server/internal/modules/questions/http/sets.go (new), server/internal/modules/questions/http/questions_handlers.go, server/internal/core/router/server.go, server/internal/modules/questions/domain/tests/set_test.go (new), server/internal/modules/questions/application/tests/sets_test.go (new)
Size: M
Done when:
- The operations, all with
x-permission: content.questions.write(70 Β§4.1: question sets map to the bank's row): -GET /teacher/question-setsreturns the caller's sets:{items: [{id, name, questionCount, sharedWith, updatedAt}]}. -POST /teacher/question-setstakes{name, description?, questionIds (1β500)}and returns 201 withQuestionSet. -GET /teacher/question-sets/{id}returnsQuestionSet {id, name, description, owner {id, name}, access, questionCount, createdAt, updatedAt}. Grantees can read it from T-R7.4 on. -PATCH /teacher/question-sets/{id}takes{name?, description?}(minProperties: 1), from the owner or an edit grantee. -DELETE /teacher/question-sets/{id}is owner only. It soft-deletes the set and deletes its grants in the same transaction. -POST /teacher/question-sets/{id}/itemstakes{questionIds (1β500)}and is owner only. It appends in the given order and ignores duplicates. -POST /teacher/question-sets/{id}/items/removetakes{questionIds (1β500)}, from the owner or an edit grantee. - Every item must be a standalone bank question (
context_group_id IS NULL, not deleted) owned by the set's owner. Anything else answers 404 per id, so another teacher's ids are not enumerable. - Each insert into
question_set_itemscallsquestions.LockForDraftUsefirst, so adding a question and deleting it contend on the same row. Deleting a question removes its set items in the delete's own transaction. -
listQuestionsgainssetId. With it, the list is that set's questions in set order when no sort is given, scoped by the set's readability rather than the bank's owner rule. Without it, nothing changes. - Each operation declares
x-resourcein R2's per-parameter form (T-R2.16):{id}asquestion-set, body/questionIdsasquestion, andlistQuestions's querysetIdasquestion-set.question-setjoins the kind enum in this PR. - Audit rows
question_set.created,question_set.updatedandquestion_set.deleteduse the data-modifying CTE pattern (AGENTS.md). - Unit tests cover the name rules and the 500 cap.
- Integration tests cover: - create, add, remove and list by set - a deleted question leaving its sets - another owner's question answering 404 - a deleted set answering 404 - the audit rows
-
make genis its own commit.
Depends on: T-R7.1, T-R7.2, R4's Notify, R4's principal-keyed limiter (ratelimit.PrincipalKey, PrincipalRateLimits())
Touches: server/internal/modules/sharing/ (new: domain, application/{command,query,ports}, repositories, http, and tests/ under each layer), server/internal/core/wiring/sharing.go (new), server/internal/core/adapters/sharing.go (new), server/internal/core/router/{modules.go,server.go,ratelimits.go}, server/internal/modules/dashboard/application/query/nav.go (R4), api/openapi.yaml, generated code
Size: L
Done when:
-
GET /teacher/directory/teachers?q=&limit=(content.share) returns{items: [{id, fullName, displayName, email, avatarUrl, disabled}]}, at most 10. - It lists users holdingworkspace.teacher, active or disabled, never the caller. - Matching on name and email goes throughapp.immutable_unaccent(lower(β¦)), so "duc" finds "Δα»©c". - It is rate-limited to 60 a minute per user, in R4'sPrincipalRateLimits()keyed byratelimit.PrincipalKey. - Leak note in the PR: colleagues' names and emails reach holders ofcontent.share, as the deck draws, and never a student, whom thex-permissionexcludes. -
GET /teacher/shares?kind=&resourceId=(content.share) returns{owner {id, name, email, avatarUrl}, grants: [{id, grantee {β¦, disabled}, access, grantedAt, grantedBy {id, name}}]}. - The owner andscope.allcan read it. A grantee gets 403SHARE_OWNER_ONLY, and anyone who cannot read the resource gets 404. -
POST /teacher/shares(content.share) takes{kind, resourceId, granteeIds (1β20), access, notify, message? (β€500)}. It returns 201 with{grants, skipped: [{userId, code}]}. - A recipient is skipped asSELF(the owner),ALREADY_SHARED,NOT_A_TEACHER(lacksworkspace.teacher) orDISABLED. - Only the owner orscope.allmay grant (SHARE_OWNER_ONLY). Recipients never share further (D4). - It is rate-limited to 30 an hour per user, inPrincipalRateLimits(). -
PATCH /teacher/shares/{shareId}(content.share) takes{access}. It is owner orscope.allonly. -
DELETE /teacher/shares/{shareId}(content.share) is "Stop sharing", for the owner orscope.all. -
DELETE /teacher/shared-with-me/{shareId}(workspace.teacher) is "Remove from my list", for the grantee only. The two deletes are separate operations: we keep one requirement per operation here by choice, and a recipient withoutcontent.sharemust still be able to leave. -
GET /teacher/shared-with-me?kind=&q=&page=&size=(workspace.teacher) returns{items: [{shareId, kind, resourceId, name, owner {id, name, avatarUrl, disabled}, access, sharedAt, questionCount?}], counts {all, test, questionSet}, page, size, total}. - Soft-deleted resources are excluded. -qmatches the name and the owner, accent-insensitively. - Audit, through the data-modifying CTE pattern:
- actions
share.granted,share.changed,share.revokedandshare.removed- entitytestorquestion_set- diffshared_withas{granteeId, access}, ids only. R5's audit read resolves the label into the Admin deck fixture's form,"{name} (can edit)"or"β¦ (can use)", because a name frozen in the append-only log could never be anonymised (D16) - With
notify, each new grantee receivescontent.sharedthrough R4'sNotify: - params{sharerName, kind, name, access, message?}, targetsharedWithMe- no recipient preference (decision above); T-R7.13 adds the email - R4's
GET /teacher/summarygainssharedWithMe, the number of live grants to the caller, through a port typed as this module's count query. -
x-resource, per parameter (T-R2.16):{shareId}asshare, which joins the kind enum in this PR, andresourceId(query onGET /teacher/shares, body onPOST) astestorquestion-setby thekindit travels with; the suite runs it once per kind. Body/granteeIdsnames no owner-scoped kind: any teacher the directory returns is a valid grantee, and the skip reasons cover the rest. The suite's table records that exemption with its reason. - Tests: - unit tests for the skip reasons and the access transitions - integration tests for grant, list, change, revoke and remove - directory accent matching, and disabled users flagged - a soft-deleted resource hidden from Shared with me - both rate limits, keyed per caller: two users behind one IP each get their own budget - the audit diffs, which carry no name
Depends on: T-R7.3
Touches: server/internal/modules/tests/repositories/access.go (new), server/internal/modules/tests/repositories/{store.go,list.go,versions.go,version_management.go,preview.go,autosave.go,publish_publisher.go,group_read.go,group_mutation.go,group_lifecycle.go,delete.go}, server/internal/modules/tests/application/command/*.go, server/internal/modules/tests/http/{tests.go,tests_handlers.go,publish.go,version_management.go}, server/internal/modules/questions/repositories/access.go (new), server/internal/modules/questions/repositories/{store.go,search.go}, server/internal/modules/questions/http/questions.go, server/internal/modules/assignments/{application/command/create.go,repositories/write.go}, server/internal/modules/attempts/repositories/{monitor.go,review_review.go,result.go}, api/openapi.yaml, generated code, the web call sites the new required fields reach
Size: L
Done when:
-
One predicate per resource kind extends R2's scope.
repositories/access.goin each module holds its predicate, and every read and write in the module uses it, including test-owned groups, preview, the version diff and version management: - read: owner, any grant, orscope.all- edit: owner, an edit grant, orscope.all- owner-only: owner orscope.all -
What each caller may do:
| Action | Owner, `scope.all` | Edit grant | Use grant | |---|---|---|---| | Read the test or set: detail, builder, versions, preview, diff | β | β | β | | Assign a published version to own classes | β | β | β | | Make a copy (T-R7.7) | β | β | β | | Autosave, change questions and groups, restore as draft, make default | β | β | | | Publish (also needs `content.tests.publish`) | β | β | | | Delete, archive, share, duplicate by reference, delete a version | β | | | -
A caller who cannot read the resource gets 404, never 403, so ids stay unguessable. A reader without the right gets 403
SHARE_READ_ONLY(a use grant writing) orSHARE_OWNER_ONLY. -
Test,TestSummary,QuestionSetandQuestion(read by id) gain requiredaccess: owner | edit | useandowner {id, name}.scope.allreads asowner, and the web tells ownership fromowner.id. -
listTestsgainsinclude=shared, which adds tests shared with the caller, withaccessandowner, for the wizard. The Tests page and the bank list show the caller's own rows for everyone, admins included (70 Β§4.1'sscope.allbullet, decided in R4; DG-53). -
An edit grantee's publish records them as
published_by; the owner stays the author (DG-90). The owner receivestest.published_by_collaboratorin-app (no switch):{editorName, title, version}, targeting test detail. -
createAssignmentaccepts a version of any test the actor can read. -
Every read that starts from an assignment reaches the version through the assignment's scope, never through a share: monitor, grading queue, attempt review, results export, item analysis and the student result. An integration test revokes a grant and shows that the grantee's monitor, grading and results still work.
-
Tests: - an integration case per action per access level - revoke mid-session: the next autosave answers 404 -
publish_snapshot_test.go,TestLockForDraftUseSerialisesAgainstDeleteandTestLockForVersionUseSerialisesAgainstDeletepass untouched - the tests and questions suites run before and after, with both runs in the PR -
make genis its own commit. The web call sites typecheck in the same PR.
Depends on: T-R7.4
Touches: server/internal/modules/tests/{domain/errors.go,repositories/autosave.go,repositories/group_mutation.go,repositories/group_copy.go,repositories/draft_members.go,http/tests.go,http/group_response.go}, server/internal/modules/questions/{domain/input.go,domain/errors.go,application/command/create.go,application/command/update.go,application/command/duplicate.go,repositories/store.go,http/input.go,http/questions_handlers.go}, api/openapi.yaml, generated code, server/internal/modules/tests/application/tests/co_editing_test.go (new), server/internal/modules/questions/application/tests/crud_test.go
Size: M
Done when:
- Every tests write sets
tests.updated_by. The 409STALE_WRITEdetails gain{updatedBy: {id, name} | null, updatedAt}, on the test and on group mutations. -
updateQuestiongains an optionalexpectedUpdatedAt. A mismatch answers 409STALE_WRITEwith the same details, and every question write setsquestions.updated_by. The field becomes required in R8 (T-R8.1). The web sends it from this PR on. -
createQuestionandduplicateQuestiongain an optionalforTestId. - The actor needs edit access to that test. - The new question'sowner_idis the test's owner, andcreated_byis the actor. - Without the field, the owner is the actor, as today. - Body/forTestIddeclaresx-resourcekindtest(T-R2.16's per-parameter form). - The draft ownership invariant stays R2's rule. After
questions.LockForDraftUse, the outline write asksQuestionLocks.NotOwnedByand answers any question not owned by the test's owner as it answers a missing one (72-r2.md, T-R2.12a). - R7 does not change that check. It adds the paths that satisfy it for a non-owner:forTestIdhere, andquestion-copiesin T-R7.7. This is the "copy-on-add" R2 left to R7. - Copying a bank group into a draft creates its member questions with the test's owner as owner, following R2's rule that duplicates and restores write the source test's owner. - A non-owner's edit without a test or set context answers 422
EDIT_CONTEXT_REQUIRED. The question's owner andscope.alledit in place, as today. - Tests:
- two sessions, the owner's and a grantee's, autosave one test; the second gets
STALE_WRITEnaming the first -forTestIdwithout edit access gets 404 or 403 - a grantee's question created withforTestIdis accepted by R2's owner check, and one created without it still gets R2's 404 - a v0.13.0-styleupdateQuestionwithoutexpectedUpdatedAtstill succeeds
Depends on: T-R7.5
Touches: server/internal/modules/questions/domain/copy_on_write.go (new), server/internal/modules/questions/repositories/{copy_on_write.go (new),references.go,sets.go}, server/internal/modules/questions/application/command/update.go, server/internal/modules/tests/application/command/edit_question.go (new), server/internal/modules/tests/application/ports/question_writes.go (new), server/internal/modules/tests/repositories/edit_question.go (new), server/internal/modules/tests/http/tests.go, server/internal/core/adapters/question_writes.go (new), api/openapi.yaml, generated code, server/internal/modules/questions/application/tests/copy_on_write_test.go (new), server/internal/modules/tests/application/tests/edit_question_race_test.go (new)
Size: M
Done when:
- The domain rule, in
copy_on_write.go: - A non-owner's edit in a context they can edit forks the question when it is referenced by any draft other than the context test, or by any set other than the context set. - Otherwise it edits in place. - The owner's edits andscope.alledits without a context never fork. - Group member questions are never forked: they belong to a test-owned group. - Test context:
PATCH /teacher/tests/{id}/questions/{questionId}(content.tests.write) takes theQuestionInputbody andexpectedUpdatedAt. It returns{question, forked, testUpdatedAt}. One transaction runs these steps in order: 1. lock the test row, as autosave does 2.questions.LockForDraftUse(original)3. count the references 4. on a fork, call R6's duplicate path with the edit applied, owner the question's owner andcreated_bythe actor, through aQuestionWritesport typed as the questions repository call that takes the transaction 5.questions.LockForDraftUse(fork), then re-point this test'stest_section_questionsrows from the original to the fork 6. bumptests.updated_atandupdated_by - Set context:
updateQuestiongainssetId. The questions module locks the set row, then the question, and on a fork re-points only that set's item. -
x-resource, per parameter (T-R2.16): the second path id{questionId}declaresquestion, beside{id}astest, andupdateQuestion's body/setIddeclaresquestion-set. The suite substitutes A's id into one of them at a time. - Locks are taken test (or set) first, then question. This is the order autosave and
add-to-set already use, and
publish_lockorder_test.gopasses unchanged. Question delete locks only the question, so it cannot deadlock with either. - Audit:
question.forked, with{from, to, context}. -
copy_on_write_test.go(integration): - A owns question Q, used by test T1 (shared with B, edit), test T2 and set S1. B edits Q in T1. T1 now references Qβ² (owner A,created_byB), while T2 and S1 still reference Q unchanged. - With no other reference, B's edit changes Q in place. - A's own edit of Q in T1 changes Q, and T2 sees it, as today. - B editing Q in shared set S2 forks it for S2 only. -
edit_question_race_test.godrives B's fork and A's delete of Q concurrently, asTestLockForDraftUseSerialisesAgainstDeletedoes. No draft ever points at a deleted question, and it fails within two attempts without the lock. -
TestLockForDraftUseSerialisesAgainstDeleteandpublish_snapshot_test.gopass untouched. The five canaries run before and after, with both runs in the PR.
Depends on: T-R7.4
Touches: server/internal/modules/tests/application/command/copy.go (new), server/internal/modules/tests/repositories/{copy.go (new),duplicate_graph.go,version_draft.go}, server/internal/modules/tests/application/command/question_copies.go (new), server/internal/modules/questions/application/command/copy_set.go (new), server/internal/modules/questions/repositories/copy.go (new), server/internal/modules/media/repositories/{references.go,delete.go,replace.go}, server/internal/modules/media/domain/errors.go, server/internal/modules/media/http/media_handlers.go, R2's media-binding check in server/internal/modules/questions/ and server/internal/modules/tests/application/ports/group_media.go, api/openapi.yaml, generated code, server/internal/modules/tests/application/tests/copy_test.go (new), server/internal/modules/media/application/tests/cross_owner_test.go (new)
Size: M
Done when:
-
POST /teacher/tests/{id}/copy(content.tests.write) copies any test the caller can read into the caller's ownership and returns 201 withTest. - It copies the current draft: sections, instructions and test-owned groups. - Every standalone question becomes a new bank question owned by the caller, through the restore-as-draft copy path, which already makes independent rows and remaps gap ids. R6's match items and rubric criteria come too. - Versions, assignments and grants are not copied. - The copy takes the title the wayduplicateTestnames its copies. - Duplicate for the owner keeps its reference semantics. -
POST /teacher/question-sets/{id}/copy(content.questions.write) takes{questionIds?}and returns 201 with{setId | null, questionIds}. - Without ids, it copies the whole set into a new set owned by the caller, with fresh questions. - With ids, which must be members of the set, it copies just those questions into the caller's bank, for "Add to test" from a shared set. -
POST /teacher/tests/{id}/question-copies(content.tests.write) takes{questionIds (1β100)}and returns{questionIds}. - It copies questions the caller can read into the test owner's bank. The caller needs edit access to the test. - The builder uses it when the caller is not the test's owner. - Copies reference media by asset id, because assets are immutable. Each copy takes
media.LockForVersionUsebefore its insert. - The copy path calls
questions.LockForDraftUsebefore each insert intotest_section_questions, whether the copy is a whole test orquestion-copiesadding to a draft.TestLockForDraftUseSerialisesAgainstDeletepasses untouched. - The media module defines "readable asset" once, and R2's media-binding check (T-R2.12b)
uses it for questions and groups. An asset is readable when it is the actor's own, when the
actor holds
scope.all, or when content the actor can read references it, including through a share. So a copier's later edit of a copied question keeps its media. Anything else gives the same not-found validation error as a missing asset. R10 applies the same definition to words, lesson media and body references. - Media soft delete answers 409
MEDIA_IN_USE_ELSEWHERE{count}when a live bank question or draft group owned by another user references the asset. No titles of the other teacher's content leave the server. The owner's own references behave as today. - Media replace (R4's T-R4.17b) re-points only rows owned by the asset's owner, or rows the actor can edit. Other owners keep the old asset, which stays served, and the response counts what it left. A copy is independent (D4), so the owner's replace never rewrites it.
- Audit
share.copied(entity and source id), beside the usual create rows. - Body
/questionIdsonquestion-copiesand on the set copy declaresx-resourcekindquestion(T-R2.16's per-parameter form). - Tests:
- a copy from a use grant, which is independent: editing it leaves the original unchanged
- a copied matching question with rubric criteria
- the whole-set copy and the partial copy
- the media guard's 409
- isolation: after A replaces an asset, B's copy still references the old asset, and
the replace response counts it as left
- B edits a copied question that binds A's audio, and the binding is accepted
- a copy and a delete driven concurrently, as
TestLockForVersionUseSerialisesAgainstDeletedoes - The five canaries and the tests, questions and media unit tests run on the branch point and on the last commit, both runs in the PR.
Depends on: T-R7.3 β¦ T-R7.7
Touches: R2's two-teacher isolation suite (server/tests/isolation_flow_test.go; the kinds question-set, share and attachment and each operation's per-parameter x-resource entries arrive in the PR that adds the operation, T-R7.2 β¦ T-R7.11), R5's transfer (server/internal/core/adapters/content_transfer.go, server/internal/modules/questions/application/command/transfer_ownership.go, server/tests/transfer_flow_test.go), server/internal/modules/sharing/repositories/, server/tests/sharing_flow_test.go (new)
Size: M
Done when:
- Every operation added in T-R7.2 β¦ T-R7.7 is in the isolation suite. Teacher B: - with no grant, gets 404 on every one of A's ids, and list operations never return them - with a use grant, reads with 200 and gets 403 on every write - with an edit grant, writes with 200 and gets 403 on owner-only actions - after revocation, gets 404 again
- The suite substitutes A's id into one declared parameter at a time, path, query or body,
keeping B's own ids in the others. With no grant, B passing A's test or set as
resourceId,setIdorforTestId, or A's question inquestionIds, answers 404. - An assignment B made during the grant keeps its monitor, grading and results after the revocation.
- Escalation cases:
- a teacher without
content.sharecannot grant (the middleware answers 403) - a grantee cannotPATCHorDELETE/teacher/shares/*(SHARE_OWNER_ONLY) - nobody can grant to a student (NOT_A_TEACHER) - R5's transfer command (
transferUserContent, T-R5.10), in its R7 form: - it moves question sets with the tests and questions, andmovedgainsquestionSets- it deletes, with an audit row, the grants whose grantee becomes the new owner - remaining grants follow their resource with no write, because shares store no owner -transfer_flow_test.gogains a shared test, a shared set and a grant to the recipient - A disabled owner's grants keep working, and Shared with me flags the owner. A disabled grantee's grants remain for when they are re-enabled.
-
sharing_flow_test.go(e2etag) runs a share, then a co-edit with a fork, publish, revoke and copy through HTTP.
Depends on: β
Touches: migrations/NNNNN_create_message_attachments.sql, migrations/NNNNN_create_conversations.sql, migrations/NNNNN_create_messages.sql, migrations/NNNNN_create_conversation_reads.sql (all new), server/internal/platform/db/tests/{constraints,privileges}_test.go, server/internal/modules/classes/repositories/delete.go and server/internal/modules/identity/repositories/delete.go (T-R2.13's referencedBy map) with their integration tests, api/openapi.yaml (the referencedBy enum), generated code, docs/plan/20-data-model.md
Size: M
Done when:
-
create_message_attachments: - columns: -id uuid PRIMARY KEY DEFAULT uuidv7()-uploaded_byβ usersRESTRICT-storage_key text NOT NULL UNIQUE-mime_type text NOT NULL CHECK (mime_type IN ('application/pdf','image/png','image/jpeg','audio/mpeg','audio/mp4')), with no WebP (decision above) -bytes integer NOT NULL CHECK (bytes BETWEEN 1 AND 20971520)-original_filename text NOT NULL(1β200) -checksum_sha256 bytea NOT NULL CHECK (length(checksum_sha256) = 32)-created_at- an index on(uploaded_by, created_at)- the app role gets SELECT, INSERT and DELETE; the RESTRICT FK frommessagesstops it deleting a sent file - Down drops the table -
create_conversations: - columns: -id-kind text CHECK (kind IN ('class','direct'))-class_idβ classesRESTRICT-staff_idandstudent_idβ usersRESTRICT-created_at-last_message_at timestamptz NOT NULL- CHECKs: - a class conversation has onlyclass_id- a direct one has onlystaff_idandstudent_id-staff_id <> student_id- unique(class_id) WHERE kind = 'class'and(staff_id, student_id) WHERE kind = 'direct'- indexes on(staff_id, last_message_at DESC)and(student_id, last_message_at DESC), bothWHERE kind = 'direct'- the app role gets SELECT, INSERT and UPDATE (last_message_at) - RESTRICT onclass_idmeans a class whose channel has posts is never hard-deleted; the delete answers 409 withreferencedBy: messages(below) - Down drops the table -
create_messages: - columns: -id uuid PRIMARY KEY DEFAULT uuidv7()-conversation_idβ conversationsRESTRICT-sender_idβ usersRESTRICT-client_id uuid NOT NULL-body text(1β4000 after trim) -attachment_idβ message_attachmentsRESTRICT,UNIQUE-created_at-CHECK (body IS NOT NULL OR attachment_id IS NOT NULL)-UNIQUE (sender_id, client_id)makes a retried send idempotent - an index on(conversation_id, id DESC)-REVOKE UPDATE, DELETE ON app.messages FROM quizzivy_app;quizzivy_maintenance, the role R5's retention sweep runs as (T-R5.17a), gets SELECT and DELETE on all four messaging tables for anonymisation, granted in these files, which fail if the role is missing, as R5's grant file does - Down drops the table -
create_conversation_reads: -(conversation_id β conversations ON DELETE CASCADE, user_id β users ON DELETE CASCADE, last_read_message_id uuid NOT NULL, read_at, PRIMARY KEY (conversation_id, user_id))- no FK on the pointer; it is compared in uuidv7 order - the app role gets SELECT, INSERT and UPDATE - Down drops the table - The new
RESTRICTFKs join T-R2.13's constraint-name map with a newreferencedByvalue,messages, added to the contract enum and both 409 descriptions: -classes/repositories/delete.go:conversations_class_id_fkey-identity/repositories/delete.go:conversations_staff_id_fkey,conversations_student_id_fkey,messages_sender_id_fkeyandmessage_attachments_uploaded_by_fkey- one integration test per repository: deleting a class with a channel, and deleting a student with a direct thread, each answer 409referencedBy: messages, never a 500 -
constraints_test.gocovers every CHECK and both partial unique indexes. -
privileges_test.goasserts that the app role cannot UPDATE or DELETEmessages, and thatquizzivy_maintenancecan delete from all four tables and write nothing else there. - goose up/down/up passes.
-
20-data-model.mdrecords the tables, append-only messages as a deviation row besideattempt_events, and why attempt-event cards are not rows.
Depends on: T-R7.9, R4's principal-keyed limiter
Touches: server/internal/modules/messaging/ (new: domain/{thread,eligibility,message}.go, application/{command/{post_direct,post_announcement,mark_read}.go,query/{staff_threads,staff_messages}.go,ports/ports.go}, repositories/{threads,messages,reads,eligibility}.go, http/staff.go), server/internal/core/wiring/messaging.go (new), server/internal/core/adapters/messaging.go (new), server/internal/core/router/{modules.go,server.go,ratelimits.go}, api/openapi.yaml, generated code
Size: L
Done when:
- Eligibility (
domain/eligibility.go, a table-tested manager): - A staff member may message a student when the student is an active member of a class whoseteacher_idis the staff member, or was created by them (users.created_by). The student must also pass R2's strict student predicate (app.student_like_roles) and be enabled.scope.alldoes not widen this. - A class channel is readable within class scope (teacher_idorscope.all). Posting also needsteaching.classes.write. An archived class answers 409CLASS_ARCHIVEDon a post. - A direct conversation is readable by its two participants only. Anyone else, admins included, gets 404. - A thread whose pair no longer qualifies stays readable. A post answers 409MESSAGING_NOT_ALLOWED. - Staff operations:
-
GET /teacher/messages/threads?tab=all|direct|classes&q=(workspace.teacher) returns{items: [{thread: {kind: class, classId} | {kind: direct, studentId}, title, subtitle, avatarUrl, lastMessageAt, preview, unread}]}. - Class channels are listed for every non-archived class in scope, posted or not. -previewis at most 120 characters, or "Attachment Β· {filename}". -qmatches names and class names, accent-insensitively. -GET /teacher/messages/students/{studentId}andGET /teacher/messages/classes/{classId}(workspace.teacher,?before=&after=&limit=up to 50) return{items: [StaffMessage], nextBefore, latestId}, whereStaffMessage {id, sender {id, name, avatarUrl, side: staff|student}, body, attachment, createdAt, mine, seenBy?}. -seenBy {seen, of}appears on class posts:ofcounts the active student members, andseenthose whose read pointer has passed the post. -POST /teacher/messages/students/{studentId}(workspace.teacher) andPOST /teacher/messages/classes/{classId}(teaching.classes.write, the announcement row of 70 Β§4.1) take{clientId, body?, attachmentId?}. - They return 201 with the message, or 200 with the existing one whenclientIdrepeats. - The first post creates the conversation (INSERT β¦ ON CONFLICT DO NOTHINGon the partial unique index). - Each post bumpslast_message_at. -POST /teacher/messages/{students/{studentId}|classes/{classId}}/read(workspace.teacher) takes{lastReadMessageId}and returns 204. - The pointer only moves forward. - A message from another conversation answers 422. - Rate limits: posts at 30 a minute and 500 a day per staff user, in R4's
PrincipalRateLimits()keyed byratelimit.PrincipalKey. - Posts, reads and attachment uploads write no audit row (decision above).
- Every operation joins R2's isolation suite. The paths name existing
x-resourcekinds (student,class), so another teacher's ids already answer 404 by rule. Body/attachmentIddeclaresattachment, which joins the kind enum in this PR. Body/clientId(client-generated) and/lastReadMessageId(checked against the conversation, 422) name no owner-scoped kind, and the suite's table records each exemption with its reason. The DM case is explicit: teachers A and B both teach student S in different classes and hold separate threads; B and an admin each get 404 on A's. - Tests:
- the eligibility table, including a student removed from the class
- idempotent
clientId-beforeandafterpaging -seenByarithmetic - an archived class - the channel list with no posts
Depends on: T-R7.10a
Touches: server/internal/modules/messaging/{application/query/{student_threads,student_messages,context_events}.go,http/student.go,repositories/context_events.go} (new), server/internal/core/adapters/messaging.go, server/internal/modules/classes/{application/query/list_mine.go,http/my_classes.go,repositories/postgres.go}, api/openapi.yaml, generated code, web/tests/units/contract/openapi-contract.test.ts
Size: M
Done when:
- Student eligibility: a student may message the
teacher_idof any class they are an active member of, and reads the channels of those classes. A channel is read-only for students, so a post answers 403. - Student operations, all
x-permission: learning.take_tests: -GET /app/messages/threadsreturns direct threads and the channels of the student's classes that have at least one post.subtitleis "Your teacher Β· {class names}" or "Class announcements". -GET /app/messages/teachers/{teacherId}, andPOSTon the same path with{clientId, body}: students send text only (decision above). -GET /app/messages/classes/{classId}returns{readOnly: true, teacher {id, name}, items}. -POST /app/messages/{teachers/{teacherId}|classes/{classId}}/read. - Student rate limits: 10 posts a minute and 200 a day per student, in
PrincipalRateLimits(). -
StudentMessageis its own Go type and its own schema:{id, sender {id, name, avatarUrl}, body, attachment, createdAt, mine}. It has noseenBy, no member count, no context events and no email. - Context events on the staff side: a direct thread's staff response gains
contextEvents: [{attemptId, assignmentId, title, focusLost, at}]. - They are the student's flagged attempts (attempts.flagged) on assignments in the reader's assignment scope. -atiscoalesce(submitted_at, started_at), from 24 hours before the thread's first message. - They are read through an attempts query behind a port. - They never count as unread. -
MyClassgainsteacherId, so the student's class card can open the thread. It is additive and not a count. -
openapi-contract.test.ts: - every schema reachable from/app/messages/*lacks the four banned keys,seenBy,contextEventsand any count of members - the staff and student message schemas are distinct - Isolation, through R2's two-student pair for
/app/*(T-R2.16): student A cannot read student B's thread with the same teacher (404), cannot read the channel of B's class (classId, kindclass), and cannot open a thread with a teacher of a class they left.teacherIdis a user id whose eligibility the handler checks; the suite's table records it as an exemption with that reason, and these cases cover it.
Depends on: T-R7.10a
Touches: server/internal/platform/filetype/sniff.go (new), server/internal/modules/media/application/internal/support/shared.go (calls filetype; a mechanical commit), server/internal/modules/messaging/{application/command/upload_attachment.go,application/query/attachment_url.go,repositories/attachments.go} (new), server/internal/modules/messaging/http/{staff.go,student.go}, server/internal/core/jobs/housekeeping.go (R5's), server/internal/core/router/ratelimits.go, api/openapi.yaml, generated code
Size: M
Done when:
-
platform/filetypeholds content sniffing for PNG, JPEG, WebP and PDF (%PDF-at offset 0). Media'sSniffImagebecomes a call to it, in its own mechanical commit, so media keeps accepting WebP as today. Attachments accept PNG, JPEG and PDF from it, never WebP (decision above). -
POST /teacher/messages/attachments(workspace.teacher) takes multipart withx-max-body-bytesat 20 MiB plus form overhead. It returns 201 with{id, filename, bytes, mimeType}. - The type is sniffed from the content:filetypefor images and PDF,platform/probefor MP3 and M4A. Anything else answers 415. - PNG and JPEG passimage.DecodeConfig(the standard library's decoders) with a cap of 10000 Γ 10000 before storage. Nothing is decoded further, and the original bytes are kept. A WebP answers 415. No dependency is added. - The upload isBoundedCopy-style: more than 20 MiB answers 413. - The object is stored atmessages/{uuid}.{ext}, and the filename is sanitised as media does. -
GET /teacher/messages/attachments/{id}(workspace.teacher) andGET /app/messages/attachments/{id}(learning.take_tests) answer 302 to a presigned GET valid five minutes. - The URL setsresponse-content-disposition: attachment; filename*=UTF-8''β¦and the sniffedresponse-content-type. - The 302 carriesCache-Control: private, no-store, below the URL's life. - Access is limited to the participants of the conversation whose message holds the file, or its uploader before it is sent. Anyone else gets 404. - A message may reference only an attachment its sender uploaded and nobody has sent
(
UNIQUE attachment_id). - R5's daily
Housekeep(core/jobs/housekeeping.go) gains two bounded steps, so it adds no wake of its own: - attachments left unsent for 24 hours: the row first, then the object - objects undermessages/that no row references, older than 24 hours. These are left by T-R7.12's anonymisation, which runs without storage credentials. - Uploads are limited to 20 an hour per user, in
PrincipalRateLimits(). - Both downloads declare their path
{id}asx-resourcekindattachment, the kind T-R7.10a added for the posts' body/attachmentId. - Tests:
- an executable renamed
.pdfanswers 415 - a PNG header declaring 50000 Γ 50000 answers 415 before any decode - a valid WebP answers 415 - MP3 goes through the probe - 20 MiB + 1 answers 413 - non-participants and an admin get 404 - the sweep keeps sent files - the redirect carries the disposition and the cache header - The five canaries and the media and messaging unit tests run on the branch point and on the last commit, both runs in the PR, because the sniffing commit changes media's upload path.
Depends on: T-R7.10b
Touches: server/internal/modules/messaging/application/query/unread.go (new), server/internal/modules/dashboard/application/query/nav.go (R4), R4's /me/summary query in server/internal/modules/notifications/, R5's export worker in server/internal/modules/exports/application/worker/ and its README.txt, R5's server/internal/core/maintenance/anonymize.go, server/internal/core/maintenance/tests/retention_test.go, api/openapi.yaml, generated code
Size: M
Done when:
-
GET /teacher/summaryandGET /me/summarygainunreadMessages: messages past the reader's pointer, excluding their own, summed over their threads. The web shows "99+" above 99. - In-app
message.received: - one item per recipient per thread, deduped onmsg:{threadKey}- upserted while unread with{senderName, className?, count, excerpt (β€80)}- marked read when the reader's pointer passes it - recipients: the counterpart of a direct message, or every active member of the class for an announcement - it honours the in-app switch formessage.received(teacher "New message", student "Messages from teachers") - the excerpt lives only in the recipient's own notifications, which only they can read, and is pruned at 180 days with the rest - R5's data export (
system.data_export) gainsmessages_summary.csv, with one row per conversation: kind, class id, participant ids, message count, first and last message dates. Bodies, filenames and attachments never appear (D17: counts, never contents). Cells are formula-escaped like every export. - D16 anonymisation (R5's
anonymize-userstep, run asquizzivy_maintenance) deletes a disabled account's direct conversations with their messages, reads and attachment rows, in the same transaction as the rest of the anonymisation. - Their objects go in R5's housekeeping sweep of unreferencedmessages/objects (T-R7.11), because the maintenance command holds no storage credentials. - The account's posts in class channels stay, under whatever name anonymisation leaves. - R5 skips accounts that own classes or content, so in practice this removes students' threads. -
anonymize.go's owner check, R5's "skipping users who own classes or content", gainsquestion_sets.owner_id, counted the way R5 counts its own content tables, so a teacher who owns only a question set is skipped and counted in the report like any other owner. - The data export's
README.txtdescribesmessages_summary.csvin vi and en. - Tests:
- the unread arithmetic across threads, with own messages excluded
- the in-app upsert and the mark-read on the pointer
- the switch turned off
- the export has no body column
- anonymisation removes the DMs and keeps channel posts
- a
retention_test.gocase: a disabled account that owns only a question set is skipped
Depends on: R5's T-R5.2 (the email platform), T-R7.3, T-R7.10a, R4's T-R4.10b producers
Touches: migrations/NNNNN_widen_notification_preference_events.sql (new), server/internal/modules/notifications/{domain/events.go,application/command/notify.go,repositories/preferences.go}, server/internal/modules/notifications/application/email/prepare.go (new), R5's server/internal/platform/email/{outbox.go,render.go}, server/internal/platform/email/templates/{vi,en}/{attempt_flagged,assignment_extended,assignment_due_soon,assignment_closing,result_ready,content_shared,class_teacher_changed,submissions_digest,message_received,weekly_summary}.tmpl (new), R5's server/internal/core/jobs/email.go, server/internal/platform/config/, .env.example, api/openapi.yaml, generated code, R5's server/internal/platform/email/tests/templates_test.go, server/internal/modules/notifications/application/email/tests/ (new)
Size: L
Done when:
-
widen_notification_preference_eventsreplaces R4's event CHECK (T-R4.10a, under the constraint name R4 gives it) with one that addsmessage.receivedandsummary.weekly,NOT VALIDthenVALIDATE. The new list, written out in the file so R10's T-R10.6 can restore exactly this one on its Down:('attempt.submitted','attempt.flagged','assignment.closing','assignment.due_soon','result.ready','message.received','summary.weekly'). Down deletes the preference rows for those two events, since they are reconstructible defaults, and restores R4's five-key CHECK. -
The email decision, per recipient and event, is the preference row's
email, or else the deck's default:| Recipient | Event | In app | Email | |---|---|---|---| | Teacher | A student submits (grouped every 15 minutes) | on | off | | Teacher | An attempt is flagged | on | on | | Teacher | New message | on | on | | Teacher | An assignment is about to close | on | off | | Teacher | Weekly summary | off | on | | Student | Test due soon, Result ready, Messages from teachers | one switch | same switch | A student's `PUT /me/notification-preferences` stores `email = inApp` (DG-82). -
Immediate emails are enqueued when the in-app item is written: -
attempt.flagged(teacher) -assignment.extended(student, when the teacher ticks Notify) -result.readywhen grading releases the score at once -content.shared, whenever the sharer ticks "Send a notification", with no recipient switch -class.teacher_changed, to both teachers when an admin changes a class's teacher with notify on. R5's T-R5.11 left this email for R7; it has no switch, like R5's in-app item. -
Grouped and delayed emails: -
attempt.submitted: one email per assignment per 15-minute bucket, sent at the bucket's end -message.received: sent ten minutes after the message only if the thread is still unread, and at most once per thread per 30 minutes -
Rows go into R5's
email_outboxthroughemail.Enqueue, with a template name, ids inparams, adedupe_keyand adeliver_after. R5 already renders at send time fromtemplateandparams. - R7 adds an optional per-templatePrepare(ctx, params) (params, send bool, err)that R5's dispatcher calls before rendering. - It may refreshparamsfrom the database: the bucket's final count, the current title. - It may drop the row: the thread was read, the assignment closed, the switch was turned off since, orNOTIFY_EMAIL_EVENTSexcludes the event. A dropped row is closed withfailed_atandlast_error_code = 'SKIPPED', so R5's housekeeping prunes it like any other. - R5's templates have noPrepareand are unchanged. -
R5's daily budget (
EMAIL_DAILY_LIMIT) still moves excess rows to the next local midnight.Preparedrops those that went stale overnight, such as a due-soon for an assignment that closed. -
The templates live beside R5's in
platform/email/templates/{vi,en}/, and R5'stemplates_test.gocovers them: both locales,missingkey=error, and escaping. - Each is chosen by the recipient'susers.locale, with vi as the default. - Links go to app routes on R5'sAPP_URL. - The footer reads "Change which emails you get" and links to the recipient's settings. -
No email carries a message body, a join code, a password, or a score the review policy does not yet show that student.
-
NOTIFY_EMAIL_EVENTS(a comma list, default all) turns an event's email off without a code change..env.exampledocuments it, and start-up logs the enabled set. -
Tests: - the preference matrix: defaults, an override, a student's shared switch - a delayed message email skipped after the thread is read - the bucket's count read at send - the kill switch - a row dropped by
Prepareis closed asSKIPPEDand never sent - golden renders in vi and en for every new template
Depends on: T-R7.13
Touches: server/internal/core/jobs/notify_schedule.go (new), server/internal/modules/notifications/application/command/{send_due_emails,send_weekly_summary}.go (new), server/internal/modules/notifications/repositories/{due.go (R4),weekly.go (new)}, server/internal/modules/assignments/application/ports/ (R4's, gaining a ScheduleNudge port), server/internal/modules/assignments/application/command/{create,update,reopen,extend,set_overrides}.go, server/internal/core/core.go, server/internal/core/jobs/tests/notify_schedule_test.go (new), docs/plan/20-data-model.md (the advisory-lock registry)
Size: M
Done when:
- One scheduler runs in the API process. It is light work, so it stays out of the worker
group, which is kept for heavy jobs.
- At start-up and after each run, it computes the next due time from the database. The
candidates:
- a student's due-soon at the effective close β 24 h and β 1 h, overrides included
- a teacher's closing at close β 1 h
- an
after_closeresult release at close - Monday 07:00 in each opted-in teacher's time zone - It sleeps until then or six hours, whichever is first, the worker'sIMPORT_WORKER_IDLE_POLLprecedent. - Assignment create, update, reopen, extend and override writes nudge it in-process, and it recomputes at once. - Each run takes
pg_try_advisory_xact_lock(73819, 42), the notify scheduler's key in the advisory-lock registry in20-data-model.md(10 and 11 imports, 40 maintenance windows, 41 legacy rotation, 42 notify scheduler). The registry's row for 42 is present when this merges. - It processes every trigger in (now β 26 h, now] under T-R4.10b's rule: still open, not handed in, attempts left. - It enqueues with the dedupe keysdue:{assignmentId}:{studentId}:{24h|1h},closing:{assignmentId}:{teacherId},result:{attemptId}andweekly:{teacherId}:{isoWeek}. - A second run enqueues nothing. - With nothing open and nobody opted into the summary, it wakes only for the six-hour check (NFR-A10, PR-7).
- The weekly summary covers each active class of the teacher: last week's submissions, average percent, answers waiting to be graded and flagged attempts. - It writes the in-app item too when that switch is on. - A teacher with no activity gets nothing. - R8 adds attendance (T-R8.9).
- R4's read-time in-app items are unchanged.
- Tests: - next-due computation at the boundaries: overrides, closed early, extended - two schedulers under the lock enqueue one set - a restart inside the window catches up once - a nudge recomputes - the weekly summary in a UTC+9 zone - nothing for a closed or handed-in assignment
Depends on: T-R7.13
Touches: migrations/NNNNN_create_email_suppressions.sql (new), server/internal/platform/email/{webhook.go,suppressions.go} (new), R5's server/internal/core/jobs/email.go, server/internal/core/wiring/email.go, server/internal/core/router/{ratelimits.go,server.go}, the open-operations list pinned in server/internal/core/router/tests/, server/internal/platform/config/, .env.example, fly.toml, .github/workflows/deploy.yml, docs/setup/dns.md, api/openapi.yaml, generated code, server/internal/platform/email/tests/webhook_test.go (new)
Size: M
Done when:
-
create_email_suppressionscreatesapp.email_suppressions. Down drops it. - Columns: -address text PRIMARY KEY, lower-cased and trimmed, checked byCHECK (address = lower(btrim(address)))-reason text NOT NULL CHECK (reason IN ('bounced','complained'))-provider_event_id text NOT NULL UNIQUE-created_at- The app role gets SELECT, INSERT and DELETE. -
POST /public/email-events(security: [], so nox-permission) receives Resend's webhook. - It verifies the Svix signature Resend signs with:svix-id,svix-timestampandsvix-signature, HMAC-SHA256 over{id}.{timestamp}.{body}with the decodedRESEND_WEBHOOK_SECRET, a five-minute timestamp tolerance, and a constant-time compare. Any failure answers 401 with an empty body. -x-max-body-bytesis 64 KiB. - A hard bounce (email.bounced) or a complaint (email.complained) inserts the recipient address withON CONFLICT DO NOTHING, idempotent onsvix-id. Every other event type is accepted and ignored. - A verified delivery always answers 204 with no body. - WithoutRESEND_WEBHOOK_SECRETthe operation answers 501, the house rule for an optional dependency. - Rate limit: 120 a minute per IP, in
RateLimits(). - The PR carries the leak review: - the operation reveals nothing: 401 or 204, both empty - a forged request cannot suppress an address, because it cannot sign - the stored address is one Quizzivy already holds
- The open-operations list gains this operation, the twelfth (70 Β§4.2: six at v0.6.0, seven after R1, eleven after R5), with its one-line justification. The pinned test changes in the same PR, so the new entry is argued, not slipped in (AGENTS.md).
- R5's dispatcher checks the suppression list before sending. A suppressed address closes the
row as failed with
last_error_code = 'SUPPRESSED', and logs the recipient's domain only. Changing a user's email clears nothing, because the new address is not on the list. - Config:
RESEND_WEBHOOK_SECRETis a Fly secret, and thedeploy.ymlpreflight names it..env.exampleanddocs/setup/dns.mdsay where the Resend dashboard shows it and which events to subscribe to. - Tests:
- signature: valid, wrong secret, stale timestamp, tampered body
- a replayed
svix-idis a no-op - a suppressed address is skipped by the dispatcher - the rate limit - 501 without the secret
Depends on: T-R7.3, R4's T-R4.29 and T-R4.30
Touches: web/src/features/sharing/ (new: components/{ShareDialog,PeoplePicker,AccessSelect}.tsx, api.ts, access.ts), web/src/features/tests/pages/teacher/{TestsListPage,TestDetailPage}.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/sharing/ (new)
Size: M
Done when:
- The dialog matches the deck. It sits 8% from the top,
min(520px, 100% β 24px)wide and at most 84% high. - The header has the kind tile, "Share β{name}β", "Only you can see it until you share it." and a close button. - The picker: - a chip box, a combobox "Add teachers by name or email", or "Add more" once someone is pending - suggestions with DG-22 initials, name and email; "Account disabled" rows cannot be picked - "No teacher matches. You can only share with teachers at this centre." - β/β, Enter, Backspace on an empty input, and Esc - lookups debounced 200 ms and cancelled on edit
- With pending adds, the Can use / Can edit select appears (Can use by default), with "Send a notification" (on) and "Add a message (optional)".
- People with access: - the owner row, "{name} (you)" and "Owner" - grantee rows whose select applies at once, with the toast "{name} can now edit it" or "β¦ use it" - Stop sharing, a danger confirm, then "Stopped sharing with {name}" - "Not shared with anyone yet." when empty
- The explainer tiles and the footnote use the deck's words. "and publish new versions" appears for tests only.
- Share grants every pending recipient, toasts "Shared with {n} teacher(s)", adding "Theyβll get a notification." when notify is on, and keeps the dialog open. Done closes it, and Cancel clears the pending adds.
- Accessibility follows WAI-ARIA (DG-36): the dialog traps focus, Esc closes it and focus
returns, and the combobox uses
aria-activedescendant. - Entry points: the test card's "Share" or "Shared Β· {n}", and the test detail header, for
the owner holding
content.shareonly. - States: a skeleton for the grants, an error line with retry, and a 403 that closes the dialog with a toast.
- Unit tests cover the keyboard flow, a disabled row that cannot be picked, existing grantees excluded, Share keeping the dialog open, and the button absent for a grantee.
- The dialog is compared with the deck at 360, 768 and 1280, in light and dark. The PR lists the new keys, vi first.
Depends on: T-R7.3, T-R7.7, T-R7.16
Touches: web/src/features/sharing/pages/teacher/SharedWithMePage.tsx (new), web/src/app/router.tsx (/teacher/shared-with-me), locales, web/tests/units/sharing/
Reuses: DataTable, Pager, R1's segmented control, Callout, ConfirmDialog
Size: M
Done when:
- The header reads "Shared with me" / "Tests, questions, word lists and courses other teachers shared with you. They still belong to the teacher who shared them." Until R10 ships them, the sub-line names only what exists.
- The Can use / Can edit callout matches the deck.
- The tabs are the segmented control with count badges: All, Tests, Questions. Word lists and Courses arrive with R10 (70 Β§2 rule 5). The search reads "Search name or teacher".
- The table has a minimum width of 820 and scrolls inside its card, never the page. Its columns are Name (kind tile, name, kind label), Shared by (initials, name, "Account disabled" when so), Access (Can edit pencil / Can use eye) and Shared (date).
- Row actions: - Open: a test goes to the builder, read-only for use; a set goes to its page. - Make a copy confirms "The copy is yoursβ¦" and toasts "Copied to your Tests" or "β¦ Question bank". - Remove confirms as a danger action ("Assignments you already made from it keep workingβ¦") and toasts "Removed from Shared with me".
- Empty states: "Nothing is shared with you yetβ¦", and "Nothing matches." after a filter. Also a skeleton and an error with retry.
- The footer shows the pager.
- Tab, search and page live in the URL.
- Compared with the deck at the five widths, light and dark. Keys vi first.
Depends on: T-R7.4 β¦ T-R7.7, R4's T-R4.31a and T-R4.31b
Touches: web/src/features/tests/pages/teacher/{TestBuilderPage,TestDetailPage}.tsx, web/src/features/tests/components/{AccessBanner,AccessRemovedNotice}.tsx (new), web/src/features/tests/components/SharedContentDialog.tsx (D4's, T-D4.14), web/src/features/tests/{api.ts,useAutosave.ts}, web/src/features/question-bank/api.ts, the wizard's test step in web/src/features/assignments/, locales, web/tests/units/builder/{access-modes,fork-swap}.test.tsx (new)
Size: L
Done when:
-
access: use: - the builder renders read-only: inputs disabled, no drag, no add, and the autosave hook not mounted - the Shared content dialog (D4, T-D4.14) opens as a read-only view for a Can-use recipient: the passage or the recording, the gap bindings and both switches shown, every field, "Insert gap" and "From Media" disabled, no Save, and Cancel reading "Close"; it never calls the group editor's save - the title bar shows "Can use Β· {owner}", Make a copy and Assign - test detail hides Restore as draft, Make default, Delete and the publish banner -
access: edit: - the banner reads "Changes you make are saved to {given name}βs copy." (deck) - Publish shows only withcontent.tests.publish- Delete, Archive and Share are absent - When the caller is not the test's owner, whatever their access (admins included):
- new questions are created with
forTestId- "From question bank" goes throughquestion-copies- question edits go throughPATCH /teacher/tests/{id}/questions/{questionId}- onforked, the editor and the outline swap to the new id and adopttestUpdatedAtas the autosave base, and the toast reads "Saved as a new copy of this question, because {owner} uses the original elsewhere." -
STALE_WRITEnames the co-editor: "{name} changed this test at {time}. Reload to see their changes." - A 404 on autosave shows the blocking notice "{owner} stopped sharing this test with you." with a link back to Shared with me, and nothing further is sent.
- The wizard's test step lists own and shared tests (
include=shared), with the hint "Shared by {name}". - The owner's builder is unchanged.
- Tests:
-
builder/autosave-unmount.test.tsxand the publish-flush test pass untouched - read-only mode never callsupdateTest, and the Shared content dialog in read-only mode never calls the group save - typing after a fork saves to the fork - the stale notice names the co-editor - the canaries run before and after, with both runs in the PR - Compared with the deck's builder and test detail at the five widths, in the three access modes, light and dark. Keys vi first.
Depends on: T-R7.2, T-R7.6, T-R7.7, T-R7.16
Touches: web/src/features/question-bank/pages/teacher/{QuestionBankPage,QuestionEditorPage}.tsx, web/src/features/question-bank/pages/teacher/QuestionSetPage.tsx (new), web/src/features/question-bank/components/{SetFacet,AddToSetDialog}.tsx (new), web/src/features/question-bank/api.ts, web/src/app/router.tsx (/teacher/question-bank/sets/:id), locales, web/tests/units/question-bank/
Reuses: DataTable, BulkActions, ShareDialog, AddToTestDialog, FormDialog
Size: M
Done when:
- The bank's filter aside, or its sheet on narrow screens, gains a Sets group: the caller's sets, each with a count, linking to the set page.
- The bulk bar gains: - "Add to set": an existing set, or "New set" with a name - "Shareβ¦": name the set, create it, then open the share dialog Both are DG-90 interim and listed for sign-off.
- The set page shows the name, "{n} questions", and the owner and access pill:
- owner: Share, Rename, Delete (confirm "Tests that use these questions are not
affected.") and Remove from set
- edit grantee: rows open the editor with
?set={id}, and Remove from set - use grantee: rows open the editor read-only, and the bulk "Add to test" copies, then adds, toasting "{n} questions copied to your bank and added to {test}" - Make a copy in the header for everyone - The question editor:
-
use: read-only, with "Make a copy" for that question -edit: saves withsetIdandexpectedUpdatedAt; on a fork, the URL switches to the fork's id and a toast says why -STALE_WRITEnames the co-editor - States: a skeleton; the set empty ("This set has no questions yet."); an error; not found.
- The extrapolated panels are compared with the deck's bank frame at the five widths, light and dark, and listed for DG-90. Keys vi first.
Depends on: T-R7.10a, T-R7.10b, T-R7.11, T-R7.12
Touches: web/src/features/messages/ (new: pages/teacher/TeacherMessagesPage.tsx, components/{ThreadList,ThreadView,MessageBubble,AnnouncementCard,AttemptEventCard,Composer,DayPill,AttachmentChip,AttachDialog,ThreadDetails}.tsx, api.ts, useThread.ts, drafts.ts, linkify.tsx), web/src/app/router.tsx (/teacher/messages, /teacher/messages/class/:classId, /teacher/messages/student/:studentId), locales, web/tests/units/messages/ (new)
Reuses: useIdlePolling, useContentWidthAtLeast, IconTile, FormDialog
Size: L
Done when:
- The header reads "Messages" / "Announcements to classes and direct messages with students.", with New announcement. - New announcement switches to the Classes tab and opens the first class channel with the composer focused. - It is disabled, with a title, when the caller teaches no class.
- The split card is
calc(100vh β 120px)high, at least 520: - the list is 320px, with the search "Search people and classes" - tabs All, Direct (with its unread count) and Classes, filter the list - class rows have a square megaphone avatar, direct rows round DG-22 initials - unread rows show a weighted name and a badge - The thread header has the back arrow (single pane only), the avatar, name, sub-line and Details. Details is a popover (DG-91 sign-off): - a student's classes and a link to Students - a class's member count and a link to its detail
- The stream sits on
--sidebar: - day pills: Today, Yesterday, weekday, date - announcement cards: "Announcement Β· {time}", the text, and an eye with "Seen by {x} of {y}" - attempt-event cards: a danger-soft flag tile, "{title} Β· left the test {n} times", time, and Open attempt to the attempt sheet (D8 tones) - bubbles: own on the right in primary, others on the left in bordered cards, time below - attachment bubbles follow the sender (DG-91) - on class threads, "Sent to all {n} students. Replies come to you as direct messages." - The composer:
- the textarea grows with its text, with "Message {name}β¦" or "Write an announcement to
{class}β¦"
- Attach file: "Attach a file", "PDF, image or audio Β· up to 20 MB", then Send
- Send (arrow-up) is dimmed when there is nothing to send
- Enter sends and Shift+Enter adds a new line; nothing is sent while an IME composition
is open (
isComposing), for Telex and VNI - a draft is kept per thread in memory - sends are optimistic withclientId, and a failed send shows Retry - Data:
- the open thread polls
?after=every 10 s throughuseIdlePolling- the list refetches when the summary'sunreadMessageschanges - the read pointer is posted when the newest message is visible, debounced one second - Load earlier keeps the scroll position - Bodies are plain text, with http(s) links rendered as React anchors
(
rel="noopener noreferrer nofollow"). No HTML. - One pane shows at a time when the content width is under 760 (the deck's
msgSingle). - States:
- list skeleton
- the list empty: "No messages yet. Message a student from Students or post to a class."
(DG-83)
- a thread empty: "No messages yet." or, for a class, "No announcements yet."
- an error with retry
- a 409
MESSAGING_NOT_ALLOWEDreplaces the composer with "You no longer share a class with {name}." - Compared with the deck's Messages at the five widths, light and dark. Keys vi first.
Depends on: T-R7.10b, T-R7.12, T-R7.20 (its components)
Touches: web/src/features/messages/pages/student/StudentMessagesPage.tsx (new), web/src/layouts/StudentLayout.tsx, web/src/app/modules.ts, web/src/app/router.tsx (/app/messages, /app/messages/class/:classId, /app/messages/teacher/:teacherId), web/src/features/classes/pages/StudentClassesPage.tsx, locales, web/tests/units/messages/, web/tests/units/student/shell.test.tsx
Size: M
Done when:
-
modules.tsturns messages on, and the top bar shows the Messages button with a lime unread count from/me/summary(R3's gate). - The page is at most 960 wide and full height, with a 300px list. One pane shows at a time below 760, as a detail route titled "Messages" whose back arrow goes home (DG-81).
- Rows: - a teacher reads "Your teacher Β· {classes}", with a round avatar - a channel reads "Class announcements", as a megaphone card - unread rows are marked
- Direct threads: - bubbles - the composer "Message {name}β¦", with Enter, Shift+Enter and the IME rule of T-R7.20 - text only, with no attach button - teacher attachments download
- Channels are read-only, with the footer "Only your teacher can post here. Reply to {name} directly." linking to the teacher's thread.
- The Classes card's message button opens
/app/messages/teacher/{teacherId}. - Polling and read pointers work as in T-R7.20.
- States: a skeleton; "No messages yet. Your teachers' announcements appear here."; an error.
- Tests: the button and the badge appear only with the module on, Enter during composition does not send, and a channel has no composer.
- Compared with the deck's student Messages at the five widths, light and dark. Keys vi first.
Depends on: T-R7.17, T-R7.20
Touches: web/src/layouts/teacherNav.ts, web/src/features/search/CommandPalette.tsx, web/src/features/students/pages/teacher/StudentsPage.tsx, web/src/features/classes/pages/teacher/ClassDetailPage.tsx, web/tests/units/contract/teacher-nav.test.ts, web/tests/units/shell/, web/tests/units/search/
Size: S
Done when:
- The nav gains two items: - group 1 becomes Dashboard, then Messages with a lime unread count; Calendar joins between them in R8 - Content becomes Tests, Shared with me with a muted count, Question bank, Media
- Each item carries the
x-permissionof the list operation it opens, andteacher-nav.test.tspasses. - The palette's Pages gain Messages. Shared with me stays out, as the deck has it.
- Students: the row menu gains "Send message". The bulk "Message" opens that student's thread when one is selected, and the Messages Direct tab when more are.
- Class detail's member menu gains "Send message". Each item shows only to callers who may message that student.
- Tests: nav order and counts, palette entries per permission, and each entry point's route.
Depends on: T-R7.13, T-R7.14, R4's T-R4.44 and T-R4.45
Touches: web/src/features/settings/sections/Notifications.tsx (R4), R3's student Settings Notifications section, web/src/features/notifications/kinds.ts, the Extend dialog in web/src/features/assignments/, server/internal/platform/email/templates/{vi,en}/{ai_allowance_warning,ai_credit_request,ai_credit_request_decided}.tmpl (new), R5's web/src/features/ai-credits/components/RulesCard.tsx (one sentence), locales, web/tests/units/settings/, web/tests/units/notifications/
Size: S
Done when:
- The teacher grid reads Update | In app | Email, with the deck's rows and hints in the deck's order, each defaulting as in T-R7.13's table: - A student submits ("Grouped every 15 minutes") - An attempt is flagged ("Focus lost more than allowed") - New message ("Direct messages from students") - An assignment is about to close ("1 hour before, with who has not submitted") - Weekly summary ("Monday morning, per class")
- The student list gains "Messages from teachers" ("Announcements and direct messages", on). One sentence under the section says updates also arrive by email at their address, since each switch covers both channels (DG-82).
- The bell gains four kinds, each with its route:
-
message.receivedgoes to the thread -content.sharedgoes to Shared with me -test.published_by_collaboratorgoes to test detail -summary.weeklygoes to the dashboard - The Email column gains the three kinds D4 raises in app (
74dT-D4.31):ai.allowance_warning(the teacher who crosses their warning percent, once a month),ai.credit_request(everyai.credits.manageholder) andai.credit_request.decided(the requester). Each is enqueued with the in-app item, on the terms ofcontent.sharedin T-R7.13: no recipient switch, aviand anentemplate, a link to the page it concerns (Settings βΊ AI assistant for the first and the third, Admin βΊ AI credits for the second). The Admin page's sentence under the warning percent then reads "They get a notification and an email.", as the v2 deck draws it. - The Extend dialog's note returns to the deck's "They get an in-app and email message".
- Tests: the defaults render, a toggle saves, a student switch sends both flags, every kind has a route, and each AI kind enqueues one email in both locales.
- Compared with the deck's Settings βΊ Notifications. Keys vi first.
Depends on: T-R7.16 β¦ T-R7.23
Touches: seed/NN-dev-collaboration.sql (new, numbered at merge, development only), web/tests/e2e/sharing.live.spec.ts (new), web/tests/e2e/messages.live.spec.ts (new), web/tests/e2e/payload-leak.live.spec.ts, web/tests/e2e/support/live.ts
Size: M
Done when:
- The dev seed matches the deck's fixtures:
- R2's second teacher, with grants both ways shaped like
SHARE_INandSHARE_0- a question set - direct threads and class posts shaped likeCONVSandMSGS- a flagged attempt for the context card -
sharing.live.spec.tsruns the round trip: 1. A shares a test with B as Can edit. 2. B edits a question A also uses in another test, and the fork happens. 3. B publishes, and A's history shows B as the publisher. 4. A revokes the grant, and B's assignment still grades. It is a live spec because the fork and the scope are server behaviour meeting the builder. -
messages.live.spec.ts: - a teacher posts an announcement, the student reads it, and the teacher sees "Seen by 1 of 1" - the student messages the teacher and the teacher replies - an admin token gets 404 on that thread - E2E 9 walks
/app/messages/*: none of the four banned keys, and noseenBy,contextEventsor member counts. -
pnpm e2e:livepasses.
Depends on: the tasks whose behaviour each part describes
Touches: docs/quizzivy-spec-v0.3.md (Β§1, Β§8, Β§9, Β§13, Β§15; version bumped per Β§18), AGENTS.md, docs/plan/{20-data-model,30-risks,40-open-items,60-backend-architecture}.md, docs/plan/70-redesign-overview.md (a check, amended only if it disagrees), docs/design/gaps.md, docs/setup/dns.md
Size: S
Done when:
- Spec: - Β§1 describes sharing between teachers (D4) - Β§8 covers Messages, Shared with me and the access modes - Β§9 covers student Messages - Β§13 and Β§15 list the new tables and operations - the "Availability: R7" markers go, and the version is bumped
- AGENTS.md:
- the repository map gains
modules/sharing,modules/messaging,features/sharingandfeatures/messages- Non-negotiable:messagesjoinsattempt_eventsandaudit_logas append-only, and a direct message is readable by its two participants only,scope.allincluded - High-risk: the draft ownership invariant and copy-on-write underLockForDraftUse- Authentication and authorization: the open-operations list gains the email webhook (T-R7.15), and its count becomes twelve - 70 is checked against what R7 built: Β§4.2's open-operation list names
POST /public/email-eventsas the twelfth, and D9 names the webhook,RESEND_WEBHOOK_SECRETandapp.email_suppressions. Any disagreement is corrected there. -
20-data-model.mdis complete for R7, with its deviations.60-backend-architecture.mdlists the new modules. -
30-risks.mdrecords the PR-7 measurement plan.40-open-items.mdgains this file's open items as O-R7 entries. -
gaps.mdnotes under DG-90 and DG-91 what was built in the meantime, and gains an entry, if not already there, for each design row this file proposes: the message-retention setting in Admin βΊ Data & privacy (O-R7.2) and the user sheet's suppressed-address status (O-R7.3).docs/setup/dns.mdnotesNOTIFY_EMAIL_EVENTSand the webhook's secret and events.
Depends on: T-R7.16 β¦ T-R7.24 Touches: none beyond the seed Size: M Done when:
- Every new or changed screen is compared with
design-deckon 5175 at 360, 768, 1024, 1280 and 1440, light and dark, with the sidebar expanded, collapsed and as a drawer: - the share dialog - Shared with me - the builder and test detail in each access mode - the bank's sets and the set page - teacher and student Messages - Settings βΊ Notifications - The dev server is confirmed fresh, and sizes are measured with
getBoundingClientRect. - Deviations are fixed in the owning task's branch or filed as issues. Vite and the API are stopped afterwards.
Depends on: T-R7.1 β¦ T-R7.26
Touches: docs/plan/, release notes
Size: S
Done when:
- The release checklist below is complete, with Thuong's go before
main.
- Every task is ticked on
work/redesign-r7.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, 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 the builder, the bank and the student payloads changed -node scripts/check-design-deck.mjs - The canaries and
TestLockForDraftUseSerialisesAgainstDeletepass untouched, andbuilder/autosave-unmount.test.tsxpasses. - Security:
- the isolation suite covers every new
/teacher/*and/app/*operation, including the DM participant cases - the escalation cases of T-R7.8 pass - E2E 9 walks/app/messages/*- every new rate-limited operation has its test - the one new public operation, the email webhook, carries its leak review in T-R7.15's PR, its rate-limit test, and its entry in the pinned open-operations list - The nine migrations are rehearsed on a Neon branch of production, with timings in the release PR.
-
RESEND_WEBHOOK_SECRETis set as a Fly secret, and the Resend dashboard sends bounce and complaint events tohttps://api.quizzivy.com/public/email-events, before the deploy. - Email volume, on the same branch: what the last 30 days would have sent per event,
recorded in the PR. Thuong confirms the Resend plan covers it, or
NOTIFY_EMAIL_EVENTSis narrowed before the deploy (O-R7.1). - The deploy window is outside exam windows.
- Deploy and smoke:
- Thuong's go, then
release/0.14.0βmain- deploy the API (the release command migrates), then Pages - smoke/healthz,/livez, and sign-in as each role - the owner shares a test with a second teacher account and revokes it - the owner posts an announcement in their test class, and a test student reads it and replies - one email of each immediate kind arrives, in vi - back-merge todevelop, and tagv0.14.0 - Release notes, vi first: - sharing, Can use and Can edit, Shared with me, and Make a copy - copy-on-write, in one sentence: "if you edit a shared question its owner uses elsewhere, your edit becomes a copy for this test" - Messages, and that direct messages are private to their two participants - students now receive email for tests due soon, released results and teacher messages, and can turn each off in Settings - the teacher email switches and the weekly summary
- Hand-off to R8: make
updateQuestion.expectedUpdatedAtrequired (T-R8.1). - Neon compute hours are measured for the week after the release and recorded against NFR-A10 (PR-7).
-
O-R7.1 β Resend plan and volume. Due-soon emails go to each student at 24 hours and 1 hour
before close, so a class of 30 costs about 60 emails per assignment, before results, messages
and teacher emails. R5's
EMAIL_DAILY_LIMIT(100, the free tier's cap per O-R5.1) defers the excess to the next day, wherePreparedrops what went stale. The mail is not lost, but it arrives late. Default: measure on the Neon branch (release checklist). Ship with every event on if the plan covers it; otherwise ship with the student's 1-hour reminder off throughNOTIFY_EMAIL_EVENTS. -
O-R7.2 β Message retention. Admin βΊ Data & privacy has no messages setting. Default: keep
messages while both accounts exist, and delete an account's direct conversations when D16
anonymises it. A retention choice needs a design row first, which T-R7.25 proposes in
gaps.md. -
O-R7.3 β Showing a suppressed address. T-R7.15 stops mail to an address that bounced or
complained, but no screen shows it. The Admin deck's user sheet draws no email status, and a
teacher cannot tell that a student's reminders no longer arrive. Default: the suppression is
logged, and an admin removes it by changing the user's email. A design row is needed for the
user sheet, which T-R7.25 proposes in
gaps.md. - O-R7.4 β Edit grantees adding questions to a set. R7 lets an edit grantee change and remove a set's questions, but not add them (decision above). Default: owner adds. DG-90 is asked.
- O-R7.5 β Class staff in Messages. Assistants attach per class (D14, DG-04), but that link is not drawn, so a class's staff in R7 is its teacher. When class staff ships, it joins channel posting and DM eligibility.
- O-R7.6 β Auditing message activity. The review allowed either no audit for posts or audit rows with ids only. Default: no audit row for posts, reads or attachment uploads (decision above), because ids alone would show audit readers who wrote to whom, past D16's deletion of the thread. Thuong confirms, or asks for id-only rows with that trade-off accepted.
- O-R7.7 β Shared files in "Choose from Media" (DG-112, DG-90). The 2026-10-04 dialog speaks of files "other teachers shared with you". Media is not a shared kind here. Default: R7 does not share media; R4's dialog lists the caller's own files. The design team is asked. "From Media" in D4's Shared content dialog (T-D4.14) is a third place that opens the media picker, beside the content editor's Media dialog and the question media block; the default covers it.
- DG-90 (the question-set surfaces), DG-91 (the thread Details popover) and DG-83 (the empty and error states named above) ship extrapolated from deck primitives and are listed for the design team's sign-off.
Generated from docs/plan/77-r7.md. Edit that file in the repository; changes made on this page are overwritten.