Plan 77 R7 - SekiroKenjii/Quizzivy GitHub Wiki

R7 β€” Collaboration (v0.14.0)

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_id on 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 unused email column, /me/summary, /teacher/summary), useIdlePolling, the builder's testUpdatedAt autosave base, DataTable and the other composites
  • R5 (75-r5.md):
    • the email platform (T-R5.2): email_outbox with dedupe_key and deliver_after, the dispatcher, APP_URL, and templates in platform/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)
  • 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 access and its owner.
  • 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 TestLockForDraftUseSerialisesAgainstDelete pass 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_shares stores 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 a kind column and CHECKs. A grant therefore cannot disagree with its resource. R5's ownership transfer moves grants without a cascade. R10 widens the table with word_list_id and course_id, each a plain ON DELETE CASCADE FK with no owner column, by replacing the three CHECKs (content_shares_kind_check, content_shares_one_resource, content_shares_kind_matches). A polymorphic resource_id was 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_by recording the grantee. So the owner can always manage every question in their own drafts, and LockForDraftUse stays 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.all never 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 403 STUDENT_SHARED and goes to a holder of people.users.manage in 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_events and audit_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 messages table is the record, readable only by its participants (D17). An audit row, even with ids only, would show system.audit.read holders who wrote to whom and when, and would outlive the direct conversations D16 anonymisation deletes, because audit_log is 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 need LISTEN/NOTIFY across 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.

T-R7.1 β€” Migrations: question sets, content shares, co-editing provenance

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_sets creates two tables. Down drops both. - app.question_sets: - id uuid PRIMARY KEY DEFAULT uuidv7() - owner_id β†’ users ON DELETE RESTRICT - name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 120) - description text (≀500) - created_by β†’ users RESTRICT, and updated_by β†’ users ON DELETE SET NULL - created_at, updated_at (with the app.set_updated_at trigger) and deleted_at - the index (owner_id, updated_at DESC) WHERE deleted_at IS NULL - app.question_set_items: - set_id β†’ question_sets ON DELETE CASCADE - question_id β†’ questions ON DELETE RESTRICT - ordinal integer NOT NULL CHECK (ordinal >= 0) - added_by β†’ users RESTRICT, and added_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_shares creates app.content_shares. Down drops it. - Columns: - id uuid PRIMARY KEY DEFAULT uuidv7() - kind text NOT NULL, with CONSTRAINT content_shares_kind_check CHECK (kind IN ('test','question_set')) - test_id β†’ tests ON DELETE CASCADE - question_set_id β†’ question_sets ON DELETE CASCADE - grantee_id β†’ users ON DELETE CASCADE - access text NOT NULL CHECK (access IN ('use','edit')) - granted_by β†’ users ON DELETE RESTRICT - message text (1–500 when present) - created_at and updated_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) and UNIQUE (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): - add word_list_id uuid REFERENCES app.word_lists ON DELETE CASCADE and course_id uuid REFERENCES app.courses ON DELETE CASCADE, each FK NOT VALID then VALIDATE; no owner column and no composite FK - replace content_shares_one_resource with num_nonnulls(test_id, question_set_id, word_list_id, course_id) = 1 - replace content_shares_kind_check with kind IN ('test','question_set','word_list','course') - replace content_shares_kind_matches with one equality per kind - each replacement CHECK NOT VALID then VALIDATE
  • 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 in identity/repositories/delete.go as referencedBy: owned_content. A deleteUser integration test on a user who owns a set answers 409 with that value, never a 500.
  • add_co_editing_provenance adds tests.updated_by and questions.updated_by (uuid REFERENCES app.users(id) ON DELETE SET NULL, nullable). - Each FK is added NOT VALID and 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.go shows each rule firing: - a share naming two resources - a kind that 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.go asserts the grants above; the app role has no DELETE on question_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.md gains the R7 section, with its Β§12 deviation rows: - typed nullable FKs per kind instead of a polymorphic resource - no owner column on grants - updated_by left unfilled for the old binary

T-R7.2 β€” Question sets: the aggregate and its API

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-sets returns the caller's sets: {items: [{id, name, questionCount, sharedWith, updatedAt}]}. - POST /teacher/question-sets takes {name, description?, questionIds (1–500)} and returns 201 with QuestionSet. - GET /teacher/question-sets/{id} returns QuestionSet {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}/items takes {questionIds (1–500)} and is owner only. It appends in the given order and ignores duplicates. - POST /teacher/question-sets/{id}/items/remove takes {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_items calls questions.LockForDraftUse first, 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.
  • listQuestions gains setId. 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-resource in R2's per-parameter form (T-R2.16): {id} as question-set, body /questionIds as question, and listQuestions's query setId as question-set. question-set joins the kind enum in this PR.
  • Audit rows question_set.created, question_set.updated and question_set.deleted use 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 gen is its own commit.

T-R7.3 β€” The sharing module: grants, directory and Shared with me

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 holding workspace.teacher, active or disabled, never the caller. - Matching on name and email goes through app.immutable_unaccent(lower(…)), so "duc" finds "Đức". - It is rate-limited to 60 a minute per user, in R4's PrincipalRateLimits() keyed by ratelimit.PrincipalKey. - Leak note in the PR: colleagues' names and emails reach holders of content.share, as the deck draws, and never a student, whom the x-permission excludes.
  • GET /teacher/shares?kind=&resourceId= (content.share) returns {owner {id, name, email, avatarUrl}, grants: [{id, grantee {…, disabled}, access, grantedAt, grantedBy {id, name}}]}. - The owner and scope.all can read it. A grantee gets 403 SHARE_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 as SELF (the owner), ALREADY_SHARED, NOT_A_TEACHER (lacks workspace.teacher) or DISABLED. - Only the owner or scope.all may grant (SHARE_OWNER_ONLY). Recipients never share further (D4). - It is rate-limited to 30 an hour per user, in PrincipalRateLimits().
  • PATCH /teacher/shares/{shareId} (content.share) takes {access}. It is owner or scope.all only.
  • DELETE /teacher/shares/{shareId} (content.share) is "Stop sharing", for the owner or scope.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 without content.share must 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. - q matches the name and the owner, accent-insensitively.
  • Audit, through the data-modifying CTE pattern: - actions share.granted, share.changed, share.revoked and share.removed - entity test or question_set - diff shared_with as {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 receives content.shared through R4's Notify: - params {sharerName, kind, name, access, message?}, target sharedWithMe - no recipient preference (decision above); T-R7.13 adds the email
  • R4's GET /teacher/summary gains sharedWithMe, 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} as share, which joins the kind enum in this PR, and resourceId (query on GET /teacher/shares, body on POST) as test or question-set by the kind it travels with; the suite runs it once per kind. Body /granteeIds names 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

T-R7.4 β€” Share-aware access in tests, questions and assignments

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.go in 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, or scope.all - edit: owner, an edit grant, or scope.all - owner-only: owner or scope.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) or SHARE_OWNER_ONLY.

  • Test, TestSummary, QuestionSet and Question (read by id) gain required access: owner | edit | use and owner {id, name}. scope.all reads as owner, and the web tells ownership from owner.id.

  • listTests gains include=shared, which adds tests shared with the caller, with access and owner, for the wizard. The Tests page and the bank list show the caller's own rows for everyone, admins included (70 Β§4.1's scope.all bullet, decided in R4; DG-53).

  • An edit grantee's publish records them as published_by; the owner stays the author (DG-90). The owner receives test.published_by_collaborator in-app (no switch): {editorName, title, version}, targeting test detail.

  • createAssignment accepts 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, TestLockForDraftUseSerialisesAgainstDelete and TestLockForVersionUseSerialisesAgainstDelete pass untouched - the tests and questions suites run before and after, with both runs in the PR

  • make gen is its own commit. The web call sites typecheck in the same PR.


T-R7.5 β€” Co-editing: provenance, stale writes and the owner's bank

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 409 STALE_WRITE details gain {updatedBy: {id, name} | null, updatedAt}, on the test and on group mutations.
  • updateQuestion gains an optional expectedUpdatedAt. A mismatch answers 409 STALE_WRITE with the same details, and every question write sets questions.updated_by. The field becomes required in R8 (T-R8.1). The web sends it from this PR on.
  • createQuestion and duplicateQuestion gain an optional forTestId. - The actor needs edit access to that test. - The new question's owner_id is the test's owner, and created_by is the actor. - Without the field, the owner is the actor, as today. - Body /forTestId declares x-resource kind test (T-R2.16's per-parameter form).
  • The draft ownership invariant stays R2's rule. After questions.LockForDraftUse, the outline write asks QuestionLocks.NotOwnedBy and 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: forTestId here, and question-copies in 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 and scope.all edit in place, as today.
  • Tests: - two sessions, the owner's and a grantee's, autosave one test; the second gets STALE_WRITE naming the first - forTestId without edit access gets 404 or 403 - a grantee's question created with forTestId is accepted by R2's owner check, and one created without it still gets R2's 404 - a v0.13.0-style updateQuestion without expectedUpdatedAt still succeeds

T-R7.6 β€” Copy-on-write for shared bank questions

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 and scope.all edits 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 the QuestionInput body and expectedUpdatedAt. 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 and created_by the actor, through a QuestionWrites port typed as the questions repository call that takes the transaction 5. questions.LockForDraftUse(fork), then re-point this test's test_section_questions rows from the original to the fork 6. bump tests.updated_at and updated_by
  • Set context: updateQuestion gains setId. 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} declares question, beside {id} as test, and updateQuestion's body /setId declares question-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.go passes 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_by B), 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.go drives B's fork and A's delete of Q concurrently, as TestLockForDraftUseSerialisesAgainstDelete does. No draft ever points at a deleted question, and it fails within two attempts without the lock.
  • TestLockForDraftUseSerialisesAgainstDelete and publish_snapshot_test.go pass untouched. The five canaries run before and after, with both runs in the PR.

T-R7.7 β€” Make a copy, question copies and the cross-owner media guard

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 with Test. - 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 way duplicateTest names 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.LockForVersionUse before its insert.
  • The copy path calls questions.LockForDraftUse before each insert into test_section_questions, whether the copy is a whole test or question-copies adding to a draft. TestLockForDraftUseSerialisesAgainstDelete passes 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 /questionIds on question-copies and on the set copy declares x-resource kind question (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 TestLockForVersionUseSerialisesAgainstDelete does
  • 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.

T-R7.8 β€” Sharing: isolation, escalation, transfer and disabled accounts

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, setId or forTestId, or A's question in questionIds, answers 404.
  • An assignment B made during the grant keeps its monitor, grading and results after the revocation.
  • Escalation cases: - a teacher without content.share cannot grant (the middleware answers 403) - a grantee cannot PATCH or DELETE /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, and moved gains questionSets - 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.go gains 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 (e2e tag) runs a share, then a co-edit with a fork, publish, revoke and copy through HTTP.

T-R7.9 β€” Migrations: messaging

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 β†’ users RESTRICT - 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 from messages stops it deleting a sent file - Down drops the table
  • create_conversations: - columns: - id - kind text CHECK (kind IN ('class','direct')) - class_id β†’ classes RESTRICT - staff_id and student_id β†’ users RESTRICT - created_at - last_message_at timestamptz NOT NULL - CHECKs: - a class conversation has only class_id - a direct one has only staff_id and student_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), both WHERE kind = 'direct' - the app role gets SELECT, INSERT and UPDATE (last_message_at) - RESTRICT on class_id means a class whose channel has posts is never hard-deleted; the delete answers 409 with referencedBy: messages (below) - Down drops the table
  • create_messages: - columns: - id uuid PRIMARY KEY DEFAULT uuidv7() - conversation_id β†’ conversations RESTRICT - sender_id β†’ users RESTRICT - client_id uuid NOT NULL - body text (1–4000 after trim) - attachment_id β†’ message_attachments RESTRICT, 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 RESTRICT FKs join T-R2.13's constraint-name map with a new referencedBy value, 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_fkey and message_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 409 referencedBy: messages, never a 500
  • constraints_test.go covers every CHECK and both partial unique indexes.
  • privileges_test.go asserts that the app role cannot UPDATE or DELETE messages, and that quizzivy_maintenance can delete from all four tables and write nothing else there.
  • goose up/down/up passes.
  • 20-data-model.md records the tables, append-only messages as a deviation row beside attempt_events, and why attempt-event cards are not rows.

T-R7.10a β€” Messaging: the module, eligibility and the staff side

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 whose teacher_id is 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.all does not widen this. - A class channel is readable within class scope (teacher_id or scope.all). Posting also needs teaching.classes.write. An archived class answers 409 CLASS_ARCHIVED on 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 409 MESSAGING_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. - preview is at most 120 characters, or "Attachment Β· {filename}". - q matches names and class names, accent-insensitively. - GET /teacher/messages/students/{studentId} and GET /teacher/messages/classes/{classId} (workspace.teacher, ?before=&after=&limit= up to 50) return {items: [StaffMessage], nextBefore, latestId}, where StaffMessage {id, sender {id, name, avatarUrl, side: staff|student}, body, attachment, createdAt, mine, seenBy?}. - seenBy {seen, of} appears on class posts: of counts the active student members, and seen those whose read pointer has passed the post. - POST /teacher/messages/students/{studentId} (workspace.teacher) and POST /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 when clientId repeats. - The first post creates the conversation (INSERT … ON CONFLICT DO NOTHING on the partial unique index). - Each post bumps last_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 by ratelimit.PrincipalKey.
  • Posts, reads and attachment uploads write no audit row (decision above).
  • Every operation joins R2's isolation suite. The paths name existing x-resource kinds (student, class), so another teacher's ids already answer 404 by rule. Body /attachmentId declares attachment, 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 - before and after paging - seenBy arithmetic - an archived class - the channel list with no posts

T-R7.10b β€” Messaging: the student side and attempt-event context

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_id of 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/threads returns direct threads and the channels of the student's classes that have at least one post. subtitle is "Your teacher Β· {class names}" or "Class announcements". - GET /app/messages/teachers/{teacherId}, and POST on 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().
  • StudentMessage is its own Go type and its own schema: {id, sender {id, name, avatarUrl}, body, attachment, createdAt, mine}. It has no seenBy, 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. - at is coalesce(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.
  • MyClass gains teacherId, 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, contextEvents and 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, kind class), and cannot open a thread with a teacher of a class they left. teacherId is 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.

T-R7.11 β€” Message attachments

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/filetype holds content sniffing for PNG, JPEG, WebP and PDF (%PDF- at offset 0). Media's SniffImage becomes 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 with x-max-body-bytes at 20 MiB plus form overhead. It returns 201 with {id, filename, bytes, mimeType}. - The type is sniffed from the content: filetype for images and PDF, platform/probe for MP3 and M4A. Anything else answers 415. - PNG and JPEG pass image.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 is BoundedCopy-style: more than 20 MiB answers 413. - The object is stored at messages/{uuid}.{ext}, and the filename is sanitised as media does.
  • GET /teacher/messages/attachments/{id} (workspace.teacher) and GET /app/messages/attachments/{id} (learning.take_tests) answer 302 to a presigned GET valid five minutes. - The URL sets response-content-disposition: attachment; filename*=UTF-8''… and the sniffed response-content-type. - The 302 carries Cache-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 under messages/ 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} as x-resource kind attachment, the kind T-R7.10a added for the posts' body /attachmentId.
  • Tests: - an executable renamed .pdf answers 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.

T-R7.12 β€” Unread counts, in-app message items, the D17 export and anonymisation

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/summary and GET /me/summary gain unreadMessages: 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 on msg:{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 for message.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) gains messages_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-user step, run as quizzivy_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 unreferenced messages/ 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", gains question_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.txt describes messages_summary.csv in 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.go case: a disabled account that owns only a question set is skipped

T-R7.13 β€” The email channel: preferences, templates and event emails

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_events replaces R4's event CHECK (T-R4.10a, under the constraint name R4 gives it) with one that adds message.received and summary.weekly, NOT VALID then VALIDATE. 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.ready when 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_outbox through email.Enqueue, with a template name, ids in params, a dedupe_key and a deliver_after. R5 already renders at send time from template and params. - R7 adds an optional per-template Prepare(ctx, params) (params, send bool, err) that R5's dispatcher calls before rendering. - It may refresh params from 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, or NOTIFY_EMAIL_EVENTS excludes the event. A dropped row is closed with failed_at and last_error_code = 'SKIPPED', so R5's housekeeping prunes it like any other. - R5's templates have no Prepare and are unchanged.

  • R5's daily budget (EMAIL_DAILY_LIMIT) still moves excess rows to the next local midnight. Prepare drops 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's templates_test.go covers them: both locales, missingkey=error, and escaping. - Each is chosen by the recipient's users.locale, with vi as the default. - Links go to app routes on R5's APP_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.example documents 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 Prepare is closed as SKIPPED and never sent - golden renders in vi and en for every new template


T-R7.14 β€” The email scheduler: due soon, closing, released results, weekly summary

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_close result 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's IMPORT_WORKER_IDLE_POLL precedent. - 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 in 20-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 keys due:{assignmentId}:{studentId}:{24h|1h}, closing:{assignmentId}:{teacherId}, result:{attemptId} and weekly:{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

T-R7.15 β€” Bounces and complaints: a signed webhook and a suppression list

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_suppressions creates app.email_suppressions. Down drops it. - Columns: - address text PRIMARY KEY, lower-cased and trimmed, checked by CHECK (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 no x-permission) receives Resend's webhook. - It verifies the Svix signature Resend signs with: svix-id, svix-timestamp and svix-signature, HMAC-SHA256 over {id}.{timestamp}.{body} with the decoded RESEND_WEBHOOK_SECRET, a five-minute timestamp tolerance, and a constant-time compare. Any failure answers 401 with an empty body. - x-max-body-bytes is 64 KiB. - A hard bounce (email.bounced) or a complaint (email.complained) inserts the recipient address with ON CONFLICT DO NOTHING, idempotent on svix-id. Every other event type is accepted and ignored. - A verified delivery always answers 204 with no body. - Without RESEND_WEBHOOK_SECRET the 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_SECRET is a Fly secret, and the deploy.yml preflight names it. .env.example and docs/setup/dns.md say where the Resend dashboard shows it and which events to subscribe to.
  • Tests: - signature: valid, wrong secret, stale timestamp, tampered body - a replayed svix-id is a no-op - a suppressed address is skipped by the dispatcher - the rate limit - 501 without the secret

T-R7.16 β€” The share dialog and the test entry points

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.share only.
  • 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.

T-R7.17 β€” Shared with me

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.

T-R7.18 β€” Access modes in the builder, test detail and the wizard

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 with content.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 through question-copies - question edits go through PATCH /teacher/tests/{id}/questions/{questionId} - on forked, the editor and the outline swap to the new id and adopt testUpdatedAt as the autosave base, and the toast reads "Saved as a new copy of this question, because {owner} uses the original elsewhere."
  • STALE_WRITE names 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.tsx and the publish-flush test pass untouched - read-only mode never calls updateTest, 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.

T-R7.19 β€” Question sets in the bank, the set page and the question editor's access

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 with setId and expectedUpdatedAt; on a fork, the URL switches to the fork's id and a toast says why - STALE_WRITE names 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.

T-R7.20 β€” Teacher Messages

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 with clientId, and a failed send shows Retry
  • Data: - the open thread polls ?after= every 10 s through useIdlePolling - the list refetches when the summary's unreadMessages changes - 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_ALLOWED replaces 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.

T-R7.21 β€” Student Messages and the shell's Messages button

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.ts turns 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.

T-R7.22 β€” Message and sharing entry points, nav and palette

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-permission of the list operation it opens, and teacher-nav.test.ts passes.
  • 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.

T-R7.23 β€” Notification settings: the Email column, new rows and new kinds

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.received goes to the thread - content.shared goes to Shared with me - test.published_by_collaborator goes to test detail - summary.weekly goes to the dashboard
  • The Email column gains the three kinds D4 raises in app (74d T-D4.31): ai.allowance_warning (the teacher who crosses their warning percent, once a month), ai.credit_request (every ai.credits.manage holder) and ai.credit_request.decided (the requester). Each is enqueued with the in-app item, on the terms of content.shared in T-R7.13: no recipient switch, a vi and an en template, 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.

T-R7.24 β€” Seed and live E2E

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_IN and SHARE_0 - a question set - direct threads and class posts shaped like CONVS and MSGS - a flagged attempt for the context card
  • sharing.live.spec.ts runs 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 no seenBy, contextEvents or member counts.
  • pnpm e2e:live passes.

T-R7.25 β€” Docs

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/sharing and features/messages - Non-negotiable: messages joins attempt_events and audit_log as append-only, and a direct message is readable by its two participants only, scope.all included - High-risk: the draft ownership invariant and copy-on-write under LockForDraftUse - 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-events as the twelfth, and D9 names the webhook, RESEND_WEBHOOK_SECRET and app.email_suppressions. Any disagreement is corrected there.
  • 20-data-model.md is complete for R7, with its deviations. 60-backend-architecture.md lists the new modules.
  • 30-risks.md records the PR-7 measurement plan. 40-open-items.md gains this file's open items as O-R7 entries.
  • gaps.md notes 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.md notes NOTIFY_EMAIL_EVENTS and the webhook's secret and events.

T-R7.26 β€” Browser verification against the deck

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-deck on 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.

T-R7.27 β€” Release v0.14.0

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.

Release checklist

  • Every task is ticked on work/redesign-r7. develop is 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 TestLockForDraftUseSerialisesAgainstDelete pass untouched, and builder/autosave-unmount.test.tsx passes.
  • 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_SECRET is set as a Fly secret, and the Resend dashboard sends bounce and complaint events to https://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_EVENTS is 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 to develop, and tag v0.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.expectedUpdatedAt required (T-R8.1).
  • Neon compute hours are measured for the week after the release and recorded against NFR-A10 (PR-7).

Open items

  • 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, where Prepare drops 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 through NOTIFY_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.

⚠️ **GitHub.com Fallback** ⚠️