diff --git a/app/api/announcements/[id]/route.ts b/app/api/announcements/[id]/route.ts index 6c32f47..a44bcb6 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() @@ -27,16 +27,30 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Invalid JSON request body' }, { status: 400 }) } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return NextResponse.json({ error: 'Invalid JSON request body' }, { status: 400 }) + } + + const payload = body as Record + const unknownFields = Object.keys(payload).filter((key) => !ALLOWED_FIELDS.includes(key)) + if (unknownFields.length > 0) { + return NextResponse.json({ error: `Unknown field(s): ${unknownFields.join(', ')}` }, { status: 400 }) + } + // Sanitize: only allow whitelisted fields const sanitizedBody: Record = {} for (const key of ALLOWED_FIELDS) { - if (key in body) { - sanitizedBody[key] = body[key] + if (Object.prototype.hasOwnProperty.call(payload, key)) { + sanitizedBody[key] = payload[key] } } + if (Object.keys(sanitizedBody).length === 0) { + return NextResponse.json({ error: 'No updatable fields provided' }, { status: 400 }) + } + const announcement = await Announcement.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, { $set: sanitizedBody }, { new: true, runValidators: true, context: 'query' } ) @@ -63,7 +77,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..f6b5ff8 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() @@ -18,8 +18,6 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Invalid id' }, { status: 400 }) } - await connectDB() - let body try { body = await req.json() @@ -27,18 +25,34 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + } + + const payload = body as Record + const unknownFields = Object.keys(payload).filter((key) => !ALLOWED_UPDATE_FIELDS.includes(key)) + if (unknownFields.length > 0) { + return NextResponse.json({ error: `Unknown field(s): ${unknownFields.join(', ')}` }, { status: 400 }) + } + // Sanitize: only allow whitelisted fields const sanitizedBody: Record = {} for (const key of ALLOWED_UPDATE_FIELDS) { - if (key in body) { - sanitizedBody[key] = body[key] + if (Object.prototype.hasOwnProperty.call(payload, key)) { + sanitizedBody[key] = payload[key] } } + if (Object.keys(sanitizedBody).length === 0) { + return NextResponse.json({ error: 'No updatable fields provided' }, { status: 400 }) + } + + await connectDB() + const assignment = await Assignment.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, sanitizedBody, - { new: true } + { new: true, runValidators: true, context: 'query' } ) if (!assignment) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(assignment) @@ -63,7 +77,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..e9f1006 100644 --- a/app/api/assignments/route.ts +++ b/app/api/assignments/route.ts @@ -49,10 +49,8 @@ export async function GET(req: NextRequest) { return NextResponse.json({ assignments, total, page, pages: Math.ceil(total / limit) }) } catch (error) { - 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 }) + console.error('GET /api/assignments error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/app/api/grades/[id]/route.ts b/app/api/grades/[id]/route.ts index 0141f63..0929397 100644 --- a/app/api/grades/[id]/route.ts +++ b/app/api/grades/[id]/route.ts @@ -3,8 +3,9 @@ import { NextRequest, NextResponse } from 'next/server' import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Grade } from '@/models/Grade' +import { calculateLetterGrade } from '@/lib/grading' -const ALLOWED_UPDATE_FIELDS = ['marks', 'maxMarks', 'grade'] +const ALLOWED_UPDATE_FIELDS = ['studentId', 'studentName', 'subject', 'term', 'marks', 'maxMarks'] export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { userId } = await auth() @@ -25,19 +26,78 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + } + + const payload = body as Record + const unknownFields = Object.keys(payload).filter((key) => !ALLOWED_UPDATE_FIELDS.includes(key)) + if (unknownFields.length > 0) { + return NextResponse.json({ error: `Unknown field(s): ${unknownFields.join(', ')}` }, { status: 400 }) + } + // Sanitize: only allow whitelisted fields const sanitizedBody: Record = {} for (const key of ALLOWED_UPDATE_FIELDS) { - if (key in body) { - sanitizedBody[key] = body[key] + if (Object.prototype.hasOwnProperty.call(payload, key)) { + sanitizedBody[key] = payload[key] + } + } + + if (Object.keys(sanitizedBody).length === 0) { + return NextResponse.json({ error: 'No updatable fields provided' }, { status: 400 }) + } + + if ('studentId' in sanitizedBody) { + const studentId = sanitizedBody.studentId + if (typeof studentId !== 'string' || !mongoose.Types.ObjectId.isValid(studentId)) { + return NextResponse.json({ error: 'studentId must be a valid id' }, { status: 400 }) + } + } + + if ('studentName' in sanitizedBody && typeof sanitizedBody.studentName !== 'string') { + return NextResponse.json({ error: 'studentName must be a string' }, { status: 400 }) + } + if ('subject' in sanitizedBody && typeof sanitizedBody.subject !== 'string') { + return NextResponse.json({ error: 'subject must be a string' }, { status: 400 }) + } + if ('term' in sanitizedBody && typeof sanitizedBody.term !== 'string') { + return NextResponse.json({ error: 'term must be a string' }, { status: 400 }) + } + + if ('marks' in sanitizedBody) { + const marks = sanitizedBody.marks + if (typeof marks !== 'number' || Number.isNaN(marks) || marks < 0) { + return NextResponse.json({ error: 'marks must be a number >= 0' }, { status: 400 }) + } + } + if ('maxMarks' in sanitizedBody) { + const maxMarks = sanitizedBody.maxMarks + if (typeof maxMarks !== 'number' || Number.isNaN(maxMarks) || maxMarks < 1) { + return NextResponse.json({ error: 'maxMarks must be a number >= 1' }, { status: 400 }) } } await connectDB() + + const existingGrade = await Grade.findOne({ _id: id, teacherId: userId }) + if (!existingGrade) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const nextMarks = typeof sanitizedBody.marks === 'number' ? sanitizedBody.marks : existingGrade.marks + const nextMaxMarks = typeof sanitizedBody.maxMarks === 'number' ? sanitizedBody.maxMarks : (existingGrade.maxMarks ?? 100) + + if (nextMarks > nextMaxMarks) { + return NextResponse.json({ error: 'marks must be less than or equal to maxMarks' }, { status: 400 }) + } + const grade = await Grade.findOneAndUpdate( - { _id: id }, - sanitizedBody, - { new: true } + { _id: id, teacherId: userId }, + { + ...sanitizedBody, + maxMarks: nextMaxMarks, + grade: calculateLetterGrade(nextMarks, nextMaxMarks), + }, + { new: true, runValidators: true, context: 'query' } ) if (!grade) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(grade) @@ -45,6 +105,9 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string if (error instanceof Error) { console.error('PUT /api/grades/[id] error:', error.message) } + if ((error as { code?: number }).code === 11000) { + return NextResponse.json({ error: 'A grade for this student, subject, and term already exists' }, { status: 409 }) + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -56,7 +119,7 @@ export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: str try { const { id } = await ctx.params 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..49cdad5 100644 --- a/app/api/grades/route.ts +++ b/app/api/grades/route.ts @@ -2,6 +2,7 @@ import { auth } from '@clerk/nextjs/server' import { NextRequest, NextResponse } from 'next/server' import { connectDB } from '@/lib/mongodb' import { Grade } from '@/models/Grade' +import { calculateLetterGrade } from '@/lib/grading' import { z } from 'zod' const GradeSchema = z.object({ @@ -19,17 +20,6 @@ const GradeSchema = z.object({ } ) -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 GET(req: NextRequest) { const { userId } = await auth() if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -47,8 +37,8 @@ export async function GET(req: NextRequest) { const grades = await Grade.find(query).sort({ createdAt: -1 }).lean() 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 }) + console.error('GET /api/grades error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -70,19 +60,17 @@ 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 max = data.maxMarks ?? 100 const term = data.term ?? '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, maxMarks: max, term, teacherId: userId, grade: calculateLetterGrade(data.marks, max) } }, + { 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 }) + console.error('POST /api/grades error:', error) + 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..dbdebdc 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -3,15 +3,8 @@ import { NextRequest, NextResponse } from 'next/server' import { connectDB } from '@/lib/mongodb' import { Teacher } from '@/models/Teacher' -export async function GET(req: NextRequest) { - 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 - } +export async function GET() { + const { userId } = await auth() if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) try { diff --git a/app/api/students/[id]/route.ts b/app/api/students/[id]/route.ts index 2eaaf93..478c65e 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() @@ -25,19 +25,33 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Bad Request' }, { status: 400 }) } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return NextResponse.json({ error: 'Bad Request' }, { status: 400 }) + } + + const payload = body as Record + const unknownFields = Object.keys(payload).filter((key) => !ALLOWED_UPDATE_FIELDS.includes(key)) + if (unknownFields.length > 0) { + return NextResponse.json({ error: `Unknown field(s): ${unknownFields.join(', ')}` }, { status: 400 }) + } + // Sanitize: only allow whitelisted fields const sanitizedBody: Record = {} for (const key of ALLOWED_UPDATE_FIELDS) { - if (key in body) { - sanitizedBody[key] = body[key] + if (Object.prototype.hasOwnProperty.call(payload, key)) { + sanitizedBody[key] = payload[key] } } + if (Object.keys(sanitizedBody).length === 0) { + return NextResponse.json({ error: 'No updatable fields provided' }, { status: 400 }) + } + await connectDB() const student = await Student.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, sanitizedBody, - { new: true } + { new: true, runValidators: true, context: 'query' } ) if (!student) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(student) @@ -65,7 +79,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..e31839f 100644 --- a/app/dashboard/OverviewClient.tsx +++ b/app/dashboard/OverviewClient.tsx @@ -8,6 +8,7 @@ import { } from 'recharts' import { CardSkeleton } from '@/components/ui/Skeleton' import { Badge } from '@/components/ui/Badge' +import { getGradePoint } from '@/lib/grading' interface Stats { totalStudents: number @@ -189,27 +190,68 @@ export function OverviewClient() { const [ studentsRes, assignmentsRes, + activeAssignmentsRes, 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] = + const [ + studentsData, + assignmentsData, + activeAssignmentsData, + attendance, + grades, + announcements, + ] = await Promise.all([ studentsRes.json(), assignmentsRes.json(), + activeAssignmentsRes.json(), attendanceRes.json(), gradesRes.json(), announcementsRes.json(), ]); - const assignments = assignmentsData.assignments ?? assignmentsData; + const studentsList = Array.isArray(studentsData?.students) + ? studentsData.students + : Array.isArray(studentsData) + ? studentsData + : []; + + const assignmentsList = Array.isArray(assignmentsData?.assignments) + ? assignmentsData.assignments + : Array.isArray(assignmentsData) + ? assignmentsData + : []; + + const totalStudents = + typeof studentsData?.total === "number" + ? studentsData.total + : studentsList.length; + + const totalAssignments = + typeof assignmentsData?.total === "number" + ? assignmentsData.total + : assignmentsList.length; + + const pendingAssignments = + typeof activeAssignmentsData?.total === "number" + ? activeAssignmentsData.total + : Array.isArray(activeAssignmentsData?.assignments) + ? activeAssignmentsData.assignments.length + : Array.isArray(activeAssignmentsData) + ? activeAssignmentsData.length + : assignmentsList.filter( + (a: { status: string }) => a.status === "active", + ).length; // ── Attendance ── const dateMap: Record< @@ -248,18 +290,9 @@ export function OverviewClient() { })); // ── CGPA trend ── - const GRADE_POINT: Record = { - "A+": 10, - A: 9, - "B+": 8, - B: 7, - C: 6, - D: 4, - F: 0, - }; const termMap: Record = {}; for (const g of grades) { - (termMap[g.term] ??= []).push(GRADE_POINT[g.grade] ?? 0); + (termMap[g.term] ??= []).push(getGradePoint(g.grade)); } const TERM_ORDER = [ "Term 1", @@ -296,7 +329,7 @@ export function OverviewClient() { // ── Upcoming deadlines ── const now = Date.now(); const upcomingDeadlines = ( - assignments as { + assignmentsList as { _id: string; title: string; subject: string; @@ -316,13 +349,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, + totalAssignments, + pendingAssignments, attendancePct, attendanceBreakdown: { present: totalPresent, @@ -334,7 +363,7 @@ export function OverviewClient() { gradeDistribution, upcomingDeadlines, recentAnnouncements: announcements.slice(0, 5), - recentStudents: students.students?.slice(0, 5) ?? [], + recentStudents: studentsList.slice(0, 5), }); setLastRefreshed(new Date()); } catch (err) { @@ -701,13 +730,12 @@ export function OverviewClient() { {stats.upcomingDeadlines.map((a) => (
  • {a.daysLeft < 0 ? "!" : a.daysLeft}
    diff --git a/app/dashboard/grades/GradesClient.tsx b/app/dashboard/grades/GradesClient.tsx index fdaa00b..a307f1d 100644 --- a/app/dashboard/grades/GradesClient.tsx +++ b/app/dashboard/grades/GradesClient.tsx @@ -13,6 +13,7 @@ import { Badge } from '@/components/ui/Badge' import { TableSkeleton } from '@/components/ui/Skeleton' import { useToast } from '@/components/ui/Toast' import { ConfirmModal } from "@/components/ui/ConfirmModal"; +import { calculateCgpaFromGrades, getGradePoint } from '@/lib/grading' interface Grade { _id: string @@ -59,16 +60,6 @@ function pct(marks: number, max: number) { return Math.round((marks / max) * 100); } -const GRADE_POINT: Record = { - 'A+': 10, A: 9, 'B+': 8, B: 7, C: 6, D: 5, F: 0, -} - -function cgpaFromGrades(gradeList: Grade[]) { - if (!gradeList.length) return null - const total = gradeList.reduce((s, g) => s + (GRADE_POINT[g.grade] ?? 0), 0) - return (total / gradeList.length).toFixed(2) -} - const LINE_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'] function exportCsv(grades: Grade[], filename = "grades.csv") { @@ -211,9 +202,9 @@ export function GradesClient() { const avg = sg.length > 0 ? Math.round( - sg.reduce((s, g) => s + pct(g.marks, g.maxMarks), 0) / - sg.length, - ) + sg.reduce((s, g) => s + pct(g.marks, g.maxMarks), 0) / + sg.length, + ) : 0; return { subject: subject.length > 10 ? subject.slice(0, 10) + "…" : subject, @@ -243,7 +234,7 @@ export function GradesClient() { const sg = grades.filter((g) => g.studentId === cgpaStudentId); return { grades: sg, - cgpa: cgpaFromGrades(sg), + cgpa: calculateCgpaFromGrades(sg), name: students.find((s) => s._id === cgpaStudentId)?.name ?? "", }; }, [cgpaStudentId, grades, students]); @@ -339,9 +330,9 @@ export function GradesClient() { const activeAvg = activeGrades.length > 0 ? Math.round( - activeGrades.reduce((s, g) => s + pct(g.marks, g.maxMarks), 0) / - activeGrades.length, - ) + activeGrades.reduce((s, g) => s + pct(g.marks, g.maxMarks), 0) / + activeGrades.length, + ) : 0; const activeFilters = @@ -364,11 +355,10 @@ export function GradesClient() { @@ -673,7 +663,7 @@ export function GradesClient() { - {GRADE_POINT[g.grade] ?? 0} + {getGradePoint(g.grade)} ))} @@ -792,19 +782,17 @@ export function GradesClient() { diff --git a/lib/grading.ts b/lib/grading.ts new file mode 100644 index 0000000..efce5fe --- /dev/null +++ b/lib/grading.ts @@ -0,0 +1,31 @@ +export const GRADE_POINTS: Record = { + 'A+': 10, + A: 9, + 'B+': 8, + B: 7, + C: 6, + D: 5, + F: 0, +} + +export function getGradePoint(grade: string | null | undefined): number { + if (!grade) return 0 + return GRADE_POINTS[grade] ?? 0 +} + +export function calculateCgpaFromGrades(grades: T[]): string | null { + if (!grades.length) return null + const total = grades.reduce((sum, item) => sum + getGradePoint(item.grade), 0) + return (total / grades.length).toFixed(2) +} + +export function calculateLetterGrade(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' +} diff --git a/models/Grade.ts b/models/Grade.ts index 311b757..d3b3eaf 100644 --- a/models/Grade.ts +++ b/models/Grade.ts @@ -38,35 +38,53 @@ GradeSchema.pre("save", function () { } }); -GradeSchema.pre("findOneAndUpdate", function () { - const update = this.getUpdate() as Record; - if (update && typeof update === "object") { - const marks = update.marks; - const maxMarks = update.maxMarks; - if ( - marks !== undefined && typeof marks === "number" && - maxMarks !== undefined && typeof maxMarks === "number" && - marks > maxMarks - ) { - throw new Error("marks must be less than or equal to maxMarks"); - } +function pickNumberFromUpdate(update: Record, field: "marks" | "maxMarks"): number | undefined { + const direct = update[field]; + if (typeof direct === "number" && Number.isFinite(direct)) return direct; + + const $set = update.$set; + if ($set && typeof $set === "object") { + const setValue = ($set as Record)[field]; + if (typeof setValue === "number" && Number.isFinite(setValue)) return setValue; } -}); -GradeSchema.pre("updateOne", function () { - const update = this.getUpdate() as Record; - if (update && typeof update === "object") { - const marks = update.marks; - const maxMarks = update.maxMarks; - if ( - marks !== undefined && typeof marks === "number" && - maxMarks !== undefined && typeof maxMarks === "number" && - marks > maxMarks - ) { - throw new Error("marks must be less than or equal to maxMarks"); + return undefined; +} + +async function validateMarksMaxMarksOnUpdate(this: mongoose.Query) { + const update = this.getUpdate() as Record | null; + if (!update || typeof update !== "object") return; + + let nextMarks = pickNumberFromUpdate(update, "marks"); + let nextMaxMarks = pickNumberFromUpdate(update, "maxMarks"); + + if (nextMarks === undefined && nextMaxMarks === undefined) return; + + if (nextMarks === undefined || nextMaxMarks === undefined) { + const currentRaw = await this.model.findOne(this.getQuery()).select("marks maxMarks").lean(); + if (currentRaw && typeof currentRaw === "object") { + const current = currentRaw as { marks?: unknown; maxMarks?: unknown }; + if (nextMarks === undefined && typeof current.marks === "number") nextMarks = current.marks; + if (nextMaxMarks === undefined && typeof current.maxMarks === "number") nextMaxMarks = current.maxMarks; + } + + // For upsert flows where maxMarks is omitted, schema default is 100. + if (nextMarks !== undefined && nextMaxMarks === undefined) { + nextMaxMarks = 100; } } -}); + + if ( + nextMarks !== undefined && + nextMaxMarks !== undefined && + nextMarks > nextMaxMarks + ) { + throw new Error("marks must be less than or equal to maxMarks"); + } +} + +GradeSchema.pre("findOneAndUpdate", validateMarksMaxMarksOnUpdate); +GradeSchema.pre("updateOne", validateMarksMaxMarksOnUpdate); GradeSchema.index({ teacherId: 1, studentId: 1, subject: 1, term: 1 }, { unique: true })