diff --git a/src/app/api/courses/[courseId]/students/route.ts b/src/app/api/courses/[courseId]/students/route.ts
index c18c9cb..b1932e8 100644
--- a/src/app/api/courses/[courseId]/students/route.ts
+++ b/src/app/api/courses/[courseId]/students/route.ts
@@ -366,8 +366,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: "Cannot remove the course professor." }, { status: 403 });
}
- await prisma.courseEnrollment.delete({
+ // Demote TA back to STUDENT rather than removing them from the course entirely.
+ // This preserves their enrollment so they stay in any active session.
+ await prisma.courseEnrollment.update({
where: { userId_courseId: { userId: target.id, courseId } },
+ data: { role: "STUDENT" },
});
return NextResponse.json({ removed: utorid.trim().toLowerCase() });
diff --git a/src/app/api/sessions/[sessionId]/questions/route.ts b/src/app/api/sessions/[sessionId]/questions/route.ts
index b46e398..56277a3 100644
--- a/src/app/api/sessions/[sessionId]/questions/route.ts
+++ b/src/app/api/sessions/[sessionId]/questions/route.ts
@@ -10,7 +10,6 @@ import {
import {
validateQuestionContent,
validateVisibility,
- checkQuestionRateLimit,
validateSessionForQuestions,
} from "@/lib/questionValidation";
import { getSessionMembership } from "@/lib/sessionService";
@@ -210,16 +209,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: sessionValidation.error }, { status: statusCode });
}
- // 7. Check rate limit using shared validation
- const isRateLimited = await checkQuestionRateLimit(authorId);
- if (isRateLimited) {
- return NextResponse.json(
- { error: "Rate limit exceeded. Please wait before asking another question." },
- { status: 429 }
- );
- }
-
- // 8. Create the question and record activity on the session atomically
+ // 7. Create the question and record activity on the session atomically
const [question] = await prisma.$transaction([
prisma.question.create({
data: {
diff --git a/src/app/api/sessions/[sessionId]/route.ts b/src/app/api/sessions/[sessionId]/route.ts
index 6fc1831..a828def 100644
--- a/src/app/api/sessions/[sessionId]/route.ts
+++ b/src/app/api/sessions/[sessionId]/route.ts
@@ -52,7 +52,7 @@ export async function GET(_req: Request, { params }: RouteParams) {
}
}
- return NextResponse.json({ status: session.status });
+ return NextResponse.json({ status: session.status, courseId: session.courseId });
} catch (error) {
console.error("[Sessions API] Failed to fetch session status:", error);
return NextResponse.json({ error: "An error occurred." }, { status: 500 });
diff --git a/src/app/room/classChat/ChatHeader.tsx b/src/app/room/classChat/ChatHeader.tsx
index 07ec92c..6513aa2 100644
--- a/src/app/room/classChat/ChatHeader.tsx
+++ b/src/app/room/classChat/ChatHeader.tsx
@@ -3,14 +3,15 @@
import { Input } from "@/components/ui/input";
import { useContext, useState } from "react";
import {
- ArrowLeft,
PanelRightClose,
Users,
GraduationCap,
Search,
X,
ArrowBigRight,
+ UserPlus,
} from "lucide-react";
+import ManageTAsModal from "./ManageTAsModal";
import { useMediaQuery } from "@/hooks/use-media-query";
import { SlideUpdateContext } from "../SlideUpdateContext";
import type { Role } from "@/utils/types";
@@ -67,114 +68,129 @@ export default function ChatHeader({
}: ChatHeaderProps) {
const { sessionTitle } = useRoom();
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
+ const [showTAModal, setShowTAModal] = useState(false);
return (
-
+
+ {showTAModal && setShowTAModal(false)} />}
+ >
);
}
diff --git a/src/app/room/classChat/ChatInput.tsx b/src/app/room/classChat/ChatInput.tsx
index b7088a7..68eb018 100644
--- a/src/app/room/classChat/ChatInput.tsx
+++ b/src/app/room/classChat/ChatInput.tsx
@@ -59,51 +59,53 @@ export default function ChatInput({
-
-
-
+ <>
+
+
+
+
+ {error &&
{error}
}
+
- {error &&
{error}
}
-
-
+ >
diff --git a/src/app/room/classChat/ManageTAsModal.tsx b/src/app/room/classChat/ManageTAsModal.tsx
new file mode 100644
index 0000000..47dc636
--- /dev/null
+++ b/src/app/room/classChat/ManageTAsModal.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { X, UserMinus, UserPlus, GraduationCap, Loader2 } from "lucide-react";
+import { useRoom } from "../RoomContext";
+
+interface TAEntry {
+ name: string;
+ utorid: string;
+}
+
+interface ManageTAsModalProps {
+ onClose: () => void;
+}
+
+export default function ManageTAsModal({ onClose }: ManageTAsModalProps) {
+ const { sessionId } = useRoom();
+
+ const [courseId, setCourseId] = useState(null);
+ const [tas, setTas] = useState([]);
+ const [loadingRoster, setLoadingRoster] = useState(true);
+ const [rosterError, setRosterError] = useState(null);
+
+ const [utoridInput, setUtoridInput] = useState("");
+ const [adding, setAdding] = useState(false);
+ const [addError, setAddError] = useState(null);
+ const [addSuccess, setAddSuccess] = useState(null);
+
+ const [removingUtorid, setRemovingUtorid] = useState(null);
+ const [removeError, setRemoveError] = useState(null);
+
+ const inputRef = useRef(null);
+
+ // Resolve courseId from the session, then load TAs
+ useEffect(() => {
+ if (!sessionId) return;
+
+ async function load() {
+ setLoadingRoster(true);
+ setRosterError(null);
+ try {
+ const sessionRes = await fetch(`/api/sessions/${sessionId}`);
+ if (!sessionRes.ok) throw new Error("Could not load session.");
+ const sessionData = await sessionRes.json();
+ const cId: string = sessionData.courseId;
+ setCourseId(cId);
+
+ const rosterRes = await fetch(`/api/courses/${cId}/students`);
+ if (!rosterRes.ok) throw new Error("Could not load TA roster.");
+ const rosterData = await rosterRes.json();
+ setTas(rosterData.tas ?? []);
+ } catch (err) {
+ setRosterError(err instanceof Error ? err.message : "An error occurred.");
+ } finally {
+ setLoadingRoster(false);
+ }
+ }
+
+ load();
+ }, [sessionId]);
+
+ async function handleAdd() {
+ const utorid = utoridInput.trim().toLowerCase();
+ if (!utorid || !courseId) return;
+ setAdding(true);
+ setAddError(null);
+ setAddSuccess(null);
+ try {
+ const res = await fetch(`/api/courses/${courseId}/students`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ utorids: [utorid], role: "TA" }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ setAddError(data.error ?? "Failed to add TA.");
+ return;
+ }
+ if (data.alreadyEnrolled?.includes(utorid)) {
+ setAddError(`${utorid} is already a TA or enrolled in this course.`);
+ return;
+ }
+ setUtoridInput("");
+ setAddSuccess(`${utorid} added as TA.`);
+ // Refresh TA list
+ const rosterRes = await fetch(`/api/courses/${courseId}/students`);
+ if (rosterRes.ok) {
+ const rosterData = await rosterRes.json();
+ setTas(rosterData.tas ?? []);
+ }
+ inputRef.current?.focus();
+ } catch {
+ setAddError("An error occurred. Please try again.");
+ } finally {
+ setAdding(false);
+ }
+ }
+
+ async function handleRemove(utorid: string) {
+ if (!courseId) return;
+ setRemovingUtorid(utorid);
+ setRemoveError(null);
+ setAddSuccess(null);
+ try {
+ const res = await fetch(`/api/courses/${courseId}/students`, {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ utorid }),
+ });
+ if (!res.ok) {
+ const data = await res.json();
+ setRemoveError(data.error ?? "Failed to remove TA.");
+ return;
+ }
+ setTas((prev) => prev.filter((ta) => ta.utorid !== utorid));
+ } catch {
+ setRemoveError("An error occurred. Please try again.");
+ } finally {
+ setRemovingUtorid(null);
+ }
+ }
+
+ return (
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ >
+
+ {/* Header */}
+
+
+
+
Manage TAs
+
+
+
+
+ {/* Add TA */}
+
+
Add by UTORid
+
+ {
+ setUtoridInput(e.target.value);
+ setAddError(null);
+ setAddSuccess(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleAdd();
+ }}
+ placeholder="e.g. smithj12"
+ className="flex-1 h-9 px-3 text-sm border border-stone-200 rounded-md bg-stone-50 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors placeholder:text-stone-400"
+ disabled={adding}
+ />
+
+
+ {addError &&
{addError}
}
+ {addSuccess &&
{addSuccess}
}
+
+
+ {/* TA list */}
+
+
+ Current TAs{!loadingRoster && ` (${tas.length})`}
+
+
+ {loadingRoster ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : rosterError ? (
+
{rosterError}
+ ) : tas.length === 0 ? (
+
No TAs assigned yet.
+ ) : (
+
+ )}
+
+ {removeError &&
{removeError}
}
+
+
+
+ );
+}
diff --git a/src/app/room/classChat/index.tsx b/src/app/room/classChat/index.tsx
index d74c000..1552f93 100644
--- a/src/app/room/classChat/index.tsx
+++ b/src/app/room/classChat/index.tsx
@@ -114,6 +114,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
const [isLoading, setIsLoading] = useState(true);
const [questionError, setQuestionError] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
+
const bottomRef = useRef(null);
// Separate history that keeps deleted messages (marked as [deleted]) for the
// session export. Never removes items — deletions are marked in-place.
@@ -354,7 +355,6 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
};
const onQuestionError = (payload: { message: string }) => {
- console.error("[ClassChat] question:error", payload.message);
setQuestionError(payload.message);
};
@@ -417,7 +417,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
socket.off("question:author:revealed", onQuestionAuthorRevealed);
socket.off("answer:author:revealed", onAnswerAuthorRevealed);
};
- }, [socket, sessionId, chatHistoryRef]);
+ }, [socket, sessionId, chatHistoryRef, role, userId]);
// Keep chatHistoryRef in sync whenever historyRef is updated via data load
// or new questions/answers arriving (deletions update it inline above).
diff --git a/src/lib/questionValidation.ts b/src/lib/questionValidation.ts
index d09de4c..46367d1 100644
--- a/src/lib/questionValidation.ts
+++ b/src/lib/questionValidation.ts
@@ -90,10 +90,15 @@ export function validateVisibility(visibility: unknown): ValidationResult {
/**
* Checks whether the given user has exceeded the question rate limit
* (10 questions per 60-second window).
+ * The counter is scoped to the session so a new session always starts fresh.
* Returns true if the limit has been exceeded.
*/
-export async function checkQuestionRateLimit(userId: string): Promise {
- return checkRateLimit(questionRateLimit(userId), RATE_LIMIT_COUNT, RATE_LIMIT_WINDOW_SECONDS);
+export async function checkQuestionRateLimit(userId: string, sessionId: string): Promise {
+ return checkRateLimit(
+ questionRateLimit(userId, sessionId),
+ RATE_LIMIT_COUNT,
+ RATE_LIMIT_WINDOW_SECONDS
+ );
}
/**
diff --git a/src/lib/redisKeys.ts b/src/lib/redisKeys.ts
index 68a0c86..c48bfd3 100644
--- a/src/lib/redisKeys.ts
+++ b/src/lib/redisKeys.ts
@@ -21,10 +21,11 @@ export function rateLimit(key: string): string {
/**
* Question rate-limit key (passed into checkRateLimit, which wraps it via rateLimit()).
- * Final Redis key: "ratelimit:{question:abc123}"
+ * Scoped per-session so a new session always gives every student a fresh counter.
+ * Final Redis key: "ratelimit:{question:abc123:sessionXYZ}"
*/
-export function questionRateLimit(userId: string): string {
- return `question:${userId}`;
+export function questionRateLimit(userId: string, sessionId: string): string {
+ return `question:${userId}:${sessionId}`;
}
/**
diff --git a/src/socket/handlers/questionHandlers.ts b/src/socket/handlers/questionHandlers.ts
index 5ffc8a1..8f9397b 100644
--- a/src/socket/handlers/questionHandlers.ts
+++ b/src/socket/handlers/questionHandlers.ts
@@ -4,7 +4,6 @@ import { prisma } from "@/lib/prisma";
import {
validateQuestionContent,
validateVisibility,
- checkQuestionRateLimit,
checkUpvoteRateLimit,
checkResolveRateLimit,
validateSessionForQuestions,
@@ -122,18 +121,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void {
return;
}
- // 5. Rate limit (first async check — only reached if all sync checks pass)
- const isRateLimited = await checkQuestionRateLimit(userId);
- if (isRateLimited) {
- console.log("[QuestionHandler] Rejected: rate limited");
- socket.emit("question:error", {
- message:
- "You have reached the question limit. Please wait before asking another question.",
- });
- return;
- }
-
- // 6. Session validation
+ // 5. Session validation
const sessionValidation = await validateSessionForQuestions(payload.sessionId);
if (!sessionValidation.valid) {
console.log("[QuestionHandler] Rejected: session validation -", sessionValidation.error);
@@ -141,7 +129,7 @@ export function handleQuestionCreate(socket: Socket, io: Server): void {
return;
}
- // 6a. Enrollment check — any non-PROFESSOR must be enrolled in the session's course
+ // 5a. 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 },
@@ -159,8 +147,8 @@ export function handleQuestionCreate(socket: Socket, io: Server): void {
authorEnrollmentRole = enrollment.role;
}
- // 7. Persist to database (include author for display name in broadcast)
- // authorId is always stored for audit purposes, but it is stripped
+ // 6. 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({
data: {
@@ -339,9 +327,7 @@ function checkResolvePermission(
questionAuthorId: string | null,
enrollmentRole: "STUDENT" | "TA" | "PROFESSOR"
): boolean {
- if (enrollmentRole === "TA" || enrollmentRole === "PROFESSOR") {
- return true;
- }
+ if (enrollmentRole === "TA" || enrollmentRole === "PROFESSOR") return true;
return questionAuthorId === userId;
}