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..266d4a2 100644 --- a/app/api/assignments/route.ts +++ b/app/api/assignments/route.ts @@ -1,26 +1,19 @@ 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"; +import { isServiceError } from "@/lib/services/service-error"; +import { validateAssignmentPayload } from "@/lib/validation"; // 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 +22,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 +32,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 +52,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,31 +66,23 @@ 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 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 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 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 } }); - 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 }, @@ -98,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/courses/[id]/route.test.ts b/app/api/courses/[id]/route.test.ts index 2dc852c..9c824fb 100644 --- a/app/api/courses/[id]/route.test.ts +++ b/app/api/courses/[id]/route.test.ts @@ -1,16 +1,33 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const prismaMock = vi.hoisted(() => ({ + $transaction: vi.fn(), course: { findUnique: vi.fn(), update: vi.fn(), + delete: vi.fn(), + }, + submission: { + deleteMany: vi.fn(), + }, + assignment: { + deleteMany: vi.fn(), + }, + enrollment: { + deleteMany: vi.fn(), + }, + progress: { + deleteMany: vi.fn(), + }, + task: { + deleteMany: 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 +149,99 @@ describe("PATCH /api/courses/[id]", () => { expect(prismaMock.course.update).not.toHaveBeenCalled(); }); }); + +describe("DELETE /api/courses/[id]", () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise) => callback(prismaMock as never)); + }); + + 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 is not archived yet", async () => { + prismaMock.course.findUnique.mockResolvedValue({ + tutorId: OWNER, + isPublished: true, + } 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 archived course and related records", async () => { + prismaMock.course.findUnique.mockResolvedValue({ + tutorId: OWNER, + isPublished: false, + } as never); + prismaMock.course.delete.mockResolvedValue({ id: 1 } as never); + prismaMock.submission.deleteMany.mockResolvedValue({ count: 0 } as never); + prismaMock.assignment.deleteMany.mockResolvedValue({ count: 0 } as never); + prismaMock.enrollment.deleteMany.mockResolvedValue({ count: 0 } as never); + prismaMock.progress.deleteMany.mockResolvedValue({ count: 0 } as never); + prismaMock.task.deleteMany.mockResolvedValue({ count: 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(200); + expect(prismaMock.submission.deleteMany).toHaveBeenCalledWith({ + where: { assignment: { courseId: 1 } }, + }); + expect(prismaMock.assignment.deleteMany).toHaveBeenCalledWith({ + where: { courseId: 1 }, + }); + expect(prismaMock.enrollment.deleteMany).toHaveBeenCalledWith({ + where: { courseId: 1 }, + }); + expect(prismaMock.progress.deleteMany).toHaveBeenCalledWith({ + where: { courseId: 1 }, + }); + expect(prismaMock.task.deleteMany).toHaveBeenCalledWith({ + where: { courseId: 1 }, + }); + 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 f48d03b..dedad9f 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: { @@ -111,3 +104,63 @@ 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, isPublished: 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; + + if (existing.isPublished) { + return NextResponse.json( + { error: "Archive the course before deleting it." }, + { status: 409 } + ); + } + + await prisma.$transaction(async (tx) => { + await tx.submission.deleteMany({ + where: { assignment: { courseId: id } }, + }); + await tx.assignment.deleteMany({ + where: { courseId: id }, + }); + await tx.enrollment.deleteMany({ + where: { courseId: id }, + }); + await tx.progress.deleteMany({ + where: { courseId: id }, + }); + await tx.task.deleteMany({ + where: { courseId: id }, + }); + await tx.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/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..739090b 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,41 @@ 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" }), + }) + ); + }); + + 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/api/courses/route.ts b/app/api/courses/route.ts index fdedb27..753d430 100644 --- a/app/api/courses/route.ts +++ b/app/api/courses/route.ts @@ -1,28 +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 }); } @@ -30,36 +20,20 @@ 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 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, - price: typeof body.price === "number" ? body.price : body.price ? parseFloat(body.price) : null, - level: body.level || null, - isPublished: !!body.isPublished, - }, - }); + const body = await request.json().catch(() => ({})); + 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 }); } -} \ 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..3bae811 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -1,19 +1,18 @@ 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, - 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 = requireAuth(request); + const auth = requireAuthenticatedUser(request); if (auth instanceof Response) return auth; const { searchParams } = new URL(request.url); @@ -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) => ({ - 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 }); } @@ -55,50 +37,22 @@ 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 { 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/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.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 23a1577..3040398 100644 --- a/app/api/study-plans/route.ts +++ b/app/api/study-plans/route.ts @@ -1,166 +1,41 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireAuth } 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 = requireAuth(req); + 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); - } - - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + 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 = 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 }); - } 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 }, - 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: targetStudentId, - 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 }); } @@ -168,48 +43,17 @@ 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 }); - } 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 }); - } - - if (auth.role === "STUDENT" && existingPlan.studentId !== auth.sub) { - return NextResponse.json({ error: "You do not own this plan" }, { status: 403 }); - } - - 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/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..5e46db5 100644 --- a/app/api/submissions/route.ts +++ b/app/api/submissions/route.ts @@ -1,24 +1,18 @@ 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"; +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 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 +28,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 +42,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,25 +77,14 @@ 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 }); } - 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 } }); @@ -109,7 +95,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 +104,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 +125,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 }, }); } @@ -148,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.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 7eef006..449469e 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -1,36 +1,20 @@ 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"; +import { isServiceError } from "@/lib/services/service-error"; +import { validateTaskStatusPayload } from "@/lib/validation"; 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") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } + 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 }, @@ -39,9 +23,12 @@ 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 }); - } + 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 }, @@ -50,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/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) { )}

Create a new course

- + diff --git a/app/dashboard/tutor/study-plan/[id]/edit/page.tsx b/app/dashboard/tutor/study-plan/[id]/edit/page.tsx index 38ff75d..9843035 100644 --- a/app/dashboard/tutor/study-plan/[id]/edit/page.tsx +++ b/app/dashboard/tutor/study-plan/[id]/edit/page.tsx @@ -65,7 +65,6 @@ export default async function EditStudyPlanPage({ params }: Props) { (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,65 @@ export default function EditCourseForm({ course }: { course: EditableCourse }) { } } + async function handleTogglePublish(nextPublished: boolean) { + 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: nextPublished }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error || `Failed to ${nextPublished ? "publish" : "archive"} course`); + return; + } + + setIsPublished(nextPublished); + setSuccess(`Course ${nextPublished ? "published" : "archived"} successfully.`); + router.refresh(); + } catch { + setError(`Failed to ${nextPublished ? "publish" : "archive"} course`); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (isPublished) { + setError("Archive the course before deleting it."); + return; + } + + const confirmed = window.confirm( + "Delete this archived course permanently? This will also remove its assignments, enrollments, progress, tasks, and submissions." + ); + 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 (
{error &&
{error}
} @@ -128,26 +188,32 @@ export default function EditCourseForm({ course }: { course: EditableCourse }) { /> - -
- setIsPublished(e.target.checked)} - /> - -
- -
+ +
+
+ + +
diff --git a/components/StudyPlanForm.tsx b/components/StudyPlanForm.tsx index c16b138..b693abe 100644 --- a/components/StudyPlanForm.tsx +++ b/components/StudyPlanForm.tsx @@ -16,7 +16,6 @@ type Course = { }; type StudyPlanFormProps = { - studentId: string; initialTasks?: Array<{ title: string; courseId: string | number; @@ -43,7 +42,6 @@ function toDateInputValue(value: string | Date): string { } export default function StudyPlanForm({ - studentId, initialTasks, planId, availableCourses, @@ -109,7 +107,7 @@ export default function StudyPlanForm({ } loadCourses(); - }, [availableCourses, studentId]); + }, [availableCourses]); function handleTaskChange(index: number, patch: Partial) { setTasks((prev) => @@ -158,7 +156,6 @@ export default function StudyPlanForm({ })), } : { - studentId, tasks: tasks.map((task) => ({ title: task.title.trim(), courseId: task.courseId, 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; diff --git a/lib/services/course-service.ts b/lib/services/course-service.ts new file mode 100644 index 0000000..8afefa0 --- /dev/null +++ b/lib/services/course-service.ts @@ -0,0 +1,34 @@ +import { prisma } from "@/lib/prisma"; +import { ServiceError } from "@/lib/services/service-error"; +import { validateCoursePayload } from "@/lib/validation"; + +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: unknown) { + const course = validateCoursePayload(input); + + return prisma.course.create({ + data: { + title: course.title, + subject: course.subject, + description: course.description, + tutorId, + price: course.price, + level: course.level, + isPublished: course.isPublished, + }, + }); +} diff --git a/lib/services/message-service.ts b/lib/services/message-service.ts new file mode 100644 index 0000000..c2d1e9f --- /dev/null +++ b/lib/services/message-service.ts @@ -0,0 +1,77 @@ +import { prisma } from "@/lib/prisma"; +import { + getConversation, + listThreadsForUser, + markThreadRead, + 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); +} + +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 payload = validateMessageSendPayload(input); + + if (payload.receiverId === input.senderId) { + throw new ServiceError("Cannot message yourself", 400); + } + + 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: 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, + }, + }; +} 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..280abcb --- /dev/null +++ b/lib/services/study-plan-service.ts @@ -0,0 +1,88 @@ +import { prisma } from "@/lib/prisma"; +import { ServiceError } from "@/lib/services/service-error"; +import { + validateStudyPlanCreatePayload, + validateStudyPlanUpdatePayload, +} from "@/lib/validation"; + +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: unknown) { + const studentExists = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + + if (!studentExists) { + throw new ServiceError("Student not found", 400); + } + + const parsed = validateStudyPlanCreatePayload(input); + const sourcePlanId = parsed.sourcePlanId; + 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 = parsed.tasks || []; + } + + return prisma.studyPlan.create({ + data: { + studentId: userId, + tasks: { + create: taskPayload, + }, + }, + include: { tasks: true }, + }); +} + +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 }, + 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); + } + + return prisma.studyPlan.update({ + where: { id: planId }, + data: { + tasks: { + deleteMany: {}, + 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 }; +}