Repository navigation
Plan 75 R5
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_idxon a Neon branch of production, and the retention sweep's dry run there reports the same integrity-event count as today'sretain-integrityand no answers or accounts (the defaults are O-23's rules); - no
data-scaleattribute or scoped variant is left inweb/src; - every CI step of 70 §8 is green locally, including
pnpm e2e:live; released asv0.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 aReferer, 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 identical410. 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 anIdempotency-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 throughIMPORT_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 andsystem.data_export, returns a presigned URL valid for 60 seconds, and a second request answers410. 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_maintenancerole. Fly secrets are visible to every process group, so the API would see a credential stored there. The role holds only whatcmd/maintenanceneeds, 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 inprivileges_test.go(70 D16). The rekey runs from the operator's machine with both join-code keys andMAINTENANCE_DATABASE_URL, never from the workflow. The sweep wakes Neon once, at 03:00 local, and scheduled runs apply only onceRETENTION_SWEEP_ENABLEDis 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_epochand callsPrincipals.Forget(userID).setRolePermissionsandupdateRolecallPrincipals.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_passwordis set,RequirePermissionanswers403 PASSWORD_CHANGE_REQUIREDto every operation exceptgetCurrentUser,changePassword,logoutandrefreshSession; the flag travels in the cached principal. R2 deferred this to R5; it is the default pending Thuong (O-R5.4). -
deleteUserstays 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.payloadwith{"removed": true};auto_score,manual_scoreand 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 infly.tomlare 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 andscope.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 byratelimit.PrincipalKey, the limiter R4 adds afterRequirePermission; "per IP" and "per token" limits stay inRateLimits(), 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: attachmentand a sandbox CSP. An SVG is checked against an allowlist and refused, not rewritten, when it fails.img-srcgains 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.allincludes 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;listAuditLoghad 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
/adminthe 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. TheVITE_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. -
availabilitystays its own module. R1 leftorganizationfree to absorb it; nothing is gained by merging a read-only snapshot into a module with writes, logos and policy.
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: aDOblock raises if any unrevoked, unexpired code lacks R2's ciphertext column (named as in72-r2.md); unrevoked legacy codes that have already expired getrevoked_at = expires_at; thenADD CONSTRAINT class_join_codes_live_encrypted CHECK (revoked_at IS NOT NULL OR code_ciphertext IS NOT NULL) NOT VALIDandVALIDATE CONSTRAINTin the same file (a small table;VALIDATEtakesSHARE UPDATE EXCLUSIVEand blocks no read or write). Down drops the constraint and leaves the revocations. - The
getDashboardfields 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.mdrecords the constraint; integration test: an insert of an unrevoked code without ciphertext fails.
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_pathlog 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, androuter.gono 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.gocovers 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.mdT-R3.3's boxes are ticked with a pointer to this PR.
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.SenderisSend(ctx, Message{To, Subject, HTML, Text, ReplyTo, IdempotencyKey, Tags}) (providerID string, err error), with errors classified as transient or permanent. -
resend.goposts tohttps://api.resend.com/emailswithAuthorization: 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.gowrites each rendered message as a JSON file underEMAIL_SINK_DIRand logs a redacted line; the server refuses to start with it whenAPP_ENV=production. The live E2E reads links from it (T-R5.31). - Config:
RESEND_API_KEY(Fly secret),EMAIL_FROM(Quizzivy <no-reply@quizzivy.com>) andAPP_URL(https://app.quizzivy.com) form one all-or-nothing group, required in production;EMAIL_REPLY_TOis optional;EMAIL_DAILY_LIMITdefaults to 100 andEMAIL_SEND_RATEto 2 per second. Without the group outside production the sink is used.APP_TIMEZONE(defaultAsia/Ho_Chi_Minh, validated bytime.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_KEYandAPP_TIMEZONE..env.example,fly.toml[env]and thedeploy.ymlpreflight (group "email" …,need SECRETS_KEY) are updated. -
platform/secrets(new): HKDF-SHA256 fromSECRETS_KEYper 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'sclasses/domain/joincode_keys.gostays where it is, derived fromJOIN_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 fromSECRETS_KEYwith the purposeemail-outbox, with the row id as AAD. After commit the caller signals the in-process dispatcher. -
operations.mdgains a "Secrets key" section: generate it withopenssl 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/templatefor HTML andtext/templatefor text, each file definingsubject,textandhtml; 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'susers.locale(R4), elsevi. Wording:account_inviteandpassword_reset_linksay the link works once and for 24 hours;sign_in_invitesends the user to sign in with Google;data_export_readylinks to the app page and says the download works once. -
core/jobs/email.goruns in the API and in the worker. It wakes on the in-process signal, on a timer armed only for the earliest pendingdeliver_after, and once at start-up. It claims up to 10 rowsFOR UPDATE SKIP LOCKED, sends atEMAIL_SEND_RATE, and counts today's sends (local day inAPP_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 setsfailed_at. Sent and failed rows losesecret_ciphertextin the same statement. Logs carry the outbox id, template, outcome and recipient domain only (NFR-P06). -
docs/setup/dns.mdlists the records Resend's dashboard gives forquizzivy.com(thesendsubdomain's MX and SPF TXT, theresend._domainkeyDKIM TXT) and a DMARC record starting atp=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 undermissingkey=error; subject, text and HTML are non-empty; a name containing<script>is escaped; the text part contains the link; no{{survives);resend_test.goagainsthttptest(headers, body, classification); integrationoutbox_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.
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 enqueuesaccount_inviteorpassword_reset_linkwith the token as the outbox secret and{APP_URL}/set-password#token=…as the link, all in the caller's transaction; aresetalso revokes the user's refresh families, bumpssession_epochand callsPrincipals.Forget(decision above). When the organization has turned password sign-in off (T-R5.7), an invite issues no link and enqueuessign_in_inviteinstead, 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, tagauth,security: [], nox-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 setspassword_hash, clearsmust_change_password, stampspassword_changed_atandlast_sign_in_at(T-R5.4), revokes the user's other links and refresh families, bumpssession_epochand callsPrincipals.Forgetbefore it issues the new session (decision above), and auditsuser.password_set{purpose}as the user.setPasswordWithLinkreturns an access token, so it joinstheCredentialMintersin this PR. - Both answer
410 SET_PASSWORD_LINK_INVALIDfor an unknown, used, expired or revoked token and for a disabled account;setPasswordWithLinkanswers422 PASSWORD_SIGN_IN_DISABLEDwhile 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")).AssertPublicRoutesLimitedpasses;public_routes_test.gocovers 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.gogains both operations, each with a one-line justification; its name follows its new count. -
/set-passwordjoins the front-door routes underAuthLayout(70 §5), a single column like Change password. It reads#token=, removes it withhistory.replaceStatebefore 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.tsis 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).
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) andlast_sign_in_method text CHECK IN ('password','google','set_password_link'), all nullable. The same file backfillslast_sign_in_atandlast_active_atwith the greatest of the user's newest refresh-tokencreated_at, newest attemptstarted_atand 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
StampActivityafter 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 inAPP_TIMEZONE. - Password and Google sign-in stamp
last_sign_in_atand the method; set-password stampsset_password_link(T-R5.3); change password stampspassword_changed_at. -
domain.AccountStatusapplies 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 asAccountStatus. - 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
listStudentsgaining aneverSignedInfilter onlast_sign_in_at IS NULLbeside R4'smustChangePassword. -
User(thegetCurrentUserread,/auth/me, self only) gains optionalpasswordChangedAt. 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.
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 singletonapp.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), theset_updated_attrigger, one row namedQuizzivy, andREVOKE INSERT, DELETE … FROM quizzivy_app. The optional text columns are nullable. Down drops the table.20-data-model.mdrecords a deviation (boolean-key singleton). - The
organizationmodule (package comment naming its model): the domainSettingswith 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 exposesports.SignInPolicyandports.Branding, which identity, classes and email consume throughcore/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 andexpectedRevision→ 200,409 STALE_WRITE, or T-R5.7's422 SIGN_IN_POLICY_LOCKOUT. It auditsorganization.updatedwith the changed fields. -
GET /public/organization(getPublicOrganization, tagpublic,security: []) →{name, shortName, website, contactEmail, frontDeskPhone, frontDeskHours, address, logo {lightUrl, darkUrl}, signIn {google, password}, joinCodes}withCache-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
getPublicOrganizationwith 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.
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-bytes1 MiB){file, variant: light|dark}→{sha256, type, width, height}. The object is stored content-addressed atbranding/{sha256}.{png|svg}in the media bucket; the logo applies when T-R5.5'sPUTreferences it ("Logo uploaded. Save settings to apply."), so an unsaved upload changes nothing. Rate limit 20 an hour per actor. - PNG: sniffed, then
image.DecodeConfigbefore any decode; square within 2%, at least 256×256 and at most 2048×2048, or415 IMAGE_DIMENSIONS; decoded under R4's semaphore and re-encoded, which drops metadata. - SVG (≤ 256 KiB): parsed with
encoding/xmland refused (415 SVG_NOT_ALLOWED) on a DOCTYPE or entity, a root other thansvg, 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), anon*attribute, anhref/xlink:hrefnot starting with#, or astyleusing@importor aurl()that does not start with#; aviewBoxthat 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}", andContent-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox. Whenvequals the current hash:Cache-Control: public, max-age=31536000, immutable; otherwisepublic, 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. -
lightUrlanddarkUrlin T-R5.5's public read carryv;web/public/_headersimg-srcgainshttps://api.quizzivy.com. - Tests: a PNG declaring 30000×30000 is refused before decoding; a non-square PNG; SVGs with
<script>,onload,<foreignObject>, an externalhref,@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.
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 answers403 PASSWORD_SIGN_IN_DISABLEDto every password login alike; Google off answers403 GOOGLE_SIGN_IN_DISABLEDto Google sign-in and linking; a non-empty allowed-domain list refuses a Google email outside it with403 GOOGLE_DOMAIN_NOT_ALLOWED(sign-in, linking and self-join through Google). - "Force password change on first sign-in" decides
must_change_passwordfor 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/joinand 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/previewleak review in the PR covers the change. -
unlink_googlerefuses 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 whenpassword_hashis set; Google when a linked identity's email is in an allowed domain. -
updateOrganizationSettingsrecomputes the impact inside its transaction and refuses with422 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.
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.rolesandGRANT INSERT, DELETE ON app.role_permissionstoquizzivy_app.app.permissionsstays read-only andDELETEonapp.rolesis never granted (DG-52). R2'srole_permissions_bump_revisiontrigger updatesroles.revisionas the invoker and relies on this grant.users_last_admindoes not: its function isSECURITY DEFINER(20-data-model.md§30), so itsFOR NO KEY UPDATEon the Admin row runs asquizzivy_migrate. Down revokes exactly these.privileges_test.gogains a case for each grant and for the absentDELETE. -
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).editableis 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.studentTargetapplies R2's strict predicate. -
POST /admin/roles(createRole,people.roles.manage){name (1–60), description (≤300), copyFromRoleId}→ 201RoleSummary, with iconkey-roundand colourgray;409 ROLE_NAME_TAKEN(case-insensitive); the copy source must pass the subset rule, and Assistant as the source answers422 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 thanlearning.take_tests(422 ROLE_LOCKED);learning.take_testsoff 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'slearning.take_testscell, which grants no power over anyone (DG-51); a role, or a key granted or revoked, that failsaccess.CanActOnagainst the caller's permissions, which ignoreslearning.take_tests(403 TARGET_ROLE_NOT_ALLOWED); a stale revision (409 STALE_WRITE). -
setRolePermissionsandupdateRolecallPrincipals.ForgetAll()after commit (decision above). - Audits
role.created,role.updated(changed metadata) androle.permissions_changed({granted: [], revoked: []}per role). - There is no delete operation for any role (DG-52), and
DELETEonapp.rolesstays 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 asquizzivy_migrate, which hides a missing grant); the catalogue equals thePermissionKeyenum minus hidden keys.
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. -
qmatchesapp.immutable_unaccent(lower(full_name || ' ' || email)). No index is added;EXPLAINon 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 pluscreatedBy,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) andcan {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?}withCache-Control: no-store. The role must be assignable (subset rule; Assistant422 ROLE_NOT_ASSIGNABLE);classIdis allowed only for a student-target role and only withscope.all;409 EMAIL_TAKEN.created_byis the actor.email_linkcallsIssueSetPasswordLink(invite) and sets no password;temporary_passwordusesPasswordManager.Temporary()and the organization's force-change rule, and is refused (422 PASSWORD_SIGN_IN_DISABLED) while password sign-in is off. Auditsuser.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; detailcanfor each guard; create with each delivery; the subset rule on create.
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,resetUserPasswordandresetUsersPasswordsdeclarex-permission: people.users.manage, asdisableUseranddisableUsersdo. - Every reset (single, bulk, either delivery), disable, sign-out and role change below
revokes the user's refresh families, bumps
session_epochand callsPrincipals.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 rowFOR UPDATEin 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 withoutworkspace.teacheranswers409 USER_OWNS_CONTENT {counts}. The change bumpssession_epochand callsPrincipals.Forget, so the target's next request is authorised by the new role on this machine and within 10 seconds on any other. Auditsuser.role_changed {role: {old, new}}. -
POST /admin/users/{id}/disableandPOST /admin/users/disable(disableUser,disableUsers,people.users.manage; the bulk form takes 1–100 ids and answers{disabled, failed: [{userId, code}]}): setdisabled_at, revoke refresh families, bump the session epoch, revoke unused set-password links and cancel the user's queued emails. Auditsuser.disabled. -
POST /admin/users/{id}/enable(enableUser) clearsdisabled_at; auditsuser.enabled. -
POST /admin/users/{id}/sign-out(signOutUser) revokes every family and bumps the epoch; self is allowed; auditsuser.signed_out_everywhere. -
POST /admin/users/{id}/reset-password(resetUserPassword){delivery}→{temporaryPassword?}, andPOST /admin/users/reset-passwords(resetUsersPasswords){userIds, delivery}→{items: [{userId, fullName, email, temporaryPassword?}], failed}, bothno-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 answer422 PASSWORD_SIGN_IN_DISABLEDand the UI hides Reset password. Auditsuser.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_passwordis set,RequirePermissionanswers403 PASSWORD_CHANGE_REQUIREDto every operation exceptgetCurrentUser,changePassword,logoutandrefreshSession; 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, sousers_last_admin, aSECURITY DEFINERfunction takingFOR NO KEY UPDATE, is exercised from the app role; a user withmust_change_passwordgetsPASSWORD_CHANGE_REQUIREDfrom a teacher operation and 200 from the four exceptions.
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, holdworkspace.teacherand 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 movesclasses.teacher_id,tests.owner_id,questions.owner_id,question_groups.owner_id,media_assets.owner_idandassignments.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-codecreated_by) are untouched; unfinished import runs stay with their creator and are reported. - Audits one
user.content_transferred {to, counts}; withnotify, the recipient gets an in-appcontent.transferrednotification (NotificationKindgains 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,TestLockForVersionUseSerialisesAgainstDeleteandpublish_snapshot_test.goare green and untouched.
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}withCache-Control: no-store(full codes, D5). It reveals bearer secrets, so it joinstheCredentialMinterswith 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?}→ 201Classwith a new encrypted code; the teacher must be active and holdworkspace.teacher. Auditsclass.created {onBehalfOf}. -
PUT /admin/classes/{id}/teacher(changeClassTeacher,scope.all){teacherId, notify}→Class. Auditsclass.teacher_changed {teacher: {old, new}}; withnotify, both teachers get an in-appclass.teacher_changednotification (email waits for R7). - Edit, rotate, archive, restore and adding members use
/teacher/classes/*(decision above). If R4 leftrotateJoinCode's options required, they become optional with R4's defaults; if R4 left adding members single, the body gainsuserIds(≤ 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/classesoperation; 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.
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.sqlis-- +goose NO TRANSACTION:CREATE INDEX CONCURRENTLY IF NOT EXISTS audit_log_recent_idx ON app.audit_log (occurred_at DESC, id DESC); DownDROP 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(default7d),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}}.deviceis R4'suaparselabel. -
rangeistoday(from the viewer's midnight),7d,30d,all, orcustom, which takesfromandtoas dates (YYYY-MM-DD), both inclusive and read in the viewer's zone (v2's Date range menu,74drow A-03).fromandtowithoutcustom,customwithout both, and atobeforefromanswerVALIDATION_FAILEDon the field. The year range is gone from the list; the export keeps its ownperiod. - Categories come from
entityindomain/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 inserver/and fails on one without a category or an explicit All-only entry. -
facetsis a new schema,AuditCategoryFacets, written likeTestStatusFacets: all seven keys required, each an integer of at least 0,additionalProperties: false. Each counts the entries that match the currentqand range (range,from,to), ignoringcategory, so selecting a tab never zeroes the others.allcounts 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 throughdomain/categories.goastests/repositories/facets.gofolds statuses; noCASErepeats the mapping in SQL.page.totalis the selected category's facet, orfacets.all, so the list runs no second count. Under a search, theEXISTSof the next item runs for every entry in the range, which the total alone already needs. -
qmatches 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,EXISTSper type). The sentence is matched on the server:domain/actions.goholds each action'sviandensentence beside its key, andcatalog_test.gofails whenaudit.action.<action>invi.jsonoren.jsondiffers 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 becomechanges; others becomedetails. A key namedcode,joinCodeorcode_hashis shown as••••-XXXXfrom its last four characters, whatever was stored (DG-11); keys namedpassword,temporaryPassword,tokenorsecretare dropped. -
GET /admin/audit-log/export(exportAuditLog,system.audit.read)?period=7d|30d|year&format=csv|jsonplus the list's filters streams an attachment withno-store: CSV throughtabular(BOM, every cell formula-escaped), JSON as a streamed array. More than 100,000 rows answers422 EXPORT_TOO_LARGE; a 30-second statement timeout applies. Auditsaudit.exported {period, format, rows}. Rate limit 5 a minute and 30 an hour per actor. -
domain/actions.golists every action the server writes.catalog_test.goscansserver/internalfor action literals (Action:fields and the dotted literals of auditINSERTs), fails on one missing from the list, and fails when an action lacksaudit.action.<action>inweb/src/lib/i18n/locales/vi.jsonoren.json. -
EXPLAINon a seeded 100,000-row log showsaudit_log_recent_idxfor the unfiltered first page, for a category filter and for the facet statement atrange=7d, when a week is a small part of the seeded log and the range bounds the scan;range=allreads the whole log once.entityis read from the heap, and the index gainsINCLUDE (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
categoryis selected; an All-only entry counted inalland in no family;page.totalequal 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.
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, anarchive/zippass refuses more than 1,000 entries, a total uncompressed size over 32 MiB, avbaProject.bin(macros) and an encrypted package; thenexcelize.OpenReaderwithOptions{UnzipSizeLimit: 32 << 20, UnzipXMLSizeLimit: 16 << 20}reads the first sheet through the streamingRows()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,
EscapeCellon 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
.xlsmand 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.
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): headersfull_name,email,role,class(case-insensitive, any order);full_name1–200; the email passes the same validation as Add user; a duplicate in the file isDUPLICATE_IN_FILE; an existing account is skipped, never updated;roleis 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 isROLE_NOT_ALLOWEDand AssistantROLE_NOT_ASSIGNABLE;classis an active class's exact name, only for a student-target role and only withscope.all, and two classes with that name areAMBIGUOUS_CLASS. -
POST /admin/user-imports/preview(previewUserImport,people.users.manage, multipart withx-max-body-bytes5 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 plusrequestId) → 202UserImportJob. 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 samerequestIdreturns 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.godrains user-import jobs after Word imports on each wake: a claimFOR UPDATE SKIP LOCKED, then 25 rows per transaction. Each row creates the user (ON CONFLICTon the email index makes a racing duplicate a skip), setscreated_by, enrols the class through the classes port under R2's student-target predicate, and auditsuser.created {via: import, jobId}.email_linkcallsIssueSetPasswordLink;sheettakesPasswordManager.Temporary(), hashes underMAX_CONCURRENT_PASSWORD_HASHESand seals the password intosecret_ciphertext(purposeuser-import-sheet, AAD job id and ordinal). Counters move per batch; a job leftrunningfor 30 minutes is resumed from its pending rows at worker start. -
POST /admin/user-imports/{id}/sheet(downloadUserImportSheet,people.users.manage, requester only) →text/csvattachment,no-store, withfull_name,email,role,class,temporary_password. One transaction reads and erases the ciphertexts and setssheet_downloaded_at; afterwards, or 24 hours after completion, it answers410 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 idempotentrequestId; a simulated crash mid-job resumes without duplicates; the sheet is single-use and requester-only; a custom role's import with anadminrow marks it invalid; one email per created row foremail_link;user_import_flow_test.go(e2e tag) runs the API and the worker runner in process.
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}→ 202DataExport;409 EXPORT_IN_PROGRESSwhile any export is queued or running. Auditsdata_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 filenamequizzivy-export-{date}.zip. One statement checksready, unexpired and undownloaded and setsdownloaded_at; otherwise410 EXPORT_UNAVAILABLE. Auditsdata_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.csvand, when asked,answers.csv(a removed answer is flagged, T-R5.17b), plus aREADME.txtin vi and en describing each file. Each CSV streams from its query throughtabular, so memory stays bounded. The file is uploaded to the private imports bucket atexports/{id}.zip, the local file removed, the row setreadywithexpires_at = now() + 24 h, anddata_export_readyenqueued to the requester with{APP_URL}/admin/exports/{id}, which carries no token. A failure setsfailedwith 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).
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:
-
Housekeepruns in the API at start-up and then onPruneRefreshTokens's 24-hour tick, so it adds no wake of its own. Each step is bounded and logs counts only:-
user_activity_daysrows 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 rowexpired; -
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.
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.shcreatesquizzivy_maintenance(LOGIN,CONNECTION LIMIT 2, not a superuser, noCREATE), password fromQUIZZIVY_MAINTENANCE_PASSWORD(defaultmaintenancelocally), idempotently like the other two roles. The PR tells anyone with an existing local volume to re-run the script. -
grant_maintenance_rolegrants exactly whatcmd/maintenanceuses:USAGEon the schema;SELECTon the tables its predicates read;UPDATE (payload)onattempt_answers;DELETEonattempt_events;UPDATE (email, full_name, display_name, phone, avatar_key, password_hash, must_change_password, disabled_at)onusers;DELETEonuser_identitiesandrefresh_tokens;UPDATE (revoked_at)onset_password_tokens;DELETEonnotifications;INSERTonaudit_log; R1's window commands'INSERT, UPDATEonmaintenance_windows,UPDATE (deadline_at)onattempts,UPDATE (closes_at)onassignmentsand, for R4's overrides,SELECT, UPDATE (closes_at)onassignment_student_overrides; and R2'srekey-join-codes'SELECT, UPDATE (code_hash, code_ciphertext, key_id)onclass_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-creditsandai-reservations-sweep(T-D4.31) use:SELECT, INSERT, UPDATEonai_credit_periodsandai_teacher_credits,SELECT, UPDATEonai_settingsandai_credit_requests,SELECT, DELETEonai_reservations,INSERT, UPDATEonnotifications,INSERTonaudit_log,SELECTon 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 cannotUPDATEorDELETEaudit_logor create objects;quizzivy_appstill has noUPDATEorDELETEonattempt_eventsoraudit_log. -
maintenance retention-sweep [-apply] [-batch N] [-budget D](R1'smaintenance <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_monthsis set): attempts submitted, and whose assignment closed, before the cutoff have each answer'spayloadreplaced by{"removed": true}where it is not already; oneretention.answers_removed {cutoff, attempts, answers, databaseRole}per batch; - focus-loss events:
RetainEventswith the cutoff fromkeep_focus_events_months; at the default 13 it deletes exactly what it deletes today; - accounts (when
anonymise_disabled_after_monthsis 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 whoseparamscarry the user's full or display name as a value are deleted, so no name outlives the anonymisation there (D16). Each is auditeduser.anonymizedas System.
- answers (when
-
retain-integrityreads the same setting;anonymize-studentbecomesanonymize-userand keeps the old name as an alias. Every command runs asquizzivy_maintenance. -
.github/workflows/retention-sweep.yml:schedule: cron '0 20 * * *'(03:00 in Ho Chi Minh City) andworkflow_dispatchwith anapplyinput; environmentproduction-maintenanceholdingMAINTENANCE_DATABASE_URL; buildscmd/maintenanceand runs it with a 5-minute budget; applies on a schedule only when the repository variableRETENTION_SWEEP_ENABLEDistrue, otherwise dry-runs;timeout-minutes: 15; one run at a time. -
operations.mdreplaces 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, withMAINTENANCE_DATABASE_URL,JOIN_CODE_KEYandJOIN_CODE_KEY_PREVIOUSfrom 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-codesandwindow-schedulerun as the role.attempts/application/tests/events_test.gois green and untouched.
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 carryanswerRemoved: truewith no response content; grading a removed answer answers409 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 equalsretention-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.gois green and untouched.
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}.peopleanddailyActiveneedpeople.users.manage,classesand the disabled-teacher rowscope.all,recentChangessystem.audit.read,storagesystem.settings.write; each is null without its key. Attention rows with a count of zero are left out. Theai_credit_requestsrow counts the pending credit requests and needsai.credits.manage(v2,74drow A-06). -
GET /admin/summary(getAdminSummary,workspace.admin) →{mustChangePassword}(null withoutpeople.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}.aiCreditsis the month's credits used against the centre pool, null withoutai.credits.manageand while the AI module is off. Limits come fromPLAN_NAME,PLAN_RENEWS_ON,PLAN_SEAT_LIMIT,PLAN_TEACHER_LIMIT,PLAN_MEDIA_BYTESandPLAN_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. -
EXPLAINof 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; unsetPLAN_*values.
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.yamlhas 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.
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:
-
AdminLayouton R4'sSidebar,TopBarandBreadcrumbs: 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 ofGET /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 fromGET /admin/ai/credits, hidden at 0 and when collapsed; v2,74drow A-02), Audit log, API reference, Settings. While the sidebar is collapsed the labels are hidden and a 1pxrole="separator"sits between groups (v2); it is the sharedSidebar'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.teacheralso gets "Đổi mật khẩu" ("Change password"), which opens/change-password(their only way to it), and "Ngôn ngữ" ("Language"), which writeslocalethrough R4'sPATCH /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-permissionof the operation its page lists (Overviewworkspace.admin, Userspeople.users.manage, Rolespeople.roles.manage, Classesscope.all, AI creditsai.credits.manage, Auditsystem.audit.read, API referencesystem.api_reference, Settingssystem.settings.write);admin-nav.test.tsreadsapi/openapi.yamland fails when one differs. Teacher view and the Teacher row needworkspace.teacher. - R4's
WorkspaceSwitchergains its "Admin console" row forworkspace.adminholders inTeacherLayout, and the teacher account menu gains "Admin console ↗" (DG-53). - Routes under R2's
RequireWorkspaceguard, theRequirePermissionguard below andAdminLayout:/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 declareshandle.crumb,handle.widthandhandle.permission. - A
RequirePermissionroute guard (web/src/app/guards/) readshandle.permissionthrough R2'scan()and renders the 403 page when the caller lacks it, so a custom role holding onlysystem.audit.readgets 403 at/admin/users, not an empty screen. Unit tests cover that role and an Admin. -
LegacyTeacherRedirectnarrows to legacy teacher paths: an/admin/*path that is a console route stays for aworkspace.adminholder; 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. -
UserPreferencesgainsworkspace: teacher | admin, written when the user switches.homePathForsends a built-in Admin topreferences.workspaceor/admin, any otherworkspace.teacherholder to/teacher, a user with onlyworkspace.adminto/admin, a student to/app. - The Users badge polls
GET /admin/summaryevery 60 seconds through R4'suseIdlePolling(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.tsaddsADMIN = /\/(layouts\/AdminLayout|features\/[^/]+\/pages\/admin\/|features\/(users|roles|audit|admin-overview|api-reference|exports)\/)/(organizationis not in it, because its public hook lives inlib/) 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.
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.manageholders, 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-cand staff in--info, "Last 14 days · {avg} on average", a tooltip per bar, every other label on phones, and a visually hidden data table.BarChartgains a stacked mode, built after loading thedatavizskill. - Needs attention: must change password → Users with
status=must_change_password; never signed in → Users withstatus=never_signed_in; classes with a disabled teacher → Classes withteacher=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,74drow 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.
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=); astatusfrom 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-roundand/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 inCredentialResult: the temporary passwords shown once with Copy all and Print, or "{n} links sent". Add to class uses a searchable class list overGET /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.
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 withpeople.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
canallows it: Reset password (the delivery toggle, thenTemporaryPasswordCardor "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 withscope.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 staysaria-disableduntil 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_CONTENTwith 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.
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
requestIdheld 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 throughuseIdlePollingwhile 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.
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 onmuted, group rows (Content, Teaching, People, System) onsidebar, rows at least 46px with label and hint, 18px checkboxes labelled "{permission} — {role}". Labels come frompermissionLabels.ts, a map over the generatedPermissionKeyunion that failspnpm typecheckwhen 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; aSTALE_WRITEreloads 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.
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=disabledfilter 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.teacherholders), 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.
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 (74drow 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.mdT-R4.36), with the same semantics and scrolling track: toggle buttons witharia-pressed, not the deck'srole="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 insuccess-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_LARGEasks 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.
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
ApiDocsSectiondoes today, and both error alerts (pop-up blocked, network) with Try again. - Base URL row:
VITE_API_BASE_URLwith 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 fromgroups.ts; a card opens the reference at#tag/{tag}.api-groups.test.tsreadsapi/openapi.yamland fails when a tag has no entry. - R4's Settings loses its API reference section and
/teacher/settings/apiredirects 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.
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
SettingsLayoutwith 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 withexpectedRevision;STALE_WRITEoffers a reload. - The section column is sticky from a content width of 768 (
position: sticky; top: 16px, capped and scrolling), through D4'sStickyAside(T-D4.20), which R4'sSettingsLayoutalready carries. This task adds no sticky rule of its own (v2,74drow 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,
SignInImpactasksPOST /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. A422 SIGN_IN_POLICY_LOCKOUTfrom 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.
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
RetentionConfirmwith 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_PROGRESSand 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,
74drow A-06) fromUsage.aiCredits, absent while it is null; read-only. -
/admin/exports/:id: status (queued and running poll every five seconds throughuseIdlePolling), 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 throughnext=. - 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.
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()readsGET /public/organizationwith a five-minute stale time and never blocks a render; each value falls back to R1'sVITE_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.
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; everydata-scale="deck"attribute is removed. - Commit 2:
--radiusis 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;useForcedLightThemeis deleted once no caller remains. -
deck-scale.test.tsxbecomesui-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.tsstops expecting the aliases. -
DateTimeField's inherited (off-deck) branch goes with the otheruseDeckScalebranches, 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.tsanduseColumnWidth(the question editor's settings column),GradeByQuestionandSettingsSections; each goes with the screen that stops using it. -
grep -rn "data-scale" web/srcfinds nothing;pnpm lint,pnpm typecheck,pnpm test:unitandpnpm buildare 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.
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-apidefaults it for local runs, andplaywright.config.tsreads the same variable rather than its own path. -
admin_escalation_flow_test.go(e2e tag), as a custom role holdingpeople.users.manageandpeople.roles.manage: creating an Admin, resetting or disabling an Admin, granting itselfsystem.settings.write, granting another role a key it lacks, copying the Admin role, changing its own role and importing a row with roleadminare 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 listscreateUser,resetUserPassword,resetUsersPasswords,createUserImport,downloadUserImportSheet,setPasswordWithLinkandlistAllClasses, 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/teacherwith empty lists; the owner removescontent.media.writefrom 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:liveis green.
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-passwordand/admin/exports/:idwith 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 newfeatures/*); "Local development" (re-runprovision-db.shfor the maintenance role,EMAIL_SINK_DIR, the worker runs user imports and exports); the "Redesign in progress" note withoutdata-scaleand 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 andplatform/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.mdanddns.mdas the tasks above describe. - Every PR that changed behaviour already changed its lines; this task checks the set and closes gaps before the release.
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#skeletonatdestinationstudent, 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 withdesign-deckon 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 withgetBoundingClientRect; the table (screen × width × theme → match, fixed, or DG id) in the release PR./set-passwordchecked 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.
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.permissionai.credits.manage(T-R5.20's guard);router-chunks.test.tspasses 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 inAPP_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
handtile: "{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 throughStickyAsidefrom a content width of 900) for "every teacher without a custom allowance", saved throughPATCH /admin/ai/rules: Monthly allowance per teacher 20,000 | 50,000 | 100,000 (default 20,000,74dQ30; "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.
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.
- Every Done-when box above is ticked on
work/redesign-r5, withdevelopmerged 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,getPublicOrganizationandgetOrganizationLogo, and for the/join/previewchange, are in their PRs; T-R5.31 is green. - Production prerequisites before merging to
main:- Resend:
quizzivy.comverified (SPF, DKIM, DMARC), open and click tracking off; Fly secretRESEND_API_KEY;SECRETS_KEYpresent since T-R0.8; -
fly.toml[env]:EMAIL_FROM,APP_URL, optionalEMAIL_REPLY_TO,EMAIL_DAILY_LIMIT(100 unless Thuong upgraded the Resend plan, O-R5.1) and anyPLAN_*values; -
./scripts/provision-db.sh "$NEON_ADMIN_URL"run once, withQUIZZIVY_MAINTENANCE_PASSWORD, soquizzivy_maintenanceexists before the grants migration; - the GitHub environment
production-maintenanceholdingMAINTENANCE_DATABASE_URLfor that role;RETENTION_SWEEP_ENABLEDleft unset.
- Resend:
- Rehearsed on a Neon branch of production: goose up with timings (the audit index build
recorded,
indisvalidtrue); the legacy join-code count is zero (T-R5.1);maintenance retention-sweepas a dry run with the default settings reports the same event count asretain-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.0cut 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→mainwith Thuong's go; Deploy runs the API (release_commandmigrates) then Pages. - Smoke on production:
/livez;/healthzwithdatabase: ok; the worker group running;GET /public/organization; the owner signs in, lands on/adminand switches to the Teacher workspace and back; enters the organization details from theVITE_ORG_*repository variables and saves; adds a test teacher with an email link to a second address of Thuong's; the message arrives fromquizzivy.comwith 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-mergemain→developby 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 toplatform/emailand 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 acmd/maintenancecommand grants the maintenance role what it needs in its own migration. Nothing R5 expanded is leftNOT VALID.
-
O-R5.1 — Resend plan and the daily budget. The free tier sends 100 emails a day and 3,000 a
month;
EMAIL_DAILY_LIMITdefaults 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) andapp.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_REQUIREDfor 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.alllists 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 onlylearning.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, throughapp.student_like_rolesin 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'sopenapi.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.
Generated from the repository's documentation on every push to develop.
Start here
Product
Implementation plan
- 00 Architecture overview
- 10 Phase 0: Scaffold and contract
- 11 Phase 1: Auth, join, shells
- 12 Phase 2: Admin authoring
- 13 Phase 3: Assign and take
- 14 Phase 4: Grade and results
- 15 Phase 5: Hardening
- 16 Phase 4.5: The student at a desk
- 17 Word import: milestone plan
- 18 Word import: teacher workflow
- 19 Versioned content contract
- 20 Data model
- 30 Risk register
- 40 Open items
- 50 Non-functional requirements
- 60 Backend architecture
- 91 September 2026 issue remediation
- 92 Student layouts and interaction rules
Redesign (Phase R)
- 70 Redesign overview
- 71 R1: Foundations (v0.7.0)
- 72 R2: Access (v0.8.0)
- 73 R3: Student console (v0.9.0)
- 74 R4: Teacher workspace (v0.10.0)
- 74d D4: Deck update (v0.11.0)
- 74e E1: Moving a question across a group
- 75 R5: Admin console and email (v0.12.0)
- 76 R6: Question types and scoring (v0.13.0)
- 77 R7: Collaboration (v0.14.0)
- 78 R8: Schedule (v0.15.0)
- 79 R9: Insights (v0.16.0)
- 80 R10: Learn (v0.17.0)
- 81 R11: Landing, leads, v1.0 (v1.0.0)
Setup and operations
- CI
- Deploying
- DNS for quizzivy.com
- Google OAuth client
- Production operations
- Cloudflare R2 bucket
- Word import worker
- R4 rehearsal on a Neon branch
Design
Team
- Agent team
- Verification environment
- Team ledger
- R4 readiness
- R4 verification strategy
- T-R4.51 browser verification
Code READMEs