diff --git a/FEATURES.md b/FEATURES.md index 59676b6..f48cc8b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -15,11 +15,9 @@ A comprehensive list of every feature in the AskEasy platform. ### Role System -- **Two-tier roles**: - - **Global role** — determined from `whitelist.txt` on every login (PROFESSOR or STUDENT) - - **Per-course role** — stored in `CourseEnrollment` (PROFESSOR, TA, or STUDENT) -- **Whitelist** — plain text file of UTORids; case-insensitive; supports legacy `utorid,PROFESSOR` format -- **Effective permissions** — course/session actions use the per-course enrollment role, not the global role +- **Roles are per class** — stored in `CourseEnrollment` (PROFESSOR, TA, or STUDENT). Every login writes `User.role = STUDENT`; there is no global professor role to authorize on. +- **Admins** — `ADMIN_WHITELIST` (case-insensitive UTORids) is the only global permission. Admins create classlists and assign each one's professors and TAs. +- **Effective permissions** — every course/session action resolves the caller's `CourseEnrollment` role for that specific course ### Endpoints @@ -31,28 +29,51 @@ A comprehensive list of every feature in the AskEasy platform. --- -## Course Management +## Classlists and Rooms + +A **classlist** is what an admin uploads: one course code, one semester, one +student roster. It owns one **room** per professor — a room is where the Q&A +actually happens, and it has exactly one professor. ### Creation -- Professors create courses with a course code, name, and optional section +- **Admins only** (`ADMIN_WHITELIST`). Professors are assigned to a class; they do not make one - **Semester auto-detection** from current date (Jan–Apr = Winter, May–Aug = Summer, Sep–Dec = Fall) - **CSV enrollment** — upload a CSV with columns: `utorid`, `givenName`, `surname`, `Email` (optional); rows with "Missing UTORid" or "ERROR" are skipped -- **TA assignment** — professors can designate TAs during course creation +- **One room per professor** — the creating admin gets a room of their own plus TA access to every other room; each other professor gets one room and no access to the rest +- **Room names** — the admin types a name for each professor's room (the creator's own defaults to their surname). It is the room's name, not the professor's: signing in never rewrites it, and only an admin changes it afterwards +- **TA assignment** — TAs added at creation are TAs in every room on the class + +### Who sees what + +| | Their own room | Other rooms on the class | +| ---------------- | -------------- | ------------------------ | +| Creating admin | PROFESSOR | TA | +| Other professors | PROFESSOR | No access at all | +| TAs | — | TA in all | +| Students | — | STUDENT in all | + +Students see one card per class and open it to pick a room. A professor's class +opens onto exactly one room. Join codes respect this too: a professor cannot use +one to enter a colleague's room on the same class. ### Operations -- **Rename** — professor can update course code and/or semester -- **Delete** — cascading deletion (questions, answers, upvotes, slide sets, sessions, enrollments); blocked if an ACTIVE session exists +- **Rename** — an admin updates the class code and/or semester; the change fans out to every room +- **Delete** — cascading deletion (questions, answers, upvotes, slide sets, sessions, enrollments) across all rooms; blocked while any room is live +- **Add a professor** — creates a room, named by the admin, seeded with the current roster and TAs +- **Rename a room** — admin-only; the professor of a room cannot rename it +- **Remove a professor** — deletes their room; blocked on the last professor and on a live room -### Student & TA Management +### Roster Management -- **View roster** — returns students and TAs with name and UTORid -- **Add individuals** — add one or more UTORids; returns added, already-enrolled, and invalid lists -- **Batch sync** — full replace of the STUDENT roster from a new CSV; preserves TAs and professor -- **Remove** — remove a single student by UTORid +- **View roster** — pooled across rooms; the strongest role wins, so a TA in one room is a TA on the class +- **Add individuals** — added to every room on the class +- **Batch sync** — full replace of the STUDENT roster from a new CSV, across every room; preserves TAs and professors +- **Remove** — a TA is demoted to student, a student is dropped; a room's professor is refused and sent to the professors endpoint - **Auto-creation** — users not yet in the database are created automatically on enrollment - **CSV diff preview** — before applying a sync, shows counts of students to add, remove, and unchanged +- **Room-level TAs** — a professor assigns TAs on their own room from inside a live session; a room takes only one professor, so that route refuses PROFESSOR --- @@ -130,7 +151,7 @@ A comprehensive list of every feature in the AskEasy platform. - **PDF only** — validated by MIME type, magic bytes, and parseability - **Size limits** — 1 KB to 50 MB -- Professor-only; session must be ACTIVE +- Professor-only (a TA can drive the deck but never replace it); session must be ACTIVE ### Viewing @@ -139,9 +160,25 @@ A comprehensive list of every feature in the AskEasy platform. ### Real-Time Sync -- Professor changes the page index; broadcast to all participants via `slide:changed` -- Late joiners call `slide:sync` to get the current page +- **One person drives at a time.** The room's professor and its TAs may each take the deck; taking it displaces whoever had it. Uploading stays the professor's alone +- The holder's page changes are broadcast to every participant via `slide:changed`; `slide:change` from anyone who is not currently holding the deck is refused +- Control is held in Redis (`slide-controller:{sessionId}`, 24 h) and is **unclaimed by default** — until someone presses the button it belongs to the room's professor, so starting a lecture needs no extra step +- `slide:control:take` claims it and pulls the room to the taker's current page, so taking over while reading ahead brings everyone along. `slide:control:changed` tells the room who is driving, which is what returns the displaced holder's toolbar to the following view +- Late joiners call `slide:sync` and get both the current page and the current holder - New upload triggers `slides:available` notification to the room +- Ending a lecture clears the slide position, the controller and the answer mode + +### Viewer toolbar + +| Who | What they see | +| -------------------------------- | ------------------------------------------------------------------------------------------------------- | +| **Holding the deck** | Controlling badge, viewer count, page controls — plus Replace and End Lecture if they are the professor | +| **Professor or TA, not holding** | Exactly the student bar, plus a **Control Slides** button | +| **Student** | Following Live / Browse Freely, page controls when browsing | + +A professor who has been taken over lands on the same bar a TA sees, with the +same button back — so control can pass back and forth without anyone needing to +release it first. ### Split View @@ -203,28 +240,36 @@ If Redis is unavailable, rate limiting fails closed (blocks all requests). ## Permissions Matrix -### Course Operations +### Class Operations + +| Action | Student | TA | Professor | Admin | +| ------------------------ | :-----: | :-: | :-------: | :---: | +| Create a classlist | | | | Yes | +| View own classes/rooms | Yes | Yes | Yes | Yes | +| Rename a class | | | | Yes | +| Delete a class | | | | Yes | +| Add/remove a professor | | | | Yes | +| View class roster | | | | Yes | +| Add/remove students, TAs | | | | Yes | +| Sync CSV roster | | | | Yes | +| Manage TAs on own room | | | Yes | Yes¹ | -| Action | Student | TA | Professor | -| ------------------- | :-----: | :-: | :---------: | -| Create course | | | Yes | -| View own courses | Yes | Yes | Yes | -| Rename course | | | Yes (owner) | -| Delete course | | | Yes (owner) | -| View roster | | | Yes (owner) | -| Add/remove students | | | Yes (owner) | -| Sync CSV roster | | | Yes (owner) | +¹ On the room they run. An admin is a TA in the other rooms on their classes, not their professor. ### Session Operations -| Action | Student | TA | Professor | -| -------------------- | :-----: | :-: | :-----------: | -| Create session | | | Yes | -| Join via code | Yes | Yes | N/A | -| End session | | | Yes (creator) | -| Regenerate join code | | | Yes (creator) | -| Upload slides | | | Yes | -| Control slide page | | | Yes | +| Action | Student | TA | Professor | +| -------------------- | :-----: | :--: | :-----------: | +| Create session | | | Yes | +| Join via code | Yes | Yes | N/A | +| End session | | | Yes (creator) | +| Regenerate join code | | | Yes (creator) | +| Upload slides | | | Yes | +| Take slide control | | Yes | Yes | +| Move the shared deck | | Yes¹ | Yes¹ | +| Browse slides freely | Yes | Yes | Yes | + +¹ Only while holding control — one person at a time. ### Question Operations diff --git a/README.md b/README.md index e9f3b03..10b498e 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ AskEasy is built for that moment. It gives every lecture a live Q&A room where t │ PostgreSQL 16 │ │ Redis 7 │ │ (via Prisma) │ │ - Socket.IO pub/sub │ │ │ │ - Rate limiting │ - │ Users, Courses │ │ - Answer mode TTL │ + │ Users, Classlists│ │ - Answer mode TTL │ │ Sessions, Q&A │ │ - Session data │ │ Upvotes, Slides │ └─────────────────────┘ └──────────────────┘ @@ -97,8 +97,8 @@ SESSION_SECRET=<64-char-hex> # Cron job auth (for /api/cron/cleanup-sessions) CRON_SECRET= -# Roles — comma-separated UTORids, case-insensitive -PROFESSOR_WHITELIST=utorid1,utorid2 +# Admins — comma-separated UTORids, case-insensitive. +# Admins create classlists and assign each one's professors and TAs. ADMIN_WHITELIST=utorid1 ``` @@ -121,31 +121,28 @@ REDIS_URL=redis://:@localhost:6379 DEV_UTORID=yourutorid DEV_NAME=Your Name DEV_EMAIL=your.email@mail.utoronto.ca -DEV_ROLE=PROFESSOR # or STUDENT ``` > **The `DEV_*` variables are ignored in production.** The auth route only reads them when `NODE_ENV !== "production"`; a production request arriving without a Shibboleth header is rejected with a 401 rather than falling back to `DEV_UTORID`. Keep them out of production environments regardless — `docker-compose.override.yml` is the only place they belong. -| Variable | Required | Description | -| ------------------------------------------- | :------: | ------------------------------------------------------------------------------------------------------------------------- | -| `POSTGRES_USER` / `PASSWORD` / `DB` | Yes | Postgres credentials | -| `REDIS_PASSWORD` | Yes | Passed to the Redis container as `--requirepass` | -| `SESSION_SECRET` | Yes | Key for iron-session cookie encryption. Changing it logs everyone out. | -| `PROFESSOR_WHITELIST` | Yes | UTORids granted the PROFESSOR role on login. Everyone else is a STUDENT. | -| `ADMIN_WHITELIST` | Yes | UTORids granted `/dashboard` access. Empty means nobody can administer. | -| `CRON_SECRET` | Yes | Bearer token for the cleanup-sessions cron endpoint | -| `DATABASE_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | -| `REDIS_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | -| `DEV_UTORID` | Dev | Fake UTORid injected when Shibboleth is not present | -| `DEV_NAME` | Dev | Display name for the fake dev user | -| `DEV_EMAIL` | Dev | Email for the fake dev user; defaults to `@mail.utoronto.ca` | -| `DEV_ROLE` | Dev | `PROFESSOR` or `STUDENT` — overrides whitelist lookup | -| `DEV_PROF_*` / `DEV_TA_*` / `DEV_STUDENT_*` | Dev | Per-persona `UTORID` / `NAME` / `ROLE` / `EMAIL` for `pnpm dev:all`. Each falls back to a default with a startup warning. | -| `SESSION_COOKIE_NAME` | No | Set per instance by `pnpm dev:all`. Ignored in production. | -| `NEXT_DIST_DIR` | No | Set per instance by `pnpm dev:all` so concurrent dev servers don't share `.next`. | -| `SOCKET_IO_USE_REDIS` | No | Set to `"false"` to disable the Socket.IO Redis adapter. Enabled otherwise. | - -Whitelists are read once at startup and cached, so restart the app after changing them. +| Variable | Required | Description | +| ------------------------------------------- | :------: | ---------------------------------------------------------------------------------------------------------------- | +| `POSTGRES_USER` / `PASSWORD` / `DB` | Yes | Postgres credentials | +| `REDIS_PASSWORD` | Yes | Passed to the Redis container as `--requirepass` | +| `SESSION_SECRET` | Yes | Key for iron-session cookie encryption. Changing it logs everyone out. | +| `ADMIN_WHITELIST` | Yes | UTORids granted `/dashboard` access and the right to create classlists. Empty means nobody can administer. | +| `CRON_SECRET` | Yes | Bearer token for the cleanup-sessions cron endpoint | +| `DATABASE_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | +| `REDIS_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | +| `DEV_UTORID` | Dev | Fake UTORid injected when Shibboleth is not present | +| `DEV_NAME` | Dev | Display name for the fake dev user | +| `DEV_EMAIL` | Dev | Email for the fake dev user; defaults to `@mail.utoronto.ca` | +| `DEV_PROF_*` / `DEV_TA_*` / `DEV_STUDENT_*` | Dev | Per-persona `UTORID` / `NAME` / `EMAIL` for `pnpm dev:all`. Each falls back to a default with a startup warning. | +| `SESSION_COOKIE_NAME` | No | Set per instance by `pnpm dev:all`. Ignored in production. | +| `NEXT_DIST_DIR` | No | Set per instance by `pnpm dev:all` so concurrent dev servers don't share `.next`. | +| `SOCKET_IO_USE_REDIS` | No | Set to `"false"` to disable the Socket.IO Redis adapter. Enabled otherwise. | + +`ADMIN_WHITELIST` is read once at startup and cached, so restart the app after changing it. --- @@ -169,7 +166,7 @@ pnpm install Create `.env` and `.env.local` in the project root using the templates in [Environment Variables](#environment-variables) above. Both are gitignored, so a fresh clone has neither. -Put your own UTORid in `PROFESSOR_WHITELIST` and `ADMIN_WHITELIST`, and set `DEV_UTORID` to the same value so your fake dev login picks up those roles. +Put your own UTORid in `ADMIN_WHITELIST`, and set `DEV_UTORID` to the same value so your fake dev login can create classlists and reach `/dashboard`. Everyone else signs in as a student until an admin assigns them to a class. ### 3. Start the database and Redis @@ -198,22 +195,28 @@ Open [http://localhost:3000](http://localhost:3000). The app auto-reloads on cha A single dev server can only ever be one user — identity comes from `DEV_UTORID` / `DEV_NAME` / `DEV_ROLE`, which are process-global. To test a student asking a question -while a professor answers it, run three instances at once: +while a professor answers it, run multiple instances at once: ```bash -pnpm dev:all +pnpm dev:all # 1 professor, 1 TA, 1 student (default) +pnpm dev:all 2 1 3 # 2 professors, 1 TA, 3 students ``` +Pass three non-negative integers: ` `. Omit them to get one of +each. Ports are assigned sequentially from `3000`. + +Default layout (1 of each): + | Persona | URL | Cookie | | --------- | --------------------- | --------------------- | | `PROF` | http://localhost:3000 | `askeasy-dev-prof` | | `TA` | http://localhost:3001 | `askeasy-dev-ta` | | `STUDENT` | http://localhost:3002 | `askeasy-dev-student` | -Open all three in tabs of the same window. Each instance gets its own session cookie +Open the instances in tabs of the same window. Each instance gets its own session cookie name, so they don't clobber each other — browser cookies are keyed by host and ignore the port, meaning a single shared name would make every tab become whoever logged in -last. All three share one Postgres and one Redis, which is what lets events broadcast +last. All instances share one Postgres and one Redis, which is what lets events broadcast between them. Configure each persona in `.env` (all optional — anything missing falls back to a @@ -233,20 +236,24 @@ DEV_STUDENT_NAME=Dev Student DEV_STUDENT_ROLE=STUDENT ``` +Extra instances of a label (e.g. a second professor tab) use numbered defaults +(`devprof2`) and can be overridden with `DEV_PROF_2_UTORID` / `_NAME` / `_EMAIL` (same +pattern for `TA` and `STUDENT`). + Notes: -- `DEV_ROLE` overrides the whitelist lookup, so the PROF persona does **not** need to be - in `PROFESSOR_WHITELIST`. It **does** need to be in `ADMIN_WHITELIST` for `/dashboard` - access, and whitelists are cached at startup — restart after editing. +- PROF / TA / STUDENT label which tab is which; they are not roles. Every persona signs + in as a student. To give one professor or TA rights, sign in as an admin, create a + classlist and add that persona's UTORid. `ADMIN_WHITELIST` is cached at startup — + restart after editing. - Leave `SOCKET_IO_USE_REDIS` unset. With the adapter disabled, events don't cross instances: a question asked in one tab won't appear in the others until you refresh. - Each instance runs its own Next compiler against its own build dir (`.next-prof`, - `.next-ta`, `.next-student`), so expect roughly 3× the memory and CPU of `pnpm dev`. + `.next-ta`, `.next-student`, …), so expect roughly N× the memory and CPU of `pnpm dev`. - Instances start one at a time rather than all at once. Next rewrites `next-env.d.ts` during startup and each instance wants its own build dir in it, so concurrent startups interleave those writes and corrupt the file — which then breaks - `pnpm typecheck` and the pre-commit hook. Startup therefore takes about three - times as long as `pnpm dev`. + `pnpm typecheck` and the pre-commit hook. Startup therefore scales with instance count. - `pnpm dev` is unaffected and still uses the `ask_easy_session` cookie, so switching between the two modes won't log you out of either. @@ -323,8 +330,8 @@ Pull requests run the same build and tests but do not deploy. The workflow is [. src/ ├── app/ # Next.js App Router pages & API routes │ ├── api/ # REST endpoints (auth, courses, sessions, questions, cron) -│ ├── classes/ # Course listing & management UI -│ ├── create-class/ # Course creation flow +│ ├── classes/ # Classlist management modal +│ ├── create-class/ # Classlist creation flow (admins only) │ ├── room/ # Live session room (chat + slide viewer) │ └── admin/ # Admin dashboard (data overview, table wipe) ├── components/ui/ # Shared UI components (Radix-based) @@ -338,7 +345,7 @@ prisma/ └── seed.ts # Resets all tables (dev use only) ``` -Professor and admin permissions are set with the `PROFESSOR_WHITELIST` and `ADMIN_WHITELIST` environment variables — see [docs/ADMIN-GUIDE.md](docs/ADMIN-GUIDE.md) for details. +Admin access is set with the `ADMIN_WHITELIST` environment variable. Professor and TA rights are granted per class by an admin — see [docs/ADMIN-GUIDE.md](docs/ADMIN-GUIDE.md) for details. --- diff --git a/docker-compose.yml b/docker-compose.yml index 0f1c3d3..380638e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,6 @@ services: - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ask_easy} - REDIS_URL=redis://:${REDIS_PASSWORD:-changeme}@redis:6379 - SESSION_SECRET=${SESSION_SECRET:?required in prod.env} - - PROFESSOR_WHITELIST=${PROFESSOR_WHITELIST} - ADMIN_WHITELIST=${ADMIN_WHITELIST} # DEV_* live in docker-compose.override.yml so production never sees them. depends_on: diff --git a/docs/ADMIN-GUIDE.md b/docs/ADMIN-GUIDE.md index a5782a5..b634cfd 100644 --- a/docs/ADMIN-GUIDE.md +++ b/docs/ADMIN-GUIDE.md @@ -1,46 +1,29 @@ # AskEasy — Administrator Guide -This guide is for professors and system administrators who need to manage the platform — handling course cleanup at the end of a semester, controlling who has professor access, and accessing the database directly if needed. +This guide is for administrators who need to manage the platform — handling cleanup at the end of a semester, controlling who can create classes, and accessing the database directly if needed. --- -## Role Control Files +## Who Can Do What -Three plain-text files on the server control who can do what. All three live in the project root directory (`~/AskEasy/`). +There is no global professor role. Everyone who signs in is a student until an admin +puts them on a class. -| File | What it controls | -|------|-----------------| -| `whitelist.txt` | Who gets the **PROFESSOR** role in the app | -| `admin_whitelist.txt` | Who can access the **Admin Dashboard** at `/dashboard` | +| Level | How it is granted | What it allows | +| ------------- | --------------------------------------------------------------- | ---------------------------------------------------------------- | +| **Admin** | `ADMIN_WHITELIST` env var (`admin_whitelist.txt` on the server) | Create classlists, assign professors and TAs, reach `/dashboard` | +| **Professor** | Assigned by an admin when the classlist is created | Runs one room — their own. Cannot see other professors' rooms | +| **TA** | Assigned by an admin or by the professor of a room | Answer questions and moderate, inside that one room | +| **Student** | On the uploaded classlist, or joins with a room code | Ask, upvote and reply | -Both files are read once at server startup and cached in memory. A restart is required to pick up any changes. +An admin who creates a classlist gets a room of their own (as its professor) plus TA +access to every other room on that classlist, so they can always see what is going on. -### `whitelist.txt` — who gets the Professor role +### `admin_whitelist.txt` — who can administer -Any UTORid listed here is assigned the **PROFESSOR** role when they log in. Everyone else gets the **STUDENT** role by default. - -``` -# One UTORid per line. Lines starting with # are ignored. -scalij -phintr -yousef10 -``` - -The role is checked on every login, so changes take effect the next time the user logs in. - -**To add a new professor:** - -```bash -ssh easy@redacted_ip -echo "newutorid" >> ~/AskEasy/whitelist.txt -docker restart ask_easy-app-1 -``` - -**TAs are not listed here.** TAs are assigned per-course by professors through the app UI. A TA has elevated permissions only within the specific course they're assigned to. - -### `admin_whitelist.txt` — who can access the Admin Dashboard - -Any UTORid listed here gains access to the `/dashboard` admin panel. They must also be a PROFESSOR (i.e., also in `whitelist.txt`) for their role to function correctly, but the dashboard itself only checks this file. +Any UTORid listed here can create classlists and reach the `/dashboard` admin panel. +The file is read once at server startup and cached in memory, so a restart is required +to pick up changes. ``` # One UTORid per line. Lines starting with # are ignored. @@ -55,6 +38,9 @@ echo "newutorid" >> ~/AskEasy/admin_whitelist.txt docker restart ask_easy-app-1 ``` +**To add a new professor**, no server access is needed: an admin creates the classlist +in the app and types the professor's UTORid. There is no `whitelist.txt` any more. + Anyone not in this file who tries to visit `/dashboard` is silently redirected to the home page. --- @@ -65,17 +51,18 @@ The **Dashboard** link appears in the **top-right corner of the home page** — ### Stats bar -At the top of the page, seven live counters give you a snapshot of the platform: +At the top of the page, eight live counters give you a snapshot of the platform: -| Counter | What it shows | -|---------|--------------| -| Total Users | Everyone who has ever logged in | -| Total Courses | All courses ever created | -| Active Sessions | Sessions currently live | -| Total Sessions | All sessions ever created | -| Total Questions | All questions ever asked | -| Total Answers | All answers ever posted | -| Enrollments | Total course membership records | +| Counter | What it shows | +| --------------- | ------------------------------------------------------------------------------- | +| Total Users | Everyone who has ever logged in | +| Classlists | All classlists ever created | +| Rooms | All rooms ever created — one per professor, so this exceeds the classlist count | +| Active Sessions | Sessions currently live | +| Total Sessions | All sessions ever created | +| Total Questions | All questions ever asked | +| Total Answers | All answers ever posted | +| Enrollments | Total room membership records | Click **Refresh** (top right of the page) to reload the counts and all table data. @@ -85,7 +72,7 @@ Click **Refresh** (top right of the page) to reload the counts and all table dat The landing tab when you open the dashboard. It has: -- A reminder about how deletions cascade (e.g. deleting a user removes their questions, answers, and enrollments; deleting a course removes all its sessions and questions) +- A reminder about how deletions cascade (e.g. deleting a user removes their questions, answers, and enrollments; deleting a room removes all its sessions and questions) - The **Danger Zone** — a "Delete Everything" button that wipes the entire database in one action. Requires typing `DELETE EVERYTHING` to confirm. Use this at the end of a term to fully reset the platform. --- @@ -95,25 +82,29 @@ The landing tab when you open the dashboard. It has: **Columns:** Name, UTORid, Email, Role **Filters:** + - Search by name or UTORid - Filter by role (Student / TA / Professor) **Actions:** + - Delete a single user — removes the user and all their questions, answers, and enrollments across the platform - **Delete All Users** button — requires typing `DELETE USERS` to confirm; wipes every user record --- -### Courses tab +### Rooms tab **Columns:** Code, Name, Semester, Created By, Enrollment count, Session count **Filters:** -- Search by course code or name + +- Search by class code, room name, or the professor who runs it **Actions:** -- Delete a single course — cascades to all its sessions, questions, answers, enrollments, and slides -- **Delete All Courses** button — requires typing `DELETE COURSES` to confirm + +- Delete a single room — cascades to all its sessions, questions, answers, enrollments, and slides. Deleting the last room on a classlist removes the classlist too +- **Delete All Rooms** button — requires typing `DELETE COURSES` to confirm --- @@ -122,14 +113,16 @@ The landing tab when you open the dashboard. It has: **Columns:** Title, Course, Status (ACTIVE / ENDED), Created By, Question count, Created date **Filters:** + - Search by session title - Filter by status (Active / Scheduled / Ended) **Actions:** + - Delete a single session — removes all its questions, answers, and uploaded slides - **Delete All Sessions** button — requires typing `DELETE SESSIONS` to confirm -> Deleting sessions here is the recommended way to clear old Q&A data at end of term without touching users or courses. +> Deleting sessions here is the recommended way to clear old Q&A data at end of term without touching users or rooms. --- @@ -138,6 +131,7 @@ The landing tab when you open the dashboard. It has: **Columns:** Metadata for every uploaded PDF, linked to its session **Actions:** + - Delete individual slide set records - **Delete All Slide Sets** button @@ -150,6 +144,7 @@ The landing tab when you open the dashboard. It has: **Columns:** Question content, session, author, status, timestamps **Actions:** + - Delete individual questions - **Delete All Questions** button @@ -160,51 +155,61 @@ The landing tab when you open the dashboard. It has: **Columns:** User name, UTORid, Course, Role (Student / TA / Professor) **Filters:** -- Search by name, UTORid, or course code + +- Search by name, UTORid, or class code - Filter by role **Actions:** -- Remove a single enrollment (removes the user from that course only, does not delete the user) + +- Remove a single enrollment (removes the user from that room only, does not delete the user) - **Delete All Enrollments** button — requires typing `DELETE ENROLLMENTS` to confirm - Supports **Load More** pagination (loads 50 at a time) -> Deleting all enrollments at end of term is a clean way to reset course rosters while keeping user accounts intact. +> Deleting all enrollments at end of term is a clean way to reset rosters while keeping user accounts intact. --- -## Managing Courses Through the App +## Managing Classes Through the App -Professors manage everything through the **Manage Lecture** modal on the `/classes` page. Click "Manage Lecture" on any course card to open it. It has four tabs: +Admins manage a class from the **gear icon** on its card on the home page. It has +four tabs: ### Students tab -- View the full student roster (searchable by name or UTORid) -- **Remove** individual students by hovering their row and clicking the remove icon -- **Add students** by typing one or more UTORids (comma, space, or newline separated) -- **Sync roster from CSV** — upload a class list CSV exported from ACORN/ROSI. The app shows a preview of who will be added and removed before applying. TAs are not affected by a sync. +- The pooled roster across every room on the class +- Add students by UTORid — they are enrolled in every room +- Sync the roster from a fresh CSV, with a diff preview before it applies +- Removing a student drops them from every room + +### Rooms & TAs tab -### TAs tab +- One row per room, showing who runs it, how many members it has, and whether it is live +- Click a room's name to rename it. The name is the room's, not the professor's — it is yours to set and nothing else ever changes it +- Add a professor — this creates them a room, with the name you give it, seeded with the current roster and TAs +- Remove a professor — this **deletes their room and everything in it**. Blocked on the last professor and on a live room +- Add TAs by UTORid — they become TAs in every room on the class -- View and remove current TAs -- Add new TAs by UTORid — same input format as students +A professor manages TAs on their own room from inside a live session, via the +Manage TAs button in the chat header. That affects their room only. ### Rename tab -- Update the course code and/or semester label +- Update the class code and/or semester label +- The change fans out to every room, so none is left showing the old code ### Delete tab -- Permanently deletes the course and everything under it: all sessions, questions, answers, upvotes, and uploaded slides -- Requires typing the course code to confirm -- **Blocked if the course has an active session** — end the session first +- Permanently deletes the class, all its rooms, and everything under them: sessions, questions, answers, upvotes, and uploaded slides +- Requires typing the class code to confirm +- **Blocked while any room is live** — end the session first --- -## End of Semester Cleanup +## End-of-Semester Cleanup -### Step 1 — Delete courses through the app +### Step 1 — Delete classes through the app -For each course you want to retire, go to `/classes`, open "Manage Lecture", go to the **Delete** tab, type the course code, and confirm. This cascades through the database and removes all associated sessions, Q&A data, enrollments, and slide records. +For each class you want to retire, open its gear icon on the home page, go to the **Delete** tab, type the class code, and confirm. This cascades through the database and removes every room on the class along with all associated sessions, Q&A data, enrollments, and slide records. ### Step 2 — Remove uploaded slide files from disk @@ -231,26 +236,27 @@ docker exec -it ask_easy-postgres-1 psql -U postgres -d ask_easy Useful psql commands: -| Command | What it does | -|---------|-------------| -| `\dt` | List all tables | -| `\q` | Exit psql | -| `SELECT * FROM "User";` | See all users who have logged in | -| `SELECT * FROM "Course";` | See all courses | -| `SELECT * FROM "Session";` | See all sessions | +| Command | What it does | +| -------------------------- | -------------------------------- | +| `\dt` | List all tables | +| `\q` | Exit psql | +| `SELECT * FROM "User";` | See all users who have logged in | +| `SELECT * FROM "Course";` | See all rooms | +| `SELECT * FROM "Session";` | See all sessions | ### Database tables -| Table | What it stores | -|-------|---------------| -| `User` | Everyone who has logged in (UTORid, name, email, global role) | -| `Course` | Courses created by professors | -| `CourseEnrollment` | Which users are in which courses (STUDENT / TA / PROFESSOR) | -| `Session` | Live Q&A sessions within a course | -| `Question` | Questions asked during sessions | -| `Answer` | Answers to questions | -| `QuestionUpvote` / `AnswerUpvote` | Upvote records | -| `SlideSet` | Uploaded PDF metadata (files live in `uploads/`) | +| Table | What it stores | +| --------------------------------- | ------------------------------------------------------------- | +| `User` | Everyone who has logged in (UTORid, name, email, global role) | +| `Classlist` | A class an admin uploaded: code, semester, roster | +| `Course` | A **room** — one professor's space inside a classlist | +| `CourseEnrollment` | Which users are in which rooms (STUDENT / TA / PROFESSOR) | +| `Session` | Live Q&A sessions within a room | +| `Question` | Questions asked during sessions | +| `Answer` | Answers to questions | +| `QuestionUpvote` / `AnswerUpvote` | Upvote records | +| `SlideSet` | Uploaded PDF metadata (files live in `uploads/`) | --- @@ -271,11 +277,10 @@ Useful psql commands: ### Key files on the VM -| File | Purpose | -|------|---------| -| `/etc/apache2/sites-enabled/askeasy.conf` | Apache vhost — TLS, reverse proxy, Shibboleth directives | -| `/etc/shibboleth/shibboleth2.xml` | Shibboleth SP config — entity ID, IdP endpoint, metadata | -| `/etc/shibboleth/utorauth_metadata_verify.crt` | U of T metadata signing certificate | -| `/etc/letsencrypt/live/askeasy.utm.utoronto.ca/` | TLS certificates (auto-renewed by certbot) | -| `~/AskEasy/whitelist.txt` | UTORids that receive the PROFESSOR role | -| `~/AskEasy/admin_whitelist.txt` | UTORids that can access the `/dashboard` admin panel | +| File | Purpose | +| ------------------------------------------------ | ---------------------------------------------------------- | +| `/etc/apache2/sites-enabled/askeasy.conf` | Apache vhost — TLS, reverse proxy, Shibboleth directives | +| `/etc/shibboleth/shibboleth2.xml` | Shibboleth SP config — entity ID, IdP endpoint, metadata | +| `/etc/shibboleth/utorauth_metadata_verify.crt` | U of T metadata signing certificate | +| `/etc/letsencrypt/live/askeasy.utm.utoronto.ca/` | TLS certificates (auto-renewed by certbot) | +| `~/AskEasy/admin_whitelist.txt` | UTORids that can create classlists and access `/dashboard` | diff --git a/prisma/migrations/20260910000000_classlist_rooms/migration.sql b/prisma/migrations/20260910000000_classlist_rooms/migration.sql new file mode 100644 index 0000000..822eb92 --- /dev/null +++ b/prisma/migrations/20260910000000_classlist_rooms/migration.sql @@ -0,0 +1,81 @@ +-- Classlists and per-professor rooms. +-- +-- A Course row is now a *room*: one professor's space inside a Classlist. +-- Existing courses become single-room classlists so nothing is orphaned. + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "hasLoggedIn" BOOLEAN NOT NULL DEFAULT false; + +-- Everyone already in the table got there either by signing in or by being on +-- an uploaded roster. Assume the former where the name is not just the UTORid. +UPDATE "User" SET "hasLoggedIn" = true WHERE "name" <> "utorid"; + +-- CreateTable +CREATE TABLE "Classlist" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "semester" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Classlist_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Classlist_createdById_idx" ON "Classlist"("createdById"); +CREATE INDEX "Classlist_semester_idx" ON "Classlist"("semester"); + +-- AddForeignKey +ALTER TABLE "Classlist" ADD CONSTRAINT "Classlist_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "Course" ADD COLUMN "classlistId" TEXT; +ALTER TABLE "Course" ADD COLUMN "professorId" TEXT; + +-- CreateIndex +CREATE INDEX "Course_classlistId_idx" ON "Course"("classlistId"); +CREATE INDEX "Course_professorId_idx" ON "Course"("professorId"); + +-- AddForeignKey +ALTER TABLE "Course" ADD CONSTRAINT "Course_classlistId_fkey" FOREIGN KEY ("classlistId") REFERENCES "Classlist"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Course" ADD CONSTRAINT "Course_professorId_fkey" FOREIGN KEY ("professorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Backfill: give every existing course a classlist of its own, and make its +-- creator the room's professor. +INSERT INTO "Classlist" ("id", "code", "semester", "createdById", "createdAt", "updatedAt") +SELECT "id", "code", "semester", "createdById", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM "Course"; + +UPDATE "Course" SET "classlistId" = "id", "professorId" = "createdById"; + +-- A room has exactly one professor. Courses created under the old co-professor +-- model can have several, so demote everyone who is not the room's professor. +UPDATE "CourseEnrollment" e +SET "role" = 'TA' +FROM "Course" c +WHERE e."courseId" = c."id" + AND e."role" = 'PROFESSOR' + AND e."userId" <> c."professorId"; + +-- The room's professor must actually be enrolled as one — a course whose +-- creator was never enrolled would otherwise end up with no professor at all. +INSERT INTO "CourseEnrollment" ("id", "userId", "courseId", "role") +SELECT md5(random()::text || clock_timestamp()::text), c."professorId", c."id", 'PROFESSOR' +FROM "Course" c +WHERE c."professorId" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM "CourseEnrollment" e + WHERE e."courseId" = c."id" AND e."userId" = c."professorId" + ); + +UPDATE "CourseEnrollment" e +SET "role" = 'PROFESSOR' +FROM "Course" c +WHERE e."courseId" = c."id" AND e."userId" = c."professorId" AND e."role" <> 'PROFESSOR'; + +-- Enforce the invariant from here on. Prisma cannot express a partial unique +-- index, so it lives in SQL: a second PROFESSOR enrollment on a room fails. +CREATE UNIQUE INDEX "CourseEnrollment_one_professor_per_course" + ON "CourseEnrollment" ("courseId") + WHERE "role" = 'PROFESSOR'; diff --git a/prisma/migrations/20260911000000_room_name_is_owned/migration.sql b/prisma/migrations/20260911000000_room_name_is_owned/migration.sql new file mode 100644 index 0000000..b26a0f3 --- /dev/null +++ b/prisma/migrations/20260911000000_room_name_is_owned/migration.sql @@ -0,0 +1,16 @@ +-- Course.name becomes the room's own label instead of a copy of the class code. +-- +-- Until now a room was labelled by deriving its professor's surname at read +-- time, which meant the label changed under the admin whenever that person's +-- account name changed. The label is now stored and admin-owned. +-- +-- Backfill with what the derivation used to produce: the last whitespace- +-- separated part of the professor's name, falling back to their UTORid. +-- Rooms with no professor keep the class code they already had. +UPDATE "Course" c +SET "name" = COALESCE( + NULLIF(regexp_replace(btrim(u."name"), '^.*\s', ''), ''), + u."utorid" +) +FROM "User" u +WHERE c."professorId" = u."id"; diff --git a/prisma/migrations/20260911010000_drop_has_logged_in/migration.sql b/prisma/migrations/20260911010000_drop_has_logged_in/migration.sql new file mode 100644 index 0000000..f1f09f1 --- /dev/null +++ b/prisma/migrations/20260911010000_drop_has_logged_in/migration.sql @@ -0,0 +1,4 @@ +-- hasLoggedIn existed only to decide whether a name an admin typed was a +-- placeholder. Room names are now the room's own, set by an admin and never +-- rewritten, so nothing branches on whether someone has signed in before. +ALTER TABLE "User" DROP COLUMN "hasLoggedIn"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d33f74..fcada2d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,19 @@ enum QuestionStatus { // ============================================================================= // Models +// +// Classlist → Course → Session +// +// A **Classlist** is what an admin uploads: one course code, one semester, one +// student roster. Nothing happens inside it directly. +// +// A **Course** row is a **room** — one professor's Q&A space for that classlist. +// Creating a classlist creates one room per professor, and a room has exactly +// one PROFESSOR enrollment (enforced by a partial unique index; see the +// classlist_rooms migration). Everything session-scoped resolves permissions +// through CourseEnrollment for the room, so a professor sees only their own. +// +// A **Session** is one live sitting of a room. // ============================================================================= model User { @@ -44,10 +57,13 @@ model User { utorid String @unique email String @unique name String + /// Always STUDENT. Real roles live on CourseEnrollment, per room. role Role @default(STUDENT) enrollments CourseEnrollment[] createdCourses Course[] + createdClasslists Classlist[] @relation("ClasslistCreator") + professorOfRooms Course[] @relation("RoomProfessor") createdSessions Session[] questions Question[] answers Answer[] @@ -58,19 +74,52 @@ model User { @@index([role]) } +/// One uploaded classlist: a course code, a semester and a student roster. +/// Owns one room (Course) per professor. +model Classlist { + id String @id @default(cuid()) + code String + semester String + /// The admin who uploaded it. They get a room of their own and TA access to + /// every other room on this classlist. + createdById String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + createdBy User @relation("ClasslistCreator", fields: [createdById], references: [id]) + rooms Course[] + + @@index([createdById]) + @@index([semester]) +} + +/// A room — one professor's Q&A space for a classlist. Exactly one +/// CourseEnrollment on it has role PROFESSOR. model Course { id String @id @default(cuid()) + /// The classlist's code, copied here for display. Same across sibling rooms. code String + /// The room's label, e.g. "Nakamura". Set by the admin who created the room + /// and only ever changed by an admin — it is the room's name, not the + /// professor's, so signing in never rewrites it. name String semester String createdById String + /// Null only for rooms created before classlists existed. + classlistId String? + /// The room's one professor. + professorId String? createdBy User @relation(fields: [createdById], references: [id]) + classlist Classlist? @relation(fields: [classlistId], references: [id]) + professor User? @relation("RoomProfessor", fields: [professorId], references: [id]) enrollments CourseEnrollment[] sessions Session[] @@index([createdById]) @@index([semester]) + @@index([classlistId]) + @@index([professorId]) } model CourseEnrollment { diff --git a/prisma/seed.ts b/prisma/seed.ts index 4d1ffe4..03cfdf3 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -13,6 +13,7 @@ async function main() { await prisma.session.deleteMany(); await prisma.courseEnrollment.deleteMany(); await prisma.course.deleteMany(); + await prisma.classlist.deleteMany(); await prisma.user.deleteMany(); console.log("✅ All tables cleared. DB is empty and ready.\n"); diff --git a/scripts/dev-all.ts b/scripts/dev-all.ts index b825bba..9d3a568 100644 --- a/scripts/dev-all.ts +++ b/scripts/dev-all.ts @@ -1,25 +1,35 @@ /** * Multi-instance dev launcher — `pnpm dev:all` * - * Spins up three dev servers, each permanently logged in as a different - * persona, so multi-user flows (a student asking, a TA resolving, a professor - * answering) can be tested in three tabs of one browser window. + * Spins up N professor, M TA, and K student dev servers so multi-user flows + * can be tested in multiple tabs of one browser window. * + * pnpm dev:all → 1 prof, 1 TA, 1 student (default) + * pnpm dev:all 2 1 3 → 2 profs, 1 TA, 3 students + * + * Default layout (1 of each): * PROF -> http://localhost:3000 askeasy-dev-prof * TA -> http://localhost:3001 askeasy-dev-ta * STUDENT -> http://localhost:3002 askeasy-dev-student * + * PROF / TA / STUDENT are labels for which tab is which, not roles. Roles are + * granted per class by an admin, so a persona is a professor only once it has + * been assigned to a class — sign in as an admin (ADMIN_WHITELIST) and add the + * other personas' UTORids when you create the classlist. + * + * Extra instances of a label get sequential ports, distinct cookie names / + * dist dirs, and numbered default identities (devprof2, …). Override with + * DEV_