Skip to content
28 changes: 18 additions & 10 deletions app/api/announcements/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { auth } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'
import mongoose from 'mongoose'
import { z } from 'zod'
import { connectDB } from '@/lib/mongodb'
import { Announcement } from '@/models/Announcement'

const ALLOWED_FIELDS = ['title', 'content', 'body', 'audience', 'category', 'pinned', 'expiresAt']
const AnnouncementUpdateSchema = z.object({
title: z.string().min(1).optional(),
content: z.string().min(1).optional(),
audience: z.string().optional(),
category: z.enum(['academic', 'events', 'admin', 'general']).optional(),
pinned: z.boolean().optional(),
})

export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { userId } = await auth()
Expand All @@ -27,17 +34,18 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string
return NextResponse.json({ error: 'Invalid JSON request body' }, { status: 400 })
}

// Sanitize: only allow whitelisted fields
const sanitizedBody: Record<string, unknown> = {}
for (const key of ALLOWED_FIELDS) {
if (key in body) {
sanitizedBody[key] = body[key]
}
const parsed = AnnouncementUpdateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}

if (Object.keys(parsed.data).length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
}

const announcement = await Announcement.findOneAndUpdate(
{ _id: id },
{ $set: sanitizedBody },
{ _id: id, teacherId: userId },
{ $set: parsed.data },
{ new: true, runValidators: true, context: 'query' }
)
if (!announcement) return NextResponse.json({ error: 'Not found' }, { status: 404 })
Expand All @@ -63,7 +71,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
35 changes: 24 additions & 11 deletions app/api/assignments/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { auth } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'
import mongoose from 'mongoose'
import { z } from 'zod'
import { connectDB } from '@/lib/mongodb'
import { Assignment } from '@/models/Assignment'

const ALLOWED_UPDATE_FIELDS = ['title', 'description', 'dueDate', 'deadline', 'subject', 'class', 'status', 'kanbanStatus', 'maxMarks']
const AssignmentUpdateSchema = z.object({
title: z.string().min(1).optional(),
description: z.string().optional(),
subject: z.string().min(1).optional(),
class: z.string().min(1).optional(),
deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Invalid deadline',
}).optional(),
Comment on lines +13 to +15
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node - <<'NODE'
for (const value of ['2026-02-31', '2026-13-01', '1', '2026-04-18', '2026-04-18T00:00:00.000Z']) {
  const ms = Date.parse(value)
  console.log(value, Number.isNaN(ms) ? 'invalid' : new Date(ms).toISOString())
}
NODE

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 251


🏁 Script executed:

# Check if the file exists and read the relevant section
fd -t f "route.ts" | grep "assignments\[id\]" | head -5

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 66


🏁 Script executed:

# Find package.json and check Zod version
fd -t f "package.json" | head -1 | xargs cat | jq '.dependencies, .devDependencies' 2>/dev/null || cat package.json | grep -i zod || echo "Checking for package.lock files..."

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 512


🏁 Script executed:

# List potential package lock files to check versions
fd -t f "package-lock.json|pnpm-lock.yaml|yarn.lock" | head -1

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 101


🏁 Script executed:

# Search more broadly for the route file
find . -name "route.ts" -o -name "route.js" 2>/dev/null | grep -i assignment

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 150


🏁 Script executed:

# Check Zod 4.3.6 API for date validation - web search will help with this
# But first let's see if the file exists anywhere
fd -type f "route.ts" 2>/dev/null | head -20

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 66


🏁 Script executed:

# Check app/api directory structure
find app -type d 2>/dev/null | head -20

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 483


🏁 Script executed:

# Read the actual file to verify the code and schema
cat -n app/api/assignments/\[id\]/route.ts | head -60

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 2613


🏁 Script executed:

# Check Zod 4.3.6 documentation for correct date validation API
# Since it's Zod 4.3.6, let's verify what date validators are available
# Let's also look for how zod is imported and used in the file
grep -n "import.*zod\|z\." app/api/assignments/\[id\]/route.ts | head -20

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 583


🌐 Web query:

Zod 4.3.6 date validation api z.iso.date() z.iso.datetime()

💡 Result:

In Zod 4.3.6, the date validation API includes z.iso.date for validating ISO 8601 date strings in YYYY-MM-DD format and z.iso.datetime for ISO 8601 datetime strings. Usage examples: import { z } from "zod"; const dateSchema = z.iso.date; dateSchema.parse("2020-01-01"); // ✅ valid dateSchema.parse("2020-1-1"); // ❌ invalid const datetimeSchema = z.iso.datetime; datetimeSchema.parse("2020-01-01T06:15:00Z"); // ✅ valid datetimeSchema.parse("2020-01-01T06:15:00.123Z"); // ✅ valid (with milliseconds) These are string validators using regex-based checks for ISO formats. They were introduced in Zod v4 (stable release announced recently, with v4.3.6 being a patch release). Ensure standard import import { z } from "zod"; and Zod v4+ installation. TypeScript recognizes these in v4.3.6 when properly installed. Note: z.date validates Date objects, while z.iso.date and z.iso.datetime validate strings. For Date coercion from strings, use z.coerce.date or codecs like z.codec(z.iso.datetime, z.date).

Citations:


🏁 Script executed:

# Let's also check if there's a create route that uses similar validation
cat -n app/api/assignments/route.ts | head -40

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 1777


Tighten deadline validation beyond Date.parse.

Date.parse accepts permissive inputs: date rollover (e.g., '2026-02-31' → Mar 3), numeric strings ('1' → 2001-01-01), and other ambiguous formats. Use Zod's stricter ISO validators.

🛡️ Proposed fix
-  deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
-    message: 'Invalid deadline',
-  }).optional(),
+  deadline: z.iso.date().optional(),

If this endpoint intentionally accepts full datetimes too:

-  deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
-    message: 'Invalid deadline',
-  }).optional(),
+  deadline: z.union([z.iso.date(), z.iso.datetime()]).optional(),

This also applies to the create route at app/api/assignments/route.ts (lines 12–14).

📝 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
deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Invalid deadline',
}).optional(),
deadline: z.string().datetime().optional(),
Suggested change
deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Invalid deadline',
}).optional(),
deadline: z.union([
z.string().date(),
z.string().datetime(),
]).optional(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/assignments/`[id]/route.ts around lines 13 - 15, The current Zod
schema for the deadline field uses Date.parse which is too permissive; update
the deadline schema (the Zod field named deadline in the route handlers) to use
Zod's stricter validators — e.g., replace the refine(Date.parse) approach with
z.string().datetime() if you expect full ISO datetimes or
z.string().regex(/^\d{4}-\d{2}-\d{2}$/) (or z.string().regex for a stricter ISO
date) for date-only values, keeping .optional() as needed; apply the same change
to the deadline field in both the update route schema (the [id] route) and the
create route schema so validation is consistent.

maxMarks: z.number().min(1).optional(),
status: z.enum(['active', 'closed']).optional(),
kanbanStatus: z.enum(['todo', 'in_progress', 'submitted']).optional(),
})

export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { userId } = await auth()
Expand All @@ -27,18 +39,19 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
}

// Sanitize: only allow whitelisted fields
const sanitizedBody: Record<string, unknown> = {}
for (const key of ALLOWED_UPDATE_FIELDS) {
if (key in body) {
sanitizedBody[key] = body[key]
}
const parsed = AssignmentUpdateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}

if (Object.keys(parsed.data).length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
}

const assignment = await Assignment.findOneAndUpdate(
{ _id: id },
sanitizedBody,
{ new: true }
{ _id: id, teacherId: userId },
{ $set: parsed.data },
{ new: true, runValidators: true, context: 'query' }
)
if (!assignment) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(assignment)
Expand All @@ -63,7 +76,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
6 changes: 4 additions & 2 deletions app/api/assignments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ const AssignmentSchema = z.object({
description: z.string().optional(),
subject: z.string().min(1),
class: z.string().min(1),
deadline: z.string().min(1),
deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Invalid deadline',
}),
Comment on lines +12 to +14
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node - <<'NODE'
for (const value of ['2026-02-31', '2026-13-01', '1', '2026-04-18', '2026-04-18T00:00:00.000Z']) {
  const ms = Date.parse(value)
  console.log(value, Number.isNaN(ms) ? 'invalid' : new Date(ms).toISOString())
}
NODE

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 251


🏁 Script executed:

cat package.json | grep -A 2 -B 2 "zod"

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 191


🏁 Script executed:

find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" \) -exec grep -l "z\\..*date" {} \; | head -10

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 113


🏁 Script executed:

cat app/api/assignments/route.ts

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 3209


🏁 Script executed:

cat app/api/attendance/route.ts

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 5226


🌐 Web query:

Zod 4.3.6 ISO date datetime validation API

💡 Result:

In Zod version 4.3.6, the ISO date and datetime validation API is available via the z.iso namespace on the main z object. Key methods: ISO Date (validates YYYY-MM-DD format): import { z } from 'zod'; const dateSchema = z.iso.date; dateSchema.parse('2020-01-01'); // ✅ valid dateSchema.parse('2020-1-1'); // ❌ invalid (must be padded) dateSchema.parse('2020-01-32'); // ❌ invalid day ISO Datetime (validates ISO 8601 format, default no timezone offsets except Z): const datetimeSchema = z.iso.datetime; datetimeSchema.parse('2020-01-01T06:15:00Z'); // ✅ valid datetimeSchema.parse('2020-01-01T06:15:00.123Z'); // ✅ valid (subseconds) datetimeSchema.parse('2020-01-01T06:15:00+02:00'); // ❌ invalid (offsets not allowed by default) Other related: - z.iso.time: HH:MM[:SS[.s+]] (no offsets) - z.iso.duration: ISO 8601 duration These are regex-based string validations (not semantic date checks like min/max dates; use .refine with date libraries for that). For Date objects: use z.date. To convert ISO string to Date: const stringToDate = z.codec( z.iso.datetime, z.date, { decode: (isoString) => new Date(isoString), encode: (date) => date.toISOString, } ); stringToDate.parse('2024-01-15T10:30:00.000Z'); // returns Date object Official docs: https://zod.dev/ (Zod 4 stable since 2025). Version 4.3.6 released ~Jan 2026 per npm.

Citations:


Use strict ISO validation for deadline.

Date.parse is permissive and accepts malformed dates. For example, '2026-02-31' parses successfully (rolling over to March 3rd) and '1' parses as year 2001. Use Zod's ISO datetime validator to reject invalid deadlines before persistence.

🛡️ Proposed fix
-  deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
-    message: 'Invalid deadline',
-  }),
+  deadline: z.iso.datetime(),

If dates without time components are acceptable:

-  deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
-    message: 'Invalid deadline',
-  }),
+  deadline: z.union([z.iso.date(), z.iso.datetime()]),
📝 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
deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Invalid deadline',
}),
deadline: z.string().datetime(),
Suggested change
deadline: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Invalid deadline',
}),
deadline: z.union([z.string().date(), z.string().datetime()]),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/assignments/route.ts` around lines 12 - 14, The 'deadline' Zod schema
field currently uses Date.parse in a refine which accepts malformed dates;
change the validator on the deadline field to use Zod's ISO datetime check
(e.g., replace the Date.parse-based refine with z.string().datetime(...) on the
deadline field) so only strict ISO datetimes are accepted; if you need date-only
values instead, validate with a strict YYYY-MM-DD pattern (regex) on the same
'deadline' schema entry.

maxMarks: z.number().min(1).optional(),
status: z.enum(['active', 'closed']).optional(),
kanbanStatus: z.enum(['todo', 'in_progress', 'submitted']).optional(),
Expand Down Expand Up @@ -52,7 +54,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
14 changes: 13 additions & 1 deletion app/api/attendance/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { auth } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'
import mongoose from 'mongoose'
import { connectDB } from '@/lib/mongodb'
import { Attendance } from '@/models/Attendance'
import { z } from 'zod'

const StudentIdSchema = z.string().refine((value) => mongoose.Types.ObjectId.isValid(value), {
message: 'Invalid studentId',
})

const AttendanceSchema = z.object({
studentId: z.string().min(1),
studentId: StudentIdSchema,
Comment on lines +3 to +13
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

🧩 Analysis chain

🌐 Web query:

In Mongoose 9, does mongoose.Types.ObjectId.isValid accept 12-character non-hex strings, and is mongoose.isObjectIdOrHexString the recommended stricter validator?

💡 Result:

Yes, in Mongoose 9, mongoose.Types.ObjectId.isValid accepts 12-character non-hex strings as valid, consistent with BSON ObjectId behavior where any 12-byte string is technically valid. For example, mongoose.Types.ObjectId.isValid("zzzzzzzzzzzz") returns true. This has been the case across versions, including Mongoose 9, as no breaking changes to this behavior appear in the v9 changelog or migration guide (though v9 makes isValidObjectId stricter for numbers). Yes, mongoose.isObjectIdOrHexString is the recommended stricter validator. It returns true only if the value is a Mongoose ObjectId instance or a 24-character hex string (matching /^[0-9a-fA-F]{24}$/), and false for 12-char non-hex strings like "zzzzzzzzzzzz" or numbers. It was introduced in Mongoose 6.2.5 to address common use cases needing hex validation, and maintainers like @vkarpov15 recommend it over isValid for verifying 24-hex strings (e.g., in issues #15510, #14200, #4083). The official docs note that by default any 12-char string is valid for ObjectId casting, and isObjectIdOrHexString provides the stricter check.

Citations:


🏁 Script executed:

# Check if the file exists and examine the relevant sections
cat -n app/api/attendance/route.ts | head -50

Repository: JavaScript-Mastery-Pro/discord-challenge

Length of output: 2098


Use strict ObjectId validation for studentId.

mongoose.Types.ObjectId.isValid accepts any 12-byte string as valid, including 12-character non-hex strings, which can be persisted as bogus attendance studentIds. Use mongoose.isObjectIdOrHexString (stricter, 24-hex only) or a regex pattern instead. Reuse the schema in both the POST validation and GET query guard.

🛡️ Proposed fix
-import mongoose from 'mongoose'
 import { connectDB } from '@/lib/mongodb'
 import { Attendance } from '@/models/Attendance'
 import { z } from 'zod'
 
-const StudentIdSchema = z.string().refine((value) => mongoose.Types.ObjectId.isValid(value), {
+const StudentIdSchema = z.string().regex(/^[0-9a-fA-F]{24}$/, {
   message: 'Invalid studentId',
 })
-    if (studentId && !mongoose.Types.ObjectId.isValid(studentId)) {
+    if (studentId && !StudentIdSchema.safeParse(studentId).success) {
       return NextResponse.json(
         { error: "Invalid studentId" },
         { status: 400 },
       );

Also applies to lines 38–43 (GET validation).

📝 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
import mongoose from 'mongoose'
import { connectDB } from '@/lib/mongodb'
import { Attendance } from '@/models/Attendance'
import { z } from 'zod'
const StudentIdSchema = z.string().refine((value) => mongoose.Types.ObjectId.isValid(value), {
message: 'Invalid studentId',
})
const AttendanceSchema = z.object({
studentId: z.string().min(1),
studentId: StudentIdSchema,
import { connectDB } from '@/lib/mongodb'
import { Attendance } from '@/models/Attendance'
import { z } from 'zod'
const StudentIdSchema = z.string().regex(/^[0-9a-fA-F]{24}$/, {
message: 'Invalid studentId',
})
const AttendanceSchema = z.object({
studentId: StudentIdSchema,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/attendance/route.ts` around lines 3 - 13, Replace the loose ObjectId
check in StudentIdSchema with a strict validator (use
mongoose.isObjectIdOrHexString(value) or a 24-hex regex) so only 24-hex strings
are accepted; then reuse that StudentIdSchema in AttendanceSchema and in the GET
query validation (the GET guard that currently duplicates validation) to avoid
divergent checks—update the refine predicate on StudentIdSchema and replace the
duplicated GET validation logic to reference StudentIdSchema.

studentName: z.string().min(1),
class: z.string().min(1),
date: z.string().min(1),
Expand All @@ -30,6 +35,13 @@ export async function GET(req: NextRequest) {

const query: Record<string, unknown> = { teacherId: userId };

if (studentId && !mongoose.Types.ObjectId.isValid(studentId)) {
return NextResponse.json(
{ error: "Invalid studentId" },
{ status: 400 },
);
}

// Helper to validate and normalize date strings to YYYY-MM-DD format
const normalizeDate = (dateStr: string): string | null => {
try {
Expand Down
73 changes: 61 additions & 12 deletions app/api/grades/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,31 @@ import { NextRequest, NextResponse } from 'next/server'
import mongoose from 'mongoose'
import { connectDB } from '@/lib/mongodb'
import { Grade } from '@/models/Grade'
import { z } from 'zod'

const ALLOWED_UPDATE_FIELDS = ['marks', 'maxMarks', 'grade']
const StudentIdSchema = z.string().refine((value) => mongoose.Types.ObjectId.isValid(value), {
message: 'Invalid studentId',
})

const GradeUpdateSchema = z.object({
studentId: StudentIdSchema.optional(),
studentName: z.string().min(1).optional(),
subject: z.string().min(1).optional(),
marks: z.number().min(0).optional(),
maxMarks: z.number().min(1).optional(),
term: z.string().min(1).optional(),
})

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 All @@ -15,7 +38,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
Expand All @@ -25,26 +48,47 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
}

// Sanitize: only allow whitelisted fields
const sanitizedBody: Record<string, unknown> = {}
for (const key of ALLOWED_UPDATE_FIELDS) {
if (key in body) {
sanitizedBody[key] = body[key]
}
const parsed = GradeUpdateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}

if (Object.keys(parsed.data).length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
}

await connectDB()
const existing = await Grade.findOne({ _id: id, teacherId: userId })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })

const marks = parsed.data.marks ?? existing.marks
const maxMarks = parsed.data.maxMarks ?? existing.maxMarks
if (marks > maxMarks) {
return NextResponse.json(
{ error: { fieldErrors: { marks: ['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 },
{
$set: {
...parsed.data,
grade: calcGrade(marks, maxMarks),
},
},
{ new: true, runValidators: true, context: 'query' }
)
Comment on lines 60 to 82
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

Minor TOCTOU between findOne and findOneAndUpdate.

The marks/maxMarks validation loads the existing doc (line 61), computes the merged values (64-65), validates (66-71), then issues findOneAndUpdate (73-82). A concurrent update landing in between could violate marks <= maxMarks on the final document. The model's pre('save') hook doesn't fire for findOneAndUpdate, so runValidators: true is your only safety net here, and the Grade schema (per relevant context) relies on the save hook for that invariant.

If concurrent grade edits are plausible, either (a) add a schema-level Mongoose validator that compares this.marks and this.maxMarks (so runValidators catches it), or (b) do a single atomic update using $expr / findOneAndUpdate with a compound filter that re-asserts the invariant. Otherwise this is an acceptable, narrow race.

🤖 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 60 - 82, The code has a TOCTOU
between Grade.findOne and Grade.findOneAndUpdate: compute merged marks/maxMarks
from parsed.data and existing then validate, but a concurrent update can violate
marks <= maxMarks before findOneAndUpdate runs; either add a schema-level
validator on the Grade model that enforces marks <= maxMarks so runValidators:
true on Grade.findOneAndUpdate will catch violations, or make the update atomic
by moving the validation into the findOneAndUpdate filter (use a compound query
or $expr to require (parsed.data.marks ?? existing.marks) <=
(parsed.data.maxMarks ?? existing.maxMarks) in the query), and ensure calcGrade
is computed from the values in the same atomic update; update the code paths
that call Grade.findOne, Grade.findOneAndUpdate and any reliance on the
pre('save') hook (which does not run for findOneAndUpdate) to use the chosen
approach.

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 for this student, subject, and term already exists' }, { status: 409 })
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
Expand All @@ -55,8 +99,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 })
Expand Down
25 changes: 17 additions & 8 deletions app/api/grades/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { auth } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'
import mongoose from 'mongoose'
import { connectDB } from '@/lib/mongodb'
import { Grade } from '@/models/Grade'
import { z } from 'zod'

const StudentIdSchema = z.string().refine((value) => mongoose.Types.ObjectId.isValid(value), {
message: 'Invalid studentId',
})

const GradeSchema = z.object({
studentId: z.string().min(1),
studentId: StudentIdSchema,
Comment on lines +3 to +13
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

🧩 Analysis chain

🌐 Web query:

In Mongoose 9, does mongoose.Types.ObjectId.isValid accept 12-character non-hex strings, and is mongoose.isObjectIdOrHexString the recommended stricter validator?

💡 Result:

Yes, in Mongoose 9, mongoose.Types.ObjectId.isValid accepts 12-character non-hex strings as valid, because it follows BSON ObjectId semantics where any 12-byte sequence (including 12-char strings) is valid. It returns true for strings like "zzzzzzzzzzzz" or "testtesttest". Yes, mongoose.isObjectIdOrHexString is the recommended stricter validator. It returns true only for ObjectId instances or exactly 24-character hex strings (matching /^[0-9a-fA-F]{24}$/), and false for 12-char strings, numbers, or other values. This is explicitly recommended by Mongoose maintainers in multiple GitHub issues for cases needing hex string validation, and the feature was added in Mongoose 6.2.5 with no breaking changes noted in Mongoose 9 changelogs.

Citations:


Replace mongoose.Types.ObjectId.isValid with mongoose.isObjectIdOrHexString for strict student ID validation.

mongoose.Types.ObjectId.isValid is too permissive and accepts 12-character non-hex strings (e.g., "zzzzzzzzzzzz"), which would pass validation and create grade records for malformed student IDs. mongoose.isObjectIdOrHexString is the recommended strict validator from Mongoose maintainers and only accepts ObjectId instances or exactly 24-character hex strings.

🛡️ Proposed fix
const StudentIdSchema = z.string().refine((value) => mongoose.isObjectIdOrHexString(value), {
   message: 'Invalid studentId',
 })
-    if (studentId && !mongoose.Types.ObjectId.isValid(studentId)) {
+    if (studentId && !mongoose.isObjectIdOrHexString(studentId)) {
       return NextResponse.json({ error: 'Invalid studentId' }, { status: 400 })
     }

Also applies to: 48-50

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

In `@app/api/grades/route.ts` around lines 3 - 13, The StudentIdSchema currently
uses mongoose.Types.ObjectId.isValid which is too permissive; update the refine
predicate to use mongoose.isObjectIdOrHexString instead to ensure only ObjectId
instances or 24-character hex strings pass, and make the same replacement where
studentId validation occurs later in this file (the other validate calls around
the GradeSchema / any validation at lines ~48-50); keep the same error message
('Invalid studentId') and schema names (StudentIdSchema, GradeSchema) so only
the predicate changes.

studentName: z.string().min(1),
subject: z.string().min(1),
marks: z.number().min(0),
maxMarks: z.number().min(1).optional(),
term: z.string().optional(),
}).refine(
(data) => !data.maxMarks || data.marks <= data.maxMarks,
(data) => data.marks <= (data.maxMarks ?? 100),
{
message: 'marks must be less than or equal to maxMarks',
path: ['marks'],
Expand Down Expand Up @@ -40,6 +45,10 @@ export async function GET(req: NextRequest) {
const studentId = searchParams.get('studentId')
const subject = searchParams.get('subject')

if (studentId && !mongoose.Types.ObjectId.isValid(studentId)) {
return NextResponse.json({ error: 'Invalid studentId' }, { status: 400 })
}

const query: Record<string, unknown> = { teacherId: userId }
if (studentId) query.studentId = studentId
if (subject) query.subject = subject
Expand All @@ -48,7 +57,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 +79,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 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: calcGrade(data.marks, max) } },
{ upsert: true, new: true, runValidators: true, context: 'query', 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 })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
14 changes: 5 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(_req: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

try {
Expand All @@ -20,6 +13,9 @@ export async function GET(req: NextRequest) {

if (!teacher) {
const clerkUser = await currentUser()
if (!clerkUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const created = await Teacher.create({
clerkId: userId,
name: clerkUser?.fullName ?? '',
Expand Down
Loading