From 5fd492603f9dc51f26334ec5a526010461d13dd1 Mon Sep 17 00:00:00 2001
From: hxddad
Date: Fri, 27 Mar 2026 10:08:00 -0400
Subject: [PATCH 1/7] refactor: centralize auth and authorization checks across
routes
---
app/api/assignments/[id]/route.ts | 40 +++++++-------
app/api/assignments/route.ts | 66 +++++++++++-------------
app/api/courses/[id]/route.ts | 37 ++++++-------
app/api/courses/enrolled/route.ts | 20 ++-----
app/api/courses/mine/route.ts | 24 +++------
app/api/courses/route.test.ts | 31 +++++++++--
app/api/courses/route.ts | 18 +++----
app/api/enrollments/route.ts | 23 +++------
app/api/messages/route.ts | 8 +--
app/api/messages/users/route.ts | 4 +-
app/api/study-plans/route.ts | 31 ++++++-----
app/api/submissions/[id]/review/route.ts | 36 ++++++-------
app/api/submissions/route.ts | 49 ++++++++----------
app/api/tasks/[id]/route.ts | 29 ++++-------
lib/api-auth.test.ts | 40 ++++++++++++--
lib/api-auth.ts | 46 +++++++++++++----
16 files changed, 258 insertions(+), 244 deletions(-)
diff --git a/app/api/assignments/[id]/route.ts b/app/api/assignments/[id]/route.ts
index a316ef2..2894a05 100644
--- a/app/api/assignments/[id]/route.ts
+++ b/app/api/assignments/[id]/route.ts
@@ -1,16 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { verifyToken } from "@/lib/jwt";
-
-// get token from cookie or authorization header
-function getToken(request: Request): string | null {
- const authHeader = request.headers.get("authorization") || "";
- if (authHeader.startsWith("Bearer ")) return authHeader.slice(7);
- const cookie = request.headers.get("cookie") || "";
- const match = /authToken=([^;]+)/.exec(cookie);
- if (match) return decodeURIComponent(match[1]);
- return null;
-}
+import { requireAuthenticatedUser, requireResourceOwner } from "@/lib/api-auth";
// GET /api/assignments/[id]
// returns assignment details + submissions (students see only theirs, tutors see all)
@@ -19,11 +9,8 @@ export async function GET(
{ params }: { params: { id: string } }
) {
try {
- const token = getToken(request);
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
const id = Number(params.id);
if (Number.isNaN(id)) {
@@ -32,7 +19,7 @@ export async function GET(
// fetch the assignment - include different stuff depending on role
let assignment;
- if (payload.role === "TUTOR") {
+ if (auth.role === "TUTOR") {
assignment = await prisma.assignment.findUnique({
where: { id },
include: {
@@ -52,7 +39,7 @@ export async function GET(
include: {
course: { select: { id: true, title: true, subject: true, tutorId: true } },
submissions: {
- where: { studentId: payload.sub },
+ where: { studentId: auth.sub },
},
},
});
@@ -63,10 +50,10 @@ export async function GET(
}
// check access - student must be enrolled
- if (payload.role === "STUDENT") {
+ if (auth.role === "STUDENT") {
const enrollment = await prisma.enrollment.findUnique({
where: {
- studentId_courseId: { studentId: payload.sub, courseId: assignment.courseId } as any,
+ studentId_courseId: { studentId: auth.sub, courseId: assignment.courseId } as any,
},
});
if (!enrollment || enrollment.status !== "ACTIVE") {
@@ -75,8 +62,17 @@ export async function GET(
}
// tutor must own the course
- if (payload.role === "TUTOR" && assignment.course.tutorId !== payload.sub) {
- return NextResponse.json({ error: "You do not own this course" }, { status: 403 });
+ if (auth.role === "TUTOR") {
+ const ownership = requireResourceOwner({
+ ownerId: assignment.course.tutorId,
+ userId: auth.sub,
+ errorMessage: "You do not own this course",
+ });
+ if (ownership instanceof Response) return ownership;
+ }
+
+ if (auth.role !== "STUDENT" && auth.role !== "TUTOR") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return NextResponse.json(assignment);
diff --git a/app/api/assignments/route.ts b/app/api/assignments/route.ts
index 78162e2..75278fa 100644
--- a/app/api/assignments/route.ts
+++ b/app/api/assignments/route.ts
@@ -1,26 +1,17 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { verifyToken } from "@/lib/jwt";
-
-// helper to grab the token from header or cookie (same pattern as enrollments route)
-function getToken(request: Request): string | null {
- const authHeader = request.headers.get("authorization") || "";
- if (authHeader.startsWith("Bearer ")) return authHeader.slice(7);
- const cookie = request.headers.get("cookie") || "";
- const match = /authToken=([^;]+)/.exec(cookie);
- if (match) return decodeURIComponent(match[1]);
- return null;
-}
+import {
+ requireAuthenticatedUser,
+ requireResourceOwner,
+ requireTutorRole,
+} from "@/lib/api-auth";
// GET /api/assignments?courseId=___
// fetches all assignments for a specific course
export async function GET(request: Request) {
try {
- const token = getToken(request);
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
const { searchParams } = new URL(request.url);
const courseId = Number(searchParams.get("courseId") || 0);
@@ -29,9 +20,9 @@ export async function GET(request: Request) {
}
// if student, make sure theyre actually enrolled
- if (payload.role === "STUDENT") {
+ if (auth.role === "STUDENT") {
const enrollment = await prisma.enrollment.findUnique({
- where: { studentId_courseId: { studentId: payload.sub, courseId } as any },
+ where: { studentId_courseId: { studentId: auth.sub, courseId } as any },
});
if (!enrollment || enrollment.status !== "ACTIVE") {
return NextResponse.json({ error: "You are not enrolled in this course" }, { status: 403 });
@@ -39,11 +30,18 @@ export async function GET(request: Request) {
}
// if tutor, check they own the course
- if (payload.role === "TUTOR") {
+ if (auth.role === "TUTOR") {
const course = await prisma.course.findUnique({ where: { id: courseId } });
- if (!course || course.tutorId !== payload.sub) {
- return NextResponse.json({ error: "You do not own this course" }, { status: 403 });
- }
+ const ownership = requireResourceOwner({
+ ownerId: course?.tutorId,
+ userId: auth.sub,
+ errorMessage: "You do not own this course",
+ });
+ if (ownership instanceof Response) return ownership;
+ }
+
+ if (auth.role !== "STUDENT" && auth.role !== "TUTOR") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const assignments = await prisma.assignment.findMany({
@@ -52,7 +50,7 @@ export async function GET(request: Request) {
include: {
course: { select: { id: true, title: true, subject: true } },
_count: { select: { submissions: true } },
- },
+ }
});
return NextResponse.json(assignments);
@@ -66,15 +64,10 @@ export async function GET(request: Request) {
// body: { courseId, title, description?, dueDate? }
export async function POST(request: Request) {
try {
- const token = getToken(request);
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
-
- if (payload.role !== "TUTOR") {
- return NextResponse.json({ error: "Only tutors can create assignments" }, { status: 403 });
- }
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
+ const tutor = requireTutorRole(auth, "Only tutors can create assignments");
+ if (tutor instanceof Response) return tutor;
const body = await request.json().catch(() => ({}));
const courseId = Number(body.courseId || 0);
@@ -88,9 +81,12 @@ export async function POST(request: Request) {
// make sure the tutor actually owns this course
const course = await prisma.course.findUnique({ where: { id: courseId } });
- if (!course || course.tutorId !== payload.sub) {
- return NextResponse.json({ error: "You do not own this course" }, { status: 403 });
- }
+ const ownership = requireResourceOwner({
+ ownerId: course?.tutorId,
+ userId: tutor.sub,
+ errorMessage: "You do not own this course",
+ });
+ if (ownership instanceof Response) return ownership;
const assignment = await prisma.assignment.create({
data: { courseId, title, description, dueDate },
diff --git a/app/api/courses/[id]/route.ts b/app/api/courses/[id]/route.ts
index f48d03b..12d6761 100644
--- a/app/api/courses/[id]/route.ts
+++ b/app/api/courses/[id]/route.ts
@@ -1,29 +1,19 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { verifyToken } from "@/lib/jwt";
-
-function getToken(request: Request): string | null {
- const authHeader = request.headers.get("authorization") || "";
- if (authHeader.startsWith("Bearer ")) return authHeader.slice(7);
-
- const cookie = request.headers.get("cookie") || "";
- const match = /authToken=([^;]+)/.exec(cookie);
- if (match) return decodeURIComponent(match[1]);
- return null;
-}
+import {
+ requireAuthenticatedUser,
+ requireResourceOwner,
+ requireTutorRole,
+} from "@/lib/api-auth";
// PATCH /api/courses/[id]
// updates a course owned by the logged-in tutor
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
try {
- const token = getToken(request);
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
- if (payload.role !== "TUTOR") {
- return NextResponse.json({ error: "Only tutors can update courses" }, { status: 403 });
- }
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
+ const tutor = requireTutorRole(auth, "Only tutors can update courses");
+ if (tutor instanceof Response) return tutor;
const id = Number(params.id);
if (Number.isNaN(id)) {
@@ -37,9 +27,12 @@ export async function PATCH(request: Request, { params }: { params: { id: string
if (!existing) {
return NextResponse.json({ error: "Course not found" }, { status: 404 });
}
- if (existing.tutorId !== payload.sub) {
- return NextResponse.json({ error: "You do not own this course" }, { status: 403 });
- }
+ const ownership = requireResourceOwner({
+ ownerId: existing.tutorId,
+ userId: tutor.sub,
+ errorMessage: "You do not own this course",
+ });
+ if (ownership instanceof Response) return ownership;
const body = await request.json().catch(() => ({}));
const data: {
diff --git a/app/api/courses/enrolled/route.ts b/app/api/courses/enrolled/route.ts
index 6375404..d054fdf 100644
--- a/app/api/courses/enrolled/route.ts
+++ b/app/api/courses/enrolled/route.ts
@@ -1,30 +1,20 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { verifyToken } from "@/lib/jwt";
+import { requireAuthenticatedUser } from "@/lib/api-auth";
// GET /api/courses/enrolled
// returns the courses the logged in student is enrolled in
export async function GET(request: Request) {
try {
- const authHeader = request.headers.get("authorization") || "";
- let token: string | null = null;
- if (authHeader.startsWith("Bearer ")) token = authHeader.slice(7);
- else {
- const cookie = request.headers.get("cookie") || "";
- const m = /authToken=([^;]+)/.exec(cookie);
- if (m) token = decodeURIComponent(m[1]);
- }
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
- if (payload.role !== "STUDENT") {
+ if (auth.role !== "STUDENT") {
return NextResponse.json({ error: "Only students can access this" }, { status: 403 });
}
const enrollments = await prisma.enrollment.findMany({
- where: { studentId: payload.sub, status: "ACTIVE" },
+ where: { studentId: auth.sub, status: "ACTIVE" },
include: {
course: {
select: {
diff --git a/app/api/courses/mine/route.ts b/app/api/courses/mine/route.ts
index fcba0f6..7940f26 100644
--- a/app/api/courses/mine/route.ts
+++ b/app/api/courses/mine/route.ts
@@ -1,30 +1,18 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { verifyToken } from "@/lib/jwt";
+import { requireAuthenticatedUser, requireTutorRole } from "@/lib/api-auth";
// GET /api/courses/mine
// returns the courses that belong to the logged-in tutor
export async function GET(request: Request) {
try {
- // grab token
- const authHeader = request.headers.get("authorization") || "";
- let token: string | null = null;
- if (authHeader.startsWith("Bearer ")) token = authHeader.slice(7);
- else {
- const cookie = request.headers.get("cookie") || "";
- const m = /authToken=([^;]+)/.exec(cookie);
- if (m) token = decodeURIComponent(m[1]);
- }
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
- if (payload.role !== "TUTOR") {
- return NextResponse.json({ error: "Only tutors can access this" }, { status: 403 });
- }
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
+ const tutor = requireTutorRole(auth);
+ if (tutor instanceof Response) return tutor;
const courses = await prisma.course.findMany({
- where: { tutorId: payload.sub },
+ where: { tutorId: tutor.sub },
orderBy: { createdAt: "desc" },
select: {
id: true,
diff --git a/app/api/courses/route.test.ts b/app/api/courses/route.test.ts
index 0f810c6..89edd36 100644
--- a/app/api/courses/route.test.ts
+++ b/app/api/courses/route.test.ts
@@ -9,6 +9,7 @@ const prismaMock = vi.hoisted(() => ({
vi.mock("@/lib/prisma", () => ({ prisma: prismaMock }));
+import { signToken } from "@/lib/jwt";
import { GET, POST } from "./route";
describe("/api/courses", () => {
@@ -44,17 +45,30 @@ describe("/api/courses", () => {
});
describe("POST", () => {
- it("returns 400 without tutorId", async () => {
+ it("returns 401 without authentication", async () => {
const req = new Request("http://localhost/api/courses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "T", subject: "S" }),
});
const res = await POST(req);
- expect(res.status).toBe(400);
+ expect(res.status).toBe(401);
});
- it("creates course when tutorId in body", async () => {
+ it("returns 403 for non-tutors", async () => {
+ const req = new Request("http://localhost/api/courses", {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${signToken("student-1", "STUDENT")}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ title: "T", subject: "S" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(403);
+ });
+
+ it("creates course for authenticated tutor", async () => {
prismaMock.course.create.mockResolvedValue({
id: 5,
title: "Calc",
@@ -64,9 +78,11 @@ describe("/api/courses", () => {
const req = new Request("http://localhost/api/courses", {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ authorization: `Bearer ${signToken("t1", "TUTOR")}`,
+ "Content-Type": "application/json",
+ },
body: JSON.stringify({
- tutorId: "t1",
title: "Calc",
subject: "Math",
isPublished: true,
@@ -74,6 +90,11 @@ describe("/api/courses", () => {
});
const res = await POST(req);
expect(res.status).toBe(201);
+ expect(prismaMock.course.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ tutorId: "t1" }),
+ })
+ );
});
});
});
diff --git a/app/api/courses/route.ts b/app/api/courses/route.ts
index fdedb27..6288e8f 100644
--- a/app/api/courses/route.ts
+++ b/app/api/courses/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
+import { requireAuthenticatedUser, requireTutorRole } from "@/lib/api-auth";
export async function GET(request: Request) {
try {
@@ -30,15 +31,12 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
- const body = await request.json().catch(() => ({}));
- // Prefer header (set by dashboard middleware) fallback to body.tutorId
- const hdrs = (request as any).headers || new Headers();
- const tutorIdHeader = hdrs.get ? hdrs.get("x-user-id") : null;
- const tutorId = tutorIdHeader || body.tutorId;
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
+ const tutor = requireTutorRole(auth, "Only tutors can create courses");
+ if (tutor instanceof Response) return tutor;
- if (!tutorId) {
- return NextResponse.json({ error: "tutorId is required" }, { status: 400 });
- }
+ const body = await request.json().catch(() => ({}));
const title = (body.title || "").toString().trim();
const subject = (body.subject || "").toString().trim();
if (!title || !subject) {
@@ -50,7 +48,7 @@ export async function POST(request: Request) {
title,
subject,
description: body.description || null,
- tutorId,
+ tutorId: tutor.sub,
price: typeof body.price === "number" ? body.price : body.price ? parseFloat(body.price) : null,
level: body.level || null,
isPublished: !!body.isPublished,
@@ -62,4 +60,4 @@ export async function POST(request: Request) {
console.error("POST /api/courses error", err);
return NextResponse.json({ error: "Failed to create course" }, { status: 500 });
}
-}
\ No newline at end of file
+}
diff --git a/app/api/enrollments/route.ts b/app/api/enrollments/route.ts
index 6a2d33e..8cfd2f0 100644
--- a/app/api/enrollments/route.ts
+++ b/app/api/enrollments/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { verifyToken } from "@/lib/jwt";
+import { requireAuthenticatedUser } from "@/lib/api-auth";
export async function POST(request: Request) {
try {
@@ -8,22 +8,11 @@ export async function POST(request: Request) {
const courseId = Number(body.courseId || 0);
if (!courseId) return NextResponse.json({ error: "courseId is required" }, { status: 400 });
- // Extract token from Authorization header or cookie
- const authHeader = request.headers.get("authorization") || "";
- let token: string | null = null;
- if (authHeader.startsWith("Bearer ")) token = authHeader.slice(7);
- else {
- const cookie = request.headers.get("cookie") || "";
- const m = /authToken=([^;]+)/.exec(cookie);
- if (m) token = decodeURIComponent(m[1]);
- }
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
- if (payload.role !== "STUDENT") return NextResponse.json({ error: "Only students can enroll" }, { status: 403 });
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
+ if (auth.role !== "STUDENT") return NextResponse.json({ error: "Only students can enroll" }, { status: 403 });
- const studentId = payload.sub;
+ const studentId = auth.sub;
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course || !course.isPublished) return NextResponse.json({ error: "Course not found" }, { status: 404 });
@@ -59,4 +48,4 @@ export async function POST(request: Request) {
console.error("POST /api/enrollments error", err);
return NextResponse.json({ error: "Failed to enroll" }, { status: 500 });
}
-}
\ No newline at end of file
+}
diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts
index b0c572d..35a11de 100644
--- a/app/api/messages/route.ts
+++ b/app/api/messages/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { requireAuth } from "@/lib/api-auth";
+import { requireAuthenticatedUser } from "@/lib/api-auth";
import {
getConversation,
listThreadsForUser,
@@ -13,7 +13,7 @@ import {
* GET /api/messages?with=uuid → { messages } (marks incoming as read)
*/
export async function GET(request: Request) {
- const auth = requireAuth(request);
+ const auth = requireAuthenticatedUser(request);
if (auth instanceof Response) return auth;
const { searchParams } = new URL(request.url);
@@ -34,7 +34,7 @@ export async function GET(request: Request) {
return NextResponse.json({
peer: { id: peer.id, fullName: peer.fullName, email: peer.email },
- messages: messages.map((m) => ({
+ messages: messages.map((m: typeof messages[number]) => ({
id: m.id,
content: m.content,
createdAt: m.createdAt.toISOString(),
@@ -55,7 +55,7 @@ export async function GET(request: Request) {
/** POST /api/messages body: { receiverId, content } */
export async function POST(request: Request) {
- const auth = requireAuth(request);
+ const auth = requireAuthenticatedUser(request);
if (auth instanceof Response) return auth;
try {
diff --git a/app/api/messages/users/route.ts b/app/api/messages/users/route.ts
index b3c0f40..bba5345 100644
--- a/app/api/messages/users/route.ts
+++ b/app/api/messages/users/route.ts
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { requireAuth } from "@/lib/api-auth";
+import { requireAuthenticatedUser } from "@/lib/api-auth";
/**
* GET /api/messages/users?q= — search users to start a conversation (excludes self).
*/
export async function GET(request: Request) {
- const auth = requireAuth(request);
+ const auth = requireAuthenticatedUser(request);
if (auth instanceof Response) return auth;
const { searchParams } = new URL(request.url);
diff --git a/app/api/study-plans/route.ts b/app/api/study-plans/route.ts
index 23a1577..f2af384 100644
--- a/app/api/study-plans/route.ts
+++ b/app/api/study-plans/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { requireAuth } from "@/lib/api-auth";
+import { requireAuthenticatedUser, requireResourceOwner } from "@/lib/api-auth";
type TaskInput = {
title: string;
@@ -11,7 +11,7 @@ type TaskInput = {
export async function GET(req: Request) {
try {
- const auth = requireAuth(req);
+ const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
const url = new URL(req.url);
@@ -21,13 +21,13 @@ export async function GET(req: Request) {
if (auth.role === "STUDENT") {
if (scope === "discover") {
const plans = await prisma.studyPlan.findMany({
- where: { studentId: { not: auth.sub } },
- include: {
- tasks: { orderBy: { dueDate: "asc" } },
- student: { select: { id: true, fullName: true } },
- },
- orderBy: { createdAt: "desc" },
- });
+ where: { studentId: { not: auth.sub } },
+ include: {
+ tasks: { orderBy: { dueDate: "asc" } },
+ student: { select: { id: true, fullName: true } },
+ },
+ orderBy: { createdAt: "desc" },
+ });
return NextResponse.json(plans);
}
@@ -98,7 +98,7 @@ function parseTasks(rawTasks: unknown):
export async function POST(req: Request) {
try {
- const auth = requireAuth(req);
+ const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
if (auth.role !== "STUDENT" && auth.role !== "TUTOR") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -168,7 +168,7 @@ export async function POST(req: Request) {
export async function PUT(req: Request) {
try {
- const auth = requireAuth(req);
+ const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
if (auth.role !== "STUDENT" && auth.role !== "TUTOR") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -193,8 +193,13 @@ export async function PUT(req: Request) {
return NextResponse.json({ error: "Study plan not found" }, { status: 404 });
}
- if (auth.role === "STUDENT" && existingPlan.studentId !== auth.sub) {
- return NextResponse.json({ error: "You do not own this plan" }, { status: 403 });
+ if (auth.role === "STUDENT") {
+ const ownership = requireResourceOwner({
+ ownerId: existingPlan.studentId,
+ userId: auth.sub,
+ errorMessage: "You do not own this plan",
+ });
+ if (ownership instanceof Response) return ownership;
}
const updated = await prisma.studyPlan.update({
diff --git a/app/api/submissions/[id]/review/route.ts b/app/api/submissions/[id]/review/route.ts
index e3c07bb..5dd3e90 100644
--- a/app/api/submissions/[id]/review/route.ts
+++ b/app/api/submissions/[id]/review/route.ts
@@ -1,15 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { verifyToken } from "@/lib/jwt";
-
-function getToken(request: Request): string | null {
- const authHeader = request.headers.get("authorization") || "";
- if (authHeader.startsWith("Bearer ")) return authHeader.slice(7);
- const cookie = request.headers.get("cookie") || "";
- const match = /authToken=([^;]+)/.exec(cookie);
- if (match) return decodeURIComponent(match[1]);
- return null;
-}
+import {
+ requireAuthenticatedUser,
+ requireResourceOwner,
+ requireTutorRole,
+} from "@/lib/api-auth";
// PATCH /api/submissions/[id]/review
// tutor grades a submission and gives feedback
@@ -18,14 +13,10 @@ export async function PATCH(
{ params }: { params: { id: string } }
) {
try {
- const token = getToken(request);
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
-
- if (payload.role !== "TUTOR") {
- return NextResponse.json({ error: "Only tutors can review submissions" }, { status: 403 });
- }
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
+ const tutor = requireTutorRole(auth, "Only tutors can review submissions");
+ if (tutor instanceof Response) return tutor;
const submissionId = Number(params.id);
if (Number.isNaN(submissionId)) {
@@ -55,9 +46,12 @@ export async function PATCH(
return NextResponse.json({ error: "Submission not found" }, { status: 404 });
}
- if (submission.assignment.course.tutorId !== payload.sub) {
- return NextResponse.json({ error: "You do not own this course" }, { status: 403 });
- }
+ const ownership = requireResourceOwner({
+ ownerId: submission.assignment.course.tutorId,
+ userId: tutor.sub,
+ errorMessage: "You do not own this course",
+ });
+ if (ownership instanceof Response) return ownership;
// update the submission with grade/feedback
const updated = await prisma.submission.update({
diff --git a/app/api/submissions/route.ts b/app/api/submissions/route.ts
index acdfdec..a5c70dc 100644
--- a/app/api/submissions/route.ts
+++ b/app/api/submissions/route.ts
@@ -1,24 +1,16 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { verifyToken } from "@/lib/jwt";
-
-function getToken(request: Request): string | null {
- const authHeader = request.headers.get("authorization") || "";
- if (authHeader.startsWith("Bearer ")) return authHeader.slice(7);
- const cookie = request.headers.get("cookie") || "";
- const match = /authToken=([^;]+)/.exec(cookie);
- if (match) return decodeURIComponent(match[1]);
- return null;
-}
+import {
+ requireAuthenticatedUser,
+ requireResourceOwner,
+} from "@/lib/api-auth";
// GET /api/submissions?assignmentId=1 or ?courseId=1
// tutors see all submissions for their course, students only see their own
export async function GET(request: Request) {
try {
- const token = getToken(request);
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
const { searchParams } = new URL(request.url);
const assignmentId = Number(searchParams.get("assignmentId") || 0);
@@ -34,12 +26,12 @@ export async function GET(request: Request) {
if (courseId) where.assignment = { courseId };
// students only see their own submissions
- if (payload.role === "STUDENT") {
- where.studentId = payload.sub;
+ if (auth.role === "STUDENT") {
+ where.studentId = auth.sub;
}
// tutors - verify they own the course before returning anything
- if (payload.role === "TUTOR") {
+ if (auth.role === "TUTOR") {
// figure out which course this is for
let targetCourseId = courseId;
if (!targetCourseId && assignmentId) {
@@ -48,9 +40,12 @@ export async function GET(request: Request) {
}
if (targetCourseId) {
const course = await prisma.course.findUnique({ where: { id: targetCourseId } });
- if (!course || course.tutorId !== payload.sub) {
- return NextResponse.json({ error: "You do not own this course" }, { status: 403 });
- }
+ const ownership = requireResourceOwner({
+ ownerId: course?.tutorId,
+ userId: auth.sub,
+ errorMessage: "You do not own this course",
+ });
+ if (ownership instanceof Response) return ownership;
}
}
@@ -80,12 +75,10 @@ export async function GET(request: Request) {
// body: { assignmentId, content }
export async function POST(request: Request) {
try {
- const token = getToken(request);
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
- if (payload.role !== "STUDENT") {
+ if (auth.role !== "STUDENT") {
return NextResponse.json({ error: "Only students can submit assignments" }, { status: 403 });
}
@@ -109,7 +102,7 @@ export async function POST(request: Request) {
// check student is enrolled in the course
const enrollment = await prisma.enrollment.findUnique({
where: {
- studentId_courseId: { studentId: payload.sub, courseId: assignment.courseId } as any,
+ studentId_courseId: { studentId: auth.sub, courseId: assignment.courseId } as any,
},
});
if (!enrollment || enrollment.status !== "ACTIVE") {
@@ -118,7 +111,7 @@ export async function POST(request: Request) {
// check if they already submitted - if so, update it (resubmit)
const existing = await prisma.submission.findFirst({
- where: { assignmentId, studentId: payload.sub },
+ where: { assignmentId, studentId: auth.sub },
});
let submission;
@@ -139,7 +132,7 @@ export async function POST(request: Request) {
resubmitted = true;
} else {
submission = await prisma.submission.create({
- data: { assignmentId, studentId: payload.sub, content },
+ data: { assignmentId, studentId: auth.sub, content },
});
}
diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts
index 7eef006..163f8da 100644
--- a/app/api/tasks/[id]/route.ts
+++ b/app/api/tasks/[id]/route.ts
@@ -1,24 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
-import { verifyToken } from "@/lib/jwt";
-
-function getToken(request: Request): string | null {
- const authHeader = request.headers.get("authorization") || "";
- if (authHeader.startsWith("Bearer ")) return authHeader.slice(7);
- const cookie = request.headers.get("cookie") || "";
- const match = /authToken=([^;]+)/.exec(cookie);
- if (match) return decodeURIComponent(match[1]);
- return null;
-}
+import { requireAuthenticatedUser, requireResourceOwner } from "@/lib/api-auth";
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
- const token = getToken(req);
- if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const payload = verifyToken(token);
- if (!payload) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
- if (payload.role !== "STUDENT" && payload.role !== "TUTOR") {
+ const auth = requireAuthenticatedUser(req);
+ if (auth instanceof Response) return auth;
+ if (auth.role !== "STUDENT" && auth.role !== "TUTOR") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
@@ -39,8 +27,13 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
if (!task) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
- if (payload.role === "STUDENT" && task.studyPlan.studentId !== payload.sub) {
- return NextResponse.json({ error: "You do not own this task" }, { status: 403 });
+ if (auth.role === "STUDENT") {
+ const ownership = requireResourceOwner({
+ ownerId: task.studyPlan.studentId,
+ userId: auth.sub,
+ errorMessage: "You do not own this task",
+ });
+ if (ownership instanceof Response) return ownership;
}
const updatedTask = await prisma.task.update({
diff --git a/lib/api-auth.test.ts b/lib/api-auth.test.ts
index 8590ca9..448ef9f 100644
--- a/lib/api-auth.test.ts
+++ b/lib/api-auth.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
-import { getTokenFromRequest, requireAuth } from "@/lib/api-auth";
+import {
+ getTokenFromRequest,
+ requireAuthenticatedUser,
+ requireResourceOwner,
+ requireTutorRole,
+} from "@/lib/api-auth";
import { signToken } from "@/lib/jwt";
import {
FIXTURE_ROLE,
@@ -26,10 +31,10 @@ describe("getTokenFromRequest (FR: API accepts Bearer or cookie)", () => {
});
});
-describe("requireAuth (FR: protected API returns 401 without valid JWT)", () => {
+describe("requireAuthenticatedUser (FR: protected API returns 401 without valid JWT)", () => {
it("returns JwtPayload for valid Bearer token", () => {
const token = signToken(FIXTURE_USER_ID, FIXTURE_ROLE);
- const result = requireAuth(requestWithBearerToken(token));
+ const result = requireAuthenticatedUser(requestWithBearerToken(token));
expect(result).not.toBeInstanceOf(Response);
if (!(result instanceof Response)) {
expect(result.sub).toBe(FIXTURE_USER_ID);
@@ -38,7 +43,7 @@ describe("requireAuth (FR: protected API returns 401 without valid JWT)", () =>
});
it("returns 401 Response when token missing", () => {
- const result = requireAuth(new Request("http://localhost/"));
+ const result = requireAuthenticatedUser(new Request("http://localhost/"));
expect(result).toBeInstanceOf(Response);
const res = result as Response;
expect(res.status).toBe(401);
@@ -46,8 +51,33 @@ describe("requireAuth (FR: protected API returns 401 without valid JWT)", () =>
it("returns 401 Response when token invalid", () => {
const req = requestWithBearerToken("bad.token.here");
- const result = requireAuth(req);
+ const result = requireAuthenticatedUser(req);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
});
});
+
+describe("requireTutorRole", () => {
+ it("returns the user when the role is tutor", () => {
+ const tutor = { sub: FIXTURE_USER_ID, role: "TUTOR" as const };
+ expect(requireTutorRole(tutor)).toEqual(tutor);
+ });
+
+ it("returns 403 Response for non-tutors", () => {
+ const result = requireTutorRole({ sub: FIXTURE_USER_ID, role: "STUDENT" });
+ expect(result).toBeInstanceOf(Response);
+ expect((result as Response).status).toBe(403);
+ });
+});
+
+describe("requireResourceOwner", () => {
+ it("returns true for the owner", () => {
+ expect(requireResourceOwner({ ownerId: FIXTURE_USER_ID, userId: FIXTURE_USER_ID })).toBe(true);
+ });
+
+ it("returns 403 Response when the user does not own the resource", () => {
+ const result = requireResourceOwner({ ownerId: "someone-else", userId: FIXTURE_USER_ID });
+ expect(result).toBeInstanceOf(Response);
+ expect((result as Response).status).toBe(403);
+ });
+});
diff --git a/lib/api-auth.ts b/lib/api-auth.ts
index 0640282..3e78f8a 100644
--- a/lib/api-auth.ts
+++ b/lib/api-auth.ts
@@ -10,20 +10,48 @@ export function getTokenFromRequest(request: Request): string | null {
return null;
}
-export function requireAuth(request: Request): JwtPayload | Response {
+function jsonAuthError(message: string, status: number): Response {
+ return new Response(JSON.stringify({ error: message }), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+export function requireAuthenticatedUser(request: Request): JwtPayload | Response {
const token = getTokenFromRequest(request);
if (!token) {
- return new Response(JSON.stringify({ error: "Unauthorized" }), {
- status: 401,
- headers: { "Content-Type": "application/json" },
- });
+ return jsonAuthError("Unauthorized", 401);
}
const payload = verifyToken(token);
if (!payload) {
- return new Response(JSON.stringify({ error: "Invalid token" }), {
- status: 401,
- headers: { "Content-Type": "application/json" },
- });
+ return jsonAuthError("Invalid token", 401);
}
return payload;
}
+
+export function requireTutorRole(
+ user: JwtPayload,
+ message = "Only tutors can access this"
+): JwtPayload | Response {
+ if (user.role !== "TUTOR") {
+ return jsonAuthError(message, 403);
+ }
+ return user;
+}
+
+export function requireResourceOwner({
+ ownerId,
+ userId,
+ errorMessage = "You do not own this resource",
+}: {
+ ownerId: string | null | undefined;
+ userId: string;
+ errorMessage?: string;
+}): true | Response {
+ if (!ownerId || ownerId !== userId) {
+ return jsonAuthError(errorMessage, 403);
+ }
+ return true;
+}
+
+export const requireAuth = requireAuthenticatedUser;
From 1c7fd6e131822e22f27c0595de8868b9d125fc9b Mon Sep 17 00:00:00 2001
From: hxddad
Date: Fri, 27 Mar 2026 10:14:27 -0400
Subject: [PATCH 2/7] enforce ownership checks for study plans and tasks
---
app/api/study-plans/route.test.ts | 17 ++---
app/api/study-plans/route.ts | 75 ++++---------------
app/api/tasks/[id]/route.test.ts | 22 +++++-
app/api/tasks/[id]/route.ts | 17 ++---
.../student/study-plan/[id]/edit/page.tsx | 1 -
.../student/study-plan/create-plan/page.tsx | 2 -
.../tutor/study-plan/[id]/edit/page.tsx | 1 -
components/StudyPlanForm.tsx | 5 +-
8 files changed, 49 insertions(+), 91 deletions(-)
diff --git a/app/api/study-plans/route.test.ts b/app/api/study-plans/route.test.ts
index 42d673d..3da64a2 100644
--- a/app/api/study-plans/route.test.ts
+++ b/app/api/study-plans/route.test.ts
@@ -49,11 +49,11 @@ describe("/api/study-plans", () => {
);
});
- it("GET returns discover plans (excluding current student)", async () => {
+ it("GET ignores query params and still returns only the caller's plans", async () => {
prismaMock.studyPlan.findMany.mockResolvedValue([{ id: 2, tasks: [] }] as never);
- const req = new Request("http://localhost/api/study-plans?scope=discover", {
+ const req = new Request("http://localhost/api/study-plans?scope=discover&studentId=" + OTHER_STUDENT_ID, {
headers: {
- authorization: `Bearer ${signToken(STUDENT_ID, "STUDENT")}`,
+ authorization: `Bearer ${signToken(TUTOR_ID, "TUTOR")}`,
},
});
@@ -61,7 +61,7 @@ describe("/api/study-plans", () => {
expect(res.status).toBe(200);
expect(prismaMock.studyPlan.findMany).toHaveBeenCalledWith(
expect.objectContaining({
- where: { studentId: { not: STUDENT_ID } },
+ where: { studentId: TUTOR_ID },
})
);
});
@@ -165,15 +165,11 @@ describe("/api/study-plans", () => {
);
});
- it("PUT allows tutor to update any student's plan", async () => {
+ it("PUT returns 403 when a tutor tries to update another user's plan", async () => {
prismaMock.studyPlan.findUnique.mockResolvedValue({
id: 8,
studentId: OTHER_STUDENT_ID,
} as never);
- prismaMock.studyPlan.update.mockResolvedValue({
- id: 8,
- tasks: [{ title: "Tutor update", courseId: 2 }],
- } as never);
const req = new Request("http://localhost/api/study-plans", {
method: "PUT",
@@ -188,6 +184,7 @@ describe("/api/study-plans", () => {
});
const res = await PUT(req);
- expect(res.status).toBe(200);
+ expect(res.status).toBe(403);
+ expect(prismaMock.studyPlan.update).not.toHaveBeenCalled();
});
});
diff --git a/app/api/study-plans/route.ts b/app/api/study-plans/route.ts
index f2af384..5b4be5f 100644
--- a/app/api/study-plans/route.ts
+++ b/app/api/study-plans/route.ts
@@ -14,44 +14,13 @@ export async function GET(req: Request) {
const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
- const url = new URL(req.url);
- const scope = (url.searchParams.get("scope") || "mine").toLowerCase();
- const studentIdFilter = url.searchParams.get("studentId");
-
- if (auth.role === "STUDENT") {
- if (scope === "discover") {
- const plans = await prisma.studyPlan.findMany({
- where: { studentId: { not: auth.sub } },
- include: {
- tasks: { orderBy: { dueDate: "asc" } },
- student: { select: { id: true, fullName: true } },
- },
- orderBy: { createdAt: "desc" },
- });
- return NextResponse.json(plans);
- }
-
- const plans = await prisma.studyPlan.findMany({
- where: { studentId: auth.sub },
- include: { tasks: { orderBy: { dueDate: "asc" } } },
- orderBy: { createdAt: "desc" },
- });
- return NextResponse.json(plans);
- }
-
- if (auth.role === "TUTOR") {
- const plans = await prisma.studyPlan.findMany({
- where: studentIdFilter ? { studentId: studentIdFilter } : undefined,
- include: {
- tasks: { orderBy: { dueDate: "asc" } },
- student: { select: { id: true, fullName: true } },
- },
- orderBy: { createdAt: "desc" },
- });
- return NextResponse.json(plans);
- }
+ const plans = await prisma.studyPlan.findMany({
+ where: { studentId: auth.sub },
+ include: { tasks: { orderBy: { dueDate: "asc" } } },
+ orderBy: { createdAt: "desc" },
+ });
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ return NextResponse.json(plans);
} catch (err) {
console.error("GET /api/study-plans error:", err);
return NextResponse.json({ error: "Failed to fetch study plans" }, { status: 500 });
@@ -100,23 +69,12 @@ export async function POST(req: Request) {
try {
const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
- if (auth.role !== "STUDENT" && auth.role !== "TUTOR") {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
const body = await req.json().catch(() => ({}));
const sourcePlanId = Number(body.sourcePlanId || 0);
- const requestedStudentId =
- typeof body.studentId === "string" ? body.studentId : "";
- const targetStudentId = auth.role === "STUDENT" ? auth.sub : requestedStudentId;
-
- if (!targetStudentId) {
- return NextResponse.json({ error: "studentId is required" }, { status: 400 });
- }
-
const studentExists = await prisma.user.findUnique({
- where: { id: targetStudentId },
+ where: { id: auth.sub },
select: { id: true },
});
if (!studentExists) {
@@ -151,7 +109,7 @@ export async function POST(req: Request) {
const newPlan = await prisma.studyPlan.create({
data: {
- studentId: targetStudentId,
+ studentId: auth.sub,
tasks: {
create: taskPayload,
},
@@ -170,9 +128,6 @@ export async function PUT(req: Request) {
try {
const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
- if (auth.role !== "STUDENT" && auth.role !== "TUTOR") {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
const body = await req.json().catch(() => ({}));
const planId = Number(body.planId || 0);
@@ -193,14 +148,12 @@ export async function PUT(req: Request) {
return NextResponse.json({ error: "Study plan not found" }, { status: 404 });
}
- if (auth.role === "STUDENT") {
- const ownership = requireResourceOwner({
- ownerId: existingPlan.studentId,
- userId: auth.sub,
- errorMessage: "You do not own this plan",
- });
- if (ownership instanceof Response) return ownership;
- }
+ const ownership = requireResourceOwner({
+ ownerId: existingPlan.studentId,
+ userId: auth.sub,
+ errorMessage: "You do not own this plan",
+ });
+ if (ownership instanceof Response) return ownership;
const updated = await prisma.studyPlan.update({
where: { id: planId },
diff --git a/app/api/tasks/[id]/route.test.ts b/app/api/tasks/[id]/route.test.ts
index f3ce87e..9805e9f 100644
--- a/app/api/tasks/[id]/route.test.ts
+++ b/app/api/tasks/[id]/route.test.ts
@@ -75,11 +75,31 @@ describe("PATCH /api/tasks/[id]", () => {
expect(res.status).toBe(200);
});
- it("allows tutor to update a task", async () => {
+ it("returns 403 when a tutor tries to update another user's task", async () => {
prismaMock.task.findUnique.mockResolvedValue({
id: 1,
studyPlan: { studentId: OTHER_STUDENT },
} as never);
+
+ const req = new Request("http://localhost/api/tasks/1", {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ authorization: `Bearer ${signToken(TUTOR, "TUTOR")}`,
+ },
+ body: JSON.stringify({ completed: false }),
+ });
+
+ const res = await PATCH(req, { params: { id: "1" } });
+ expect(res.status).toBe(403);
+ expect(prismaMock.task.update).not.toHaveBeenCalled();
+ });
+
+ it("updates task when the authenticated owner has tutor role", async () => {
+ prismaMock.task.findUnique.mockResolvedValue({
+ id: 1,
+ studyPlan: { studentId: TUTOR },
+ } as never);
prismaMock.task.update.mockResolvedValue({
id: 1,
completed: false,
diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts
index 163f8da..1cbd487 100644
--- a/app/api/tasks/[id]/route.ts
+++ b/app/api/tasks/[id]/route.ts
@@ -6,9 +6,6 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
try {
const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
- if (auth.role !== "STUDENT" && auth.role !== "TUTOR") {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
const { completed } = await req.json().catch(() => ({}));
const taskId = Number(params.id);
@@ -27,14 +24,12 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
if (!task) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
- if (auth.role === "STUDENT") {
- const ownership = requireResourceOwner({
- ownerId: task.studyPlan.studentId,
- userId: auth.sub,
- errorMessage: "You do not own this task",
- });
- if (ownership instanceof Response) return ownership;
- }
+ const ownership = requireResourceOwner({
+ ownerId: task.studyPlan.studentId,
+ userId: auth.sub,
+ errorMessage: "You do not own this task",
+ });
+ if (ownership instanceof Response) return ownership;
const updatedTask = await prisma.task.update({
where: { id: taskId },
diff --git a/app/dashboard/student/study-plan/[id]/edit/page.tsx b/app/dashboard/student/study-plan/[id]/edit/page.tsx
index fc8e758..84976d4 100644
--- a/app/dashboard/student/study-plan/[id]/edit/page.tsx
+++ b/app/dashboard/student/study-plan/[id]/edit/page.tsx
@@ -64,7 +64,6 @@ export default async function StudentEditStudyPlanPage({ params }: Props) {
Forbidden
;
- const studentId = payload.sub;
const copyFrom = Number(searchParams?.copyFrom || 0);
const enrollments = await prisma.enrollment.findMany({
@@ -97,7 +96,6 @@ export default async function CreateStudyPlanPage({ searchParams }: Props) {
)}
) {
setTasks((prev) =>
@@ -158,7 +156,6 @@ export default function StudyPlanForm({
})),
}
: {
- studentId,
tasks: tasks.map((task) => ({
title: task.title.trim(),
courseId: task.courseId,
From 53a549f034e736b574a8f84c3c8278f14a99653c Mon Sep 17 00:00:00 2001
From: hxddad
Date: Fri, 27 Mar 2026 10:17:31 -0400
Subject: [PATCH 3/7] harden course creation by enforcing authenticated
tutor-only access
---
app/api/courses/route.test.ts | 30 ++++++++++++++++++++++++++++
app/dashboard/tutor/courses/page.tsx | 2 +-
components/CreateCourseForm.tsx | 5 ++---
3 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/app/api/courses/route.test.ts b/app/api/courses/route.test.ts
index 89edd36..739090b 100644
--- a/app/api/courses/route.test.ts
+++ b/app/api/courses/route.test.ts
@@ -96,5 +96,35 @@ describe("/api/courses", () => {
})
);
});
+
+ it("ignores spoofed tutorId input and uses the authenticated tutor", async () => {
+ prismaMock.course.create.mockResolvedValue({
+ id: 6,
+ title: "Physics I",
+ subject: "Physics",
+ tutorId: "real-tutor",
+ } as never);
+
+ const req = new Request("http://localhost/api/courses", {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${signToken("real-tutor", "TUTOR")}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ title: "Physics I",
+ subject: "Physics",
+ tutorId: "spoofed-tutor",
+ }),
+ });
+
+ const res = await POST(req);
+ expect(res.status).toBe(201);
+ expect(prismaMock.course.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ tutorId: "real-tutor" }),
+ })
+ );
+ });
});
});
diff --git a/app/dashboard/tutor/courses/page.tsx b/app/dashboard/tutor/courses/page.tsx
index ac0061e..d544710 100644
--- a/app/dashboard/tutor/courses/page.tsx
+++ b/app/dashboard/tutor/courses/page.tsx
@@ -111,7 +111,7 @@ export default async function TutorCoursesPage() {
diff --git a/components/CreateCourseForm.tsx b/components/CreateCourseForm.tsx
index 8e4d5d4..2bc3d6e 100644
--- a/components/CreateCourseForm.tsx
+++ b/components/CreateCourseForm.tsx
@@ -3,7 +3,7 @@
import React, { useState } from "react";
import { useRouter } from "next/navigation";
-export default function CreateCourseForm({ tutorId }: { tutorId?: string }) {
+export default function CreateCourseForm() {
const router = useRouter();
const [title, setTitle] = useState("");
const [subject, setSubject] = useState("");
@@ -25,7 +25,7 @@ export default function CreateCourseForm({ tutorId }: { tutorId?: string }) {
}
setSaving(true);
try {
- const payload: any = {
+ const payload = {
title: title.trim(),
subject: subject.trim(),
description: description.trim() || null,
@@ -33,7 +33,6 @@ export default function CreateCourseForm({ tutorId }: { tutorId?: string }) {
price: price ? Number(price) : null,
isPublished,
};
- if (tutorId) payload.tutorId = tutorId;
const res = await fetch("/api/courses", {
method: "POST",
headers: { "Content-Type": "application/json" },
From 79113f2780a90d332650e6c345503df555788b82 Mon Sep 17 00:00:00 2001
From: hxddad
Date: Fri, 27 Mar 2026 10:40:43 -0400
Subject: [PATCH 4/7] add service and repository abstractions for courses,
study plans, and messaging
---
app/api/courses/route.ts | 44 ++---
app/api/messages/route.ts | 86 +++-------
app/api/study-plans/route.ts | 152 +++---------------
.../student/study-plan/create-plan/page.tsx | 1 +
lib/services/course-service.ts | 47 ++++++
lib/services/message-service.ts | 85 ++++++++++
lib/services/service-error.ts | 13 ++
lib/services/study-plan-service.ts | 128 +++++++++++++++
8 files changed, 323 insertions(+), 233 deletions(-)
create mode 100644 lib/services/course-service.ts
create mode 100644 lib/services/message-service.ts
create mode 100644 lib/services/service-error.ts
create mode 100644 lib/services/study-plan-service.ts
diff --git a/app/api/courses/route.ts b/app/api/courses/route.ts
index 6288e8f..753d430 100644
--- a/app/api/courses/route.ts
+++ b/app/api/courses/route.ts
@@ -1,29 +1,18 @@
import { NextResponse } from "next/server";
-import { prisma } from "@/lib/prisma";
import { requireAuthenticatedUser, requireTutorRole } from "@/lib/api-auth";
+import { createCourseForTutor, listPublishedCourses } from "@/lib/services/course-service";
+import { isServiceError } from "@/lib/services/service-error";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const subject = searchParams.get("subject");
- const where: any = { isPublished: true };
-
- if (subject) {
- where.subject = subject;
- }
-
- const courses = await prisma.course.findMany({
- where,
- orderBy: { createdAt: "desc" },
- include: {
- tutor: {
- select: { id: true, fullName: true, avatar: true },
- },
- },
- });
-
+ const courses = await listPublishedCourses(subject);
return NextResponse.json(courses);
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("GET /api/courses error", err);
return NextResponse.json({ error: "Failed to fetch courses" }, { status: 500 });
}
@@ -37,26 +26,13 @@ export async function POST(request: Request) {
if (tutor instanceof Response) return tutor;
const body = await request.json().catch(() => ({}));
- const title = (body.title || "").toString().trim();
- const subject = (body.subject || "").toString().trim();
- if (!title || !subject) {
- return NextResponse.json({ error: "title and subject are required" }, { status: 400 });
- }
-
- const course = await prisma.course.create({
- data: {
- title,
- subject,
- description: body.description || null,
- tutorId: tutor.sub,
- price: typeof body.price === "number" ? body.price : body.price ? parseFloat(body.price) : null,
- level: body.level || null,
- isPublished: !!body.isPublished,
- },
- });
+ const course = await createCourseForTutor(tutor.sub, body);
return NextResponse.json(course, { status: 201 });
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("POST /api/courses error", err);
return NextResponse.json({ error: "Failed to create course" }, { status: 500 });
}
diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts
index 35a11de..3bae811 100644
--- a/app/api/messages/route.ts
+++ b/app/api/messages/route.ts
@@ -1,16 +1,15 @@
import { NextResponse } from "next/server";
-import { prisma } from "@/lib/prisma";
import { requireAuthenticatedUser } from "@/lib/api-auth";
import {
- getConversation,
- listThreadsForUser,
- markThreadRead,
- sendMessage,
-} from "@/lib/messages";
+ getMessageThreadForUser,
+ listMessageThreadsForUser,
+ sendMessageFromUser,
+} from "@/lib/services/message-service";
+import { isServiceError } from "@/lib/services/service-error";
/**
- * GET /api/messages → { threads }
- * GET /api/messages?with=uuid → { messages } (marks incoming as read)
+ * GET /api/messages -> { threads }
+ * GET /api/messages?with=uuid -> { messages } (marks incoming as read)
*/
export async function GET(request: Request) {
const auth = requireAuthenticatedUser(request);
@@ -21,33 +20,16 @@ export async function GET(request: Request) {
try {
if (withUserId) {
- if (withUserId === auth.sub) {
- return NextResponse.json({ error: "Cannot message yourself" }, { status: 400 });
- }
- const peer = await prisma.user.findUnique({ where: { id: withUserId } });
- if (!peer) {
- return NextResponse.json({ error: "User not found" }, { status: 404 });
- }
-
- const messages = await getConversation(auth.sub, withUserId, 200);
- await markThreadRead(auth.sub, withUserId);
-
- return NextResponse.json({
- peer: { id: peer.id, fullName: peer.fullName, email: peer.email },
- messages: messages.map((m: typeof messages[number]) => ({
- id: m.id,
- content: m.content,
- createdAt: m.createdAt.toISOString(),
- isRead: m.isRead,
- senderId: m.senderId,
- receiverId: m.receiverId,
- })),
- });
+ const thread = await getMessageThreadForUser(auth.sub, withUserId);
+ return NextResponse.json(thread);
}
- const threads = await listThreadsForUser(auth.sub);
+ const threads = await listMessageThreadsForUser(auth.sub);
return NextResponse.json({ threads });
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("GET /api/messages", err);
return NextResponse.json({ error: "Failed to load messages" }, { status: 500 });
}
@@ -60,45 +42,17 @@ export async function POST(request: Request) {
try {
const body = await request.json().catch(() => ({}));
- const receiverId = typeof body.receiverId === "string" ? body.receiverId.trim() : "";
- const content =
- typeof body.content === "string" ? body.content.trim() : "";
-
- if (!receiverId) {
- return NextResponse.json({ error: "receiverId is required" }, { status: 400 });
- }
- if (receiverId === auth.sub) {
- return NextResponse.json({ error: "Cannot message yourself" }, { status: 400 });
- }
- if (!content || content.length > 8000) {
- return NextResponse.json(
- { error: "content must be 1–8000 characters" },
- { status: 400 }
- );
- }
-
- const receiver = await prisma.user.findUnique({ where: { id: receiverId } });
- if (!receiver) {
- return NextResponse.json({ error: "Receiver not found" }, { status: 404 });
- }
-
- const message = await sendMessage({
+ const result = await sendMessageFromUser({
senderId: auth.sub,
- receiverId,
- content,
+ receiverId: typeof body.receiverId === "string" ? body.receiverId : "",
+ content: typeof body.content === "string" ? body.content : "",
});
- return NextResponse.json({
- message: {
- id: message.id,
- content: message.content,
- createdAt: message.createdAt.toISOString(),
- isRead: message.isRead,
- senderId: message.senderId,
- receiverId: message.receiverId,
- },
- });
+ return NextResponse.json(result);
} catch (err: unknown) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("POST /api/messages", err);
const detail =
err instanceof Error ? err.message : typeof err === "string" ? err : "Unknown error";
diff --git a/app/api/study-plans/route.ts b/app/api/study-plans/route.ts
index 5b4be5f..3040398 100644
--- a/app/api/study-plans/route.ts
+++ b/app/api/study-plans/route.ts
@@ -1,124 +1,41 @@
import { NextResponse } from "next/server";
-import { prisma } from "@/lib/prisma";
-import { requireAuthenticatedUser, requireResourceOwner } from "@/lib/api-auth";
-
-type TaskInput = {
- title: string;
- dueDate: string;
- courseId: string | number;
- completed?: boolean;
-};
+import { requireAuthenticatedUser } from "@/lib/api-auth";
+import {
+ createStudyPlanForUser,
+ listStudyPlansForUser,
+ updateOwnedStudyPlan,
+} from "@/lib/services/study-plan-service";
+import { isServiceError } from "@/lib/services/service-error";
export async function GET(req: Request) {
try {
const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
- const plans = await prisma.studyPlan.findMany({
- where: { studentId: auth.sub },
- include: { tasks: { orderBy: { dueDate: "asc" } } },
- orderBy: { createdAt: "desc" },
- });
-
+ const plans = await listStudyPlansForUser(auth.sub);
return NextResponse.json(plans);
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("GET /api/study-plans error:", err);
return NextResponse.json({ error: "Failed to fetch study plans" }, { status: 500 });
}
}
-function parseTasks(rawTasks: unknown):
- | { tasks: Array<{ title: string; dueDate: Date; courseId: number; completed: boolean }> }
- | { error: string } {
- if (!Array.isArray(rawTasks) || rawTasks.length === 0) {
- return { error: "At least one task is required" };
- }
-
- const tasks: Array<{
- title: string;
- dueDate: Date;
- courseId: number;
- completed: boolean;
- }> = [];
- for (let i = 0; i < rawTasks.length; i += 1) {
- const task = rawTasks[i] as TaskInput;
- const title = (task?.title || "").toString().trim();
- const dueDate = new Date(task?.dueDate);
- const courseId = Number(task?.courseId);
-
- if (!title) return { error: `Task ${i + 1}: title is required` };
- if (!Number.isInteger(courseId) || courseId <= 0) {
- return { error: `Task ${i + 1}: course is required` };
- }
- if (Number.isNaN(dueDate.getTime())) {
- return { error: `Task ${i + 1}: due date is invalid` };
- }
-
- tasks.push({
- title,
- dueDate,
- courseId,
- completed: !!task?.completed,
- });
- }
-
- return { tasks };
-}
-
export async function POST(req: Request) {
try {
const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
const body = await req.json().catch(() => ({}));
- const sourcePlanId = Number(body.sourcePlanId || 0);
-
- const studentExists = await prisma.user.findUnique({
- where: { id: auth.sub },
- select: { id: true },
- });
- if (!studentExists) {
- return NextResponse.json({ error: "Student not found" }, { status: 400 });
- }
-
- let taskPayload: Array<{ title: string; dueDate: Date; courseId: number; completed: boolean }> =
- [];
-
- if (sourcePlanId) {
- const sourcePlan = await prisma.studyPlan.findUnique({
- where: { id: sourcePlanId },
- include: { tasks: true },
- });
- if (!sourcePlan) {
- return NextResponse.json({ error: "Source plan not found" }, { status: 404 });
- }
-
- taskPayload = sourcePlan.tasks.map((task) => ({
- title: task.title,
- dueDate: task.dueDate,
- courseId: task.courseId,
- completed: !!task.completed,
- }));
- } else {
- const parsed = parseTasks(body.tasks);
- if ("error" in parsed) {
- return NextResponse.json({ error: parsed.error }, { status: 400 });
- }
- taskPayload = parsed.tasks;
- }
-
- const newPlan = await prisma.studyPlan.create({
- data: {
- studentId: auth.sub,
- tasks: {
- create: taskPayload,
- },
- },
- include: { tasks: true },
- });
+ const newPlan = await createStudyPlanForUser(auth.sub, body);
return NextResponse.json(newPlan, { status: 201 });
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("POST /api/study-plans error:", err);
return NextResponse.json({ error: "Failed to create study plan" }, { status: 500 });
}
@@ -130,44 +47,13 @@ export async function PUT(req: Request) {
if (auth instanceof Response) return auth;
const body = await req.json().catch(() => ({}));
- const planId = Number(body.planId || 0);
- if (!planId) {
- return NextResponse.json({ error: "planId is required" }, { status: 400 });
- }
-
- const parsed = parseTasks(body.tasks);
- if ("error" in parsed) {
- return NextResponse.json({ error: parsed.error }, { status: 400 });
- }
-
- const existingPlan = await prisma.studyPlan.findUnique({
- where: { id: planId },
- select: { id: true, studentId: true },
- });
- if (!existingPlan) {
- return NextResponse.json({ error: "Study plan not found" }, { status: 404 });
- }
-
- const ownership = requireResourceOwner({
- ownerId: existingPlan.studentId,
- userId: auth.sub,
- errorMessage: "You do not own this plan",
- });
- if (ownership instanceof Response) return ownership;
-
- const updated = await prisma.studyPlan.update({
- where: { id: planId },
- data: {
- tasks: {
- deleteMany: {},
- create: parsed.tasks,
- },
- },
- include: { tasks: true },
- });
+ const updated = await updateOwnedStudyPlan(auth.sub, body);
return NextResponse.json(updated);
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("PUT /api/study-plans error:", err);
return NextResponse.json({ error: "Failed to update study plan" }, { status: 500 });
}
diff --git a/app/dashboard/student/study-plan/create-plan/page.tsx b/app/dashboard/student/study-plan/create-plan/page.tsx
index d4c013d..42423be 100644
--- a/app/dashboard/student/study-plan/create-plan/page.tsx
+++ b/app/dashboard/student/study-plan/create-plan/page.tsx
@@ -18,6 +18,7 @@ export default async function CreateStudyPlanPage({ searchParams }: Props) {
const payload = verifyToken(token);
if (!payload || payload.role !== "STUDENT") return Forbidden
;
+ const studentId = payload.sub;
const copyFrom = Number(searchParams?.copyFrom || 0);
const enrollments = await prisma.enrollment.findMany({
diff --git a/lib/services/course-service.ts b/lib/services/course-service.ts
new file mode 100644
index 0000000..7644396
--- /dev/null
+++ b/lib/services/course-service.ts
@@ -0,0 +1,47 @@
+import { prisma } from "@/lib/prisma";
+import { ServiceError } from "@/lib/services/service-error";
+
+type CourseInput = {
+ title?: unknown;
+ subject?: unknown;
+ description?: unknown;
+ price?: unknown;
+ level?: unknown;
+ isPublished?: unknown;
+};
+
+export async function listPublishedCourses(subject: string | null) {
+ const where: { isPublished: true; subject?: string } = { isPublished: true };
+ if (subject) where.subject = subject;
+
+ return prisma.course.findMany({
+ where,
+ orderBy: { createdAt: "desc" },
+ include: {
+ tutor: {
+ select: { id: true, fullName: true, avatar: true },
+ },
+ },
+ });
+}
+
+export async function createCourseForTutor(tutorId: string, input: CourseInput) {
+ const title = (input.title || "").toString().trim();
+ const subject = (input.subject || "").toString().trim();
+
+ if (!title || !subject) {
+ throw new ServiceError("title and subject are required", 400);
+ }
+
+ return prisma.course.create({
+ data: {
+ title,
+ subject,
+ description: input.description ? String(input.description).trim() || null : null,
+ tutorId,
+ price: typeof input.price === "number" ? input.price : input.price ? parseFloat(String(input.price)) : null,
+ level: input.level ? String(input.level).trim() || null : null,
+ isPublished: !!input.isPublished,
+ },
+ });
+}
diff --git a/lib/services/message-service.ts b/lib/services/message-service.ts
new file mode 100644
index 0000000..bc9232a
--- /dev/null
+++ b/lib/services/message-service.ts
@@ -0,0 +1,85 @@
+import { prisma } from "@/lib/prisma";
+import {
+ getConversation,
+ listThreadsForUser,
+ markThreadRead,
+ sendMessage,
+} from "@/lib/messages";
+import { ServiceError } from "@/lib/services/service-error";
+
+export async function listMessageThreadsForUser(userId: string) {
+ return listThreadsForUser(userId);
+}
+
+export async function getMessageThreadForUser(userId: string, peerId: string) {
+ if (!peerId) {
+ throw new ServiceError("User id is required", 400);
+ }
+
+ if (peerId === userId) {
+ throw new ServiceError("Cannot message yourself", 400);
+ }
+
+ const peer = await prisma.user.findUnique({ where: { id: peerId } });
+ if (!peer) {
+ throw new ServiceError("User not found", 404);
+ }
+
+ const messages = await getConversation(userId, peerId, 200);
+ await markThreadRead(userId, peerId);
+
+ return {
+ peer: { id: peer.id, fullName: peer.fullName, email: peer.email },
+ messages: messages.map((message: typeof messages[number]) => ({
+ id: message.id,
+ content: message.content,
+ createdAt: message.createdAt.toISOString(),
+ isRead: message.isRead,
+ senderId: message.senderId,
+ receiverId: message.receiverId,
+ })),
+ };
+}
+
+export async function sendMessageFromUser(input: {
+ senderId: string;
+ receiverId: string;
+ content: string;
+}) {
+ const receiverId = input.receiverId.trim();
+ const content = input.content.trim();
+
+ if (!receiverId) {
+ throw new ServiceError("receiverId is required", 400);
+ }
+
+ if (receiverId === input.senderId) {
+ throw new ServiceError("Cannot message yourself", 400);
+ }
+
+ if (!content || content.length > 8000) {
+ throw new ServiceError("content must be 1-8000 characters", 400);
+ }
+
+ const receiver = await prisma.user.findUnique({ where: { id: receiverId } });
+ if (!receiver) {
+ throw new ServiceError("Receiver not found", 404);
+ }
+
+ const message = await sendMessage({
+ senderId: input.senderId,
+ receiverId,
+ content,
+ });
+
+ return {
+ message: {
+ id: message.id,
+ content: message.content,
+ createdAt: message.createdAt.toISOString(),
+ isRead: message.isRead,
+ senderId: message.senderId,
+ receiverId: message.receiverId,
+ },
+ };
+}
diff --git a/lib/services/service-error.ts b/lib/services/service-error.ts
new file mode 100644
index 0000000..203f996
--- /dev/null
+++ b/lib/services/service-error.ts
@@ -0,0 +1,13 @@
+export class ServiceError extends Error {
+ status: number;
+
+ constructor(message: string, status = 400) {
+ super(message);
+ this.name = "ServiceError";
+ this.status = status;
+ }
+}
+
+export function isServiceError(error: unknown): error is ServiceError {
+ return error instanceof ServiceError;
+}
diff --git a/lib/services/study-plan-service.ts b/lib/services/study-plan-service.ts
new file mode 100644
index 0000000..4fd35d3
--- /dev/null
+++ b/lib/services/study-plan-service.ts
@@ -0,0 +1,128 @@
+import { prisma } from "@/lib/prisma";
+import { ServiceError } from "@/lib/services/service-error";
+
+type TaskInput = {
+ title?: unknown;
+ dueDate?: unknown;
+ courseId?: unknown;
+ completed?: unknown;
+};
+
+type StudyPlanInput = {
+ sourcePlanId?: unknown;
+ planId?: unknown;
+ tasks?: unknown;
+};
+
+function parseTasks(rawTasks: unknown) {
+ if (!Array.isArray(rawTasks) || rawTasks.length === 0) {
+ throw new ServiceError("At least one task is required", 400);
+ }
+
+ return rawTasks.map((rawTask, index) => {
+ const task = rawTask as TaskInput;
+ const title = (task?.title || "").toString().trim();
+ const dueDate = new Date(String(task?.dueDate || ""));
+ const courseId = Number(task?.courseId);
+
+ if (!title) throw new ServiceError(`Task ${index + 1}: title is required`, 400);
+ if (!Number.isInteger(courseId) || courseId <= 0) {
+ throw new ServiceError(`Task ${index + 1}: course is required`, 400);
+ }
+ if (Number.isNaN(dueDate.getTime())) {
+ throw new ServiceError(`Task ${index + 1}: due date is invalid`, 400);
+ }
+
+ return {
+ title,
+ dueDate,
+ courseId,
+ completed: !!task?.completed,
+ };
+ });
+}
+
+export async function listStudyPlansForUser(userId: string) {
+ return prisma.studyPlan.findMany({
+ where: { studentId: userId },
+ include: { tasks: { orderBy: { dueDate: "asc" } } },
+ orderBy: { createdAt: "desc" },
+ });
+}
+
+export async function createStudyPlanForUser(userId: string, input: StudyPlanInput) {
+ const studentExists = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { id: true },
+ });
+
+ if (!studentExists) {
+ throw new ServiceError("Student not found", 400);
+ }
+
+ const sourcePlanId = Number(input.sourcePlanId || 0);
+ let taskPayload: Array<{ title: string; dueDate: Date; courseId: number; completed: boolean }> = [];
+
+ if (sourcePlanId) {
+ const sourcePlan = await prisma.studyPlan.findUnique({
+ where: { id: sourcePlanId },
+ include: { tasks: true },
+ });
+
+ if (!sourcePlan) {
+ throw new ServiceError("Source plan not found", 404);
+ }
+
+ taskPayload = sourcePlan.tasks.map((task: typeof sourcePlan.tasks[number]) => ({
+ title: task.title,
+ dueDate: task.dueDate,
+ courseId: task.courseId,
+ completed: !!task.completed,
+ }));
+ } else {
+ taskPayload = parseTasks(input.tasks);
+ }
+
+ return prisma.studyPlan.create({
+ data: {
+ studentId: userId,
+ tasks: {
+ create: taskPayload,
+ },
+ },
+ include: { tasks: true },
+ });
+}
+
+export async function updateOwnedStudyPlan(userId: string, input: StudyPlanInput) {
+ const planId = Number(input.planId || 0);
+ if (!planId) {
+ throw new ServiceError("planId is required", 400);
+ }
+
+ const existingPlan = await prisma.studyPlan.findUnique({
+ where: { id: planId },
+ select: { id: true, studentId: true },
+ });
+
+ if (!existingPlan) {
+ throw new ServiceError("Study plan not found", 404);
+ }
+
+ if (existingPlan.studentId !== userId) {
+ throw new ServiceError("You do not own this plan", 403);
+ }
+
+ const tasks = parseTasks(input.tasks);
+
+ return prisma.studyPlan.update({
+ where: { id: planId },
+ data: {
+ tasks: {
+ deleteMany: {},
+ create: tasks,
+ },
+ },
+ include: { tasks: true },
+ });
+}
From 750df3f58840e7e54e530651e269ebc6839cd920 Mon Sep 17 00:00:00 2001
From: hxddad
Date: Fri, 27 Mar 2026 10:44:37 -0400
Subject: [PATCH 5/7] standardize validation across core features
---
app/api/assignments/route.ts | 17 +-
app/api/auth/login/route.ts | 32 +---
app/api/auth/register/route.ts | 81 ++--------
app/api/submissions/route.ts | 16 +-
app/api/tasks/[id]/route.ts | 10 +-
lib/services/course-service.ts | 31 ++--
lib/services/message-service.ts | 36 ++---
lib/services/study-plan-service.ts | 64 ++------
lib/validation.ts | 239 +++++++++++++++++++++++++++++
9 files changed, 311 insertions(+), 215 deletions(-)
create mode 100644 lib/validation.ts
diff --git a/app/api/assignments/route.ts b/app/api/assignments/route.ts
index 75278fa..266d4a2 100644
--- a/app/api/assignments/route.ts
+++ b/app/api/assignments/route.ts
@@ -5,6 +5,8 @@ import {
requireResourceOwner,
requireTutorRole,
} from "@/lib/api-auth";
+import { isServiceError } from "@/lib/services/service-error";
+import { validateAssignmentPayload } from "@/lib/validation";
// GET /api/assignments?courseId=___
// fetches all assignments for a specific course
@@ -69,15 +71,9 @@ export async function POST(request: Request) {
const tutor = requireTutorRole(auth, "Only tutors can create assignments");
if (tutor instanceof Response) return tutor;
- const body = await request.json().catch(() => ({}));
- const courseId = Number(body.courseId || 0);
- const title = (body.title || "").trim();
- const description = (body.description || "").trim() || null;
- const dueDate = body.dueDate ? new Date(body.dueDate) : null;
-
- if (!courseId || !title) {
- return NextResponse.json({ error: "courseId and title are required" }, { status: 400 });
- }
+ const { courseId, title, description, dueDate } = validateAssignmentPayload(
+ await request.json().catch(() => ({}))
+ );
// make sure the tutor actually owns this course
const course = await prisma.course.findUnique({ where: { id: courseId } });
@@ -94,6 +90,9 @@ export async function POST(request: Request) {
return NextResponse.json(assignment, { status: 201 });
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("POST /api/assignments error:", err);
return NextResponse.json({ error: "Failed to create assignment" }, { status: 500 });
}
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 789cfaf..947cbf9 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -2,38 +2,17 @@ import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
import { signToken } from "@/lib/jwt";
-
-function parseBody(body: unknown): { email?: string; password?: string } {
- if (body && typeof body === "object" && "email" in body && "password" in body) {
- return {
- email: typeof (body as Record).email === "string" ? (body as Record).email as string : undefined,
- password: typeof (body as Record).password === "string" ? (body as Record).password as string : undefined,
- };
- }
- return {};
-}
+import { isServiceError } from "@/lib/services/service-error";
+import { validateLoginPayload } from "@/lib/validation";
// We validate credentials, then return a JWT and user info.
export async function POST(request: NextRequest) {
try {
const body = await request.json();
- const { email, password } = parseBody(body);
-
- if (!email?.trim()) {
- return NextResponse.json(
- { error: "Email is required." },
- { status: 400 }
- );
- }
- if (!password) {
- return NextResponse.json(
- { error: "Password is required." },
- { status: 400 }
- );
- }
+ const { email, password } = validateLoginPayload(body);
const user = await prisma.user.findUnique({
- where: { email: email.trim().toLowerCase() },
+ where: { email },
});
if (!user) {
@@ -64,6 +43,9 @@ export async function POST(request: NextRequest) {
},
});
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("Login error:", err);
return NextResponse.json(
{ error: "Login failed. Please try again." },
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts
index 11035a4..54fe911 100644
--- a/app/api/auth/register/route.ts
+++ b/app/api/auth/register/route.ts
@@ -1,83 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
-import { Role } from "@prisma/client";
+import { isServiceError } from "@/lib/services/service-error";
+import { validateRegistrationPayload } from "@/lib/validation";
const SALT_ROUNDS = 10;
-function parseBody(body: unknown): { fullName?: string; email?: string; password?: string; role?: string } {
- if (body && typeof body === "object" && "fullName" in body && "email" in body && "password" in body && "role" in body) {
- return {
- fullName: typeof (body as Record).fullName === "string" ? (body as Record).fullName as string : undefined,
- email: typeof (body as Record).email === "string" ? (body as Record).email as string : undefined,
- password: typeof (body as Record).password === "string" ? (body as Record).password as string : undefined,
- role: typeof (body as Record).role === "string" ? (body as Record).role as string : undefined,
- };
- }
- return {};
-}
-
-const VALID_ROLES: Role[] = ["STUDENT", "TUTOR", "ADMIN"];
-
-function toRole(value: string): Role | null {
- const u = value?.toUpperCase();
- return VALID_ROLES.includes(u as Role) ? (u as Role) : null;
-}
-
// We validate input, hash the password, and create the user in the DB.
export async function POST(request: NextRequest) {
try {
const body = await request.json();
- const { fullName, email, password, role } = parseBody(body);
-
- if (!fullName?.trim()) {
- return NextResponse.json(
- { error: "Full name is required." },
- { status: 400 }
- );
- }
- if (fullName.trim().length < 2) {
- return NextResponse.json(
- { error: "Name must be at least 2 characters." },
- { status: 400 }
- );
- }
- if (!email?.trim()) {
- return NextResponse.json(
- { error: "Email is required." },
- { status: 400 }
- );
- }
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- if (!emailRegex.test(email.trim())) {
- return NextResponse.json(
- { error: "Please enter a valid email address." },
- { status: 400 }
- );
- }
- if (!password) {
- return NextResponse.json(
- { error: "Password is required." },
- { status: 400 }
- );
- }
- if (password.length < 8) {
- return NextResponse.json(
- { error: "Password must be at least 8 characters." },
- { status: 400 }
- );
- }
-
- const parsedRole = toRole(role ?? "");
- if (!parsedRole) {
- return NextResponse.json(
- { error: "Please select a valid role (STUDENT, TUTOR, or ADMIN)." },
- { status: 400 }
- );
- }
+ const { fullName, email, password, role } = validateRegistrationPayload(body);
const existing = await prisma.user.findUnique({
- where: { email: email.trim().toLowerCase() },
+ where: { email },
});
if (existing) {
return NextResponse.json(
@@ -90,10 +26,10 @@ export async function POST(request: NextRequest) {
const user = await prisma.user.create({
data: {
- fullName: fullName.trim(),
- email: email.trim().toLowerCase(),
+ fullName,
+ email,
password: hashedPassword,
- role: parsedRole,
+ role,
},
});
@@ -108,6 +44,9 @@ export async function POST(request: NextRequest) {
},
});
} catch (err: unknown) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("Register error:", err);
const message =
err && typeof err === "object" && "code" in err
diff --git a/app/api/submissions/route.ts b/app/api/submissions/route.ts
index a5c70dc..5e46db5 100644
--- a/app/api/submissions/route.ts
+++ b/app/api/submissions/route.ts
@@ -4,6 +4,8 @@ import {
requireAuthenticatedUser,
requireResourceOwner,
} from "@/lib/api-auth";
+import { isServiceError } from "@/lib/services/service-error";
+import { validateSubmissionPayload } from "@/lib/validation";
// GET /api/submissions?assignmentId=1 or ?courseId=1
// tutors see all submissions for their course, students only see their own
@@ -82,16 +84,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Only students can submit assignments" }, { status: 403 });
}
- const body = await request.json().catch(() => ({}));
- const assignmentId = Number(body.assignmentId || 0);
- const content = (body.content || "").trim();
-
- if (!assignmentId) {
- return NextResponse.json({ error: "assignmentId is required" }, { status: 400 });
- }
- if (!content) {
- return NextResponse.json({ error: "Submission content cannot be empty" }, { status: 400 });
- }
+ const { assignmentId, content } = validateSubmissionPayload(await request.json().catch(() => ({})));
// check assignment exists
const assignment = await prisma.assignment.findUnique({ where: { id: assignmentId } });
@@ -141,6 +134,9 @@ export async function POST(request: Request) {
{ status: resubmitted ? 200 : 201 }
);
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("POST /api/submissions error:", err);
return NextResponse.json({ error: "Failed to submit" }, { status: 500 });
}
diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts
index 1cbd487..449469e 100644
--- a/app/api/tasks/[id]/route.ts
+++ b/app/api/tasks/[id]/route.ts
@@ -1,21 +1,20 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuthenticatedUser, requireResourceOwner } from "@/lib/api-auth";
+import { isServiceError } from "@/lib/services/service-error";
+import { validateTaskStatusPayload } from "@/lib/validation";
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const auth = requireAuthenticatedUser(req);
if (auth instanceof Response) return auth;
- const { completed } = await req.json().catch(() => ({}));
+ const { completed } = validateTaskStatusPayload(await req.json().catch(() => ({})));
const taskId = Number(params.id);
if (Number.isNaN(taskId)) {
return NextResponse.json({ error: "Invalid task id" }, { status: 400 });
}
- if (typeof completed !== "boolean") {
- return NextResponse.json({ error: "Invalid completed value" }, { status: 400 });
- }
const task = await prisma.task.findUnique({
where: { id: taskId },
@@ -38,6 +37,9 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
return NextResponse.json(updatedTask, { status: 200 });
} catch (err) {
+ if (isServiceError(err)) {
+ return NextResponse.json({ error: err.message }, { status: err.status });
+ }
console.error("PATCH /api/tasks/[id] error:", err);
return NextResponse.json({ error: "Failed to update task" }, { status: 500 });
}
diff --git a/lib/services/course-service.ts b/lib/services/course-service.ts
index 7644396..8afefa0 100644
--- a/lib/services/course-service.ts
+++ b/lib/services/course-service.ts
@@ -1,14 +1,6 @@
import { prisma } from "@/lib/prisma";
import { ServiceError } from "@/lib/services/service-error";
-
-type CourseInput = {
- title?: unknown;
- subject?: unknown;
- description?: unknown;
- price?: unknown;
- level?: unknown;
- isPublished?: unknown;
-};
+import { validateCoursePayload } from "@/lib/validation";
export async function listPublishedCourses(subject: string | null) {
const where: { isPublished: true; subject?: string } = { isPublished: true };
@@ -25,23 +17,18 @@ export async function listPublishedCourses(subject: string | null) {
});
}
-export async function createCourseForTutor(tutorId: string, input: CourseInput) {
- const title = (input.title || "").toString().trim();
- const subject = (input.subject || "").toString().trim();
-
- if (!title || !subject) {
- throw new ServiceError("title and subject are required", 400);
- }
+export async function createCourseForTutor(tutorId: string, input: unknown) {
+ const course = validateCoursePayload(input);
return prisma.course.create({
data: {
- title,
- subject,
- description: input.description ? String(input.description).trim() || null : null,
+ title: course.title,
+ subject: course.subject,
+ description: course.description,
tutorId,
- price: typeof input.price === "number" ? input.price : input.price ? parseFloat(String(input.price)) : null,
- level: input.level ? String(input.level).trim() || null : null,
- isPublished: !!input.isPublished,
+ price: course.price,
+ level: course.level,
+ isPublished: course.isPublished,
},
});
}
diff --git a/lib/services/message-service.ts b/lib/services/message-service.ts
index bc9232a..c2d1e9f 100644
--- a/lib/services/message-service.ts
+++ b/lib/services/message-service.ts
@@ -6,6 +6,7 @@ import {
sendMessage,
} from "@/lib/messages";
import { ServiceError } from "@/lib/services/service-error";
+import { validateMessageSendPayload } from "@/lib/validation";
export async function listMessageThreadsForUser(userId: string) {
return listThreadsForUser(userId);
@@ -46,40 +47,31 @@ export async function sendMessageFromUser(input: {
receiverId: string;
content: string;
}) {
- const receiverId = input.receiverId.trim();
- const content = input.content.trim();
+ const payload = validateMessageSendPayload(input);
- if (!receiverId) {
- throw new ServiceError("receiverId is required", 400);
- }
-
- if (receiverId === input.senderId) {
+ if (payload.receiverId === input.senderId) {
throw new ServiceError("Cannot message yourself", 400);
}
- if (!content || content.length > 8000) {
- throw new ServiceError("content must be 1-8000 characters", 400);
- }
-
- const receiver = await prisma.user.findUnique({ where: { id: receiverId } });
+ const receiver = await prisma.user.findUnique({ where: { id: payload.receiverId } });
if (!receiver) {
throw new ServiceError("Receiver not found", 404);
}
const message = await sendMessage({
senderId: input.senderId,
- receiverId,
- content,
+ receiverId: payload.receiverId,
+ content: payload.content,
});
return {
message: {
- id: message.id,
- content: message.content,
- createdAt: message.createdAt.toISOString(),
- isRead: message.isRead,
- senderId: message.senderId,
- receiverId: message.receiverId,
- },
- };
+ id: message.id,
+ content: message.content,
+ createdAt: message.createdAt.toISOString(),
+ isRead: message.isRead,
+ senderId: message.senderId,
+ receiverId: message.receiverId,
+ },
+ };
}
diff --git a/lib/services/study-plan-service.ts b/lib/services/study-plan-service.ts
index 4fd35d3..280abcb 100644
--- a/lib/services/study-plan-service.ts
+++ b/lib/services/study-plan-service.ts
@@ -1,46 +1,9 @@
import { prisma } from "@/lib/prisma";
import { ServiceError } from "@/lib/services/service-error";
-
-type TaskInput = {
- title?: unknown;
- dueDate?: unknown;
- courseId?: unknown;
- completed?: unknown;
-};
-
-type StudyPlanInput = {
- sourcePlanId?: unknown;
- planId?: unknown;
- tasks?: unknown;
-};
-
-function parseTasks(rawTasks: unknown) {
- if (!Array.isArray(rawTasks) || rawTasks.length === 0) {
- throw new ServiceError("At least one task is required", 400);
- }
-
- return rawTasks.map((rawTask, index) => {
- const task = rawTask as TaskInput;
- const title = (task?.title || "").toString().trim();
- const dueDate = new Date(String(task?.dueDate || ""));
- const courseId = Number(task?.courseId);
-
- if (!title) throw new ServiceError(`Task ${index + 1}: title is required`, 400);
- if (!Number.isInteger(courseId) || courseId <= 0) {
- throw new ServiceError(`Task ${index + 1}: course is required`, 400);
- }
- if (Number.isNaN(dueDate.getTime())) {
- throw new ServiceError(`Task ${index + 1}: due date is invalid`, 400);
- }
-
- return {
- title,
- dueDate,
- courseId,
- completed: !!task?.completed,
- };
- });
-}
+import {
+ validateStudyPlanCreatePayload,
+ validateStudyPlanUpdatePayload,
+} from "@/lib/validation";
export async function listStudyPlansForUser(userId: string) {
return prisma.studyPlan.findMany({
@@ -50,7 +13,7 @@ export async function listStudyPlansForUser(userId: string) {
});
}
-export async function createStudyPlanForUser(userId: string, input: StudyPlanInput) {
+export async function createStudyPlanForUser(userId: string, input: unknown) {
const studentExists = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
@@ -60,7 +23,8 @@ export async function createStudyPlanForUser(userId: string, input: StudyPlanInp
throw new ServiceError("Student not found", 400);
}
- const sourcePlanId = Number(input.sourcePlanId || 0);
+ const parsed = validateStudyPlanCreatePayload(input);
+ const sourcePlanId = parsed.sourcePlanId;
let taskPayload: Array<{ title: string; dueDate: Date; courseId: number; completed: boolean }> = [];
if (sourcePlanId) {
@@ -80,7 +44,7 @@ export async function createStudyPlanForUser(userId: string, input: StudyPlanInp
completed: !!task.completed,
}));
} else {
- taskPayload = parseTasks(input.tasks);
+ taskPayload = parsed.tasks || [];
}
return prisma.studyPlan.create({
@@ -94,11 +58,9 @@ export async function createStudyPlanForUser(userId: string, input: StudyPlanInp
});
}
-export async function updateOwnedStudyPlan(userId: string, input: StudyPlanInput) {
- const planId = Number(input.planId || 0);
- if (!planId) {
- throw new ServiceError("planId is required", 400);
- }
+export async function updateOwnedStudyPlan(userId: string, input: unknown) {
+ const parsed = validateStudyPlanUpdatePayload(input);
+ const planId = parsed.planId;
const existingPlan = await prisma.studyPlan.findUnique({
where: { id: planId },
@@ -113,14 +75,12 @@ export async function updateOwnedStudyPlan(userId: string, input: StudyPlanInput
throw new ServiceError("You do not own this plan", 403);
}
- const tasks = parseTasks(input.tasks);
-
return prisma.studyPlan.update({
where: { id: planId },
data: {
tasks: {
deleteMany: {},
- create: tasks,
+ create: parsed.tasks,
},
},
include: { tasks: true },
diff --git a/lib/validation.ts b/lib/validation.ts
new file mode 100644
index 0000000..1be382c
--- /dev/null
+++ b/lib/validation.ts
@@ -0,0 +1,239 @@
+import { Role } from "@prisma/client";
+import { ServiceError } from "@/lib/services/service-error";
+
+type RegistrationInput = {
+ fullName: string;
+ email: string;
+ password: string;
+ role: Role;
+};
+
+type LoginInput = {
+ email: string;
+ password: string;
+};
+
+type CourseInput = {
+ title: string;
+ subject: string;
+ description: string | null;
+ price: number | null;
+ level: string | null;
+ isPublished: boolean;
+};
+
+type StudyPlanTaskInput = {
+ title: string;
+ dueDate: Date;
+ courseId: number;
+ completed: boolean;
+};
+
+type StudyPlanCreateInput = {
+ sourcePlanId: number;
+ tasks?: StudyPlanTaskInput[];
+};
+
+type StudyPlanUpdateInput = {
+ planId: number;
+ tasks: StudyPlanTaskInput[];
+};
+
+type TaskStatusInput = {
+ completed: boolean;
+};
+
+type MessageSendInput = {
+ receiverId: string;
+ content: string;
+};
+
+type AssignmentInput = {
+ courseId: number;
+ title: string;
+ description: string | null;
+ dueDate: Date | null;
+};
+
+type SubmissionInput = {
+ assignmentId: number;
+ content: string;
+};
+
+const VALID_ROLES: Role[] = ["STUDENT", "TUTOR", "ADMIN"];
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === "object" ? (value as Record) : {};
+}
+
+function toTrimmedString(value: unknown): string {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function toOptionalTrimmedString(value: unknown): string | null {
+ const text = toTrimmedString(value);
+ return text || null;
+}
+
+export function validateRegistrationPayload(raw: unknown): RegistrationInput {
+ const body = asRecord(raw);
+ const fullName = toTrimmedString(body.fullName);
+ const email = toTrimmedString(body.email).toLowerCase();
+ const password = typeof body.password === "string" ? body.password : "";
+ const roleValue = toTrimmedString(body.role).toUpperCase();
+
+ if (!fullName) throw new ServiceError("Full name is required.", 400);
+ if (fullName.length < 2) throw new ServiceError("Name must be at least 2 characters.", 400);
+ if (!email) throw new ServiceError("Email is required.", 400);
+ if (!EMAIL_REGEX.test(email)) throw new ServiceError("Please enter a valid email address.", 400);
+ if (!password) throw new ServiceError("Password is required.", 400);
+ if (password.length < 8) throw new ServiceError("Password must be at least 8 characters.", 400);
+ if (!VALID_ROLES.includes(roleValue as Role)) {
+ throw new ServiceError("Please select a valid role (STUDENT, TUTOR, or ADMIN).", 400);
+ }
+
+ return { fullName, email, password, role: roleValue as Role };
+}
+
+export function validateLoginPayload(raw: unknown): LoginInput {
+ const body = asRecord(raw);
+ const email = toTrimmedString(body.email).toLowerCase();
+ const password = typeof body.password === "string" ? body.password : "";
+
+ if (!email) throw new ServiceError("Email is required.", 400);
+ if (!password) throw new ServiceError("Password is required.", 400);
+
+ return { email, password };
+}
+
+export function validateCoursePayload(raw: unknown): CourseInput {
+ const body = asRecord(raw);
+ const title = toTrimmedString(body.title);
+ const subject = toTrimmedString(body.subject);
+
+ if (!title || !subject) {
+ throw new ServiceError("title and subject are required", 400);
+ }
+
+ let price: number | null = null;
+ if (body.price !== undefined && body.price !== null && body.price !== "") {
+ price = Number(body.price);
+ if (!Number.isFinite(price) || price < 0) {
+ throw new ServiceError("Price must be a non-negative number", 400);
+ }
+ }
+
+ return {
+ title,
+ subject,
+ description: toOptionalTrimmedString(body.description),
+ price,
+ level: toOptionalTrimmedString(body.level),
+ isPublished: !!body.isPublished,
+ };
+}
+
+export function validateStudyPlanTasks(rawTasks: unknown): StudyPlanTaskInput[] {
+ if (!Array.isArray(rawTasks) || rawTasks.length === 0) {
+ throw new ServiceError("At least one task is required", 400);
+ }
+
+ return rawTasks.map((rawTask, index) => {
+ const task = asRecord(rawTask);
+ const title = toTrimmedString(task.title);
+ const dueDate = new Date(String(task.dueDate || ""));
+ const courseId = Number(task.courseId);
+
+ if (!title) throw new ServiceError(`Task ${index + 1}: title is required`, 400);
+ if (!Number.isInteger(courseId) || courseId <= 0) {
+ throw new ServiceError(`Task ${index + 1}: course is required`, 400);
+ }
+ if (Number.isNaN(dueDate.getTime())) {
+ throw new ServiceError(`Task ${index + 1}: due date is invalid`, 400);
+ }
+
+ return {
+ title,
+ dueDate,
+ courseId,
+ completed: !!task.completed,
+ };
+ });
+}
+
+export function validateStudyPlanCreatePayload(raw: unknown): StudyPlanCreateInput {
+ const body = asRecord(raw);
+ const sourcePlanId = Number(body.sourcePlanId || 0);
+
+ return {
+ sourcePlanId,
+ tasks: sourcePlanId ? undefined : validateStudyPlanTasks(body.tasks),
+ };
+}
+
+export function validateStudyPlanUpdatePayload(raw: unknown): StudyPlanUpdateInput {
+ const body = asRecord(raw);
+ const planId = Number(body.planId || 0);
+ if (!planId) throw new ServiceError("planId is required", 400);
+
+ return {
+ planId,
+ tasks: validateStudyPlanTasks(body.tasks),
+ };
+}
+
+export function validateTaskStatusPayload(raw: unknown): TaskStatusInput {
+ const body = asRecord(raw);
+ if (typeof body.completed !== "boolean") {
+ throw new ServiceError("Invalid completed value", 400);
+ }
+
+ return { completed: body.completed };
+}
+
+export function validateMessageSendPayload(raw: unknown): MessageSendInput {
+ const body = asRecord(raw);
+ const receiverId = toTrimmedString(body.receiverId);
+ const content = toTrimmedString(body.content);
+
+ if (!receiverId) throw new ServiceError("receiverId is required", 400);
+ if (!content || content.length > 8000) {
+ throw new ServiceError("content must be 1-8000 characters", 400);
+ }
+
+ return { receiverId, content };
+}
+
+export function validateAssignmentPayload(raw: unknown): AssignmentInput {
+ const body = asRecord(raw);
+ const courseId = Number(body.courseId || 0);
+ const title = toTrimmedString(body.title);
+
+ if (!courseId || !title) {
+ throw new ServiceError("courseId and title are required", 400);
+ }
+
+ const dueDate = body.dueDate ? new Date(String(body.dueDate)) : null;
+ if (dueDate && Number.isNaN(dueDate.getTime())) {
+ throw new ServiceError("dueDate is invalid", 400);
+ }
+
+ return {
+ courseId,
+ title,
+ description: toOptionalTrimmedString(body.description),
+ dueDate,
+ };
+}
+
+export function validateSubmissionPayload(raw: unknown): SubmissionInput {
+ const body = asRecord(raw);
+ const assignmentId = Number(body.assignmentId || 0);
+ const content = toTrimmedString(body.content);
+
+ if (!assignmentId) throw new ServiceError("assignmentId is required", 400);
+ if (!content) throw new ServiceError("Submission content cannot be empty", 400);
+
+ return { assignmentId, content };
+}
From 94f794f05a9908f4a6cbd4ed707c13d113a73205 Mon Sep 17 00:00:00 2001
From: hxddad
Date: Fri, 27 Mar 2026 10:49:03 -0400
Subject: [PATCH 6/7] add tutor course management actions for update, archive,
and unpublish
---
app/api/courses/[id]/route.test.ts | 78 ++++++++++++++++++++++++++++-
app/api/courses/[id]/route.ts | 58 ++++++++++++++++++++++
components/EditCourseForm.tsx | 79 ++++++++++++++++++++++++++++--
3 files changed, 211 insertions(+), 4 deletions(-)
diff --git a/app/api/courses/[id]/route.test.ts b/app/api/courses/[id]/route.test.ts
index 2dc852c..5d64db2 100644
--- a/app/api/courses/[id]/route.test.ts
+++ b/app/api/courses/[id]/route.test.ts
@@ -4,13 +4,14 @@ const prismaMock = vi.hoisted(() => ({
course: {
findUnique: vi.fn(),
update: vi.fn(),
+ delete: vi.fn(),
},
}));
vi.mock("@/lib/prisma", () => ({ prisma: prismaMock }));
import { signToken } from "@/lib/jwt";
-import { PATCH } from "./route";
+import { DELETE, PATCH } from "./route";
const OWNER = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
const OTHER_TUTOR = "cccccccc-cccc-cccc-cccc-cccccccccccc";
@@ -132,3 +133,78 @@ describe("PATCH /api/courses/[id]", () => {
expect(prismaMock.course.update).not.toHaveBeenCalled();
});
});
+
+describe("DELETE /api/courses/[id]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns 401 without token", async () => {
+ const req = new Request("http://localhost/api/courses/1", { method: "DELETE" });
+ const res = await DELETE(req, { params: { id: "1" } });
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 for non-tutor roles", async () => {
+ const req = new Request("http://localhost/api/courses/1", {
+ method: "DELETE",
+ headers: {
+ authorization: `Bearer ${signToken("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "STUDENT")}`,
+ },
+ });
+
+ const res = await DELETE(req, { params: { id: "1" } });
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 404 when course does not exist", async () => {
+ prismaMock.course.findUnique.mockResolvedValue(null);
+
+ const req = new Request("http://localhost/api/courses/1", {
+ method: "DELETE",
+ headers: {
+ authorization: `Bearer ${signToken(OWNER, "TUTOR")}`,
+ },
+ });
+
+ const res = await DELETE(req, { params: { id: "1" } });
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 409 when course has related records", async () => {
+ prismaMock.course.findUnique.mockResolvedValue({
+ tutorId: OWNER,
+ _count: { enrollments: 1, assignments: 0, tasks: 0, progresses: 0 },
+ } as never);
+
+ const req = new Request("http://localhost/api/courses/1", {
+ method: "DELETE",
+ headers: {
+ authorization: `Bearer ${signToken(OWNER, "TUTOR")}`,
+ },
+ });
+
+ const res = await DELETE(req, { params: { id: "1" } });
+ expect(res.status).toBe(409);
+ expect(prismaMock.course.delete).not.toHaveBeenCalled();
+ });
+
+ it("deletes owned course when no related records exist", async () => {
+ prismaMock.course.findUnique.mockResolvedValue({
+ tutorId: OWNER,
+ _count: { enrollments: 0, assignments: 0, tasks: 0, progresses: 0 },
+ } as never);
+ prismaMock.course.delete.mockResolvedValue({ id: 1 } as never);
+
+ const req = new Request("http://localhost/api/courses/1", {
+ method: "DELETE",
+ headers: {
+ authorization: `Bearer ${signToken(OWNER, "TUTOR")}`,
+ },
+ });
+
+ const res = await DELETE(req, { params: { id: "1" } });
+ expect(res.status).toBe(200);
+ expect(prismaMock.course.delete).toHaveBeenCalledWith({ where: { id: 1 } });
+ });
+});
diff --git a/app/api/courses/[id]/route.ts b/app/api/courses/[id]/route.ts
index 12d6761..f58dd1b 100644
--- a/app/api/courses/[id]/route.ts
+++ b/app/api/courses/[id]/route.ts
@@ -104,3 +104,61 @@ export async function PATCH(request: Request, { params }: { params: { id: string
return NextResponse.json({ error: "Failed to update course" }, { status: 500 });
}
}
+
+export async function DELETE(request: Request, { params }: { params: { id: string } }) {
+ try {
+ const auth = requireAuthenticatedUser(request);
+ if (auth instanceof Response) return auth;
+ const tutor = requireTutorRole(auth, "Only tutors can delete courses");
+ if (tutor instanceof Response) return tutor;
+
+ const id = Number(params.id);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: "Invalid course id" }, { status: 400 });
+ }
+
+ const existing = await prisma.course.findUnique({
+ where: { id },
+ select: {
+ tutorId: true,
+ _count: {
+ select: {
+ enrollments: true,
+ assignments: true,
+ tasks: true,
+ progresses: true,
+ },
+ },
+ },
+ });
+ if (!existing) {
+ return NextResponse.json({ error: "Course not found" }, { status: 404 });
+ }
+
+ const ownership = requireResourceOwner({
+ ownerId: existing.tutorId,
+ userId: tutor.sub,
+ errorMessage: "You do not own this course",
+ });
+ if (ownership instanceof Response) return ownership;
+
+ const hasDependents =
+ existing._count.enrollments > 0 ||
+ existing._count.assignments > 0 ||
+ existing._count.tasks > 0 ||
+ existing._count.progresses > 0;
+
+ if (hasDependents) {
+ return NextResponse.json(
+ { error: "Course cannot be deleted because it already has related activity. Unpublish it instead." },
+ { status: 409 }
+ );
+ }
+
+ await prisma.course.delete({ where: { id } });
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error("DELETE /api/courses/[id] error:", err);
+ return NextResponse.json({ error: "Failed to delete course" }, { status: 500 });
+ }
+}
diff --git a/components/EditCourseForm.tsx b/components/EditCourseForm.tsx
index d62bc3b..234252f 100644
--- a/components/EditCourseForm.tsx
+++ b/components/EditCourseForm.tsx
@@ -26,6 +26,7 @@ export default function EditCourseForm({ course }: { course: EditableCourse }) {
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [saving, setSaving] = useState(false);
+ const [deleting, setDeleting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -68,6 +69,60 @@ export default function EditCourseForm({ course }: { course: EditableCourse }) {
}
}
+ async function handleUnpublish() {
+ setError(null);
+ setSuccess(null);
+ setSaving(true);
+ try {
+ const res = await fetch(`/api/courses/${course.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ isPublished: false }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError(data.error || "Failed to unpublish course");
+ return;
+ }
+
+ setIsPublished(false);
+ setSuccess("Course unpublished successfully.");
+ router.refresh();
+ } catch {
+ setError("Failed to unpublish course");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete() {
+ const confirmed = window.confirm(
+ "Delete this course permanently? This only works when the course has no related activity."
+ );
+ if (!confirmed) return;
+
+ setError(null);
+ setSuccess(null);
+ setDeleting(true);
+ try {
+ const res = await fetch(`/api/courses/${course.id}`, {
+ method: "DELETE",
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError(data.error || "Failed to delete course");
+ return;
+ }
+
+ router.push("/dashboard/tutor/courses");
+ router.refresh();
+ } catch {
+ setError("Failed to delete course");
+ } finally {
+ setDeleting(false);
+ }
+ }
+
return (