From d9370a5f3b7166f0fdf28660a22d5f42121741a9 Mon Sep 17 00:00:00 2001 From: Phineas Truong <56217067+phintruong@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:38:44 -0400 Subject: [PATCH 1/7] done --- admin_whitelist.txt | 9 + src/app/api/admin/answers/[answerId]/route.ts | 31 ++ src/app/api/admin/courses/[courseId]/route.ts | 43 +++ src/app/api/admin/courses/route.ts | 39 +++ .../admin/enrollments/[enrollmentId]/route.ts | 30 ++ src/app/api/admin/enrollments/route.ts | 57 ++++ .../api/admin/questions/[questionId]/route.ts | 34 +++ src/app/api/admin/questions/route.ts | 56 ++++ .../api/admin/sessions/[sessionId]/route.ts | 43 +++ src/app/api/admin/sessions/route.ts | 42 +++ src/app/api/admin/stats/route.ts | 36 +++ src/app/api/admin/users/[userId]/route.ts | 44 +++ src/app/api/admin/users/route.ts | 41 +++ src/app/dashboard/components/CoursesTable.tsx | 110 +++++++ .../dashboard/components/EnrollmentsTable.tsx | 160 +++++++++++ .../dashboard/components/QuestionsTable.tsx | 272 ++++++++++++++++++ .../dashboard/components/SessionsTable.tsx | 142 +++++++++ src/app/dashboard/components/UsersTable.tsx | 133 +++++++++ src/app/dashboard/layout.tsx | 32 +++ src/app/dashboard/page.tsx | 139 +++++++++ src/lib/adminAuth.ts | 32 +++ src/lib/adminWhitelist.ts | 51 ++++ 22 files changed, 1576 insertions(+) create mode 100644 admin_whitelist.txt create mode 100644 src/app/api/admin/answers/[answerId]/route.ts create mode 100644 src/app/api/admin/courses/[courseId]/route.ts create mode 100644 src/app/api/admin/courses/route.ts create mode 100644 src/app/api/admin/enrollments/[enrollmentId]/route.ts create mode 100644 src/app/api/admin/enrollments/route.ts create mode 100644 src/app/api/admin/questions/[questionId]/route.ts create mode 100644 src/app/api/admin/questions/route.ts create mode 100644 src/app/api/admin/sessions/[sessionId]/route.ts create mode 100644 src/app/api/admin/sessions/route.ts create mode 100644 src/app/api/admin/stats/route.ts create mode 100644 src/app/api/admin/users/[userId]/route.ts create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/dashboard/components/CoursesTable.tsx create mode 100644 src/app/dashboard/components/EnrollmentsTable.tsx create mode 100644 src/app/dashboard/components/QuestionsTable.tsx create mode 100644 src/app/dashboard/components/SessionsTable.tsx create mode 100644 src/app/dashboard/components/UsersTable.tsx create mode 100644 src/app/dashboard/layout.tsx create mode 100644 src/app/dashboard/page.tsx create mode 100644 src/lib/adminAuth.ts create mode 100644 src/lib/adminWhitelist.ts 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..7a6690d --- /dev/null +++ b/src/app/api/admin/courses/[courseId]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +interface RouteParams { + params: Promise<{ courseId: string }>; +} + +// --------------------------------------------------------------------------- +// DELETE /api/admin/courses/[courseId] +// +// TODO (Backend): Implement cascading delete in a $transaction. +// Reuse the pattern from src/app/api/courses/[courseId]/route.ts (lines 134-178) +// but skip the professor-owner check (admin bypasses ownership). +// +// Steps: +// 1. Gather session IDs for the course +// 2. Delete slide files from disk + SlideSet rows +// 3. Delete QuestionUpvote, Answer, Question for those sessions +// 4. Delete Sessions +// 5. Delete CourseEnrollment +// 6. Delete Course +// --------------------------------------------------------------------------- + +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; + + // TODO: Implement cascading course deletion (see instructions above) + console.warn(`[Admin Courses] DELETE course ${courseId} — not yet implemented`); + return NextResponse.json( + { error: "Course deletion not yet implemented. Assign to backend developer." }, + { status: 501 } + ); + } 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/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/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/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..3e56ed8 --- /dev/null +++ b/src/app/api/admin/sessions/[sessionId]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +interface RouteParams { + params: Promise<{ sessionId: string }>; +} + +// --------------------------------------------------------------------------- +// DELETE /api/admin/sessions/[sessionId] +// +// TODO (Backend): Implement cascading delete in a $transaction. +// +// Steps: +// 1. Get all question IDs for the session +// 2. Delete QuestionUpvote for those questions +// 3. Delete Answer for those questions (AnswerUpvote cascades via onDelete) +// 4. Delete Question rows +// 5. Delete slide files from disk + SlideSet rows +// 6. Delete the Session +// +// See src/app/api/courses/[courseId]/route.ts for the cascade pattern. +// --------------------------------------------------------------------------- + +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; + + // TODO: Implement cascading session deletion (see instructions above) + console.warn(`[Admin Sessions] DELETE session ${sessionId} — not yet implemented`); + return NextResponse.json( + { error: "Session deletion not yet implemented. Assign to backend developer." }, + { status: 501 } + ); + } 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/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/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/users/[userId]/route.ts b/src/app/api/admin/users/[userId]/route.ts new file mode 100644 index 0000000..a439068 --- /dev/null +++ b/src/app/api/admin/users/[userId]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth"; + +interface RouteParams { + params: Promise<{ userId: string }>; +} + +// --------------------------------------------------------------------------- +// DELETE /api/admin/users/[userId] +// +// TODO (Backend): Implement cascading delete in a $transaction. +// 1. Delete QuestionUpvote where userId +// 2. Delete AnswerUpvote where userId +// 3. Delete Answer where authorId +// 4. Delete Question where authorId +// 5. Delete CourseEnrollment where userId +// 6. Delete SlideSet where uploadedBy (+ delete files from disk) +// 7. Delete Session where createdById (+ cascade their Q&A) +// 8. Delete Course where createdById +// 9. Delete the User +// +// See src/app/api/courses/[courseId]/route.ts for the cascade pattern. +// --------------------------------------------------------------------------- + +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; + + // TODO: Implement cascading user deletion + console.warn(`[Admin Users] DELETE user ${userId} — not yet implemented`); + return NextResponse.json( + { error: "User deletion not yet implemented. Assign to backend developer." }, + { status: 501 } + ); + } 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/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/dashboard/components/CoursesTable.tsx b/src/app/dashboard/components/CoursesTable.tsx new file mode 100644 index 0000000..3b2a8d5 --- /dev/null +++ b/src/app/dashboard/components/CoursesTable.tsx @@ -0,0 +1,110 @@ +"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"; + +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 [loading, setLoading] = useState(true); + + const fetchCourses = useCallback(() => { + setLoading(true); + const params = new URLSearchParams(); + if (search) params.set("search", search); + fetch(`/api/admin/courses?${params}`) + .then((res) => (res.ok ? res.json() : { courses: [] })) + .then((data) => setCourses(data.courses ?? [])) + .catch(() => setCourses([])) + .finally(() => setLoading(false)); + }, [search]); + + useEffect(() => { + fetchCourses(); + }, [fetchCourses]); + + const handleDelete = async (courseId: string, code: string) => { + if (!window.confirm(`Delete course "${code}" and ALL its sessions, questions, and enrollments? This cannot be undone.`)) + return; + const res = await fetch(`/api/admin/courses/${courseId}`, { method: "DELETE" }); + if (res.ok) fetchCourses(); + else alert("Failed to delete course."); + }; + + return ( +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : 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} + +
+
+
+ ); +} diff --git a/src/app/dashboard/components/EnrollmentsTable.tsx b/src/app/dashboard/components/EnrollmentsTable.tsx new file mode 100644 index 0000000..e08d3ae --- /dev/null +++ b/src/app/dashboard/components/EnrollmentsTable.tsx @@ -0,0 +1,160 @@ +"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"; + +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 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 handleDelete = async (enrollmentId: string, userName: string, courseCode: string) => { + if (!window.confirm(`Remove ${userName} from ${courseCode}?`)) return; + 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."); + }; + + 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 && ( +
+ +
+ )} +
+ ); +} + +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..433c9e4 --- /dev/null +++ b/src/app/dashboard/components/QuestionsTable.tsx @@ -0,0 +1,272 @@ +"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"; + +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 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 handleDeleteQuestion = async (questionId: string) => { + if (!window.confirm("Delete this question and all its answers? This cannot be undone.")) return; + 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 handleDeleteAnswer = async (answerId: string, questionId: string) => { + if (!window.confirm("Delete this answer?")) return; + 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: 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 && ( +
+ +
+ )} +
+ ); +} + +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..8f37fa1 --- /dev/null +++ b/src/app/dashboard/components/SessionsTable.tsx @@ -0,0 +1,142 @@ +"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"; + +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([]); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [loading, setLoading] = useState(true); + + const fetchSessions = useCallback(() => { + setLoading(true); + 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) => setSessions(data.sessions ?? [])) + .catch(() => setSessions([])) + .finally(() => setLoading(false)); + }, [search, statusFilter]); + + useEffect(() => { + fetchSessions(); + }, [fetchSessions]); + + const handleDelete = async (sessionId: string, title: string) => { + if (!window.confirm(`Delete session "${title}" and ALL its questions, answers, and slides? This cannot be undone.`)) + return; + const res = await fetch(`/api/admin/sessions/${sessionId}`, { method: "DELETE" }); + if (res.ok) fetchSessions(); + else alert("Failed to delete session."); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : 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()} + + +
+
+
+ ); +} + +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/UsersTable.tsx b/src/app/dashboard/components/UsersTable.tsx new file mode 100644 index 0000000..8e20881 --- /dev/null +++ b/src/app/dashboard/components/UsersTable.tsx @@ -0,0 +1,133 @@ +"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"; + +interface User { + id: string; + utorid: string; + email: string; + name: string; + role: string; +} + +export default function UsersTable() { + const [users, setUsers] = useState([]); + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState(""); + const [loading, setLoading] = useState(true); + + const fetchUsers = useCallback(() => { + setLoading(true); + 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) => setUsers(data.users ?? [])) + .catch(() => setUsers([])) + .finally(() => setLoading(false)); + }, [search, roleFilter]); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + const handleDelete = async (userId: string, name: string) => { + if (!window.confirm(`Delete user "${name}" and ALL their data? This cannot be undone.`)) return; + const res = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" }); + if (res.ok) fetchUsers(); + else alert("Failed to delete user."); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ +
+ + + + + + + + + + + + {loading ? ( + + + + ) : users.length === 0 ? ( + + + + ) : ( + users.map((user) => ( + + + + + + + + )) + )} + +
NameUTORidEmailRoleActions
+ Loading… +
+ No users found. +
{user.name}{user.utorid}{user.email} + + + +
+
+
+ ); +} + +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..3654961 --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,32 @@ +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..bd2adc0 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import React, { useEffect, useState, useCallback } 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 UsersTable from "./components/UsersTable"; +import CoursesTable from "./components/CoursesTable"; +import SessionsTable from "./components/SessionsTable"; +import QuestionsTable from "./components/QuestionsTable"; +import EnrollmentsTable from "./components/EnrollmentsTable"; + +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 fetchStats = useCallback(() => { + setLoading(true); + fetch("/api/admin/stats") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data) setStats(data); + }) + .catch(() => null) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + const handleRefresh = () => { + fetchStats(); + 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 + Questions + Enrollments + + + + + +

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

+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ ); +} 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()); +} From 0795af0337db7e3872d82ddc957edcf75f00393d Mon Sep 17 00:00:00 2001 From: Phineas Truong <56217067+phintruong@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:33:58 -0400 Subject: [PATCH 2/7] lint --- src/app/dashboard/components/CoursesTable.tsx | 25 +++++++++------- .../dashboard/components/EnrollmentsTable.tsx | 11 +++++-- .../dashboard/components/QuestionsTable.tsx | 26 +++++++++++++---- .../dashboard/components/SessionsTable.tsx | 29 +++++++++++-------- src/app/dashboard/components/UsersTable.tsx | 23 ++++++++------- src/app/dashboard/layout.tsx | 5 +--- src/app/dashboard/page.tsx | 22 +++++++------- 7 files changed, 85 insertions(+), 56 deletions(-) diff --git a/src/app/dashboard/components/CoursesTable.tsx b/src/app/dashboard/components/CoursesTable.tsx index 3b2a8d5..158a1a2 100644 --- a/src/app/dashboard/components/CoursesTable.tsx +++ b/src/app/dashboard/components/CoursesTable.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +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"; @@ -19,26 +19,29 @@ export default function CoursesTable() { const [search, setSearch] = useState(""); const [loading, setLoading] = useState(true); - const fetchCourses = useCallback(() => { + const fetchRef = useRef(0); + + useEffect(() => { + const id = ++fetchRef.current; setLoading(true); const params = new URLSearchParams(); if (search) params.set("search", search); fetch(`/api/admin/courses?${params}`) .then((res) => (res.ok ? res.json() : { courses: [] })) - .then((data) => setCourses(data.courses ?? [])) - .catch(() => setCourses([])) - .finally(() => setLoading(false)); + .then((data) => { if (id === fetchRef.current) setCourses(data.courses ?? []); }) + .catch(() => { if (id === fetchRef.current) setCourses([]); }) + .finally(() => { if (id === fetchRef.current) setLoading(false); }); }, [search]); - useEffect(() => { - fetchCourses(); - }, [fetchCourses]); - const handleDelete = async (courseId: string, code: string) => { - if (!window.confirm(`Delete course "${code}" and ALL its sessions, questions, and enrollments? This cannot be undone.`)) + if ( + !window.confirm( + `Delete course "${code}" and ALL its sessions, questions, and enrollments? This cannot be undone.` + ) + ) return; const res = await fetch(`/api/admin/courses/${courseId}`, { method: "DELETE" }); - if (res.ok) fetchCourses(); + if (res.ok) { fetchRef.current++; setCourses((prev) => prev.filter((c) => c.id !== courseId)); } else alert("Failed to delete course."); }; diff --git a/src/app/dashboard/components/EnrollmentsTable.tsx b/src/app/dashboard/components/EnrollmentsTable.tsx index e08d3ae..6bc97ff 100644 --- a/src/app/dashboard/components/EnrollmentsTable.tsx +++ b/src/app/dashboard/components/EnrollmentsTable.tsx @@ -137,7 +137,12 @@ export default function EnrollmentsTable() { {hasMore && (
-
@@ -153,7 +158,9 @@ function RoleBadge({ role }: { role: string }) { 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 index 433c9e4..4f4f51b 100644 --- a/src/app/dashboard/components/QuestionsTable.tsx +++ b/src/app/dashboard/components/QuestionsTable.tsx @@ -167,7 +167,10 @@ export default function QuestionsTable() { {q._count.answers > 0 && ( - @@ -265,7 +279,9 @@ function QuestionStatusBadge({ status }: { status: string }) { 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 index 8f37fa1..5617045 100644 --- a/src/app/dashboard/components/SessionsTable.tsx +++ b/src/app/dashboard/components/SessionsTable.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +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"; @@ -21,27 +21,30 @@ export default function SessionsTable() { const [statusFilter, setStatusFilter] = useState(""); const [loading, setLoading] = useState(true); - const fetchSessions = useCallback(() => { + const fetchRef = useRef(0); + + useEffect(() => { + const id = ++fetchRef.current; setLoading(true); 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) => setSessions(data.sessions ?? [])) - .catch(() => setSessions([])) - .finally(() => setLoading(false)); + .then((data) => { if (id === fetchRef.current) setSessions(data.sessions ?? []); }) + .catch(() => { if (id === fetchRef.current) setSessions([]); }) + .finally(() => { if (id === fetchRef.current) setLoading(false); }); }, [search, statusFilter]); - useEffect(() => { - fetchSessions(); - }, [fetchSessions]); - const handleDelete = async (sessionId: string, title: string) => { - if (!window.confirm(`Delete session "${title}" and ALL its questions, answers, and slides? This cannot be undone.`)) + if ( + !window.confirm( + `Delete session "${title}" and ALL its questions, answers, and slides? This cannot be undone.` + ) + ) return; const res = await fetch(`/api/admin/sessions/${sessionId}`, { method: "DELETE" }); - if (res.ok) fetchSessions(); + if (res.ok) { fetchRef.current++; setSessions((prev) => prev.filter((s) => s.id !== sessionId)); } else alert("Failed to delete session."); }; @@ -135,7 +138,9 @@ function StatusBadge({ status }: { status: string }) { ENDED: "bg-stone-100 text-stone-600", }; return ( - + {status} ); diff --git a/src/app/dashboard/components/UsersTable.tsx b/src/app/dashboard/components/UsersTable.tsx index 8e20881..e469c5b 100644 --- a/src/app/dashboard/components/UsersTable.tsx +++ b/src/app/dashboard/components/UsersTable.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +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"; @@ -19,26 +19,25 @@ export default function UsersTable() { const [roleFilter, setRoleFilter] = useState(""); const [loading, setLoading] = useState(true); - const fetchUsers = useCallback(() => { + const fetchRef = useRef(0); + + useEffect(() => { + const id = ++fetchRef.current; setLoading(true); 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) => setUsers(data.users ?? [])) - .catch(() => setUsers([])) - .finally(() => setLoading(false)); + .then((data) => { if (id === fetchRef.current) setUsers(data.users ?? []); }) + .catch(() => { if (id === fetchRef.current) setUsers([]); }) + .finally(() => { if (id === fetchRef.current) setLoading(false); }); }, [search, roleFilter]); - useEffect(() => { - fetchUsers(); - }, [fetchUsers]); - const handleDelete = async (userId: string, name: string) => { if (!window.confirm(`Delete user "${name}" and ALL their data? This cannot be undone.`)) return; const res = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" }); - if (res.ok) fetchUsers(); + if (res.ok) { fetchRef.current++; setUsers((prev) => prev.filter((u) => u.id !== userId)); } else alert("Failed to delete user."); }; @@ -126,7 +125,9 @@ function RoleBadge({ role }: { role: string }) { STUDENT: "bg-stone-100 text-stone-600", }; return ( - + {role} ); diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 3654961..b2909b0 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -14,10 +14,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
- + ← Back

Admin Dashboard

diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index bd2adc0..27686f0 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +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"; @@ -36,23 +36,22 @@ export default function DashboardPage() { const [activeTab, setActiveTab] = useState("overview"); const [refreshKey, setRefreshKey] = useState(0); - const fetchStats = useCallback(() => { + useEffect(() => { + let cancelled = false; setLoading(true); fetch("/api/admin/stats") .then((res) => (res.ok ? res.json() : null)) .then((data) => { - if (data) setStats(data); + if (!cancelled && data) setStats(data); }) .catch(() => null) - .finally(() => setLoading(false)); - }, []); - - useEffect(() => { - fetchStats(); - }, [fetchStats]); + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [refreshKey]); const handleRefresh = () => { - fetchStats(); setRefreshKey((k) => k + 1); }; @@ -108,7 +107,8 @@ export default function DashboardPage() {

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

From 07b7452193261816b6879fccf84f8897855d950c Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Wed, 8 Apr 2026 22:42:52 -0400 Subject: [PATCH 3/7] fix: remove linting error with setloading by removing loading state --- src/app/dashboard/components/CoursesTable.tsx | 19 ++++++++++------- .../dashboard/components/SessionsTable.tsx | 21 +++++++++++-------- src/app/dashboard/components/UsersTable.tsx | 21 +++++++++++-------- src/app/dashboard/page.tsx | 5 +++-- 4 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/app/dashboard/components/CoursesTable.tsx b/src/app/dashboard/components/CoursesTable.tsx index 158a1a2..539363a 100644 --- a/src/app/dashboard/components/CoursesTable.tsx +++ b/src/app/dashboard/components/CoursesTable.tsx @@ -17,20 +17,21 @@ interface Course { export default function CoursesTable() { const [courses, setCourses] = useState([]); const [search, setSearch] = useState(""); - const [loading, setLoading] = useState(true); const fetchRef = useRef(0); useEffect(() => { const id = ++fetchRef.current; - setLoading(true); 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([]); }) - .finally(() => { if (id === fetchRef.current) setLoading(false); }); + .then((data) => { + if (id === fetchRef.current) setCourses(data.courses ?? []); + }) + .catch(() => { + if (id === fetchRef.current) setCourses([]); + }); }, [search]); const handleDelete = async (courseId: string, code: string) => { @@ -41,8 +42,10 @@ export default function CoursesTable() { ) return; 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."); + if (res.ok) { + fetchRef.current++; + setCourses((prev) => prev.filter((c) => c.id !== courseId)); + } else alert("Failed to delete course."); }; return ( @@ -71,7 +74,7 @@ export default function CoursesTable() { - {loading ? ( + {courses === null ? ( Loading… diff --git a/src/app/dashboard/components/SessionsTable.tsx b/src/app/dashboard/components/SessionsTable.tsx index 5617045..4979840 100644 --- a/src/app/dashboard/components/SessionsTable.tsx +++ b/src/app/dashboard/components/SessionsTable.tsx @@ -16,24 +16,25 @@ interface Session { } export default function SessionsTable() { - const [sessions, setSessions] = useState([]); + const [sessions, setSessions] = useState(null); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState(""); - const [loading, setLoading] = useState(true); const fetchRef = useRef(0); useEffect(() => { const id = ++fetchRef.current; - setLoading(true); 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([]); }) - .finally(() => { if (id === fetchRef.current) setLoading(false); }); + .then((data) => { + if (id === fetchRef.current) setSessions(data.sessions ?? []); + }) + .catch(() => { + if (id === fetchRef.current) setSessions([]); + }); }, [search, statusFilter]); const handleDelete = async (sessionId: string, title: string) => { @@ -44,8 +45,10 @@ export default function SessionsTable() { ) return; const res = await fetch(`/api/admin/sessions/${sessionId}`, { method: "DELETE" }); - if (res.ok) { fetchRef.current++; setSessions((prev) => prev.filter((s) => s.id !== sessionId)); } - else alert("Failed to delete session."); + if (res.ok) { + fetchRef.current++; + setSessions((prev) => (prev ? prev.filter((s) => s.id !== sessionId) : prev)); + } else alert("Failed to delete session."); }; return ( @@ -86,7 +89,7 @@ export default function SessionsTable() { - {loading ? ( + {sessions === null ? ( Loading… diff --git a/src/app/dashboard/components/UsersTable.tsx b/src/app/dashboard/components/UsersTable.tsx index e469c5b..4d77c45 100644 --- a/src/app/dashboard/components/UsersTable.tsx +++ b/src/app/dashboard/components/UsersTable.tsx @@ -14,31 +14,34 @@ interface User { } export default function UsersTable() { - const [users, setUsers] = useState([]); + const [users, setUsers] = useState(null); const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState(""); - const [loading, setLoading] = useState(true); const fetchRef = useRef(0); useEffect(() => { const id = ++fetchRef.current; - setLoading(true); 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([]); }) - .finally(() => { if (id === fetchRef.current) setLoading(false); }); + .then((data) => { + if (id === fetchRef.current) setUsers(data.users ?? []); + }) + .catch(() => { + if (id === fetchRef.current) setUsers([]); + }); }, [search, roleFilter]); const handleDelete = async (userId: string, name: string) => { if (!window.confirm(`Delete user "${name}" and ALL their data? This cannot be undone.`)) return; const res = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" }); - if (res.ok) { fetchRef.current++; setUsers((prev) => prev.filter((u) => u.id !== userId)); } - else alert("Failed to delete user."); + if (res.ok) { + fetchRef.current++; + setUsers((prev) => (prev ? prev.filter((u) => u.id !== userId) : prev)); + } else alert("Failed to delete user."); }; return ( @@ -77,7 +80,7 @@ export default function UsersTable() { - {loading ? ( + {users === null ? ( Loading… diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 27686f0..c0d9498 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -38,7 +38,6 @@ export default function DashboardPage() { useEffect(() => { let cancelled = false; - setLoading(true); fetch("/api/admin/stats") .then((res) => (res.ok ? res.json() : null)) .then((data) => { @@ -48,7 +47,9 @@ export default function DashboardPage() { .finally(() => { if (!cancelled) setLoading(false); }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [refreshKey]); const handleRefresh = () => { From f23e4b9ab48dc955a1cd6b8db7ffd297ff6a9b7a Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Wed, 8 Apr 2026 23:10:09 -0400 Subject: [PATCH 4/7] fix: add whitelists to dockerfile to ensure it can used during auth --- Dockerfile | 4 ++++ whitelist.txt | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) 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/whitelist.txt b/whitelist.txt index 88cb692..59a11f3 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -7,4 +7,3 @@ # # ---- TEST ACCOUNTS ---- testprof -# testprof2 From 9166989812feb5a1c6dbc4aafe9d2b79c0c71dd6 Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Wed, 8 Apr 2026 23:50:49 -0400 Subject: [PATCH 5/7] feat: implement backend deleting logic --- src/app/api/admin/courses/[courseId]/route.ts | 72 +++++++---- .../api/admin/sessions/[sessionId]/route.ts | 61 +++++---- src/app/api/admin/users/[userId]/route.ts | 122 ++++++++++++++---- 3 files changed, 188 insertions(+), 67 deletions(-) diff --git a/src/app/api/admin/courses/[courseId]/route.ts b/src/app/api/admin/courses/[courseId]/route.ts index 7a6690d..8627e23 100644 --- a/src/app/api/admin/courses/[courseId]/route.ts +++ b/src/app/api/admin/courses/[courseId]/route.ts @@ -1,27 +1,13 @@ 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 }>; } -// --------------------------------------------------------------------------- -// DELETE /api/admin/courses/[courseId] -// -// TODO (Backend): Implement cascading delete in a $transaction. -// Reuse the pattern from src/app/api/courses/[courseId]/route.ts (lines 134-178) -// but skip the professor-owner check (admin bypasses ownership). -// -// Steps: -// 1. Gather session IDs for the course -// 2. Delete slide files from disk + SlideSet rows -// 3. Delete QuestionUpvote, Answer, Question for those sessions -// 4. Delete Sessions -// 5. Delete CourseEnrollment -// 6. Delete Course -// --------------------------------------------------------------------------- - export async function DELETE(_request: NextRequest, { params }: RouteParams) { try { const user = await requireAdmin(); @@ -30,12 +16,54 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) { const { courseId } = await params; - // TODO: Implement cascading course deletion (see instructions above) - console.warn(`[Admin Courses] DELETE course ${courseId} — not yet implemented`); - return NextResponse.json( - { error: "Course deletion not yet implemented. Assign to backend developer." }, - { status: 501 } - ); + // 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/sessions/[sessionId]/route.ts b/src/app/api/admin/sessions/[sessionId]/route.ts index 3e56ed8..7034946 100644 --- a/src/app/api/admin/sessions/[sessionId]/route.ts +++ b/src/app/api/admin/sessions/[sessionId]/route.ts @@ -1,27 +1,13 @@ 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 }>; } -// --------------------------------------------------------------------------- -// DELETE /api/admin/sessions/[sessionId] -// -// TODO (Backend): Implement cascading delete in a $transaction. -// -// Steps: -// 1. Get all question IDs for the session -// 2. Delete QuestionUpvote for those questions -// 3. Delete Answer for those questions (AnswerUpvote cascades via onDelete) -// 4. Delete Question rows -// 5. Delete slide files from disk + SlideSet rows -// 6. Delete the Session -// -// See src/app/api/courses/[courseId]/route.ts for the cascade pattern. -// --------------------------------------------------------------------------- - export async function DELETE(_request: NextRequest, { params }: RouteParams) { try { const user = await requireAdmin(); @@ -30,12 +16,43 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) { const { sessionId } = await params; - // TODO: Implement cascading session deletion (see instructions above) - console.warn(`[Admin Sessions] DELETE session ${sessionId} — not yet implemented`); - return NextResponse.json( - { error: "Session deletion not yet implemented. Assign to backend developer." }, - { status: 501 } - ); + 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/users/[userId]/route.ts b/src/app/api/admin/users/[userId]/route.ts index a439068..590ea66 100644 --- a/src/app/api/admin/users/[userId]/route.ts +++ b/src/app/api/admin/users/[userId]/route.ts @@ -1,28 +1,13 @@ 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 }>; } -// --------------------------------------------------------------------------- -// DELETE /api/admin/users/[userId] -// -// TODO (Backend): Implement cascading delete in a $transaction. -// 1. Delete QuestionUpvote where userId -// 2. Delete AnswerUpvote where userId -// 3. Delete Answer where authorId -// 4. Delete Question where authorId -// 5. Delete CourseEnrollment where userId -// 6. Delete SlideSet where uploadedBy (+ delete files from disk) -// 7. Delete Session where createdById (+ cascade their Q&A) -// 8. Delete Course where createdById -// 9. Delete the User -// -// See src/app/api/courses/[courseId]/route.ts for the cascade pattern. -// --------------------------------------------------------------------------- - export async function DELETE(_request: NextRequest, { params }: RouteParams) { try { const user = await requireAdmin(); @@ -31,12 +16,103 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) { const { userId } = await params; - // TODO: Implement cascading user deletion - console.warn(`[Admin Users] DELETE user ${userId} — not yet implemented`); - return NextResponse.json( - { error: "User deletion not yet implemented. Assign to backend developer." }, - { status: 501 } - ); + 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 }); From f149f28c97dcfbc5330a9a1da7481e116df1ef36 Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Thu, 9 Apr 2026 00:15:18 -0400 Subject: [PATCH 6/7] feat: delete all and delete UI dash upgrade --- src/app/api/admin/courses/all/route.ts | 27 ++++ src/app/api/admin/enrollments/all/route.ts | 18 +++ src/app/api/admin/questions/all/route.ts | 23 ++++ src/app/api/admin/sessions/all/route.ts | 25 ++++ src/app/api/admin/system/all/route.ts | 28 ++++ src/app/api/admin/users/all/route.ts | 28 ++++ src/app/api/courses/route.ts | 13 ++ src/app/dashboard/components/CoursesTable.tsx | 76 ++++++++--- .../components/DeleteConfirmModal.tsx | 122 ++++++++++++++++++ .../dashboard/components/EnrollmentsTable.tsx | 56 +++++++- .../dashboard/components/QuestionsTable.tsx | 102 ++++++++++++++- .../dashboard/components/SessionsTable.tsx | 58 +++++++-- src/app/dashboard/components/UsersTable.tsx | 53 +++++++- src/app/dashboard/page.tsx | 58 ++++++++- 14 files changed, 649 insertions(+), 38 deletions(-) create mode 100644 src/app/api/admin/courses/all/route.ts create mode 100644 src/app/api/admin/enrollments/all/route.ts create mode 100644 src/app/api/admin/questions/all/route.ts create mode 100644 src/app/api/admin/sessions/all/route.ts create mode 100644 src/app/api/admin/system/all/route.ts create mode 100644 src/app/api/admin/users/all/route.ts create mode 100644 src/app/dashboard/components/DeleteConfirmModal.tsx 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/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/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/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/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/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/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 index 539363a..017972b 100644 --- a/src/app/dashboard/components/CoursesTable.tsx +++ b/src/app/dashboard/components/CoursesTable.tsx @@ -4,6 +4,7 @@ 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; @@ -17,6 +18,11 @@ interface Course { 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); @@ -34,13 +40,7 @@ export default function CoursesTable() { }); }, [search]); - const handleDelete = async (courseId: string, code: string) => { - if ( - !window.confirm( - `Delete course "${code}" and ALL its sessions, questions, and enrollments? This cannot be undone.` - ) - ) - return; + const confirmDeleteSingle = async (courseId: string) => { const res = await fetch(`/api/admin/courses/${courseId}`, { method: "DELETE" }); if (res.ok) { fetchRef.current++; @@ -48,16 +48,29 @@ export default function CoursesTable() { } 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" - /> +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
@@ -99,7 +112,9 @@ export default function CoursesTable() {
+ + 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 index 6bc97ff..8761f0e 100644 --- a/src/app/dashboard/components/EnrollmentsTable.tsx +++ b/src/app/dashboard/components/EnrollmentsTable.tsx @@ -4,6 +4,7 @@ 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; @@ -19,6 +20,12 @@ export default function EnrollmentsTable() { 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) => { @@ -51,13 +58,19 @@ export default function EnrollmentsTable() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [search, roleFilter]); - const handleDelete = async (enrollmentId: string, userName: string, courseCode: string) => { - if (!window.confirm(`Remove ${userName} from ${courseCode}?`)) return; + 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 (
@@ -80,6 +93,9 @@ export default function EnrollmentsTable() { +
@@ -122,7 +138,14 @@ export default function EnrollmentsTable() {
)} + + 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); + } + }} + />
); } diff --git a/src/app/dashboard/components/QuestionsTable.tsx b/src/app/dashboard/components/QuestionsTable.tsx index 4f4f51b..0bd390b 100644 --- a/src/app/dashboard/components/QuestionsTable.tsx +++ b/src/app/dashboard/components/QuestionsTable.tsx @@ -4,6 +4,7 @@ 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; @@ -38,6 +39,12 @@ export default function QuestionsTable() { 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) => { @@ -70,8 +77,7 @@ export default function QuestionsTable() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [search, statusFilter]); - const handleDeleteQuestion = async (questionId: string) => { - if (!window.confirm("Delete this question and all its answers? This cannot be undone.")) return; + 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)); @@ -79,8 +85,16 @@ export default function QuestionsTable() { } else alert("Failed to delete question."); }; - const handleDeleteAnswer = async (answerId: string, questionId: string) => { - if (!window.confirm("Delete this answer?")) return; + 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) => ({ @@ -89,7 +103,7 @@ export default function QuestionsTable() { })); setQuestions((prev) => prev.map((q) => - q.id === questionId ? { ...q, _count: { answers: q._count.answers - 1 } } : q + q.id === questionId ? { ...q, _count: { answers: Math.max(0, q._count.answers - 1) } } : q ) ); } else alert("Failed to delete answer."); @@ -132,6 +146,9 @@ export default function QuestionsTable() { +
@@ -202,7 +219,9 @@ export default function QuestionsTable() {
)} + + 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); + } + }} + />
); } diff --git a/src/app/dashboard/components/SessionsTable.tsx b/src/app/dashboard/components/SessionsTable.tsx index 4979840..a0a94bd 100644 --- a/src/app/dashboard/components/SessionsTable.tsx +++ b/src/app/dashboard/components/SessionsTable.tsx @@ -4,6 +4,7 @@ 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; @@ -19,6 +20,11 @@ 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); @@ -37,13 +43,7 @@ export default function SessionsTable() { }); }, [search, statusFilter]); - const handleDelete = async (sessionId: string, title: string) => { - if ( - !window.confirm( - `Delete session "${title}" and ALL its questions, answers, and slides? This cannot be undone.` - ) - ) - return; + const confirmDeleteSingle = async (sessionId: string) => { const res = await fetch(`/api/admin/sessions/${sessionId}`, { method: "DELETE" }); if (res.ok) { fetchRef.current++; @@ -51,6 +51,14 @@ export default function SessionsTable() { } 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 (
@@ -73,6 +81,9 @@ export default function SessionsTable() { +
@@ -118,7 +129,9 @@ export default function SessionsTable() {
+ + 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); + } + }} + />
); } diff --git a/src/app/dashboard/components/UsersTable.tsx b/src/app/dashboard/components/UsersTable.tsx index 4d77c45..61c6411 100644 --- a/src/app/dashboard/components/UsersTable.tsx +++ b/src/app/dashboard/components/UsersTable.tsx @@ -4,6 +4,7 @@ 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; @@ -17,6 +18,11 @@ 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); @@ -35,8 +41,7 @@ export default function UsersTable() { }); }, [search, roleFilter]); - const handleDelete = async (userId: string, name: string) => { - if (!window.confirm(`Delete user "${name}" and ALL their data? This cannot be undone.`)) return; + const confirmDeleteSingle = async (userId: string) => { const res = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" }); if (res.ok) { fetchRef.current++; @@ -44,6 +49,14 @@ export default function UsersTable() { } 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 (
@@ -66,6 +79,9 @@ export default function UsersTable() { +
@@ -105,7 +121,9 @@ export default function UsersTable() {
+ + 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); + } + }} + />
); } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index c0d9498..7eb58bb 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -13,6 +13,7 @@ import { UserCheck, RefreshCw, } from "lucide-react"; +import DeleteConfirmModal from "./components/DeleteConfirmModal"; import UsersTable from "./components/UsersTable"; import CoursesTable from "./components/CoursesTable"; @@ -35,6 +36,7 @@ export default function DashboardPage() { const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState("overview"); const [refreshKey, setRefreshKey] = useState(0); + const [showSystemDelete, setShowSystemDelete] = useState(false); useEffect(() => { let cancelled = false; @@ -106,11 +108,40 @@ export default function DashboardPage() { - +

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. +

+
+ +
+
@@ -135,6 +166,31 @@ export default function DashboardPage() { + + 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."); + } + }} + />
); } From bb50239e13aeaa57bdb572f3e3df53ac00cbc58c Mon Sep 17 00:00:00 2001 From: Jaden Scali Date: Thu, 9 Apr 2026 00:24:46 -0400 Subject: [PATCH 7/7] feat: add slideset to dashboard --- .../api/admin/slidesets/[slideSetId]/route.ts | 32 +++ src/app/api/admin/slidesets/all/route.ts | 30 +++ src/app/api/admin/slidesets/route.ts | 44 ++++ .../dashboard/components/SlideSetsTable.tsx | 210 ++++++++++++++++++ src/app/dashboard/page.tsx | 6 + 5 files changed, 322 insertions(+) create mode 100644 src/app/api/admin/slidesets/[slideSetId]/route.ts create mode 100644 src/app/api/admin/slidesets/all/route.ts create mode 100644 src/app/api/admin/slidesets/route.ts create mode 100644 src/app/dashboard/components/SlideSetsTable.tsx 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/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/page.tsx b/src/app/dashboard/page.tsx index 7eb58bb..83bc29b 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -20,6 +20,7 @@ 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; @@ -102,6 +103,7 @@ export default function DashboardPage() { Users Courses Sessions + Slide Sets Questions Enrollments @@ -158,6 +160,10 @@ export default function DashboardPage() { + + + +