diff --git a/app/api/announcements/[id]/route.ts b/app/api/announcements/[id]/route.ts index 6c32f47..4e0929b 100644 --- a/app/api/announcements/[id]/route.ts +++ b/app/api/announcements/[id]/route.ts @@ -4,7 +4,7 @@ import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Announcement } from '@/models/Announcement' -const ALLOWED_FIELDS = ['title', 'content', 'body', 'audience', 'category', 'pinned', 'expiresAt'] +const ALLOWED_FIELDS = ['title', 'content', 'audience', 'category', 'pinned'] export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { userId } = await auth() @@ -36,7 +36,7 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string } const announcement = await Announcement.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, { $set: sanitizedBody }, { new: true, runValidators: true, context: 'query' } ) @@ -63,7 +63,7 @@ export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: str } await connectDB() - const deleted = await Announcement.findOneAndDelete({ _id: id }) + const deleted = await Announcement.findOneAndDelete({ _id: id, teacherId: userId }) if (!deleted) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) diff --git a/app/api/assignments/[id]/route.ts b/app/api/assignments/[id]/route.ts index 21fca1c..7e894a8 100644 --- a/app/api/assignments/[id]/route.ts +++ b/app/api/assignments/[id]/route.ts @@ -4,7 +4,7 @@ import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Assignment } from '@/models/Assignment' -const ALLOWED_UPDATE_FIELDS = ['title', 'description', 'dueDate', 'deadline', 'subject', 'class', 'status', 'kanbanStatus', 'maxMarks'] +const ALLOWED_UPDATE_FIELDS = ['title', 'description', 'deadline', 'subject', 'class', 'status', 'kanbanStatus', 'maxMarks'] export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { userId } = await auth() @@ -36,9 +36,9 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string } const assignment = await Assignment.findOneAndUpdate( - { _id: id }, - sanitizedBody, - { new: true } + { _id: id, teacherId: userId }, + { $set: sanitizedBody }, + { new: true, runValidators: true, context: 'query' } ) if (!assignment) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(assignment) @@ -63,7 +63,7 @@ export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: str } await connectDB() - const deleted = await Assignment.findOneAndDelete({ _id: id }) + const deleted = await Assignment.findOneAndDelete({ _id: id, teacherId: userId }) if (!deleted) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) diff --git a/app/api/assignments/route.ts b/app/api/assignments/route.ts index 19021d8..1d88b7e 100644 --- a/app/api/assignments/route.ts +++ b/app/api/assignments/route.ts @@ -52,7 +52,7 @@ export async function GET(req: NextRequest) { if (error instanceof Error) { console.error('GET /api/assignments error:', error.message) } - return NextResponse.json({ error: error instanceof Error ? error.stack : 'Internal server error' }, { status: 500 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/app/api/attendance/route.ts b/app/api/attendance/route.ts index 14b6c4d..f2c9c3a 100644 --- a/app/api/attendance/route.ts +++ b/app/api/attendance/route.ts @@ -110,6 +110,12 @@ export async function POST(req: NextRequest) { // Support both single and bulk const isBulk = Array.isArray(body) + if (isBulk && body.length === 0) { + return NextResponse.json( + { error: "Bulk payload must include at least one record" }, + { status: 400 }, + ); + } if (isBulk && body.length > 500) { return NextResponse.json( { error: "Bulk payload exceeds maximum of 500 records" }, diff --git a/app/api/grades/[id]/route.ts b/app/api/grades/[id]/route.ts index 0141f63..b07b7b0 100644 --- a/app/api/grades/[id]/route.ts +++ b/app/api/grades/[id]/route.ts @@ -4,7 +4,18 @@ import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Grade } from '@/models/Grade' -const ALLOWED_UPDATE_FIELDS = ['marks', 'maxMarks', 'grade'] +const ALLOWED_UPDATE_FIELDS = ['marks', 'maxMarks'] + +function calcGrade(marks: number, max: number): string { + const pct = (marks / max) * 100 + if (pct >= 90) return 'A+' + if (pct >= 80) return 'A' + if (pct >= 70) return 'B+' + if (pct >= 60) return 'B' + if (pct >= 50) return 'C' + if (pct >= 40) return 'D' + return 'F' +} export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { userId } = await auth() @@ -15,7 +26,7 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string // Validate ObjectId if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }) } let body @@ -34,10 +45,30 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string } await connectDB() + const existing = await Grade.findOne({ _id: id, teacherId: userId }).lean() + if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const updatePayload: Record = { ...sanitizedBody } + const hasMarks = 'marks' in updatePayload + const hasMaxMarks = 'maxMarks' in updatePayload + if (hasMarks || hasMaxMarks) { + const nextMarks = hasMarks ? Number(updatePayload.marks) : existing.marks + const nextMaxMarks = hasMaxMarks ? Number(updatePayload.maxMarks) : existing.maxMarks + if ((hasMarks && Number.isNaN(nextMarks)) || (hasMaxMarks && Number.isNaN(nextMaxMarks))) { + return NextResponse.json({ error: 'marks and maxMarks must be numbers' }, { status: 400 }) + } + if (nextMarks > nextMaxMarks) { + return NextResponse.json({ error: 'marks must be less than or equal to maxMarks' }, { status: 400 }) + } + if (hasMarks) updatePayload.marks = nextMarks + if (hasMaxMarks) updatePayload.maxMarks = nextMaxMarks + updatePayload.grade = calcGrade(nextMarks, nextMaxMarks) + } + const grade = await Grade.findOneAndUpdate( - { _id: id }, - sanitizedBody, - { new: true } + { _id: id, teacherId: userId }, + { $set: updatePayload }, + { new: true, runValidators: true, context: 'query' } ) if (!grade) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(grade) @@ -55,8 +86,13 @@ export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: str try { const { id } = await ctx.params + + if (!mongoose.Types.ObjectId.isValid(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }) + } + await connectDB() - const deleted = await Grade.findOneAndDelete({ _id: id }) + const deleted = await Grade.findOneAndDelete({ _id: id, teacherId: userId }) if (!deleted) { return NextResponse.json({ error: 'Grade not found' }, { status: 404 }) diff --git a/app/api/grades/route.ts b/app/api/grades/route.ts index b9da63d..1b0dc80 100644 --- a/app/api/grades/route.ts +++ b/app/api/grades/route.ts @@ -9,10 +9,10 @@ const GradeSchema = z.object({ studentName: z.string().min(1), subject: z.string().min(1), marks: z.number().min(0), - maxMarks: z.number().min(1).optional(), + maxMarks: z.number().min(1).default(100), term: z.string().optional(), }).refine( - (data) => !data.maxMarks || data.marks <= data.maxMarks, + (data) => data.marks <= data.maxMarks, { message: 'marks must be less than or equal to maxMarks', path: ['marks'], @@ -21,7 +21,7 @@ const GradeSchema = z.object({ function calcGrade(marks: number, max: number): string { const pct = (marks / max) * 100 - if (pct > 90) return 'A+' + if (pct >= 90) return 'A+' if (pct >= 80) return 'A' if (pct >= 70) return 'B+' if (pct >= 60) return 'B' @@ -48,7 +48,7 @@ export async function GET(req: NextRequest) { return NextResponse.json(grades) } catch (error) { console.error('GET /api/grades error:', error instanceof Error ? error.message : error) - return NextResponse.json({ error: error instanceof Error ? error.stack : 'Internal server error' }, { status: 500 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -70,19 +70,19 @@ export async function POST(req: NextRequest) { if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) const data = parsed.data - const max = data.maxMarks! - const term = data.term ?? 'Term 1' + const maxMarks = data.maxMarks + const term = data.term?.trim() || 'Term 1' - const grade = Grade.findOneAndUpdate( + const grade = await Grade.findOneAndUpdate( { teacherId: userId, studentId: data.studentId, subject: data.subject, term }, - { $set: { ...data, term, teacherId: userId, grade: calcGrade(data.marks, max) } }, - { upsert: true, new: true } + { $set: { ...data, term, maxMarks, teacherId: userId, grade: calcGrade(data.marks, maxMarks) } }, + { upsert: true, new: true, runValidators: true, context: 'query' } ) return NextResponse.json(grade, { status: 201 }) } catch (error) { if (error instanceof Error) { console.error('POST /api/grades error:', error.message) } - return NextResponse.json({ error: error instanceof Error ? error.stack : 'Internal server error' }, { status: 500 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index a3d98bf..f0d39ca 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -4,15 +4,14 @@ import { connectDB } from '@/lib/mongodb' import { Teacher } from '@/models/Teacher' export async function GET(req: NextRequest) { + const { userId } = await auth() + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { searchParams } = new URL(req.url) const queryUserId = searchParams.get('userId') - - let userId: string | null = queryUserId - if (!userId) { - const session = await auth() - userId = session.userId + if (queryUserId && queryUserId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) try { await connectDB() diff --git a/app/api/students/[id]/route.ts b/app/api/students/[id]/route.ts index 2eaaf93..f21cab6 100644 --- a/app/api/students/[id]/route.ts +++ b/app/api/students/[id]/route.ts @@ -4,7 +4,7 @@ import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Student } from '@/models/Student' -const ALLOWED_UPDATE_FIELDS = ['name', 'email', 'grade', 'rollNo', 'class', 'phone', 'address', 'parentName', 'parentPhone'] +const ALLOWED_UPDATE_FIELDS = ['name', 'email', 'rollNo', 'class', 'phone', 'address', 'parentName', 'parentPhone'] export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { userId } = await auth() @@ -35,9 +35,9 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string await connectDB() const student = await Student.findOneAndUpdate( - { _id: id }, - sanitizedBody, - { new: true } + { _id: id, teacherId: userId }, + { $set: sanitizedBody }, + { new: true, runValidators: true, context: 'query' } ) if (!student) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(student) @@ -65,7 +65,7 @@ export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: str } await connectDB() - const deleted = await Student.findOneAndDelete({ _id: id }) + const deleted = await Student.findOneAndDelete({ _id: id, teacherId: userId }) if (!deleted) { return NextResponse.json({ error: 'Student not found' }, { status: 404 }) diff --git a/app/api/students/route.ts b/app/api/students/route.ts index 8f3dcc2..bed5f30 100644 --- a/app/api/students/route.ts +++ b/app/api/students/route.ts @@ -92,9 +92,12 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Malformed JSON' }, { status: 400 }) } - StudentSchema.safeParse(body) + const parsed = StudentSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + } - const student = await Student.create({ ...(body as Record), teacherId: userId }) + const student = await Student.create({ ...parsed.data, teacherId: userId }) return NextResponse.json(student, { status: 201 }) } catch (error) { if (error instanceof Error) { diff --git a/app/dashboard/OverviewClient.tsx b/app/dashboard/OverviewClient.tsx index 169795e..d7b40af 100644 --- a/app/dashboard/OverviewClient.tsx +++ b/app/dashboard/OverviewClient.tsx @@ -189,27 +189,39 @@ export function OverviewClient() { const [ studentsRes, assignmentsRes, + assignmentsActiveRes, attendanceRes, gradesRes, announcementsRes, ] = await Promise.all([ fetch("/api/students?limit=5"), fetch("/api/assignments"), + fetch("/api/assignments?status=active&limit=1"), fetch("/api/attendance"), fetch("/api/grades"), fetch("/api/announcements?limit=5"), ]); - const [students, assignmentsData, attendance, grades, announcements] = - await Promise.all([ - studentsRes.json(), - assignmentsRes.json(), - attendanceRes.json(), - gradesRes.json(), - announcementsRes.json(), - ]); + const [ + students, + assignmentsData, + assignmentsActiveData, + attendance, + grades, + announcements, + ] = await Promise.all([ + studentsRes.json(), + assignmentsRes.json(), + assignmentsActiveRes.json(), + attendanceRes.json(), + gradesRes.json(), + announcementsRes.json(), + ]); const assignments = assignmentsData.assignments ?? assignmentsData; + const assignmentsTotal = assignmentsData.total ?? assignments.length; + const pendingAssignments = assignmentsActiveData.total ?? + (assignmentsActiveData.assignments ?? assignmentsActiveData ?? []).length; // ── Attendance ── const dateMap: Record< @@ -316,13 +328,9 @@ export function OverviewClient() { .slice(0, 5); setStats({ - totalStudents: students.students?.length ?? 0, - totalAssignments: Array.isArray(assignments) - ? assignments.length - : (assignments.length ?? 0), - pendingAssignments: assignments.filter( - (a: { status: string }) => a.status === "active", - ).length, + totalStudents: students.total ?? students.students?.length ?? 0, + totalAssignments: assignmentsTotal, + pendingAssignments, attendancePct, attendanceBreakdown: { present: totalPresent, diff --git a/app/dashboard/assignments/AssignmentsClient.tsx b/app/dashboard/assignments/AssignmentsClient.tsx index e0e7f13..b9b1c0d 100644 --- a/app/dashboard/assignments/AssignmentsClient.tsx +++ b/app/dashboard/assignments/AssignmentsClient.tsx @@ -344,7 +344,7 @@ export function AssignmentsClient() { description: a.description, subject: a.subject, class: a.class, - deadline: a.deadline, + deadline: a.deadline ? a.deadline.slice(0, 10) : "", maxMarks: a.maxMarks, }); setModalOpen(true); @@ -356,10 +356,18 @@ export function AssignmentsClient() { : "/api/assignments"; const method = editing ? "PUT" : "POST"; try { + const normalizedMaxMarks = Number.isFinite(data.maxMarks) + ? Number(data.maxMarks) + : undefined; const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...data, maxMarks: Number(data.maxMarks) }), + body: JSON.stringify({ + ...data, + ...(normalizedMaxMarks !== undefined + ? { maxMarks: normalizedMaxMarks } + : {}), + }), }); if (res.ok) { toast( @@ -387,7 +395,7 @@ export function AssignmentsClient() { ), ); try { - await fetch(`/api/assignments/${id}`, { + const res = await fetch(`/api/assignments/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -395,6 +403,7 @@ export function AssignmentsClient() { status: col === "submitted" ? "closed" : "active", }), }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (error) { fetchAssignments(); toast( diff --git a/app/dashboard/grades/GradesClient.tsx b/app/dashboard/grades/GradesClient.tsx index fdaa00b..919ba90 100644 --- a/app/dashboard/grades/GradesClient.tsx +++ b/app/dashboard/grades/GradesClient.tsx @@ -60,7 +60,7 @@ function pct(marks: number, max: number) { } const GRADE_POINT: Record = { - 'A+': 10, A: 9, 'B+': 8, B: 7, C: 6, D: 5, F: 0, + 'A+': 10, A: 9, 'B+': 8, B: 7, C: 6, D: 4, F: 0, } function cgpaFromGrades(gradeList: Grade[]) { @@ -294,10 +294,13 @@ export function GradesClient() { toast("Selected student not found", "error"); return; } + const normalizedMaxMarks = Number.isFinite(data.maxMarks) + ? Number(data.maxMarks) + : undefined; const payload = { ...data, marks: Number(data.marks), - maxMarks: Number(data.maxMarks), + ...(normalizedMaxMarks !== undefined ? { maxMarks: normalizedMaxMarks } : {}), studentName: selectedStudent.name, }; const url = editing ? `/api/grades/${editing._id}` : "/api/grades"; diff --git a/app/dashboard/students/StudentsClient.tsx b/app/dashboard/students/StudentsClient.tsx index 82a1d96..b2d2a82 100644 --- a/app/dashboard/students/StudentsClient.tsx +++ b/app/dashboard/students/StudentsClient.tsx @@ -211,7 +211,7 @@ function StudentDrawer({ }, [grades]); const cgpa = useMemo(() => { - const GRADE_POINT: Record = { 'A+': 10, A: 9, 'B+': 8, B: 7, C: 6, D: 5, F: 0 } + const GRADE_POINT: Record = { 'A+': 10, A: 9, 'B+': 8, B: 7, C: 6, D: 4, F: 0 } if (!grades.length) return null const sum = grades.reduce((s, g) => s + (GRADE_POINT[g.grade] ?? 0), 0) return (sum / grades.length).toFixed(2) diff --git a/components/dashboard/Navbar.tsx b/components/dashboard/Navbar.tsx index 49b4a0c..b5664cf 100644 --- a/components/dashboard/Navbar.tsx +++ b/components/dashboard/Navbar.tsx @@ -8,22 +8,10 @@ interface NavbarProps { } export function Navbar({ onMenuClick, title }: NavbarProps) { - const [dark, setDark] = useState(false); - - // Initialize theme from localStorage and system preference on client-side only - useEffect(() => { - try { - const stored = localStorage.getItem("theme"); - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - const isDark = stored ? stored === "dark" : prefersDark; - setDark(isDark); - document.documentElement.classList.toggle("dark", isDark); - } catch (e) { - // Silently fail if localStorage is not available - } - }, []); + const [dark, setDark] = useState(() => { + if (typeof document === "undefined") return false; + return document.documentElement.classList.contains("dark"); + }); // Sync dark class to whenever dark state changes useEffect(() => { diff --git a/lib/mongodb.ts b/lib/mongodb.ts index 01dddf9..f8e26cf 100644 --- a/lib/mongodb.ts +++ b/lib/mongodb.ts @@ -1,9 +1,11 @@ import mongoose from 'mongoose' -const MONGODB_URI = process.env.MONGODB_URI! - -if (!MONGODB_URI) { - throw new Error('Please define the MONGODB_URI environment variable') +function getMongoUri(): string { + const uri = process.env.MONGODB_URI + if (!uri) { + throw new Error('Please define the MONGODB_URI environment variable') + } + return uri } interface MongooseCache { @@ -22,8 +24,9 @@ export async function connectDB(): Promise { if (cached.conn) return cached.conn if (!cached.promise) { + const uri = getMongoUri() cached.promise = mongoose - .connect(MONGODB_URI, { bufferCommands: false }) + .connect(uri, { bufferCommands: false }) .catch((error) => { cached.promise = null throw error diff --git a/next.config.ts b/next.config.ts index 6230bd9..b08b686 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,27 +4,30 @@ const nextConfig: NextConfig = { images: { remotePatterns: [ { - protocol: 'https', - hostname: 'img.clerk.com', + protocol: "https", + hostname: "img.clerk.com", }, { - protocol: 'https', - hostname: 'images.clerk.dev', + protocol: "https", + hostname: "images.clerk.dev", }, ], }, async headers() { return [ { - source: '/(.*)', + source: "/(.*)", headers: [ - { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, - { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, ], }, - ] + ]; }, }; diff --git a/middleware.ts b/proxy.ts similarity index 99% rename from middleware.ts rename to proxy.ts index f352d7d..d8e7f84 100644 --- a/middleware.ts +++ b/proxy.ts @@ -17,4 +17,4 @@ export const config = { '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', '/(api|trpc)(.*)', ], -} +} \ No newline at end of file