diff --git a/Bug-Fix-Report.md b/Bug-Fix-Report.md new file mode 100644 index 0000000..ec21704 --- /dev/null +++ b/Bug-Fix-Report.md @@ -0,0 +1,473 @@ +# Bug Hunt Report + +This document details the bugs found in the codebase along with their fixes. + +--- + +## Bug #1: Missing `await` on Zod Validation + +**Severity:** High + +**File:** `app/api/students/route.ts` + +**Line:** 73 + +**Problem:** The `safeParse` result from Zod validation is never stored or checked. The validation result is completely ignored, so invalid data passes through without any validation. + +**Original Code:** + +```typescript +StudentSchema.safeParse(body); + +const student = await Student.create({ + ...(body as Record), + teacherId: userId, +}); +``` + +**Fixed Code:** + +```typescript +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, +}); +``` + +--- + +## Bug #2: Missing `await` on `findOneAndUpdate` + +**Severity:** High + +**File:** `app/api/grades/route.ts` + +**Lines:** 77-82 + +**Problem:** `findOneAndUpdate` returns a Query object, not a Promise. Without `await`, the `grade` variable will be a Mongoose Query object, not the actual document. The API returns an invalid response. + +**Original Code:** + +```typescript +const grade = 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 }, +); +return NextResponse.json(grade, { status: 201 }); +``` + +**Fixed Code:** + +```typescript +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 }, +); +return NextResponse.json(grade, { status: 201 }); +``` + +--- + +## Bug #3: Wrong Field Name in ALLOWED_UPDATE_FIELDS + +**Severity:** Medium + +**File:** `app/api/assignments/[id]/route.ts` + +**Line:** 9 + +**Problem:** `dueDate` is listed in `ALLOWED_UPDATE_FIELDS` but the Assignment model has `deadline`, not `dueDate`. This means the `dueDate` field (if sent) will silently be ignored. + +**Original Code:** + +```typescript +const ALLOWED_UPDATE_FIELDS = [ + "title", + "description", + "dueDate", + "deadline", + "subject", + "class", + "status", + "kanbanStatus", + "maxMarks", +]; +``` + +**Fixed Code:** + +```typescript +const ALLOWED_UPDATE_FIELDS = [ + "title", + "description", + "deadline", + "subject", + "class", + "status", + "kanbanStatus", + "maxMarks", +]; +``` + +--- + +## Bug #4: Wrong Field Name in ALLOWED_FIELDS + +**Severity:** Low + +**File:** `app/api/announcements/[id]/route.ts` + +**Line:** 9 + +**Problem:** `body` is whitelisted but doesn't exist in the Announcement schema. The schema uses `content` instead. The `body` field will be silently ignored. + +**Original Code:** + +```typescript +const ALLOWED_FIELDS = [ + "title", + "content", + "body", + "audience", + "category", + "pinned", + "expiresAt", +]; +``` + +**Fixed Code:** + +```typescript +const ALLOWED_FIELDS = [ + "title", + "content", + "audience", + "category", + "pinned", + "expiresAt", +]; +``` + +--- + +## Bug #5: Missing TeacherId Filter in Grades GET + +**Severity:** ~~High~~ (False Positive - Already Correct) + +**File:** `app/api/grades/route.ts` + +**Lines:** 46-49 + +**Problem:** Originally suspected that `GET /api/grades` was missing a `teacherId` filter. Upon further review, the code **already correctly filters by `teacherId`**. + +**Code (Already Correct):** + +```typescript +const query: Record = { teacherId: userId }; +if (studentId) query.studentId = studentId; +if (subject) query.subject = subject; +``` + +--- + +## Bug #6: Wrong Field Name `grade` Instead of `class` + +**Severity:** High + +**File:** `app/api/students/[id]/route.ts` + +**Line:** 9 + +**Problem:** The `ALLOWED_UPDATE_FIELDS` array includes `'grade'` but the Student model does NOT have a `grade` field. It has a `class` field instead. This means you can never actually update the class of a student. + +**Original Code:** + +```typescript +const ALLOWED_UPDATE_FIELDS = [ + "name", + "email", + "grade", + "rollNo", + "class", + "phone", + "address", + "parentName", + "parentPhone", +]; +``` + +**Fixed Code:** + +```typescript +const ALLOWED_UPDATE_FIELDS = [ + "name", + "email", + "class", + "rollNo", + "phone", + "address", + "parentName", + "parentPhone", +]; +``` + +--- + +## Bug #7: Security Issue - GET /api/profile Allows Fetching ANY User's Profile + +**Severity:** High (Security Issue) + +**File:** `app/api/profile/route.ts` + +**Lines:** 8-12 + +**Problem:** The `GET` handler accepts a `userId` query parameter that can be used to fetch ANY teacher's profile, not just the authenticated user's own profile. This is a serious information disclosure vulnerability. + +**Original Code:** + +```typescript +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; + } + // ... fetches profile for ANY userId passed in query +``` + +**Fixed Code:** + +```typescript +export async function GET(req: NextRequest) { + const session = await auth(); + const userId = session.userId; + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +``` + +--- + +## Bug #8: `expiresAt` in ALLOWED_FIELDS but Not in Announcement Schema + +**Severity:** Low + +**File:** `app/api/announcements/[id]/route.ts` + +**Line:** 9 + +**Problem:** `'expiresAt'` is listed in `ALLOWED_FIELDS` but the Announcement model does NOT have an `expiresAt` field. This field will be silently ignored when updating announcements. + +**Original Code:** + +```typescript +const ALLOWED_FIELDS = [ + "title", + "content", + "audience", + "category", + "pinned", + "expiresAt", +]; +``` + +**Fixed Code:** + +```typescript +const ALLOWED_FIELDS = ["title", "content", "audience", "category", "pinned"]; +``` + +--- + +## Bug #9: `maxMarks` Marked Optional in Zod but Dereferenced Without Null Check + +**Severity:** Medium + +**File:** `app/api/grades/route.ts` + +**Lines:** 11, 74 + +**Problem:** The Zod schema marks `maxMarks` as optional (`z.number().min(1).optional()`), but then accesses it with `data.maxMarks!` which assumes it exists. If a client sends no `maxMarks`, the code will crash with a null reference error. + +**Original Code:** + +```typescript +const GradeSchema = z.object({ + // ... + maxMarks: z.number().min(1).optional(), + // ... +}); +// Later in POST handler: +const max = data.maxMarks!; // ❌ Crashes if maxMarks is undefined +``` + +**Fixed Code:** + +```typescript +const GradeSchema = z.object({ + // ... + maxMarks: z.number().min(1).default(100), + // ... +}); +// Later in POST handler: +const max = data.maxMarks; // No longer needs ! since default is provided +``` + +--- + +## Bug #10: Missing teacherId Filter in PUT/DELETE Routes + +**Severity:** High (Security Best Practice) + +**Files:** + +- `app/api/students/[id]/route.ts` (PUT & DELETE) +- `app/api/assignments/[id]/route.ts` (PUT & DELETE) +- `app/api/announcements/[id]/route.ts` (PUT & DELETE) +- `app/api/grades/[id]/route.ts` (PUT & DELETE) + +**Problem:** PUT and DELETE operations by ID did not filter by `teacherId`, potentially allowing teachers to modify or delete other teachers' data. + +**Original Code:** + +```typescript +// Example from PUT /api/students/[id] +const student = await Student.findOneAndUpdate( + { _id: id }, // ❌ No teacherId filter + sanitizedBody, + { new: true }, +); +``` + +**Fixed Code:** + +```typescript +// Example from PUT /api/students/[id] +const student = await Student.findOneAndUpdate( + { _id: id, teacherId: userId }, // ✅ Added teacherId filter + sanitizedBody, + { new: true }, +); +``` + +--- + +## Summary + +| # | Bug | Severity | File | Status | +| --- | -------------------------------------------- | --------------- | --------------------------- | ------ | +| 1 | Missing `await` on Zod validation | High | POST /api/students | Fixed | +| 2 | Missing `await` on `findOneAndUpdate` | High | POST /api/grades | Fixed | +| 3 | Wrong field `dueDate` (should be `deadline`) | Medium | PUT /api/assignments/[id] | Fixed | +| 4 | Wrong field `body` (doesn't exist in schema) | Low | PUT /api/announcements/[id] | Fixed | +| 5 | Missing teacherId filter in grades GET | False Positive | GET /api/grades | N/A | +| 6 | `grade` should be `class` | High | PUT /api/students/[id] | Fixed | +| 7 | Security: userId query param leak | High (Security) | GET /api/profile | Fixed | +| 8 | `expiresAt` not in schema | Low | PUT /api/announcements/[id] | Fixed | +| 9 | `maxMarks` optional but dereferenced | Medium | POST /api/grades | Fixed | +| 10 | Missing teacherId filter in PUT/DELETE | High (Security) | PUT/DELETE [id] routes | Fixed | + +## 1. High-Level Summary (TL;DR) + +- **Impact:** **High** ⚠️ - Addresses critical security vulnerabilities (IDOR), missing validation logic, and enforces strict tenant isolation across the application. +- **Key Changes:** + - 🔒 **Tenant Isolation:** Enforced `teacherId: userId` filters on database update queries (`Announcements`, `Assignments`, `Grades`, `Students`) to prevent unauthorized modifications of other users' data. + - 🛡️ **IDOR Vulnerability Fix:** Removed the ability to query the `Profile` API using a `userId` query parameter, forcing the use of the authenticated session's user ID. + - ✅ **Strict Validation:** Fixed a bug where Zod validation was executed but ignored during Student creation. It now properly rejects invalid requests. + - 🧹 **Payload Sanitization:** Cleaned up whitelisted fields for API updates, removing deprecated or redundant fields like `dueDate` and `body`. + - 📝 **Bug Tracking:** Added a comprehensive `Bug-Fix-Report.md` detailing previously identified issues. + +## 2. Visual Overview (Code & Logic Map) + +The following diagram illustrates the newly enforced security boundary for PUT requests across the application's APIs. + +```mermaid +graph TD + subgraph "Secure Update Flow (Tenant Isolation)" + Client("Client Request (PUT)") --> AuthRoute["auth() Middleware"] + + AuthRoute -- "Valid Session" --> ExtractID["Extract userId"] + AuthRoute -- "No Session" --> 401["401 Unauthorized"] + + ExtractID --> Sanitization["Sanitize Body (Allowed Fields)"] + + Sanitization --> DBQuery["findOneAndUpdate()"] + + DBQuery -. "Filter: { _id: id, teacherId: userId }" .-> DB[("MongoDB")] + + DB -- "Match Found" --> 200["200 OK (Updated)"] + DB -- "No Match (or Not Owner)" --> 404["404 Not Found"] + end + + style AuthRoute fill:#bbdefb,color:#0d47a1 + style DBQuery fill:#fff3e0,color:#e65100 + style DB fill:#c8e6c9,color:#1a5e20 + style 401 fill:#ffcdd2,color:#b71c1c + style 404 fill:#ffcdd2,color:#b71c1c +``` + +## 3. Detailed Change Analysis + +### 🔐 Security & Tenant Isolation (Various API Routes) + +**What Changed:** Multiple API routes that modify data were vulnerable to Cross-Tenant Data Mutation. Previously, a user could update any record if they knew its `_id`. The queries have been updated to require ownership. + +- _Affected Files:_ `app/api/announcements/[id]/route.ts`, `app/api/assignments/[id]/route.ts`, `app/api/grades/[id]/route.ts`, `app/api/students/[id]/route.ts` +- _Logic Update:_ `findOneAndUpdate({ _id: id })` was changed to `findOneAndUpdate({ _id: id, teacherId: userId })`. + +### 🛡️ Profile API Security + +**What Changed:** Fixed an Insecure Direct Object Reference (IDOR) vulnerability. + +- _Affected File:_ `app/api/profile/route.ts` +- _Logic Update:_ Removed `const queryUserId = searchParams.get('userId')`. The API now strictly enforces fetching the profile of the currently authenticated user (`const session = await auth(); userId = session.userId`). + +### ✅ Input Validation & Schema Fixes + +**What Changed:** Fixed bypassed validation logic and updated schema defaults. + +- _Affected Files:_ `app/api/students/route.ts`, `app/api/grades/route.ts` +- _Logic Update:_ + - In the Students POST route, `StudentSchema.safeParse(body)` was called, but the result was ignored. It now checks `parsed.success` and returns a `400` error with flattened validation errors if it fails. It also securely uses `parsed.data` instead of the raw `body` for creation. + - In the Grades route, `maxMarks` was changed from `.optional()` to `.default(100)` in the Zod schema. + +### 🧹 Field Sanitization Updates + +The `ALLOWED_UPDATE_FIELDS` whitelists were tightened across multiple controllers to prevent invalid data injections. + +| API Route | Removed Fields | Description / Reason | +| :---------------- | :------------------ | :----------------------------------------------------------------------------------------------- | +| **Announcements** | `body`, `expiresAt` | Standardized on using `content` instead of `body`. `expiresAt` is no longer user-updatable. | +| **Assignments** | `dueDate` | Standardized on using `deadline` instead of `dueDate` to match the schema. | +| **Students** | `grade` | Removed `grade` from direct student updates (likely managed via a separate relation/system now). | + +## 4. Impact & Risk Assessment + +- **⚠️ Breaking Changes:** + - **Profile API:** Any external client or frontend component that relied on passing `?userId=123` to fetch another user's profile will now fail. + - **Payload Structures:** Clients sending `dueDate` for assignments or `body` for announcements will find those fields silently dropped during updates due to the updated sanitization lists. +- **🧪 Testing Suggestions:** + - **Cross-Account Access:** Log in as User A, grab an assignment/student `_id`, log in as User B, and attempt a `PUT` request using User A's `_id`. Verify a `404 Not Found` is returned. + - **Validation Rejection:** Send a POST request to create a Student with missing required fields and verify a `400 Bad Request` is returned with the appropriate Zod error payload. + - **Profile Fetching:** Verify the frontend loads the profile correctly without relying on query parameters. diff --git a/app/api/announcements/[id]/route.ts b/app/api/announcements/[id]/route.ts index 6c32f47..124ac1d 100644 --- a/app/api/announcements/[id]/route.ts +++ b/app/api/announcements/[id]/route.ts @@ -1,79 +1,100 @@ -import { auth } from '@clerk/nextjs/server' -import { NextRequest, NextResponse } from 'next/server' -import mongoose from 'mongoose' -import { connectDB } from '@/lib/mongodb' -import { Announcement } from '@/models/Announcement' +import { auth } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +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() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function PUT( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - const { id } = await ctx.params + const { id } = await ctx.params; // Validate ObjectId if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: 'Invalid id' }, { status: 400 }) + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); } - await connectDB() - - let body + await connectDB(); + + let body; try { - body = await req.json() + body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON request body' }, { status: 400 }) + return NextResponse.json( + { error: "Invalid JSON request body" }, + { status: 400 }, + ); } // Sanitize: only allow whitelisted fields - const sanitizedBody: Record = {} + const sanitizedBody: Record = {}; for (const key of ALLOWED_FIELDS) { if (key in body) { - sanitizedBody[key] = body[key] + sanitizedBody[key] = body[key]; } } const announcement = await Announcement.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, { $set: sanitizedBody }, - { new: true, runValidators: true, context: 'query' } - ) - if (!announcement) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - return NextResponse.json(announcement) + { new: true, runValidators: true, context: "query" }, + ); + if (!announcement) + return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json(announcement); } catch (error) { if (error instanceof Error) { - console.error('PUT /api/announcements/[id] error:', error.message) + console.error("PUT /api/announcements/[id] error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } -export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function DELETE( + _req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - const { id } = await ctx.params + const { id } = await ctx.params; // Validate ObjectId if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: 'Invalid id' }, { status: 400 }) + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); } - await connectDB() - const deleted = await Announcement.findOneAndDelete({ _id: id }) - + await connectDB(); + const deleted = await Announcement.findOneAndDelete({ + _id: id, + teacherId: userId, + }); + if (!deleted) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ error: "Not found" }, { status: 404 }); } - - return NextResponse.json({ success: true }) + + return NextResponse.json({ success: true }); } catch (error) { if (error instanceof Error) { - console.error('DELETE /api/announcements/[id] error:', error.message) + console.error("DELETE /api/announcements/[id] error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } diff --git a/app/api/assignments/[id]/route.ts b/app/api/assignments/[id]/route.ts index 21fca1c..fbc43de 100644 --- a/app/api/assignments/[id]/route.ts +++ b/app/api/assignments/[id]/route.ts @@ -1,79 +1,109 @@ -import { auth } from '@clerk/nextjs/server' -import { NextRequest, NextResponse } from 'next/server' -import mongoose from 'mongoose' -import { connectDB } from '@/lib/mongodb' -import { Assignment } from '@/models/Assignment' +import { auth } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +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() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function PUT( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - const { id } = await ctx.params + const { id } = await ctx.params; // Validate ObjectId if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: 'Invalid id' }, { status: 400 }) + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); } - await connectDB() - - let body + await connectDB(); + + let body; try { - body = await req.json() + body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 }, + ); } // Sanitize: only allow whitelisted fields - const sanitizedBody: Record = {} + const sanitizedBody: Record = {}; for (const key of ALLOWED_UPDATE_FIELDS) { if (key in body) { - sanitizedBody[key] = body[key] + sanitizedBody[key] = body[key]; } } const assignment = await Assignment.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, sanitizedBody, - { new: true } - ) - if (!assignment) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - return NextResponse.json(assignment) + { new: true }, + ); + if (!assignment) + return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json(assignment); } catch (error) { if (error instanceof Error) { - console.error('PUT /api/assignments/[id] error:', error.message) + console.error("PUT /api/assignments/[id] error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } -export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function DELETE( + _req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - const { id } = await ctx.params + const { id } = await ctx.params; // Validate ObjectId if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: 'Invalid id' }, { status: 400 }) + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); } - await connectDB() - const deleted = await Assignment.findOneAndDelete({ _id: id }) - + await connectDB(); + const deleted = await Assignment.findOneAndDelete({ + _id: id, + teacherId: userId, + }); + if (!deleted) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ error: "Not found" }, { status: 404 }); } - - return NextResponse.json({ success: true }) + + return NextResponse.json({ success: true }); } catch (error) { if (error instanceof Error) { - console.error('DELETE /api/assignments/[id] error:', error.message) + console.error("DELETE /api/assignments/[id] error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + 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..83805b5 100644 --- a/app/api/grades/[id]/route.ts +++ b/app/api/grades/[id]/route.ts @@ -1,72 +1,93 @@ -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 { 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"; -const ALLOWED_UPDATE_FIELDS = ['marks', 'maxMarks', 'grade'] +const ALLOWED_UPDATE_FIELDS = ["marks", "maxMarks", "grade"]; -export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function PUT( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - const { id } = await ctx.params + const { id } = await ctx.params; // Validate ObjectId if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ error: "Not found" }, { status: 404 }); } - let body + let body; try { - body = await req.json() + body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 }, + ); } // Sanitize: only allow whitelisted fields - const sanitizedBody: Record = {} + const sanitizedBody: Record = {}; for (const key of ALLOWED_UPDATE_FIELDS) { if (key in body) { - sanitizedBody[key] = body[key] + sanitizedBody[key] = body[key]; } } - await connectDB() + await connectDB(); const grade = await Grade.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, sanitizedBody, - { new: true } - ) - if (!grade) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - return NextResponse.json(grade) + { new: true }, + ); + 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) + console.error("PUT /api/grades/[id] error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } -export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function DELETE( + _req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - const { id } = await ctx.params - await connectDB() - const deleted = await Grade.findOneAndDelete({ _id: id }) - + const { id } = await ctx.params; + await connectDB(); + const deleted = await Grade.findOneAndDelete({ + _id: id, + teacherId: userId, + }); + if (!deleted) { - return NextResponse.json({ error: 'Grade not found' }, { status: 404 }) + return NextResponse.json({ error: "Grade not found" }, { status: 404 }); } - - return NextResponse.json({ success: true }) + + return NextResponse.json({ success: true }); } catch (error) { if (error instanceof Error) { - console.error('DELETE /api/grades/[id] error:', error.message) + console.error("DELETE /api/grades/[id] error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } diff --git a/app/api/grades/route.ts b/app/api/grades/route.ts index b9da63d..9ef8c70 100644 --- a/app/api/grades/route.ts +++ b/app/api/grades/route.ts @@ -1,88 +1,117 @@ -import { auth } from '@clerk/nextjs/server' -import { NextRequest, NextResponse } from 'next/server' -import { connectDB } from '@/lib/mongodb' -import { Grade } from '@/models/Grade' -import { z } from 'zod' +import { auth } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +import { connectDB } from "@/lib/mongodb"; +import { Grade } from "@/models/Grade"; +import { z } from "zod"; -const GradeSchema = z.object({ - studentId: z.string().min(1), - 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, - { - message: 'marks must be less than or equal to maxMarks', - path: ['marks'], - } -) +const GradeSchema = z + .object({ + studentId: z.string().min(1), + studentName: z.string().min(1), + subject: z.string().min(1), + marks: z.number().min(0), + maxMarks: z.number().min(1).default(100), + term: z.string().optional(), + }) + .refine((data) => !data.maxMarks || data.marks <= data.maxMarks, { + message: "marks must be less than or equal to maxMarks", + path: ["marks"], + }); 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' + 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 }) + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - const { searchParams } = new URL(req.url) - const studentId = searchParams.get('studentId') - const subject = searchParams.get('subject') + await connectDB(); + const { searchParams } = new URL(req.url); + const studentId = searchParams.get("studentId"); + const subject = searchParams.get("subject"); - const query: Record = { teacherId: userId } - if (studentId) query.studentId = studentId - if (subject) query.subject = subject + const query: Record = { teacherId: userId }; + if (studentId) query.studentId = studentId; + if (subject) query.subject = subject; - const grades = await Grade.find(query).sort({ createdAt: -1 }).lean() - return NextResponse.json(grades) + 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 instanceof Error ? error.message : error, + ); + return NextResponse.json( + { error: error instanceof Error ? error.stack : "Internal server error" }, + { status: 500 }, + ); } } export async function POST(req: NextRequest) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - - let body + await connectDB(); + + let body; try { - body = await req.json() + body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 }, + ); } - - const parsed = GradeSchema.safeParse(body) - 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 grade = 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 } - ) - return NextResponse.json(grade, { status: 201 }) + const parsed = GradeSchema.safeParse(body); + 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 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 }, + ); + return NextResponse.json(grade, { status: 201 }); } catch (error) { if (error instanceof Error) { - console.error('POST /api/grades error:', error.message) + 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: error instanceof Error ? error.stack : "Internal server error" }, + { status: 500 }, + ); } } diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index a3d98bf..f33d0af 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -1,73 +1,96 @@ -import { auth, currentUser } from '@clerk/nextjs/server' -import { NextRequest, NextResponse } from 'next/server' -import { connectDB } from '@/lib/mongodb' -import { Teacher } from '@/models/Teacher' +import { auth, currentUser } from "@clerk/nextjs/server"; +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 - } - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function GET() { + const session = await auth(); + const userId = session.userId; + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - let teacher = await Teacher.findOne({ clerkId: userId }).lean() + await connectDB(); + let teacher = await Teacher.findOne({ clerkId: userId }).lean(); if (!teacher) { - const clerkUser = await currentUser() + const clerkUser = await currentUser(); const created = await Teacher.create({ clerkId: userId, - name: clerkUser?.fullName ?? '', - email: clerkUser?.emailAddresses[0]?.emailAddress ?? '', - department: '', + name: clerkUser?.fullName ?? "", + email: clerkUser?.emailAddresses[0]?.emailAddress ?? "", + department: "", subjects: [], - }) - teacher = created.toObject() + }); + teacher = created.toObject(); } - return NextResponse.json(teacher) + return NextResponse.json(teacher); } catch (error) { - console.error('GET /api/profile error:', error instanceof Error ? error.message : error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + console.error( + "GET /api/profile error:", + error instanceof Error ? error.message : error, + ); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } export async function PUT(req: NextRequest) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - - let body + await connectDB(); + + let body; try { - body = await req.json() + body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 }, + ); } - - const { name, department, subjects, phone, bio, academicHistory } = body + + const { name, department, subjects, phone, bio, academicHistory } = body; // Validate input - if (typeof name !== 'string' || !name.trim()) { - return NextResponse.json({ error: 'name must be a non-empty string' }, { status: 400 }) + if (typeof name !== "string" || !name.trim()) { + return NextResponse.json( + { error: "name must be a non-empty string" }, + { status: 400 }, + ); } - if (department !== undefined && typeof department !== 'string') { - return NextResponse.json({ error: 'department must be a string' }, { status: 400 }) + if (department !== undefined && typeof department !== "string") { + return NextResponse.json( + { error: "department must be a string" }, + { status: 400 }, + ); } - if (!Array.isArray(subjects) || !subjects.every((s) => typeof s === 'string')) { - return NextResponse.json({ error: 'subjects must be an array of strings' }, { status: 400 }) + if ( + !Array.isArray(subjects) || + !subjects.every((s) => typeof s === "string") + ) { + return NextResponse.json( + { error: "subjects must be an array of strings" }, + { status: 400 }, + ); } - if (phone !== undefined && typeof phone !== 'string') { - return NextResponse.json({ error: 'phone must be a string' }, { status: 400 }) + if (phone !== undefined && typeof phone !== "string") { + return NextResponse.json( + { error: "phone must be a string" }, + { status: 400 }, + ); } - if (bio !== undefined && typeof bio !== 'string') { - return NextResponse.json({ error: 'bio must be a string' }, { status: 400 }) + if (bio !== undefined && typeof bio !== "string") { + return NextResponse.json( + { error: "bio must be a string" }, + { status: 400 }, + ); } if (academicHistory !== undefined) { if ( @@ -76,39 +99,46 @@ export async function PUT(req: NextRequest) { !academicHistory.every( (entry: unknown) => entry !== null && - typeof entry === 'object' && - typeof (entry as Record).year === 'string' && - typeof (entry as Record).title === 'string', + typeof entry === "object" && + typeof (entry as Record).year === "string" && + typeof (entry as Record).title === "string", ) ) { return NextResponse.json( - { error: 'academicHistory must be an array of objects with string year and title (max 20 items)' }, + { + error: + "academicHistory must be an array of objects with string year and title (max 20 items)", + }, { status: 400 }, - ) + ); } } - const updatePayload: Record = { name, subjects } - if (department !== undefined) updatePayload.department = department - if (phone !== undefined) updatePayload.phone = phone - if (bio !== undefined) updatePayload.bio = bio - if (academicHistory !== undefined) updatePayload.academicHistory = academicHistory + const updatePayload: Record = { name, subjects }; + if (department !== undefined) updatePayload.department = department; + if (phone !== undefined) updatePayload.phone = phone; + if (bio !== undefined) updatePayload.bio = bio; + if (academicHistory !== undefined) + updatePayload.academicHistory = academicHistory; const teacher = await Teacher.findOneAndUpdate( { clerkId: userId }, { $set: updatePayload }, - { new: true } - ) - + { new: true }, + ); + if (!teacher) { - return NextResponse.json({ error: 'Teacher not found' }, { status: 404 }) + return NextResponse.json({ error: "Teacher not found" }, { status: 404 }); } - return NextResponse.json(teacher) + return NextResponse.json(teacher); } catch (error) { if (error instanceof Error) { - console.error('PUT /api/profile error:', error.message) + console.error("PUT /api/profile error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } diff --git a/app/api/students/[id]/route.ts b/app/api/students/[id]/route.ts index 2eaaf93..9a0a89a 100644 --- a/app/api/students/[id]/route.ts +++ b/app/api/students/[id]/route.ts @@ -1,81 +1,111 @@ -import { auth } from '@clerk/nextjs/server' -import { NextRequest, NextResponse } from 'next/server' -import mongoose from 'mongoose' -import { connectDB } from '@/lib/mongodb' -import { Student } from '@/models/Student' +import { auth } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +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", + "class", + "rollNo", + "phone", + "address", + "parentName", + "parentPhone", +]; -export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function PUT( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - const { id } = await ctx.params + const { id } = await ctx.params; // Validate ObjectId if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: 'Bad Request' }, { status: 400 }) + return NextResponse.json({ error: "Bad Request" }, { status: 400 }); } - let body + let body; try { - body = await req.json() + body = await req.json(); } catch { - return NextResponse.json({ error: 'Bad Request' }, { status: 400 }) + return NextResponse.json({ error: "Bad Request" }, { status: 400 }); } // Sanitize: only allow whitelisted fields - const sanitizedBody: Record = {} + const sanitizedBody: Record = {}; for (const key of ALLOWED_UPDATE_FIELDS) { if (key in body) { - sanitizedBody[key] = body[key] + sanitizedBody[key] = body[key]; } } - await connectDB() + await connectDB(); const student = await Student.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, sanitizedBody, - { new: true } - ) - if (!student) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - return NextResponse.json(student) + { new: true }, + ); + if (!student) + return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json(student); } catch (error) { if (error instanceof Error) { - console.error('PUT /api/students/[id] error:', error.message) + console.error("PUT /api/students/[id] error:", error.message); } if ((error as { code?: number }).code === 11000) { - return NextResponse.json({ error: 'A student with this roll number already exists' }, { status: 409 }) + return NextResponse.json( + { error: "A student with this roll number already exists" }, + { status: 409 }, + ); } - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); } } -export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function DELETE( + _req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - const { id } = await ctx.params + const { id } = await ctx.params; // Validate ObjectId if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: 'Bad Request' }, { status: 400 }) + return NextResponse.json({ error: "Bad Request" }, { status: 400 }); } - await connectDB() - const deleted = await Student.findOneAndDelete({ _id: id }) - + await connectDB(); + const deleted = await Student.findOneAndDelete({ + _id: id, + teacherId: userId, + }); + if (!deleted) { - return NextResponse.json({ error: 'Student not found' }, { status: 404 }) + return NextResponse.json({ error: "Student not found" }, { status: 404 }); } - - return NextResponse.json({ success: true }) + + return NextResponse.json({ success: true }); } catch (error) { if (error instanceof Error) { - console.error('DELETE /api/students/[id] error:', error.message) + console.error("DELETE /api/students/[id] error:", error.message); } - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); } } diff --git a/app/api/students/route.ts b/app/api/students/route.ts index 8f3dcc2..93d77d4 100644 --- a/app/api/students/route.ts +++ b/app/api/students/route.ts @@ -92,9 +92,10 @@ 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) {