Plan 75 R5 - SekiroKenjii/Quizzivy GitHub Wiki

R5 — Admin console and email (v0.12.0)

The release that gives /admin/* to the Admin console the deck draws in docs/design/deck/Quizzivy Admin.dc.html, and that brings email. It is the first release in which a second teacher can be created from the product (70 §2 rule 2), so it runs after R2 (72-r2.md: roles, permissions, the principal cache, ownership scoping and the subset, student-target and last-admin guards) and after R4 (74-r4.md: the console shell and composites, in-app notifications, profile and preferences, platform/tabular, platform/imagesafe, platform/uaparse, and the rotation of every legacy join code).

The Admin deck has no hash routes: its screen is the prototype's route state (overview, users, roles, classes, audit, apiref, settings), set for comparison through localStorage['qz-admin'] = {"route":"users","dark":false}. Design gaps are cited by DG id from docs/design/gaps.md. Outside the console, the teacher and student sides change only where this file says so: the workspace switcher gains its "Admin console" row, class screens say when joining by code is off, a removed answer reads as removed, and the Students page's "Last active" becomes true.

Resend lands here with its first consumers (70 §2 rule 6): set-password links (D15), sign-in invitations and the data-export notice. The notification platform keeps its in-app channel until R7 adds email to it.

Deliverable: the Admin console (AdminLayout, Overview, Users with the user sheet, Add user, import and bulk actions, Roles & permissions, Classes across teachers, Audit log, API reference, System settings with Organization, Sign-in, Data & privacy and Plan & usage) and the backend it needs: the organization, audit and exports modules, admin user and role management under the subset rule, activity tracking, content transfer for offboarding, user-import and data-export jobs in the worker, platform/email with Resend and the outbox, set-password links, the sign-in policy with lockout validation, and the nightly retention sweep under its own database role. The primitives take the deck's geometry as their default.

Exit criteria:

  • every /admin/* route matches the deck at 360, 768, 1024, 1280 and 1440, in light and dark, with the sidebar expanded, collapsed and as a drawer (T-R5.33);
  • an admin creates a teacher with an emailed set-password link; the teacher sets a password from the email and signs in to an empty workspace that shows nothing of anyone else's (admin-console.live.spec.ts, and once on production with a real Resend message whose headers show SPF, DKIM and DMARC passing);
  • the escalation flow test is green: no holder of a People or System key can act on, create or grant a role that is not a subset of their own, edit their own role, demote or disable the last Admin, or save a sign-in policy that locks the Admins out;
  • a matrix edit, a role change or a disable is honoured on the target's next request to the machine that made it, and within 10 seconds on any other (R2's principal cache, 70 §4.2);
  • the audit log's first page uses audit_log_recent_idx on a Neon branch of production, and the retention sweep's dry run there reports the same integrity-event count as today's retain-integrity and no answers or accounts (the defaults are O-23's rules);
  • no data-scale attribute or scoped variant is left in web/src;
  • every CI step of 70 §8 is green locally, including pnpm e2e:live; released as v0.12.0.

Schema changes (numbers assigned at merge, from 00128, the first free number when work/redesign-r5 was cut from develop at b64b4940; each new column on a populated table is nullable, so the D4 binary (v0.11.0) keeps inserting during the rolling deploy without a fill trigger):

File Task
NNNNN_require_encrypted_join_codes.sql T-R5.1
NNNNN_create_email_outbox.sql T-R5.2
NNNNN_create_set_password_tokens.sql T-R5.3
NNNNN_add_users_activity.sql T-R5.4
NNNNN_create_user_activity_days.sql T-R5.4
NNNNN_create_organization_settings.sql T-R5.5
NNNNN_grant_role_writes.sql T-R5.8
NNNNN_index_audit_log_recency.sql (-- +goose NO TRANSACTION, CONCURRENTLY) T-R5.12
NNNNN_create_user_import_jobs.sql T-R5.14
NNNNN_create_user_import_rows.sql T-R5.14
NNNNN_create_data_exports.sql T-R5.15
NNNNN_grant_maintenance_role.sql T-R5.17a

Order of work. The backend tasks (T-R5.1–T-R5.19, and T-R5.36 before T-R5.11) run first, at most four agents at a time; T-R5.2 (email) and T-R5.5 (organization) unblock most of the rest. AdminLayout (T-R5.20) needs only R4's shell. Each screen task waits for the layout and for the API task it names. The defaults flip (T-R5.30) waits for the layout, because it is the last console built. Close-out (T-R5.31–T-R5.34) comes last. Branches are feature/t-r5-<nn>-<slug> off work/redesign-r5.

Decisions taken in this file, worth a second look.

  • Credentials leave the server in one of two ways, chosen per action. Add user, Reset password (one or many) and user import each offer "Email a set-password link" (the default when email is configured) or "Show a temporary password" to hand over in person. D15 forbids a password in an inbox, not on a slip handed to a child in class. The deck draws this toggle only on the import dialog ("Email sign-in details"); the same toggle is added to Add user and Reset, with the wording DG-05 asks for. A temporary password is never written to the outbox, a log or object storage.
  • The set-password token travels in the URL fragment, and using it signs the user in. …/set-password#token=… never reaches a server log, a proxy or a Referer, and the page removes it from the address bar before its first request. Only the SHA-256 of a 256-bit token is stored; the raw token exists only as outbox ciphertext until the message is sent. Every invalid state answers one identical 410. Setting the password returns a session the way sign-in does, since the link has just proved control of the mailbox, and ends the account's other sessions, since a reset should.
  • Resend over net/http, with no SDK. Sending is one authenticated POST carrying an Idempotency-Key (the outbox row id). That is about a hundred lines, against a new dependency.
  • Email is text-first: no images, no tracking, click tracking off in Resend. Gmail does not render SVG and many clients block remote images; click tracking would pass the set-password token through Resend's redirector. The organization's name heads every message.
  • The outbox drains from both processes, within a daily budget. The API and the worker each run a dispatcher woken by their own enqueues and by a timer armed only while a row is pending, so an empty outbox never wakes Neon (#134, #143). EMAIL_DAILY_LIMIT (default 100, Resend's free tier) moves the excess to the next local day, and the import dialog says so before a large send (O-R5.1).
  • Heavy work runs in the worker; parsing stays in the API. An import preview parses at most 5 MiB and 2,000 rows under a one-slot semaphore and writes nothing. The accepted rows, not the file, are stored; the worker creates accounts and hashes passwords under MAX_CONCURRENT_PASSWORD_HASHES. Data exports are written to the worker's disk, then to the private bucket. Both jobs wake the worker through IMPORT_WORKER_WAKE_URL, as imports do.
  • The hand-out sheet is assembled at download, once. Each row's temporary password is sealed with a SECRETS_KEY-derived key as its row commits, so a worker crash loses nothing. The first download decrypts, streams and erases them, and after 24 hours nothing is readable.
  • A data export is fetched from an app page, by its requester, once. The email links to /admin/exports/{id}, never to an object. The download needs the requester's session and system.data_export, returns a presigned URL valid for 60 seconds, and a second request answers 410. The deck's editable "Send the link to" becomes the requester's own address, read-only: a notice about a full export of personal data has no reason to go anywhere else (O-R5.3).
  • The nightly sweep runs from GitHub Actions under a new quizzivy_maintenance role. Fly secrets are visible to every process group, so the API would see a credential stored there. The role holds only what cmd/maintenance needs, including R1's window commands (with R4's override close times) and R2's join-code rekey, so the owner credential leaves routine operations. A release that adds or widens a command grants exactly what it needs in its own migration and pins it in privileges_test.go (70 D16). The rekey runs from the operator's machine with both join-code keys and MAINTENANCE_DATABASE_URL, never from the workflow. The sweep wakes Neon once, at 03:00 local, and scheduled runs apply only once RETENTION_SWEEP_ENABLED is set after a reviewed dry run.
  • Ending access is one rule. Every password reset (single, bulk, reset-link issue and link use), disable, sign-out-everywhere and role change does three things in one command: it revokes the user's refresh families, bumps session_epoch and calls Principals.Forget(userID). setRolePermissions and updateRole call Principals.ForgetAll(). The change applies on the next request to the machine that made it and within 10 seconds on any other (R2's cache).
  • The server enforces a temporary password. While must_change_password is set, RequirePermission answers 403 PASSWORD_CHANGE_REQUIRED to every operation except getCurrentUser, changePassword, logout and refreshSession; the flag travels in the cached principal. R2 deferred this to R5; it is the default pending Thuong (O-R5.4).
  • deleteUser stays student-only and has no console control. Offboarding is disable, then transfer (R2 left the widening to R5).
  • Built-in role names render from role.builtin.<key> keys while the stored name is still the seeded one, else as stored. So an English matrix reads "Teacher" until an Admin renames the role (R2 left the English rendering to R5). The seeded name, not the revision, decides, because R2's trigger bumps the revision on every grant change.
  • The retention defaults are O-23's rules. Focus-loss events keep 13 months, which the deck's 6 / 12 / 24 cannot express, so "13 months (default)" is a fourth option (DG-07). Answers are kept forever and disabled accounts are never anonymised. Production behaves as it does today until an admin changes a select (70 §6).
  • Removing answers keeps scores and comments. The sweep replaces attempt_answers.payload with {"removed": true}; auto_score, manual_score and the grader's comment stay, so no result or average moves ("Scores are kept forever. This only deletes the answers.").
  • Plan & usage values are deploy configuration. PLAN_* values in fly.toml are shown and not enforced (DG-55). A column the app cannot write would need a second write path for values that change once a year.
  • Transferring content requires a disabled source. Moving a live teacher's drafts while they autosave would race the rule that a draft references only its owner's questions. Offboarding is disable, then transfer; moving one class under an active teacher is Change teacher.
  • The console reuses the teacher workspace's class operations. Edit, rotate, archive, restore and adding members call /teacher/classes/*, which an Admin may call on any class through the wildcard and scope.all. Only the cross-teacher list, creating a class for a teacher and changing its teacher are /admin/* operations. Each rule has one implementation, and R2's isolation suite already covers them.
  • Assistant is shown, never assignable, until DG-04 is drawn. Its card (0 people) and matrix column appear as the deck draws them, and its grants are editable under the subset rule (D2). Every place a role is picked leaves it out (Add user, the user sheet, bulk actions, import role values and New role's "Copy permissions from"), and the API refuses it there (422 ROLE_NOT_ASSIGNABLE), as D14 says. Without class staff, an Assistant would be a teacher with fewer keys, which D14 rules out.
  • Two role reads, one key each. GET /admin/roles (people.roles.manage) feeds the matrix; GET /admin/roles/assignable (people.users.manage) feeds the pickers and returns only roles the subset rule lets the caller assign, never Assistant, so no picker offers what the server would refuse.
  • Per-actor limits use R4's principal-keyed limiter. Every limit this file gives "per actor" is registered in PrincipalRateLimits() and keyed by ratelimit.PrincipalKey, the limiter R4 adds after RequirePermission; "per IP" and "per token" limits stay in RateLimits(), which runs before authentication.
  • The organization logo is served by the API, not by a signed URL. Bytes carry a content-hash version for immutable caching, Content-Disposition: attachment and a sandbox CSP. An SVG is checked against an allowlist and refused, not rewritten, when it fails. img-src gains the API origin.
  • Audit labels are resolved when read. No label column joins the append-only log: a name frozen there could never be anonymised (D16). An entry about a deleted row shows its type only.
  • The audit tabs show counts, and the counts follow the search and the range. The deck's search and date control are static, so it does not say what its badges count. They follow the contract's facet rule (TestStatusFacets): the current search and range, whichever tab is selected. all includes the entries only the All tab lists, so it can exceed the sum of the six families, as the deck draws (1,248 against 132). Decided 2026-10-03, with the deck re-import; listAuditLog had no facets before (T-R5.12, T-R5.26).
  • Lockout includes the actor. A sign-in policy is refused when the user saving it, or every active Admin, would be left without a way in. Google counts only for accounts already linked in an allowed domain. The page asks the server for the impact before it offers Save.
  • Home is the last workspace used. A built-in Admin lands on /admin the first time and afterwards on the workspace they last switched to (preferences.workspace); the owner teaches most days.
  • The front door's organization lines fall back to the build values. Each line reads the Organization settings and falls back to R1's VITE_ORG_* values, so Forgot password keeps the front-desk phone between the deploy and the first save. The VITE_ORG_* build values stay. They are the Landing's contact source (R11), and the front door falls back to them when the API is unreachable.
  • availability stays its own module. R1 left organization free to absorb it; nothing is gained by merging a read-only snapshot into a module with writes, logos and policy.

T-R5.1 — Contract steps owed by R4

Depends on: D4 (v0.11.0) in production Touches: migrations/NNNNN_require_encrypted_join_codes.sql (new), server/internal/modules/classes/{domain/joincode.go,repositories/joincodes.go,repositories/lookup.go,application/query/preview.go}, server/internal/modules/classes/repositories/tests/, api/openapi.yaml (getDashboard), server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/dashboard/{domain/readmodel.go,repositories/postgres.go}, docs/plan/20-data-model.md Size: S Done when:

  • Before the first commit, a Neon branch of production shows no join code that is unrevoked, unexpired and still reachable only through the legacy SHA-256 lookup (R4's T-R4.22 rotated them at the v0.10.0 start-up). The query and its result go in the PR.
  • The legacy lookup branch is deleted from preview, join and Google-with-code: a code is found only through R2's keyed lookup. The legacy-path unit tests are replaced by one showing that a revoked legacy row never matches.
  • NNNNN_require_encrypted_join_codes.sql, in one transaction: a DO block raises if any unrevoked, unexpired code lacks R2's ciphertext column (named as in 72-r2.md); unrevoked legacy codes that have already expired get revoked_at = expires_at; then ADD CONSTRAINT class_join_codes_live_encrypted CHECK (revoked_at IS NOT NULL OR code_ciphertext IS NOT NULL) NOT VALID and VALIDATE CONSTRAINT in the same file (a small table; VALIDATE takes SHARE UPDATE EXCLUSIVE and blocks no read or write). Down drops the constraint and leaves the revocations.
  • The getDashboard fields R4 lists (T-R4.19 and its hand-off) as no longer read by the R4 SPA leave the contract; the web call sites change in the same PR (pnpm typecheck). When R4's list is empty, this bullet and the dashboard files leave the task.
  • 20-data-model.md records the constraint; integration test: an insert of an unrevoked code without ciphertext fails.

T-R5.36 — Remove the /admin legacy alias (T-R3.3)

Depends on: seven days of production legacy_admin_path log counts, read by Thuong (O-R4.22). The owner moved T-R3.3 here on 2026-10-12 (74-r4.md T-R4.49): v0.10.0 shipped T-R3.1 and T-R3.2 and kept the alias, and R5 removes it when it gives /admin to the Admin console. Before: T-R5.11. GET /admin/classes and POST /admin/classes are two of the alias's patterns (legacy_admin_paths.go), and the alias wraps the mux, so while it stands it rewrites listAllClasses and createClassForTeacher to /teacher/classes before the router sees them. No other R5 operation shares a pattern with it. Touches: as 73-r3.md T-R3.3: server/internal/core/router/{legacy_admin.go,legacy_admin_paths.go} (removed), server/internal/core/router/tests/legacy_admin_test.go (removed), server/internal/core/router/tests/unrouted_test.go (TestTheLegacyAliasKeepsTheSameAnswers removed), server/internal/core/router/router.go (the alias wiring), server/internal/core/router/ratelimits.go, server/internal/core/router/tests/router_test.go, AGENTS.md, docs/setup/deploy.md (the v0.7.0 tabs paragraph) Size: S Done when: 73-r3.md T-R3.3's boxes, unchanged:

  • The PR records the number of legacy_admin_path log lines in production over the seven days before merge, and the paths seen. Traffic that is not a stale browser tab, such as a script or a bookmarked API path, is named and dealt with before the alias goes.
  • The handler (legacy_admin.go), its pinned path list (legacy_admin_paths.go) and its test (legacy_admin_test.go) are removed together, and router.go no longer wraps the mux with it. The PR says why the test goes: the behaviour it pinned is removed on schedule, and the test is passing, not failing.
  • A former teaching path under /admin/ now gets the same 404 as any path the contract does not declare. router_test.go covers it.
  • RateLimits() holds no key for an aliased path. The test that every credential-minting operation has a limit still passes.
  • The R2 bullet in AGENTS.md's "Redesign in progress" no longer mentions an alias, and the v0.9.1 bullet beside it leaves with the last of T-R3.1 to T-R3.3. The composition-root table and "Authentication and authorization" lose their alias lines too.
  • 73-r3.md T-R3.3's boxes are ticked with a pointer to this PR.

T-R5.2 — Email platform: outbox, Resend, templates

Depends on: T-R0.8 (Resend domain DNS, SECRETS_KEY) Touches: migrations/NNNNN_create_email_outbox.sql (new), server/internal/platform/email/{email.go,resend.go,sink.go,render.go,outbox.go} (new), server/internal/platform/email/templates/{vi,en}/{account_invite,password_reset_link,sign_in_invite,data_export_ready}.tmpl (new), server/internal/platform/email/tests/ (new), server/internal/platform/secrets/{secrets.go,tests/} (new), server/internal/platform/config/{config.go,import_worker.go}, server/internal/core/jobs/email.go (new), server/internal/core/{core.go,import_worker.go}, server/internal/core/wiring/email.go (new), .env.example, fly.toml, .github/workflows/deploy.yml, docs/setup/dns.md, docs/setup/deploy.md, docs/setup/operations.md, docs/plan/20-data-model.md Size: L Done when:

  • create_email_outbox: app.email_outbox (id uuid PK DEFAULT uuidv7(), to_user_id uuid REFERENCES app.users ON DELETE SET NULL, to_address text NOT NULL (3–254), template text NOT NULL CHECK (template ~ '^[a-z_]{1,64}$'), locale text NOT NULL CHECK IN ('vi','en'), params jsonb NOT NULL DEFAULT '{}' (object, ≤ 4096 bytes), secret_ciphertext bytea, dedupe_key text UNIQUE, deliver_after timestamptz NOT NULL DEFAULT now(), attempts smallint NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 10), last_error_code text, provider_id text, sent_at timestamptz, failed_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()), CHECK (sent_at IS NULL OR failed_at IS NULL), and a partial index (deliver_after) WHERE sent_at IS NULL AND failed_at IS NULL. The template list is Go's, not a CHECK, so R7 adds templates without a migration. Down drops the table.
  • email.Sender is Send(ctx, Message{To, Subject, HTML, Text, ReplyTo, IdempotencyKey, Tags}) (providerID string, err error), with errors classified as transient or permanent.
  • resend.go posts to https://api.resend.com/emails with Authorization: Bearer, Idempotency-Key: <outbox id> and the JSON body; 429, 5xx and network errors are transient, other 4xx permanent. No new dependency (decision above).
  • sink.go writes each rendered message as a JSON file under EMAIL_SINK_DIR and logs a redacted line; the server refuses to start with it when APP_ENV=production. The live E2E reads links from it (T-R5.31).
  • Config: RESEND_API_KEY (Fly secret), EMAIL_FROM (Quizzivy <[email protected]>) and APP_URL (https://app.quizzivy.com) form one all-or-nothing group, required in production; EMAIL_REPLY_TO is optional; EMAIL_DAILY_LIMIT defaults to 100 and EMAIL_SEND_RATE to 2 per second. Without the group outside production the sink is used. APP_TIMEZONE (default Asia/Ho_Chi_Minh, validated by time.LoadLocation) is parsed here, its first server consumer; every "local day" in this file reads it. The worker's config loads the same group, SECRETS_KEY and APP_TIMEZONE. .env.example, fly.toml [env] and the deploy.yml preflight (group "email" …, need SECRETS_KEY) are updated.
  • platform/secrets (new): HKDF-SHA256 from SECRETS_KEY per purpose (email-outbox, user-import-sheet), AES-256-GCM seal and open, config validation (standard base64 of exactly 32 bytes) and pinned test vectors. R2's classes/domain/joincode_keys.go stays where it is, derived from JOIN_CODE_KEY.
  • email.Enqueue(ctx, q, Message) writes inside the caller's transaction. A secret (a set-password token) is sealed with AES-256-GCM under a key derived from SECRETS_KEY with the purpose email-outbox, with the row id as AAD. After commit the caller signals the in-process dispatcher.
  • operations.md gains a "Secrets key" section: generate it with openssl rand -base64 32, store it as a Fly secret with an offline copy; to rotate, drain the outbox, let pending import sheets expire (24 hours), swap the key and re-issue unsent links; on loss, pending sealed rows fail closed and links must be re-issued; it is never logged.
  • Templates: html/template for HTML and text/template for text, each file defining subject, text and html; vi written first, en second; the organization's short name (T-R5.5) as a text header; no images, remote CSS or tracking; locale from the recipient's users.locale (R4), else vi. Wording: account_invite and password_reset_link say the link works once and for 24 hours; sign_in_invite sends the user to sign in with Google; data_export_ready links to the app page and says the download works once.
  • core/jobs/email.go runs in the API and in the worker. It wakes on the in-process signal, on a timer armed only for the earliest pending deliver_after, and once at start-up. It claims up to 10 rows FOR UPDATE SKIP LOCKED, sends at EMAIL_SEND_RATE, and counts today's sends (local day in APP_TIMEZONE); once the budget is spent, remaining rows move to the next local midnight. Transient failures back off 1 min, 5 min, 15 min, then hourly, up to 10 attempts; a permanent failure or the tenth attempt sets failed_at. Sent and failed rows lose secret_ciphertext in the same statement. Logs carry the outbox id, template, outcome and recipient domain only (NFR-P06).
  • docs/setup/dns.md lists the records Resend's dashboard gives for quizzivy.com (the send subdomain's MX and SPF TXT, the resend._domainkey DKIM TXT) and a DMARC record starting at p=none; open and click tracking are off for the domain.
  • Tests: templates_test.go (every template in both locales; each parses and renders its param struct under missingkey=error; subject, text and HTML are non-empty; a name containing <script> is escaped; the text part contains the link; no {{ survives); resend_test.go against httptest (headers, body, classification); integration outbox_test.go (a rolled-back enqueue sends nothing; two dispatchers never send one row twice; the budget defers; the secret is gone after sending and after failing); a config test that production refuses the sink and a partial group.

T-R5.3 — Set-password links (D15, DG-05)

Depends on: T-R5.2 Touches: migrations/NNNNN_create_set_password_tokens.sql (new), api/openapi.yaml, server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/identity/{domain/setpassword.go (new),application/command/issue_set_password_link.go (new),application/command/set_password.go (new),application/query/check_set_password_link.go (new),repositories/set_password_tokens.go (new),http/set_password.go (new)}, server/internal/modules/identity/application/tests/set_password_test.go (new), server/internal/core/router/ratelimits.go, server/internal/core/router/tests/{auth_middleware_test.go,public_routes_test.go,credential_limits_test.go}, web/src/features/auth/pages/SetPasswordPage.tsx (new), web/src/features/auth/setPasswordSchema.ts (new), web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/auth/set-password.test.tsx (new) Size: L Done when:

  • create_set_password_tokens: app.set_password_tokens (id uuid PK DEFAULT uuidv7(), user_id uuid NOT NULL REFERENCES app.users ON DELETE CASCADE, token_hash bytea NOT NULL UNIQUE CHECK (length(token_hash) = 32), purpose text NOT NULL CHECK IN ('invite','reset'), created_by uuid REFERENCES app.users ON DELETE SET NULL, expires_at timestamptz NOT NULL, used_at timestamptz, revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), CHECK (expires_at <= created_at + interval '24 hours')) and a partial index (user_id) WHERE used_at IS NULL AND revoked_at IS NULL. Down drops the table.
  • IssueSetPasswordLink (a command, not an HTTP operation) generates 32 random bytes (base64url), stores their SHA-256, revokes the user's earlier unused links and enqueues account_invite or password_reset_link with the token as the outbox secret and {APP_URL}/set-password#token=… as the link, all in the caller's transaction; a reset also revokes the user's refresh families, bumps session_epoch and calls Principals.Forget (decision above). When the organization has turned password sign-in off (T-R5.7), an invite issues no link and enqueues sign_in_invite instead, and a reset is refused (422 PASSWORD_SIGN_IN_DISABLED). T-R5.9 and T-R5.14 call it.
  • POST /auth/set-password/check (checkSetPasswordLink, tag auth, security: [], no x-permission) {token} → 200 {email, fullName, purpose, expiresAt}.
  • POST /auth/set-password (setPasswordWithLink, security: []) {token, newPassword}, with R1's password schema → 200 with the sign-in response and the refresh cookie on /auth. One statement claims the token (UPDATE … SET used_at = now() WHERE token_hash = $1 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > now() RETURNING user_id), so two concurrent submits set the password once. It sets password_hash, clears must_change_password, stamps password_changed_at and last_sign_in_at (T-R5.4), revokes the user's other links and refresh families, bumps session_epoch and calls Principals.Forget before it issues the new session (decision above), and audits user.password_set {purpose} as the user. setPasswordWithLink returns an access token, so it joins theCredentialMinters in this PR.
  • Both answer 410 SET_PASSWORD_LINK_INVALID for an unknown, used, expired or revoked token and for a disabled account; setPasswordWithLink answers 422 PASSWORD_SIGN_IN_DISABLED while the organization has password sign-in off.
  • Rate limits in RateLimits(), sized for a class opening its links together behind one school NAT: check 120 a minute and 1,000 an hour per IP plus 20 an hour per token; set 60 a minute and 600 an hour per IP plus 10 an hour per token (JSONFieldKey("token")). AssertPublicRoutesLimited passes; public_routes_test.go covers each 429.
  • Leak review in the PR description: every invalid state returns byte-identical 410 bodies; the lookup is an indexed equality on the SHA-256 of a 256-bit token, so timing reveals nothing usable; the check names only the account the token already controls; the token is never logged, never in a query string and never in a Referer.
  • The open-operation pin in auth_middleware_test.go gains both operations, each with a one-line justification; its name follows its new count.
  • /set-password joins the front-door routes under AuthLayout (70 §5), a single column like Change password. It reads #token=, removes it with history.replaceState before any request, then checks it. States: checking (skeleton); valid ("Đặt mật khẩu cho {email}" / "Set a password for {email}", new and confirm fields with R1's rules); invalid ("Liên kết này đã hết hạn hoặc đã được dùng. Hãy nhờ trung tâm gửi liên kết mới." / "This link has expired or has already been used. Ask your centre for a new one.", and "Đến trang đăng nhập"); passwords off (sign in with Google instead); submitting; a server error. Success enters the session through the existing session store and goes to the user's home. The page is listed for DG-05 sign-off.
  • Tests: domain unit tests (hashing, states); integration (concurrent submits set once; a reissue revokes the earlier link; a disabled user's link is invalid; expiry at exactly 24 hours); router tests (open pin, limits); web unit tests (the fragment is removed before the first request, every state, keyboard). client.refresh.test.ts is green and untouched.
  • A Google-only account can ask for a set-password link from Settings, and the Settings Google card says so (it replaces T-R4.43's settings.googleOnlyTeacher, which only states that Google cannot be unlinked yet).

T-R5.4 — Activity tracking (DG-54)

Depends on: — Touches: migrations/NNNNN_add_users_activity.sql (new), migrations/NNNNN_create_user_activity_days.sql (new), server/internal/modules/identity/{domain/user.go,domain/status.go (new),application/command/stamp_activity.go (new),application/command/{login,google_sign_in,change_password}.go,application/query/{current_user,list_students}.go,repositories/store.go,repositories/activity.go (new)}, R2's principal resolver in server/internal/core/adapters/, api/openapi.yaml, generated code, web/src/features/students/ (R4's Students page), web/src/features/auth/components/StudentSettingsSections.tsx (R3's student Sign-in section, PasswordRow), web/src/features/settings/sections/Security.tsx (R4's Sign-in & security), web/tests/units/student/settings.test.tsx, web/tests/units/auth/settings-sections.test.tsx, docs/plan/20-data-model.md Size: M Done when:

  • add_users_activity: last_active_at, last_sign_in_at, password_changed_at (timestamptz) and last_sign_in_method text CHECK IN ('password','google','set_password_link'), all nullable. The same file backfills last_sign_in_at and last_active_at with the greatest of the user's newest refresh-token created_at, newest attempt started_at and newest audit entry as actor, so accounts used before R5 do not read "Never signed in". Down drops the four columns.
  • create_user_activity_days: app.user_activity_days (user_id uuid REFERENCES app.users ON DELETE CASCADE, day date NOT NULL, PRIMARY KEY (user_id, day)) and an index on (day). Down drops the table.
  • An integration test inserts a user with the v0.10.0 column list and succeeds.
  • "Active" means an authenticated request that day (DG-54). R2's principal resolver calls StampActivity after a successful resolve; an in-process throttle lets a user through at most once per five minutes per instance, and a buffered writer flushes every 30 seconds or at 200 entries, only while entries are waiting, so an idle API writes nothing. The day is the local date in APP_TIMEZONE.
  • Password and Google sign-in stamp last_sign_in_at and the method; set-password stamps set_password_link (T-R5.3); change password stamps password_changed_at.
  • domain.AccountStatus applies DG-54's order: disabled, then must change password, then never signed in (last_sign_in_at IS NULL), then active. The contract exposes it as AccountStatus.
  • R4's Students page shows the true "Last active" (R4's open item). Its Account column gains the deck's "Not signed in" pill (DG-54's precedence) and its Status filter gains "Not signed in", backed by listStudents gaining a neverSignedIn filter on last_sign_in_at IS NULL beside R4's mustChangePassword.
  • User (the getCurrentUser read, /auth/me, self only) gains optional passwordChangedAt. R3's student Sign-in section and R4's Sign-in & security section show the deck's "Changed …" / "Last changed …" line from it, and nothing when it is null; a web unit test covers each.
  • Tests: status order table; 100 requests from one user within five minutes cause one update and one day row; the day boundary at 00:00 in Asia/Ho_Chi_Minh; the backfill against a fixture of refresh tokens, attempts and audit rows.

T-R5.5 — Organization settings and the public organization read

Depends on: — Touches: migrations/NNNNN_create_organization_settings.sql (new), server/internal/modules/organization/{domain,application/{command,query,ports,model},repositories,http}/ (new), server/internal/core/wiring/organization.go (new), server/internal/core/router/{modules.go,server.go,ratelimits.go}, server/internal/core/tests/architecture_test.go, api/openapi.yaml, generated code, server/internal/core/router/tests/{auth_middleware_test.go,public_routes_test.go}, docs/plan/20-data-model.md, docs/plan/60-backend-architecture.md Size: M Done when:

  • create_organization_settings: a singleton app.organization_settings (id boolean PRIMARY KEY DEFAULT true CHECK (id), name text NOT NULL (1–120 after trim), short_name (1–40), website (≤200), contact_email (≤254), front_desk_phone (^[0-9+ ().-]{6,30}$), front_desk_hours (≤120), address (≤300), logo_sha256 bytea (32 bytes), logo_type text CHECK IN ('image/png','image/svg+xml'), logo_dark_sha256, logo_dark_type (the same), google_sign_in, password_sign_in, force_password_change and join_codes_enabled (each boolean NOT NULL DEFAULT true), allowed_google_domains text[] NOT NULL DEFAULT '{}' (≤ 50, lower-case domains), keep_answers_months smallint CHECK IN (12, 24) (NULL = forever), keep_focus_events_months smallint NOT NULL DEFAULT 13 CHECK IN (6, 12, 13, 24), anonymise_disabled_after_months smallint CHECK IN (12, 24) (NULL = never), revision integer NOT NULL DEFAULT 1, updated_by uuid REFERENCES app.users ON DELETE SET NULL, updated_at timestamptz NOT NULL DEFAULT now()), CHECK (google_sign_in OR password_sign_in), the set_updated_at trigger, one row named Quizzivy, and REVOKE INSERT, DELETE … FROM quizzivy_app. The optional text columns are nullable. Down drops the table. 20-data-model.md records a deviation (boolean-key singleton).
  • The organization module (package comment naming its model): the domain Settings with its validation, GetSettings, UpdateSettings (revision guard), and an in-process snapshot refreshed at most every 30 seconds and only when asked (single-flight), invalidated locally on update. It exposes ports.SignInPolicy and ports.Branding, which identity, classes and email consume through core/wiring.
  • GET /admin/settings (getOrganizationSettings, x-permission: system.settings.write) → OrganizationSettings {organization {name, shortName, website, contactEmail, frontDeskPhone, frontDeskHours, address, logo {light, dark}}, signIn {google, password, forcePasswordChange, joinCodes, allowedGoogleDomains}, retention {keepAnswersMonths, keepFocusEventsMonths, anonymiseDisabledAfterMonths}, revision, updatedAt, updatedBy}.
  • PUT /admin/settings (updateOrganizationSettings, system.settings.write) takes the same document and expectedRevision → 200, 409 STALE_WRITE, or T-R5.7's 422 SIGN_IN_POLICY_LOCKOUT. It audits organization.updated with the changed fields.
  • GET /public/organization (getPublicOrganization, tag public, security: []) → {name, shortName, website, contactEmail, frontDeskPhone, frontDeskHours, address, logo {lightUrl, darkUrl}, signIn {google, password}, joinCodes} with Cache-Control: public, max-age=300. Rate limit 120 a minute and 2,000 an hour per IP.
  • Leak review in the PR: the response is the same for every caller and carries only what the front door already prints; no plan, usage, count, person, domain list or retention value.
  • The open-operation pin gains getPublicOrganization with its justification.
  • Tests: validation table; revision conflict; the snapshot serves a second read without a query and refreshes after 30 seconds; the public response has exactly the listed keys; 429.

T-R5.6 — Organization logo: upload and serving

Depends on: T-R5.5 Touches: server/internal/platform/imagesafe/{svg.go (new),png.go} (R4's package), server/internal/platform/imagesafe/tests/, server/internal/modules/organization/{application/command/upload_logo.go (new),application/query/logo.go (new),http/logo.go (new)}, server/internal/core/router/ratelimits.go, api/openapi.yaml, generated code, web/public/_headers Size: M Done when:

  • POST /admin/settings/logo (uploadOrganizationLogo, system.settings.write, multipart, x-max-body-bytes 1 MiB) {file, variant: light|dark} → {sha256, type, width, height}. The object is stored content-addressed at branding/{sha256}.{png|svg} in the media bucket; the logo applies when T-R5.5's PUT references it ("Logo uploaded. Save settings to apply."), so an unsaved upload changes nothing. Rate limit 20 an hour per actor.
  • PNG: sniffed, then image.DecodeConfig before any decode; square within 2%, at least 256×256 and at most 2048×2048, or 415 IMAGE_DIMENSIONS; decoded under R4's semaphore and re-encoded, which drops metadata.
  • SVG (≤ 256 KiB): parsed with encoding/xml and refused (415 SVG_NOT_ALLOWED) on a DOCTYPE or entity, a root other than svg, any element outside an allowlist (svg, g, path, rect, circle, ellipse, line, polyline, polygon, defs, linearGradient, radialGradient, stop, clipPath, mask, symbol, use, title, desc, text, tspan, style), an on* attribute, an href/xlink:href not starting with #, or a style using @import or a url() that does not start with #; a viewBox that is not square within 2% is refused as above. Nothing is rewritten.
  • GET /public/organization/logo (getOrganizationLogo, security: []) ?variant=light|dark&v= returns the bytes with the stored type, X-Content-Type-Options: nosniff, Content-Disposition: attachment; filename="logo.{ext}", and Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox. When v equals the current hash: Cache-Control: public, max-age=31536000, immutable; otherwise public, max-age=300. No logo: 404. Rate limit 300 a minute per IP. Leak review in the PR (public bytes; no key or bucket name). The open-operation pin gains it.
  • lightUrl and darkUrl in T-R5.5's public read carry v; web/public/_headers img-src gains https://api.quizzivy.com.
  • Tests: a PNG declaring 30000×30000 is refused before decoding; a non-square PNG; SVGs with <script>, onload, <foreignObject>, an external href, @import, a DOCTYPE and a billion-laughs entity are each refused; a clean SVG from a design tool (with <style> and gradients) is accepted; the headers; the immutable and short caches.

T-R5.7 — Sign-in policy and lockout validation

Depends on: T-R5.5 Touches: server/internal/modules/organization/application/query/sign_in_impact.go (new), server/internal/modules/organization/application/command/update_settings.go, server/internal/modules/identity/application/command/{login,google_sign_in,link_google,unlink_google,create_student,reset_student_password}.go, server/internal/modules/identity/application/ports/ports.go, server/internal/modules/classes/application/{query/preview.go,command/enrol_new_member.go,command/enrol_existing.go}, api/openapi.yaml, generated code, R4's JoinCodePanel and class list callout in web/src/features/classes/, web/src/lib/i18n/locales/{vi,en}.json Size: M Done when:

  • Identity reads ports.SignInPolicy: password sign-in off answers 403 PASSWORD_SIGN_IN_DISABLED to every password login alike; Google off answers 403 GOOGLE_SIGN_IN_DISABLED to Google sign-in and linking; a non-empty allowed-domain list refuses a Google email outside it with 403 GOOGLE_DOMAIN_NOT_ALLOWED (sign-in, linking and self-join through Google).
  • "Force password change on first sign-in" decides must_change_password for every temporary password issued afterwards (admin create and reset, import sheet, R4's teacher create and reset); existing flags are untouched.
  • "Students can join classes with a code" off makes /join/preview, POST /app/classes/join and Google-with-code behave exactly like an unknown code (status, body and code path). The teacher's class list and join-code card, and the admin Classes page (T-R5.25), show "Trung tâm đang tắt tham gia bằng mã." / "Your centre has turned off joining with a code.". The /join/preview leak review in the PR covers the change.
  • unlink_google refuses to leave a user without a working method under the current policy (409 LAST_SIGN_IN_METHOD).
  • POST /admin/settings/sign-in-impact (getSignInImpact, system.settings.write) {signIn} → {affectedUsers, affectedAdmins, actorLocked, lockout}. A method counts for an active user when the proposed policy allows it: password when password_hash is set; Google when a linked identity's email is in an allowed domain.
  • updateOrganizationSettings recomputes the impact inside its transaction and refuses with 422 SIGN_IN_POLICY_LOCKOUT {affectedUsers, affectedAdmins} when the actor would lose every method or no active built-in Admin keeps one.
  • A policy change reaches other instances within 30 seconds (the snapshot); the runbook says so.
  • Tests: each enforcement path (unit and integration); the three lockout outcomes (actor locked, last Admin locked, allowed); impact counts; join codes off answers byte-identically to an unknown code; web unit test for the class callout; keys vi first.

T-R5.8 — Roles and permissions API

Depends on: R2's access module Touches: migrations/NNNNN_grant_role_writes.sql (new), server/internal/modules/access/{domain,application/command/{create_role,update_role,set_role_permissions}.go (new),application/query/{role_matrix,assignable_roles}.go (new),repositories,http} (R2's module), server/internal/modules/access/application/tests/, server/internal/platform/db/tests/privileges_test.go, api/openapi.yaml, generated code Size: M Done when:

  • grant_role_writes: GRANT INSERT, UPDATE (name, description, icon, color, revision, updated_at) ON app.roles and GRANT INSERT, DELETE ON app.role_permissions to quizzivy_app. app.permissions stays read-only and DELETE on app.roles is never granted (DG-52). R2's role_permissions_bump_revision trigger updates roles.revision as the invoker and relies on this grant. users_last_admin does not: its function is SECURITY DEFINER (20-data-model.md §30), so its FOR NO KEY UPDATE on the Admin row runs as quizzivy_migrate. Down revokes exactly these. privileges_test.go gains a case for each grant and for the absent DELETE.
  • GET /admin/roles (listRoles, people.roles.manage) → {roles: [RoleSummary {id, builtinKey | null, name, description, icon, color, custom, userCount, permissions: PermissionKey[], revision, editable, inProgressAttempts}], catalogue: [{key, group, adminLocked}]}. The catalogue is the matrix rows of 70 §4.1 in order; hidden keys are omitted. Assistant is listed, as the deck draws it (D2); only the pickers leave it out (DG-04, D14). editable is false where the subset rule stops the caller.
  • GET /admin/roles/assignable (listAssignableRoles, people.users.manage) → [{id, builtinKey | null, name, icon, color, studentTarget}]: roles whose permissions are a subset of the caller's, without Assistant. studentTarget applies R2's strict predicate.
  • POST /admin/roles (createRole, people.roles.manage) {name (1–60), description (≤300), copyFromRoleId} → 201 RoleSummary, with icon key-round and colour gray; 409 ROLE_NAME_TAKEN (case-insensitive); the copy source must pass the subset rule, and Assistant as the source answers 422 ROLE_NOT_ASSIGNABLE.
  • PATCH /admin/roles/{id} (updateRole, people.roles.manage) {name?, description?, icon?, color?, expectedRevision} → RoleSummary; icons are the Edit role dialog's twelve, colours its seven (contract enums matching R2's CHECKs); 409 STALE_WRITE; the target must pass the subset rule.
  • PUT /admin/roles/permissions (setRolePermissions, people.roles.manage) {changes: [{roleId, key, granted}] (1–200), expectedRevisions: [{roleId, revision}]} → {roles}. One transaction; refused whole when any change breaks a rule: an Admin cell other than learning.take_tests (422 ROLE_LOCKED); learning.take_tests off the built-in Student (422 STUDENT_NEEDS_TAKE_TESTS); a hidden or pseudo key (422 KEY_NOT_GRANTABLE); the caller's own role (422 CANNOT_ACT_ON_SELF), except the Admin role's learning.take_tests cell, which grants no power over anyone (DG-51); a role, or a key granted or revoked, that fails access.CanActOn against the caller's permissions, which ignores learning.take_tests (403 TARGET_ROLE_NOT_ALLOWED); a stale revision (409 STALE_WRITE).
  • setRolePermissions and updateRole call Principals.ForgetAll() after commit (decision above).
  • Audits role.created, role.updated (changed metadata) and role.permissions_changed ({granted: [], revoked: []} per role).
  • There is no delete operation for any role (DG-52), and DELETE on app.roles stays revoked.
  • Tests: a unit test per guard; integration: a member's next request sees the new set (R2's revision bump), a stale save, a custom role copied from Teacher, an Admin without Take tests granting it to a custom role, an Admin toggling its own role's Take tests; a role create, edit and matrix save run under SET ROLE quizzivy_app (every other integration test connects as quizzivy_migrate, which hides a missing grant); the catalogue equals the PermissionKey enum minus hidden keys.

T-R5.9a — Admin users API: list, detail, create

Depends on: T-R5.3, T-R5.4, T-R5.8 Touches: server/internal/modules/identity/{application/query/{list_users,get_user}.go (new),application/command/create_user.go (new),repositories/users_admin.go (new),http/users.go (new)}, server/internal/modules/identity/application/tests/users_admin_test.go (new), server/internal/core/router/ratelimits.go, api/openapi.yaml, generated code Size: M Done when:

  • GET /admin/users (listUsers, people.users.manage) ?tab=all|teachers|students|admins|disabled, status=must_change_password|never_signed_in, q, email (exact), page, size (10, 20 or 50) → {items: AdminUserRow[], page, facets {all, teachers, students, admins, disabled}}. Admins are the built-in Admin role, students R2's student targets, teachers everyone else; role tabs include disabled users, as the deck's counts do. Newest first.
  • AdminUserRow {id, fullName, email, avatarUrl, role {id, name, builtinKey, icon, color}, signIn {password, google}, classes {count, firstName | null}, lastActiveAt, status, createdAt}: a student's classes are memberships, anyone else's the classes they teach.
  • q matches app.immutable_unaccent(lower(full_name || ' ' || email)). No index is added; EXPLAIN on 5,000 seeded users is in the PR (§13.6: an index waits for the evidence).
  • GET /admin/users/{id} (getUser, people.users.manage) → the row plus createdBy, classes (≤ 20), ownedContent {classes, tests, questions, media, assignments}, inProgressAttempts, activity (≤ 8: the last sign-in with its method, the user's last audit entries as actor, a student's last submissions and class joins) and can {changeRole, resetPassword, disable, signOut, transfer} computed by the guards.
  • POST /admin/users (createUser, people.users.manage) {fullName, email, roleId, classId?, delivery: email_link | temporary_password} → 201 {user, temporaryPassword?} with Cache-Control: no-store. The role must be assignable (subset rule; Assistant 422 ROLE_NOT_ASSIGNABLE); classId is allowed only for a student-target role and only with scope.all; 409 EMAIL_TAKEN. created_by is the actor. email_link calls IssueSetPasswordLink (invite) and sets no password; temporary_password uses PasswordManager.Temporary() and the organization's force-change rule, and is refused (422 PASSWORD_SIGN_IN_DISABLED) while password sign-in is off. Audits user.created {role, delivery}.
  • Rate limit for createUser: 30 a minute and 300 an hour per actor; R2's credential-minting test lists it.
  • Tests: tab classification with a custom student-like role and an Admin holding learning.take_tests (an Admin is never a student); search with and without diacritics; detail can for each guard; create with each delivery; the subset rule on create.

T-R5.9b — Admin users API: role, disable, enable, sign out, resets

Depends on: T-R5.9a Touches: server/internal/modules/identity/application/command/{change_user_role,disable_users,enable_user,sign_out_user,reset_user_passwords}.go (new), server/internal/modules/identity/{repositories/users_admin.go,http/users.go}, server/internal/platform/email/outbox.go, server/internal/core/router/ratelimits.go, R2's RequirePermission in server/internal/platform/httpx/ and its principal resolver in server/internal/core/adapters/, server/internal/platform/httpx/tests/, server/internal/core/router/tests/credential_limits_test.go, api/openapi.yaml, generated code Size: M Done when:

  • enableUser, signOutUser, resetUserPassword and resetUsersPasswords declare x-permission: people.users.manage, as disableUser and disableUsers do.
  • Every reset (single, bulk, either delivery), disable, sign-out and role change below revokes the user's refresh families, bumps session_epoch and calls Principals.Forget(userID) in the same command (decision above).
  • Every operation applies, in order: the subset rule on the target's role (403 TARGET_ROLE_NOT_ALLOWED; the target is visible in the list, so no 404); not self for a role change, a disable or a password reset (422 CANNOT_ACT_ON_SELF; the actor's own password changes through Change password), while signing oneself out everywhere is allowed; the last-Admin rule (409 LAST_ADMIN). The last-Admin count locks every active Admin row FOR UPDATE in id order first, so two Admins disabling each other at once cannot both succeed.
  • PUT /admin/users/{id}/role (changeUserRole, people.roles.manage) {roleId} → AdminUser. Both roles must pass the subset rule; Assistant is refused; moving a user who owns classes or content to a role without workspace.teacher answers 409 USER_OWNS_CONTENT {counts}. The change bumps session_epoch and calls Principals.Forget, so the target's next request is authorised by the new role on this machine and within 10 seconds on any other. Audits user.role_changed {role: {old, new}}.
  • POST /admin/users/{id}/disable and POST /admin/users/disable (disableUser, disableUsers, people.users.manage; the bulk form takes 1–100 ids and answers {disabled, failed: [{userId, code}]}): set disabled_at, revoke refresh families, bump the session epoch, revoke unused set-password links and cancel the user's queued emails. Audits user.disabled.
  • POST /admin/users/{id}/enable (enableUser) clears disabled_at; audits user.enabled.
  • POST /admin/users/{id}/sign-out (signOutUser) revokes every family and bumps the epoch; self is allowed; audits user.signed_out_everywhere.
  • POST /admin/users/{id}/reset-password (resetUserPassword) {delivery} → {temporaryPassword?}, and POST /admin/users/reset-passwords (resetUsersPasswords) {userIds, delivery} → {items: [{userId, fullName, email, temporaryPassword?}], failed}, both no-store. A link reset clears the password, so the old one stops working at once; either kind revokes sessions. The bulk form takes 1–40 ids for temporary passwords (at most two hashes at a time inside the global semaphore, R4's precedent) and 1–100 for links. While password sign-in is off, both answer 422 PASSWORD_SIGN_IN_DISABLED and the UI hides Reset password. Audits user.password_reset {delivery} per user.
  • Rate limits per actor: single reset 5 a minute and 30 an hour; bulk reset 2 a minute and 10 an hour; disable 30 a minute. The credential-minting test lists both resets.
  • While must_change_password is set, RequirePermission answers 403 PASSWORD_CHANGE_REQUIRED to every operation except getCurrentUser, changePassword, logout and refreshSession; the flag is carried in the cached principal (decision above, O-R5.4). The error code joins the contract; the release notes say an old tab holding a temporary password is sent to change it.
  • Tests: a unit test per guard and per operation; integration: the target's next request answers 401 after disable and sign-out (R2's principal cache honours the epoch), two Admins disabling each other concurrently leave one active, a demoted owner is refused, a link reset stops the old password, partial failure in the bulk forms; the last-admin path (disable and demote) runs under SET ROLE quizzivy_app, so users_last_admin, a SECURITY DEFINER function taking FOR NO KEY UPDATE, is exercised from the app role; a user with must_change_password gets PASSWORD_CHANGE_REQUIRED from a teacher operation and 200 from the four exceptions.

T-R5.10 — Transfer content for offboarding (DG-56)

Depends on: T-R5.9b Touches: server/internal/modules/identity/application/command/transfer_content.go (new), server/internal/modules/identity/application/ports/ports.go, server/internal/core/adapters/content_transfer.go (new), server/internal/modules/{classes,tests,questions,media,assignments}/application/command/transfer_ownership.go (new, one per module), their repositories, server/internal/modules/notifications/ (R4: a new kind), api/openapi.yaml, generated code, server/tests/transfer_flow_test.go (new) Size: M Done when:

  • POST /admin/users/{id}/transfer-content (transferUserContent, scope.all) {toUserId, notify} → {moved {classes, tests, questions, questionGroups, media, assignments}, leftBehind {imports}}.
  • The source must be disabled (409 USER_NOT_DISABLED); the recipient must be active, hold workspace.teacher and differ from the source (422 INVALID_RECIPIENT).
  • One transaction through the shared db context: the adapter calls each module's TransferOwnership(from, to) handler, which moves classes.teacher_id, tests.owner_id, questions.owner_id, question_groups.owner_id, media_assets.owner_id and assignments.created_by, taking row locks in id order. Tests and their questions move together, so a draft still references only its owner's questions. Provenance columns (created_by, uploaded_by, join-code created_by) are untouched; unfinished import runs stay with their creator and are reported.
  • Audits one user.content_transferred {to, counts}; with notify, the recipient gets an in-app content.transferred notification (NotificationKind gains it) targeting Classes.
  • R7 and R10 extend the adapter for question sets, shares, word lists and courses; the command's doc comment says so.
  • Tests: transfer_flow_test.go (e2e tag): every type moves; the recipient lists and edits them in the teacher workspace; the classes' students keep their assignments and results; a third teacher sees none of it; TestLockForDraftUseSerialisesAgainstDelete, TestLockForVersionUseSerialisesAgainstDelete and publish_snapshot_test.go are green and untouched.

T-R5.11 — Classes across teachers API

Depends on: R2 (class teacher_id, encrypted codes), R4 (schedule and room), T-R5.36 (the alias rewrites GET and POST /admin/classes while it stands) Touches: server/internal/modules/classes/application/query/list_all.go (new), server/internal/modules/classes/application/command/create.go, server/internal/modules/classes/application/command/change_teacher.go (new), server/internal/modules/classes/repositories/postgres.go, server/internal/modules/classes/http/admin_classes.go (new), server/internal/modules/classes/application/tests/, server/internal/core/router/ratelimits.go, server/internal/core/router/tests/credential_limits_test.go, api/openapi.yaml, generated code Size: S Done when:

  • GET /admin/classes (listAllClasses, scope.all) ?tab=active|archived, q (class or teacher name), teacher=disabled, page, size → {items: [{id, name, scheduleLabel, room, teacher {id, name, avatarUrl, disabled}, studentCount, joinCode {code, expiresAt} | null, createdAt}], page, facets {active, archived}, disabledTeacherClasses} with Cache-Control: no-store (full codes, D5). It reveals bearer secrets, so it joins theCredentialMinters with a limit of 60 a minute and 600 an hour per actor (getJoinCode's budget).
  • POST /admin/classes (createClassForTeacher, scope.all) {name, teacherId, scheduleLabel?, room?} → 201 Class with a new encrypted code; the teacher must be active and hold workspace.teacher. Audits class.created {onBehalfOf}.
  • PUT /admin/classes/{id}/teacher (changeClassTeacher, scope.all) {teacherId, notify} → Class. Audits class.teacher_changed {teacher: {old, new}}; with notify, both teachers get an in-app class.teacher_changed notification (email waits for R7).
  • Edit, rotate, archive, restore and adding members use /teacher/classes/* (decision above). If R4 left rotateJoinCode's options required, they become optional with R4's defaults; if R4 left adding members single, the body gains userIds (≤ 100). Both are additive, and R2's student-target predicate still applies to every member added.
  • Tests: a teacher token gets 403 on each /admin/classes operation; after a change of teacher the new teacher sees the class and its assignments and the old one gets 404; notifications; the list's search and disabled-teacher filter.

T-R5.12 — Audit read model, recency index and export (DG-11)

Depends on: — Touches: migrations/NNNNN_index_audit_log_recency.sql (new), server/internal/modules/audit/{domain/{entry,categories,actions}.go,application/query/{list,export}.go,repositories/postgres.go,http/audit.go} (new), server/internal/modules/audit/domain/tests/catalog_test.go (new), server/internal/core/wiring/audit.go (new), server/internal/core/router/{modules.go,server.go,ratelimits.go}, api/openapi.yaml, generated code, web/src/lib/i18n/locales/{vi,en}.json (action keys), docs/setup/deploy.md, docs/plan/20-data-model.md Size: M Done when:

  • NNNNN_index_audit_log_recency.sql is -- +goose NO TRANSACTION: CREATE INDEX CONCURRENTLY IF NOT EXISTS audit_log_recent_idx ON app.audit_log (occurred_at DESC, id DESC); Down DROP INDEX CONCURRENTLY IF EXISTS app.audit_log_recent_idx. deploy.md's list of interrupted concurrent builds gains it. Grants are untouched: the log stays append-only.
  • GET /admin/audit-log (listAuditLog, system.audit.read) ?category=accounts|classes|attempts|tests|media|imports, q, range=today|7d|30d|all|custom (default 7d), from, to, page, size=10 → {items: [AuditEntry {id, occurredAt, actor {id, name, avatarUrl} | null (null is System), action, category, entity {type, id, label | null}, ip, device | null, changes: [{field, from, to}], details}], page, facets {all, accounts, classes, attempts, tests, media, imports}}. device is R4's uaparse label.
  • range is today (from the viewer's midnight), 7d, 30d, all, or custom, which takes from and to as dates (YYYY-MM-DD), both inclusive and read in the viewer's zone (v2's Date range menu, 74d row A-03). from and to without custom, custom without both, and a to before from answer VALIDATION_FAILED on the field. The year range is gone from the list; the export keeps its own period.
  • Categories come from entity in domain/categories.go (Accounts: users, students, roles, passwords and sessions; Attempts: attempts and assignments; Tests: tests, questions and groups; Media; Imports: Word and user imports; Classes). Organization, export, maintenance and retention entries appear under All only. A test lists every entity string written in server/ and fails on one without a category or an explicit All-only entry.
  • facets is a new schema, AuditCategoryFacets, written like TestStatusFacets: all seven keys required, each an integer of at least 0, additionalProperties: false. Each counts the entries that match the current q and range (range, from, to), ignoring category, so selecting a tab never zeroes the others. all counts every such entry, the All-only ones included (decision above).
  • The facets come from one statement over the list's range and search, SELECT entity, count(*) … GROUP BY entity, folded through domain/categories.go as tests/repositories/facets.go folds statuses; no CASE repeats the mapping in SQL. page.total is the selected category's facet, or facets.all, so the list runs no second count. Under a search, the EXISTS of the next item runs for every entry in the range, which the total alone already needs.
  • q matches the actor's name or email, the action's sentence and the entity's label (the name of the user, class or test an entry is about, EXISTS per type). The sentence is matched on the server: domain/actions.go holds each action's vi and en sentence beside its key, and catalog_test.go fails when audit.action.<action> in vi.json or en.json differs from it. Labels are resolved per page in one batched query per entity type; a missing row gives a null label.
  • Diffs of the {field: {old, new}} shape become changes; others become details. A key named code, joinCode or code_hash is shown as ••••-XXXX from its last four characters, whatever was stored (DG-11); keys named password, temporaryPassword, token or secret are dropped.
  • GET /admin/audit-log/export (exportAuditLog, system.audit.read) ?period=7d|30d|year&format=csv|json plus the list's filters streams an attachment with no-store: CSV through tabular (BOM, every cell formula-escaped), JSON as a streamed array. More than 100,000 rows answers 422 EXPORT_TOO_LARGE; a 30-second statement timeout applies. Audits audit.exported {period, format, rows}. Rate limit 5 a minute and 30 an hour per actor.
  • domain/actions.go lists every action the server writes. catalog_test.go scans server/internal for action literals (Action: fields and the dotted literals of audit INSERTs), fails on one missing from the list, and fails when an action lacks audit.action.<action> in web/src/lib/i18n/locales/vi.json or en.json.
  • EXPLAIN on a seeded 100,000-row log shows audit_log_recent_idx for the unfiltered first page, for a category filter and for the facet statement at range=7d, when a week is a small part of the seeded log and the range bounds the scan; range=all reads the whole log once. entity is read from the heap, and the index gains INCLUDE (entity) only if these timings show the need. Recorded in the PR and rehearsed on the Neon branch.
  • Tests: filters, ranges (each of the five, a custom range inclusive of both days in the viewer's zone, and its three refusals) and paging; a search that hits only the actor, only the action's sentence and only the entity's label; facet counts under a search and under each range, the same whichever category is selected; an All-only entry counted in all and in no family; page.total equal to the selected facet; the System actor; a stored full code comes back hinted; secret keys dropped; CSV escape (=HYPERLINK(…)) and the row cap; a teacher token gets 403.

T-R5.13 — Tabular: XLSX reading and export escaping

Depends on: R4's platform/tabular Touches: server/internal/platform/tabular/{reader.go (new),xlsx.go (new),csv.go}, server/internal/platform/tabular/tests/ (+ testdata/), server/go.mod, server/go.sum Size: S Done when:

  • New dependency github.com/xuri/excelize/v2, reason in the PR: reading XLSX user imports here, and word lists and gradebook exports later. A hand-written OOXML reader was considered and rejected over shared strings, inline strings and date cells.
  • tabular.ReadTable(r io.ReaderAt, size int64, format, Limits) returns the header and a row iterator. XLSX: before excelize opens it, an archive/zip pass refuses more than 1,000 entries, a total uncompressed size over 32 MiB, a vbaProject.bin (macros) and an encrypted package; then excelize.OpenReader with Options{UnzipSizeLimit: 32 << 20, UnzipXMLSizeLimit: 16 << 20} reads the first sheet through the streaming Rows() iterator. Formulas are never evaluated; a formula cell yields its cached value as text.
  • CSV: UTF-8 only (a BOM is stripped; anything else is CSV_NOT_UTF8, whose message says to save as "CSV UTF-8"); comma or semicolon detected from the header line; RFC 4180 quoting.
  • Limits per call: rows (2,000 for user imports), 20 columns, 1,000 characters a cell.
  • Every CSV this release writes goes through R4's writer (BOM, EscapeCell on every cell): the audit export, each data-export file and the import sheet. A test feeds each producer a =HYPERLINK("http://x") value and asserts the escaped cell.
  • Tests: a zip bomb is refused before excelize allocates; an .xlsm and an encrypted file are refused; semicolon CSV; non-UTF-8 CSV; the row limit; a 5 MiB workbook's heap growth is measured and noted in the PR.

T-R5.14 — User import, run by the worker

Depends on: T-R5.3, T-R5.9a, T-R5.13 Touches: migrations/NNNNN_create_user_import_jobs.sql (new), migrations/NNNNN_create_user_import_rows.sql (new), server/internal/modules/identity/{domain/userimport.go (new),application/command/{preview_user_import,create_user_import,download_import_sheet}.go (new),application/worker/user_import.go (new),repositories/user_imports.go (new),http/user_imports.go (new)}, server/internal/core/{import_worker.go,wiring/import_worker.go,jobs/imports.go}, server/internal/core/adapters/worker_wake.go, server/internal/platform/config/import_worker.go, server/internal/core/router/ratelimits.go, api/openapi.yaml, generated code, server/tests/user_import_flow_test.go (new) Size: L Done when:

  • create_user_import_jobs: app.user_import_jobs (id uuid PK DEFAULT uuidv7(), created_by uuid NOT NULL REFERENCES app.users ON DELETE CASCADE, request_id uuid NOT NULL, filename text NOT NULL (≤255), format text CHECK IN ('csv','xlsx'), default_role_id uuid NOT NULL REFERENCES app.roles ON DELETE RESTRICT, delivery text NOT NULL CHECK IN ('email_link','sheet'), status text NOT NULL DEFAULT 'queued' CHECK IN ('queued','running','done','failed'), total_rows smallint NOT NULL CHECK (total_rows BETWEEN 1 AND 2000), created_count, skipped_count, invalid_count smallint NOT NULL DEFAULT 0, error_code text, sheet_expires_at, sheet_downloaded_at, created_at, started_at, completed_at timestamptz), UNIQUE (created_by, request_id), and a partial index (created_at) WHERE status IN ('queued','running'). Down drops the table.
  • create_user_import_rows: (job_id uuid REFERENCES app.user_import_jobs ON DELETE CASCADE, ordinal smallint, full_name text NOT NULL, email text NOT NULL, role_id uuid NOT NULL REFERENCES app.roles, class_id uuid REFERENCES app.classes ON DELETE SET NULL, outcome text NOT NULL DEFAULT 'pending' CHECK IN ('pending','created','skipped_exists'), user_id uuid REFERENCES app.users ON DELETE SET NULL, secret_ciphertext bytea, PRIMARY KEY (job_id, ordinal)). Invalid rows are counted, never stored. Down drops the table.
  • Row rules (domain/userimport.go): headers full_name, email, role, class (case-insensitive, any order); full_name 1–200; the email passes the same validation as Add user; a duplicate in the file is DUPLICATE_IN_FILE; an existing account is skipped, never updated; role is a built-in key, its Vietnamese name ("học viên", "giáo viên", "quản trị viên") or a custom role's name, and empty means the dialog's default; a role outside the importer's permissions is ROLE_NOT_ALLOWED and Assistant ROLE_NOT_ASSIGNABLE; class is an active class's exact name, only for a student-target role and only with scope.all, and two classes with that name are AMBIGUOUS_CLASS.
  • POST /admin/user-imports/preview (previewUserImport, people.users.manage, multipart with x-max-body-bytes 5 MiB) {file, defaultRole: student | teacher, delivery} → {rows, newAccounts, existingEmails {count, sample (≤5)}, invalid [{row, field, code}] (≤50), invalidCount, classes [{id, name}], emailBudget {remainingToday, limit} | null}. It parses in the API under a one-slot semaphore and writes nothing. Rate limit 10 a minute per actor.
  • POST /admin/user-imports (createUserImport, people.users.manage, same multipart plus requestId) → 202 UserImportJob. It re-parses and re-validates the file, never trusting the preview, stores the job and its valid rows in one transaction, and wakes the worker. The same requestId returns the same job. Rate limit 5 an hour per actor.
  • GET /admin/user-imports/{id} (getUserImport, people.users.manage) → {status, total, processed, created, skipped, invalid, delivery, sheet {available, expiresAt, downloadedAt} | null, errorCode}; another user's job is 404.
  • The worker loop in core/import_worker.go drains user-import jobs after Word imports on each wake: a claim FOR UPDATE SKIP LOCKED, then 25 rows per transaction. Each row creates the user (ON CONFLICT on the email index makes a racing duplicate a skip), sets created_by, enrols the class through the classes port under R2's student-target predicate, and audits user.created {via: import, jobId}. email_link calls IssueSetPasswordLink; sheet takes PasswordManager.Temporary(), hashes under MAX_CONCURRENT_PASSWORD_HASHES and seals the password into secret_ciphertext (purpose user-import-sheet, AAD job id and ordinal). Counters move per batch; a job left running for 30 minutes is resumed from its pending rows at worker start.
  • POST /admin/user-imports/{id}/sheet (downloadUserImportSheet, people.users.manage, requester only) → text/csv attachment, no-store, with full_name, email, role, class, temporary_password. One transaction reads and erases the ciphertexts and sets sheet_downloaded_at; afterwards, or 24 hours after completion, it answers 410 SHEET_UNAVAILABLE. Rate limit 5 a minute per actor; the credential-minting test lists it.
  • Tests: row-rule table tests; CSV and XLSX fixtures in testdata/; preview counts equal the job's; an idempotent requestId; a simulated crash mid-job resumes without duplicates; the sheet is single-use and requester-only; a custom role's import with an admin row marks it invalid; one email per created row for email_link; user_import_flow_test.go (e2e tag) runs the API and the worker runner in process.

T-R5.15 — Data export, built by the worker and fetched from the app

Depends on: T-R5.2, T-R5.13 Touches: migrations/NNNNN_create_data_exports.sql (new), server/internal/modules/exports/{domain,application/{command,query,worker},repositories,http}/ (new), server/internal/core/wiring/exports.go (new), server/internal/core/{import_worker.go,wiring/import_worker.go}, server/internal/core/router/{modules.go,server.go,ratelimits.go}, api/openapi.yaml, generated code, docs/plan/60-backend-architecture.md Size: L Done when:

  • create_data_exports: app.data_exports (id uuid PK DEFAULT uuidv7(), requested_by uuid NOT NULL REFERENCES app.users ON DELETE CASCADE, include_answers boolean NOT NULL, status text NOT NULL DEFAULT 'queued' CHECK IN ('queued','running','ready','failed','expired'), storage_key text, bytes bigint CHECK (bytes > 0), error_code text, created_at, started_at, completed_at, expires_at, downloaded_at timestamptz), CHECK (status <> 'ready' OR (storage_key IS NOT NULL AND expires_at IS NOT NULL)), and indexes (created_at) WHERE status IN ('queued','running') and (requested_by, created_at DESC). Down drops the table.
  • POST /admin/data-exports (requestDataExport, system.data_export) {includeAnswers} → 202 DataExport; 409 EXPORT_IN_PROGRESS while any export is queued or running. Audits data_export.requested. Rate limit 1 an hour per actor.
  • GET /admin/data-exports/{id} (getDataExport, system.data_export) → {id, status, includeAnswers, bytes, createdAt, completedAt, expiresAt, downloadedAt}; another user's export is 404.
  • POST /admin/data-exports/{id}/download (downloadDataExport, system.data_export, requester only) → {url, expiresInSeconds: 60}: a presigned GET with an attachment filename quizzivy-export-{date}.zip. One statement checks ready, unexpired and undownloaded and sets downloaded_at; otherwise 410 EXPORT_UNAVAILABLE. Audits data_export.downloaded. Rate limit 10 an hour per actor.
  • The worker claims the job and writes a ZIP to a file under IMPORT_WORK_DIR: users.csv (no hashes or tokens), roles.csv, classes.csv (no join codes), class_members.csv, tests.csv, test_versions.csv, assignments.csv, attempts.csv and, when asked, answers.csv (a removed answer is flagged, T-R5.17b), plus a README.txt in vi and en describing each file. Each CSV streams from its query through tabular, so memory stays bounded. The file is uploaded to the private imports bucket at exports/{id}.zip, the local file removed, the row set ready with expires_at = now() + 24 h, and data_export_ready enqueued to the requester with {APP_URL}/admin/exports/{id}, which carries no token. A failure sets failed with a code and sends nothing.
  • Tests: ZIP contents and escaping against a seeded fixture; single use; another admin gets 404; expiry; the template test shows no URL other than the app page; the worker's heap stays under its limit on a 50,000-attempt fixture (measured, noted in the PR).

T-R5.16 — Daily housekeeping

Depends on: T-R5.2, T-R5.3, T-R5.4, T-R5.6, T-R5.14, T-R5.15 Touches: server/internal/core/jobs/housekeeping.go (new), server/internal/core/core.go, server/internal/core/jobs/tests/housekeeping_test.go (new), the owning repositories Size: S Done when:

  • Housekeep runs in the API at start-up and then on PruneRefreshTokens's 24-hour tick, so it adds no wake of its own. Each step is bounded and logs counts only:
    • user_activity_days rows older than 400 days;
    • outbox rows sent or failed more than 30 days ago;
    • set-password links used, revoked or expired more than 30 days ago;
    • import-sheet ciphertexts 24 hours after completion, and import jobs 30 days after;
    • exports past expires_at: the object deleted, the row expired;
    • branding/ objects the settings do not reference, older than 24 hours;
    • avatar objects under avatars/{userId}/ of anonymised users (T-R5.17a).
  • Tests: each step against seeded rows and a fake store; a second run changes nothing.

T-R5.17a — Retention sweep under the maintenance role (D16, DG-07)

Depends on: T-R5.3, T-R5.4, T-R5.5 (the grant names their tables and columns) Touches: scripts/provision-db.sh, .env.example, migrations/NNNNN_grant_maintenance_role.sql (new), server/internal/core/maintenance/{maintenance.go,retention.go (new),anonymize.go (new)}, server/cmd/maintenance/main.go, server/internal/core/maintenance/tests/retention_test.go (new), server/internal/platform/db/tests/privileges_test.go, .github/workflows/retention-sweep.yml (new), docs/setup/operations.md, docs/plan/20-data-model.md Size: M Done when:

  • provision-db.sh creates quizzivy_maintenance (LOGIN, CONNECTION LIMIT 2, not a superuser, no CREATE), password from QUIZZIVY_MAINTENANCE_PASSWORD (default maintenance locally), idempotently like the other two roles. The PR tells anyone with an existing local volume to re-run the script.
  • grant_maintenance_role grants exactly what cmd/maintenance uses: USAGE on the schema; SELECT on the tables its predicates read; UPDATE (payload) on attempt_answers; DELETE on attempt_events; UPDATE (email, full_name, display_name, phone, avatar_key, password_hash, must_change_password, disabled_at) on users; DELETE on user_identities and refresh_tokens; UPDATE (revoked_at) on set_password_tokens; DELETE on notifications; INSERT on audit_log; R1's window commands' INSERT, UPDATE on maintenance_windows, UPDATE (deadline_at) on attempts, UPDATE (closes_at) on assignments and, for R4's overrides, SELECT, UPDATE (closes_at) on assignment_student_overrides; and R2's rekey-join-codes' SELECT, UPDATE (code_hash, code_ciphertext, key_id) on class_join_codes. It fails when the role is missing; there is no conditional grant. Down revokes everything from the role. Later releases that add or widen a command grant in their own migration (decision above).
  • The same migration grants what D4's ai-credits and ai-reservations-sweep (T-D4.31) use: SELECT, INSERT, UPDATE on ai_credit_periods and ai_teacher_credits, SELECT, UPDATE on ai_settings and ai_credit_requests, SELECT, DELETE on ai_reservations, INSERT, UPDATE on notifications, INSERT on audit_log, SELECT on the users, roles and grants they read, and never the ledger (20-data-model.md §46, "Privileges").
  • privileges_test.go: the maintenance role can do exactly that and cannot UPDATE or DELETE audit_log or create objects; quizzivy_app still has no UPDATE or DELETE on attempt_events or audit_log.
  • maintenance retention-sweep [-apply] [-batch N] [-budget D] (R1's maintenance <command> [flags] order; the budget also sets the command's timeout) reads the organization's retention settings and runs three steps in bounded batches until each is done or the budget ends, then prints a JSON report of counts and cutoffs:
    • answers (when keep_answers_months is set): attempts submitted, and whose assignment closed, before the cutoff have each answer's payload replaced by {"removed": true} where it is not already; one retention.answers_removed {cutoff, attempts, answers, databaseRole} per batch;
    • focus-loss events: RetainEvents with the cutoff from keep_focus_events_months; at the default 13 it deletes exactly what it deletes today;
    • accounts (when anonymise_disabled_after_months is set): users disabled before the cutoff are anonymised by the O-23 method, generalised to every role (the student placeholder name for student targets, "Người dùng đã ẩn danh" for others; display name, phone and avatar key cleared; identities, refresh tokens and links removed), skipping users who own classes or content and users with an attempt in progress, which the report counts. Before the name is overwritten, in-app notifications whose params carry the user's full or display name as a value are deleted, so no name outlives the anonymisation there (D16). Each is audited user.anonymized as System.
  • retain-integrity reads the same setting; anonymize-student becomes anonymize-user and keeps the old name as an alias. Every command runs as quizzivy_maintenance.
  • .github/workflows/retention-sweep.yml: schedule: cron '0 20 * * *' (03:00 in Ho Chi Minh City) and workflow_dispatch with an apply input; environment production-maintenance holding MAINTENANCE_DATABASE_URL; builds cmd/maintenance and runs it with a 5-minute budget; applies on a schedule only when the repository variable RETENTION_SWEEP_ENABLED is true, otherwise dry-runs; timeout-minutes: 15; one run at a time.
  • operations.md replaces the manual retention section: the role and how it is provisioned, the workflow, the first dry run, reading the report, pausing (unset the variable), and that the credential never goes to Fly. Its "Join-code key" section says the rekey runs from the operator's machine as this role, with MAINTENANCE_DATABASE_URL, JOIN_CODE_KEY and JOIN_CODE_KEY_PREVIOUS from the offline copy; the GitHub environment holds no join-code key.
  • Tests (integration): each step at, just before and just after its cutoff; defaults touch no answer and no account; owners and in-progress attempts are skipped; a notification naming an anonymised user is gone; a second run changes nothing; the dry run writes nothing; every audit row has a NULL actor; rekey-join-codes and window-schedule run as the role. attempts/application/tests/events_test.go is green and untouched.

T-R5.17b — Removed answers in the read paths, and the retention impact

Depends on: T-R5.17a Touches: server/internal/modules/attempts/{domain/review.go,domain/grading.go,repositories/{result,review_review,review_byquestion,review_grade}.go,application/query/{result,review,answers_for_question}.go}, server/internal/modules/organization/application/query/retention_impact.go (new), api/openapi.yaml, generated code, R3's result review item in web/src/features/results/, R4's attempt review and grading components in web/src/features/attempts/, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/{results,attempts}/ Size: M Done when:

  • The attempts domain reads {"removed": true} as a removed answer. The student result, the teacher review and grade-by-question carry answerRemoved: true with no response content; grading a removed answer answers 409 ANSWER_REMOVED; scores are unchanged.
  • Web: each place that renders a response shows "Câu trả lời đã được xoá theo chính sách lưu trữ của trung tâm." / "This answer was removed under the centre's retention policy." and no score editor for it.
  • POST /admin/settings/retention-impact (getRetentionImpact, system.settings.write) {retention} → {answers {attempts, answers}, focusEvents, accounts {anonymise, skippedOwners}}, read under the app role. An integration test shows it equals retention-sweep's dry-run counts on the same data.
  • E2E 9's key list is unchanged; the attempts unit tests run before and after (high-risk area); publish_snapshot_test.go is green and untouched.

T-R5.18 — Overview, nav summary and usage read models

Depends on: T-R5.4, T-R5.12, D4's T-D4.31 Touches: server/internal/modules/dashboard/{domain/admin.go (new),application/query/{admin_overview,admin_summary,usage}.go (new),repositories/admin.go (new),http/admin.go (new)}, server/internal/platform/config/config.go (PLAN_*), fly.toml, .env.example, api/openapi.yaml, generated code Size: M Done when:

  • GET /admin/overview (getAdminOverview, workspace.admin) → {organizationName, people {total, activeThisWeek, teachers, teachersDisabled, students, studentsNewThisMonth}, classes {active, archived}, dailyActive {days: [{date, students, staff}] (14), average}, attention: [{kind: must_change_password | never_signed_in | disabled_teacher_classes | storage | ai_credit_requests, count, detail}], recentChanges: AuditEntry[] (5), storage: Usage}. people and dailyActive need people.users.manage, classes and the disabled-teacher row scope.all, recentChanges system.audit.read, storage system.settings.write; each is null without its key. Attention rows with a count of zero are left out. The ai_credit_requests row counts the pending credit requests and needs ai.credits.manage (v2, 74d row A-06).
  • GET /admin/summary (getAdminSummary, workspace.admin) → {mustChangePassword} (null without people.users.manage) for the nav badge (DG-54).
  • GET /admin/usage (getUsage, system.settings.write) → Usage {plan {name, renewsOn} | null, seats {used, limit}, teachers {used, limit}, media {bytes, limit}, imports {bytes, count, limit}, attemptsKept {count}, aiCredits {used, pool} | null}. aiCredits is the month's credits used against the centre pool, null without ai.credits.manage and while the AI module is off. Limits come from PLAN_NAME, PLAN_RENEWS_ON, PLAN_SEAT_LIMIT, PLAN_TEACHER_LIMIT, PLAN_MEDIA_BYTES and PLAN_IMPORT_BYTES, each optional; nothing is enforced (DG-55). Attempts kept has no limit until DG-55 names a denominator.
  • The AI figures come from D4's AI module through a port typed as its credits query (GET /admin/ai/credits, T-D4.31), as every cross-module read does; with the adapter nil the port answers nothing and both figures are null or absent.
  • "Staff" counts every active user who is not a student target, which the chart labels Teachers as the deck does. Days are local dates in APP_TIMEZONE.
  • EXPLAIN of each aggregate at seeded volume is in the PR; no index is added.
  • Tests: each section against a fixture; null sections for a custom role holding only system.audit.read; the 14-day window across a month boundary; unset PLAN_* values.

T-R5.19 — API reference summary

Depends on: — Touches: server/internal/platform/apidocs/{summary.go (new),apidocs.go}, server/internal/modules/identity/http/docs.go, api/openapi.yaml (tag descriptions), generated code, server/internal/platform/apidocs/tests/ Size: S Done when:

  • GET /admin/api-reference (getApiReferenceSummary, system.api_reference) → {openapi, version, endpointCount, groups: [{tag, count}]}, computed once from the embedded spec.
  • GET /admin/api-reference/openapi.json (downloadOpenApi, system.api_reference) returns the embedded spec as an attachment (DG-10: JSON, because that is what the server embeds).
  • Every tag in api/openapi.yaml has a description; the web shows its own vi/en group names and blurbs (T-R5.27).
  • Tests: the count equals the spec's operations; groups cover every tag; a teacher gets 403.

T-R5.20 — AdminLayout and the /admin route tree

Depends on: R4's shell (T-R4.4, T-R4.5), R3's web/src/app/modules.ts, D4's T-D4.20 (the shared Sidebar's separator) Touches: web/src/layouts/AdminLayout.tsx (new), web/src/layouts/adminNav.ts (new), web/src/layouts/shell/WorkspaceSwitcher.tsx, web/src/layouts/TeacherLayout.tsx, web/src/features/auth/{AccountMenu.tsx,home.ts}, web/src/app/router.tsx, web/src/app/guards/RequirePermission.tsx (new), web/src/app/LegacyTeacherRedirect.tsx, web/src/app/boot/handoff.ts, api/openapi.yaml (UserPreferences.workspace), generated code, web/tests/units/shell/admin-layout.test.tsx (new), web/tests/units/guards/require-permission.test.tsx (new), web/tests/units/contract/admin-nav.test.ts (new), web/tests/integration/router-chunks.test.ts, web/src/lib/i18n/locales/{vi,en}.json Size: M Done when:

  • AdminLayout on R4's Sidebar, TopBar and Breadcrumbs: sidebar 248px, 60px collapsed, a 272px drawer below 768; the switcher tile is the 32px charcoal tile with the shield, "Quizzivy" / "Bảng quản trị" ("Admin console"); the nav is (no label) Overview; People Users with the warning count of GET /admin/summary (hidden at 0 and when collapsed), Roles & permissions; Teaching Classes; System AI credits (sparkles, first, with an accent badge of the pending credit requests from GET /admin/ai/credits, hidden at 0 and when collapsed; v2, 74d row A-02), Audit log, API reference, Settings. While the sidebar is collapsed the labels are hidden and a 1px role="separator" sits between groups (v2); it is the shared Sidebar's (T-D4.20), and this task adds nothing for it.
  • Top bar 56px: sidebar toggle, divider, "Admin › {page}", then the theme toggle and an outline "Chế độ giáo viên" ("Teacher view") button, icon-only on phones. No search, ⌘K, bell or language switch in the top bar, as the deck draws it. Content max 1320px (1080 for API reference and Settings); padding 24/28/40, on phones 16/14/32.
  • Account menu: System settings, API reference, Teacher workspace ↗, Dark mode / Light mode, Sign out; a user without workspace.teacher also gets "Đổi mật khẩu" ("Change password"), which opens /change-password (their only way to it), and "Ngôn ngữ" ("Language"), which writes locale through R4's PATCH /auth/me (their only way to change language). The Language item is an interim control listed for sign-off (Open items).
  • Each nav and menu item carries the x-permission of the operation its page lists (Overview workspace.admin, Users people.users.manage, Roles people.roles.manage, Classes scope.all, AI credits ai.credits.manage, Audit system.audit.read, API reference system.api_reference, Settings system.settings.write); admin-nav.test.ts reads api/openapi.yaml and fails when one differs. Teacher view and the Teacher row need workspace.teacher.
  • R4's WorkspaceSwitcher gains its "Admin console" row for workspace.admin holders in TeacherLayout, and the teacher account menu gains "Admin console ↗" (DG-53).
  • Routes under R2's RequireWorkspace guard, the RequirePermission guard below and AdminLayout: /admin (Overview), /admin/users, /admin/roles, /admin/classes, /admin/ai (T-R5.35), /admin/audit, /admin/api, /admin/settings/:section? (organization, sign-in, data, plan) and /admin/exports/:id; each declares handle.crumb, handle.width and handle.permission.
  • A RequirePermission route guard (web/src/app/guards/) reads handle.permission through R2's can() and renders the 403 page when the caller lacks it, so a custom role holding only system.audit.read gets 403 at /admin/users, not an empty screen. Unit tests cover that role and an Admin.
  • LegacyTeacherRedirect narrows to legacy teacher paths: an /admin/* path that is a console route stays for a workspace.admin holder; any other, and every one for a teacher, maps to its /teacher/* equivalent (/admin/classes/:id → /teacher/classes/:id, /admin/settings/profile → /teacher/settings/profile). Unit tests cover both audiences.
  • UserPreferences gains workspace: teacher | admin, written when the user switches. homePathFor sends a built-in Admin to preferences.workspace or /admin, any other workspace.teacher holder to /teacher, a user with only workspace.admin to /admin, a student to /app.
  • The Users badge polls GET /admin/summary every 60 seconds through R4's useIdlePolling (visible tab, input within ten minutes); the admin skeleton is registered with R1's splash hand-off; the document title is "{last crumb} · Quizzivy".
  • router-chunks.test.ts adds ADMIN = /\/(layouts\/AdminLayout|features\/[^/]+\/pages\/admin\/|features\/(users|roles|audit|admin-overview|api-reference|exports)\/)/ (organization is not in it, because its public hook lives in lib/) and asserts it stays out of the entry chunk, is built in non-entry chunks, and shares no chunk with STUDENT or TEACHER page modules. Every existing assertion is kept.
  • Unit tests: the nav for an Admin, for a custom role with only people.users.manage, and for a teacher (no console); the drawer at 360; the badge; the switcher in both layouts; home paths. Keys vi first, listed in the PR.

T-R5.21 — Overview

Depends on: T-R5.20, T-R5.18, T-R5.22b (its Add user dialog) Touches: web/src/features/admin-overview/{api.ts,pages/admin/OverviewPage.tsx,components/{AttentionList,RecentChanges,StorageCard}.tsx} (new), R4's web/src/components/shared/charts/BarChart.tsx, web/tests/units/admin-overview/ (new) Reuses: KpiTile, BarChart, Meter, IconTile, Callout, ListState, T-R5.22b's AddUserDialog Size: M Done when:

  • Header "Tổng quan" ("Overview") with "{organization} · {n} người" and a primary "Thêm người dùng" ("Add user") for people.users.manage holders, opening the Add user dialog in place.
  • Four KPI tiles (4 columns at a content width of 900 and up, else 2): Active this week ("{p}% of {n} people") → Users; Teachers ("{n} disabled") → Users › Teachers; Students ("+{n} this month") → Users › Students; Classes ("{n} archived") → Classes.
  • Daily active people: a 170px stacked bar chart over 14 days, students in --accent-c and staff in --info, "Last 14 days · {avg} on average", a tooltip per bar, every other label on phones, and a visually hidden data table. BarChart gains a stacked mode, built after loading the dataviz skill.
  • Needs attention: must change password → Users with status=must_change_password; never signed in → Users with status=never_signed_in; classes with a disabled teacher → Classes with teacher=disabled; storage → Settings › Plan & usage; and, while credit requests are pending, "{n} teacher(s) asked for more AI credits" (sparkles, accent tones) → AI credits (v2, 74d row A-06). With none: "Không có gì cần xử lý." ("Nothing needs attention.").
  • Recent changes (five entries, "Nhật ký →" to the Audit log) and Storage (the four meters, and v2's fifth, "AI credits", "{used} of {pool} this month" in the accent tone, present only with Usage.aiCredits) render only when present.
  • States: a skeleton per card, an error with Retry per card, and a hidden section when its data is null (DG-83).
  • Compared with the deck (route: overview) at 360, 768, 1024, 1280 and 1440, light and dark, sidebar expanded, collapsed and as a drawer. Keys vi first.

T-R5.22a — Users: list, tabs, search and bulk actions

Depends on: T-R5.20, T-R5.9a, T-R5.9b Touches: web/src/features/users/{api.ts,pages/admin/UsersPage.tsx,components/{UsersTable,UserCard,BulkResetDialog,CredentialResult,AddToClassDialog}.tsx} (new), web/tests/units/users/ (new) Reuses: DataTable, Pager, BulkActions, FormDialog, ConfirmDialog, GoogleMark, R1's segmented control and avatar Size: M Done when:

  • Header "Người dùng" / "Mọi người có thể đăng nhập vào Quizzivy." with outline "Nhập CSV" ("Import CSV") and primary "Thêm người dùng"; a segmented control All · Teachers · Students · Admins · Disabled with count pills; a 300px search ("Tìm theo tên hoặc email"). Tab, search, status filter, page and size live in the URL (?tab=&q=&status=&page=&size=); a status from the Overview shows as one removable chip, drawn as the deck's 22px tag with ×; the deck draws no chip here and no row of filter chips.
  • Table: checkbox, User (32px avatar with DG-22 initials, name, email plus folded columns), Role (outline badge with the role's icon), Sign-in ("Mật khẩu" with key-round and/or the Google mark, the AGENTS.md data-indicator exception), Classes, Last active ("Bây giờ", "5 phút trước", "Chưa bao giờ"), Status (active success, must change warning — shortened when Last active is folded — never signed in muted, disabled danger), row menu. Columns appear at content widths of 640 (role), 560 (status), 820 (last active), 960 (classes) and 1080 (sign-in); below 560 each user is a card with a "Chọn trang" header.
  • Selection across pages; the bulk bar offers Reset password, Add to class (with scope.all) and Disable. Reset opens a confirm with the delivery toggle (link by default; at most 40 for temporary passwords) and ends in CredentialResult: the temporary passwords shown once with Copy all and Print, or "{n} links sent". Add to class uses a searchable class list over GET /admin/classes; Disable is a danger confirm. Partial failures are reported per user.
  • Pager "1–10 / 312 người dùng", rows per page 10/20/50 (hidden on phones).
  • States: skeleton rows, "Không có người dùng phù hợp." with Clear filters, and an error with Retry.
  • Unit tests: column folding at each threshold, cards at 360 (viewport("phone")), selection across pages, the 40 cap for temporary passwords, the result shown once. Compared with the deck (route: users) at the five widths, light and dark. Keys vi first.

T-R5.22b — User sheet, Add user and Transfer content

Depends on: T-R5.22a, T-R5.10 Touches: web/src/features/users/components/{UserSheet,AddUserDialog,ChangeRoleConfirm,TransferContentDialog}.tsx (new), web/src/features/students/components/TemporaryPasswordCard.tsx (reused through features/students/index.ts), web/tests/units/users/ Reuses: Sheet, FormDialog, ConfirmDialog, Timeline, TemporaryPasswordCard Size: M Done when:

  • The sheet opens from a row or its menu at ?user=<id>, 440px (full width on phones): avatar, name, email, close. Role: the deck's Student / Teacher / Admin segments when no custom role exists, otherwise a select of assignable roles; shown only with people.roles.manage; a change confirms ("Chuyển {name} thành quản trị viên?" and the deck's bodies). Facts: Status, Sign-in, Classes, Last active, Created.
  • Actions, each shown only when can allows it: Reset password (the delivery toggle, then TemporaryPasswordCard or "Link sent to {email}"); Sign out everywhere (no confirm, as drawn); Disable account (danger confirm; when the user teaches classes it adds "They teach {n} classes. Students keep access; move the classes with Transfer content."; when they are mid-test, "They are taking a test right now."); Enable account.
  • Transfer content, for a disabled user who owns content (DG-56): "Chuyển nội dung của {name} cho" with a teacher picker, the counts that will move, "Báo cho giáo viên nhận" (on), then a result listing what moved and any imports left behind.
  • Recent activity timeline (sign-ins success, submissions info, joins muted).
  • Add user (480px): Full name, Email ("Nhập đầy đủ địa chỉ email", and a debounced ?email= check giving "Email này đã có người dùng"), Role segments or select, "Thêm vào lớp (không bắt buộc)" for a student-target role with scope.all, and the delivery toggle ("Gửi liên kết đặt mật khẩu qua email" / "Hiện mật khẩu tạm thời"). Create stays aria-disabled until valid; success shows the credential result, then opens the new user.
  • Server errors map to their fields or to a callout (TARGET_ROLE_NOT_ALLOWED, LAST_ADMIN, USER_OWNS_CONTENT with a link to Transfer content).
  • Unit tests: each action's visibility from can, each confirm, the transfer flow, email taken, keyboard and Esc. Compared with the deck's sheet and dialog at 360 and 1280, light and dark. Keys vi first.

T-R5.23 — Import users dialog

Depends on: T-R5.22a, T-R5.14 Touches: web/src/features/users/components/{ImportUsersDialog,ImportProgress}.tsx (new), web/tests/units/users/import.test.tsx (new) Reuses: FormDialog, FileDrop, useIdlePolling, ProgressBar Size: M Done when:

  • "Nhập người dùng" (520px): "Cột: full_name, email, role, class."; a file drop ("CSV hoặc XLSX · tối đa 5 MB"); Role if the file has none (Student / Teacher); the delivery toggle ("Gửi thông tin đăng nhập qua email", sub "Nếu tắt, tải bảng mật khẩu tạm để phát tay", per DG-05).
  • Choosing a file calls the preview: "{n} tài khoản mới", "{n} email đã có và sẽ được bỏ qua", "Thêm vào {classes}", and "{n} dòng có lỗi" with a disclosure of row, field and reason. When links exceed today's budget: "Hôm nay còn gửi được {n} email; phần còn lại gửi vào ngày mai." Submit reads "Nhập {n} người" and is disabled with no valid row.
  • Submitting creates the job with a requestId held for the dialog's life (a retry cannot duplicate it) and shows progress ("Đang tạo tài khoản… {done}/{total}") polled every two seconds through useIdlePolling while running. At the end, "{created} tài khoản đã tạo · {skipped} bỏ qua", and for a sheet "Tải bảng mật khẩu" (single use; afterwards "Đã tải lúc {time}"). Closing mid-job is allowed; the job continues and the Users list refreshes.
  • States: parse errors (CSV_NOT_UTF8, size, rows), a failed job, the sheet unavailable.
  • Unit tests: preview rendering, the budget warning, idempotent retry, progress and completion with fake timers, a single-use download. Compared with the deck's dialog at 360 and 1280. Keys vi first.

T-R5.24 — Roles & permissions

Depends on: T-R5.20, T-R5.8 Touches: web/src/features/roles/{api.ts,permissionLabels.ts,pages/admin/RolesPage.tsx,components/{RoleCarousel,RoleCard,PermissionMatrix,ColumnHighlight,RoleDialog}.tsx} (new), web/tests/units/roles/ (new) Reuses: DirtyBar, FormDialog, R1's checkbox and icon map Size: L Done when:

  • Header "Vai trò & quyền" / "Mỗi vai trò làm được gì. Quản trị viên luôn làm được mọi việc." with outline "Vai trò mới" (for people.roles.manage).
  • Carousel: scroll-snap, 3 cards per view at a content width of 1000, 2 at 640, else one at 86%; round 40px arrows that fade at the ends; dots (the active one 22px). Cards: icon tile in the role's colour token (dark → primary, lime → brand-soft, blue → info-soft, green → success-soft, amber → warning-soft, rose → danger-soft, gray → muted), a "Tùy chỉnh" ("Custom") pill, the pencil, name (22/600), description, "{n} người" and "{n} quyền", "Sửa quyền ↓". Assistant's card (0 people) and matrix column are shown as the deck draws them, with cells editable under the subset rule (D2); only role pickers leave it out (DG-04, D14). Built-in role names come from role.builtin.<key> while the stored name is still the seeded one (decision above).
  • Matrix: "Quyền" minmax(240px, 2fr) plus 104px per role, 44px header on muted, group rows (Content, Teaching, People, System) on sidebar, rows at least 46px with label and hint, 18px checkboxes labelled "{permission} — {role}". Labels come from permissionLabels.ts, a map over the generated PermissionKey union that fails pnpm typecheck when a key is missing, including D12's "Tạo tài khoản học viên" ("Create student accounts").
  • Admin cells are locked at .5 with "Quản trị viên luôn có quyền này" ("Admins always have this"), except Take tests (DG-51); cells the subset rule forbids the caller are disabled with "Bạn chỉ đổi được những quyền bạn có." ("You can only change what you can do yourself.").
  • Clicking a role header or "Sửa quyền ↓" highlights the column (?role=<id>) with a 2px primary frame that moves over .2s (instant under reduced motion) and scrolls to the matrix.
  • The DirtyBar: "{n} quyền đã thay đổi. Mọi người nhận quyền mới ở lần tải trang tiếp theo." (en per the deck, with plurals); when a role losing Take tests has members mid-test, "{n} people are taking a test right now." Discard reverts; Save sends one setRolePermissions; a STALE_WRITE reloads the matrix and keeps the unsaved cells marked.
  • New role: name, "Sao chép quyền từ" (Teacher, Student and custom roles), description, "Vai trò này đã tồn tại" on a duplicate; on success the carousel scrolls to the new card. Edit role: the twelve icons, name, description, the seven colours; the Admin dialog says "Quản trị viên luôn giữ mọi quyền." No card or dialog offers Delete (DG-52).
  • States: skeleton, error with Retry, save errors in a callout.
  • Unit tests: locked and forbidden cells (the caller's own role is disabled except the Admin's Take tests), the Assistant column shown and absent from "Sao chép quyền từ", dirty count, highlight, carousel by width, keyboard (arrows, dots, cells), the exhaustive label map, a built-in name in English until renamed. Compared with the deck (route: roles) at the five widths, light and dark. Keys vi first.

T-R5.25 — Classes across teachers

Depends on: T-R5.20, T-R5.11 Touches: web/src/features/classes/pages/admin/AdminClassesPage.tsx (new), web/src/features/classes/components/admin/{AdminClassesTable,AdminClassDialog,ChangeTeacherDialog}.tsx (new), web/tests/units/classes/admin-classes.test.tsx (new) Reuses: DataTable, RowMenu, FormDialog, ConfirmDialog, CopyField Size: M Done when:

  • Header "Lớp học" / "Mọi lớp của mọi giáo viên." with primary "Lớp mới"; tabs Active · Archived with counts; a 280px search ("Tìm lớp hoặc giáo viên"); the teacher=disabled filter as a removable chip; the callout when joining by code is off (T-R5.7).
  • Table: Class (name, schedule sub-line), Teacher (avatar, name, "Đã tắt" when disabled), Students (right-aligned), Join code (monospace full code with an inline ↻), Created ("Th6 2026"), menu. Teacher appears at a content width of 600, the code at 760, Created at 900; a folded teacher joins the sub-line. Rows are not links (there is no admin class detail).
  • Menu: Edit class, Change teacher, Rotate join code, then Archive (danger) or Restore on the Archived tab. Rotate confirms with the deck's copy and toasts the new full code; Archive confirms ("Học viên không thể tham gia hay bắt đầu bài nữa. Kết quả được giữ lại."); Restore has no confirm. These call the teacher workspace's operations (decision above).
  • New class: name, teacher (active workspace.teacher holders), schedule. Edit class: name and schedule, with the teacher shown read-only and "Đổi giáo viên" as its own action, so a reassignment always offers the notice. Change teacher: "Giáo viên mới" and "Báo cho cả hai giáo viên" (on).
  • States: skeleton, empty per tab, error with Retry.
  • Unit tests: folding, menu per tab, each dialog. Compared with the deck (route: classes) at the five widths, light and dark. Keys vi first.

T-R5.26 — Audit log

Depends on: T-R5.20, T-R5.12 Touches: web/src/features/audit/{api.ts,actions.ts,pages/admin/AuditLogPage.tsx,components/{AuditRow,AuditDiff,ExportAuditDialog}.tsx} (new), web/tests/units/audit/ (new) Reuses: DataTable, FormDialog, Pager, R1's segmented control and select Size: M Done when:

  • Header "Nhật ký kiểm tra" / "Bản ghi các thay đổi quan trọng. Không thể sửa hay xoá." with outline "Xuất CSV". The filter row has two groups, which wrap onto separate lines when they do not fit. Left: a segmented control All · Accounts · Classes · Attempts · Tests · Media · Imports, each option with its count badge from facets (T-R5.12), formatted for the locale ("1.248") and shown at zero too. Right, as v2 draws it (74d row A-03): a live search (36px, flex: 1 1 260px, at most 320, "Search person, action or entity", aria-label="Search audit log", with a clear button) and a "Date range" menu button (calendar, the label, a chevron) offering Today, Last 7 days, Last 30 days, All time and "Custom range…", which opens a dialog with From and To date fields ("Must be on or after the start date" on To); a custom label reads "{d Mon} – {d Mon}". "Reset" shows while a search or a non-default range is set and clears both. The pager counts the filtered rows. Filters live in the URL (category, q, range, from, to).
  • The control is the segmented control with count badges that R4's Imports history uses (74-r4.md T-R4.36), with the same semantics and scrolling track: toggle buttons with aria-pressed, not the deck's role="tablist". The counts follow the search and the range and stay the same whichever option is selected; selecting an option resets the page.
  • Rows: When ("24 Th9, 20:31" in the viewer's zone), Who (avatar and name, or "Hệ thống"), Action (category dot and the sentence from audit.action.<action>, a humanised fallback for an unknown action), Entity ("{type} · {label}", or "{type} đã xoá"), IP (monospace), chevron. When folds under Who below 700, Entity under Action below 860, IP hides below 1040.
  • Expanding a row shows the diff (field, from struck through in danger-ink, to in success-ink, monospace; two columns below 560) and the device line ("Mac · Chrome"). Hinted join codes render as returned.
  • Pager "1–10 / 1.248 mục", fixed at 10, no size control, as drawn.
  • Export dialog: period, format (CSV / JSON), "Chỉ bộ lọc hiện tại" (on when a filter is active); submitting downloads the streamed file and toasts; EXPORT_TOO_LARGE asks for a shorter period.
  • States: skeleton rows, "Không có mục nào trong khoảng này." for an empty log in the default range, "Không có mục nào khớp bộ lọc này." ("No entries match these filters.") when a search, a category or a range other than the default is set, error with Retry.
  • Unit tests: folding, expand and collapse, System actor, unknown action fallback, the export filter toggle; the live search and its clear button, each preset range, the custom dialog with its date refusal, Reset, and the "No entries match these filters." row; each option shows its facet, a zero included; the counts follow the search and the range and stay the same when another option is selected; selecting an option resets the page. Compared with the deck (route: audit) at the five widths, light and dark, including the widths where the filter row wraps. Keys vi first.

T-R5.27 — API reference

Depends on: T-R5.20, T-R5.19 Touches: web/src/features/api-reference/{pages/admin/ApiReferencePage.tsx,groups.ts} (new), web/src/features/auth/components/SettingsSections.tsx (ApiDocsSection moved out), web/src/features/settings/pages/teacher/TeacherSettingsPage.tsx (R4: the api section removed), web/tests/units/api-reference/ (new), web/tests/units/contract/api-groups.test.ts (new) Size: S Done when:

  • The page (max 1080) holds the hero: a charcoal tile, "Quizzivy API", "OpenAPI {openapi} · v{version} · {n} endpoint"; outline "openapi.json" (download); primary "Mở tài liệu" ("Open reference") with the pending "Đang mở…" state, which opens the docs session and the reference as ApiDocsSection does today, and both error alerts (pop-up blocked, network) with Try again.
  • Base URL row: VITE_API_BASE_URL with Copy (no /v1, DG-10). The security note says what is true (DG-10): "Mở trong thẻ mới bằng một phiên xem tài liệu ngắn gắn với tài khoản của bạn. Phiên này chỉ đọc được tài liệu và không thay bạn gọi API."
  • "Có gì bên trong": a card per tag (auto-fill minmax(300px)), with an icon and vi/en name and blurb from groups.ts; a card opens the reference at #tag/{tag}. api-groups.test.ts reads api/openapi.yaml and fails when a tag has no entry.
  • R4's Settings loses its API reference section and /teacher/settings/api redirects to /admin/api.
  • Unit tests: pending, both errors, copy, group links. Compared with the deck (route: apiref) at 360 and 1280, light and dark. Keys vi first.

T-R5.28a — System settings: frame, Organization and Sign-in

Depends on: T-R5.20, T-R5.5, T-R5.6, T-R5.7 Touches: web/src/features/organization/{api.ts,pages/admin/SystemSettingsPage.tsx,sections/{Organization,SignIn}.tsx,components/{LogoDialog,SignInImpact}.tsx} (new), web/tests/units/organization/ (new) Reuses: SettingsLayout, DirtyBar, FormDialog, FileDrop, ChipInput, R1's switch Size: M Done when:

  • "Cài đặt hệ thống" / "Áp dụng cho mọi người ở {organization}."; R4's SettingsLayout with a 200px section column (a row on phones): Organization, Sign-in, Data & privacy, Plan & usage at /admin/settings/:section. The four sections share one form and one DirtyBar ("Bạn có thay đổi chưa lưu."), stay mounted, and save with expectedRevision; STALE_WRITE offers a reload.
  • The section column is sticky from a content width of 768 (position: sticky; top: 16px, capped and scrolling), through D4's StickyAside (T-D4.20), which R4's SettingsLayout already carries. This task adds no sticky rule of its own (v2, 74d row A-04).
  • Organization: the 56px logo tile (the organization's logo, else the Quizzivy mark), "Thay logo" opening the dialog (a required square SVG or PNG of at least 256 × 256, and an optional dark version; "Logo đã tải lên. Lưu cài đặt để áp dụng."; server refusals shown on the field); fields Organization name, Short name ("Dùng trong email và thẻ trình duyệt"), Website, and DG-41's Contact email, Front-desk phone, Front-desk hours and Address.
  • Sign-in: the four switches with the deck's hints (vi first), and Allowed Google domains in a ChipInput (Enter, comma or space adds; lower-cased, leading @ stripped, deduplicated; Backspace removes the last).
  • While Sign-in differs from the saved values, SignInImpact asks POST /admin/settings/sign-in-impact (debounced) and shows "{n} người sẽ không đăng nhập được nữa." as a warning callout; a lockout disables Save and explains which rule it breaks. A 422 SIGN_IN_POLICY_LOCKOUT from Save shows the same.
  • States: skeleton, load error, save error.
  • Unit tests: one DirtyBar across sections, discard, the chip input, the impact callout and lockout, the logo dialog. Compared with the deck (route: settings, Organization and Sign-in) at 360, 768 and 1280, light and dark. Keys vi first.

T-R5.28b — System settings: Data & privacy, Plan & usage, and the export page

Depends on: T-R5.28a, T-R5.15, T-R5.17b, T-R5.18 Touches: web/src/features/organization/{sections/{DataPrivacy,PlanUsage}.tsx,components/{RetentionConfirm,ExportDataDialog}.tsx} (new), web/src/features/exports/{api.ts,pages/admin/DataExportPage.tsx} (new), web/tests/units/{organization,exports}/ (new) Reuses: DirtyBar, FormDialog, Meter, useIdlePolling Size: M Done when:

  • Data & privacy: Keep attempt answers (1 year, 2 years, Forever), Keep focus-loss events (6 months, 1 year, "13 tháng (mặc định)", 2 years) and "Ẩn danh tài khoản đã tắt sau" (Never, 1 year, 2 years) with DG-07's wording ("Kết quả được giữ; tên và email bị xoá."). They join the DirtyBar. Saving a shorter retention first shows RetentionConfirm with the impact counts ("Tối nay sẽ xoá câu trả lời của {n} lượt làm bài…").
  • Export all data (the danger-bordered card): "Xuất toàn bộ dữ liệu?" with "Gửi liên kết tới" read-only (the requester's address), "Bao gồm câu trả lời" (off, "Tệp sẽ lớn hơn nhiều"), and a toast "Đã yêu cầu xuất. Liên kết sẽ gửi tới {email}"; EXPORT_IN_PROGRESS and the hourly limit are explained.
  • Plan & usage: "Gói trung tâm · gia hạn {date}" when configured; meters for Seats, Media storage, Import document storage ("· {n} lần nhập") and Teachers, each without a limit when none is configured, then v2's "AI credits" row ("{used} of {pool} this month", accent tone, 74d row A-06) from Usage.aiCredits, absent while it is null; read-only.
  • /admin/exports/:id: status (queued and running poll every five seconds through useIdlePolling), size, expiry, and "Tải xuống" (single use; afterwards "Đã tải lúc {time}"); failed, expired and not-yours (404) states. The email's link lands here after sign-in through next=.
  • Unit tests: the retention confirm, the export dialog, meters without limits, the AI credits row present and absent, the export page states with fake timers. Compared with the deck (route: settings, Data & privacy and Plan & usage) at 360, 768 and 1280, light and dark. Keys vi first.

T-R5.29 — The front door reads the organization

Depends on: T-R5.5, T-R5.6 Touches: web/src/lib/organization.ts (new), web/src/lib/config.ts, web/src/components/shared/BrandFrame.tsx, web/src/app/pages/SystemFrame.tsx, web/src/features/auth/pages/{LoginPage,ForgotPasswordPage}.tsx, R1's join page in web/src/features/join/, web/tests/units/auth/organization.test.tsx (new) Size: S Done when:

  • useOrganization() reads GET /public/organization with a five-minute stale time and never blocks a render; each value falls back to R1's VITE_ORG_* config, which stays (decision above), and a line with neither stays hidden (DG-41).
  • The Sign in brand panel and the system-page caption use the name and, when set, the organization's logo (light or dark by theme); Forgot password shows the front-desk phone and hours; the login page hides the password form when password sign-in is off and the Google button when Google is off; the join page says joining by code is off when it is.
  • System pages render from cache or config when the request fails (they must work with the API down).
  • Unit tests: fallback order, hidden lines, each sign-in combination, API down. Checked at 360 and 1280 in both themes.

T-R5.30 — Primitive defaults flip

Depends on: T-R5.20, R4's T-R4.48 Touches: web/src/components/ui/**, web/src/components/shared/**, web/src/index.css, web/src/layouts/*.tsx, web/src/app/pages/*, web/src/lib/theme.ts, web/tests/units/ui/{deck-scale,icon-scale}.test.tsx, web/tests/units/styles/tokens.test.ts Size: M Done when:

  • Commit 1, mechanical and nothing else: each in-data-[scale=deck]: class becomes the plain class and the class it overrode is deleted; every data-scale="deck" attribute is removed.
  • Commit 2: --radius is 8px at :root (sm 6, md 8, lg 10 everywhere) and the scoped rule goes; the aliases R1 kept (destructive-ink, destructive-foreground, success-foreground, warning-foreground) are removed and their call sites swept; useForcedLightTheme is deleted once no caller remains.
  • deck-scale.test.tsx becomes ui-scale.test.tsx, asserting the deck classes with no ancestor attribute; icon-scale.test.tsx's assertions of the old sizes move to the deck sizes, each change listed in the PR as intended; tokens.test.ts stops expecting the aliases.
  • DateTimeField's inherited (off-deck) branch goes with the other useDeckScale branches, since every date input renders on a deck surface (T-R4.48). Beside this task, R5 revisits what T-R4.48 kept because it is still imported: PageAside, SideColumn, slots.ts and useColumnWidth (the question editor's settings column), GradeByQuestion and SettingsSections; each goes with the screen that stops using it.
  • grep -rn "data-scale" web/src finds nothing; pnpm lint, pnpm typecheck, pnpm test:unit and pnpm build are green.
  • Every console and the front door measured at 1280 and 360, light and dark: no element moves by more than 1px against the same pages before the flip, since each already rendered inside the scope.

T-R5.31 — Security and the live E2E

Depends on: T-R5.1 … T-R5.29 Touches: server/tests/admin_escalation_flow_test.go (new), R2's spec-driven forbidden test, server/internal/core/router/tests/{auth_middleware_test.go,public_routes_test.go}, web/tests/e2e/admin-console.live.spec.ts (new), web/tests/e2e/support/live.ts, web/playwright.config.ts (EMAIL_SINK_DIR for the live project), .github/workflows/ci.yml (EMAIL_SINK_DIR: ${{ runner.temp }}/email-sink in the e2e-live env), Makefile (dev-api default) Size: M Done when:

  • The API and Playwright read one EMAIL_SINK_DIR: CI's e2e-live job sets it for the API process and the Playwright run alike, make dev-api defaults it for local runs, and playwright.config.ts reads the same variable rather than its own path.
  • admin_escalation_flow_test.go (e2e tag), as a custom role holding people.users.manage and people.roles.manage: creating an Admin, resetting or disabling an Admin, granting itself system.settings.write, granting another role a key it lacks, copying the Admin role, changing its own role and importing a row with role admin are each refused; as an Admin, disabling or demoting the last Admin and saving a lockout policy are refused.
  • R2's spec-driven test shows every new /admin/* operation answers 403 to a Teacher and a Student token; the open-operation pin lists exactly R5's four additions with their justifications; every public operation's 429 is tested; the credential-minting test lists createUser, resetUserPassword, resetUsersPasswords, createUserImport, downloadUserImportSheet, setPasswordWithLink and listAllClasses, each added by its own task's PR.
  • admin-console.live.spec.ts (a claim about the two halves meeting: email, token and session across a real API): the owner signs in and lands on /admin; adds a teacher with an emailed link; the test reads the link from the sink; a fresh context opens it, sets a password and lands on /teacher with empty lists; the owner removes content.media.write from Teacher and the teacher's next upload is refused, then restores it; the owner disables the teacher, whose next navigation shows R1's session-expired overlay.
  • E2E 9 (payload leak) is green with its key list unchanged; pnpm e2e:live is green.

T-R5.32 — Docs

Depends on: the tasks whose behaviour each part describes Touches: docs/quizzivy-spec-v0.3.md (§5, §6, §8, §9, §13, §15, §16; version bumped per §18), AGENTS.md, docs/plan/{20-data-model,30-risks,40-open-items,50-nfr-registry,60-backend-architecture,70-redesign-overview}.md, docs/setup/{operations,deploy,dns}.md, .env.example Size: S Done when:

  • Spec: §5 admin user management and its guards, set-password links, the sign-in policy and lockout, activity and statuses; §6 joining by code switched off by the organization and the legacy lookup gone; §8 and §9 the Admin console, /set-password and /admin/exports/:id with their "Availability: R5" lines removed; §13 the new tables, columns, grants and the maintenance role; §15 every new operation and error code; §16 R5 marked done.
  • AGENTS.md: the open operations and their count; the Admin console under "Authentication and authorization"; the repository map (modules/organization, modules/audit, modules/exports, platform/email, the new features/*); "Local development" (re-run provision-db.sh for the maintenance role, EMAIL_SINK_DIR, the worker runs user imports and exports); the "Redesign in progress" note without data-scale and forced light.
  • 20-data-model.md: every R5 migration and its deviations (the boolean-key singleton, the outbox secret, hash-only links, the maintenance role's column grants, the answer-removed marker); 60-backend-architecture.md: the three modules and platform/email; 30-risks.md: PR-2's evidence; 40-open-items.md: this file's open items as O-R5 entries; 50-nfr-registry.md: NFR-P06 for email logs and the nightly wake under NFR-A10; 70-redesign-overview.md: §4.2's open-operation schedule checked against what shipped (eleven after R5: checkSetPasswordLink, setPasswordWithLink, getPublicOrganization, getOrganizationLogo), D16's workflow, role and grant rule as built, and §6's retention row.
  • operations.md, deploy.md and dns.md as the tasks above describe.
  • Every PR that changed behaviour already changed its lines; this task checks the set and closes gaps before the release.

T-R5.33 — Browser verification against the deck

Depends on: T-R5.20 … T-R5.30 Touches: seed/NN-dev-admin-console.sql (new, development only; numbered at merge, after R2's access and classroom seeds and R4's teacher-workspace seed, which it relies on and names in its header), seed/99-assert.sql Size: M Done when:

  • The dev seed holds data shaped like the deck's fixtures: three admins, nine teachers (one disabled, teaching a class), students with every status, six active classes and one archived, a custom role "Tổ trưởng", audit entries across every category including a System one, and fourteen days of activity.
  • The splash hand-off skeleton registered for each destination is compared with docs/design/deck/Quizzivy Splash.dc.html#skeleton at destination student, teacher and admin, at 360 and 1280, light and dark.
  • Every /admin/* route, the Add user, import, role and settings dialogs and the user sheet compared with design-deck on 5175 at 360, 768, 1024, 1280 and 1440, light and dark, sidebar expanded, collapsed and as a drawer; the dev server confirmed fresh; sizes measured with getBoundingClientRect; the table (screen × width × theme → match, fixed, or DG id) in the release PR. /set-password checked at 360 and 1280.
  • Where an Admin-deck heading sits 6px lower than the product's, the comparison cites DG-117 and does not open a new gap: the Admin deck's seven h1s set no line-height and inherit the page root's 1.5, as the Sign in deck's do. The row's "Where" gains the Admin deck in the same pull request.
  • Deviations fixed in the owning task's branch or filed as issues; Vite, the API, the worker and the deck server stopped afterwards.

T-R5.35 — AI credits

Depends on: T-R5.20, T-D4.20 (StickyAside), D4 in production (74d T-D4.31 and T-D4.32: the operations, the permission key and the maintenance command) Touches: web/src/features/ai-credits/{api.ts,pages/admin/AiCreditsPage.tsx,components/{PoolCard,RequestCard,TeachersCard,AllowanceDialog,RulesCard,AddCreditsDialog}.tsx} (new), web/src/app/router.tsx (/admin/ai, lazy), web/src/layouts/adminNav.ts (T-R5.20's AI credits item), web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/ai-credits/ (new) Size: M

The Admin page the deck draws at route ai (74d inventory row A-05), on the operations D4 ships, all ai.credits.manage: GET /admin/ai/credits, PATCH /admin/ai/rules, POST /admin/ai/credits/additions, PUT /admin/ai/teachers/{userId}/allowance and POST /admin/ai/credit-requests/{id}/approve|decline. Until it ships, Thuong uses cmd/maintenance ai-credits (74d Q28, decided 2026-10-10). The page changes no server code.

Done when:

  • Route /admin/ai, lazy, crumb "AI credits", handle.permission ai.credits.manage (T-R5.20's guard); router-chunks.test.ts passes untouched, since the admin tree stays out of the entry chunk. Header "AI credits" / "Monthly allowances for AI features. Usage resets on {1 Month}." (the first of next month in APP_TIMEZONE, from the read) with a primary "Add credits".
  • Add credits is a dialog: Amount 50,000 / 100,000 / 250,000 (a segment), "Added to the centre pool right away. The extra is paid for outside Quizzivy. Added credits don’t carry over to next month." (DG-196: the deck's "billed on your next invoice" is not taken), and a toast. It calls POST /admin/ai/credits/additions, which records the approver.
  • The pool card: "Centre pool · {Month}" and "Centre plan {pool} (+ {n} added) · resets 1 {Month}"; "{used}" at 28px and "of {pool} credits used"; a 10px bar (accent; warning from 75%; danger from 90%) with a 2px projection tick ("Projected by 30 September"); three facts in a grid: "Left this month" ("{d} days to go"), "On pace for ≈ {n}" ("by 30 September, within the pool", or in warning ink "More than the pool. Add credits or lower allowances.") and "Teacher allowances {sum}" ("Can add up to more than the pool, since few teachers use all of theirs"). The projection is the read's, never computed in the browser.
  • Request cards, one per pending request, in warning tones with a hand tile: "{name} asked for {n} more credits · {used} of {allow} used", the reason and its age, "Decline" (toast "{name} will be told the request was declined") and "Approve {n}" (primary; raises the allowance; toast "{name}’s allowance is now {n} this month"). Both buttons are disabled while their request is in flight, and a request decided elsewhere leaves the list on the next read.
  • The Teachers card, "{n} teachers used AI this month · {m} haven’t yet": rows sorted by used over allowance, each with the avatar, name and email, "{used}" and "of {allow}" ("· custom" when custom) over a 6px bar, a status pill with a dot (On track, Near limit from the warn percent, At limit, Paused) and "Edit allowance" (pencil), which opens "AI allowance · {name}": "{used} credits used so far this month.", Monthly allowance Default · {n} | Custom, Credits per month (a custom value of at least 1,000: "Enter at least 1,000"; "Takes effect right away. Usage so far still counts.") and a switch "Pause AI for this teacher" ("AI buttons stay visible but can’t be used until you turn this off."). It calls PUT /admin/ai/teachers/{userId}/allowance.
  • The Rules card (flex: 1 1 320px, sticky through StickyAside from a content width of 900) for "every teacher without a custom allowance", saved through PATCH /admin/ai/rules: Monthly allowance per teacher 20,000 | 50,000 | 100,000 (default 20,000, 74d Q30; "Teachers see their balance in Settings → AI assistant."); Warn the teacher at 80% | 90% ("They get a notification."; the email joins with R7's T-R7.23); When a teacher runs out: Pause until next month | Let them ask for more ("Requests appear at the top of this page."); and When the centre pool runs out, which has one value, "Pause AI for everyone", shown as a fixed line and not a choice (DG-196: the deck's "Allow 10% extra" and "Extra use is billed on the next invoice." are not taken; "Add credits" is the approval).
  • States (DG-83): a skeleton per card; an error with Retry; "No requests" leaves the request section out; a read that answers 501 (the provider is off) shows one sentence, "AI is not turned on for this school.", and no card.
  • Unit tests: the bar's three tones at 74, 75, 89 and 90%; the projection tick; approve and decline requests and their toasts; the allowance dialog's 1,000 minimum and its pause switch; the add-credits dialog; the rules card shows one pool rule; a 501. Compared with the deck (route: ai) at 360, 768, 1024, 1280 and 1440, light and dark. Keys vi first.

T-R5.34 — Release v0.12.0

Depends on: T-R5.1 … T-R5.33, T-R5.35, T-R5.36 Touches: docs/plan/, release notes Size: S Done when:

  • The release checklist below is complete, with Thuong's go before main.

Release checklist

  • Every Done-when box above is ticked on work/redesign-r5, with develop merged in.
  • 70 §8.1: every CI step locally, in order, judged by exit code — make lint, make gen-check, make test-api (docker compose up -d db minio), 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 (auth and join changed), node scripts/check-design-deck.mjs.
  • Canaries green and untouched: publish_snapshot_test.go, audio-player.test.tsx, attempts/application/tests/events_test.go, client.refresh.test.ts, router-chunks.test.ts (the ADMIN assertions added, none weakened).
  • Every PR's new vi keys reviewed by Thuong (D11), including the four email templates.
  • Security: the leak reviews for checkSetPasswordLink, setPasswordWithLink, getPublicOrganization and getOrganizationLogo, and for the /join/preview change, are in their PRs; T-R5.31 is green.
  • Production prerequisites before merging to main:
    • Resend: quizzivy.com verified (SPF, DKIM, DMARC), open and click tracking off; Fly secret RESEND_API_KEY; SECRETS_KEY present since T-R0.8;
    • fly.toml [env]: EMAIL_FROM, APP_URL, optional EMAIL_REPLY_TO, EMAIL_DAILY_LIMIT (100 unless Thuong upgraded the Resend plan, O-R5.1) and any PLAN_* values;
    • ./scripts/provision-db.sh "$NEON_ADMIN_URL" run once, with QUIZZIVY_MAINTENANCE_PASSWORD, so quizzivy_maintenance exists before the grants migration;
    • the GitHub environment production-maintenance holding MAINTENANCE_DATABASE_URL for that role; RETENTION_SWEEP_ENABLED left unset.
  • Rehearsed on a Neon branch of production: goose up with timings (the audit index build recorded, indisvalid true); the legacy join-code count is zero (T-R5.1); maintenance retention-sweep as a dry run with the default settings reports the same event count as retain-integrity's dry run and no answers or accounts; the branch deleted.
  • Exam windows checked: no assignment closing within the deploy window.
  • work/redesign-r5 → develop (--no-ff); release/0.12.0 cut with the release notes, the spec version confirmed and the NFR registry reviewed.
  • Release notes: the new Admin console (owners land there first and on their last workspace afterwards; Teacher view switches back); adding people one by one or from a CSV or Excel file, with a set-password link by email or a temporary password to hand over, never a password in an email; roles and the permission matrix; classes across teachers; the audit log and its export; organization details on the sign-in and forgot-password pages; sign-in rules; data export by a single-use download; retention settings, whose defaults keep today's rules; teachers' old /admin/… bookmarks still reach their pages; a tab still signed in with a temporary password is sent to change it before anything else works (O-R5.4).
  • release/0.12.0 → main with Thuong's go; Deploy runs the API (release_command migrates) then Pages.
  • Smoke on production: /livez; /healthz with database: ok; the worker group running; GET /public/organization; the owner signs in, lands on /admin and switches to the Teacher workspace and back; enters the organization details from the VITE_ORG_* repository variables and saves; adds a test teacher with an email link to a second address of Thuong's; the message arrives from quizzivy.com with SPF, DKIM and DMARC passing; the link sets a password and the teacher sees an empty workspace; the owner disables the test teacher; the audit log shows each step; a data export arrives by email and downloads once.
  • The retention workflow run by hand as a dry run on production; Thuong reviews the report; only then RETENTION_SWEEP_ENABLED=true.
  • Tag v0.12.0; back-merge main → develop by PR; close the R5 epic. Rollback is roll forward (70 §8).
  • Neon compute hours measured for the week after and recorded against NFR-A10 (PR-7): the nightly sweep adds one wake a day.
  • Hand-off: the VITE_ORG_* build values and repository variables stay as the Landing's contact source (R11) and the front door's fallback; R7 adds its templates to platform/email and extends content transfer to shares and question sets, and the anonymisation owner check to question sets; R10 extends both to word lists and courses. A release that adds or widens a cmd/maintenance command grants the maintenance role what it needs in its own migration. Nothing R5 expanded is left NOT VALID.

Open items

  • O-R5.1 — Resend plan and the daily budget. The free tier sends 100 emails a day and 3,000 a month; EMAIL_DAILY_LIMIT defaults to 100 and moves the rest to the next day. Thuong decides whether to upgrade before the first class-sized import by email.
  • O-R5.2 — Bounces and complaints. R5 has no Resend webhook; a hard bounce shows only as a failed outbox row in the logs. R7 adds Resend's signed webhook (POST /public/email-events, RESEND_WEBHOOK_SECRET) and app.email_suppressions, which the dispatcher skips (70 D9).
  • O-R5.3 — "Send the link to" on Export all data. The field is read-only with the requester's address (decision above); the design team is asked to redraw it.
  • O-R5.4 — Enforcing a temporary password on the server. R2 deferred it to R5. Default: built as the decision above says (403 PASSWORD_CHANGE_REQUIRED for everything but the current user, change password, logout and refresh), because a temporary password a teacher or admin has seen otherwise stays usable against the API indefinitely. It changes behaviour for sessions already in flight; Thuong confirms or drops it before T-R5.9b merges.
  • O-R5.5 — An Admin's Students and Classes lists in the teacher workspace. R4 decides that content lists show the caller's own rows for everyone, while Students and Classes keep scope.all lists until the console's Users and Classes take them over (70 §4.1, DG-53). Default: R5 leaves both lists as R4 ships them; narrowing them to the Admin's own rows now that the console exists is Thuong's call.
  • O-R5.6 — Self-join by an Admin who takes tests. Turning on the Admin's Take tests (DG-51) opens the student app to that Admin, and the server accepts their joinClass, which needs only learning.take_tests. Rosters, resets and assignment targets never treat the Admin as a student, and JoinPage still says joining is for students (72-r2 open items, T-R2.17). Default: refuse the self-join on the server as well, through app.student_like_roles in the enrol path, and hide the student app's join links for a user who is not a strict student. Thuong decides.
  • Admin language control. A user whose only workspace is the console has no language switch in the deck. The account menu's "Ngôn ngữ" item (T-R5.20) is an interim control; a gap-register entry asks the design team to draw one.
  • DG-04 — Assistant linkage. The card and matrix column are shown as drawn; Assistant is left out of every role picker and refused by the API there until drawn (D14).
  • DG-57 — not built in R5. Terms land in R8 (T-R8.13), the leads inbox and its recipients in R11 (T-R11.9), and maintenance scheduling stays R1's command line (DG-42).
  • DG-05 — the set-password page and the delivery toggle's wording on Add user, Reset and import, built from deck parts and listed for sign-off.
  • DG-07 — retention wording and the "13 months (default)" option kept for O-23 parity.
  • DG-10 — the download button serves openapi.json, not the deck's openapi.yaml.
  • DG-54 — account statuses and the Users badge, built to the register's Meanwhile rule.
  • DG-55 — plan values and the Attempts kept denominator. Configuration, shown and not enforced; Attempts kept is a count without a meter.
  • DG-56 — Transfer content, built from deck parts in the user sheet; listed for sign-off.
  • DG-83 — empty, loading and error states on Overview, Users, Roles, Audit and Settings, built as one sentence and one action; listed for sign-off.

Generated from docs/plan/75-r5.md. Edit that file in the repository; changes made on this page are overwritten.

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