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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 18 additions & 22 deletions app/api/assignments/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand 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)) {
Expand All @@ -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: {
Expand All @@ -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 },
},
},
});
Expand All @@ -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") {
Expand All @@ -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);
Expand Down
83 changes: 39 additions & 44 deletions app/api/assignments/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -29,21 +22,28 @@ 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 });
}
}

// 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({
Expand All @@ -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);
Expand All @@ -66,38 +66,33 @@ 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 },
});

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 });
}
Expand Down
32 changes: 7 additions & 25 deletions app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).email === "string" ? (body as Record<string, unknown>).email as string : undefined,
password: typeof (body as Record<string, unknown>).password === "string" ? (body as Record<string, unknown>).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) {
Expand Down Expand Up @@ -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." },
Expand Down
81 changes: 10 additions & 71 deletions app/api/auth/register/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).fullName === "string" ? (body as Record<string, unknown>).fullName as string : undefined,
email: typeof (body as Record<string, unknown>).email === "string" ? (body as Record<string, unknown>).email as string : undefined,
password: typeof (body as Record<string, unknown>).password === "string" ? (body as Record<string, unknown>).password as string : undefined,
role: typeof (body as Record<string, unknown>).role === "string" ? (body as Record<string, unknown>).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(
Expand All @@ -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,
},
});

Expand All @@ -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
Expand Down
Loading
Loading