Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
4bf2dbf
fix: defer mongodb env validation
Deprasny Apr 18, 2026
786f196
fix: scope announcement mutations to current teacher
Deprasny Apr 18, 2026
f8497d9
fix: harden assignment mutations and errors
Deprasny Apr 18, 2026
e2e78e6
fix: require auth for profile reads
Deprasny Apr 18, 2026
6eece45
fix: validate and scope student writes
Deprasny Apr 18, 2026
bcdb097
fix: repair grade persistence and access control
Deprasny Apr 18, 2026
c6311ad
fix: initialize navbar theme without extra render
Deprasny Apr 18, 2026
b226041
chore: migrate middleware to proxy convention
Deprasny Apr 18, 2026
8914ff4
fix: cascade student deletions to related records
Deprasny Apr 18, 2026
0d8e23c
fix: correct dashboard totals and cgpa scale
Deprasny Apr 18, 2026
fc6af7b
fix: reject grade updates above max marks
Deprasny Apr 18, 2026
e82b944
fix: show empty state for filtered assignments
Deprasny Apr 18, 2026
9ea4918
fix: load all assignment pages for kanban board
Deprasny Apr 18, 2026
f530169
fix: load attendance rosters by exact class
Deprasny Apr 18, 2026
a7cdbb9
fix: load all students for grade workflows
Deprasny Apr 18, 2026
ab2575d
fix: rollback kanban moves on failed updates
Deprasny Apr 18, 2026
ff55180
fix: validate attendance students before writes
Deprasny Apr 18, 2026
8b88cc5
fix: validate grade student ownership
Deprasny Apr 18, 2026
055487b
fix: preserve local calendar dates in dashboards
Deprasny Apr 18, 2026
43c256a
fix: normalize attendance dates before persistence
Deprasny Apr 18, 2026
ee7249d
fix: load all student classes for filters
Deprasny Apr 18, 2026
7a21f8a
fix: enforce max marks on grade upserts
Deprasny Apr 18, 2026
7ee4890
fix: load all assignments for overview deadlines
Deprasny Apr 18, 2026
dfc35bb
fix: sort student grade history by repo terms
Deprasny Apr 18, 2026
4fccff5
fix: return 400 for invalid assignment updates
Deprasny Apr 18, 2026
6219828
fix: return 400 for invalid announcement updates
Deprasny Apr 18, 2026
b8eb247
fix: show student list load failures
Deprasny Apr 18, 2026
267235d
fix: rank grade comparisons by percentage
Deprasny Apr 18, 2026
52ddc21
fix: preserve overview term ordering for later terms
Deprasny Apr 18, 2026
2b81c1d
fix: sort overview grade distribution by scale
Deprasny Apr 18, 2026
c7c40a8
fix: require positive assignment max marks
Deprasny Apr 18, 2026
463ad4d
fix: reject unsupported assignment update fields
Deprasny Apr 18, 2026
65fc704
fix: reject unsupported announcement update fields
Deprasny Apr 18, 2026
4b13671
fix: reject unsupported student update fields
Deprasny Apr 18, 2026
d5774df
fix: reject empty grade update payloads
Deprasny Apr 18, 2026
fa49c8a
fix: return bad requests for invalid student updates
Deprasny Apr 18, 2026
d861074
fix: return bad requests for invalid grade updates
Deprasny Apr 18, 2026
2485abf
fix: stop requiring derived attendance fields
Deprasny Apr 18, 2026
d47ddd2
fix: stop requiring derived grade student names
Deprasny Apr 18, 2026
db2900a
fix: return bad requests for invalid assignment creates
Deprasny Apr 18, 2026
5924714
fix: reject empty attendance bulk payloads
Deprasny Apr 18, 2026
95010df
fix: surface assignment action errors in dashboard
Deprasny Apr 18, 2026
84bd692
fix: surface announcement action errors in dashboard
Deprasny Apr 18, 2026
5c776e3
fix: surface attendance save errors in dashboard
Deprasny Apr 18, 2026
ac290d6
fix: surface profile validation errors in dashboard
Deprasny Apr 18, 2026
eec1e9b
fix: surface student delete errors in dashboard
Deprasny Apr 18, 2026
923e838
fix: surface grade delete errors in dashboard
Deprasny Apr 18, 2026
dc33710
fix: surface grade validation errors in dashboard
Deprasny Apr 18, 2026
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
4 changes: 2 additions & 2 deletions app/api/announcements/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
)
Expand All @@ -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 })
Expand Down
6 changes: 3 additions & 3 deletions app/api/assignments/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string
}

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)
Expand All @@ -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 })
Expand Down
2 changes: 1 addition & 1 deletion app/api/assignments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
}

Expand Down
41 changes: 35 additions & 6 deletions app/api/grades/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ['studentId', 'studentName', 'subject', 'term', 'marks', 'maxMarks']
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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()
Expand Down Expand Up @@ -34,17 +45,30 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string
}

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

sanitizedBody.grade = calcGrade(nextMarks, nextMaxMarks)
Comment on lines +76 to +88
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Recalculated grade can drift from the persisted marks/maxMarks.

The typeof … === 'number' guards make nextMarks/nextMaxMarks fall back to the existing values when the client sends a non-number (e.g. "90" as a string, or null). However, the raw value is still present in sanitizedBody and will be $set on the document — Mongoose may cast the string to a number, so grade gets computed from the old marks while the stored marks reflects the new value, producing an inconsistent record.

Either reject non-numeric marks/maxMarks with a 400, or normalize/coerce into sanitizedBody so the computation and the persisted fields stay in sync:

♻️ Suggested direction
-    const nextMarks =
-      typeof sanitizedBody.marks === 'number' ? sanitizedBody.marks : existingGrade.marks
-    const nextMaxMarks =
-      typeof sanitizedBody.maxMarks === 'number' ? sanitizedBody.maxMarks : existingGrade.maxMarks
+    if ('marks' in sanitizedBody && typeof sanitizedBody.marks !== 'number') {
+      return NextResponse.json({ error: '`marks` must be a number' }, { status: 400 })
+    }
+    if ('maxMarks' in sanitizedBody && typeof sanitizedBody.maxMarks !== 'number') {
+      return NextResponse.json({ error: '`maxMarks` must be a number' }, { status: 400 })
+    }
+    const nextMarks = (sanitizedBody.marks as number | undefined) ?? existingGrade.marks
+    const nextMaxMarks = (sanitizedBody.maxMarks as number | undefined) ?? existingGrade.maxMarks
+    if (!nextMaxMarks || nextMaxMarks <= 0) {
+      return NextResponse.json({ error: '`maxMarks` must be greater than 0' }, { status: 400 })
+    }

The explicit maxMarks > 0 check also removes the theoretical divide-by-zero / NaN result from calcGrade if legacy data ever has maxMarks === 0.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/grades/`[id]/route.ts around lines 52 - 57, The code can produce
inconsistent persisted data because sanitizedBody may still contain non-numeric
marks/maxMarks while nextMarks/nextMaxMarks use numeric fallbacks; either
validate and reject non-numeric input or coerce and normalize sanitizedBody
before computing grade. Update the handler around
sanitizedBody/nextMarks/nextMaxMarks (the logic referencing sanitizedBody,
existingGrade, and calcGrade) to: 1) validate that if sanitizedBody.marks or
sanitizedBody.maxMarks are present they are numeric (and that maxMarks > 0) and
return 400 on invalid input, or 2) coerce values into numbers (e.g.
sanitizedBody.marks = Number(sanitizedBody.marks)) before computing
nextMarks/nextMaxMarks and before setting sanitizedBody so calcGrade and the
persisted marks/maxMarks stay in sync, then compute sanitizedBody.grade =
calcGrade(nextMarks, nextMaxMarks).


const grade = await Grade.findOneAndUpdate(
{ _id: id },
sanitizedBody,
{ new: true }
{ _id: id, teacherId: userId },
{ $set: sanitizedBody },
{ new: true, runValidators: true, context: 'query' }
)
if (!grade) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(grade)
} catch (error) {
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 already exists for this student, subject, and term' }, { status: 409 })
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
Expand All @@ -55,8 +79,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: 'Not found' }, { status: 404 })
}

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 })
Expand Down
15 changes: 9 additions & 6 deletions app/api/grades/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 })
}
}

Expand All @@ -70,19 +70,22 @@ 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 }
{ upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }
)
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 })
if ((error as { code?: number }).code === 11000) {
return NextResponse.json({ error: 'A grade already exists for this student, subject, and term' }, { status: 409 })
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
11 changes: 2 additions & 9 deletions app/api/profile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions app/api/students/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string

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)
Expand Down Expand Up @@ -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 })
Expand Down
7 changes: 5 additions & 2 deletions app/api/students/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>), teacherId: userId })
const student = await Student.create({ ...parsed.data, teacherId: userId })
return NextResponse.json(student, { status: 201 })
} catch (error) {
if (error instanceof Error) {
Expand Down
21 changes: 6 additions & 15 deletions components/dashboard/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,13 @@ 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')
})
Comment on lines +11 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential hydration mismatch from lazy initializer.

During SSR, typeof document === 'undefined' so dark initializes to false, and the server-rendered HTML will always show the moon icon and "Switch to dark mode" title. On the client, the lazy initializer runs again during hydration and may return true (since the inline script in app/layout.tsx has already set the dark class on documentElement). This produces a React hydration mismatch on the toggle button's title attribute and rendered SVG whenever the user's resolved theme is dark.

Consider initializing to false and syncing from the DOM inside useEffect (accepting one extra render, which was the original intent), or keep the lazy initializer but guard the theme-dependent UI behind a mounted flag / suppressHydrationWarning.

Proposed fix
-  const [dark, setDark] = useState(() => {
-    if (typeof document === 'undefined') {
-      return false
-    }
-
-    return document.documentElement.classList.contains('dark')
-  })
+  const [dark, setDark] = useState(false)
+
+  // Sync from DOM after mount to avoid hydration mismatch with the
+  // pre-hydration theme script in app/layout.tsx.
+  useEffect(() => {
+    setDark(document.documentElement.classList.contains('dark'))
+  }, [])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [dark, setDark] = useState(() => {
if (typeof document === 'undefined') {
return false
}
}, []);
return document.documentElement.classList.contains('dark')
})
const [dark, setDark] = useState(false)
// Sync from DOM after mount to avoid hydration mismatch with the
// pre-hydration theme script in app/layout.tsx.
useEffect(() => {
setDark(document.documentElement.classList.contains('dark'))
}, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/dashboard/Navbar.tsx` around lines 11 - 17, The current lazy
initializer for the dark state causes a hydration mismatch because on the server
you return false (document undefined) but on the client the initializer may read
document and return true; change to initialize dark to false and then
synchronize with the DOM inside a useEffect (call
setDark(document.documentElement.classList.contains('dark'))), or keep the lazy
initializer but introduce a mounted flag (e.g., useState mounted + useEffect to
set mounted true) and only render theme-dependent UI (the toggle title/SVG) when
mounted (or use suppressHydrationWarning) so the server and first client render
match; update references to the dark state in Navbar (the dark variable and
setDark) and the toggle button rendering to respect the mounted flag or
post-hydration sync.


// Sync dark class to <html> whenever dark state changes
useEffect(() => {
Expand Down
18 changes: 11 additions & 7 deletions lib/mongodb.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import mongoose from 'mongoose'

const MONGODB_URI = process.env.MONGODB_URI!

if (!MONGODB_URI) {
throw new Error('Please define the MONGODB_URI environment variable')
}

interface MongooseCache {
conn: typeof mongoose | null
promise: Promise<typeof mongoose> | null
Expand All @@ -18,12 +12,22 @@ declare global {
const cached: MongooseCache = global.mongooseCache ?? { conn: null, promise: null }
global.mongooseCache = cached

function getMongoUri(): string {
const mongoUri = process.env.MONGODB_URI

if (!mongoUri) {
throw new Error('Please define the MONGODB_URI environment variable')
}

return mongoUri
}

export async function connectDB(): Promise<typeof mongoose> {
if (cached.conn) return cached.conn

if (!cached.promise) {
cached.promise = mongoose
.connect(MONGODB_URI, { bufferCommands: false })
.connect(getMongoUri(), { bufferCommands: false })
.catch((error) => {
cached.promise = null
throw error
Expand Down
File renamed without changes.
Loading