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}

}
+ ); +} + export const bestToTop = (replies: Post[] | undefined) => { return replies ?? []; }; diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index d20a98c..25168c1 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -13,8 +13,9 @@ import { Presentation, } from "lucide-react"; import { Question, Post } from "@/utils/types"; -import { UpvoteButton, renderUsername } from "./PostUtils"; +import { UpvoteButton, canRevealAuthor, renderUsername, RevealAuthorButton } from "./PostUtils"; import { useRoom } from "../../RoomContext"; +import { ANSWER_MAX_LENGTH, ANSWER_MIN_LENGTH } from "@/utils/contentLimits"; // --------------------------------------------------------------------------- // Reply composer @@ -37,9 +38,14 @@ function ReplySection({ canAnswer, onSubmit, onCancel }: ReplySectionProps) { ); } + // Same rule as the question composer: an out-of-bounds reply greys out Post + // rather than erroring. The server enforces the same bounds — see + // validateAnswerContent. + const trimmed = text.trim(); + const canPost = trimmed.length >= ANSWER_MIN_LENGTH && trimmed.length <= ANSWER_MAX_LENGTH; + const handleSubmit = () => { - const trimmed = text.trim(); - if (!trimmed) return; + if (!canPost) return; onSubmit(trimmed); setText(""); onCancel(); @@ -64,7 +70,7 @@ function ReplySection({ canAnswer, onSubmit, onCancel }: ReplySectionProps) { - @@ -140,6 +146,7 @@ export default function QuestionPost({ const [isReplying, setIsReplying] = useState(false); const [threadState, setThreadState] = useState("default"); const [confirmingDelete, setConfirmingDelete] = useState(false); + const [revealed, setRevealed] = useState(false); const { navigateToQuestionSlide } = useRoom(); /** Parent (socket/API) is the source of truth; optimistic updates flow through `post`. */ @@ -172,19 +179,19 @@ export default function QuestionPost({ return (
{/* Question body */} -
{post.content}
+
+ {post.content} +
{/* Meta row */}
- {/* Left: status dot + username + time + toggle */} + {/* Left: username + time + toggle */}
-
- {renderUsername(post.user, post.isAnonymous)} + {renderUsername(post.user, post.isAnonymous, revealed)} {post.timestamp} {post.slidePageIndex != null && post.slideSetId && ( @@ -249,6 +256,7 @@ export default function QuestionPost({ @@ -292,6 +300,10 @@ export default function QuestionPost({ )} + {canRevealAuthor(post) && ( + setRevealed((v) => !v)} /> + )} + {onDelete && (