diff --git a/FEATURES.md b/FEATURES.md
index 78ce1f0..59676b6 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -7,12 +7,14 @@ A comprehensive list of every feature in the AskEasy platform.
## Authentication & Authorization
### Authentication
+
- **Shibboleth SSO** — Production login via UofT's SAML identity provider (reads `utorid`, `displayname`, `email` headers from Apache mod_shib)
- **Dev login** — Local development uses `DEV_UTORID`, `DEV_NAME`, `DEV_EMAIL` environment variables
- **Session cookies** — iron-session sealed httpOnly cookies
- **Open redirect protection** — Post-login redirects restricted to same-origin relative paths
### 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)
@@ -20,27 +22,31 @@ A comprehensive list of every feature in the AskEasy platform.
- **Effective permissions** — course/session actions use the per-course enrollment role, not the global role
### Endpoints
-| Endpoint | Description |
-|----------|-------------|
-| `GET /api/auth/session` | Establishes session from Shibboleth/dev headers |
-| `GET /api/auth/me` | Returns current user info (userId, utorid, name, email, role) |
-| `POST /api/auth/logout` | Destroys session cookie |
+
+| Endpoint | Description |
+| ----------------------- | ------------------------------------------------------------- |
+| `GET /api/auth/session` | Establishes session from Shibboleth/dev headers |
+| `GET /api/auth/me` | Returns current user info (userId, utorid, name, email, role) |
+| `POST /api/auth/logout` | Destroys session cookie |
---
## Course Management
### Creation
+
- Professors create courses with a course code, name, and optional section
- **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
### 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
### Student & TA 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
@@ -53,12 +59,14 @@ A comprehensive list of every feature in the AskEasy platform.
## Session Management
### Lifecycle
+
- **Statuses**: ACTIVE, ENDED
- **Creation** — professor creates a session with a title (3–100 characters); starts as ACTIVE immediately
- **Manual end** — professor ends the session; broadcasts `session:ended` to all connected clients; cleans up Q&A data and slide files
- **Auto-end** — sessions with no question activity for 2 hours are automatically ended
### Join Codes
+
- **Format** — 6-character uppercase alphanumeric code
- **Case-insensitive lookup**
- **Regeneration** — professor can regenerate the code (rate limit: 5 per hour)
@@ -66,10 +74,12 @@ A comprehensive list of every feature in the AskEasy platform.
- **Ended sessions** — attempting to join returns 410 Gone
### Activity Tracking
+
- `lastActivityAt` updated on every question creation
- Used by the auto-end check (cutoff = 2 hours of inactivity)
### Cron Cleanup
+
- `GET /api/cron/cleanup-sessions` — secured by `CRON_SECRET` bearer token
- Finds and ends all stale ACTIVE sessions in parallel
- Returns `{ended: N, failed: M}`
@@ -79,18 +89,20 @@ A comprehensive list of every feature in the AskEasy platform.
## Live Q&A Room
### Questions
+
- **Create** — 5–500 characters; optional anonymous flag and visibility setting
- **Visibility** — PUBLIC (everyone) or INSTRUCTOR_ONLY (TAs and professors only)
- **Upvote** — toggle per user; updates count in real time
- **Resolve** — marks question as RESOLVED; students can resolve their own, TAs/professors can resolve any
- **Unresolve** — TAs/professors can reopen a resolved question
-- **Delete** — professors can delete any question; TAs can delete student questions; students cannot delete
+- **Delete** — anyone can delete their own question; professors can delete any question; TAs can additionally delete student questions
- **Filtering** — by status: All, Unresolved, Resolved
- **Search** — case-insensitive substring match on question content
- **Sorting** — newest first (default) or by vote count
- **Pagination** — cursor-based, 20 per page (max 50)
### Answers
+
- **Create** — 1–1,000 characters; optional anonymous flag
- **Upvote** — toggle per user; updates count in real time
- **Delete** — same permission rules as questions
@@ -98,13 +110,15 @@ A comprehensive list of every feature in the AskEasy platform.
- **Thread states** — default (shows accepted/best answers), expanded (all replies), collapsed (hidden)
### Anonymous Posting
+
- Questions and answers can be posted anonymously
- **Students** see "Anonymous" as the author
- **TAs and professors** receive a separate `author:revealed` event showing the real identity and role
### Answer Mode Restriction
+
- Professor can toggle between "all" (everyone can answer) and "instructors_only" (only TAs/professors can answer)
-- **Exception**: the question author can always answer their own question regardless of mode
+- **Exception**: the question author can always answer their own question regardless of mode — unless they asked anonymously, since with everyone else locked out a reply would identify them as the asker
- **Default**: instructors only
- Stored in Redis with 24-hour TTL; late joiners sync on connect
@@ -113,20 +127,24 @@ A comprehensive list of every feature in the AskEasy platform.
## Slide Viewer
### Upload
+
- **PDF only** — validated by MIME type, magic bytes, and parseability
- **Size limits** — 1 KB to 50 MB
- Professor-only; session must be ACTIVE
### Viewing
+
- Served inline with `Content-Disposition: inline` and 1-hour cache
- Auth-gated: must be enrolled in the session's course
### 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
- New upload triggers `slides:available` notification to the room
### Split View
+
- Resizable panel layout — Q&A chat and slide viewer side by side
- Panels adapt based on screen size (mobile detection via `useMediaQuery`)
@@ -165,16 +183,19 @@ A comprehensive list of every feature in the AskEasy platform.
All rate limits are per-user, enforced via Redis counters.
-| Action | Limit | Window |
-|--------|-------|--------|
-| Question creation | 10 | 60 s |
-| Question upvote | 30 | 60 s |
-| Question resolve/unresolve | 20 | 60 s |
-| Answer creation | 15 | 60 s |
-| Answer upvote | 30 | 60 s |
-| Join code lookup | 30 | 60 s |
-| Join code registration | 10 | 60 s |
-| Join code regeneration | 5 | 1 hour |
+| Action | Limit | Window |
+| -------------------------- | ----- | ------ |
+| Question creation | 2 | 10 s |
+| Question upvote | 10 | 10 s |
+| Question resolve/unresolve | 10 | 10 s |
+| Answer creation | 5 | 10 s |
+| Answer upvote | 10 | 10 s |
+| Join code lookup | 30 | 60 s |
+| Join code registration | 10 | 60 s |
+| Join code regeneration | 5 | 1 hour |
+
+Upvotes share one counter across questions and answers, as do resolve and unresolve.
+A refused action returns a short message the client shows as a toast.
If Redis is unavailable, rate limiting fails closed (blocks all requests).
@@ -184,60 +205,62 @@ If Redis is unavailable, rate limiting fails closed (blocks all requests).
### Course Operations
-| 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) |
+| 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) |
### 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 |
+| Control slide page | | | Yes |
### Question Operations
-| Action | Student | TA | Professor |
-|--------|:-------:|:--:|:---------:|
-| Ask question | Yes | Yes | Yes |
-| Upvote | Yes | Yes | Yes |
-| Resolve own | Yes | Yes | Yes |
-| Resolve others' | | Yes | Yes |
-| Unresolve | | Yes | Yes |
-| Delete (student Qs) | | Yes | Yes |
-| Delete (TA Qs) | | | Yes |
-| See INSTRUCTOR_ONLY | | Yes | Yes |
+| Action | Student | TA | Professor |
+| ------------------- | :-----: | :-: | :-------: |
+| Ask question | Yes | Yes | Yes |
+| Upvote | Yes | Yes | Yes |
+| Resolve own | Yes | Yes | Yes |
+| Resolve others' | | Yes | Yes |
+| Unresolve | | Yes | Yes |
+| Delete own | Yes | Yes | Yes |
+| Delete (student Qs) | | Yes | Yes |
+| Delete (TA Qs) | | | Yes |
+| See INSTRUCTOR_ONLY | | Yes | Yes |
### Answer Operations
-| Action | Student | TA | Professor |
-|--------|:-------:|:--:|:---------:|
-| Answer (open mode) | Yes | Yes | Yes |
-| Answer (restricted mode) | Own Q only | Yes | Yes |
-| Upvote | Yes | Yes | Yes |
-| Delete (student As) | | Yes | Yes |
-| Delete (TA As) | | | Yes |
+| Action | Student | TA | Professor |
+| ------------------------ | :-----------------------------------: | :-: | :-------: |
+| Answer (open mode) | Yes | Yes | Yes |
+| Answer (restricted mode) | Own Q only (not if asked anonymously) | Yes | Yes |
+| Upvote | Yes | Yes | Yes |
+| Delete own | Yes | Yes | Yes |
+| Delete (student As) | | Yes | Yes |
+| Delete (TA As) | | | Yes |
---
## Content Constraints
-| Item | Min | Max |
-|------|-----|-----|
-| Question | 5 chars | 500 chars |
-| Answer | 1 char | 1,000 chars |
-| Session title | 3 chars | 100 chars |
-| Slide file | 1 KB | 50 MB |
+| Item | Min | Max |
+| ------------- | ------- | ----------- |
+| Question | 5 chars | 500 chars |
+| Answer | 1 char | 1,000 chars |
+| Session title | 3 chars | 100 chars |
+| Slide file | 1 KB | 50 MB |
---
@@ -245,45 +268,46 @@ If Redis is unavailable, rate limiting fails closed (blocks all requests).
### Client → Server
-| Event | Payload |
-|-------|---------|
-| `question:create` | `{content, sessionId, visibility?, isAnonymous?}` |
-| `question:upvote` | `{questionId}` |
-| `question:resolve` | `{questionId}` |
-| `question:unresolve` | `{questionId}` |
-| `question:delete` | `{questionId, sessionId}` |
-| `answer:create` | `{questionId, content, isAnonymous?}` |
-| `answer:upvote` | `{answerId}` |
-| `answer:delete` | `{answerId, sessionId}` |
-| `answer-mode:change` | `{sessionId, mode}` |
-| `answer-mode:sync` | `{sessionId}` |
-| `slide:change` | `{sessionId, pageIndex}` |
-| `slides:uploaded` | `{sessionId, slideSetId}` |
-| `slide:sync` | `{sessionId}` |
+| Event | Payload |
+| -------------------- | ------------------------------------------------- |
+| `question:create` | `{content, sessionId, visibility?, isAnonymous?}` |
+| `question:upvote` | `{questionId}` |
+| `question:resolve` | `{questionId}` |
+| `question:unresolve` | `{questionId}` |
+| `question:delete` | `{questionId, sessionId}` |
+| `answer:create` | `{questionId, content, isAnonymous?}` |
+| `answer:upvote` | `{answerId}` |
+| `answer:delete` | `{answerId, sessionId}` |
+| `answer-mode:change` | `{sessionId, mode}` |
+| `answer-mode:sync` | `{sessionId}` |
+| `slide:change` | `{sessionId, pageIndex}` |
+| `slides:uploaded` | `{sessionId, slideSetId}` |
+| `slide:sync` | `{sessionId}` |
### Server → Client
-| Event | Description |
-|-------|-------------|
-| `question:created` | New question (author redacted if anonymous) |
-| `question:updated` | Upvote count changed |
-| `question:resolved` | Status → RESOLVED |
-| `question:unresolved` | Status → OPEN |
-| `question:deleted` | Question removed |
-| `question:author:revealed` | Anonymous author disclosed (instructors only) |
-| `answer:created` | New answer (author redacted if anonymous) |
-| `answer:updated` | Upvote count changed |
-| `answer:deleted` | Answer removed |
-| `answer:author:revealed` | Anonymous author disclosed (instructors only) |
-| `answer-mode:changed` | Answer restriction toggled |
-| `slide:changed` | Page index updated |
-| `slides:available` | New slide set uploaded |
-| `slide:sync` | Current page index (to requesting socket only) |
-| `session:ended` | Session has ended |
-| `question:error` | Error on question operation |
-| `answer:error` | Error on answer operation |
-| `slide:error` | Error on slide operation |
+| Event | Description |
+| -------------------------- | ---------------------------------------------- |
+| `question:created` | New question (author redacted if anonymous) |
+| `question:updated` | Upvote count changed |
+| `question:resolved` | Status → RESOLVED |
+| `question:unresolved` | Status → OPEN |
+| `question:deleted` | Question removed |
+| `question:author:revealed` | Anonymous author disclosed (instructors only) |
+| `answer:created` | New answer (author redacted if anonymous) |
+| `answer:updated` | Upvote count changed |
+| `answer:deleted` | Answer removed |
+| `answer:author:revealed` | Anonymous author disclosed (instructors only) |
+| `answer-mode:changed` | Answer restriction toggled |
+| `slide:changed` | Page index updated |
+| `slides:available` | New slide set uploaded |
+| `slide:sync` | Current page index (to requesting socket only) |
+| `session:ended` | Session has ended |
+| `question:error` | Error on question operation |
+| `answer:error` | Error on answer operation |
+| `slide:error` | Error on slide operation |
### Room Names
+
- `session:{sessionId}` — all participants
- `session:{sessionId}:instructors` — TAs and professors only
diff --git a/package.json b/package.json
index e8458c9..f0a9e93 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"react-resizable-panels": "^2.0.19",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
+ "sonner": "^2.0.8",
"tailwind-merge": "^3.4.0",
"tsx": "^4.21.0"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 349381e..28e447c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,6 +83,9 @@ importers:
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
+ sonner:
+ specifier: ^2.0.8
+ version: 2.0.8(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -616,105 +619,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -787,28 +774,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.1':
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@next/swc-linux-x64-gnu@16.1.1':
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-x64-musl@16.1.1':
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.1':
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
@@ -1649,79 +1632,66 @@ packages:
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
cpu: [ppc64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -1814,28 +1784,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -2050,49 +2016,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -2695,6 +2653,7 @@ packages:
eslint@9.39.2:
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -3151,28 +3110,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -3732,6 +3687,16 @@ packages:
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
engines: {node: '>=10.2.0'}
+ sonner@2.0.8:
+ resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
+ peerDependencies:
+ '@types/react': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -7914,6 +7879,13 @@ snapshots:
- supports-color
- utf-8-validate
+ sonner@2.0.8(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.7
+
source-map-js@1.2.1: {}
split2@4.2.0: {}
diff --git a/src/app/api/questions/[questionId]/answers/route.ts b/src/app/api/questions/[questionId]/answers/route.ts
index af092a6..68b2f2f 100644
--- a/src/app/api/questions/[questionId]/answers/route.ts
+++ b/src/app/api/questions/[questionId]/answers/route.ts
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import {
validateAnswerContent,
checkAnswerRateLimit,
+ answerRetryAfter,
validateQuestionForAnswers,
} from "@/lib/answerValidation";
import { getQuestionAnswers } from "@/services/answerService";
@@ -83,7 +84,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
* 1. Authenticated user from session cookie
* 2. Question exists and belongs to an active session
* 3. Content length bounds
- * 4. Rate limit (15 answers per 60 seconds per user)
+ * 4. Rate limit (5 answers per 10 seconds per user)
*/
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
@@ -129,7 +130,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// Answer mode check — mirror the socket-layer restriction
const mode = await redisCache.get(answerModeKey(sessionId));
if (mode === "instructors_only") {
- const isQuestionAuthor = questionValidation.question!.authorId === user.userId;
+ // The author exemption stops at anonymous questions — see answerHandlers.
+ const isQuestionAuthor =
+ questionValidation.question!.authorId === user.userId &&
+ !questionValidation.question!.isAnonymous;
if (!isQuestionAuthor) {
const effectiveRole = enrollment?.role ?? "STUDENT";
if (effectiveRole === "STUDENT") {
@@ -149,9 +153,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const isRateLimited = await checkAnswerRateLimit(user.userId);
if (isRateLimited) {
+ const retryAfter = await answerRetryAfter(user.userId);
return NextResponse.json(
- { error: "Rate limit exceeded. Please wait before submitting another answer." },
- { status: 429 }
+ { error: "Too many answers.", retryAfterSeconds: retryAfter },
+ { status: 429, headers: { "Retry-After": String(retryAfter) } }
);
}
diff --git a/src/app/api/sessions/[sessionId]/questions/route.ts b/src/app/api/sessions/[sessionId]/questions/route.ts
index 56a6345..43125f4 100644
--- a/src/app/api/sessions/[sessionId]/questions/route.ts
+++ b/src/app/api/sessions/[sessionId]/questions/route.ts
@@ -10,10 +10,12 @@ import {
import {
validateQuestionContent,
validateVisibility,
+ checkQuestionRateLimit,
+ questionRetryAfter,
validateSessionForQuestions,
validateQuestionSlideContext,
} from "@/lib/questionValidation";
-import { getSessionMembership } from "@/lib/sessionService";
+import { getCourseRoles, getSessionMembership } from "@/lib/sessionService";
// ---------------------------------------------------------------------------
// Types
@@ -83,6 +85,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
author: { select: { id: true, name: true, role: true, utorid: true } },
_count: { select: { answers: true } },
answers: { where: { isAccepted: true }, select: { id: true }, take: 1 },
+ // The viewer's own upvote, so the button comes back filled after a reload.
+ upvotes: { where: { userId }, select: { id: true }, take: 1 },
} as const;
const questions =
@@ -108,6 +112,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const canRevealAnonymous = role === "TA" || role === "PROFESSOR";
+ // Author roles come from CourseEnrollment, not User.role — the latter is
+ // global and stays STUDENT for someone who is a TA in this course, which
+ // would drop the instructor cap and mis-scope the delete buttons.
+ const courseRoles = await getCourseRoles(
+ membership.courseId!,
+ page.map((q) => q.authorId).filter((id): id is string => id !== null)
+ );
+
const transformedQuestions = page.map((q) => ({
id: q.id,
content: q.content,
@@ -115,13 +127,19 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
status: q.status,
isAnonymous: q.isAnonymous,
upvoteCount: q.upvoteCount,
+ hasUpvoted: q.upvotes.length > 0,
answerCount: q._count.answers,
hasAcceptedAnswer: q.answers.length > 0,
acceptedAnswerId: q.answers[0]?.id ?? null,
createdAt: q.createdAt,
slidePageIndex: q.slidePageIndex,
slideSetId: q.slideSetId,
- author: q.isAnonymous && !canRevealAnonymous ? null : q.author,
+ author:
+ q.isAnonymous && !canRevealAnonymous
+ ? null
+ : q.author && { ...q.author, role: courseRoles.get(q.author.id) ?? q.author.role },
+ /** Lets the author delete their own post even when anonymity hides them. */
+ isMine: q.authorId === userId,
}));
const payload: {
@@ -168,7 +186,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
* Validations:
* 1. Content length bounds
* 2. Visibility is valid if provided
- * 3. Rate limit (10 questions per 60 seconds per user)
+ * 3. Rate limit (2 questions per 10 seconds per user, per session)
* 4. Session exists and has submissions enabled
*/
export async function POST(request: NextRequest, { params }: RouteParams) {
@@ -209,6 +227,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: visibilityValidation.error }, { status: 400 });
}
+ // 5b. Rate limit — counter is per user per session
+ if (await checkQuestionRateLimit(authorId, sessionId)) {
+ const retryAfter = await questionRetryAfter(authorId, sessionId);
+ return NextResponse.json(
+ { error: "Too many questions.", retryAfterSeconds: retryAfter },
+ { status: 429, headers: { "Retry-After": String(retryAfter) } }
+ );
+ }
+
// 6. Validate session using shared validation (submissions enabled check)
const sessionValidation = await validateSessionForQuestions(sessionId);
if (!sessionValidation.valid) {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 8b4f41a..3000f8d 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
+import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -48,7 +49,10 @@ export default function RootLayout({
}>) {
return (
-
{children}
+
+ {children}
+
+
);
}
diff --git a/src/app/room/classChat/ChatInput.tsx b/src/app/room/classChat/ChatInput.tsx
index 014057e..6ad283f 100644
--- a/src/app/room/classChat/ChatInput.tsx
+++ b/src/app/room/classChat/ChatInput.tsx
@@ -4,8 +4,7 @@ import { useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Ghost, User, Send, Presentation } from "lucide-react";
import { useSlideContext } from "../RoomContext";
-
-const MIN_LENGTH = 5;
+import { QUESTION_MAX_LENGTH, QUESTION_MIN_LENGTH } from "@/utils/contentLimits";
interface ChatInputProps {
onSubmit: (content: string, isAnonymous: boolean, includeSlideContext: boolean) => void;
@@ -30,27 +29,23 @@ export default function ChatInput({
}: ChatInputProps) {
const slideContext = useSlideContext();
const [content, setContent] = useState("");
- const [localError, setLocalError] = useState(null);
- const error = serverError ?? localError;
const slideContextAvailable =
slideContext.slidePageIndex !== null && slideContext.slideSetId !== null;
+ // Out-of-bounds length just greys out Post, the same as an empty box. The
+ // server enforces the same bounds — see validateQuestionContent.
+ const trimmed = content.trim();
+ const canPost =
+ !disabled && trimmed.length >= QUESTION_MIN_LENGTH && trimmed.length <= QUESTION_MAX_LENGTH;
+
const handleChange = (e: React.ChangeEvent) => {
setContent(e.target.value);
- if (localError) setLocalError(null);
if (serverError) onClearError?.();
};
const handleSubmit = () => {
- const trimmed = content.trim();
- if (disabled) return;
- if (!trimmed) return;
- if (trimmed.length < MIN_LENGTH) {
- setLocalError(`Question must be at least ${MIN_LENGTH} characters.`);
- return;
- }
- setLocalError(null);
+ if (!canPost) return;
onSubmit(trimmed, isAnonymous, includeSlideContext && slideContextAvailable);
setContent("");
};
@@ -76,7 +71,7 @@ export default function ChatInput({
disabled={disabled}
rows={3}
className={`resize-none min-h-[70px] focus-visible:ring-0 focus-visible:border-stone-400 ${
- error ? "border-red-400 bg-red-50" : ""
+ serverError ? "border-red-400 bg-red-50" : ""
}`}
/>
@@ -121,11 +116,11 @@ export default function ChatInput({
{includeSlideContext ? `Slide ${slideContext.slidePageIndex! + 1}` : "No slide"}
)}
- {error &&
{error}
}
+ {serverError &&
{serverError}
}