Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/app/api/courses/[courseId]/students/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
Expand Down
12 changes: 1 addition & 11 deletions src/app/api/sessions/[sessionId]/questions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
import {
validateQuestionContent,
validateVisibility,
checkQuestionRateLimit,
validateSessionForQuestions,
} from "@/lib/questionValidation";
import { getSessionMembership } from "@/lib/sessionService";
Expand Down Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/sessions/[sessionId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
212 changes: 114 additions & 98 deletions src/app/room/classChat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -67,114 +68,129 @@ export default function ChatHeader({
}: ChatHeaderProps) {
const { sessionTitle } = useRoom();
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showTAModal, setShowTAModal] = useState(false);

return (
<div className="flex flex-col border-b bg-stone-50 sticky top-0 z-10">
<header className="pl-2 pr-4 py-2 flex items-center justify-between gap-2 min-h-[56px]">
{isSearchExpanded ? (
<div className="flex-1 flex items-center gap-2 w-full animate-in fade-in zoom-in-95 duration-200">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-stone-400 pointer-events-none" />
<Input
autoFocus
className="h-9 pl-8 pr-7 bg-stone-200 focus-visible:ring-0 focus-visible:border-stone-400 text-sm text-stone-500 placeholder:text-stone-500 w-full"
placeholder="Search questions & answers…"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={() => {
if (!searchQuery) setIsSearchExpanded(false);
}}
/>
{searchQuery && (
<button
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onSearchChange("");
<>
<div className="flex flex-col border-b bg-stone-50 sticky top-0 z-10">
<header className="pl-2 pr-4 py-2 flex items-center justify-between gap-2 min-h-[56px]">
{isSearchExpanded ? (
<div className="flex-1 flex items-center gap-2 w-full animate-in fade-in zoom-in-95 duration-200">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-stone-400 pointer-events-none" />
<Input
autoFocus
className="h-9 pl-8 pr-7 bg-stone-200 focus-visible:ring-0 focus-visible:border-stone-400 text-sm text-stone-500 placeholder:text-stone-500 w-full"
placeholder="Search questions & answers…"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={() => {
if (!searchQuery) setIsSearchExpanded(false);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-700 transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<button
onClick={() => {
onSearchChange("");
setIsSearchExpanded(false);
}}
className="text-sm font-medium text-stone-500 hover:text-stone-900 px-2 shrink-0 transition-colors"
>
Cancel
</button>
</div>
) : (
<>
<div className="flex items-center gap-2 shrink-0 animate-in fade-in duration-200">
<SlideToggle />
{sessionTitle && (
<h1 className="text-xl font-bold truncate max-w-[140px] sm:max-w-xs">
{sessionTitle}
</h1>
)}
</div>

<div className="flex items-center gap-2 shrink-0 animate-in fade-in duration-200">
<button
onClick={() => setIsSearchExpanded(true)}
className={`w-9 h-9 flex items-center justify-center rounded-md transition-colors ${
searchQuery
? "bg-stone-800 text-stone-50 hover:bg-stone-700"
: "bg-stone-200 text-stone-600 hover:bg-stone-300"
}`}
aria-label="Search"
>
<Search className="w-4 h-4" />
/>
{searchQuery && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-green-500" />
<button
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onSearchChange("");
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-700 transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<button
onClick={() => {
onSearchChange("");
setIsSearchExpanded(false);
}}
className="text-sm font-medium text-stone-500 hover:text-stone-900 px-2 shrink-0 transition-colors"
>
Cancel
</button>
</div>
) : (
<>
<div className="flex items-center gap-2 shrink-0 animate-in fade-in duration-200">
<SlideToggle />
{sessionTitle && (
<h1 className="text-xl font-bold truncate max-w-[140px] sm:max-w-xs">
{sessionTitle}
</h1>
)}
</div>

{/* Answer mode toggle — professors only */}
{role === "PROFESSOR" && (
<div className="flex items-center gap-2 shrink-0 animate-in fade-in duration-200">
<button
onClick={onToggleAnswerMode}
title={
answerMode === "all"
? "Anyone can answer — click to restrict to TAs/Professors"
: "TAs/Professors only — click to allow everyone"
}
className={`flex items-center gap-1.5 h-9 px-3 rounded-md text-sm font-medium transition-colors shrink-0 cursor-pointer ${
answerMode === "all"
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-amber-100 text-amber-700 hover:bg-amber-200"
onClick={() => setIsSearchExpanded(true)}
className={`w-9 h-9 flex items-center justify-center rounded-md transition-colors ${
searchQuery
? "bg-stone-800 text-stone-50 hover:bg-stone-700"
: "bg-stone-200 text-stone-600 hover:bg-stone-300"
}`}
aria-label="Search"
>
{answerMode === "all" ? (
<>
<Users className="w-3.5 h-3.5" />
Anyone
</>
) : (
<>
<GraduationCap className="w-3.5 h-3.5" />
TAs only
</>
<Search className="w-4 h-4" />
{searchQuery && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-green-500" />
)}
</button>
)}
<Link
href="/"
aria-label="Back to home"
className="inline-flex items-center gap-1.5 rounded-md h-9 px-3 text-sm font-medium text-stone-700 bg-stone-200 hover:bg-stone-300 transition-colors"
>
Back
<ArrowBigRight className="w-4 h-4" />
</Link>
</div>
</>
)}
</header>
</div>

{/* Answer mode toggle — professors only */}
{role === "PROFESSOR" && (
<button
onClick={onToggleAnswerMode}
title={
answerMode === "all"
? "Anyone can answer — click to restrict to TAs/Professors"
: "TAs/Professors only — click to allow everyone"
}
className={`flex items-center gap-1.5 h-9 px-3 rounded-md text-sm font-medium transition-colors shrink-0 cursor-pointer ${
answerMode === "all"
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-amber-100 text-amber-700 hover:bg-amber-200"
}`}
>
{answerMode === "all" ? (
<>
<Users className="w-3.5 h-3.5" />
Anyone
</>
) : (
<>
<GraduationCap className="w-3.5 h-3.5" />
TAs only
</>
)}
</button>
)}
{role === "PROFESSOR" && (
<button
onClick={() => setShowTAModal(true)}
title="Manage TAs"
className="w-9 h-9 flex items-center justify-center rounded-md text-stone-600 bg-stone-200 hover:bg-stone-300 transition-colors shrink-0"
aria-label="Manage TAs"
>
<UserPlus className="w-4 h-4" />
</button>
)}
<Link
href="/"
aria-label="Back to home"
className="inline-flex items-center gap-1.5 rounded-md h-9 px-3 text-sm font-medium text-stone-700 bg-stone-200 hover:bg-stone-300 transition-colors"
>
Back
<ArrowBigRight className="w-4 h-4" />
</Link>
</div>
</>
)}
</header>
</div>

{showTAModal && <ManageTAsModal onClose={() => setShowTAModal(false)} />}
</>
);
}
84 changes: 43 additions & 41 deletions src/app/room/classChat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,51 +59,53 @@ export default function ChatInput({
<div className="absolute inset-0 bg-background backdrop-blur-xl [mask-image:linear-gradient(to_top,black,transparent)]" />
<div className="max-w-4xl mx-auto px-4 pt-20 pb-4 relative">
<div className="flex flex-col gap-2 pointer-events-auto w-full">
<Textarea
placeholder={`Ask a question!\n(Shift+Enter for new line)`}
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={disabled}
rows={3}
className={`resize-none min-h-[70px] focus-visible:ring-0 focus-visible:border-stone-400 ${
error ? "border-red-400 bg-red-50" : ""
}`}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<>
<Textarea
placeholder={`Ask a question!\n(Shift+Enter for new line)`}
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={disabled}
rows={3}
className={`resize-none min-h-[70px] focus-visible:ring-0 focus-visible:border-stone-400 ${
error ? "border-red-400 bg-red-50" : ""
}`}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => onAnonymousChange(!isAnonymous)}
className={`flex items-center gap-1.5 h-9 px-3 rounded-md text-sm font-medium transition-colors cursor-pointer ${
isAnonymous
? "bg-stone-800 hover:bg-stone-700 text-stone-50"
: "bg-stone-200 hover:bg-stone-300 text-stone-700"
}`}
>
{isAnonymous ? (
<>
<Ghost className="w-4 h-4" />
Anonymous
</>
) : (
<>
<User className="w-4 h-4" />
Public
</>
)}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
<button
type="button"
onClick={() => onAnonymousChange(!isAnonymous)}
className={`flex items-center gap-1.5 h-9 px-3 rounded-md text-sm font-medium transition-colors cursor-pointer ${
isAnonymous
? "bg-stone-800 hover:bg-stone-700 text-stone-50"
: "bg-stone-200 hover:bg-stone-300 text-stone-700"
}`}
onClick={handleSubmit}
disabled={disabled || !content.trim()}
className="flex items-center justify-center gap-1.5 h-9 px-4 bg-stone-900 hover:bg-stone-800 text-stone-50 rounded-md text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isAnonymous ? (
<>
<Ghost className="w-4 h-4" />
Anonymous
</>
) : (
<>
<User className="w-4 h-4" />
Public
</>
)}
Post
<Send className="w-4 h-4" />
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
<button
onClick={handleSubmit}
disabled={disabled || !content.trim()}
className="flex items-center justify-center gap-1.5 h-9 px-4 bg-stone-900 hover:bg-stone-800 text-stone-50 rounded-md text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Post
<Send className="w-4 h-4" />
</button>
</div>
</>
</div>
</div>
</div>
Expand Down
Loading
Loading