From cc289bd5e5e7fed94b3600a5436df03b4771ac65 Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Sun, 6 Sep 2026 22:54:53 -0400 Subject: [PATCH 01/16] Fix missing roles until refresh by sending more info --- FEATURES.md | 203 ++++++++++-------- .../sessions/[sessionId]/questions/route.ts | 17 +- src/app/room/classChat/index.tsx | 52 +++-- src/app/room/classChat/post/index.tsx | 2 +- src/lib/sessionService.ts | 27 +++ src/services/answerService.ts | 31 ++- src/socket/handlers/answerHandlers.ts | 48 +++-- src/socket/handlers/questionHandlers.ts | 47 ++-- src/socket/types.ts | 6 + src/utils/types.ts | 2 + 10 files changed, 288 insertions(+), 147 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 78ce1f0..24082cc 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,11 +110,13 @@ 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 - **Default**: instructors only @@ -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,16 @@ 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 | 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 | If Redis is unavailable, rate limiting fails closed (blocks all requests). @@ -184,60 +202,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 | 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 +265,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/src/app/api/sessions/[sessionId]/questions/route.ts b/src/app/api/sessions/[sessionId]/questions/route.ts index 56a6345..6b76ebd 100644 --- a/src/app/api/sessions/[sessionId]/questions/route.ts +++ b/src/app/api/sessions/[sessionId]/questions/route.ts @@ -13,7 +13,7 @@ import { validateSessionForQuestions, validateQuestionSlideContext, } from "@/lib/questionValidation"; -import { getSessionMembership } from "@/lib/sessionService"; +import { getCourseRoles, getSessionMembership } from "@/lib/sessionService"; // --------------------------------------------------------------------------- // Types @@ -108,6 +108,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, @@ -121,7 +129,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { 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: { diff --git a/src/app/room/classChat/index.tsx b/src/app/room/classChat/index.tsx index 98620e0..3f7b6fe 100644 --- a/src/app/room/classChat/index.tsx +++ b/src/app/room/classChat/index.tsx @@ -26,6 +26,8 @@ interface APIQuestion { slidePageIndex?: number | null; slideSetId?: string | null; author: { id: string; utorid: string; name: string; role: Role } | null; + /** True for the viewer's own question, even when it was posted anonymously. */ + isMine?: boolean; } interface APIAnswer { @@ -40,6 +42,8 @@ interface APIAnswer { isAccepted: boolean; upvoteCount: number; createdAt: string; + /** True for the viewer's own answer, even when it was posted anonymously. */ + isMine?: boolean; } // --------------------------------------------------------------------------- @@ -69,6 +73,7 @@ function apiAnswerToPost(a: APIAnswer): Comment { content: a.content, upvotes: a.upvoteCount ?? 0, isAnonymous: a.isAnonymous, + isMine: a.isMine, }; } @@ -92,6 +97,7 @@ function apiQuestionToPost(q: APIQuestion, answers: APIAnswer[]): Question { upvotes: q.upvoteCount, isResolved: q.status === "RESOLVED", isAnonymous: q.isAnonymous, + isMine: q.isMine, replies: answers.map((a) => apiAnswerToPost(a)), visibility: q.visibility, slidePageIndex: q.slidePageIndex ?? null, @@ -190,8 +196,10 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { authorId?: string | null; authorName?: string | null; authorUtorid?: string | null; + authorRole?: Role; slidePageIndex?: number | null; slideSetId?: string | null; + isMine?: boolean; }) => { const user = payload.isAnonymous || !payload.authorName @@ -201,7 +209,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { utorid: payload.authorUtorid ?? undefined, username: payload.authorName, pfp: "", - role: "STUDENT" as Role, + role: payload.authorRole ?? ("STUDENT" as Role), }; const newQuestion: Question = { @@ -213,6 +221,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { upvotes: 0, isResolved: false, isAnonymous: payload.isAnonymous, + isMine: payload.isMine, replies: [], visibility: payload.visibility as "PUBLIC" | "INSTRUCTOR_ONLY", slidePageIndex: payload.slidePageIndex ?? null, @@ -252,6 +261,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { authorRole: Role; isAccepted: boolean; createdAt: Date; + isMine?: boolean; }) => { const apiAnswer: APIAnswer = { id: payload.id, @@ -271,6 +281,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { isAccepted: payload.isAccepted, upvoteCount: 0, createdAt: new Date(payload.createdAt).toISOString(), + isMine: payload.isMine, }; const newReply = apiAnswerToPost(apiAnswer); @@ -521,20 +532,33 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { socket.emit("answer:delete", { answerId, sessionId }); }; + /** + * True when the post belongs to the current user. `isMine` is what makes + * this work for posts the viewer made anonymously — those come back with the + * author stripped, so the id comparison alone would say no. + */ + function isOwnPost(post: { user: { id?: string } | null; isMine?: boolean }): boolean { + if (post.isMine) return true; + return post.user?.id !== undefined && post.user.id === userId; + } + /** * Returns true when the current user may delete the given post. - * - PROFESSOR: always (including anonymous posts) - * - TA: only named STUDENT posts, or their own named posts. - * Anonymous posts are excluded because the client cannot verify the - * author's role, and the author could be a professor or another TA. - * - STUDENT: never + * Mirrors the server rules in question/answer `delete` handlers: + * - Own post: always, whatever the viewer's role. `isMine` covers posts the + * viewer made anonymously, where the author is stripped from the payload. + * - PROFESSOR: any post, including anonymous ones. + * - TA: any STUDENT-authored post. Anonymous posts by someone else are + * excluded when the author is hidden, since the role can't be checked. + * - STUDENT: nothing beyond their own. */ - function canDelete(post: { user: { id?: string; role: Role } | null }): boolean { + function canDelete(post: { + user: { id?: string; role: Role } | null; + isMine?: boolean; + }): boolean { + if (isOwnPost(post)) return true; if (role === "PROFESSOR") return true; - if (role === "TA") { - if (!post.user) return false; // anonymous — author role unknown, hide button - return post.user.role === "STUDENT" || post.user.id === userId; - } + if (role === "TA") return post.user?.role === "STUDENT"; return false; } @@ -623,12 +647,10 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { commentView={commentView} onUpvote={() => handleUpvote(q.id)} onResolve={ - isInstructor || q.user?.id === userId - ? () => handleResolve(q.id) - : undefined + isInstructor || isOwnPost(q) ? () => handleResolve(q.id) : undefined } onUnresolve={isInstructor ? () => handleUnresolve(q.id) : undefined} - canAnswer={canAnswerGlobal || q.user?.id === userId} + canAnswer={canAnswerGlobal || isOwnPost(q)} onSubmitAnswer={(content) => handleSubmitAnswer(q.id, content)} onAnswerUpvote={handleAnswerUpvote} onDeleteQuestion={canDelete(q) ? () => handleDeleteQuestion(q.id) : undefined} diff --git a/src/app/room/classChat/post/index.tsx b/src/app/room/classChat/post/index.tsx index 4a6f9a7..18aeae5 100644 --- a/src/app/room/classChat/post/index.tsx +++ b/src/app/room/classChat/post/index.tsx @@ -14,7 +14,7 @@ interface PostItemProps { onUnresolve?: () => void; onSubmitAnswer?: (content: string) => void; onAnswerUpvote?: (answerId: string) => void; - /** Called when the professor/TA wants to delete this question. */ + /** Called when the current user may delete this question. */ onDeleteQuestion?: () => void; /** * Given a reply (Comment), returns a delete callback if the current user diff --git a/src/lib/sessionService.ts b/src/lib/sessionService.ts index 5162e1c..ca0ece5 100644 --- a/src/lib/sessionService.ts +++ b/src/lib/sessionService.ts @@ -20,6 +20,8 @@ export interface SessionMembershipResult { error?: string; statusCode?: number; role?: Role; + /** Course that owns the session — set whenever the session exists. */ + courseId?: string; } export interface ProfessorRoleValidationResult { @@ -180,15 +182,40 @@ export async function getSessionMembership( valid: false, error: "You are not enrolled in this course.", statusCode: 403, + courseId: session.courseId, }; } return { valid: true, role: enrollment.role, + courseId: session.courseId, }; } +/** + * Resolves the per-course role of several users in one query. + * + * CourseEnrollment is the source of truth for role-based UI: `User.role` is + * global and stays STUDENT for someone who is a TA in a particular course. + * Users with no enrollment row (e.g. a professor acting outside their own + * courses) are absent from the map — fall back to their global role. + */ +export async function getCourseRoles( + courseId: string, + userIds: string[] +): Promise> { + const ids = [...new Set(userIds)]; + if (ids.length === 0) return new Map(); + + const enrollments = await prisma.courseEnrollment.findMany({ + where: { courseId, userId: { in: ids } }, + select: { userId: true, role: true }, + }); + + return new Map(enrollments.map((e) => [e.userId, e.role])); +} + /** * Validates that a user is a professor in the specified course. * Checks CourseEnrollment for PROFESSOR role. diff --git a/src/services/answerService.ts b/src/services/answerService.ts index 100d764..58402f4 100644 --- a/src/services/answerService.ts +++ b/src/services/answerService.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/prisma"; import type { Role } from "@/generated/prisma"; +import { getCourseRoles } from "@/lib/sessionService"; // --------------------------------------------------------------------------- // Constants @@ -34,6 +35,8 @@ export interface AnswerResponse { isAnonymous: boolean; upvoteCount: number; createdAt: Date; + /** True for the requesting user's own answer, even when anonymity hides them. */ + isMine: boolean; } export interface GetAnswersResult { @@ -79,10 +82,13 @@ function canRevealAnonymous(role: Role): boolean { } /** - * Strips author identity from an answer when it should be hidden from the - * requesting user. + * Shapes an answer for the wire: strips author identity when it should be + * hidden from the requesting user, and reports the author's per-course role. + * + * `courseRole` comes from CourseEnrollment and wins over the author's global + * `User.role`, which stays STUDENT for someone who is a TA in this course. */ -function redactAuthorIfAnonymous( +function toAnswerResponse( answer: { id: string; questionId: string; @@ -93,9 +99,12 @@ function redactAuthorIfAnonymous( createdAt: Date; author: { id: string; utorid: string; name: string; role: Role }; }, - viewerCanReveal: boolean + viewerCanReveal: boolean, + viewerId: string, + courseRole: Role | undefined ): AnswerResponse { const hideAuthor = answer.isAnonymous && !viewerCanReveal; + const role = courseRole ?? answer.author.role; return { id: answer.id, @@ -107,13 +116,14 @@ function redactAuthorIfAnonymous( id: answer.author.id, utorid: answer.author.utorid, name: answer.author.name, - role: answer.author.role, + role, }, - authorRole: answer.author.role, + authorRole: role, isAccepted: answer.isAccepted, isAnonymous: answer.isAnonymous, upvoteCount: answer.upvoteCount, createdAt: answer.createdAt, + isMine: answer.author.id === viewerId, }; } @@ -223,9 +233,13 @@ export async function getQuestionAnswers( // ---- Redact anonymous authors for students ------------------------------- const viewerCanReveal = canRevealAnonymous(viewerRole); + const courseRoles = await getCourseRoles( + question.session.courseId, + answers.map((a) => a.author.id) + ); const transformed: AnswerResponse[] = answers.map((a) => - redactAuthorIfAnonymous(a, viewerCanReveal) + toAnswerResponse(a, viewerCanReveal, userId, courseRoles.get(a.author.id)) ); return { @@ -298,11 +312,12 @@ export async function getAnswerById( } const viewerCanReveal = canRevealAnonymous(viewerRole); + const authorRole = await getUserCourseRole(answer.author.id, answer.question.session.courseId); return { ok: true, data: { - answer: redactAuthorIfAnonymous(answer, viewerCanReveal), + answer: toAnswerResponse(answer, viewerCanReveal, userId, authorRole ?? undefined), questionId: answer.questionId, }, }; diff --git a/src/socket/handlers/answerHandlers.ts b/src/socket/handlers/answerHandlers.ts index 00a515a..c51ac66 100644 --- a/src/socket/handlers/answerHandlers.ts +++ b/src/socket/handlers/answerHandlers.ts @@ -38,9 +38,26 @@ interface AnswerDeletePayload { * Broadcasts a newly created answer to the session room. * * Answers are always broadcast to all users in the session (no visibility filtering). + * + * When `authorSocket` is in the room it receives a copy flagged `isMine` + * instead of the shared payload, so the author can still act on an anonymous + * answer whose identity was stripped from the broadcast. */ -export function broadcastAnswer(io: Server, sessionId: string, answer: AnswerCreatedPayload): void { - io.to(`session:${sessionId}`).emit("answer:created", answer); +export function broadcastAnswer( + io: Server, + sessionId: string, + answer: AnswerCreatedPayload, + authorSocket?: Socket +): void { + const room = `session:${sessionId}`; + + if (authorSocket?.rooms.has(room)) { + authorSocket.to(room).emit("answer:created", answer); + authorSocket.emit("answer:created", { ...answer, isMine: true }); + return; + } + + io.to(room).emit("answer:created", answer); } /** @@ -174,7 +191,7 @@ export function handleAnswerCreate(socket: Socket, io: Server): void { createdAt: answer.createdAt, }; - broadcastAnswer(io, questionValidation.question!.sessionId, broadcastPayload); + broadcastAnswer(io, questionValidation.question!.sessionId, broadcastPayload, socket); // For anonymous answers, reveal the author to instructors via a separate event. if (answer.isAnonymous) { @@ -323,9 +340,10 @@ export function handleAnswerUpvote(socket: Socket, io: Server): void { * Registers the `answer:delete` event listener on the given socket. * * Permission rules (same as question delete): + * - Anyone → may delete their own answer, whatever their role * - PROFESSOR → may delete any answer - * - TA → may delete any STUDENT's answer, or their own - * - STUDENT → never allowed + * - TA → may additionally delete any STUDENT's answer + * - STUDENT → nothing beyond their own */ export function handleAnswerDelete(socket: Socket, io: Server): void { socket.on("answer:delete", async (payload: AnswerDeletePayload) => { @@ -381,17 +399,17 @@ export function handleAnswerDelete(socket: Socket, io: Server): void { const requesterRole = requesterEnrollment?.role ?? "STUDENT"; - // 5. Permission check - if (requesterRole === "STUDENT") { - socket.emit("answer:error", { - message: "You do not have permission to delete this answer.", - }); - return; - } + // 5. Permission check — authors may always delete their own answer + const isOwn = answer.authorId === userId; + if (!isOwn) { + if (requesterRole === "STUDENT") { + socket.emit("answer:error", { + message: "You do not have permission to delete this answer.", + }); + return; + } - if (requesterRole === "TA") { - const isOwn = answer.authorId === userId; - if (!isOwn) { + if (requesterRole === "TA") { const authorEnrollment = await prisma.courseEnrollment.findUnique({ where: { userId_courseId: { userId: answer.authorId, courseId } }, select: { role: true }, diff --git a/src/socket/handlers/questionHandlers.ts b/src/socket/handlers/questionHandlers.ts index 37ee86a..e488aa9 100644 --- a/src/socket/handlers/questionHandlers.ts +++ b/src/socket/handlers/questionHandlers.ts @@ -32,10 +32,14 @@ interface QuestionBroadcastPayload { authorId?: string | null; authorName?: string | null; authorUtorid?: string | null; + authorRole?: "STUDENT" | "TA" | "PROFESSOR"; slidePageIndex?: number | null; slideSetId?: string | null; } +/** Extra field on the copy sent back to the author — never broadcast to others. */ +type OwnQuestionPayload = QuestionBroadcastPayload & { isMine: true }; + interface QuestionUpvotePayload { questionId: string; } @@ -61,17 +65,28 @@ interface QuestionDeletePayload { * session:{sessionId}:instructors — TAs and professors only * * PUBLIC questions go to the first room; INSTRUCTOR_ONLY questions go to the second. + * + * When `authorSocket` is in the target room it receives a copy flagged + * `isMine` instead of the shared payload, so the author can still act on an + * anonymous question whose identity was stripped from the broadcast. */ export function broadcastQuestion( io: Server, sessionId: string, - question: QuestionBroadcastPayload + question: QuestionBroadcastPayload, + authorSocket?: Socket ): void { const targetRoom = question.visibility === "INSTRUCTOR_ONLY" ? `session:${sessionId}:instructors` : `session:${sessionId}`; + if (authorSocket?.rooms.has(targetRoom)) { + authorSocket.to(targetRoom).emit("question:created", question); + authorSocket.emit("question:created", { ...question, isMine: true } as OwnQuestionPayload); + return; + } + io.to(targetRoom).emit("question:created", question); } @@ -200,10 +215,11 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { authorId: question.authorId, authorName: question.author?.name ?? null, authorUtorid: question.author?.utorid ?? null, + authorRole: authorEnrollmentRole as "STUDENT" | "TA" | "PROFESSOR", }), }; - broadcastQuestion(io, question.sessionId, broadcastPayload); + broadcastQuestion(io, question.sessionId, broadcastPayload, socket); // For anonymous questions, reveal the author to instructors via a separate event // so that TAs and Professors can see who posted while students remain unaware. @@ -590,9 +606,10 @@ export function handleQuestionUnresolve(socket: Socket, io: Server): void { * Registers the `question:delete` event listener on the given socket. * * Permission rules: + * - Anyone → may delete their own question, whatever their role * - PROFESSOR → may delete any question - * - TA → may delete any STUDENT's question, or their own - * - STUDENT → never allowed + * - TA → may additionally delete any STUDENT's question + * - STUDENT → nothing beyond their own * * Roles are resolved via CourseEnrollment so that per-course TA status is * correctly detected regardless of the user's global User.role. @@ -647,18 +664,18 @@ export function handleQuestionDelete(socket: Socket, io: Server): void { const requesterRole = requesterEnrollment?.role ?? "STUDENT"; - // 5. Permission check - if (requesterRole === "STUDENT") { - socket.emit("question:error", { - message: "You do not have permission to delete this question.", - }); - return; - } + // 5. Permission check — authors may always delete their own question + const isOwn = question.authorId === userId; + if (!isOwn) { + if (requesterRole === "STUDENT") { + socket.emit("question:error", { + message: "You do not have permission to delete this question.", + }); + return; + } - if (requesterRole === "TA") { - // TAs may only delete their own messages or those by STUDENT authors - const isOwn = question.authorId === userId; - if (!isOwn) { + if (requesterRole === "TA") { + // TAs may only delete questions by STUDENT authors const authorEnrollment = question.authorId ? await prisma.courseEnrollment.findUnique({ where: { userId_courseId: { userId: question.authorId, courseId } }, diff --git a/src/socket/types.ts b/src/socket/types.ts index 3cadcc3..d4cf8cf 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -23,6 +23,10 @@ export interface QuestionCreatedPayload { authorId?: string | null; authorName?: string | null; authorUtorid?: string | null; + /** Omitted for anonymous questions, alongside the other author fields. */ + authorRole?: "STUDENT" | "TA" | "PROFESSOR"; + /** Only ever true on the copy sent back to the author of the question. */ + isMine?: boolean; slidePageIndex?: number | null; slideSetId?: string | null; } @@ -65,6 +69,8 @@ export interface AnswerCreatedPayload { authorRole: "STUDENT" | "TA" | "PROFESSOR"; isAccepted: boolean; createdAt: Date; + /** Only ever true on the copy sent back to the author of the answer. */ + isMine?: boolean; } export interface QuestionUpvotePayload { diff --git a/src/utils/types.ts b/src/utils/types.ts index cc3d55b..3f939e4 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -19,6 +19,8 @@ interface BasePost { content: string; upvotes: number; isAnonymous?: boolean; + /** True for the viewer's own post — set even when anonymity hides the author. */ + isMine?: boolean; } export interface Question extends BasePost { From cb652569290b264b6172c9b6c6252e99b53ee3dd Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Sun, 6 Sep 2026 22:55:18 -0400 Subject: [PATCH 02/16] Remove colour circle beside name that was unneeded --- src/app/room/classChat/post/QuestionPost.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index d20a98c..89671ef 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -179,11 +179,8 @@ export default function QuestionPost({ {/* Meta row */}
- {/* Left: status dot + username + time + toggle */} + {/* Left: username + time + toggle */}
-
{renderUsername(post.user, post.isAnonymous)} {post.timestamp} From 642dcfdff51388ea149cf48bcd4653776ec34c3a Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Sun, 6 Sep 2026 23:05:15 -0400 Subject: [PATCH 03/16] Removed redundant instructor answer tag --- src/app/room/classChat/post/CommentPost.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/app/room/classChat/post/CommentPost.tsx b/src/app/room/classChat/post/CommentPost.tsx index 5598191..c80828a 100644 --- a/src/app/room/classChat/post/CommentPost.tsx +++ b/src/app/room/classChat/post/CommentPost.tsx @@ -27,11 +27,6 @@ export default function CommentPost({ post, onUpvote, onDelete }: CommentPostPro
{renderUsername(post.user, post.isAnonymous)} {post.timestamp} - {isInstructor && ( - - Instructor - - )}
Date: Sun, 6 Sep 2026 23:09:25 -0400 Subject: [PATCH 04/16] Filled grad cap for profs --- src/app/room/classChat/post/PostUtils.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/room/classChat/post/PostUtils.tsx b/src/app/room/classChat/post/PostUtils.tsx index b9d006e..65c4330 100644 --- a/src/app/room/classChat/post/PostUtils.tsx +++ b/src/app/room/classChat/post/PostUtils.tsx @@ -73,7 +73,10 @@ export function UpvoteButton({ initialVotes, controlledVotes, onUpvote }: Upvote } export function renderRoleIcon(user: User) { - if (user.role === "TA" || user.role === "PROFESSOR") { + if (user.role === "PROFESSOR") { + return ; + } + if (user.role === "TA") { return ; } return null; From 9f0f2b4904fcbfb6fe4f0a430cd7a5b08123e14f Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Sun, 6 Sep 2026 23:09:46 -0400 Subject: [PATCH 05/16] Full colour outline for questions not just left side --- src/app/room/classChat/post/QuestionPost.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index 89671ef..4e149d8 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -172,7 +172,7 @@ export default function QuestionPost({ return (
{/* Question body */}
{post.content}
From 4f4ec13f5352a9a9d64787bc7c6ade7dcca4e8d3 Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Sun, 6 Sep 2026 23:17:57 -0400 Subject: [PATCH 06/16] Upvote UI state persist across page refresh --- src/app/api/sessions/[sessionId]/questions/route.ts | 3 +++ src/app/room/classChat/index.tsx | 4 ++++ src/app/room/classChat/post/CommentPost.tsx | 1 + src/app/room/classChat/post/PostUtils.tsx | 11 +++++++++-- src/app/room/classChat/post/QuestionPost.tsx | 1 + src/services/answerService.ts | 7 +++++++ src/utils/types.ts | 2 ++ 7 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/app/api/sessions/[sessionId]/questions/route.ts b/src/app/api/sessions/[sessionId]/questions/route.ts index 6b76ebd..1f94ae6 100644 --- a/src/app/api/sessions/[sessionId]/questions/route.ts +++ b/src/app/api/sessions/[sessionId]/questions/route.ts @@ -83,6 +83,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 = @@ -123,6 +125,7 @@ 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, diff --git a/src/app/room/classChat/index.tsx b/src/app/room/classChat/index.tsx index 3f7b6fe..fe2987c 100644 --- a/src/app/room/classChat/index.tsx +++ b/src/app/room/classChat/index.tsx @@ -21,6 +21,7 @@ interface APIQuestion { status: "OPEN" | "ANSWERED" | "RESOLVED"; isAnonymous: boolean; upvoteCount: number; + hasUpvoted?: boolean; answerCount: number; createdAt: string; slidePageIndex?: number | null; @@ -41,6 +42,7 @@ interface APIAnswer { authorRole: Role; isAccepted: boolean; upvoteCount: number; + hasUpvoted?: boolean; createdAt: string; /** True for the viewer's own answer, even when it was posted anonymously. */ isMine?: boolean; @@ -74,6 +76,7 @@ function apiAnswerToPost(a: APIAnswer): Comment { upvotes: a.upvoteCount ?? 0, isAnonymous: a.isAnonymous, isMine: a.isMine, + hasUpvoted: a.hasUpvoted, }; } @@ -95,6 +98,7 @@ function apiQuestionToPost(q: APIQuestion, answers: APIAnswer[]): Question { timestamp: fmt(q.createdAt), content: q.content, upvotes: q.upvoteCount, + hasUpvoted: q.hasUpvoted, isResolved: q.status === "RESOLVED", isAnonymous: q.isAnonymous, isMine: q.isMine, diff --git a/src/app/room/classChat/post/CommentPost.tsx b/src/app/room/classChat/post/CommentPost.tsx index c80828a..4dcd875 100644 --- a/src/app/room/classChat/post/CommentPost.tsx +++ b/src/app/room/classChat/post/CommentPost.tsx @@ -44,6 +44,7 @@ export default function CommentPost({ post, onUpvote, onDelete }: CommentPostPro )} diff --git a/src/app/room/classChat/post/PostUtils.tsx b/src/app/room/classChat/post/PostUtils.tsx index 65c4330..16c5f77 100644 --- a/src/app/room/classChat/post/PostUtils.tsx +++ b/src/app/room/classChat/post/PostUtils.tsx @@ -32,12 +32,19 @@ interface UpvoteButtonProps { initialVotes: number; /** When provided, clicking emits an upvote and displays the server-controlled count. */ controlledVotes?: number; + /** Whether the viewer had already upvoted when the post was loaded. */ + initialUpvoted?: boolean; onUpvote?: () => void; } -export function UpvoteButton({ initialVotes, controlledVotes, onUpvote }: UpvoteButtonProps) { +export function UpvoteButton({ + initialVotes, + controlledVotes, + initialUpvoted = false, + onUpvote, +}: UpvoteButtonProps) { const [localVotes, setLocalVotes] = useState(initialVotes); - const [isUpvoted, setIsUpvoted] = useState(false); + const [isUpvoted, setIsUpvoted] = useState(initialUpvoted); const displayedVotes = controlledVotes !== undefined ? controlledVotes : localVotes; diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index 4e149d8..d4668e2 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -246,6 +246,7 @@ export default function QuestionPost({ diff --git a/src/services/answerService.ts b/src/services/answerService.ts index 58402f4..70c1a98 100644 --- a/src/services/answerService.ts +++ b/src/services/answerService.ts @@ -37,6 +37,8 @@ export interface AnswerResponse { createdAt: Date; /** True for the requesting user's own answer, even when anonymity hides them. */ isMine: boolean; + /** True when the requesting user has already upvoted this answer. */ + hasUpvoted: boolean; } export interface GetAnswersResult { @@ -98,6 +100,7 @@ function toAnswerResponse( upvoteCount: number; createdAt: Date; author: { id: string; utorid: string; name: string; role: Role }; + upvotes: { id: string }[]; }, viewerCanReveal: boolean, viewerId: string, @@ -124,6 +127,7 @@ function toAnswerResponse( upvoteCount: answer.upvoteCount, createdAt: answer.createdAt, isMine: answer.author.id === viewerId, + hasUpvoted: answer.upvotes.length > 0, }; } @@ -216,6 +220,8 @@ export async function getQuestionAnswers( author: { select: { id: true, utorid: true, name: true, role: true }, }, + // The viewer's own upvote, so the button comes back filled after a reload. + upvotes: { where: { userId }, select: { id: true }, take: 1 }, }, }); @@ -274,6 +280,7 @@ export async function getAnswerById( author: { select: { id: true, utorid: true, name: true, role: true }, }, + upvotes: { where: { userId }, select: { id: true }, take: 1 }, question: { select: { id: true, diff --git a/src/utils/types.ts b/src/utils/types.ts index 3f939e4..eb8abe6 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -21,6 +21,8 @@ interface BasePost { isAnonymous?: boolean; /** True for the viewer's own post — set even when anonymity hides the author. */ isMine?: boolean; + /** True when the viewer has already upvoted this post. */ + hasUpvoted?: boolean; } export interface Question extends BasePost { From ac2fcb58aa8fad90525d1b974a25b4f6943ebd7e Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Sun, 6 Sep 2026 23:25:01 -0400 Subject: [PATCH 07/16] Question post scrolls chat to your question and no long scrolls when someone else posts their question --- src/app/room/classChat/index.tsx | 21 +++++++++++++++++--- src/app/room/classChat/post/QuestionPost.tsx | 1 + 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/app/room/classChat/index.tsx b/src/app/room/classChat/index.tsx index fe2987c..5541c19 100644 --- a/src/app/room/classChat/index.tsx +++ b/src/app/room/classChat/index.tsx @@ -129,6 +129,8 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { const [isLoading, setIsLoading] = useState(true); const [questionError, setQuestionError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + /** Id of a question the viewer just asked, pending a scroll to its card. */ + const [scrollTargetId, setScrollTargetId] = useState(null); const bottomRef = useRef(null); // Separate history that keeps deleted messages (marked as [deleted]) for the @@ -234,6 +236,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { setQuestions((prev) => [...prev, newQuestion]); historyRef.current = [...historyRef.current, { ...newQuestion, replies: [] }]; + if (payload.isMine) setScrollTargetId(payload.id); }; const onQuestionUpdated = (payload: { id: string; upvoteCount: number }) => { @@ -451,10 +454,22 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { // when the questions state changes (which always follows a history update). }, [questions, chatHistoryRef]); - // Scroll to bottom whenever new questions arrive + // Land on the newest questions once the initial history has loaded useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [questions.length]); + if (isLoading) return; + bottomRef.current?.scrollIntoView(); + }, [isLoading]); + + // The list is sorted by resolved state then upvotes, so a question the viewer + // just asked can land anywhere — scroll to its card rather than to the bottom. + // Nothing to scroll to when the current filter or search excludes it. + useEffect(() => { + if (!scrollTargetId) return; + document + .getElementById(`question-${scrollTargetId}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + setScrollTargetId(null); + }, [scrollTargetId]); // ------------------------------------------------------------------------- // Action handlers diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index d4668e2..4fcb911 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -172,6 +172,7 @@ export default function QuestionPost({ return (
{/* Question body */} From e43159ed6c8d2a45484f87dfb3280abbf60afbaf Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Sun, 6 Sep 2026 23:33:02 -0400 Subject: [PATCH 08/16] Anon needs a click before being revealed by a prof --- src/app/room/classChat/post/CommentPost.tsx | 23 +++++++++-- src/app/room/classChat/post/PostUtils.tsx | 41 +++++++++++++++++--- src/app/room/classChat/post/QuestionPost.tsx | 9 ++++- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/app/room/classChat/post/CommentPost.tsx b/src/app/room/classChat/post/CommentPost.tsx index 4dcd875..30803b7 100644 --- a/src/app/room/classChat/post/CommentPost.tsx +++ b/src/app/room/classChat/post/CommentPost.tsx @@ -4,7 +4,13 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Comment } from "@/utils/types"; -import { renderAvatar, UpvoteButton, renderUsername } from "./PostUtils"; +import { + canRevealAuthor, + renderAvatar, + renderUsername, + RevealAuthorButton, + UpvoteButton, +} from "./PostUtils"; interface CommentPostProps { post: Comment; @@ -13,19 +19,24 @@ interface CommentPostProps { } export default function CommentPost({ post, onUpvote, onDelete }: CommentPostProps) { - const isInstructor = post.user?.role === "TA" || post.user?.role === "PROFESSOR"; const [confirmingDelete, setConfirmingDelete] = useState(false); + const [revealed, setRevealed] = useState(false); + + // While an anonymous author stays hidden the answer looks exactly as it does + // for students — placeholder avatar, no name, and no instructor tint. + const showAuthor = !post.isAnonymous || revealed; + const isInstructor = showAuthor && (post.user?.role === "TA" || post.user?.role === "PROFESSOR"); return (
- {renderAvatar(post)} + {renderAvatar(post, revealed)}
- {renderUsername(post.user, post.isAnonymous)} + {renderUsername(post.user, post.isAnonymous, revealed)} {post.timestamp}
@@ -49,6 +60,10 @@ export default function CommentPost({ post, onUpvote, onDelete }: CommentPostPro /> )} + {!confirmingDelete && canRevealAuthor(post) && ( + setRevealed((v) => !v)} /> + )} + {onDelete && (confirmingDelete ? (
diff --git a/src/app/room/classChat/post/PostUtils.tsx b/src/app/room/classChat/post/PostUtils.tsx index 16c5f77..bb7ef2a 100644 --- a/src/app/room/classChat/post/PostUtils.tsx +++ b/src/app/room/classChat/post/PostUtils.tsx @@ -3,11 +3,11 @@ import { useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { ArrowBigUp, GraduationCap } from "lucide-react"; +import { ArrowBigUp, Eye, EyeOff, GraduationCap } from "lucide-react"; import { Post, User, getInitials, isLikelyAvatarImageUrl } from "@/utils/types"; -export function renderAvatar(post: Post) { - if (post?.user) { +export function renderAvatar(post: Post, revealed?: boolean) { + if (post?.user && !(post.isAnonymous && !revealed)) { return ( {isLikelyAvatarImageUrl(post.user.pfp) && ( @@ -89,8 +89,10 @@ export function renderRoleIcon(user: User) { return null; } -export function renderUsername(user: User | null, isAnonymous?: boolean) { - if (!user) { +export function renderUsername(user: User | null, isAnonymous?: boolean, revealed?: boolean) { + // Anonymous posts read as "Anonymous" for everyone. TAs and professors are + // sent the author anyway, so revealing is a local toggle — see RevealAuthorButton. + if (!user || (isAnonymous && !revealed)) { return Anonymous; } return ( @@ -102,6 +104,35 @@ export function renderUsername(user: User | null, isAnonymous?: boolean) { ); } +/** + * True when an anonymous post arrived with its author attached — which only + * happens for TAs and professors, the roles allowed to unmask it. + */ +export function canRevealAuthor(post: Post): boolean { + return !!post.isAnonymous && !!post.user; +} + +interface RevealAuthorButtonProps { + revealed: boolean; + onToggle: () => void; +} + +export function RevealAuthorButton({ revealed, onToggle }: RevealAuthorButtonProps) { + const Icon = revealed ? EyeOff : Eye; + + return ( + + ); +} + 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 4fcb911..9cd1d84 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -13,7 +13,7 @@ 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"; // --------------------------------------------------------------------------- @@ -140,6 +140,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`. */ @@ -182,7 +183,7 @@ export default function QuestionPost({
{/* Left: username + time + toggle */}
- {renderUsername(post.user, post.isAnonymous)} + {renderUsername(post.user, post.isAnonymous, revealed)} {post.timestamp} {post.slidePageIndex != null && post.slideSetId && ( @@ -291,6 +292,10 @@ export default function QuestionPost({ )} + {canRevealAuthor(post) && ( + setRevealed((v) => !v)} /> + )} + {onDelete && ( )} - {error &&

{error}

} + {serverError &&

{serverError}

}
-
diff --git a/src/lib/answerValidation.ts b/src/lib/answerValidation.ts index fb61f2f..0aebc0d 100644 --- a/src/lib/answerValidation.ts +++ b/src/lib/answerValidation.ts @@ -1,13 +1,13 @@ import { prisma } from "@/lib/prisma"; import { checkRateLimit } from "@/lib/rateLimit"; import { answerRateLimit } from "@/lib/redisKeys"; +import { ANSWER_MAX_LENGTH, ANSWER_MIN_LENGTH } from "@/utils/contentLimits"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -export const ANSWER_MIN_LENGTH = 1; -export const ANSWER_MAX_LENGTH = 1000; +export { ANSWER_MIN_LENGTH, ANSWER_MAX_LENGTH }; export const RATE_LIMIT_COUNT = 15; export const RATE_LIMIT_WINDOW_SECONDS = 60; diff --git a/src/lib/questionValidation.ts b/src/lib/questionValidation.ts index 1eef58b..3db2fce 100644 --- a/src/lib/questionValidation.ts +++ b/src/lib/questionValidation.ts @@ -1,13 +1,13 @@ import { prisma } from "@/lib/prisma"; import { checkRateLimit } from "@/lib/rateLimit"; import { questionRateLimit, upvoteRateLimit, resolveRateLimit } from "@/lib/redisKeys"; +import { QUESTION_MAX_LENGTH, QUESTION_MIN_LENGTH } from "@/utils/contentLimits"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -export const QUESTION_MIN_LENGTH = 5; -export const QUESTION_MAX_LENGTH = 500; +export { QUESTION_MIN_LENGTH, QUESTION_MAX_LENGTH }; export const RATE_LIMIT_COUNT = 10; export const RATE_LIMIT_WINDOW_SECONDS = 60; diff --git a/src/utils/contentLimits.ts b/src/utils/contentLimits.ts new file mode 100644 index 0000000..1ae706f --- /dev/null +++ b/src/utils/contentLimits.ts @@ -0,0 +1,13 @@ +/** + * Length bounds for user-authored content. + * + * Kept free of server imports so the composer UI and the server validators can + * share one definition — the client disables its Post button on these bounds, + * and the server rejects anything that gets past it. + */ + +export const QUESTION_MIN_LENGTH = 5; +export const QUESTION_MAX_LENGTH = 500; + +export const ANSWER_MIN_LENGTH = 1; +export const ANSWER_MAX_LENGTH = 1000; From 9cdf9541718704c849a9f61501554aab30dfff7a Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Mon, 7 Sep 2026 08:46:57 -0400 Subject: [PATCH 12/16] Add toast for errors and ratelimiting --- FEATURES.md | 13 ++-- package.json | 1 + pnpm-lock.yaml | 70 ++++++------------- .../questions/[questionId]/answers/route.ts | 8 ++- .../sessions/[sessionId]/questions/route.ts | 13 +++- src/app/layout.tsx | 6 +- src/app/room/classChat/index.tsx | 23 +++++- src/components/RateLimitToast.tsx | 43 ++++++++++++ src/components/icons/mood-sad-dizzy.tsx | 29 ++++++++ src/components/ui/sonner.tsx | 32 +++++++++ src/lib/answerValidation.ts | 13 ++-- src/lib/questionValidation.ts | 36 +++++++--- src/lib/rateLimit.ts | 18 +++++ src/socket/handlers/answerHandlers.ts | 16 +++-- src/socket/handlers/questionHandlers.ts | 42 ++++++++--- src/socket/types.ts | 18 ++++- 16 files changed, 288 insertions(+), 93 deletions(-) create mode 100644 src/components/RateLimitToast.tsx create mode 100644 src/components/icons/mood-sad-dizzy.tsx create mode 100644 src/components/ui/sonner.tsx diff --git a/FEATURES.md b/FEATURES.md index 22a6e50..59676b6 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -185,15 +185,18 @@ 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 | +| 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). --- 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 8000acc..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 { @@ -152,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 1f94ae6..43125f4 100644 --- a/src/app/api/sessions/[sessionId]/questions/route.ts +++ b/src/app/api/sessions/[sessionId]/questions/route.ts @@ -10,6 +10,8 @@ import { import { validateQuestionContent, validateVisibility, + checkQuestionRateLimit, + questionRetryAfter, validateSessionForQuestions, validateQuestionSlideContext, } from "@/lib/questionValidation"; @@ -184,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) { @@ -225,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/index.tsx b/src/app/room/classChat/index.tsx index 74bcbb3..a0ae262 100644 --- a/src/app/room/classChat/index.tsx +++ b/src/app/room/classChat/index.tsx @@ -9,11 +9,18 @@ import ChatHeader from "./ChatHeader"; import ChatInput from "./ChatInput"; import FilterTabs from "./FilterTabs"; import type { Question, Comment, Role } from "@/utils/types"; +import { showRateLimitToast } from "@/components/RateLimitToast"; // --------------------------------------------------------------------------- // API response types (what the REST endpoints return) // --------------------------------------------------------------------------- +interface RateLimitAwareError { + message: string; + code?: string; + retryAfterSeconds?: number; +} + interface APIQuestion { id: string; content: string; @@ -381,10 +388,22 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { ); }; - const onQuestionError = (payload: { message: string }) => { + // Rate-limit refusals arrive already phrased for the user, and they are not + // about what is in the composer — they get a toast instead of inline text. + const onQuestionError = (payload: RateLimitAwareError) => { + if (payload.code === "RATE_LIMITED") { + showRateLimitToast(payload.message, payload.retryAfterSeconds); + return; + } setQuestionError(payload.message); }; + const onAnswerError = (payload: RateLimitAwareError) => { + if (payload.code === "RATE_LIMITED") { + showRateLimitToast(payload.message, payload.retryAfterSeconds); + } + }; + const onQuestionDeleted = (payload: { questionId: string }) => { setQuestions((prev) => prev.filter((q) => q.id !== payload.questionId)); // Keep in history but mark content as deleted @@ -426,6 +445,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { socket.on("answer:deleted", onAnswerDeleted); socket.on("answer-mode:changed", onAnswerModeChanged); socket.on("question:error", onQuestionError); + socket.on("answer:error", onAnswerError); socket.on("question:author:revealed", onQuestionAuthorRevealed); socket.on("answer:author:revealed", onAnswerAuthorRevealed); @@ -441,6 +461,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { socket.off("answer:deleted", onAnswerDeleted); socket.off("answer-mode:changed", onAnswerModeChanged); socket.off("question:error", onQuestionError); + socket.off("answer:error", onAnswerError); socket.off("question:author:revealed", onQuestionAuthorRevealed); socket.off("answer:author:revealed", onAnswerAuthorRevealed); }; diff --git a/src/components/RateLimitToast.tsx b/src/components/RateLimitToast.tsx new file mode 100644 index 0000000..92bbf99 --- /dev/null +++ b/src/components/RateLimitToast.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +interface CountdownProps { + message: string; + seconds: number; +} + +/** Ticks the remaining cooldown down beside the server's message. */ +function Countdown({ message, seconds }: CountdownProps) { + const [remaining, setRemaining] = useState(seconds); + + useEffect(() => { + const timer = setInterval(() => setRemaining((s) => Math.max(0, s - 1)), 1000); + return () => clearInterval(timer); + }, []); + + return ( + + {message} Wait {remaining}s + + ); +} + +/** + * Shows a rate-limit refusal, counting down to when the action can be retried. + * + * The toast lives exactly as long as the cooldown, so it clears itself the + * moment the user is allowed to try again. Without a usable retry window — + * Redis unreachable, say — it falls back to the bare message. + */ +export function showRateLimitToast(message: string, retryAfterSeconds?: number) { + if (!retryAfterSeconds || retryAfterSeconds <= 0) { + toast.error(message); + return; + } + + toast.error(, { + duration: retryAfterSeconds * 1000, + }); +} diff --git a/src/components/icons/mood-sad-dizzy.tsx b/src/components/icons/mood-sad-dizzy.tsx new file mode 100644 index 0000000..c56d0a8 --- /dev/null +++ b/src/components/icons/mood-sad-dizzy.tsx @@ -0,0 +1,29 @@ +import type { SVGProps } from "react"; + +/** + * Tabler's `mood-sad-dizzy`, inlined rather than depending on + * @tabler/icons-react for a single glyph. Tabler Icons are MIT licensed. + * https://tabler.io/icons/icon/mood-sad-dizzy + */ +export function MoodSadDizzy(props: SVGProps) { + return ( + + ); +} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..4b7bc6a --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Toaster as Sonner, ToasterProps } from "sonner"; + +import { MoodSadDizzy } from "@/components/icons/mood-sad-dizzy"; + +/** + * shadcn's sonner wrapper. The upstream version reads the active theme from + * next-themes; this app has no theme provider and renders light only, so the + * hook is left out rather than pulling in a dependency for a constant. + */ +const Toaster = ({ ...props }: ToasterProps) => { + return ( + }} + className="toaster group" + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + } as React.CSSProperties + } + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/src/lib/answerValidation.ts b/src/lib/answerValidation.ts index 0aebc0d..bba29a3 100644 --- a/src/lib/answerValidation.ts +++ b/src/lib/answerValidation.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/prisma"; -import { checkRateLimit } from "@/lib/rateLimit"; +import { checkRateLimit, rateLimitRetryAfter } from "@/lib/rateLimit"; import { answerRateLimit } from "@/lib/redisKeys"; import { ANSWER_MAX_LENGTH, ANSWER_MIN_LENGTH } from "@/utils/contentLimits"; @@ -8,8 +8,8 @@ import { ANSWER_MAX_LENGTH, ANSWER_MIN_LENGTH } from "@/utils/contentLimits"; // --------------------------------------------------------------------------- export { ANSWER_MIN_LENGTH, ANSWER_MAX_LENGTH }; -export const RATE_LIMIT_COUNT = 15; -export const RATE_LIMIT_WINDOW_SECONDS = 60; +export const RATE_LIMIT_COUNT = 5; +export const RATE_LIMIT_WINDOW_SECONDS = 10; // --------------------------------------------------------------------------- // Types @@ -65,13 +65,18 @@ export function validateAnswerContent(content: unknown): ValidationResult { /** * Checks whether the given user has exceeded the answer rate limit - * (15 answers per 60-second window). + * (5 answers per 10-second window). * Returns true if the limit has been exceeded. */ export async function checkAnswerRateLimit(userId: string): Promise { return checkRateLimit(answerRateLimit(userId), RATE_LIMIT_COUNT, RATE_LIMIT_WINDOW_SECONDS); } +/** Seconds until a refused answer may be retried. */ +export async function answerRetryAfter(userId: string): Promise { + return rateLimitRetryAfter(answerRateLimit(userId)); +} + /** * Validates that a question exists and belongs to an active session. * Returns the question with sessionId if valid, or an error message if not. diff --git a/src/lib/questionValidation.ts b/src/lib/questionValidation.ts index 3db2fce..3ac9c90 100644 --- a/src/lib/questionValidation.ts +++ b/src/lib/questionValidation.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/prisma"; -import { checkRateLimit } from "@/lib/rateLimit"; +import { checkRateLimit, rateLimitRetryAfter } from "@/lib/rateLimit"; import { questionRateLimit, upvoteRateLimit, resolveRateLimit } from "@/lib/redisKeys"; import { QUESTION_MAX_LENGTH, QUESTION_MIN_LENGTH } from "@/utils/contentLimits"; @@ -8,13 +8,13 @@ import { QUESTION_MAX_LENGTH, QUESTION_MIN_LENGTH } from "@/utils/contentLimits" // --------------------------------------------------------------------------- export { QUESTION_MIN_LENGTH, QUESTION_MAX_LENGTH }; -export const RATE_LIMIT_COUNT = 10; -export const RATE_LIMIT_WINDOW_SECONDS = 60; +export const RATE_LIMIT_COUNT = 2; +export const RATE_LIMIT_WINDOW_SECONDS = 10; -export const UPVOTE_RATE_LIMIT_COUNT = 30; -export const UPVOTE_RATE_LIMIT_WINDOW_SECONDS = 60; -export const RESOLVE_RATE_LIMIT_COUNT = 20; -export const RESOLVE_RATE_LIMIT_WINDOW_SECONDS = 60; +export const UPVOTE_RATE_LIMIT_COUNT = 10; +export const UPVOTE_RATE_LIMIT_WINDOW_SECONDS = 10; +export const RESOLVE_RATE_LIMIT_COUNT = 10; +export const RESOLVE_RATE_LIMIT_WINDOW_SECONDS = 10; export const VALID_VISIBILITIES = new Set(["PUBLIC", "INSTRUCTOR_ONLY"]); @@ -96,7 +96,7 @@ export function validateVisibility(visibility: unknown): ValidationResult { /** * Checks whether the given user has exceeded the question rate limit - * (10 questions per 60-second window). + * (2 questions per 10-second window, per session). * The counter is scoped to the session so a new session always starts fresh. * Returns true if the limit has been exceeded. */ @@ -110,7 +110,7 @@ export async function checkQuestionRateLimit(userId: string, sessionId: string): /** * Checks whether the given user has exceeded the upvote rate limit - * (30 upvotes per 60-second window). + * (10 upvotes per 10-second window). * Returns true if the limit has been exceeded. */ export async function checkUpvoteRateLimit(userId: string): Promise { @@ -123,7 +123,7 @@ export async function checkUpvoteRateLimit(userId: string): Promise { /** * Checks whether the given user has exceeded the resolve rate limit - * (20 resolves per 60-second window). + * (10 resolves per 10-second window). * Returns true if the limit has been exceeded. */ export async function checkResolveRateLimit(userId: string): Promise { @@ -238,3 +238,19 @@ export async function validateQuestionSlideContext( slideSetId, }; } + +// --------------------------------------------------------------------------- +// Retry windows — seconds until a refused action may be retried +// --------------------------------------------------------------------------- + +export async function questionRetryAfter(userId: string, sessionId: string): Promise { + return rateLimitRetryAfter(questionRateLimit(userId, sessionId)); +} + +export async function upvoteRetryAfter(userId: string): Promise { + return rateLimitRetryAfter(upvoteRateLimit(userId)); +} + +export async function resolveRetryAfter(userId: string): Promise { + return rateLimitRetryAfter(resolveRateLimit(userId)); +} diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts index b06e4f2..6803fb6 100644 --- a/src/lib/rateLimit.ts +++ b/src/lib/rateLimit.ts @@ -29,6 +29,24 @@ export async function incrementRateLimit(key: string, windowSeconds: number): Pr } } +/** + * Seconds left in the current window for a rate-limit key. + * + * Only meaningful right after `checkRateLimit` has refused an action — it reads + * the TTL Redis set on the counter's first increment, which is when the caller + * may try again. Returns 0 when the key has no TTL or Redis is unreachable, so + * callers fall back to a message without a countdown rather than a wrong one. + */ +export async function rateLimitRetryAfter(key: string): Promise { + try { + const ttl = await redisRateLimit.ttl(rateLimit(key)); + return ttl > 0 ? ttl : 0; + } catch (error) { + console.error("[RateLimit] Redis error reading retry window:", error); + return 0; + } +} + /** * Check if a request should be rate limited * @param key - The identifier for rate limiting (e.g., IP address, user ID) diff --git a/src/socket/handlers/answerHandlers.ts b/src/socket/handlers/answerHandlers.ts index b112a83..de15619 100644 --- a/src/socket/handlers/answerHandlers.ts +++ b/src/socket/handlers/answerHandlers.ts @@ -6,9 +6,10 @@ import { answerMode as answerModeKey } from "@/lib/redisKeys"; import { validateAnswerContent, checkAnswerRateLimit, + answerRetryAfter, validateQuestionForAnswers, } from "@/lib/answerValidation"; -import { checkUpvoteRateLimit } from "@/lib/questionValidation"; +import { checkUpvoteRateLimit, upvoteRetryAfter } from "@/lib/questionValidation"; import type { AnswerCreatedPayload } from "@/socket/types"; // --------------------------------------------------------------------------- @@ -68,7 +69,7 @@ export function broadcastAnswer( * 2. Payload shape — must be a non-null object * 3. Question — must exist in the DB and belong to an active session * 4. Content — length bounds via validateAnswerContent - * 5. Rate limit — 15 answers / 60 s per user, enforced in Redis + * 5. Rate limit — 5 answers / 10 s per user, enforced in Redis * 6. Persist — answer written to the database * 7. Broadcast — emitted to the session room * @@ -148,8 +149,9 @@ export function handleAnswerCreate(socket: Socket, io: Server): void { const isRateLimited = await checkAnswerRateLimit(userId); if (isRateLimited) { socket.emit("answer:error", { - message: - "You have reached the answer limit. Please wait before submitting another answer.", + message: "Too many answers.", + code: "RATE_LIMITED", + retryAfterSeconds: await answerRetryAfter(userId), }); return; } @@ -226,7 +228,7 @@ export function handleAnswerCreate(socket: Socket, io: Server): void { * Guard order: * 1. Auth — socket.data.userId must exist * 2. Payload shape — must be a non-null object with answerId - * 3. Rate limit — shared upvote rate limit (30 / 60 s per user) + * 3. Rate limit — shared upvote rate limit (10 / 10 s per user) * 4. Toggle — create or delete AnswerUpvote, adjust upvoteCount * 5. Broadcast — emitted to the session room as answer:updated */ @@ -256,7 +258,9 @@ export function handleAnswerUpvote(socket: Socket, io: Server): void { const isRateLimited = await checkUpvoteRateLimit(userId); if (isRateLimited) { socket.emit("answer:error", { - message: "You are upvoting too quickly. Please wait before trying again.", + message: "Too many upvotes.", + code: "RATE_LIMITED", + retryAfterSeconds: await upvoteRetryAfter(userId), }); return; } diff --git a/src/socket/handlers/questionHandlers.ts b/src/socket/handlers/questionHandlers.ts index e488aa9..264f6f2 100644 --- a/src/socket/handlers/questionHandlers.ts +++ b/src/socket/handlers/questionHandlers.ts @@ -4,8 +4,12 @@ import { prisma } from "@/lib/prisma"; import { validateQuestionContent, validateVisibility, + checkQuestionRateLimit, checkUpvoteRateLimit, checkResolveRateLimit, + questionRetryAfter, + upvoteRetryAfter, + resolveRetryAfter, validateSessionForQuestions, validateQuestionSlideContext, } from "@/lib/questionValidation"; @@ -98,7 +102,7 @@ export function broadcastQuestion( * 2. Payload shape — must be a non-null object * 3. Content — length bounds via validateQuestionContent * 4. Visibility — must be a recognised Visibility value if provided - * 5. Rate limit — 10 questions / 60 s per user, enforced in Redis + * 5. Rate limit — 2 questions / 10 s per user per session, enforced in Redis * 6. Session — must exist in the DB and have isSubmissionsEnabled === true * 7. Persist — question written to the database (authorId always stored) * 8. Broadcast — emitted to the correct room; authorId stripped when anonymous @@ -141,7 +145,17 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { return; } - // 5. Session validation + // 5. Rate limit — counter is per user per session + if (await checkQuestionRateLimit(userId, payload.sessionId)) { + socket.emit("question:error", { + message: "Too many questions.", + code: "RATE_LIMITED", + retryAfterSeconds: await questionRetryAfter(userId, payload.sessionId), + }); + return; + } + + // 6. Session validation const sessionValidation = await validateSessionForQuestions(payload.sessionId); if (!sessionValidation.valid) { console.log("[QuestionHandler] Rejected: session validation -", sessionValidation.error); @@ -149,7 +163,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { return; } - // 5b. Slide context validation — invalid context is dropped; question still saves + // 6a. Slide context validation — invalid context is dropped; question still saves const slideValidation = await validateQuestionSlideContext( payload.sessionId, payload.slidePageIndex, @@ -164,7 +178,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { const slidePageIndex = slideValidation.valid ? slideValidation.slidePageIndex : null; const slideSetId = slideValidation.valid ? slideValidation.slideSetId : null; - // 5a. Enrollment check — any non-PROFESSOR must be enrolled in the session's course + // 6b. Enrollment check — any non-PROFESSOR must be enrolled in the session's course const sessionForEnrollment = await prisma.session.findUnique({ where: { id: payload.sessionId }, select: { courseId: true }, @@ -182,7 +196,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { authorEnrollmentRole = enrollment.role; } - // 6. Persist to database (include author for display name in broadcast) + // 7. Persist to database (include author for display name in broadcast) // authorId is always stored for audit purposes, but stripped // from the broadcast payload in step 8 when isAnonymous is true. const question = await prisma.question.create({ @@ -251,7 +265,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { * Guard order (cheap-before-expensive): * 1. Auth — socket.data.userId must exist * 2. Payload shape — must be a non-null object with a string questionId - * 3. Rate limit — 30 upvotes / 60 s per user, enforced in Redis + * 3. Rate limit — 10 upvotes / 10 s per user, enforced in Redis * 4. Toggle — create or delete upvote + update count in a transaction * 5. Broadcast — emit updated count to the session room */ @@ -281,7 +295,9 @@ export function handleQuestionUpvote(socket: Socket, io: Server): void { const isRateLimited = await checkUpvoteRateLimit(userId); if (isRateLimited) { socket.emit("question:error", { - message: "You are upvoting too quickly. Please wait before trying again.", + message: "Too many upvotes.", + code: "RATE_LIMITED", + retryAfterSeconds: await upvoteRetryAfter(userId), }); return; } @@ -377,7 +393,7 @@ function checkResolvePermission( * Guard order (cheap-before-expensive): * 1. Auth — socket.data.userId must exist * 2. Payload shape — must be a non-null object with a string questionId - * 3. Rate limit — 20 resolves / 60 s per user, enforced in Redis + * 3. Rate limit — 10 resolves / 10 s per user, enforced in Redis * 4. Question — must exist in the DB and not already be resolved * 5. Permission — TA/PROFESSOR can resolve any; STUDENT only their own * 6. Persist — question status updated to RESOLVED @@ -409,7 +425,9 @@ export function handleQuestionResolve(socket: Socket, io: Server): void { const isRateLimited = await checkResolveRateLimit(userId); if (isRateLimited) { socket.emit("question:error", { - message: "You are resolving too quickly. Please wait before trying again.", + message: "Too many updates.", + code: "RATE_LIMITED", + retryAfterSeconds: await resolveRetryAfter(userId), }); return; } @@ -497,7 +515,7 @@ interface QuestionUnresolvePayload { * Guard order (cheap-before-expensive): * 1. Auth — socket.data.userId must exist * 2. Payload shape — must be a non-null object with a string questionId - * 3. Rate limit — shared with resolve: 20 / 60 s per user + * 3. Rate limit — shared with resolve: 10 / 10 s per user * 4. Question — must exist and currently be RESOLVED * 5. Permission — TA/PROFESSOR only * 6. Persist — question status updated to OPEN @@ -529,7 +547,9 @@ export function handleQuestionUnresolve(socket: Socket, io: Server): void { const isRateLimited = await checkResolveRateLimit(userId); if (isRateLimited) { socket.emit("question:error", { - message: "You are resolving too quickly. Please wait before trying again.", + message: "Too many updates.", + code: "RATE_LIMITED", + retryAfterSeconds: await resolveRetryAfter(userId), }); return; } diff --git a/src/socket/types.ts b/src/socket/types.ts index d4cf8cf..f0e5c6f 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -5,6 +5,20 @@ export interface SocketData { currentSessionId?: string; } +export interface SocketErrorPayload { + message: string; + /** + * Set only on rate-limit refusals. The message is already short and phrased + * for the user, so clients can show it as a toast without rewriting it. + */ + code?: "RATE_LIMITED"; + /** + * Seconds until the refused action may be retried. Clients count this down + * beside the message; 0 or absent means the window could not be read. + */ + retryAfterSeconds?: number; +} + export interface QuestionCreatePayload { content: string; sessionId: string; @@ -207,9 +221,9 @@ export interface ViewerCountPayload { /** Events the **server** can send to the **client**. */ export interface ServerToClientEvents { "question:created": (payload: QuestionCreatedPayload) => void; - "question:error": (payload: { message: string }) => void; + "question:error": (payload: SocketErrorPayload) => void; "answer:created": (payload: AnswerCreatedPayload) => void; - "answer:error": (payload: { message: string }) => void; + "answer:error": (payload: SocketErrorPayload) => void; "question:updated": (payload: QuestionUpdatedPayload) => void; "answer:updated": (payload: AnswerUpdatedPayload) => void; "question:resolved": (payload: QuestionResolvedPayload) => void; From 942bb54176021336604c56b68ac6e1a5af9e0518 Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Mon, 7 Sep 2026 09:04:37 -0400 Subject: [PATCH 13/16] New error icon and resolved handle on server --- src/app/room/classChat/index.tsx | 6 ----- src/components/icons/mood-sad-dizzy.tsx | 29 ------------------------- src/components/ui/sonner.tsx | 5 ++--- src/socket/handlers/questionHandlers.ts | 22 +++++++++++-------- src/socket/types.ts | 7 ++++++ 5 files changed, 22 insertions(+), 47 deletions(-) delete mode 100644 src/components/icons/mood-sad-dizzy.tsx diff --git a/src/app/room/classChat/index.tsx b/src/app/room/classChat/index.tsx index a0ae262..befb391 100644 --- a/src/app/room/classChat/index.tsx +++ b/src/app/room/classChat/index.tsx @@ -537,17 +537,11 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { const handleResolve = (questionId: string) => { if (!socket) return; socket.emit("question:resolve", { questionId }); - // Optimistic update - setQuestions((prev) => prev.map((q) => (q.id === questionId ? { ...q, isResolved: true } : q))); }; const handleUnresolve = (questionId: string) => { if (!socket) return; socket.emit("question:unresolve", { questionId }); - // Optimistic update - setQuestions((prev) => - prev.map((q) => (q.id === questionId ? { ...q, isResolved: false } : q)) - ); }; const handleSubmitAnswer = (questionId: string, content: string) => { diff --git a/src/components/icons/mood-sad-dizzy.tsx b/src/components/icons/mood-sad-dizzy.tsx deleted file mode 100644 index c56d0a8..0000000 --- a/src/components/icons/mood-sad-dizzy.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { SVGProps } from "react"; - -/** - * Tabler's `mood-sad-dizzy`, inlined rather than depending on - * @tabler/icons-react for a single glyph. Tabler Icons are MIT licensed. - * https://tabler.io/icons/icon/mood-sad-dizzy - */ -export function MoodSadDizzy(props: SVGProps) { - return ( - - ); -} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 4b7bc6a..1eb460c 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,9 +1,8 @@ "use client"; +import { Gauge } from "lucide-react"; import { Toaster as Sonner, ToasterProps } from "sonner"; -import { MoodSadDizzy } from "@/components/icons/mood-sad-dizzy"; - /** * shadcn's sonner wrapper. The upstream version reads the active theme from * next-themes; this app has no theme provider and renders light only, so the @@ -15,7 +14,7 @@ const Toaster = ({ ...props }: ToasterProps) => { // richColors gives toast.error its own red palette instead of the // neutral popover surface used by plain toasts. richColors - icons={{ error: }} + icons={{ error: }} className="toaster group" style={ { diff --git a/src/socket/handlers/questionHandlers.ts b/src/socket/handlers/questionHandlers.ts index 264f6f2..a8b14c3 100644 --- a/src/socket/handlers/questionHandlers.ts +++ b/src/socket/handlers/questionHandlers.ts @@ -108,6 +108,12 @@ export function broadcastQuestion( * 8. Broadcast — emitted to the correct room; authorId stripped when anonymous */ export function handleQuestionCreate(socket: Socket, io: Server): void { + // Everything this handler refuses is about the content being submitted, so it + // is scoped to the composer. The rate-limit refusal below opts out: it is a + // cooldown rather than a problem with the draft, and belongs in a toast. + const emitCreateError = (message: string | undefined) => + socket.emit("question:error", { message: message ?? "Invalid request.", source: "create" }); + socket.on("question:create", async (payload: QuestionCreatePayload) => { console.log("[QuestionHandler] question:create received", JSON.stringify(payload)); try { @@ -115,14 +121,14 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { const userId: string | undefined = socket.data?.userId; if (!userId) { console.log("[QuestionHandler] Rejected: no userId"); - socket.emit("question:error", { message: "Authentication required." }); + emitCreateError("Authentication required."); return; } // 2. Payload shape guard — socket events can arrive with any shape if (!payload || typeof payload !== "object") { console.log("[QuestionHandler] Rejected: invalid payload shape"); - socket.emit("question:error", { message: "Invalid request." }); + emitCreateError("Invalid request."); return; } @@ -130,7 +136,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { const contentValidation = validateQuestionContent(payload.content); if (!contentValidation.valid) { console.log("[QuestionHandler] Rejected: content validation -", contentValidation.error); - socket.emit("question:error", { message: contentValidation.error }); + emitCreateError(contentValidation.error); return; } @@ -141,7 +147,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { "[QuestionHandler] Rejected: visibility validation -", visibilityValidation.error ); - socket.emit("question:error", { message: visibilityValidation.error }); + emitCreateError(visibilityValidation.error); return; } @@ -159,7 +165,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { const sessionValidation = await validateSessionForQuestions(payload.sessionId); if (!sessionValidation.valid) { console.log("[QuestionHandler] Rejected: session validation -", sessionValidation.error); - socket.emit("question:error", { message: sessionValidation.error }); + emitCreateError(sessionValidation.error); return; } @@ -190,7 +196,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { select: { role: true }, }); if (!enrollment) { - socket.emit("question:error", { message: "You are not enrolled in this session." }); + emitCreateError("You are not enrolled in this session."); return; } authorEnrollmentRole = enrollment.role; @@ -248,9 +254,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { } } catch (error) { console.error("[QuestionHandler] Failed to create question:", error); - socket.emit("question:error", { - message: "An error occurred while creating your question.", - }); + emitCreateError("An error occurred while creating your question."); } }); } diff --git a/src/socket/types.ts b/src/socket/types.ts index f0e5c6f..3a8d1fc 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -17,6 +17,13 @@ export interface SocketErrorPayload { * beside the message; 0 or absent means the window could not be read. */ retryAfterSeconds?: number; + /** + * "create" marks an error about the content being submitted, which belongs + * inline under the composer. Everything else refers to an action taken on an + * existing post and is surfaced as a toast instead. `code` wins over this: + * a rate-limit refusal always toasts, whatever it was refusing. + */ + source?: "create"; } export interface QuestionCreatePayload { From bed9be41ff3eac7d6b3d799ef9b268da622be238 Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Mon, 7 Sep 2026 09:13:35 -0400 Subject: [PATCH 14/16] Fix Question not resolved text error on resolve too fast --- src/socket/handlers/questionHandlers.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/socket/handlers/questionHandlers.ts b/src/socket/handlers/questionHandlers.ts index a8b14c3..8e8d03b 100644 --- a/src/socket/handlers/questionHandlers.ts +++ b/src/socket/handlers/questionHandlers.ts @@ -398,7 +398,7 @@ function checkResolvePermission( * 1. Auth — socket.data.userId must exist * 2. Payload shape — must be a non-null object with a string questionId * 3. Rate limit — 10 resolves / 10 s per user, enforced in Redis - * 4. Question — must exist in the DB and not already be resolved + * 4. Question — must exist in the DB; no-op when already resolved * 5. Permission — TA/PROFESSOR can resolve any; STUDENT only their own * 6. Persist — question status updated to RESOLVED * 7. Broadcast — emit resolved status to the session room @@ -454,8 +454,9 @@ export function handleQuestionResolve(socket: Socket, io: Server): void { return; } + // Already in the requested state — a duplicate click, not something the + // user can act on. No-op rather than reporting an error. if (question.status === "RESOLVED") { - socket.emit("question:error", { message: "Question is already resolved." }); return; } @@ -520,7 +521,7 @@ interface QuestionUnresolvePayload { * 1. Auth — socket.data.userId must exist * 2. Payload shape — must be a non-null object with a string questionId * 3. Rate limit — shared with resolve: 10 / 10 s per user - * 4. Question — must exist and currently be RESOLVED + * 4. Question — must exist; no-op when it is not RESOLVED * 5. Permission — TA/PROFESSOR only * 6. Persist — question status updated to OPEN * 7. Broadcast — emit unresolved status to the session room @@ -575,8 +576,8 @@ export function handleQuestionUnresolve(socket: Socket, io: Server): void { return; } + // Already in the requested state — see the matching guard in resolve. if (question.status !== "RESOLVED") { - socket.emit("question:error", { message: "Question is not resolved." }); return; } From 66a5c1430fb3d432a467792a5920a4279f5479e8 Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Mon, 7 Sep 2026 09:22:41 -0400 Subject: [PATCH 15/16] Answer vertical lines now the same color and thickness --- src/app/room/classChat/post/CommentPost.tsx | 2 +- src/app/room/classChat/post/QuestionPost.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/room/classChat/post/CommentPost.tsx b/src/app/room/classChat/post/CommentPost.tsx index 30803b7..d39d379 100644 --- a/src/app/room/classChat/post/CommentPost.tsx +++ b/src/app/room/classChat/post/CommentPost.tsx @@ -31,7 +31,7 @@ export default function CommentPost({ post, onUpvote, onDelete }: CommentPostPro
{renderAvatar(post, revealed)} -
+
diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index 037785c..25168c1 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -322,7 +322,7 @@ export default function QuestionPost({ {/* Thread */} {showThread && ( -
+
{isReplying && ( Date: Mon, 7 Sep 2026 09:25:05 -0400 Subject: [PATCH 16/16] New icon for waiting on slides upload --- src/app/room/slideViewer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/room/slideViewer.tsx b/src/app/room/slideViewer.tsx index e11f407..39f952e 100644 --- a/src/app/room/slideViewer.tsx +++ b/src/app/room/slideViewer.tsx @@ -7,6 +7,7 @@ import { Navigation, Users, Square, + SquareMousePointer, Upload, LogOut, Unlink, @@ -720,7 +721,7 @@ export default function SlideViewer({ return (
- +

Waiting for professor to upload slides…