diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bba324b..2c46976 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,6 +59,7 @@ model School { reportSchedules ReportSchedule[] homeworks Homework[] announcements Announcement[] + notificationPreferences NotificationPreferences[] selfAssessments SelfAssessment[] learningGoals LearningGoal[] portfolioEntries PortfolioEntry[] @@ -93,7 +94,11 @@ model School { communicationRooms CommunicationRoom[] aiTestGenerations AITestGeneration[] gradingReviews TeacherGradingReview[] + gradingAnnotations GradingAnnotation[] chatMessages ChatMessage[] + examPlans ExamPlan[] + studyPlans StudyPlan[] + studySessions StudySession[] } model User { @@ -134,6 +139,7 @@ model User { assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee") homeworks Homework[] announcements Announcement[] + notificationPreferences NotificationPreferences? timetableSlots TimetableSlot[] resources Resource[] organizedSchoolEvents SchoolEvent[] @@ -164,12 +170,15 @@ model User { aiTestGenerations AITestGeneration[] gradingReviews TeacherGradingReview[] gradingReviewComments GradingReviewComment[] + gradingAnnotations GradingAnnotation[] + announcementReads AnnouncementRead[] selectedCharacterId String? // Communication room relations roomStudent CommunicationRoom[] @relation("RoomStudent") roomTeacher CommunicationRoom[] @relation("RoomTeacher") // Class responsible teacher responsibleClasses ClassGroup[] @relation("ResponsibleTeacher") + examPlans ExamPlan[] } model SchoolYear { @@ -226,6 +235,7 @@ model ClassGroup { peerAssessments PeerAssessment[] schoolEvents SchoolEvent[] aiTestGenerations AITestGeneration[] + examPlans ExamPlan[] } model ClassGroupTeacher { @@ -281,6 +291,9 @@ model Student { goals StudentGoal[] illnessReports IllnessReport[] disciplinaryCases DisciplinaryCase[] + studyPlans StudyPlan[] + studySessions StudySession[] + gradingAnnotations GradingAnnotation[] @@index([userId]) } @@ -328,6 +341,9 @@ model Subject { competitions Competition[] subjectContents SubjectContent[] aiTestGenerations AITestGeneration[] + examPlans ExamPlan[] + studyPlans StudyPlan[] + studySessions StudySession[] } model CompetencyTemplate { @@ -447,6 +463,8 @@ model Assessment { assessmentResults AssessmentResult[] aiTestGenerations AITestGeneration[] gradingReviews TeacherGradingReview[] + gradingAnnotations GradingAnnotation[] + examPlan ExamPlan? } model AssessmentCompetencyLink { @@ -473,6 +491,7 @@ model AssessmentResult { assessment Assessment @relation(fields: [assessmentId], references: [id]) student Student @relation(fields: [studentId], references: [id]) + annotations GradingAnnotation[] @@unique([assessmentId, studentId]) } @@ -979,9 +998,12 @@ model Notification { schoolId String userId String // recipient type String // ASSESSMENT_DUE, MISSING_OBSERVATION, NOTEBOOK_SHARED, BEHAVIOR_ALERT, GRADE_COMPUTED, etc. + category String @default("academic") // academic, behavioral, administrative, calendar, communication, system + priority String @default("normal") // urgent, high, normal, low title String message String isRead Boolean @default(false) + isArchived Boolean @default(false) actionUrl String? // optional deep link relatedId String? // optional related entity ID createdAt DateTime @default(now()) @@ -993,6 +1015,8 @@ model Notification { @@index([userId, isRead]) @@index([schoolId]) + @@index([category]) + @@index([priority]) @@map("notifications") } @@ -1024,6 +1048,7 @@ model CalendarEvent { classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) parentEvent CalendarEvent? @relation("EventRecurrence", fields: [parentEventId], references: [id]) childEvents CalendarEvent[] @relation("EventRecurrence") + examPlan ExamPlan? @@index([schoolId]) @@index([teacherId]) @@ -1252,6 +1277,7 @@ model Announcement { title String content String priority String @default("normal") // low, normal, high, urgent + announcementType String @default("general") // general, urgent, event, holiday, exam, deadline targetAudience String @default("all") // all, teachers, students, parents, class classGroupId String? // if target is specific class isPinned Boolean @default(false) @@ -1264,11 +1290,52 @@ model Announcement { school School @relation(fields: [schoolId], references: [id]) author User @relation(fields: [authorId], references: [id]) classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + reads AnnouncementRead[] @@index([schoolId, createdAt]) @@map("announcements") } +model AnnouncementRead { + id String @id @default(cuid()) + announcementId String + userId String + readAt DateTime @default(now()) + + announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([announcementId, userId]) + @@index([announcementId]) + @@index([userId]) + @@map("announcement_reads") +} + +model NotificationPreferences { + id String @id @default(cuid()) + userId String @unique + schoolId String + academicEnabled Boolean @default(true) + behavioralEnabled Boolean @default(true) + administrativeEnabled Boolean @default(true) + calendarEnabled Boolean @default(true) + communicationEnabled Boolean @default(true) + systemEnabled Boolean @default(true) + quietHoursStart String? // HH:mm format, e.g. "22:00" + quietHoursEnd String? // HH:mm format, e.g. "07:00" + quietHoursEnabled Boolean @default(false) + emailDigestEnabled Boolean @default(false) + emailDigestFrequency String @default("daily") // daily, weekly, never + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + school School @relation(fields: [schoolId], references: [id]) + + @@index([userId]) + @@map("notification_preferences") +} + // ─── Student Self-Assessment ────────────────────────────────────────── model SelfAssessment { @@ -2434,3 +2501,144 @@ model GradingReviewComment { @@index([userId]) @@map("grading_review_comments") } + +// ─── Grading Annotations (Tablet Stylus) ──────────────────────────── + +model GradingAnnotation { + id String @id @default(cuid()) + schoolId String + assessmentId String + studentId String + resultId String? // AssessmentResult ID + teacherId String + type String @default("drawing") // drawing, text, highlight, stamp + content String? // text content or stamp symbol + positionX Float @default(0) + positionY Float @default(0) + width Float? + height Float? + color String @default("#ef4444") + strokeWidth Float @default(2) + page Int @default(1) + pathData String? // JSON: array of {x,y,pressure} points for drawing + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + assessment Assessment @relation(fields: [assessmentId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + result AssessmentResult? @relation(fields: [resultId], references: [id]) + teacher User @relation(fields: [teacherId], references: [id]) + + @@index([schoolId]) + @@index([assessmentId]) + @@index([studentId]) + @@index([teacherId]) + @@index([page]) + @@map("grading_annotations") +} + +// ─── Exam Planning ───────────────────────────────────────────────── + +model ExamPlan { + id String @id @default(cuid()) + schoolId String + teacherId String + subjectId String + classGroupId String + title String + date DateTime + startTime String? // HH:mm format + endTime String? // HH:mm format + room String? + topics String? // JSON array of topic strings + weight Float @default(1.0) + status String @default("planned") // planned, confirmed, completed, cancelled + notes String? + calendarEventId String? @unique // Link to CalendarEvent + assessmentId String? @unique // Link to Assessment (after exam is created) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + teacher User @relation(fields: [teacherId], references: [id]) + subject Subject @relation(fields: [subjectId], references: [id]) + classGroup ClassGroup @relation(fields: [classGroupId], references: [id]) + calendarEvent CalendarEvent? @relation(fields: [calendarEventId], references: [id]) + assessment Assessment? @relation(fields: [assessmentId], references: [id]) + + @@index([schoolId]) + @@index([teacherId]) + @@index([subjectId]) + @@index([classGroupId]) + @@index([date]) + @@index([status]) + @@map("exam_plans") +} + +// ─── Student Study Planner ────────────────────────────────────────── + +model StudyPlan { + id String @id @default(cuid()) + schoolId String + studentId String + title String + description String? + subjectId String? + subjectName String? // Denormalized subject name for display + dayOfWeek Int // 0=Sunday, 1=Monday, ..., 6=Saturday + startTime String // HH:mm format + duration Int // Duration in minutes + isRecurring Boolean @default(true) + specificDate DateTime? // For one-time study sessions + priority String @default("medium") // low, medium, high + status String @default("active") // active, completed, paused, cancelled + color String? // Custom color for the plan + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + sessions StudySession[] + + @@index([schoolId]) + @@index([studentId]) + @@index([dayOfWeek]) + @@index([status]) + @@map("study_plans") +} + +model StudySession { + id String @id @default(cuid()) + schoolId String + studentId String + studyPlanId String? // Optional link to a StudyPlan + subjectId String? + subjectName String? // Denormalized subject name + startTime DateTime + endTime DateTime? + duration Int @default(0) // Actual duration in minutes + plannedDuration Int? // Planned duration in minutes + type String @default("pomodoro") // pomodoro, free, guided + pomodorosCompleted Int @default(0) + pomodoroLength Int @default(25) // Minutes per pomodoro + breakLength Int @default(5) // Minutes per break + status String @default("in_progress") // in_progress, completed, abandoned + focusScore Int? // 1-100 self-reported focus score + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + studyPlan StudyPlan? @relation(fields: [studyPlanId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + + @@index([schoolId]) + @@index([studentId]) + @@index([startTime]) + @@index([status]) + @@map("study_sessions") +} diff --git a/src/app/api/ai-grading-audit/[id]/route.ts b/src/app/api/ai-grading-audit/[id]/route.ts new file mode 100644 index 0000000..9f9085a --- /dev/null +++ b/src/app/api/ai-grading-audit/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const review = await db.teacherGradingReview.findUnique({ + where: { id }, + include: { + assessment: { select: { id: true, title: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + comments: { + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!review) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(review); + } catch (error) { + console.error('AI Grading Audit GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/ai-grading-audit/route.ts b/src/app/api/ai-grading-audit/route.ts new file mode 100644 index 0000000..ca16a30 --- /dev/null +++ b/src/app/api/ai-grading-audit/route.ts @@ -0,0 +1,133 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import ZAI from 'z-ai-web-dev-sdk'; + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const role = session.user?.role; + if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { schoolId, assessmentId, studentId } = body; + + if (!schoolId || !assessmentId || !studentId) { + return NextResponse.json({ error: 'schoolId, assessmentId, and studentId are required' }, { status: 400 }); + } + + // Get assessment and student data + const assessment = await db.assessment.findUnique({ + where: { id: assessmentId }, + include: { + results: { + where: { studentId }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + }); + + if (!assessment) { + return NextResponse.json({ error: 'Assessment not found' }, { status: 404 }); + } + + // Get annotations for this student's submission + const annotations = await db.gradingAnnotation.findMany({ + where: { assessmentId, studentId }, + orderBy: { createdAt: 'asc' }, + }); + + const studentResult = assessment.results[0]; + const student = studentResult?.student; + + // Generate AI grading audit + const systemPrompt = `Du bist ein erfahrener Lehrer und Prüfer an einer deutschen Schule. +Du überprüfst die Benotung eines Schülers und gibst detailliertes Feedback. +Die deutsche Notenskala: 1 (sehr gut), 2 (gut), 3 (befriedigend), 4 (ausreichend), 5 (mangelhaft), 6 (ungenügend). +Analysiere die Leistung und die gegebene Benotung. Wenn die Benotung nicht angemessen ist, schlage eine Korrektur vor mit Begründung. +Antworte immer auf Deutsch und im JSON-Format.`; + + const gradingInfo = { + studentName: student ? `${student.firstName} ${student.lastName}` : 'Unknown', + assessmentTitle: assessment.title, + assessmentType: assessment.type, + maxScore: assessment.maxScore, + score: studentResult?.score ?? null, + note: studentResult?.note ?? null, + annotationsCount: annotations.length, + annotationTypes: annotations.map((a: { type: string; content: string | null }) => ({ type: a.type, content: a.content })), + teacherAnnotations: annotations.filter((a: { type: string }) => a.type === 'stamp' || a.type === 'text').map((a: { type: string; content: string | null; color: string }) => ({ type: a.type, content: a.content, color: a.color })), + }; + + let reviewResult: string; + let discrepanciesFound = 0; + + try { + const ai = await ZAI.create(); + const response = await ai.chat.completions.create({ + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: `Überprüfe die Benotung für diesen Schüler und gib Feedback:\n\n${JSON.stringify(gradingInfo, null, 2)}\n\nGib deine Antwort als JSON mit folgenden Feldern:\n- currentGrade: die aktuelle Benotung\n- suggestedGrade: deine vorgeschlagene Note\n- reason: detaillierte Begründung auf Deutsch\n- discrepancies: Liste der gefundenen Unstimmigkeiten\n- overallAssessment: Gesamtbewertung der Benotung`, + }, + ], + }); + + reviewResult = response?.choices?.[0]?.message?.content || 'Review could not be generated'; + } catch (aiError) { + console.error('AI grading audit error:', aiError); + reviewResult = JSON.stringify({ + currentGrade: studentResult?.score ?? 'N/A', + suggestedGrade: 'N/A', + reason: 'AI review could not be generated. Manual review recommended.', + discrepancies: ['AI analysis unavailable — please review manually'], + overallAssessment: 'AI audit could not be completed due to a technical error. Please review the grading manually.', + }); + } + + // Count discrepancies + try { + const parsed = typeof reviewResult === 'string' ? JSON.parse(reviewResult) : reviewResult; + discrepanciesFound = Array.isArray(parsed.discrepancies) ? parsed.discrepancies.length : 0; + } catch { + discrepanciesFound = 0; + } + + // Create or update the grading review + const review = await db.teacherGradingReview.create({ + data: { + schoolId, + assessmentId, + teacherId: session.userId, + aiProvider: 'pollination', + status: 'completed', + reviewResult, + discrepanciesFound, + }, + include: { + assessment: { select: { id: true, title: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + comments: { + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + return NextResponse.json(review, { status: 201 }); + } catch (error) { + console.error('AI Grading Audit POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/announcements/[id]/read/route.ts b/src/app/api/announcements/[id]/read/route.ts new file mode 100644 index 0000000..b474c60 --- /dev/null +++ b/src/app/api/announcements/[id]/read/route.ts @@ -0,0 +1,69 @@ +// CompetenceTrack — Announcement Read Receipt API +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + // Verify announcement exists + const announcement = await db.announcement.findUnique({ + where: { id, deletedAt: null }, + }); + + if (!announcement) { + return NextResponse.json({ error: 'Announcement not found' }, { status: 404 }); + } + + // Upsert read receipt + const readReceipt = await db.announcementRead.upsert({ + where: { + announcementId_userId: { + announcementId: id, + userId: session.user.id, + }, + }, + update: { + readAt: new Date(), + }, + create: { + announcementId: id, + userId: session.user.id, + }, + }); + + return NextResponse.json({ readReceipt }); +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + // Get all read receipts for this announcement + const reads = await db.announcementRead.findMany({ + where: { announcementId: id }, + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + orderBy: { readAt: 'desc' }, + }); + + return NextResponse.json({ reads, totalReads: reads.length }); +} diff --git a/src/app/api/announcements/[id]/route.ts b/src/app/api/announcements/[id]/route.ts index 644ef53..54db40c 100644 --- a/src/app/api/announcements/[id]/route.ts +++ b/src/app/api/announcements/[id]/route.ts @@ -5,11 +5,13 @@ import { getSession } from '@/lib/auth'; const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']); const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']); +const announcementTypeEnum = z.enum(['general', 'urgent', 'event', 'holiday', 'exam', 'deadline']); const updateAnnouncementSchema = z.object({ title: z.string().min(1).max(200).optional(), content: z.string().min(1).max(10000).optional(), priority: priorityEnum.optional(), + announcementType: announcementTypeEnum.optional(), targetAudience: targetAudienceEnum.optional(), classGroupId: z.string().optional().nullable(), isPinned: z.boolean().optional(), @@ -17,7 +19,7 @@ const updateAnnouncementSchema = z.object({ }); function isTeacherOrAdmin(role: string | undefined): boolean { - return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL'; } export async function GET( @@ -36,6 +38,12 @@ export async function GET( include: { author: { select: { id: true, firstName: true, lastName: true } }, classGroup: { select: { id: true, name: true } }, + reads: { + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { readAt: 'desc' }, + }, }, }); @@ -43,7 +51,21 @@ export async function GET( return NextResponse.json({ error: 'Announcement not found' }, { status: 404 }); } - return NextResponse.json(announcement); + // Check if current user has read it + const readReceipt = await db.announcementRead.findUnique({ + where: { + announcementId_userId: { + announcementId: id, + userId: session.userId, + }, + }, + }); + + return NextResponse.json({ + ...announcement, + isReadByCurrentUser: !!readReceipt, + totalReads: announcement.reads.length, + }); } catch (error) { console.error('Announcement GET [id] error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -87,6 +109,7 @@ export async function PUT( if (parsed.data.title !== undefined) updateData.title = parsed.data.title; if (parsed.data.content !== undefined) updateData.content = parsed.data.content; if (parsed.data.priority !== undefined) updateData.priority = parsed.data.priority; + if (parsed.data.announcementType !== undefined) updateData.announcementType = parsed.data.announcementType; if (parsed.data.targetAudience !== undefined) updateData.targetAudience = parsed.data.targetAudience; if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId || null; if (parsed.data.isPinned !== undefined) updateData.isPinned = parsed.data.isPinned; diff --git a/src/app/api/announcements/route.ts b/src/app/api/announcements/route.ts index cf096a9..af5c6cd 100644 --- a/src/app/api/announcements/route.ts +++ b/src/app/api/announcements/route.ts @@ -5,12 +5,14 @@ import { getSession } from '@/lib/auth'; const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']); const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']); +const announcementTypeEnum = z.enum(['general', 'urgent', 'event', 'holiday', 'exam', 'deadline']); const createAnnouncementSchema = z.object({ schoolId: z.string().min(1), title: z.string().min(1).max(200), content: z.string().min(1).max(10000), priority: priorityEnum.optional().default('normal'), + announcementType: announcementTypeEnum.optional().default('general'), targetAudience: targetAudienceEnum.optional().default('all'), classGroupId: z.string().optional().nullable(), isPinned: z.boolean().optional().default(false), @@ -18,7 +20,7 @@ const createAnnouncementSchema = z.object({ }); function isTeacherOrAdmin(role: string | undefined): boolean { - return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL'; } export async function GET(request: Request) { @@ -34,10 +36,12 @@ export async function GET(request: Request) { const targetAudience = searchParams.get('targetAudience'); const classGroupId = searchParams.get('classGroupId'); const isPinned = searchParams.get('isPinned'); + const announcementType = searchParams.get('announcementType'); const limit = parseInt(searchParams.get('limit') || '50', 10); + const includeReads = searchParams.get('includeReads') === 'true'; let schoolId: string | undefined; - if (session.user?.role === 'SCHOOL_ADMIN') { + if (session.user?.role === 'SCHOOL_ADMIN' || session.user?.role === 'VICE_PRINCIPAL') { schoolId = session.user.schoolId ?? undefined; } else { schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; @@ -59,6 +63,7 @@ export async function GET(request: Request) { if (targetAudience && targetAudience !== 'all') where.targetAudience = targetAudience; if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId; if (isPinned === 'true') where.isPinned = true; + if (announcementType && announcementType !== 'all') where.announcementType = announcementType; // Filter by role-based target audience if (session.user?.role === 'STUDENT') { @@ -83,10 +88,52 @@ export async function GET(request: Request) { include: { author: { select: { id: true, firstName: true, lastName: true } }, classGroup: { select: { id: true, name: true } }, + ...(includeReads ? { + reads: { + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { readAt: 'desc' as const }, + }, + } : {}), }, }); - return NextResponse.json(announcements); + // Add read status for the current user + const currentUserId = session.userId; + const announcementsWithReadStatus = await Promise.all( + announcements.map(async (a) => { + if (!includeReads) { + const readReceipt = await db.announcementRead.findUnique({ + where: { + announcementId_userId: { + announcementId: a.id, + userId: currentUserId, + }, + }, + }); + return { + ...a, + isReadByCurrentUser: !!readReceipt, + }; + } + const readReceipt = await db.announcementRead.findUnique({ + where: { + announcementId_userId: { + announcementId: a.id, + userId: currentUserId, + }, + }, + }); + return { + ...a, + isReadByCurrentUser: !!readReceipt, + totalReads: a.reads?.length ?? 0, + }; + }) + ); + + return NextResponse.json(announcementsWithReadStatus); } catch (error) { console.error('Announcements GET error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -138,6 +185,7 @@ export async function POST(request: Request) { title: rest.title, content: rest.content, priority: rest.priority, + announcementType: rest.announcementType, targetAudience: rest.targetAudience, classGroupId: classGroupId || null, isPinned: rest.isPinned, diff --git a/src/app/api/exam-plans/[id]/route.ts b/src/app/api/exam-plans/[id]/route.ts new file mode 100644 index 0000000..24d4f86 --- /dev/null +++ b/src/app/api/exam-plans/[id]/route.ts @@ -0,0 +1,200 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get single exam plan ────────────────────────────────────────── +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + + const examPlan = await db.examPlan.findUnique({ + where: { id }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + calendarEvent: { select: { id: true } }, + assessment: { select: { id: true, title: true } }, + }, + }); + + if (!examPlan) { + return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 }); + } + + // Role-based access check + if ( + session.user?.role === 'STUDENT' || + session.user?.role === 'PARENT' + ) { + // Students and parents can view but not modify + } else if (session.user?.role === 'TEACHER') { + // Teachers can only view their own exams + if (examPlan.teacherId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + return NextResponse.json(examPlan); + } catch (error) { + console.error('ExamPlan GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update exam plan ────────────────────────────────────────────── +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const userRole = session.user?.role; + + if ( + userRole !== 'TEACHER' && + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const existing = await db.examPlan.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 }); + } + + // Teachers can only edit their own exams + if (userRole === 'TEACHER' && existing.teacherId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + title, + date, + startTime, + endTime, + subjectId, + classGroupId, + room, + topics, + weight, + status, + notes, + } = body; + + const updateData: Record = {}; + if (title !== undefined) updateData.title = title; + if (date !== undefined) updateData.date = new Date(date); + if (startTime !== undefined) updateData.startTime = startTime || null; + if (endTime !== undefined) updateData.endTime = endTime || null; + if (subjectId !== undefined) updateData.subjectId = subjectId; + if (classGroupId !== undefined) updateData.classGroupId = classGroupId; + if (room !== undefined) updateData.room = room || null; + if (topics !== undefined) updateData.topics = topics ? JSON.stringify(topics) : null; + if (weight !== undefined) updateData.weight = weight; + if (status !== undefined) updateData.status = status; + if (notes !== undefined) updateData.notes = notes || null; + + const updated = await db.examPlan.update({ + where: { id }, + data: updateData, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + calendarEvent: { select: { id: true } }, + assessment: { select: { id: true, title: true } }, + }, + }); + + // Also update the linked CalendarEvent + if (existing.calendarEventId) { + const calendarUpdateData: Record = {}; + if (title !== undefined) calendarUpdateData.title = `[Exam] ${title}`; + if (date !== undefined) calendarUpdateData.date = new Date(date); + if (startTime !== undefined) calendarUpdateData.startTime = startTime || null; + if (endTime !== undefined) calendarUpdateData.endTime = endTime || null; + if (subjectId !== undefined) calendarUpdateData.subjectId = subjectId; + if (classGroupId !== undefined) calendarUpdateData.classGroupId = classGroupId; + if (notes !== undefined) calendarUpdateData.notes = notes || null; + if (startTime !== undefined || endTime !== undefined) { + calendarUpdateData.allDay = !startTime && !endTime; + } + + if (Object.keys(calendarUpdateData).length > 0) { + await db.calendarEvent.update({ + where: { id: existing.calendarEventId }, + data: calendarUpdateData, + }); + } + } + + return NextResponse.json(updated); + } catch (error) { + console.error('ExamPlan PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── DELETE: Delete exam plan ─────────────────────────────────────────── +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const userRole = session.user?.role; + + if ( + userRole !== 'TEACHER' && + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const existing = await db.examPlan.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 }); + } + + // Teachers can only delete their own exams + if (userRole === 'TEACHER' && existing.teacherId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Delete the linked CalendarEvent first + if (existing.calendarEventId) { + await db.calendarEvent.delete({ where: { id: existing.calendarEventId } }).catch(() => {}); + } + + await db.examPlan.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('ExamPlan DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/exam-plans/route.ts b/src/app/api/exam-plans/route.ts new file mode 100644 index 0000000..b111434 --- /dev/null +++ b/src/app/api/exam-plans/route.ts @@ -0,0 +1,316 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { differenceInCalendarDays } from 'date-fns'; + +// ── GET: List exam plans with filters ────────────────────────────────── +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined; + const classId = searchParams.get('classId'); + const subjectId = searchParams.get('subjectId'); + const teacherId = searchParams.get('teacherId'); + const status = searchParams.get('status'); + const dateFrom = searchParams.get('dateFrom'); + const dateTo = searchParams.get('dateTo'); + const viewMode = searchParams.get('viewMode') ?? 'all'; // all, upcoming, past + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + + if (classId) where.classGroupId = classId; + if (subjectId) where.subjectId = subjectId; + if (teacherId) where.teacherId = teacherId; + if (status) where.status = status; + + // Date filters + const dateFilter: Record = {}; + if (dateFrom) dateFilter.gte = new Date(dateFrom); + if (dateTo) dateFilter.lte = new Date(dateTo); + if (Object.keys(dateFilter).length > 0) where.date = dateFilter; + + // View mode filters + const now = new Date(); + if (viewMode === 'upcoming') { + where.date = { ...((where.date as Record) || {}), gte: now }; + } else if (viewMode === 'past') { + where.date = { ...((where.date as Record) || {}), lt: now }; + } + + // Role-based access + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId }, + select: { id: true }, + }); + if (student) { + const enrollments = await db.enrollment.findMany({ + where: { studentId: student.id }, + select: { classGroupId: true }, + }); + const classIds = enrollments.map((e) => e.classGroupId); + where.classGroupId = { in: classIds }; + } + } else if (session.user?.role === 'PARENT') { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.userId }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + const enrollments = await db.enrollment.findMany({ + where: { studentId: { in: studentIds } }, + select: { classGroupId: true }, + }); + const classIds = enrollments.map((e) => e.classGroupId); + where.classGroupId = { in: classIds }; + } else if (session.user?.role === 'TEACHER') { + // Teachers see their own exams by default, or all if explicitly requested + if (!teacherId) { + where.teacherId = session.userId; + } + } + + const examPlans = await db.examPlan.findMany({ + where, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + calendarEvent: { select: { id: true } }, + assessment: { select: { id: true, title: true } }, + }, + orderBy: { date: 'asc' }, + }); + + // Add computed fields + const enriched = examPlans.map((plan) => { + const daysUntil = differenceInCalendarDays(new Date(plan.date), now); + const isWithinTwoWeeks = daysUntil >= 0 && daysUntil <= 14; + return { + ...plan, + daysUntil, + isWithinTwoWeeks, + countdownLabel: + daysUntil === 0 + ? 'today' + : daysUntil === 1 + ? 'tomorrow' + : daysUntil < 7 + ? `${daysUntil} days` + : daysUntil < 14 + ? `${Math.floor(daysUntil / 7)} week` + : `${Math.floor(daysUntil / 7)} weeks`, + }; + }); + + // Check for AI test availability for each exam + const examIdsWithTests = await db.aITestGeneration.findMany({ + where: { + classGroupId: { in: enriched.map((e) => e.classGroupId) }, + subjectId: { in: enriched.map((e) => e.subjectId) }, + isCompleted: true, + }, + select: { classGroupId: true, subjectId: true }, + }); + + const testSet = new Set( + examIdsWithTests.map((t) => `${t.classGroupId}-${t.subjectId}`) + ); + + const result = enriched.map((plan) => ({ + ...plan, + hasAITest: testSet.has(`${plan.classGroupId}-${plan.subjectId}`), + })); + + // Statistics (for admin view) + let stats = null; + if ( + session.user?.role === 'SCHOOL_ADMIN' || + session.user?.role === 'VICE_PRINCIPAL' || + session.user?.role === 'SUPER_ADMIN' + ) { + const totalExams = await db.examPlan.count({ where: { schoolId } }); + const upcomingExams = await db.examPlan.count({ + where: { schoolId, date: { gte: now } }, + }); + const plannedExams = await db.examPlan.count({ + where: { schoolId, status: 'planned' }, + }); + const confirmedExams = await db.examPlan.count({ + where: { schoolId, status: 'confirmed' }, + }); + const completedExams = await db.examPlan.count({ + where: { schoolId, status: 'completed' }, + }); + const cancelledExams = await db.examPlan.count({ + where: { schoolId, status: 'cancelled' }, + }); + + // Exams per subject + const examsPerSubject = await db.examPlan.groupBy({ + by: ['subjectId'], + where: { schoolId }, + _count: { id: true }, + }); + + const subjectNames = await db.subject.findMany({ + where: { + id: { in: examsPerSubject.map((e) => e.subjectId) }, + }, + select: { id: true, name: true }, + }); + + const subjectMap = Object.fromEntries( + subjectNames.map((s) => [s.id, s.name]) + ); + + // Exams per class + const examsPerClass = await db.examPlan.groupBy({ + by: ['classGroupId'], + where: { schoolId }, + _count: { id: true }, + }); + + const classNames = await db.classGroup.findMany({ + where: { + id: { in: examsPerClass.map((e) => e.classGroupId) }, + }, + select: { id: true, name: true }, + }); + + const classMap = Object.fromEntries( + classNames.map((c) => [c.id, c.name]) + ); + + stats = { + totalExams, + upcomingExams, + byStatus: { planned: plannedExams, confirmed: confirmedExams, completed: completedExams, cancelled: cancelledExams }, + bySubject: examsPerSubject.map((e) => ({ + subjectId: e.subjectId, + subjectName: subjectMap[e.subjectId] || e.subjectId, + count: e._count.id, + })), + byClass: examsPerClass.map((e) => ({ + classGroupId: e.classGroupId, + className: classMap[e.classGroupId] || e.classGroupId, + count: e._count.id, + })), + }; + } + + return NextResponse.json({ exams: result, stats }); + } catch (error) { + console.error('ExamPlans GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create an exam plan ────────────────────────────────────────── +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userRole = session.user?.role; + if ( + userRole !== 'TEACHER' && + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + title, + date, + startTime, + endTime, + subjectId, + classGroupId, + schoolId, + room, + topics, + weight, + status, + notes, + } = body; + + if (!title || !date || !schoolId || !subjectId || !classGroupId) { + return NextResponse.json( + { error: 'title, date, schoolId, subjectId, and classGroupId are required' }, + { status: 400 } + ); + } + + // Create the exam plan + const examPlan = await db.examPlan.create({ + data: { + schoolId, + teacherId: session.userId, + subjectId, + classGroupId, + title, + date: new Date(date), + startTime: startTime || null, + endTime: endTime || null, + room: room || null, + topics: topics ? JSON.stringify(topics) : null, + weight: weight ?? 1.0, + status: status || 'planned', + notes: notes || null, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Also create a CalendarEvent for the exam + const calendarEvent = await db.calendarEvent.create({ + data: { + schoolId, + teacherId: session.userId, + title: `[Exam] ${title}`, + date: new Date(date), + startTime: startTime || null, + endTime: endTime || null, + eventType: 'exam', + subjectId, + classGroupId, + notes: notes || null, + allDay: !startTime && !endTime, + }, + }); + + // Link the calendar event to the exam plan + await db.examPlan.update({ + where: { id: examPlan.id }, + data: { calendarEventId: calendarEvent.id }, + }); + + const result = { + ...examPlan, + calendarEventId: calendarEvent.id, + }; + + return NextResponse.json(result, { status: 201 }); + } catch (error) { + console.error('ExamPlans POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/grading-annotations/[id]/route.ts b/src/app/api/grading-annotations/[id]/route.ts new file mode 100644 index 0000000..897e577 --- /dev/null +++ b/src/app/api/grading-annotations/[id]/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const updateAnnotationSchema = z.object({ + type: z.enum(['drawing', 'text', 'highlight', 'stamp']).optional(), + content: z.string().optional(), + positionX: z.number().optional(), + positionY: z.number().optional(), + width: z.number().optional(), + height: z.number().optional(), + color: z.string().optional(), + strokeWidth: z.number().optional(), + page: z.number().optional(), + pathData: z.string().optional(), +}); + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const annotation = await db.gradingAnnotation.findUnique({ + where: { id }, + include: { + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (!annotation) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(annotation); + } catch (error) { + console.error('GradingAnnotation GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const role = session.user?.role; + if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateAnnotationSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const existing = await db.gradingAnnotation.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + // Only the creator or admin can update + if (existing.teacherId !== session.userId && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const annotation = await db.gradingAnnotation.update({ + where: { id }, + data: parsed.data, + include: { + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(annotation); + } catch (error) { + console.error('GradingAnnotation PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const role = session.user?.role; + if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const existing = await db.gradingAnnotation.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (existing.teacherId !== session.userId && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.gradingAnnotation.delete({ where: { id } }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('GradingAnnotation DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/grading-annotations/route.ts b/src/app/api/grading-annotations/route.ts new file mode 100644 index 0000000..75c85fe --- /dev/null +++ b/src/app/api/grading-annotations/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const createAnnotationSchema = z.object({ + schoolId: z.string().min(1), + assessmentId: z.string().min(1), + studentId: z.string().min(1), + resultId: z.string().optional(), + type: z.enum(['drawing', 'text', 'highlight', 'stamp']).default('drawing'), + content: z.string().optional(), + positionX: z.number().default(0), + positionY: z.number().default(0), + width: z.number().optional(), + height: z.number().optional(), + color: z.string().default('#ef4444'), + strokeWidth: z.number().default(2), + page: z.number().default(1), + pathData: z.string().optional(), +}); + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const assessmentId = searchParams.get('assessmentId'); + const studentId = searchParams.get('studentId'); + const page = searchParams.get('page'); + + if (!assessmentId) { + return NextResponse.json({ error: 'assessmentId is required' }, { status: 400 }); + } + + const where: Record = { assessmentId }; + if (studentId) where.studentId = studentId; + if (page) where.page = parseInt(page, 10); + + const annotations = await db.gradingAnnotation.findMany({ + where, + orderBy: { createdAt: 'asc' }, + include: { + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(annotations); + } catch (error) { + console.error('GradingAnnotations GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const role = session.user?.role; + if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createAnnotationSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const data = parsed.data; + const annotation = await db.gradingAnnotation.create({ + data: { + schoolId: data.schoolId, + assessmentId: data.assessmentId, + studentId: data.studentId, + resultId: data.resultId, + teacherId: session.userId, + type: data.type, + content: data.content, + positionX: data.positionX, + positionY: data.positionY, + width: data.width, + height: data.height, + color: data.color, + strokeWidth: data.strokeWidth, + page: data.page, + pathData: data.pathData, + }, + include: { + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(annotation, { status: 201 }); + } catch (error) { + console.error('GradingAnnotations POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts new file mode 100644 index 0000000..d279f4c --- /dev/null +++ b/src/app/api/notifications/[id]/route.ts @@ -0,0 +1,103 @@ +// CompetenceTrack — Single Notification API +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const notification = await db.notification.findFirst({ + where: { id, userId: session.user.id, deletedAt: null }, + }); + + if (!notification) { + return NextResponse.json({ error: 'Notification not found' }, { status: 404 }); + } + + return NextResponse.json(notification); +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const { id } = await params; + const body = await request.json(); + const { isRead, isArchived, action } = body as { + isRead?: boolean; + isArchived?: boolean; + action?: 'read' | 'unread' | 'archive' | 'unarchive' | 'dismiss'; + }; + + const existing = await db.notification.findFirst({ + where: { id, userId: session.user.id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Notification not found' }, { status: 404 }); + } + + const updateData: Record = {}; + if (action === 'read' || isRead === true) { + updateData.isRead = true; + } else if (action === 'unread' || isRead === false) { + updateData.isRead = false; + } + if (action === 'archive' || isArchived === true) { + updateData.isArchived = true; + } else if (action === 'unarchive' || isArchived === false) { + updateData.isArchived = false; + } + if (action === 'dismiss') { + updateData.deletedAt = new Date(); + } + + const notification = await db.notification.update({ + where: { id }, + data: updateData, + }); + + return NextResponse.json({ notification }); + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.notification.findFirst({ + where: { id, userId: session.user.id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Notification not found' }, { status: 404 }); + } + + await db.notification.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts new file mode 100644 index 0000000..06cb697 --- /dev/null +++ b/src/app/api/notifications/preferences/route.ts @@ -0,0 +1,100 @@ +// CompetenceTrack — Notification Preferences API +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET() { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const schoolId = session.user.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 400 }); + } + + let prefs = await db.notificationPreferences.findUnique({ + where: { userId: session.user.id }, + }); + + // Create default preferences if none exist + if (!prefs) { + prefs = await db.notificationPreferences.create({ + data: { + userId: session.user.id, + schoolId, + }, + }); + } + + return NextResponse.json(prefs); +} + +export async function PUT(request: Request) { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const schoolId = session.user.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 400 }); + } + + try { + const body = await request.json(); + const { + academicEnabled, + behavioralEnabled, + administrativeEnabled, + calendarEnabled, + communicationEnabled, + systemEnabled, + quietHoursStart, + quietHoursEnd, + quietHoursEnabled, + emailDigestEnabled, + emailDigestFrequency, + } = body as { + academicEnabled?: boolean; + behavioralEnabled?: boolean; + administrativeEnabled?: boolean; + calendarEnabled?: boolean; + communicationEnabled?: boolean; + systemEnabled?: boolean; + quietHoursStart?: string | null; + quietHoursEnd?: string | null; + quietHoursEnabled?: boolean; + emailDigestEnabled?: boolean; + emailDigestFrequency?: string; + }; + + const updateData: Record = {}; + if (academicEnabled !== undefined) updateData.academicEnabled = academicEnabled; + if (behavioralEnabled !== undefined) updateData.behavioralEnabled = behavioralEnabled; + if (administrativeEnabled !== undefined) updateData.administrativeEnabled = administrativeEnabled; + if (calendarEnabled !== undefined) updateData.calendarEnabled = calendarEnabled; + if (communicationEnabled !== undefined) updateData.communicationEnabled = communicationEnabled; + if (systemEnabled !== undefined) updateData.systemEnabled = systemEnabled; + if (quietHoursStart !== undefined) updateData.quietHoursStart = quietHoursStart; + if (quietHoursEnd !== undefined) updateData.quietHoursEnd = quietHoursEnd; + if (quietHoursEnabled !== undefined) updateData.quietHoursEnabled = quietHoursEnabled; + if (emailDigestEnabled !== undefined) updateData.emailDigestEnabled = emailDigestEnabled; + if (emailDigestFrequency !== undefined) updateData.emailDigestFrequency = emailDigestFrequency; + + const prefs = await db.notificationPreferences.upsert({ + where: { userId: session.user.id }, + update: updateData, + create: { + userId: session.user.id, + schoolId, + ...updateData, + }, + }); + + return NextResponse.json(prefs); + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index b214273..21a9d9c 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,9 +1,9 @@ -// CompetenceTrack — Notifications API (DB-backed) +// CompetenceTrack — Notifications API (DB-backed, Enhanced) import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; -export async function GET() { +export async function GET(request: Request) { const session = await getSession(); if (!session?.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -19,18 +19,54 @@ export async function GET() { }); } + const { searchParams } = new URL(request.url); + const category = searchParams.get('category'); + const priority = searchParams.get('priority'); + const isRead = searchParams.get('isRead'); + const isArchived = searchParams.get('isArchived'); + const dateFrom = searchParams.get('dateFrom'); + const dateTo = searchParams.get('dateTo'); + const limit = parseInt(searchParams.get('limit') || '50', 10); + const offset = parseInt(searchParams.get('offset') || '0', 10); + + // Build where clause for DB notifications + const where: Record = { + userId, + deletedAt: null, + }; + + if (category && category !== 'all') where.category = category; + if (priority && priority !== 'all') where.priority = priority; + if (isRead === 'true') where.isRead = true; + else if (isRead === 'false') where.isRead = false; + if (isArchived === 'true') where.isArchived = true; + else if (isArchived === 'false') where.isArchived = false; + else if (!isArchived) where.isArchived = false; // default: not archived + + if (dateFrom || dateTo) { + const dateFilter: Record = {}; + if (dateFrom) dateFilter.gte = new Date(dateFrom); + if (dateTo) dateFilter.lte = new Date(dateTo); + where.createdAt = dateFilter; + } + // Fetch DB notifications for the current user const notifications = await db.notification.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + }); + + const unreadCount = await db.notification.count({ where: { userId, deletedAt: null, + isRead: false, + isArchived: false, }, - orderBy: { createdAt: 'desc' }, - take: 50, }); - const unreadCount = notifications.filter((n) => !n.isRead).length; - // Also generate dynamic notifications from existing data const now = new Date(); const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); @@ -71,12 +107,15 @@ export async function GET() { } // Upcoming assessments (next 7 days) - const upcomingAssessments: Array<{ + const dynamicNotifications: Array<{ id: string; type: string; + category: string; + priority: string; title: string; message: string; isRead: boolean; + isArchived: boolean; actionUrl: string | null; relatedId: string | null; createdAt: string; @@ -94,26 +133,25 @@ export async function GET() { include: { classGroup: { select: { name: true } }, subject: { select: { name: true } }, - assessmentResults: { select: { id: true } }, }, orderBy: { date: 'asc' }, take: 10, }); for (const a of assessments) { - const diffMs = new Date(a.date).getTime() - now.getTime(); - const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24))); - // Check if we already have a DB notification for this assessment const existing = notifications.find( (n) => n.type === 'ASSESSMENT_DUE' && n.relatedId === a.id ); if (!existing) { - upcomingAssessments.push({ + dynamicNotifications.push({ id: `assessment-${a.id}`, type: 'ASSESSMENT_DUE', + category: 'academic', + priority: 'high', title: a.title, message: `${a.classGroup.name} - ${a.subject.name}`, isRead: false, + isArchived: false, actionUrl: 'assessments', relatedId: a.id, createdAt: new Date().toISOString(), @@ -153,12 +191,15 @@ export async function GET() { (n) => n.type === 'MISSING_OBSERVATION' && n.relatedId === e.student.id ); if (!existing) { - upcomingAssessments.push({ + dynamicNotifications.push({ id: `missing-${e.student.id}-${e.classGroupId}`, type: 'MISSING_OBSERVATION', + category: 'academic', + priority: 'normal', title: `${e.student.firstName} ${e.student.lastName}`, message: e.classGroup.name, isRead: false, + isArchived: false, actionUrl: 'progress', relatedId: e.student.id, createdAt: new Date().toISOString(), @@ -173,19 +214,41 @@ export async function GET() { const dbNotifications = notifications.map((n) => ({ id: n.id, type: n.type, + category: n.category, + priority: n.priority, title: n.title, message: n.message, isRead: n.isRead, + isArchived: n.isArchived, actionUrl: n.actionUrl, relatedId: n.relatedId, createdAt: n.createdAt.toISOString(), })); - const allNotifications = [...dbNotifications, ...upcomingAssessments]; + const allNotifications = [...dbNotifications, ...dynamicNotifications]; + + // Get statistics + const totalByCategory = await db.notification.groupBy({ + by: ['category'], + where: { + userId, + deletedAt: null, + isArchived: false, + }, + _count: { id: true }, + }); + + const stats = { + byCategory: totalByCategory.map((item) => ({ + category: item.category, + count: item._count.id, + })), + }; return NextResponse.json({ notifications: allNotifications, - unreadCount: allNotifications.filter((n) => !n.isRead).length, + unreadCount, + stats, }); } @@ -197,10 +260,15 @@ export async function PUT(request: Request) { try { const body = await request.json(); - const { ids, markAll } = body as { ids?: string[]; markAll?: boolean }; + const { ids, markAll, action, archiveAll, deleteIds } = body as { + ids?: string[]; + markAll?: boolean; + action?: 'read' | 'unread' | 'archive' | 'unarchive'; + archiveAll?: boolean; + deleteIds?: string[]; + }; if (markAll) { - // Mark all notifications as read await db.notification.updateMany({ where: { userId: session.user.id, @@ -212,16 +280,48 @@ export async function PUT(request: Request) { return NextResponse.json({ success: true }); } + if (archiveAll) { + await db.notification.updateMany({ + where: { + userId: session.user.id, + deletedAt: null, + isArchived: false, + }, + data: { isArchived: true }, + }); + return NextResponse.json({ success: true }); + } + if (ids && Array.isArray(ids)) { - // Mark specific DB notifications as read const dbIds = ids.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-')); + if (dbIds.length > 0) { + const updateData: Record = {}; + if (action === 'read') updateData.isRead = true; + else if (action === 'unread') updateData.isRead = false; + else if (action === 'archive') updateData.isArchived = true; + else if (action === 'unarchive') updateData.isArchived = false; + else updateData.isRead = true; // default: mark read + + await db.notification.updateMany({ + where: { + id: { in: dbIds }, + userId: session.user.id, + }, + data: updateData, + }); + } + return NextResponse.json({ success: true }); + } + + if (deleteIds && Array.isArray(deleteIds)) { + const dbIds = deleteIds.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-')); if (dbIds.length > 0) { await db.notification.updateMany({ where: { id: { in: dbIds }, userId: session.user.id, }, - data: { isRead: true }, + data: { deletedAt: new Date() }, }); } return NextResponse.json({ success: true }); @@ -241,10 +341,12 @@ export async function POST(request: Request) { try { const body = await request.json(); - const { schoolId, userId, type, title, message, actionUrl, relatedId } = body as { + const { schoolId, userId, type, category, priority, title, message, actionUrl, relatedId } = body as { schoolId: string; userId: string; type: string; + category?: string; + priority?: string; title: string; message: string; actionUrl?: string; @@ -255,11 +357,27 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + // Determine category from type if not provided + const categoryMap: Record = { + ASSESSMENT_DUE: 'academic', + MISSING_OBSERVATION: 'academic', + NOTEBOOK_SHARED: 'academic', + BEHAVIOR_ALERT: 'behavioral', + GRADE_COMPUTED: 'academic', + ATTENDANCE_ALERT: 'administrative', + REPORT_READY: 'administrative', + CALENDAR_EVENT: 'calendar', + COMMUNICATION: 'communication', + SYSTEM: 'system', + }; + const notification = await db.notification.create({ data: { schoolId, userId, type, + category: category || categoryMap[type] || 'system', + priority: priority || 'normal', title, message, actionUrl: actionUrl ?? null, @@ -284,16 +402,15 @@ export async function DELETE(request: Request) { const { ids } = body as { ids?: string[] }; if (ids && Array.isArray(ids)) { - // Soft-delete specific notifications + const dbIds = ids.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-')); await db.notification.updateMany({ where: { - id: { in: ids }, + id: { in: dbIds }, userId: session.user.id, }, data: { deletedAt: new Date() }, }); } else { - // Soft-delete all notifications for the user await db.notification.updateMany({ where: { userId: session.user.id, diff --git a/src/app/api/student-achievements/route.ts b/src/app/api/student-achievements/route.ts new file mode 100644 index 0000000..a6840ad --- /dev/null +++ b/src/app/api/student-achievements/route.ts @@ -0,0 +1,269 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const schoolIdParam = searchParams.get('schoolId'); + const studentIdParam = searchParams.get('studentId'); + + let schoolId: string | undefined; + if (session.user?.role === 'SCHOOL_ADMIN') { + schoolId = session.user.schoolId ?? undefined; + } else { + schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; + } + + if (!schoolId) return NextResponse.json([]); + + // If student is requesting their own data + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId, schoolId, deletedAt: null }, + select: { id: true }, + }); + if (!student) return NextResponse.json({ badges: [], character: null, leaderboard: [] }); + + const studentId = student.id; + + // Get student's earned badges + const studentBadges = await db.studentBadge.findMany({ + where: { schoolId, studentId }, + orderBy: { awardedAt: 'desc' }, + include: { + badge: true, + awardedByUser: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Get all available badges for the school + const allBadges = await db.badge.findMany({ + where: { schoolId, deletedAt: null }, + orderBy: { category: 'asc' }, + }); + + // Get virtual character + const character = await db.virtualCharacter.findUnique({ + where: { userId: session.userId }, + }); + + // Get class-level leaderboard (students in same class, ordered by XP) + const enrollment = await db.enrollment.findFirst({ + where: { studentId, endDate: null }, + select: { classGroupId: true }, + }); + + let leaderboard: Array<{ studentId: string; firstName: string; lastName: string; xp: number; level: number; badgeCount: number }> = []; + if (enrollment) { + const classStudents = await db.enrollment.findMany({ + where: { classGroupId: enrollment.classGroupId, endDate: null }, + select: { studentId: true }, + }); + const classStudentIds = classStudents.map(e => e.studentId); + + const studentData = await db.student.findMany({ + where: { id: { in: classStudentIds }, deletedAt: null }, + select: { + id: true, + firstName: true, + lastName: true, + userId: true, + studentBadges: { select: { id: true } }, + }, + }); + + const charData = await db.virtualCharacter.findMany({ + where: { userId: { in: studentData.map(s => s.userId).filter(Boolean) as string[] } }, + select: { userId: true, xp: true, level: true }, + }); + + const charMap = new Map(charData.map(c => [c.userId, c])); + leaderboard = studentData + .map(s => { + const char = s.userId ? charMap.get(s.userId) : null; + return { + studentId: s.id, + firstName: s.firstName, + lastName: s.lastName, + xp: char?.xp ?? 0, + level: char?.level ?? 1, + badgeCount: s.studentBadges.length, + }; + }) + .sort((a, b) => b.xp - a.xp) + .slice(0, 20); + } + + return NextResponse.json({ + badges: allBadges.map(b => ({ + ...b, + earned: studentBadges.some(sb => sb.badgeId === b.id), + earnedAt: studentBadges.find(sb => sb.badgeId === b.id)?.awardedAt ?? null, + })), + earnedBadges: studentBadges, + character, + leaderboard, + totalXP: character?.xp ?? 0, + level: character?.level ?? 1, + }); + } + + // Teacher/Admin view + if (studentIdParam) { + const studentBadges = await db.studentBadge.findMany({ + where: { schoolId, studentId: studentIdParam }, + orderBy: { awardedAt: 'desc' }, + include: { + badge: true, + student: { select: { id: true, firstName: true, lastName: true } }, + awardedByUser: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + const allBadges = await db.badge.findMany({ + where: { schoolId, deletedAt: null }, + orderBy: { category: 'asc' }, + }); + + return NextResponse.json({ + badges: allBadges.map(b => ({ + ...b, + earned: studentBadges.some(sb => sb.badgeId === b.id), + earnedAt: studentBadges.find(sb => sb.badgeId === b.id)?.awardedAt ?? null, + })), + earnedBadges: studentBadges, + }); + } + + // Return all badges for school + const allBadges = await db.badge.findMany({ + where: { schoolId, deletedAt: null }, + orderBy: { category: 'asc' }, + }); + + return NextResponse.json({ badges: allBadges }); + } catch (error) { + console.error('StudentAchievements GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const body = await request.json(); + const { action, schoolId, studentId, badgeId, xpAmount, characterData } = body; + + if (action === 'award_badge') { + if (!schoolId || !studentId || !badgeId) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // Check if already awarded + const existing = await db.studentBadge.findUnique({ + where: { studentId_badgeId: { studentId, badgeId } }, + }); + if (existing) return NextResponse.json({ error: 'Badge already awarded' }, { status: 409 }); + + const badge = await db.badge.findUnique({ where: { id: badgeId, deletedAt: null } }); + if (!badge) return NextResponse.json({ error: 'Badge not found' }, { status: 404 }); + + const studentBadge = await db.studentBadge.create({ + data: { + schoolId, + studentId, + badgeId, + awardedBy: session.userId, + }, + include: { badge: true }, + }); + + // Award XP for badge + const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } }); + if (student?.userId) { + await upsertVirtualCharacter(student.userId, schoolId, 50); + } + + return NextResponse.json(studentBadge, { status: 201 }); + } + + if (action === 'add_xp') { + if (!schoolId || !studentId || xpAmount === undefined) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } }); + if (!student?.userId) return NextResponse.json({ error: 'Student has no user account' }, { status: 400 }); + + const character = await upsertVirtualCharacter(student.userId, schoolId, xpAmount); + return NextResponse.json(character); + } + + if (action === 'update_character') { + if (!characterData) { + return NextResponse.json({ error: 'Missing characterData' }, { status: 400 }); + } + + const character = await db.virtualCharacter.upsert({ + where: { userId: session.userId }, + update: { + characterId: characterData.characterId ?? undefined, + name: characterData.name ?? undefined, + color: characterData.color ?? undefined, + mood: characterData.mood ?? undefined, + }, + create: { + userId: session.userId, + schoolId: schoolId ?? session.user?.schoolId ?? '', + characterId: characterData.characterId ?? 'owl', + name: characterData.name ?? 'My Companion', + color: characterData.color ?? '#10b981', + mood: characterData.mood ?? 'happy', + }, + }); + + return NextResponse.json(character); + } + + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } catch (error) { + console.error('StudentAchievements POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +async function upsertVirtualCharacter(userId: string, schoolId: string, xpGain: number) { + const existing = await db.virtualCharacter.findUnique({ where: { userId } }); + + if (existing) { + const newXp = existing.xp + xpGain; + const newLevel = Math.floor(newXp / 100) + 1; + + return db.virtualCharacter.update({ + where: { userId }, + data: { + xp: newXp, + level: newLevel, + mood: newLevel > existing.level ? 'celebrating' : 'happy', + }, + }); + } + + return db.virtualCharacter.create({ + data: { + userId, + schoolId, + characterId: 'owl', + name: 'My Companion', + xp: xpGain, + level: Math.floor(xpGain / 100) + 1, + mood: 'happy', + }, + }); +} diff --git a/src/app/api/student-study-planner/[id]/route.ts b/src/app/api/student-study-planner/[id]/route.ts new file mode 100644 index 0000000..f0342e9 --- /dev/null +++ b/src/app/api/student-study-planner/[id]/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { id } = await params; + const body = await request.json(); + + // Verify the plan belongs to the student + const existing = await db.studyPlan.findUnique({ where: { id } }); + if (!existing) return NextResponse.json({ error: 'Plan not found' }, { status: 404 }); + + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId, schoolId: existing.schoolId, deletedAt: null }, + select: { id: true }, + }); + if (!student || student.id !== existing.studentId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const plan = await db.studyPlan.update({ + where: { id }, + data: { + title: body.title ?? undefined, + description: body.description ?? undefined, + subjectId: body.subjectId ?? undefined, + subjectName: body.subjectName ?? undefined, + dayOfWeek: body.dayOfWeek ?? undefined, + startTime: body.startTime ?? undefined, + duration: body.duration ?? undefined, + isRecurring: body.isRecurring ?? undefined, + specificDate: body.specificDate ? new Date(body.specificDate) : undefined, + priority: body.priority ?? undefined, + status: body.status ?? undefined, + color: body.color ?? undefined, + notes: body.notes ?? undefined, + }, + include: { + subject: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(plan); + } catch (error) { + console.error('StudyPlan PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { id } = await params; + + const existing = await db.studyPlan.findUnique({ where: { id } }); + if (!existing) return NextResponse.json({ error: 'Plan not found' }, { status: 404 }); + + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId, schoolId: existing.schoolId, deletedAt: null }, + select: { id: true }, + }); + if (!student || student.id !== existing.studentId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + // Soft delete by setting status to cancelled + await db.studyPlan.update({ + where: { id }, + data: { status: 'cancelled' }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('StudyPlan DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/student-study-planner/route.ts b/src/app/api/student-study-planner/route.ts new file mode 100644 index 0000000..b102b85 --- /dev/null +++ b/src/app/api/student-study-planner/route.ts @@ -0,0 +1,166 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const schoolIdParam = searchParams.get('schoolId'); + const studentIdParam = searchParams.get('studentId'); + + let schoolId: string | undefined; + if (session.user?.role === 'SCHOOL_ADMIN') { + schoolId = session.user.schoolId ?? undefined; + } else { + schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; + } + + if (!schoolId) return NextResponse.json([]); + + let studentId = studentIdParam; + + // If student is requesting their own data + if (session.user?.role === 'STUDENT' && !studentId) { + const student = await db.student.findFirst({ + where: { userId: session.userId, schoolId, deletedAt: null }, + select: { id: true }, + }); + if (!student) return NextResponse.json([]); + studentId = student.id; + } + + if (!studentId) return NextResponse.json([]); + + // Verify access + if (session.user?.role === 'STUDENT') { + const student = await db.student.findUnique({ + where: { id: studentId }, + select: { userId: true }, + }); + if (!student || student.userId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const studyPlans = await db.studyPlan.findMany({ + where: { schoolId, studentId, status: 'active' }, + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + include: { + subject: { select: { id: true, name: true } }, + }, + }); + + // Get recent sessions + const recentSessions = await db.studySession.findMany({ + where: { schoolId, studentId }, + orderBy: { startTime: 'desc' }, + take: 10, + include: { + subject: { select: { id: true, name: true } }, + }, + }); + + // Get weekly stats + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + const weekSessions = await db.studySession.findMany({ + where: { + schoolId, + studentId, + status: 'completed', + startTime: { gte: oneWeekAgo }, + }, + }); + + const totalMinutesThisWeek = weekSessions.reduce((sum, s) => sum + s.duration, 0); + const subjectTimeMap = new Map(); + weekSessions.forEach(s => { + const name = s.subjectName ?? 'Other'; + subjectTimeMap.set(name, (subjectTimeMap.get(name) ?? 0) + s.duration); + }); + + return NextResponse.json({ + plans: studyPlans, + recentSessions, + weeklyStats: { + totalMinutes: totalMinutesThisWeek, + sessionsCount: weekSessions.length, + subjectBreakdown: Object.fromEntries(subjectTimeMap), + }, + }); + } catch (error) { + console.error('StudyPlanner GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const body = await request.json(); + const { + schoolId, + studentId, + title, + description, + subjectId, + subjectName, + dayOfWeek, + startTime, + duration, + isRecurring, + specificDate, + priority, + color, + notes, + } = body; + + if (!schoolId || !studentId || !title || dayOfWeek === undefined || !startTime || !duration) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // Verify access + if (session.user?.role === 'STUDENT') { + const student = await db.student.findUnique({ + where: { id: studentId }, + select: { userId: true, schoolId: true }, + }); + if (!student || student.userId !== session.userId || student.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const plan = await db.studyPlan.create({ + data: { + schoolId, + studentId, + title, + description: description || null, + subjectId: subjectId || null, + subjectName: subjectName || null, + dayOfWeek, + startTime, + duration, + isRecurring: isRecurring ?? true, + specificDate: specificDate ? new Date(specificDate) : null, + priority: priority || 'medium', + color: color || null, + notes: notes || null, + }, + include: { + subject: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(plan, { status: 201 }); + } catch (error) { + console.error('StudyPlanner POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/student-study-sessions/route.ts b/src/app/api/student-study-sessions/route.ts new file mode 100644 index 0000000..cb6d7f2 --- /dev/null +++ b/src/app/api/student-study-sessions/route.ts @@ -0,0 +1,147 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const body = await request.json(); + const { + schoolId, + studentId, + studyPlanId, + subjectId, + subjectName, + startTime, + plannedDuration, + type, + pomodoroLength, + breakLength, + notes, + } = body; + + if (!schoolId || !studentId) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // Verify access + if (session.user?.role === 'STUDENT') { + const student = await db.student.findUnique({ + where: { id: studentId }, + select: { userId: true, schoolId: true }, + }); + if (!student || student.userId !== session.userId || student.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const studySession = await db.studySession.create({ + data: { + schoolId, + studentId, + studyPlanId: studyPlanId || null, + subjectId: subjectId || null, + subjectName: subjectName || null, + startTime: startTime ? new Date(startTime) : new Date(), + plannedDuration: plannedDuration || null, + type: type || 'pomodoro', + pomodoroLength: pomodoroLength || 25, + breakLength: breakLength || 5, + notes: notes || null, + status: 'in_progress', + }, + }); + + // Award XP for starting a study session + const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } }); + if (student?.userId) { + const existing = await db.virtualCharacter.findUnique({ where: { userId: student.userId } }); + if (existing) { + await db.virtualCharacter.update({ + where: { userId: student.userId }, + data: { xp: existing.xp + 5, level: Math.floor((existing.xp + 5) / 100) + 1 }, + }); + } else { + await db.virtualCharacter.create({ + data: { + userId: student.userId, + schoolId, + characterId: 'owl', + name: 'My Companion', + xp: 5, + level: 1, + mood: 'happy', + }, + }); + } + } + + return NextResponse.json(studySession, { status: 201 }); + } catch (error) { + console.error('StudySession POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const body = await request.json(); + const { sessionId, endTime, duration, pomodorosCompleted, focusScore, status, notes } = body; + + if (!sessionId) { + return NextResponse.json({ error: 'sessionId is required' }, { status: 400 }); + } + + const existing = await db.studySession.findUnique({ where: { id: sessionId } }); + if (!existing) return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + + // Verify access + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId, schoolId: existing.schoolId, deletedAt: null }, + select: { id: true }, + }); + if (!student || student.id !== existing.studentId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const updated = await db.studySession.update({ + where: { id: sessionId }, + data: { + endTime: endTime ? new Date(endTime) : undefined, + duration: duration ?? undefined, + pomodorosCompleted: pomodorosCompleted ?? undefined, + focusScore: focusScore ?? undefined, + status: status ?? undefined, + notes: notes ?? undefined, + }, + }); + + // Award XP for completing a study session + if (status === 'completed' && duration) { + const xpGain = Math.floor(duration / 5); // 1 XP per 5 minutes + const student = await db.student.findUnique({ where: { id: existing.studentId }, select: { userId: true } }); + if (student?.userId) { + const char = await db.virtualCharacter.findUnique({ where: { userId: student.userId } }); + if (char) { + const newXp = char.xp + xpGain; + await db.virtualCharacter.update({ + where: { userId: student.userId }, + data: { xp: newXp, level: Math.floor(newXp / 100) + 1 }, + }); + } + } + } + + return NextResponse.json(updated); + } catch (error) { + console.error('StudySession PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 1119f2e..3321566 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -9,6 +9,7 @@ const updateUserSchema = z.object({ email: z.string().email().optional(), role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN', 'VICE_PRINCIPAL']).optional(), locale: z.string().optional(), + schoolId: z.string().optional(), }); export async function GET( diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 3eef885..6875001 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -63,6 +63,9 @@ import { MessageSquare, Brain, Sparkles, + Tablet, + CalendarClock, + Timer, } from 'lucide-react'; import { Sidebar, @@ -168,6 +171,12 @@ import AITestsView from './ai-tests-view'; import AIChatWidget from './ai-chat-widget'; import VirtualCharacter from './virtual-character'; import ParentPortalView from './parent-portal-view'; +import NotificationCenterView from './notification-center-view'; +import SchoolAnnouncementsView from './school-announcements-view'; +import TabletGradingView from './tablet-grading-view'; +import ExamCalendarView from './exam-calendar-view'; +import StudentAchievementsView from './student-achievements-view'; +import StudentStudyPlannerView from './student-study-planner-view'; // Dynamic imports for heavy components with loading skeletons const AnalyticsView = dynamic(() => import('./analytics-view'), { @@ -232,6 +241,10 @@ const navSections: NavSection[] = [ { key: 'counseling', icon: Heart, labelKey: 'counseling.title' }, { key: 'disciplinary', icon: Shield, labelKey: 'disciplinary.title' }, { key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' }, + { key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' }, + { key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' }, + { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, + { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, ], }, { @@ -266,7 +279,13 @@ const studentNavSections: NavSection[] = [ { key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' }, { key: 'counseling', icon: Heart, labelKey: 'counseling.title' }, { key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' }, + { key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' }, + { key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' }, + { key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' }, + { key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' }, { key: 'resources', icon: FolderOpen, labelKey: 'nav.resources' }, + { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, + { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, { key: 'settings', icon: Settings, labelKey: 'nav.settings' }, ], }, @@ -287,6 +306,10 @@ const parentNavSections: NavSection[] = [ { key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' }, { key: 'illness', icon: Heart, labelKey: 'nav.illness' }, { key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' }, + { key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' }, + { key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' }, + { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, + { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, ], }, ]; @@ -426,6 +449,12 @@ function renderView(view: ViewName) { case 'disciplinary': return ; case 'ai-tests': return ; case 'parent-portal': return ; + case 'notification-center': return ; + case 'announcements': return ; + case 'tablet-grading': return ; + case 'exam-calendar': return ; + case 'student-achievements': return ; + case 'student-study-planner': return ; default: return ; } } @@ -1246,7 +1275,15 @@ export default function AppLayout() { {/* Footer */} {!notifLoading && notifData && notifData.notifications.length > 0 && ( -
+
+ + )} +
+
+ + + {/* Stats Cards */} + {isAdmin && stats && ( + + + +
+
+ +
+
+

+ +

+

{t('exam_calendar.total_exams')}

+
+
+
+
+ + +
+
+ +
+
+

+ +

+

{t('exam_calendar.upcoming_exams')}

+
+
+
+
+ + +
+
+ +
+
+

+ +

+

{t('exam_calendar.confirmed_exams')}

+
+
+
+
+ + +
+
+ +
+
+

+ +

+

{t('exam_calendar.two_week_warning')}

+
+
+
+
+
+ )} + + {/* Teacher/Student Quick Stats */} + {!isAdmin && ( + + + +
+
+ +
+
+

+ +

+

+ {isStudent ? t('exam_calendar.student_upcoming') : t('exam_calendar.upcoming_exams')} +

+
+
+
+
+ + +
+
+ +
+
+

+ +

+

{t('exam_calendar.two_week_warning')}

+
+
+
+
+ + +
+
+ +
+
+

+ e.hasAITest).length} /> +

+

{t('exam_calendar.ai_tests_available')}

+
+
+
+
+
+ )} + + {/* 2-Week Warning Banner with pulsing indicator */} + {twoWeekWarningExams.length > 0 && ( + + {/* Pulsing warning glow */} + +
+ + + + + {t('exam_calendar.two_week_banner_title')} + + +
+
+ {twoWeekWarningExams.slice(0, 5).map((exam) => { + const color = getSubjectColor(exam.subjectId); + return ( + + openDetailDialog(exam)} + > + {exam.title} ({exam.countdownLabel}) + + + ); + })} +
+
+ )} + + {/* Filters & View Toggle */} + +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 w-48 h-9" + /> +
+ + + {(isTeacher || isAdmin) && ( + + )} +
+
+ + +
+
+ + {/* Content */} + + {viewMode === 'calendar' ? ( + + {/* Calendar Navigation */} +
+ +

{monthLabel}

+ +
+ + {/* Calendar Grid */} +
+ {/* Week day headers */} +
+ {weekDays.map((day) => ( +
+ {day} +
+ ))} +
+ {/* Day cells */} +
+ {calendarDays.map((dayInfo, idx) => { + const isToday = dayInfo.date.toDateString() === new Date().toDateString(); + const hasExams = dayInfo.exams.length > 0; + return ( + + {/* Shimmer effect on cells with exams */} + {hasExams && dayInfo.isCurrentMonth && ( + + )} +
+ + {dayInfo.date.getDate()} + + {(isTeacher || isAdmin) && dayInfo.isCurrentMonth && ( + + )} +
+
+ {dayInfo.exams.slice(0, 3).map((exam) => { + const color = getSubjectColor(exam.subjectId); + return ( + openDetailDialog(exam)} + title={exam.title} + > + {exam.title} + {exam.startTime && ( + {exam.startTime} + )} + {exam.isWithinTwoWeeks && ( + + )} + + ); + })} + {dayInfo.exams.length > 3 && ( +
+ +{dayInfo.exams.length - 3} {t('exam_calendar.more')} +
+ )} +
+
+ ); + })} +
+
+
+ ) : ( + + +
+ {filteredExams.length === 0 && !loading && ( +
+ +

{t('exam_calendar.no_exams')}

+
+ )} + {filteredExams.map((exam, idx) => { + const color = getSubjectColor(exam.subjectId); + const statusColor = getStatusColor(exam.status); + const StatusIcon = getStatusIcon(exam.status); + return ( + + openDetailDialog(exam)} + > + +
+
+
+

{exam.title}

+ + + {t(`exam_calendar.status_${exam.status}`)} + + {exam.isWithinTwoWeeks && exam.status !== 'cancelled' && ( + + + {exam.countdownLabel} + + )} + {exam.hasAITest && ( + + + {t('exam_calendar.ai_test_available')} + + )} +
+
+ + + {new Date(exam.date).toLocaleDateString(currentUser?.locale === 'en' ? 'en-US' : 'de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })} + + {exam.startTime && ( + + + {exam.startTime}{exam.endTime ? ` - ${exam.endTime}` : ''} + + )} + +
+ {exam.subject.name} + + + + {exam.classGroup.name} + + {exam.room && ( + + + {exam.room} + + )} +
+
+
+ {exam.daysUntil >= 0 && exam.status !== 'cancelled' && ( + + {exam.countdownLabel} + + )} + {(isTeacher || isAdmin) && ( + + + + + + { e.stopPropagation(); openEditDialog(exam); }}> + + {t('action.edit')} + + { e.stopPropagation(); setSelectedExam(exam); setDeleteOpen(true); }} + className="text-destructive" + > + + {t('action.delete')} + + + + )} +
+
+ + + + ); + })} +
+ +
+ )} + + + {/* Admin Stats Section */} + {isAdmin && stats && ( + + + + + + {t('exam_calendar.exams_by_subject')} + + + +
+ {stats.bySubject.map((item) => { + const max = Math.max(...stats.bySubject.map((s) => s.count), 1); + const pct = (item.count / max) * 100; + return ( +
+
+ {item.subjectName} + {item.count} +
+
+ +
+
+ ); + })} +
+
+
+ + + + + {t('exam_calendar.exams_by_class')} + + + +
+ {stats.byClass.map((item) => { + const max = Math.max(...stats.byClass.map((c) => c.count), 1); + const pct = (item.count / max) * 100; + return ( +
+
+ {item.className} + {item.count} +
+
+ +
+
+ ); + })} +
+
+
+
+ )} + + {/* ── Create Exam Dialog ──────────────────────────────────────── */} + + + + + + {t('exam_calendar.create_exam')} + + {t('exam_calendar.create_exam_desc')} + +
+
+ + setFormTitle(e.target.value)} placeholder={t('exam_calendar.exam_title_placeholder')} /> +
+
+
+ + setFormDate(e.target.value)} /> +
+
+ + setFormWeight(e.target.value)} /> +
+
+
+
+ + setFormStartTime(e.target.value)} /> +
+
+ + setFormEndTime(e.target.value)} /> +
+
+
+
+ + +
+
+ + +
+
+
+ + setFormRoom(e.target.value)} placeholder={t('exam_calendar.room_placeholder')} /> +
+
+ + setFormTopics(e.target.value)} placeholder={t('exam_calendar.topics_placeholder')} /> +
+
+ + +
+
+ +