diff --git a/Dockerfile b/Dockerfile index 9cea3c6..3a0c29f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,6 +64,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/src ./src COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./ +# Whitelist files (needed by auth logic) +COPY --from=builder --chown=nextjs:nodejs /app/admin_whitelist.txt ./ +COPY --from=builder --chown=nextjs:nodejs /app/whitelist.txt ./ + USER nextjs EXPOSE 3000 diff --git a/admin_whitelist.txt b/admin_whitelist.txt new file mode 100644 index 0000000..f083fa3 --- /dev/null +++ b/admin_whitelist.txt @@ -0,0 +1,9 @@ +# Admin whitelist — god-mode dashboard access +# Format: one UTORid per line +# UTORids listed here can access /dashboard with full read/delete powers. +# Lines starting with # are comments and are ignored. +# Restart the server to pick up changes. +# +# ---- ADMINS ---- +testprof +truon316 diff --git a/src/app/api/admin/answers/[answerId]/route.ts b/src/app/api/admin/answers/[answerId]/route.ts new file mode 100644 index 0000000..b40bbc8 --- /dev/null +++ b/src/app/api/admin/answers/[answerId]/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +interface RouteParams { + params: Promise<{ answerId: string }>; +} + +/** + * DELETE /api/admin/answers/[answerId] + * + * Deletes an answer. AnswerUpvote cascades automatically + * via onDelete: Cascade in the Prisma schema. + */ +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { answerId } = await params; + + await prisma.answer.delete({ where: { id: answerId } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Answers] Failed to delete answer:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/courses/[courseId]/route.ts b/src/app/api/admin/courses/[courseId]/route.ts new file mode 100644 index 0000000..8627e23 --- /dev/null +++ b/src/app/api/admin/courses/[courseId]/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import { prisma } from "@/lib/prisma"; +import { deleteFile } from "@/lib/storage"; + +interface RouteParams { + params: Promise<{ courseId: string }>; +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { courseId } = await params; + + // Gather all session IDs for this course + const sessions = await prisma.session.findMany({ + where: { courseId }, + select: { id: true }, + }); + const sessionIds = sessions.map((s) => s.id); + + await prisma.$transaction(async (tx) => { + if (sessionIds.length > 0) { + // Collect slide storage keys before deleting + const slideSets = await tx.slideSet.findMany({ + where: { sessionId: { in: sessionIds } }, + select: { storageKey: true }, + }); + + // Delete slide files from disk + await Promise.allSettled( + slideSets.map((ss) => + deleteFile(ss.storageKey).catch((err) => + console.error("[Admin Courses API] Failed to delete slide file:", ss.storageKey, err) + ) + ) + ); + + // Get question IDs to remove upvotes and answers + const questions = await tx.question.findMany({ + where: { sessionId: { in: sessionIds } }, + select: { id: true }, + }); + const questionIds = questions.map((q) => q.id); + + if (questionIds.length > 0) { + await tx.questionUpvote.deleteMany({ where: { questionId: { in: questionIds } } }); + await tx.answer.deleteMany({ where: { questionId: { in: questionIds } } }); + await tx.question.deleteMany({ where: { id: { in: questionIds } } }); + } + + // SlideSet + await tx.slideSet.deleteMany({ where: { sessionId: { in: sessionIds } } }); + + await tx.session.deleteMany({ where: { courseId } }); + } + + await tx.courseEnrollment.deleteMany({ where: { courseId } }); + await tx.course.delete({ where: { id: courseId } }); + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Courses] Failed to delete course:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/courses/all/route.ts b/src/app/api/admin/courses/all/route.ts new file mode 100644 index 0000000..0e02bfd --- /dev/null +++ b/src/app/api/admin/courses/all/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +export async function DELETE() { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + await prisma.$transaction([ + prisma.answerUpvote.deleteMany(), + prisma.questionUpvote.deleteMany(), + prisma.answer.deleteMany(), + prisma.question.deleteMany(), + prisma.slideSet.deleteMany(), + prisma.session.deleteMany(), + prisma.courseEnrollment.deleteMany(), + prisma.course.deleteMany(), + ]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Courses] Failed to delete all courses:", error); + return NextResponse.json({ error: "Failed to delete all courses." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/courses/route.ts b/src/app/api/admin/courses/route.ts new file mode 100644 index 0000000..89cb469 --- /dev/null +++ b/src/app/api/admin/courses/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import type { Prisma } from "@/generated/prisma"; + +export async function GET(request: NextRequest) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") ?? ""; + + const where: Prisma.CourseWhereInput = {}; + + if (search) { + where.OR = [ + { code: { contains: search, mode: "insensitive" } }, + { name: { contains: search, mode: "insensitive" } }, + ]; + } + + const courses = await prisma.course.findMany({ + where, + include: { + createdBy: { select: { name: true, utorid: true } }, + _count: { select: { enrollments: true, sessions: true } }, + }, + orderBy: { code: "asc" }, + }); + + return NextResponse.json({ courses }); + } catch (error) { + console.error("[Admin Courses] Failed to fetch courses:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/enrollments/[enrollmentId]/route.ts b/src/app/api/admin/enrollments/[enrollmentId]/route.ts new file mode 100644 index 0000000..c0bd3b5 --- /dev/null +++ b/src/app/api/admin/enrollments/[enrollmentId]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +interface RouteParams { + params: Promise<{ enrollmentId: string }>; +} + +/** + * DELETE /api/admin/enrollments/[enrollmentId] + * + * Removes a single course enrollment. + */ +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { enrollmentId } = await params; + + await prisma.courseEnrollment.delete({ where: { id: enrollmentId } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Enrollments] Failed to delete enrollment:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/enrollments/all/route.ts b/src/app/api/admin/enrollments/all/route.ts new file mode 100644 index 0000000..c795b64 --- /dev/null +++ b/src/app/api/admin/enrollments/all/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +export async function DELETE() { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + await prisma.courseEnrollment.deleteMany(); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Enrollments] Failed to delete all enrollments:", error); + return NextResponse.json({ error: "Failed to delete all enrollments." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/enrollments/route.ts b/src/app/api/admin/enrollments/route.ts new file mode 100644 index 0000000..bbeda29 --- /dev/null +++ b/src/app/api/admin/enrollments/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import type { Prisma } from "@/generated/prisma"; + +export async function GET(request: NextRequest) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") ?? ""; + const role = searchParams.get("role") ?? ""; + const courseId = searchParams.get("courseId") ?? ""; + const cursorParam = searchParams.get("cursor") ?? ""; + const limit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "50", 10) || 50, 1), 100); + + const where: Prisma.CourseEnrollmentWhereInput = {}; + + if (search) { + where.OR = [ + { user: { name: { contains: search, mode: "insensitive" } } }, + { user: { utorid: { contains: search, mode: "insensitive" } } }, + { course: { code: { contains: search, mode: "insensitive" } } }, + ]; + } + if (role && ["STUDENT", "TA", "PROFESSOR"].includes(role)) { + where.role = role as "STUDENT" | "TA" | "PROFESSOR"; + } + if (courseId) { + where.courseId = courseId; + } + + const enrollments = await prisma.courseEnrollment.findMany({ + where, + include: { + user: { select: { name: true, utorid: true } }, + course: { select: { code: true, name: true } }, + }, + orderBy: { id: "asc" }, + take: limit + 1, + ...(cursorParam ? { cursor: { id: cursorParam }, skip: 1 } : {}), + }); + + const hasMore = enrollments.length > limit; + if (hasMore) enrollments.pop(); + + const nextCursor = hasMore ? enrollments[enrollments.length - 1]?.id : null; + + return NextResponse.json({ enrollments, nextCursor }); + } catch (error) { + console.error("[Admin Enrollments] Failed to fetch enrollments:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/questions/[questionId]/route.ts b/src/app/api/admin/questions/[questionId]/route.ts new file mode 100644 index 0000000..6ca0ace --- /dev/null +++ b/src/app/api/admin/questions/[questionId]/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +interface RouteParams { + params: Promise<{ questionId: string }>; +} + +/** + * DELETE /api/admin/questions/[questionId] + * + * Deletes a question. Answers and upvotes cascade automatically + * via onDelete: Cascade in the Prisma schema. + */ +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { questionId } = await params; + + // QuestionUpvote uses onDelete: Cascade on the question relation + // Answer uses onDelete: Cascade on the question relation + // AnswerUpvote uses onDelete: Cascade on the answer relation + await prisma.question.delete({ where: { id: questionId } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Questions] Failed to delete question:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/questions/all/route.ts b/src/app/api/admin/questions/all/route.ts new file mode 100644 index 0000000..ba4283c --- /dev/null +++ b/src/app/api/admin/questions/all/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +export async function DELETE() { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + await prisma.$transaction([ + prisma.answerUpvote.deleteMany(), + prisma.questionUpvote.deleteMany(), + prisma.answer.deleteMany(), + prisma.question.deleteMany(), + ]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Questions] Failed to delete all questions:", error); + return NextResponse.json({ error: "Failed to delete all questions." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/questions/route.ts b/src/app/api/admin/questions/route.ts new file mode 100644 index 0000000..929bd9c --- /dev/null +++ b/src/app/api/admin/questions/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import type { Prisma } from "@/generated/prisma"; + +export async function GET(request: NextRequest) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") ?? ""; + const status = searchParams.get("status") ?? ""; + const sessionId = searchParams.get("sessionId") ?? ""; + const cursorParam = searchParams.get("cursor") ?? ""; + const limit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "50", 10) || 50, 1), 100); + + const where: Prisma.QuestionWhereInput = {}; + + if (search) { + where.content = { contains: search, mode: "insensitive" }; + } + if (status && ["OPEN", "ANSWERED", "RESOLVED"].includes(status)) { + where.status = status as "OPEN" | "ANSWERED" | "RESOLVED"; + } + if (sessionId) { + where.sessionId = sessionId; + } + + const questions = await prisma.question.findMany({ + where, + include: { + author: { select: { name: true, utorid: true } }, + session: { + select: { title: true, course: { select: { code: true } } }, + }, + _count: { select: { answers: true } }, + }, + orderBy: { createdAt: "desc" }, + take: limit + 1, + ...(cursorParam ? { cursor: { id: cursorParam }, skip: 1 } : {}), + }); + + const hasMore = questions.length > limit; + if (hasMore) questions.pop(); + + const nextCursor = hasMore ? questions[questions.length - 1]?.id : null; + + return NextResponse.json({ questions, nextCursor }); + } catch (error) { + console.error("[Admin Questions] Failed to fetch questions:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/sessions/[sessionId]/route.ts b/src/app/api/admin/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..7034946 --- /dev/null +++ b/src/app/api/admin/sessions/[sessionId]/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import { prisma } from "@/lib/prisma"; +import { deleteFile } from "@/lib/storage"; + +interface RouteParams { + params: Promise<{ sessionId: string }>; +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { sessionId } = await params; + + await prisma.$transaction(async (tx) => { + // Collect slide storage keys before deleting + const slideSets = await tx.slideSet.findMany({ + where: { sessionId }, + select: { storageKey: true }, + }); + + // Delete slide files from disk + await Promise.allSettled( + slideSets.map((ss) => + deleteFile(ss.storageKey).catch((err) => + console.error("[Admin Sessions API] Failed to delete slide file:", ss.storageKey, err) + ) + ) + ); + + // Get question IDs to remove upvotes and answers + const questions = await tx.question.findMany({ + where: { sessionId }, + select: { id: true }, + }); + const questionIds = questions.map((q) => q.id); + + if (questionIds.length > 0) { + await tx.questionUpvote.deleteMany({ where: { questionId: { in: questionIds } } }); + await tx.answer.deleteMany({ where: { questionId: { in: questionIds } } }); + await tx.question.deleteMany({ where: { id: { in: questionIds } } }); + } + + // SlideSet + await tx.slideSet.deleteMany({ where: { sessionId } }); + + // Session + await tx.session.delete({ where: { id: sessionId } }); + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Sessions] Failed to delete session:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/sessions/all/route.ts b/src/app/api/admin/sessions/all/route.ts new file mode 100644 index 0000000..8968c4f --- /dev/null +++ b/src/app/api/admin/sessions/all/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +export async function DELETE() { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + await prisma.$transaction([ + prisma.answerUpvote.deleteMany(), + prisma.questionUpvote.deleteMany(), + prisma.answer.deleteMany(), + prisma.question.deleteMany(), + prisma.slideSet.deleteMany(), + prisma.session.deleteMany(), + ]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Sessions] Failed to delete all sessions:", error); + return NextResponse.json({ error: "Failed to delete all sessions." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/sessions/route.ts b/src/app/api/admin/sessions/route.ts new file mode 100644 index 0000000..a2e0b80 --- /dev/null +++ b/src/app/api/admin/sessions/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import type { Prisma } from "@/generated/prisma"; + +export async function GET(request: NextRequest) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") ?? ""; + const status = searchParams.get("status") ?? ""; + + const where: Prisma.SessionWhereInput = {}; + + if (search) { + where.title = { contains: search, mode: "insensitive" }; + } + + if (status && ["ACTIVE", "SCHEDULED", "ENDED"].includes(status)) { + where.status = status as "ACTIVE" | "SCHEDULED" | "ENDED"; + } + + const sessions = await prisma.session.findMany({ + where, + include: { + course: { select: { code: true, name: true } }, + createdBy: { select: { name: true, utorid: true } }, + _count: { select: { questions: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ sessions }); + } catch (error) { + console.error("[Admin Sessions] Failed to fetch sessions:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/slidesets/[slideSetId]/route.ts b/src/app/api/admin/slidesets/[slideSetId]/route.ts new file mode 100644 index 0000000..47123b4 --- /dev/null +++ b/src/app/api/admin/slidesets/[slideSetId]/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import { deleteFile } from "@/lib/storage"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ slideSetId: string }> } +) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { slideSetId } = await params; + + const slideSet = await prisma.slideSet.findUnique({ where: { id: slideSetId } }); + if (!slideSet) { + return NextResponse.json({ error: "SlideSet not found." }, { status: 404 }); + } + + // Delete associated physical file + await deleteFile(slideSet.storageKey); + + await prisma.slideSet.delete({ where: { id: slideSetId } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin SlideSets] Failed to delete slideset:", error); + return NextResponse.json({ error: "Failed to delete slideset." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/slidesets/all/route.ts b/src/app/api/admin/slidesets/all/route.ts new file mode 100644 index 0000000..1cade1f --- /dev/null +++ b/src/app/api/admin/slidesets/all/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import { deleteFile } from "@/lib/storage"; + +export async function DELETE() { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const slideSets = await prisma.slideSet.findMany({ select: { storageKey: true } }); + + // Delete all physical files + for (const s of slideSets) { + try { + await deleteFile(s.storageKey); + } catch (e) { + console.error("Failed to delete file:", s.storageKey, e); + } + } + + await prisma.slideSet.deleteMany(); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin SlideSets] Failed to delete all slidesets:", error); + return NextResponse.json({ error: "Failed to delete all slidesets." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/slidesets/route.ts b/src/app/api/admin/slidesets/route.ts new file mode 100644 index 0000000..866c6f8 --- /dev/null +++ b/src/app/api/admin/slidesets/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import type { Prisma } from "@/generated/prisma"; + +export async function GET(request: NextRequest) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") ?? ""; + const limit = parseInt(searchParams.get("limit") ?? "50", 10); + const cursor = searchParams.get("cursor"); + + const where: Prisma.SlideSetWhereInput = {}; + if (search) { + where.OR = [{ filename: { contains: search, mode: "insensitive" } }]; + } + + const slideSets = await prisma.slideSet.findMany({ + where, + take: limit + 1, + ...(cursor && { cursor: { id: cursor }, skip: 1 }), + include: { + session: { select: { title: true, course: { select: { code: true } } } }, + uploader: { select: { name: true, utorid: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + let nextCursor: string | null = null; + if (slideSets.length > limit) { + const nextItem = slideSets.pop(); + nextCursor = nextItem?.id ?? null; + } + + return NextResponse.json({ slideSets, nextCursor }); + } catch (error) { + console.error("[Admin SlideSets] Failed to fetch slidesets:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts new file mode 100644 index 0000000..9e0716e --- /dev/null +++ b/src/app/api/admin/stats/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +export async function GET() { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const [users, courses, sessions, activeSessions, questions, answers, enrollments] = + await Promise.all([ + prisma.user.count(), + prisma.course.count(), + prisma.session.count(), + prisma.session.count({ where: { status: "ACTIVE" } }), + prisma.question.count(), + prisma.answer.count(), + prisma.courseEnrollment.count(), + ]); + + return NextResponse.json({ + users, + courses, + sessions, + activeSessions, + questions, + answers, + enrollments, + }); + } catch (error) { + console.error("[Admin Stats] Failed to fetch stats:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/system/all/route.ts b/src/app/api/admin/system/all/route.ts new file mode 100644 index 0000000..5e2b01f --- /dev/null +++ b/src/app/api/admin/system/all/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +export async function DELETE() { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + await prisma.$transaction([ + prisma.answerUpvote.deleteMany(), + prisma.questionUpvote.deleteMany(), + prisma.answer.deleteMany(), + prisma.question.deleteMany(), + prisma.slideSet.deleteMany(), + prisma.session.deleteMany(), + prisma.courseEnrollment.deleteMany(), + prisma.course.deleteMany(), + prisma.user.deleteMany(), + ]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin System] Failed to delete all data:", error); + return NextResponse.json({ error: "Failed to delete all data." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/users/[userId]/route.ts b/src/app/api/admin/users/[userId]/route.ts new file mode 100644 index 0000000..590ea66 --- /dev/null +++ b/src/app/api/admin/users/[userId]/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import { prisma } from "@/lib/prisma"; +import { deleteFile } from "@/lib/storage"; + +interface RouteParams { + params: Promise<{ userId: string }>; +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { userId } = await params; + + await prisma.$transaction(async (tx) => { + // 1. Gather all courses created by this user + const courses = await tx.course.findMany({ + where: { createdById: userId }, + select: { id: true }, + }); + const courseIds = courses.map((c) => c.id); + + // 2. Gather all sessions created by this user OR belonging to their courses + const sessions = await tx.session.findMany({ + where: { + OR: [{ createdById: userId }, { courseId: { in: courseIds } }], + }, + select: { id: true }, + }); + const sessionIds = sessions.map((s) => s.id); + + // 3. Handle SlideSets (uploaded by user OR in dying sessions) + const slideSets = await tx.slideSet.findMany({ + where: { + OR: [{ uploadedBy: userId }, { sessionId: { in: sessionIds } }], + }, + select: { id: true, storageKey: true }, + }); + if (slideSets.length > 0) { + await Promise.allSettled( + slideSets.map((ss) => + deleteFile(ss.storageKey).catch((err) => + console.error("[Admin Users API] Failed to delete slide file:", ss.storageKey, err) + ) + ) + ); + const slideSetIds = slideSets.map((ss) => ss.id); + await tx.slideSet.deleteMany({ where: { id: { in: slideSetIds } } }); + } + + // 4. Gather all questions to delete (authored by user OR in dying sessions) + const questionsToDel = await tx.question.findMany({ + where: { + OR: [{ authorId: userId }, { sessionId: { in: sessionIds } }], + }, + select: { id: true }, + }); + const questionIds = questionsToDel.map((q) => q.id); + + // 5. Gather all answers to delete (authored by user OR in dying questions) + const answersToDel = await tx.answer.findMany({ + where: { + OR: [{ authorId: userId }, { questionId: { in: questionIds } }], + }, + select: { id: true }, + }); + const answerIds = answersToDel.map((a) => a.id); + + // 6. Delete Upvotes (made by user OR on dying questions/answers) + await tx.questionUpvote.deleteMany({ + where: { + OR: [{ userId }, { questionId: { in: questionIds } }], + }, + }); + + await tx.answerUpvote.deleteMany({ + where: { + OR: [{ userId }, { answerId: { in: answerIds } }], + }, + }); + + // 7. Delete Answers and Questions + if (answerIds.length > 0) { + await tx.answer.deleteMany({ where: { id: { in: answerIds } } }); + } + if (questionIds.length > 0) { + await tx.question.deleteMany({ where: { id: { in: questionIds } } }); + } + + // 8. Delete Sessions + if (sessionIds.length > 0) { + await tx.session.deleteMany({ where: { id: { in: sessionIds } } }); + } + + // 9. Delete Enrollments (where user is enrolled, OR in dying courses) + await tx.courseEnrollment.deleteMany({ + where: { + OR: [{ userId }, { courseId: { in: courseIds } }], + }, + }); + + // 10. Delete Courses + if (courseIds.length > 0) { + await tx.course.deleteMany({ where: { id: { in: courseIds } } }); + } + + // 11. Finally, delete the User + await tx.user.delete({ where: { id: userId } }); + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Users] Failed to delete user:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/users/all/route.ts b/src/app/api/admin/users/all/route.ts new file mode 100644 index 0000000..95d1a7d --- /dev/null +++ b/src/app/api/admin/users/all/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +export async function DELETE() { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + await prisma.$transaction([ + prisma.answerUpvote.deleteMany(), + prisma.questionUpvote.deleteMany(), + prisma.answer.deleteMany(), + prisma.question.deleteMany(), + prisma.slideSet.deleteMany(), + prisma.session.deleteMany(), + prisma.courseEnrollment.deleteMany(), + prisma.course.deleteMany(), + prisma.user.deleteMany(), + ]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Admin Users] Failed to delete all users:", error); + return NextResponse.json({ error: "Failed to delete all users." }, { status: 500 }); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..61da129 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; +import type { Prisma } from "@/generated/prisma"; + +export async function GET(request: NextRequest) { + try { + const user = await requireAdmin(); + const guard = adminGuardResponse(user); + if (guard) return guard; + + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") ?? ""; + const role = searchParams.get("role") ?? ""; + + const where: Prisma.UserWhereInput = {}; + + if (search) { + where.OR = [ + { name: { contains: search, mode: "insensitive" } }, + { utorid: { contains: search, mode: "insensitive" } }, + ]; + } + + if (role && ["STUDENT", "TA", "PROFESSOR"].includes(role)) { + where.role = role as "STUDENT" | "TA" | "PROFESSOR"; + } + + const users = await prisma.user.findMany({ + where, + select: { id: true, utorid: true, email: true, name: true, role: true }, + orderBy: { name: "asc" }, + }); + + return NextResponse.json({ users }); + } catch (error) { + console.error("[Admin Users] Failed to fetch users:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} diff --git a/src/app/api/courses/route.ts b/src/app/api/courses/route.ts index 524cd10..4bc7d70 100644 --- a/src/app/api/courses/route.ts +++ b/src/app/api/courses/route.ts @@ -133,6 +133,19 @@ export async function POST(request: NextRequest) { // Create the course and enroll the professor in one transaction const course = await prisma.$transaction(async (tx) => { + // Ensure the professor exists in the DB (in case of a system wipe) + await tx.user.upsert({ + where: { id: user.userId }, + update: {}, + create: { + id: user.userId, + utorid: user.utorid, + email: user.email, + name: user.name, + role: "PROFESSOR", + }, + }); + const newCourse = await tx.course.create({ data: { code: courseCode, diff --git a/src/app/dashboard/components/CoursesTable.tsx b/src/app/dashboard/components/CoursesTable.tsx new file mode 100644 index 0000000..017972b --- /dev/null +++ b/src/app/dashboard/components/CoursesTable.tsx @@ -0,0 +1,160 @@ +"use client"; + +import React, { useEffect, useState, useRef } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Trash2, Search } from "lucide-react"; +import DeleteConfirmModal from "./DeleteConfirmModal"; + +interface Course { + id: string; + code: string; + name: string; + semester: string; + createdBy: { name: string; utorid: string }; + _count: { enrollments: number; sessions: number }; +} + +export default function CoursesTable() { + const [courses, setCourses] = useState([]); + const [search, setSearch] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<{ + type: "single" | "all"; + id?: string; + code?: string; + } | null>(null); + + const fetchRef = useRef(0); + + useEffect(() => { + const id = ++fetchRef.current; + const params = new URLSearchParams(); + if (search) params.set("search", search); + fetch(`/api/admin/courses?${params}`) + .then((res) => (res.ok ? res.json() : { courses: [] })) + .then((data) => { + if (id === fetchRef.current) setCourses(data.courses ?? []); + }) + .catch(() => { + if (id === fetchRef.current) setCourses([]); + }); + }, [search]); + + const confirmDeleteSingle = async (courseId: string) => { + const res = await fetch(`/api/admin/courses/${courseId}`, { method: "DELETE" }); + if (res.ok) { + fetchRef.current++; + setCourses((prev) => prev.filter((c) => c.id !== courseId)); + } else alert("Failed to delete course."); + }; + + const confirmDeleteAll = async () => { + const res = await fetch(`/api/admin/courses/all`, { method: "DELETE" }); + if (res.ok) { + fetchRef.current++; + setCourses([]); + } else alert("Failed to delete all courses."); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ +
+ + + + + + + + + + + + + + {courses === null ? ( + + + + ) : courses.length === 0 ? ( + + + + ) : ( + courses.map((course) => ( + + + + + + + + + + )) + )} + +
CodeNameSemesterCreated ByEnrollmentsSessionsActions
+ Loading… +
+ No courses found. +
{course.code}{course.name}{course.semester}{course.createdBy.name}{course._count.enrollments}{course._count.sessions} + +
+
+ + setDeleteTarget(null)} + title={deleteTarget?.type === "all" ? "Delete All Courses" : "Delete Course"} + description={ + deleteTarget?.type === "all" ? ( + <> + This will permanently delete ALL courses and{" "} + ALL their sessions, questions, and enrollments. This cannot be + undone. + + ) : ( + <> + This will permanently delete {deleteTarget?.code} and{" "} + ALL its sessions, questions, and enrollments. This cannot be undone. + + ) + } + requireTypeToConfirm={deleteTarget?.type === "all" ? "DELETE COURSES" : undefined} + confirmText={deleteTarget?.type === "all" ? "Delete All Courses" : "Delete Course"} + onConfirm={async () => { + if (deleteTarget?.type === "all") { + await confirmDeleteAll(); + } else if (deleteTarget?.type === "single" && deleteTarget.id) { + await confirmDeleteSingle(deleteTarget.id); + } + }} + /> +
+ ); +} diff --git a/src/app/dashboard/components/DeleteConfirmModal.tsx b/src/app/dashboard/components/DeleteConfirmModal.tsx new file mode 100644 index 0000000..d321b27 --- /dev/null +++ b/src/app/dashboard/components/DeleteConfirmModal.tsx @@ -0,0 +1,122 @@ +import React, { useState, useRef } from "react"; +import { X, Trash2 } from "lucide-react"; + +interface DeleteConfirmModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => Promise; + title: string; + description: React.ReactNode; + requireTypeToConfirm?: string; + confirmText?: string; +} + +export default function DeleteConfirmModal({ + isOpen, + onClose, + onConfirm, + title, + description, + requireTypeToConfirm, + confirmText = "Delete", +}: DeleteConfirmModalProps) { + const [confirmInput, setConfirmInput] = useState(""); + const [isDeleting, setIsDeleting] = useState(false); + const backdropMouseDownRef = useRef(false); + + if (!isOpen) return null; + + const handleConfirm = async () => { + if (requireTypeToConfirm && confirmInput !== requireTypeToConfirm) return; + setIsDeleting(true); + try { + await onConfirm(); + } finally { + setIsDeleting(false); + onClose(); + setConfirmInput(""); + } + }; + + return ( +
{ + backdropMouseDownRef.current = e.target === e.currentTarget; + }} + onMouseUp={(e) => { + if (backdropMouseDownRef.current && e.target === e.currentTarget) { + onClose(); + setConfirmInput(""); + } + backdropMouseDownRef.current = false; + }} + > +
+ {/* Header */} +
+
+ +

{title}

+
+ +
+ + {/* Content */} +
+
+ {description} +
+ + {requireTypeToConfirm && ( +
+ + setConfirmInput(e.target.value)} + placeholder={requireTypeToConfirm} + className="border border-stone-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:border-red-400" + /> +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/dashboard/components/EnrollmentsTable.tsx b/src/app/dashboard/components/EnrollmentsTable.tsx new file mode 100644 index 0000000..8761f0e --- /dev/null +++ b/src/app/dashboard/components/EnrollmentsTable.tsx @@ -0,0 +1,217 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Trash2, Search } from "lucide-react"; +import DeleteConfirmModal from "./DeleteConfirmModal"; + +interface Enrollment { + id: string; + role: string; + user: { name: string; utorid: string }; + course: { code: string; name: string }; +} + +export default function EnrollmentsTable() { + const [enrollments, setEnrollments] = useState([]); + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState(""); + const [loading, setLoading] = useState(true); + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ + type: "single" | "all"; + id?: string; + userName?: string; + courseCode?: string; + } | null>(null); + + const fetchEnrollments = useCallback( + (append = false) => { + setLoading(true); + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (roleFilter) params.set("role", roleFilter); + params.set("limit", "50"); + if (append && cursor) params.set("cursor", cursor); + + fetch(`/api/admin/enrollments?${params}`) + .then((res) => (res.ok ? res.json() : { enrollments: [], nextCursor: null })) + .then((data) => { + const items = data.enrollments ?? []; + setEnrollments((prev) => (append ? [...prev, ...items] : items)); + setCursor(data.nextCursor ?? null); + setHasMore(!!data.nextCursor); + }) + .catch(() => { + if (!append) setEnrollments([]); + }) + .finally(() => setLoading(false)); + }, + [search, roleFilter, cursor] + ); + + useEffect(() => { + setCursor(null); + fetchEnrollments(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search, roleFilter]); + + const confirmDeleteSingle = async (enrollmentId: string) => { + const res = await fetch(`/api/admin/enrollments/${enrollmentId}`, { method: "DELETE" }); + if (res.ok) setEnrollments((prev) => prev.filter((e) => e.id !== enrollmentId)); + else alert("Failed to delete enrollment."); + }; + + const confirmDeleteAll = async () => { + const res = await fetch(`/api/admin/enrollments/all`, { method: "DELETE" }); + if (res.ok) { + setEnrollments([]); + } else alert("Failed to delete all enrollments."); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + +
+ +
+ + + + + + + + + + + + {loading && enrollments.length === 0 ? ( + + + + ) : enrollments.length === 0 ? ( + + + + ) : ( + enrollments.map((e) => ( + + + + + + + + )) + )} + +
UserUTORidCourseRoleActions
+ Loading… +
+ No enrollments found. +
{e.user.name}{e.user.utorid} + {e.course.code} + — {e.course.name} + + + + +
+
+ + {hasMore && ( +
+ +
+ )} + + setDeleteTarget(null)} + title={deleteTarget?.type === "all" ? "Delete All Enrollments" : "Delete Enrollment"} + description={ + deleteTarget?.type === "all" ? ( + <> + This will permanently delete ALL enrollments. This cannot be undone. + + ) : ( + <> + Remove {deleteTarget?.userName} from{" "} + {deleteTarget?.courseCode}? + + ) + } + requireTypeToConfirm={deleteTarget?.type === "all" ? "DELETE ENROLLMENTS" : undefined} + confirmText={deleteTarget?.type === "all" ? "Delete All Enrollments" : "Delete Enrollment"} + onConfirm={async () => { + if (deleteTarget?.type === "all") { + await confirmDeleteAll(); + } else if (deleteTarget?.type === "single" && deleteTarget.id) { + await confirmDeleteSingle(deleteTarget.id); + } + }} + /> +
+ ); +} + +function RoleBadge({ role }: { role: string }) { + const styles: Record = { + PROFESSOR: "bg-purple-100 text-purple-700", + TA: "bg-blue-100 text-blue-700", + STUDENT: "bg-stone-100 text-stone-600", + }; + return ( + + {role} + + ); +} diff --git a/src/app/dashboard/components/QuestionsTable.tsx b/src/app/dashboard/components/QuestionsTable.tsx new file mode 100644 index 0000000..0bd390b --- /dev/null +++ b/src/app/dashboard/components/QuestionsTable.tsx @@ -0,0 +1,376 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Trash2, Search, ChevronDown, ChevronRight } from "lucide-react"; +import DeleteConfirmModal from "./DeleteConfirmModal"; + +interface Answer { + id: string; + content: string; + authorId: string; + author: { name: string; utorid: string } | null; + isAnonymous: boolean; + isAccepted: boolean; + upvoteCount: number; + createdAt: string; +} + +interface Question { + id: string; + content: string; + status: string; + visibility: string; + isAnonymous: boolean; + upvoteCount: number; + createdAt: string; + author: { name: string; utorid: string } | null; + session: { title: string; course: { code: string } }; + _count: { answers: number }; +} + +export default function QuestionsTable() { + const [questions, setQuestions] = useState([]); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [loading, setLoading] = useState(true); + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [expandedId, setExpandedId] = useState(null); + const [answers, setAnswers] = useState>({}); + const [deleteTarget, setDeleteTarget] = useState<{ + type: "question" | "answer" | "all"; + id?: string; // id for single question or single answer + parentId?: string; // questionId if deleting answer + name?: string; // string snippet of question or answer text + } | null>(null); + + const fetchQuestions = useCallback( + (append = false) => { + setLoading(true); + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (statusFilter) params.set("status", statusFilter); + params.set("limit", "50"); + if (append && cursor) params.set("cursor", cursor); + + fetch(`/api/admin/questions?${params}`) + .then((res) => (res.ok ? res.json() : { questions: [], nextCursor: null })) + .then((data) => { + const items = data.questions ?? []; + setQuestions((prev) => (append ? [...prev, ...items] : items)); + setCursor(data.nextCursor ?? null); + setHasMore(!!data.nextCursor); + }) + .catch(() => { + if (!append) setQuestions([]); + }) + .finally(() => setLoading(false)); + }, + [search, statusFilter, cursor] + ); + + useEffect(() => { + setCursor(null); + fetchQuestions(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search, statusFilter]); + + const confirmDeleteQuestion = async (questionId: string) => { + const res = await fetch(`/api/admin/questions/${questionId}`, { method: "DELETE" }); + if (res.ok) { + setQuestions((prev) => prev.filter((q) => q.id !== questionId)); + if (expandedId === questionId) setExpandedId(null); + } else alert("Failed to delete question."); + }; + + const confirmDeleteAll = async () => { + const res = await fetch(`/api/admin/questions/all`, { method: "DELETE" }); + if (res.ok) { + setQuestions([]); + setAnswers({}); + setExpandedId(null); + } else alert("Failed to delete all questions."); + }; + + const confirmDeleteAnswer = async (answerId: string, questionId: string) => { + const res = await fetch(`/api/admin/answers/${answerId}`, { method: "DELETE" }); + if (res.ok) { + setAnswers((prev) => ({ + ...prev, + [questionId]: (prev[questionId] ?? []).filter((a) => a.id !== answerId), + })); + setQuestions((prev) => + prev.map((q) => + q.id === questionId ? { ...q, _count: { answers: Math.max(0, q._count.answers - 1) } } : q + ) + ); + } else alert("Failed to delete answer."); + }; + + const toggleExpand = async (questionId: string) => { + if (expandedId === questionId) { + setExpandedId(null); + return; + } + setExpandedId(questionId); + if (!answers[questionId]) { + const res = await fetch(`/api/questions/${questionId}/answers`); + if (res.ok) { + const data = await res.json(); + setAnswers((prev) => ({ ...prev, [questionId]: data.answers ?? [] })); + } + } + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + +
+ +
+ + + + + + + + + + + + + + + {loading && questions.length === 0 ? ( + + + + ) : questions.length === 0 ? ( + + + + ) : ( + questions.map((q) => ( + + + + + + + + + + + + {expandedId === q.id && ( + + + + )} + + )) + )} + +
ContentAuthorSessionStatusAnswersVotesActions
+ Loading… +
+ No questions found. +
+ {q._count.answers > 0 && ( + + )} + + {q.content.length > 80 ? q.content.slice(0, 80) + "…" : q.content} + + {q.isAnonymous ? ( + Anonymous + ) : ( + (q.author?.name ?? "—") + )} + + {q.session.course.code} + / {q.session.title} + + + {q._count.answers}{q.upvoteCount} + +
+
+

Answers

+ {(answers[q.id] ?? []).length === 0 ? ( +

Loading answers…

+ ) : ( + (answers[q.id] ?? []).map((a) => ( +
+
+

{a.content}

+

+ {a.isAnonymous ? "Anonymous" : (a.author?.name ?? "—")} ·{" "} + {a.upvoteCount} votes + {a.isAccepted && ( + + Accepted + + )} +

+
+ +
+ )) + )} +
+
+
+ + {hasMore && ( +
+ +
+ )} + + setDeleteTarget(null)} + title={ + deleteTarget?.type === "all" + ? "Delete All Questions" + : deleteTarget?.type === "question" + ? "Delete Question" + : "Delete Answer" + } + description={ + deleteTarget?.type === "all" ? ( + <> + This will permanently delete ALL questions and{" "} + ALL answers. This cannot be undone. + + ) : deleteTarget?.type === "question" ? ( + <> + This will permanently delete this question and ALL its answers. This + cannot be undone. +
+
+ + “ + {deleteTarget?.name?.slice(0, 100) + + (deleteTarget?.name && deleteTarget.name.length > 100 ? "..." : "")} + ” + + + ) : ( + <> + This will permanently delete this answer. This cannot be undone. +
+
+ + “ + {deleteTarget?.name?.slice(0, 100) + + (deleteTarget?.name && deleteTarget.name.length > 100 ? "..." : "")} + ” + + + ) + } + requireTypeToConfirm={deleteTarget?.type === "all" ? "DELETE QUESTIONS" : undefined} + confirmText={ + deleteTarget?.type === "all" + ? "Delete All Questions" + : deleteTarget?.type === "question" + ? "Delete Question" + : "Delete Answer" + } + onConfirm={async () => { + if (deleteTarget?.type === "all") { + await confirmDeleteAll(); + } else if (deleteTarget?.type === "question" && deleteTarget.id) { + await confirmDeleteQuestion(deleteTarget.id); + } else if (deleteTarget?.type === "answer" && deleteTarget.id && deleteTarget.parentId) { + await confirmDeleteAnswer(deleteTarget.id, deleteTarget.parentId); + } + }} + /> +
+ ); +} + +function QuestionStatusBadge({ status }: { status: string }) { + const styles: Record = { + OPEN: "bg-blue-100 text-blue-700", + ANSWERED: "bg-yellow-100 text-yellow-700", + RESOLVED: "bg-green-100 text-green-700", + }; + return ( + + {status} + + ); +} diff --git a/src/app/dashboard/components/SessionsTable.tsx b/src/app/dashboard/components/SessionsTable.tsx new file mode 100644 index 0000000..a0a94bd --- /dev/null +++ b/src/app/dashboard/components/SessionsTable.tsx @@ -0,0 +1,192 @@ +"use client"; + +import React, { useEffect, useState, useRef } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Trash2, Search } from "lucide-react"; +import DeleteConfirmModal from "./DeleteConfirmModal"; + +interface Session { + id: string; + title: string; + status: string; + createdAt: string; + course: { code: string; name: string }; + createdBy: { name: string; utorid: string }; + _count: { questions: number }; +} + +export default function SessionsTable() { + const [sessions, setSessions] = useState(null); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<{ + type: "single" | "all"; + id?: string; + title?: string; + } | null>(null); + + const fetchRef = useRef(0); + + useEffect(() => { + const id = ++fetchRef.current; + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (statusFilter) params.set("status", statusFilter); + fetch(`/api/admin/sessions?${params}`) + .then((res) => (res.ok ? res.json() : { sessions: [] })) + .then((data) => { + if (id === fetchRef.current) setSessions(data.sessions ?? []); + }) + .catch(() => { + if (id === fetchRef.current) setSessions([]); + }); + }, [search, statusFilter]); + + const confirmDeleteSingle = async (sessionId: string) => { + const res = await fetch(`/api/admin/sessions/${sessionId}`, { method: "DELETE" }); + if (res.ok) { + fetchRef.current++; + setSessions((prev) => (prev ? prev.filter((s) => s.id !== sessionId) : prev)); + } else alert("Failed to delete session."); + }; + + const confirmDeleteAll = async () => { + const res = await fetch(`/api/admin/sessions/all`, { method: "DELETE" }); + if (res.ok) { + fetchRef.current++; + setSessions([]); + } else alert("Failed to delete all sessions."); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + +
+ +
+ + + + + + + + + + + + + + {sessions === null ? ( + + + + ) : sessions.length === 0 ? ( + + + + ) : ( + sessions.map((session) => ( + + + + + + + + + + )) + )} + +
TitleCourseStatusCreated ByQuestionsCreatedActions
+ Loading… +
+ No sessions found. +
{session.title}{session.course.code} + + {session.createdBy.name}{session._count.questions} + {new Date(session.createdAt).toLocaleDateString()} + + +
+
+ + setDeleteTarget(null)} + title={deleteTarget?.type === "all" ? "Delete All Sessions" : "Delete Session"} + description={ + deleteTarget?.type === "all" ? ( + <> + This will permanently delete ALL sessions and{" "} + ALL their associated questions, answers, and slides. This cannot be + undone. + + ) : ( + <> + This will permanently delete session {deleteTarget?.title} and{" "} + ALL its questions, answers, and slides. This cannot be undone. + + ) + } + requireTypeToConfirm={deleteTarget?.type === "all" ? "DELETE SESSIONS" : undefined} + confirmText={deleteTarget?.type === "all" ? "Delete All Sessions" : "Delete Session"} + onConfirm={async () => { + if (deleteTarget?.type === "all") { + await confirmDeleteAll(); + } else if (deleteTarget?.type === "single" && deleteTarget.id) { + await confirmDeleteSingle(deleteTarget.id); + } + }} + /> +
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + ACTIVE: "bg-green-100 text-green-700", + SCHEDULED: "bg-yellow-100 text-yellow-700", + ENDED: "bg-stone-100 text-stone-600", + }; + return ( + + {status} + + ); +} diff --git a/src/app/dashboard/components/SlideSetsTable.tsx b/src/app/dashboard/components/SlideSetsTable.tsx new file mode 100644 index 0000000..6c7af93 --- /dev/null +++ b/src/app/dashboard/components/SlideSetsTable.tsx @@ -0,0 +1,210 @@ +"use client"; + +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Trash2, Search } from "lucide-react"; +import DeleteConfirmModal from "./DeleteConfirmModal"; + +interface SlideSet { + id: string; + filename: string; + fileSize: number; + pageCount: number; + createdAt: string; + session: { title: string; course: { code: string } }; + uploader: { name: string; utorid: string }; +} + +function formatBytes(bytes: number, decimals = 2) { + if (!+bytes) return "0 Bytes"; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +} + +export default function SlideSetsTable() { + const [slideSets, setSlideSets] = useState([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ + type: "single" | "all"; + id?: string; + filename?: string; + } | null>(null); + + const fetchRef = useRef(0); + + const fetchSlideSets = useCallback( + (append = false) => { + setLoading(true); + const id = ++fetchRef.current; + const params = new URLSearchParams(); + if (search) params.set("search", search); + params.set("limit", "50"); + if (append && cursor) params.set("cursor", cursor); + + fetch(`/api/admin/slidesets?${params}`) + .then((res) => (res.ok ? res.json() : { slideSets: [], nextCursor: null })) + .then((data) => { + if (id !== fetchRef.current) return; + const items = data.slideSets ?? []; + setSlideSets((prev) => (append ? [...prev, ...items] : items)); + setCursor(data.nextCursor ?? null); + setHasMore(!!data.nextCursor); + }) + .catch(() => { + if (!append && id === fetchRef.current) setSlideSets([]); + }) + .finally(() => { + if (id === fetchRef.current) setLoading(false); + }); + }, + [search, cursor] + ); + + useEffect(() => { + setCursor(null); + const timeout = setTimeout(() => { + fetchSlideSets(false); + }, 300); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search]); + + const confirmDeleteSingle = async (slideSetId: string) => { + const res = await fetch(`/api/admin/slidesets/${slideSetId}`, { method: "DELETE" }); + if (res.ok) setSlideSets((prev) => prev.filter((s) => s.id !== slideSetId)); + else alert("Failed to delete slideset."); + }; + + const confirmDeleteAll = async () => { + const res = await fetch(`/api/admin/slidesets/all`, { method: "DELETE" }); + if (res.ok) { + setSlideSets([]); + } else alert("Failed to delete all slidesets."); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ +
+ + + + + + + + + + + + + {loading && slideSets.length === 0 ? ( + + + + ) : slideSets.length === 0 ? ( + + + + ) : ( + slideSets.map((s) => ( + + + + + + + + + )) + )} + +
FilenameSessionUploaderPagesSizeActions
+ Loading… +
+ No slide sets found. +
+ {s.filename} + + {s.session?.course?.code} + {s.session?.title} + {s.uploader?.name}{s.pageCount} + {formatBytes(s.fileSize)} + + +
+
+ + {hasMore && ( +
+ +
+ )} + + setDeleteTarget(null)} + title={deleteTarget?.type === "all" ? "Delete All Slide Sets" : "Delete Slide Set"} + description={ + deleteTarget?.type === "all" ? ( + <> + This will permanently delete ALL slide sets and their associated + physical storage files. This cannot be undone. + + ) : ( + <> + Permanently delete {deleteTarget?.filename} and its physical storage + file? + + ) + } + requireTypeToConfirm={deleteTarget?.type === "all" ? "DELETE SLIDESETS" : undefined} + confirmText={deleteTarget?.type === "all" ? "Delete All Slide Sets" : "Delete Slide Set"} + onConfirm={async () => { + if (deleteTarget?.type === "all") { + await confirmDeleteAll(); + } else if (deleteTarget?.type === "single" && deleteTarget.id) { + await confirmDeleteSingle(deleteTarget.id); + } + }} + /> +
+ ); +} diff --git a/src/app/dashboard/components/UsersTable.tsx b/src/app/dashboard/components/UsersTable.tsx new file mode 100644 index 0000000..61c6411 --- /dev/null +++ b/src/app/dashboard/components/UsersTable.tsx @@ -0,0 +1,184 @@ +"use client"; + +import React, { useEffect, useState, useRef } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Trash2, Search } from "lucide-react"; +import DeleteConfirmModal from "./DeleteConfirmModal"; + +interface User { + id: string; + utorid: string; + email: string; + name: string; + role: string; +} + +export default function UsersTable() { + const [users, setUsers] = useState(null); + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<{ + type: "single" | "all"; + id?: string; + name?: string; + } | null>(null); + + const fetchRef = useRef(0); + + useEffect(() => { + const id = ++fetchRef.current; + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (roleFilter) params.set("role", roleFilter); + fetch(`/api/admin/users?${params}`) + .then((res) => (res.ok ? res.json() : { users: [] })) + .then((data) => { + if (id === fetchRef.current) setUsers(data.users ?? []); + }) + .catch(() => { + if (id === fetchRef.current) setUsers([]); + }); + }, [search, roleFilter]); + + const confirmDeleteSingle = async (userId: string) => { + const res = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" }); + if (res.ok) { + fetchRef.current++; + setUsers((prev) => (prev ? prev.filter((u) => u.id !== userId) : prev)); + } else alert("Failed to delete user."); + }; + + const confirmDeleteAll = async () => { + const res = await fetch(`/api/admin/users/all`, { method: "DELETE" }); + if (res.ok) { + fetchRef.current++; + setUsers([]); + } else alert("Failed to delete all users."); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + +
+ +
+ + + + + + + + + + + + {users === null ? ( + + + + ) : users.length === 0 ? ( + + + + ) : ( + users.map((user) => ( + + + + + + + + )) + )} + +
NameUTORidEmailRoleActions
+ Loading… +
+ No users found. +
{user.name}{user.utorid}{user.email} + + + +
+
+ + setDeleteTarget(null)} + title={deleteTarget?.type === "all" ? "Delete All Users" : "Delete User"} + description={ + deleteTarget?.type === "all" ? ( + <> + This will permanently delete ALL users and{" "} + ALL their associated data. This is extremely dangerous and cannot be + undone. + + ) : ( + <> + This will permanently delete {deleteTarget?.name} and{" "} + ALL their data. This cannot be undone. + + ) + } + requireTypeToConfirm={deleteTarget?.type === "all" ? "DELETE USERS" : undefined} + confirmText={deleteTarget?.type === "all" ? "Delete All Users" : "Delete User"} + onConfirm={async () => { + if (deleteTarget?.type === "all") { + await confirmDeleteAll(); + } else if (deleteTarget?.type === "single" && deleteTarget.id) { + await confirmDeleteSingle(deleteTarget.id); + } + }} + /> +
+ ); +} + +function RoleBadge({ role }: { role: string }) { + const styles: Record = { + PROFESSOR: "bg-purple-100 text-purple-700", + TA: "bg-blue-100 text-blue-700", + STUDENT: "bg-stone-100 text-stone-600", + }; + return ( + + {role} + + ); +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..b2909b0 --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,29 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; + +import { getCurrentUser } from "@/lib/auth"; +import { isAdmin } from "@/lib/adminWhitelist"; + +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { + const user = await getCurrentUser(); + if (!user || !isAdmin(user.utorid)) { + redirect("/"); + } + + return ( +
+
+
+ + ← Back + +

Admin Dashboard

+
+ + {user.name} ({user.utorid}) + +
+
{children}
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..83bc29b --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { + Users, + BookOpen, + Radio, + MessageSquare, + MessageCircle, + UserCheck, + RefreshCw, +} from "lucide-react"; +import DeleteConfirmModal from "./components/DeleteConfirmModal"; + +import UsersTable from "./components/UsersTable"; +import CoursesTable from "./components/CoursesTable"; +import SessionsTable from "./components/SessionsTable"; +import QuestionsTable from "./components/QuestionsTable"; +import EnrollmentsTable from "./components/EnrollmentsTable"; +import SlideSetsTable from "./components/SlideSetsTable"; + +interface Stats { + users: number; + courses: number; + sessions: number; + activeSessions: number; + questions: number; + answers: number; + enrollments: number; +} + +export default function DashboardPage() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState("overview"); + const [refreshKey, setRefreshKey] = useState(0); + const [showSystemDelete, setShowSystemDelete] = useState(false); + + useEffect(() => { + let cancelled = false; + fetch("/api/admin/stats") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled && data) setStats(data); + }) + .catch(() => null) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [refreshKey]); + + const handleRefresh = () => { + setRefreshKey((k) => k + 1); + }; + + const statCards = [ + { label: "Total Users", value: stats?.users, icon: Users }, + { label: "Total Courses", value: stats?.courses, icon: BookOpen }, + { label: "Active Sessions", value: stats?.activeSessions, icon: Radio }, + { label: "Total Sessions", value: stats?.sessions, icon: Radio }, + { label: "Total Questions", value: stats?.questions, icon: MessageSquare }, + { label: "Total Answers", value: stats?.answers, icon: MessageCircle }, + { label: "Enrollments", value: stats?.enrollments, icon: UserCheck }, + ]; + + return ( +
+
+

Overview

+ +
+ +
+ {statCards.map((card) => ( + + +
+ + {card.label} +
+
+ + + {loading ? "—" : (card.value ?? 0)} + + +
+ ))} +
+ + + + Overview + Users + Courses + Sessions + Slide Sets + Questions + Enrollments + + + + + +

+ Select a tab above to manage data. Use the Refresh button to reload stats and table + data. +

+
+

+ Important Note on Deletions +

+

+ Deleting a record may cascade to other sections. For example, if you delete a + user, it will also delete their answers, questions, and enrollments across the + platform. Note that deleting a user only deletes a class if that user is the + professor who created it; deleting a student will not delete the class. Deleting a + course will delete all of its sessions, enrollments, and questions. Exercise + caution when deleting data. +

+
+ +
+

Danger Zone

+
+
+

Delete Everything

+

+ This action will delete absolutely all data in the database. This cannot be + undone. +

+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + setShowSystemDelete(false)} + title="Delete Entire Database" + description={ + <> + This action will permanently delete{" "} + + absolutely ALL users, courses, sessions, questions, enrollments, and answers + {" "} + in the database. This CANNOT be undone. + + } + requireTypeToConfirm="DELETE EVERYTHING" + confirmText="Delete Database" + onConfirm={async () => { + const res = await fetch("/api/admin/system/all", { method: "DELETE" }); + if (res.ok) { + handleRefresh(); + } else { + alert("Failed to clear database."); + } + }} + /> +
+ ); +} diff --git a/src/lib/adminAuth.ts b/src/lib/adminAuth.ts new file mode 100644 index 0000000..1679162 --- /dev/null +++ b/src/lib/adminAuth.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; + +import { getCurrentUser, type AuthUser } from "@/lib/auth"; +import { isAdmin } from "@/lib/adminWhitelist"; + +/** + * Checks that the current request is from an authenticated admin user. + * Returns the AuthUser if valid, or null if not authenticated / not admin. + */ +export async function requireAdmin(): Promise { + const user = await getCurrentUser(); + if (!user) return null; + if (!isAdmin(user.utorid)) return null; + return user; +} + +/** + * Convenience helper for API route handlers. + * Returns a NextResponse (401/403) if the user is not an admin, or null if OK. + * + * Usage: + * const user = await requireAdmin(); + * const guard = adminGuardResponse(user); + * if (guard) return guard; + */ +export function adminGuardResponse(user: AuthUser | null): NextResponse | null { + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + // If requireAdmin returned a user, they are already verified as admin. + return null; +} diff --git a/src/lib/adminWhitelist.ts b/src/lib/adminWhitelist.ts new file mode 100644 index 0000000..c314149 --- /dev/null +++ b/src/lib/adminWhitelist.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Admin whitelist +// +// Reads `admin_whitelist.txt` (or the path in ADMIN_WHITELIST_PATH env var) +// to determine which UTORids have god-mode dashboard access. +// One UTORid per line; comment lines start with #. +// +// File format (one entry per line): +// utorid +// # comment lines are ignored +// --------------------------------------------------------------------------- + +function loadAdminWhitelist(): Set { + const whitelistPath = resolve(process.env.ADMIN_WHITELIST_PATH ?? "./admin_whitelist.txt"); + + let contents: string; + try { + contents = readFileSync(whitelistPath, "utf-8"); + } catch { + console.warn( + `[admin-whitelist] Could not read admin whitelist at ${whitelistPath}. No users will have dashboard access.` + ); + return new Set(); + } + + const set = new Set(); + + for (const rawLine of contents.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const utorid = line.split(",")[0].trim().toLowerCase(); + if (utorid) set.add(utorid); + } + + return set; +} + +// Loaded once at startup (module-level cache). +// Restart the server to pick up changes to admin_whitelist.txt. +const ADMIN_WHITELIST: Set = loadAdminWhitelist(); + +/** + * Returns true if the UTORid is in the admin whitelist. + */ +export function isAdmin(utorid: string): boolean { + return ADMIN_WHITELIST.has(utorid.toLowerCase()); +} diff --git a/whitelist.txt b/whitelist.txt index 88cb692..59a11f3 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -7,4 +7,3 @@ # # ---- TEST ACCOUNTS ---- testprof -# testprof2