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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions admin_whitelist.txt
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions src/app/api/admin/answers/[answerId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
71 changes: 71 additions & 0 deletions src/app/api/admin/courses/[courseId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";

import { requireAdmin, adminGuardResponse } from "@/lib/adminAuth";
import { prisma } from "@/lib/prisma";
import { deleteFile } from "@/lib/storage";

interface RouteParams {
params: Promise<{ courseId: string }>;
}

export async function DELETE(_request: NextRequest, { params }: RouteParams) {
try {
const user = await requireAdmin();
const guard = adminGuardResponse(user);
if (guard) return guard;

const { courseId } = await params;

// Gather all session IDs for this course
const sessions = await prisma.session.findMany({
where: { courseId },
select: { id: true },
});
const sessionIds = sessions.map((s) => s.id);

await prisma.$transaction(async (tx) => {
if (sessionIds.length > 0) {
// Collect slide storage keys before deleting
const slideSets = await tx.slideSet.findMany({
where: { sessionId: { in: sessionIds } },
select: { storageKey: true },
});

// Delete slide files from disk
await Promise.allSettled(
slideSets.map((ss) =>
deleteFile(ss.storageKey).catch((err) =>
console.error("[Admin Courses API] Failed to delete slide file:", ss.storageKey, err)
)
)
);

// Get question IDs to remove upvotes and answers
const questions = await tx.question.findMany({
where: { sessionId: { in: sessionIds } },
select: { id: true },
});
const questionIds = questions.map((q) => q.id);

if (questionIds.length > 0) {
await tx.questionUpvote.deleteMany({ where: { questionId: { in: questionIds } } });
await tx.answer.deleteMany({ where: { questionId: { in: questionIds } } });
await tx.question.deleteMany({ where: { id: { in: questionIds } } });
}

// SlideSet
await tx.slideSet.deleteMany({ where: { sessionId: { in: sessionIds } } });

await tx.session.deleteMany({ where: { courseId } });
}

await tx.courseEnrollment.deleteMany({ where: { courseId } });
await tx.course.delete({ where: { id: courseId } });
});

return NextResponse.json({ success: true });
} catch (error) {
console.error("[Admin Courses] Failed to delete course:", error);
return NextResponse.json({ error: "An error occurred." }, { status: 500 });
}
}
27 changes: 27 additions & 0 deletions src/app/api/admin/courses/all/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
39 changes: 39 additions & 0 deletions src/app/api/admin/courses/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
30 changes: 30 additions & 0 deletions src/app/api/admin/enrollments/[enrollmentId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
18 changes: 18 additions & 0 deletions src/app/api/admin/enrollments/all/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
57 changes: 57 additions & 0 deletions src/app/api/admin/enrollments/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
34 changes: 34 additions & 0 deletions src/app/api/admin/questions/[questionId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
23 changes: 23 additions & 0 deletions src/app/api/admin/questions/all/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading
Loading