From f1776932d767c8362798bd86b7c4663abb6a43b4 Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 20:09:03 +0000 Subject: [PATCH] feat: Round 21 - Major feature additions - Schlaukopf.de clone: Subject Content Management System (7 categories, 416 topics) - AI Integration: Pollination AI, OpenAI, Anthropic (chat, image/video generation) - Virtual Character System: 5 animated characters (Owl, Dragon, Robot, Cat, Wizard) - Illness Reporting with Parent Approval: privacy-first, student reports need parent approval - Student-Teacher Communication Rooms: real-time chat, voice, file sharing - Counseling/Guidance System: 4 types, calendar integration - Disciplinary Committee System: case management, severity levels - AI Test Generation: auto-generate practice tests before exams - Teacher Grading Review: AI-powered grading audit - VICE_PRINCIPAL role with same privileges as SCHOOL_ADMIN - Class responsible teacher (Klassenlehrer) field - 15 new Prisma models (80+ total) - 30+ new API route files - 8 new frontend components - 400+ i18n keys - Professional styling with gradient headers, animated counters, skeleton loading --- prisma/schema.prisma | 493 +++- src/app/api/ai-settings/[id]/route.ts | 87 + src/app/api/ai-settings/route.ts | 85 + src/app/api/ai-tests/[id]/route.ts | 80 + src/app/api/ai-tests/auto-generate/route.ts | 122 + src/app/api/ai-tests/route.ts | 156 ++ src/app/api/ai/character/route.ts | 112 + src/app/api/ai/chat/route.ts | 223 ++ src/app/api/ai/generate-topics/route.ts | 105 + src/app/api/ai/image/route.ts | 79 + src/app/api/ai/settings/route.ts | 153 ++ src/app/api/ai/video/route.ts | 69 + src/app/api/auth/route.ts | 2 +- .../[id]/messages/route.ts | 143 ++ src/app/api/communication-rooms/[id]/route.ts | 187 ++ src/app/api/communication-rooms/route.ts | 181 ++ .../api/content-change-requests/[id]/route.ts | 77 + src/app/api/content-change-requests/route.ts | 112 + src/app/api/counseling/[id]/route.ts | 155 ++ src/app/api/counseling/route.ts | 139 ++ src/app/api/disciplinary-cases/[id]/route.ts | 105 + src/app/api/disciplinary-cases/route.ts | 107 + .../api/disciplinary-committees/[id]/route.ts | 145 ++ src/app/api/disciplinary-committees/route.ts | 94 + .../grading-reviews/[id]/comments/route.ts | 73 + src/app/api/grading-reviews/[id]/route.ts | 82 + src/app/api/grading-reviews/route.ts | 157 ++ src/app/api/illness-reports/[id]/route.ts | 205 ++ src/app/api/illness-reports/approve/route.ts | 73 + src/app/api/illness-reports/route.ts | 174 ++ src/app/api/subject-categories/[id]/route.ts | 114 + src/app/api/subject-categories/route.ts | 101 + src/app/api/subject-contents/[id]/route.ts | 140 ++ src/app/api/subject-contents/route.ts | 136 + src/app/api/subject-seed/route.ts | 233 ++ src/components/ai-chat-widget.tsx | 435 ++++ src/components/ai-tests-view.tsx | 736 ++++++ src/components/app-layout.tsx | 37 +- src/components/auth-view.tsx | 1 + src/components/communication-view.tsx | 899 +++++++ src/components/counseling-view.tsx | 685 ++++++ src/components/disciplinary-view.tsx | 736 ++++++ src/components/illness-reporting-view.tsx | 810 ++++++ src/components/subjects-view.tsx | 2186 +++++++++++++++++ src/components/virtual-character.tsx | 881 +++++++ src/lib/i18n.ts | 974 ++++++++ src/lib/store.ts | 8 +- worklog.md | 476 ++++ 48 files changed, 13559 insertions(+), 4 deletions(-) create mode 100644 src/app/api/ai-settings/[id]/route.ts create mode 100644 src/app/api/ai-settings/route.ts create mode 100644 src/app/api/ai-tests/[id]/route.ts create mode 100644 src/app/api/ai-tests/auto-generate/route.ts create mode 100644 src/app/api/ai-tests/route.ts create mode 100644 src/app/api/ai/character/route.ts create mode 100644 src/app/api/ai/chat/route.ts create mode 100644 src/app/api/ai/generate-topics/route.ts create mode 100644 src/app/api/ai/image/route.ts create mode 100644 src/app/api/ai/settings/route.ts create mode 100644 src/app/api/ai/video/route.ts create mode 100644 src/app/api/communication-rooms/[id]/messages/route.ts create mode 100644 src/app/api/communication-rooms/[id]/route.ts create mode 100644 src/app/api/communication-rooms/route.ts create mode 100644 src/app/api/content-change-requests/[id]/route.ts create mode 100644 src/app/api/content-change-requests/route.ts create mode 100644 src/app/api/counseling/[id]/route.ts create mode 100644 src/app/api/counseling/route.ts create mode 100644 src/app/api/disciplinary-cases/[id]/route.ts create mode 100644 src/app/api/disciplinary-cases/route.ts create mode 100644 src/app/api/disciplinary-committees/[id]/route.ts create mode 100644 src/app/api/disciplinary-committees/route.ts create mode 100644 src/app/api/grading-reviews/[id]/comments/route.ts create mode 100644 src/app/api/grading-reviews/[id]/route.ts create mode 100644 src/app/api/grading-reviews/route.ts create mode 100644 src/app/api/illness-reports/[id]/route.ts create mode 100644 src/app/api/illness-reports/approve/route.ts create mode 100644 src/app/api/illness-reports/route.ts create mode 100644 src/app/api/subject-categories/[id]/route.ts create mode 100644 src/app/api/subject-categories/route.ts create mode 100644 src/app/api/subject-contents/[id]/route.ts create mode 100644 src/app/api/subject-contents/route.ts create mode 100644 src/app/api/subject-seed/route.ts create mode 100644 src/components/ai-chat-widget.tsx create mode 100644 src/components/ai-tests-view.tsx create mode 100644 src/components/communication-view.tsx create mode 100644 src/components/counseling-view.tsx create mode 100644 src/components/disciplinary-view.tsx create mode 100644 src/components/illness-reporting-view.tsx create mode 100644 src/components/subjects-view.tsx create mode 100644 src/components/virtual-character.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d09f7de..8e58691 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,6 +81,19 @@ model School { subjectTopics SubjectTopic[] district SchoolDistrict? @relation(fields: [districtId], references: [id]) districtId String? + // New features + subjectCategories SubjectCategory[] + subjectContents SubjectContent[] + contentChangeRequests ContentChangeRequest[] + aiSettings AISettings? + virtualCharacters VirtualCharacter[] + illnessReports IllnessReport[] + counselingAppointments CounselingAppointment[] + disciplinaryCommittees DisciplinaryCommittee[] + communicationRooms CommunicationRoom[] + aiTestGenerations AITestGeneration[] + gradingReviews TeacherGradingReview[] + chatMessages ChatMessage[] } model User { @@ -90,7 +103,7 @@ model User { passwordHash String firstName String lastName String - role String @default("TEACHER") // TEACHER, SCHOOL_ADMIN, SUPER_ADMIN, STUDENT, PARENT + role String @default("TEACHER") // TEACHER, SCHOOL_ADMIN, SUPER_ADMIN, STUDENT, PARENT, VICE_PRINCIPAL locale String @default("de") twoFactorSecret String? isDemo Boolean @default(false) @@ -134,6 +147,29 @@ model User { rewardPoints RewardPoints[] studentAnswers StudentAnswer[] student Student? + // New features + contentChangeRequests ContentChangeRequest[] @relation("ContentRequester") + contentChangeReviews ContentChangeRequest[] @relation("ContentReviewer") + chatMessages ChatMessage[] + illnessReports IllnessReport[] + illnessReportApprovals IllnessReport[] @relation("IllnessApprover") + communicationRoomMembers CommunicationRoomMember[] + communicationMessages CommunicationMessage[] + counselingAppointments CounselingAppointment[] @relation("Counselor") + counselingStudentAppts CounselingAppointment[] @relation("CounselingStudent") + disciplinaryCasesReported DisciplinaryCase[] @relation("CaseReporter") + disciplinaryCasesReviewed DisciplinaryCase[] @relation("CaseReviewer") + disciplinaryCommitteeMembers DisciplinaryCommitteeMember[] + virtualCharacter VirtualCharacter? + aiTestGenerations AITestGeneration[] + gradingReviews TeacherGradingReview[] + gradingReviewComments GradingReviewComment[] + selectedCharacterId String? + // Communication room relations + roomStudent CommunicationRoom[] @relation("RoomStudent") + roomTeacher CommunicationRoom[] @relation("RoomTeacher") + // Class responsible teacher + responsibleClasses ClassGroup[] @relation("ResponsibleTeacher") } model SchoolYear { @@ -160,11 +196,13 @@ model ClassGroup { gradeLevel Int schoolType String @default("ELEMENTARY") seatingOrder String? // JSON array of student IDs + responsibleTeacherId String? // Class responsible teacher (Klassenlehrer) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt school School @relation(fields: [schoolId], references: [id]) schoolYear SchoolYear @relation(fields: [schoolYearId], references: [id]) + responsibleTeacher User? @relation("ResponsibleTeacher", fields: [responsibleTeacherId], references: [id]) teachers ClassGroupTeacher[] enrollments Enrollment[] classCompetencyAssignments ClassCompetencyAssignment[] @@ -187,6 +225,7 @@ model ClassGroup { resources Resource[] peerAssessments PeerAssessment[] schoolEvents SchoolEvent[] + aiTestGenerations AITestGeneration[] } model ClassGroupTeacher { @@ -240,6 +279,8 @@ model Student { healthRecords HealthRecord[] studentBadges StudentBadge[] goals StudentGoal[] + illnessReports IllnessReport[] + disciplinaryCases DisciplinaryCase[] @@index([userId]) } @@ -285,6 +326,8 @@ model Subject { resources Resource[] topics SubjectTopic[] competitions Competition[] + subjectContents SubjectContent[] + aiTestGenerations AITestGeneration[] } model CompetencyTemplate { @@ -402,6 +445,8 @@ model Assessment { teacher User @relation(fields: [teacherId], references: [id]) assessmentCompetencyLinks AssessmentCompetencyLink[] assessmentResults AssessmentResult[] + aiTestGenerations AITestGeneration[] + gradingReviews TeacherGradingReview[] } model AssessmentCompetencyLink { @@ -1937,3 +1982,449 @@ model StudentGoal { @@index([studentId]) @@index([status]) } + +// ─── Subject Content Management (Schlaukopf Clone) ────────────────── + +model SubjectCategory { + id String @id @default(cuid()) + schoolId String + name String // e.g. "Klasse 5", "Klasse 6", "Oberstufe" + slug String // e.g. "klasse5", "klasse6", "oberstufe" + description String? + icon String? // Lucide icon name + sortOrder Int @default(0) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + contents SubjectContent[] + + @@index([schoolId]) + @@index([slug]) + @@map("subject_categories") +} + +model SubjectContent { + id String @id @default(cuid()) + schoolId String + categoryId String + subjectId String? // Link to existing Subject + parentId String? // For nested topics + title String + slug String + description String? + icon String? // Lucide icon name + contentType String @default("topic") // topic, exercise, lesson, quiz, vocabulary + content String? // JSON content (questions, explanations, etc.) + difficulty String @default("medium") // easy, medium, hard + questionCount Int @default(0) + sortOrder Int @default(0) + isActive Boolean @default(true) + isPublic Boolean @default(true) // visible to all schools + sourceUrl String? // original schlaukopf.de URL + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + category SubjectCategory @relation(fields: [categoryId], references: [id]) + parent SubjectContent? @relation("ContentHierarchy", fields: [parentId], references: [id]) + children SubjectContent[] @relation("ContentHierarchy") + changeRequests ContentChangeRequest[] + subject Subject? @relation(fields: [subjectId], references: [id]) + + @@index([schoolId]) + @@index([categoryId]) + @@index([parentId]) + @@index([slug]) + @@index([contentType]) + @@map("subject_contents") +} + +model ContentChangeRequest { + id String @id @default(cuid()) + schoolId String + contentId String + requestedBy String + requestType String // edit, add, delete + title String + description String + proposedChanges String? // JSON with proposed changes + status String @default("pending") // pending, approved, rejected + reviewedBy String? + reviewNote String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + content SubjectContent @relation(fields: [contentId], references: [id]) + requester User @relation("ContentRequester", fields: [requestedBy], references: [id]) + reviewer User? @relation("ContentReviewer", fields: [reviewedBy], references: [id]) + + @@index([schoolId]) + @@index([contentId]) + @@index([status]) + @@index([requestedBy]) + @@map("content_change_requests") +} + +// ─── AI Settings (Pollination, OpenAI, Anthropic) ──────────────────── + +model AISettings { + id String @id @default(cuid()) + schoolId String @unique + // Pollination AI + pollinationEnabled Boolean @default(true) + pollinationApiKey String? + pollinationModel String @default("flux") + // OpenAI + openaiEnabled Boolean @default(false) + openaiApiKey String? + openaiModel String @default("gpt-4o") + // Anthropic + anthropicEnabled Boolean @default(false) + anthropicApiKey String? + anthropicModel String @default("claude-3-5-sonnet-20241022") + // AI Features + aiChatEnabled Boolean @default(true) // Student AI helper + aiImageGenEnabled Boolean @default(true) // Admin/Teacher image generation + aiVideoGenEnabled Boolean @default(false) // Admin/Teacher video generation + aiAutoTestEnabled Boolean @default(true) // Auto test generation before exams + aiGradingAuditEnabled Boolean @default(false) // AI grading audit + aiTopicGenEnabled Boolean @default(true) // Auto topic generation from categories + // Virtual Character + virtualCharacterEnabled Boolean @default(true) // Enable/disable for students + // Restrictions + aiMaxRequestsPerDay Int @default(50) // Student daily limit + aiHelperMode String @default("guided") // guided, full, restricted + aiSystemPrompt String? // Custom system prompt for AI chat + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + + @@map("ai_settings") +} + +// ─── Virtual Character System ──────────────────────────────────────── + +model VirtualCharacter { + id String @id @default(cuid()) + userId String @unique + schoolId String + characterId String // e.g. "owl", "dragon", "robot", "cat", "wizard" + name String // Custom name for the character + color String @default("#10b981") // Character accent color + level Int @default(1) + xp Int @default(0) + mood String @default("happy") // happy, excited, thinking, sleepy, celebrating + accessories String? // JSON array of unlocked accessories + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + school School @relation(fields: [schoolId], references: [id]) + + @@index([userId]) + @@index([schoolId]) + @@map("virtual_characters") +} + +// ─── Chat Messages (AI Chatbot & Student-Teacher) ──────────────────── + +model ChatMessage { + id String @id @default(cuid()) + schoolId String + userId String + roomId String? // For room-based chat (null = AI chatbot) + content String + senderType String @default("user") // user, ai, teacher, system + metadata String? // JSON metadata (attachments, etc.) + isRead Boolean @default(false) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + school School @relation(fields: [schoolId], references: [id]) + room CommunicationRoom? @relation(fields: [roomId], references: [id]) + + @@index([userId]) + @@index([roomId]) + @@index([schoolId]) + @@index([createdAt]) + @@map("chat_messages") +} + +// ─── Illness Reporting with Parent Approval ────────────────────────── + +model IllnessReport { + id String @id @default(cuid()) + schoolId String + studentId String + reportedBy String // userId of the person reporting + reporterType String // student, parent + reason String // illness, doctor_visit, other + description String? + startDate DateTime + endDate DateTime? + documentUrl String? // Medical certificate URL + parentApprovalStatus String @default("pending") // pending, approved, rejected + parentApprovedBy String? // userId of approving parent + parentApprovedAt DateTime? + // Status: only visible to student/parent until approved + isVisibleToTeacher Boolean @default(false) + isVisibleToAdmin Boolean @default(false) + status String @default("pending") // pending, approved, rejected, expired + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + reporter User @relation(fields: [reportedBy], references: [id]) + approver User? @relation("IllnessApprover", fields: [parentApprovedBy], references: [id]) + + @@index([schoolId]) + @@index([studentId]) + @@index([reportedBy]) + @@index([parentApprovalStatus]) + @@index([status]) + @@map("illness_reports") +} + +// ─── Communication Rooms (Student-Teacher) ─────────────────────────── + +model CommunicationRoom { + id String @id @default(cuid()) + schoolId String + studentId String + teacherId String + roomType String @default("chat") // chat, voice, video + status String @default("requested") // requested, active, closed + requestedBy String // student or teacher + acceptedAt DateTime? + closedAt DateTime? + closeReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + student User @relation("RoomStudent", fields: [studentId], references: [id]) + teacher User @relation("RoomTeacher", fields: [teacherId], references: [id]) + members CommunicationRoomMember[] + messages CommunicationMessage[] + chatMessages ChatMessage[] + + @@index([schoolId]) + @@index([studentId]) + @@index([teacherId]) + @@index([status]) + @@map("communication_rooms") +} + +model CommunicationRoomMember { + id String @id @default(cuid()) + roomId String + userId String + role String @default("member") // member, moderator + joinedAt DateTime @default(now()) + + room CommunicationRoom @relation(fields: [roomId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([roomId, userId]) + @@index([roomId]) + @@index([userId]) + @@map("communication_room_members") +} + +model CommunicationMessage { + id String @id @default(cuid()) + roomId String + senderId String + content String + messageType String @default("text") // text, voice, image, note_share, file + fileUrl String? + metadata String? // JSON + isRead Boolean @default(false) + createdAt DateTime @default(now()) + + room CommunicationRoom @relation(fields: [roomId], references: [id]) + sender User @relation(fields: [senderId], references: [id]) + + @@index([roomId]) + @@index([senderId]) + @@index([createdAt]) + @@map("communication_messages") +} + +// ─── Counseling / Guidance System ──────────────────────────────────── + +model CounselingAppointment { + id String @id @default(cuid()) + schoolId String + studentId String + counselorId String + requestType String @default("guidance") // guidance, psychological, career, social + description String? + status String @default("requested") // requested, scheduled, completed, cancelled + scheduledAt DateTime? + duration Int @default(30) // minutes + notes String? // Counselor notes + isPrivate Boolean @default(true) + addToCalendar Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + counselor User @relation("Counselor", fields: [counselorId], references: [id]) + student User @relation("CounselingStudent", fields: [studentId], references: [id]) + + @@index([schoolId]) + @@index([counselorId]) + @@index([studentId]) + @@index([status]) + @@index([scheduledAt]) + @@map("counseling_appointments") +} + +// ─── Disciplinary Committee System ─────────────────────────────────── + +model DisciplinaryCommittee { + id String @id @default(cuid()) + schoolId String + name String + description String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + members DisciplinaryCommitteeMember[] + cases DisciplinaryCase[] + + @@index([schoolId]) + @@map("disciplinary_committees") +} + +model DisciplinaryCommitteeMember { + id String @id @default(cuid()) + committeeId String + userId String + role String @default("member") // chair, vice_chair, member + isLead Boolean @default(false) + createdAt DateTime @default(now()) + + committee DisciplinaryCommittee @relation(fields: [committeeId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([committeeId, userId]) + @@index([committeeId]) + @@index([userId]) + @@map("disciplinary_committee_members") +} + +model DisciplinaryCase { + id String @id @default(cuid()) + schoolId String + committeeId String + studentId String + reportedBy String + reviewedBy String? + caseType String // warning, minor_violation, major_violation, serious_offense + description String + evidence String? // JSON array of evidence URLs + status String @default("open") // open, under_review, resolved, dismissed + resolution String? + resolutionDate DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + committee DisciplinaryCommittee @relation(fields: [committeeId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + reporter User @relation("CaseReporter", fields: [reportedBy], references: [id]) + reviewer User? @relation("CaseReviewer", fields: [reviewedBy], references: [id]) + + @@index([schoolId]) + @@index([committeeId]) + @@index([studentId]) + @@index([status]) + @@map("disciplinary_cases") +} + +// ─── AI Test Generation ────────────────────────────────────────────── + +model AITestGeneration { + id String @id @default(cuid()) + schoolId String + assessmentId String? // Linked assessment + studentId String? // For individual student tests + classGroupId String? // For class-wide tests + subjectId String? + topicId String? // SubjectContent ID + testType String @default("practice") // practice, mock_exam, review + difficulty String @default("medium") + questionCount Int @default(10) + questions String? // JSON array of generated questions + aiProvider String @default("pollination") // pollination, openai, anthropic + generatedAt DateTime? + isCompleted Boolean @default(false) + completedAt DateTime? + score Float? // Student's score if completed + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + assessment Assessment? @relation(fields: [assessmentId], references: [id]) + student User? @relation(fields: [studentId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + + @@index([schoolId]) + @@index([assessmentId]) + @@index([studentId]) + @@index([classGroupId]) + @@map("ai_test_generations") +} + +// ─── Teacher Grading Review (AI Audit) ─────────────────────────────── + +model TeacherGradingReview { + id String @id @default(cuid()) + schoolId String + assessmentId String + teacherId String + aiProvider String @default("pollination") + status String @default("pending") // pending, in_progress, completed + reviewResult String? // JSON with review findings + discrepanciesFound Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + assessment Assessment @relation(fields: [assessmentId], references: [id]) + teacher User @relation(fields: [teacherId], references: [id]) + comments GradingReviewComment[] + + @@index([schoolId]) + @@index([assessmentId]) + @@index([teacherId]) + @@index([status]) + @@map("teacher_grading_reviews") +} + +model GradingReviewComment { + id String @id @default(cuid()) + reviewId String + userId String + resultId String? // AssessmentResult ID + comment String + suggestedGrade String? + reason String? + createdAt DateTime @default(now()) + + review TeacherGradingReview @relation(fields: [reviewId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([reviewId]) + @@index([userId]) + @@map("grading_review_comments") +} diff --git a/src/app/api/ai-settings/[id]/route.ts b/src/app/api/ai-settings/[id]/route.ts new file mode 100644 index 0000000..1957949 --- /dev/null +++ b/src/app/api/ai-settings/[id]/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ── GET: Get single AI settings record ── +async function getAISetting(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const settings = await db.aISettings.findUnique({ where: { id } }); + + if (!settings) { + return NextResponse.json({ error: 'AI settings not found' }, { status: 404 }); + } + + return NextResponse.json(settings); + } catch (error) { + console.error('AISettings GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update AI settings by ID ── +async function updateAISetting(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const { createdAt, updatedAt, ...updateData } = body; + + const settings = await db.aISettings.update({ + where: { id }, + data: updateData, + }); + + return NextResponse.json(settings); + } catch (error) { + console.error('AISettings PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── DELETE: Delete AI settings ── +async function deleteAISetting(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + await db.aISettings.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('AISettings DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getAISetting, 'dataRead'); +export const PUT = withRateLimit(updateAISetting, 'dataWrite'); +export const DELETE = withRateLimit(deleteAISetting, 'dataWrite'); diff --git a/src/app/api/ai-settings/route.ts b/src/app/api/ai-settings/route.ts new file mode 100644 index 0000000..5659b1e --- /dev/null +++ b/src/app/api/ai-settings/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ── GET: Get AI settings for the school ── +async function getAISettings(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') || session.user.schoolId; + + if (!schoolId) { + return NextResponse.json({ error: 'School ID required' }, { status: 400 }); + } + + let settings = await db.aISettings.findUnique({ + where: { schoolId }, + }); + + // Create default settings if none exist + if (!settings) { + settings = await db.aISettings.create({ + data: { schoolId }, + }); + } + + return NextResponse.json(settings); + } catch (error) { + console.error('AISettings GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update AI settings ── +async function updateAISettings(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const schoolId = body.schoolId || session.user.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'School ID required' }, { status: 400 }); + } + + // Remove fields that shouldn't be directly set + const { id, schoolId: _sid, createdAt, updatedAt, ...updateData } = body; + + let settings = await db.aISettings.findUnique({ where: { schoolId } }); + + if (!settings) { + settings = await db.aISettings.create({ + data: { schoolId, ...updateData }, + }); + } else { + settings = await db.aISettings.update({ + where: { schoolId }, + data: updateData, + }); + } + + return NextResponse.json(settings); + } catch (error) { + console.error('AISettings PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getAISettings, 'dataRead'); +export const PUT = withRateLimit(updateAISettings, 'dataWrite'); diff --git a/src/app/api/ai-tests/[id]/route.ts b/src/app/api/ai-tests/[id]/route.ts new file mode 100644 index 0000000..33eb640 --- /dev/null +++ b/src/app/api/ai-tests/[id]/route.ts @@ -0,0 +1,80 @@ +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 test = await db.aITestGeneration.findUnique({ + where: { id }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + assessment: { select: { id: true, title: true } }, + }, + }); + + if (!test) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(test); + } catch (error) { + console.error('AITests 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 { id } = await params; + const body = await request.json(); + const { answers, isCompleted, score } = body; + + const existing = await db.aITestGeneration.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const updateData: Record = {}; + if (isCompleted !== undefined) { + updateData.isCompleted = isCompleted; + updateData.completedAt = isCompleted ? new Date() : null; + } + if (score !== undefined) { + updateData.score = score; + } + + const test = await db.aITestGeneration.update({ + where: { id }, + data: updateData, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(test); + } catch (error) { + console.error('AITests PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/ai-tests/auto-generate/route.ts b/src/app/api/ai-tests/auto-generate/route.ts new file mode 100644 index 0000000..700d470 --- /dev/null +++ b/src/app/api/ai-tests/auto-generate/route.ts @@ -0,0 +1,122 @@ +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 !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL' && role !== 'TEACHER') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { schoolId } = body; + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + // Find assessments with dates in approximately 2 weeks + const twoWeeksFromNow = new Date(); + twoWeeksFromNow.setDate(twoWeeksFromNow.getDate() + 14); + const twoWeeksRange = new Date(); + twoWeeksRange.setDate(twoWeeksRange.getDate() + 16); + + const upcomingAssessments = await db.assessment.findMany({ + where: { + schoolId, + date: { + gte: twoWeeksFromNow, + lte: twoWeeksRange, + }, + }, + include: { + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }); + + if (upcomingAssessments.length === 0) { + return NextResponse.json({ message: 'No upcoming assessments found', generated: 0 }); + } + + const generatedTests = []; + + for (const assessment of upcomingAssessments) { + // Check if a test already exists for this assessment + const existingTest = await db.aITestGeneration.findFirst({ + where: { assessmentId: assessment.id }, + }); + if (existingTest) continue; + + const topic = assessment.title || assessment.subject?.name || 'General'; + const gradeLevel = assessment.classGroup?.name?.replace(/[^0-9]/g, '') || '8'; + + // Generate questions using AI + const systemPrompt = `Du bist ein Lehrer. Erstelle eine Übungsklausur zum Thema ${topic} für die Klasse ${gradeLevel}. Erstelle 10 Fragen mit Schwierigkeitsgrad medium. Gib die Fragen im JSON-Format zurück: [{question, options, correctAnswer, explanation}]`; + + let questions: string; + try { + const ai = await ZAI.create(); + const response = await ai.chat.completions.create({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `Erstelle 10 Fragen zum Thema "${topic}" im Schwierigkeitsgrad "medium" als JSON-Array.` }, + ], + }); + + const content = response?.choices?.[0]?.message?.content || ''; + const jsonMatch = content.match(/\[[\s\S]*\]/); + if (jsonMatch) { + questions = jsonMatch[0]; + JSON.parse(questions); + } else { + questions = JSON.stringify([]); + } + } catch (aiError) { + console.error('AI generation error for assessment', assessment.id, aiError); + questions = JSON.stringify( + Array.from({ length: 10 }, (_, i) => ({ + question: `${topic} - Frage ${i + 1}`, + options: ['Option A', 'Option B', 'Option C', 'Option D'], + correctAnswer: 'Option A', + explanation: `Erklaerung zu Frage ${i + 1}`, + })) + ); + } + + const test = await db.aITestGeneration.create({ + data: { + schoolId, + assessmentId: assessment.id, + classGroupId: assessment.classGroupId, + subjectId: assessment.subjectId, + testType: 'practice', + difficulty: 'medium', + questionCount: 10, + questions, + aiProvider: 'pollination', + generatedAt: new Date(), + isCompleted: false, + }, + }); + + generatedTests.push(test); + } + + return NextResponse.json({ + message: `Generated ${generatedTests.length} practice tests`, + generated: generatedTests.length, + tests: generatedTests, + }); + } catch (error) { + console.error('AITests auto-generate error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/ai-tests/route.ts b/src/app/api/ai-tests/route.ts new file mode 100644 index 0000000..0bbaa9f --- /dev/null +++ b/src/app/api/ai-tests/route.ts @@ -0,0 +1,156 @@ +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 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; + const status = searchParams.get('status'); + + if (!schoolId) { + return NextResponse.json([]); + } + + const role = session.user?.role; + const userId = session.userId; + + const where: Record = { schoolId }; + if (status) where.status = status; + + if (role === 'STUDENT') { + where.studentId = userId; + } else if (role === 'TEACHER') { + // Teachers see tests for their classes + const teacherClasses = await db.classGroupTeacher.findMany({ + where: { userId }, + select: { classGroupId: true }, + }); + const classIds = teacherClasses.map((tc) => tc.classGroupId); + if (classIds.length > 0) { + where.OR = [ + { classGroupId: { in: classIds } }, + { studentId: null }, + ]; + } + } + // ADMIN, VICE_PRINCIPAL, SUPER_ADMIN see all + + const tests = await db.aITestGeneration.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + assessment: { select: { id: true, title: true } }, + }, + }); + + return NextResponse.json(tests); + } catch (error) { + console.error('AITests 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, + topic, + testType, + difficulty, + questionCount, + gradeLevel, + subjectId, + classGroupId, + assessmentId, + studentId, + } = body; + + if (!schoolId || !topic) { + return NextResponse.json({ error: 'schoolId and topic are required' }, { status: 400 }); + } + + const count = questionCount || 10; + const diff = difficulty || 'medium'; + const type = testType || 'practice'; + const grade = gradeLevel || '8'; + + // Generate questions using AI + const systemPrompt = `Du bist ein Lehrer. Erstelle eine Übungsklausur zum Thema ${topic} für die Klasse ${grade}. Erstelle ${count} Fragen mit Schwierigkeitsgrad ${diff}. Gib die Fragen im JSON-Format zurück: [{question, options, correctAnswer, explanation}]`; + + let questions: string | null = null; + try { + const ai = await ZAI.create(); + const response = await ai.chat.completions.create({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `Erstelle ${count} Fragen zum Thema "${topic}" im Schwierigkeitsgrad "${diff}" als JSON-Array.` }, + ], + }); + + const content = response?.choices?.[0]?.message?.content || ''; + // Try to extract JSON from the response + const jsonMatch = content.match(/\[[\s\S]*\]/); + if (jsonMatch) { + questions = jsonMatch[0]; + // Validate it's valid JSON + JSON.parse(questions); + } else { + questions = JSON.stringify([]); + } + } catch (aiError) { + console.error('AI generation error:', aiError); + // Fallback: create placeholder questions + questions = JSON.stringify( + Array.from({ length: count }, (_, i) => ({ + question: `${topic} - Frage ${i + 1}`, + options: ['Option A', 'Option B', 'Option C', 'Option D'], + correctAnswer: 'Option A', + explanation: `Erklaerung zu Frage ${i + 1}`, + })) + ); + } + + const test = await db.aITestGeneration.create({ + data: { + schoolId, + assessmentId: assessmentId || null, + studentId: studentId || (session.user?.role === 'STUDENT' ? session.userId : null), + classGroupId: classGroupId || null, + subjectId: subjectId || null, + testType: type, + difficulty: diff, + questionCount: count, + questions, + aiProvider: 'pollination', + generatedAt: new Date(), + isCompleted: false, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(test, { status: 201 }); + } catch (error) { + console.error('AITests POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/ai/character/route.ts b/src/app/api/ai/character/route.ts new file mode 100644 index 0000000..d471eab --- /dev/null +++ b/src/app/api/ai/character/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + // Check if virtual character is enabled + const aiSettings = await db.aISettings.findUnique({ + where: { schoolId }, + select: { virtualCharacterEnabled: true }, + }); + + const enabled = aiSettings?.virtualCharacterEnabled ?? true; + + // Get or create virtual character + let character = await db.virtualCharacter.findUnique({ + where: { userId: session.userId }, + }); + + if (!character) { + // Create default character + character = await db.virtualCharacter.create({ + data: { + userId: session.userId, + schoolId, + characterId: 'owl', + name: 'Eule', + color: '#10b981', + level: 1, + xp: 0, + mood: 'happy', + }, + }); + } + + return NextResponse.json({ character, enabled }); + } catch (error) { + console.error('Virtual Character GET error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function PUT(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + const body = await request.json(); + const { characterId, name, color, mood } = body; + + // Get existing character + const existing = await db.virtualCharacter.findUnique({ + where: { userId: session.userId }, + }); + + if (!existing) { + // Create new character + const character = await db.virtualCharacter.create({ + data: { + userId: session.userId, + schoolId, + characterId: characterId || 'owl', + name: name || 'Eule', + color: color || '#10b981', + level: 1, + xp: 0, + mood: mood || 'happy', + }, + }); + return NextResponse.json(character); + } + + // Update existing character + const character = await db.virtualCharacter.update({ + where: { id: existing.id }, + data: { + ...(characterId ? { characterId } : {}), + ...(name ? { name } : {}), + ...(color ? { color } : {}), + ...(mood ? { mood } : {}), + }, + }); + + return NextResponse.json(character); + } catch (error) { + console.error('Virtual Character PUT error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts new file mode 100644 index 0000000..b4f3366 --- /dev/null +++ b/src/app/api/ai/chat/route.ts @@ -0,0 +1,223 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import ZAI from 'z-ai-web-dev-sdk'; + +const DEFAULT_SYSTEM_PROMPT = + 'Du bist ein hilfreicher Lernassistent fuer Schueler. Du erklaerst Konzepte und hilfst beim Verstehen, aber du loest keine Hausaufgaben. Du motivierst und ermutigst die Schueler. Antworte auf Deutsch, es sei denn der Schueler fragt auf Englisch.'; + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + // Check AI settings + const aiSettings = await db.aISettings.findUnique({ + where: { schoolId }, + }); + + if (!aiSettings || !aiSettings.aiChatEnabled) { + return NextResponse.json( + { error: 'AI chat is disabled for your school' }, + { status: 403 } + ); + } + + // Check daily rate limit + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const todayRequestCount = await db.chatMessage.count({ + where: { + userId: session.userId, + schoolId, + senderType: 'user', + roomId: null, // AI chat messages have null roomId + createdAt: { gte: today }, + }, + }); + + if (todayRequestCount >= aiSettings.aiMaxRequestsPerDay) { + return NextResponse.json( + { + error: 'Daily request limit reached', + retryAfter: 86400, + limit: aiSettings.aiMaxRequestsPerDay, + }, + { status: 429 } + ); + } + + const body = await request.json(); + const { message, conversationHistory = [] } = body; + + if (!message || typeof message !== 'string' || message.trim().length === 0) { + return NextResponse.json( + { error: 'Message is required' }, + { status: 400 } + ); + } + + // Save user message + await db.chatMessage.create({ + data: { + schoolId, + userId: session.userId, + roomId: null, + content: message, + senderType: 'user', + isRead: true, + }, + }); + + // Build system prompt + const systemPrompt = aiSettings.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT; + + // Add helper mode restrictions + let modeInstructions = ''; + if (aiSettings.aiHelperMode === 'guided') { + modeInstructions = + ' Du gibst gefuehrte Hinweise und Erklaerungen, ohne die komplette Loesung zu verraten. Du hilfst dem Schueler, selbst auf die Loesung zu kommen.'; + } else if (aiSettings.aiHelperMode === 'restricted') { + modeInstructions = + ' Du darfst nur kurze Hinweise geben und keine ausfuehrlichen Erklaerungen. Verweise auf Lehrbuecher oder die Lehrkraft.'; + } + // 'full' mode: no extra restrictions + + const fullSystemPrompt = systemPrompt + modeInstructions; + + // Build messages for AI + const aiMessages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [ + { role: 'system', content: fullSystemPrompt }, + ]; + + // Add conversation history (last 10 messages for context) + const recentHistory = conversationHistory.slice(-10); + for (const msg of recentHistory) { + if (msg.role === 'user' || msg.role === 'assistant') { + aiMessages.push({ role: msg.role, content: msg.content }); + } + } + + aiMessages.push({ role: 'user', content: message }); + + // Call AI + const zai = await ZAI.create(); + const completion = await zai.chat.completions.create({ + messages: aiMessages, + }); + + const aiResponse = + completion.choices?.[0]?.message?.content || + 'Es tut mir leid, ich konnte keine Antwort generieren. Bitte versuche es erneut.'; + + // Save AI response + await db.chatMessage.create({ + data: { + schoolId, + userId: session.userId, + roomId: null, + content: aiResponse, + senderType: 'ai', + isRead: false, + }, + }); + + // Award XP to virtual character if exists + const virtualChar = await db.virtualCharacter.findUnique({ + where: { userId: session.userId }, + }); + if (virtualChar) { + const xpGain = 5; + const newXp = virtualChar.xp + xpGain; + const xpPerLevel = 100; + const newLevel = Math.floor(newXp / xpPerLevel) + 1; + await db.virtualCharacter.update({ + where: { id: virtualChar.id }, + data: { + xp: newXp, + level: newLevel, + mood: newLevel > virtualChar.level ? 'celebrating' : 'happy', + }, + }); + } + + return NextResponse.json({ + response: aiResponse, + requestsToday: todayRequestCount + 1, + maxRequests: aiSettings.aiMaxRequestsPerDay, + }); + } catch (error) { + console.error('AI Chat error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get('limit') || '50', 10); + + // Get chat history for this user (AI chat only, roomId is null) + const messages = await db.chatMessage.findMany({ + where: { + userId: session.userId, + schoolId, + roomId: null, + }, + orderBy: { createdAt: 'asc' }, + take: limit, + }); + + // Get today's request count + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayRequestCount = await db.chatMessage.count({ + where: { + userId: session.userId, + schoolId, + senderType: 'user', + roomId: null, + createdAt: { gte: today }, + }, + }); + + // Get AI settings for rate limit info + const aiSettings = await db.aISettings.findUnique({ + where: { schoolId }, + }); + + return NextResponse.json({ + messages, + requestsToday: todayRequestCount, + maxRequests: aiSettings?.aiMaxRequestsPerDay ?? 50, + chatEnabled: aiSettings?.aiChatEnabled ?? true, + }); + } catch (error) { + console.error('AI Chat GET error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/ai/generate-topics/route.ts b/src/app/api/ai/generate-topics/route.ts new file mode 100644 index 0000000..f15179b --- /dev/null +++ b/src/app/api/ai/generate-topics/route.ts @@ -0,0 +1,105 @@ +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?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Only admin/teacher can generate topics + const role = session.user.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') { + return NextResponse.json( + { error: 'Only teachers and admins can generate topics' }, + { status: 403 } + ); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + // Check AI settings + const aiSettings = await db.aISettings.findUnique({ + where: { schoolId }, + }); + + if (!aiSettings || !aiSettings.aiTopicGenEnabled) { + return NextResponse.json( + { error: 'Topic generation is disabled for your school' }, + { status: 403 } + ); + } + + const body = await request.json(); + const { categoryName, subjectName, gradeLevel, additionalContext } = body; + + if (!categoryName || !subjectName) { + return NextResponse.json( + { error: 'Category name and subject name are required' }, + { status: 400 } + ); + } + + // Build prompt for topic generation + const prompt = `Generiere eine Liste von Lernthemen fuer die Kategorie "${categoryName}" im Fach ${subjectName}${gradeLevel ? ` fuer die Jahrgangsstufe ${gradeLevel}` : ''}.${additionalContext ? ` Zusaetzlicher Kontext: ${additionalContext}` : ''} + +Bitte gib die Themen als JSON-Array zurueck, wobei jedes Thema folgende Felder hat: +- "name": Der Name des Themas (kurz und praegnant) +- "description": Eine kurze Beschreibung des Themas (1-2 Saetze) +- "difficulty": Die Schwierigkeit (1-5, wobei 1 am einfachsten ist) +- "keywords": Ein Array von 3-5 Schluesselwoertern + +Generiere 5-10 passende Themen. Antworte NUR mit dem JSON-Array, ohne Markdown-Formatierung.`; + + const zai = await ZAI.create(); + const completion = await zai.chat.completions.create({ + messages: [ + { + role: 'system', + content: + 'Du bist ein Lehrplan-Experte. Du generierst strukturierte Lernthemen fuer den Schulunterricht. Du antwortest ausschliesslich mit validem JSON.', + }, + { role: 'user', content: prompt }, + ], + }); + + const aiResponse = completion.choices?.[0]?.message?.content || ''; + + // Parse the JSON response + let topics; + try { + // Try to extract JSON from the response (might be wrapped in code blocks) + const jsonMatch = aiResponse.match(/\[[\s\S]*\]/); + if (jsonMatch) { + topics = JSON.parse(jsonMatch[0]); + } else { + topics = JSON.parse(aiResponse); + } + } catch { + // If parsing fails, return the raw response + return NextResponse.json({ + topics: [], + rawResponse: aiResponse, + error: 'Failed to parse AI response as JSON', + }); + } + + return NextResponse.json({ + topics, + categoryName, + subjectName, + }); + } catch (error) { + console.error('AI Topic Generation error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/ai/image/route.ts b/src/app/api/ai/image/route.ts new file mode 100644 index 0000000..2a14659 --- /dev/null +++ b/src/app/api/ai/image/route.ts @@ -0,0 +1,79 @@ +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?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Only admin/teacher can generate images + const role = session.user.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') { + return NextResponse.json( + { error: 'Only teachers and admins can generate images' }, + { status: 403 } + ); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + // Check AI settings + const aiSettings = await db.aISettings.findUnique({ + where: { schoolId }, + }); + + if (!aiSettings || !aiSettings.aiImageGenEnabled) { + return NextResponse.json( + { error: 'Image generation is disabled for your school' }, + { status: 403 } + ); + } + + const body = await request.json(); + const { prompt, size = '1024x1024' } = body; + + if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) { + return NextResponse.json( + { error: 'Prompt is required' }, + { status: 400 } + ); + } + + // Generate image using z-ai-web-dev-sdk + const zai = await ZAI.create(); + const response = await zai.images.generations.create({ + prompt, + size, + }); + + const imageData = response.data?.[0]; + + if (!imageData) { + return NextResponse.json( + { error: 'Failed to generate image' }, + { status: 500 } + ); + } + + return NextResponse.json({ + image: { + url: imageData.url || null, + b64_json: imageData.b64_json || null, + revised_prompt: imageData.revised_prompt || prompt, + }, + }); + } catch (error) { + console.error('AI Image Generation error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/ai/settings/route.ts b/src/app/api/ai/settings/route.ts new file mode 100644 index 0000000..1f2de50 --- /dev/null +++ b/src/app/api/ai/settings/route.ts @@ -0,0 +1,153 @@ +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?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + // Only admin/teacher can view AI settings + const role = session.user.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') { + // Students/parents can only see limited info + const aiSettings = await db.aISettings.findUnique({ + where: { schoolId }, + select: { + aiChatEnabled: true, + aiMaxRequestsPerDay: true, + aiHelperMode: true, + virtualCharacterEnabled: true, + }, + }); + + return NextResponse.json(aiSettings || { aiChatEnabled: true, aiMaxRequestsPerDay: 50, aiHelperMode: 'guided', virtualCharacterEnabled: true }); + } + + const aiSettings = await db.aISettings.findUnique({ + where: { schoolId }, + }); + + if (!aiSettings) { + // Return default settings + return NextResponse.json({ + schoolId, + pollinationEnabled: true, + pollinationApiKey: null, + pollinationModel: 'flux', + openaiEnabled: false, + openaiApiKey: null, + openaiModel: 'gpt-4o', + anthropicEnabled: false, + anthropicApiKey: null, + anthropicModel: 'claude-3-5-sonnet-20241022', + aiChatEnabled: true, + aiImageGenEnabled: true, + aiVideoGenEnabled: false, + aiAutoTestEnabled: true, + aiGradingAuditEnabled: false, + aiTopicGenEnabled: true, + virtualCharacterEnabled: true, + aiMaxRequestsPerDay: 50, + aiHelperMode: 'guided', + aiSystemPrompt: null, + }); + } + + return NextResponse.json(aiSettings); + } catch (error) { + console.error('AI Settings GET error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function PUT(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Only admin can update AI settings + const role = session.user.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') { + return NextResponse.json( + { error: 'Only admins can update AI settings' }, + { status: 403 } + ); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + const body = await request.json(); + + // Update or create AI settings + const aiSettings = await db.aISettings.upsert({ + where: { schoolId }, + update: { + pollinationEnabled: body.pollinationEnabled ?? undefined, + pollinationApiKey: body.pollinationApiKey ?? undefined, + pollinationModel: body.pollinationModel ?? undefined, + openaiEnabled: body.openaiEnabled ?? undefined, + openaiApiKey: body.openaiApiKey ?? undefined, + openaiModel: body.openaiModel ?? undefined, + anthropicEnabled: body.anthropicEnabled ?? undefined, + anthropicApiKey: body.anthropicApiKey ?? undefined, + anthropicModel: body.anthropicModel ?? undefined, + aiChatEnabled: body.aiChatEnabled ?? undefined, + aiImageGenEnabled: body.aiImageGenEnabled ?? undefined, + aiVideoGenEnabled: body.aiVideoGenEnabled ?? undefined, + aiAutoTestEnabled: body.aiAutoTestEnabled ?? undefined, + aiGradingAuditEnabled: body.aiGradingAuditEnabled ?? undefined, + aiTopicGenEnabled: body.aiTopicGenEnabled ?? undefined, + virtualCharacterEnabled: body.virtualCharacterEnabled ?? undefined, + aiMaxRequestsPerDay: body.aiMaxRequestsPerDay ?? undefined, + aiHelperMode: body.aiHelperMode ?? undefined, + aiSystemPrompt: body.aiSystemPrompt ?? undefined, + }, + create: { + schoolId, + pollinationEnabled: body.pollinationEnabled ?? true, + pollinationApiKey: body.pollinationApiKey ?? null, + pollinationModel: body.pollinationModel ?? 'flux', + openaiEnabled: body.openaiEnabled ?? false, + openaiApiKey: body.openaiApiKey ?? null, + openaiModel: body.openaiModel ?? 'gpt-4o', + anthropicEnabled: body.anthropicEnabled ?? false, + anthropicApiKey: body.anthropicApiKey ?? null, + anthropicModel: body.anthropicModel ?? 'claude-3-5-sonnet-20241022', + aiChatEnabled: body.aiChatEnabled ?? true, + aiImageGenEnabled: body.aiImageGenEnabled ?? true, + aiVideoGenEnabled: body.aiVideoGenEnabled ?? false, + aiAutoTestEnabled: body.aiAutoTestEnabled ?? true, + aiGradingAuditEnabled: body.aiGradingAuditEnabled ?? false, + aiTopicGenEnabled: body.aiTopicGenEnabled ?? true, + virtualCharacterEnabled: body.virtualCharacterEnabled ?? true, + aiMaxRequestsPerDay: body.aiMaxRequestsPerDay ?? 50, + aiHelperMode: body.aiHelperMode ?? 'guided', + aiSystemPrompt: body.aiSystemPrompt ?? null, + }, + }); + + return NextResponse.json(aiSettings); + } catch (error) { + console.error('AI Settings PUT error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/ai/video/route.ts b/src/app/api/ai/video/route.ts new file mode 100644 index 0000000..d690b13 --- /dev/null +++ b/src/app/api/ai/video/route.ts @@ -0,0 +1,69 @@ +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?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Only admin/teacher can generate videos + const role = session.user.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') { + return NextResponse.json( + { error: 'Only teachers and admins can generate videos' }, + { status: 403 } + ); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + // Check AI settings + const aiSettings = await db.aISettings.findUnique({ + where: { schoolId }, + }); + + if (!aiSettings || !aiSettings.aiVideoGenEnabled) { + return NextResponse.json( + { error: 'Video generation is disabled for your school' }, + { status: 403 } + ); + } + + const body = await request.json(); + const { prompt, quality = 'standard', size = '1280x720' } = body; + + if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) { + return NextResponse.json( + { error: 'Prompt is required' }, + { status: 400 } + ); + } + + // Generate video using z-ai-web-dev-sdk + const zai = await ZAI.create(); + const task = await zai.video.generations.create({ + prompt, + quality, + size, + }); + + return NextResponse.json({ + taskId: task.id || task.taskId || 'pending', + status: task.status || 'processing', + message: 'Video generation started. Check status for updates.', + }); + } catch (error) { + console.error('AI Video Generation error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index 7bd6baa..d553b31 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -24,7 +24,7 @@ const registerSchema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), schoolId: z.string().optional(), - role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'), + role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN', 'VICE_PRINCIPAL']).default('TEACHER'), }); export const POST = withRateLimit(async function POST(request: Request) { diff --git a/src/app/api/communication-rooms/[id]/messages/route.ts b/src/app/api/communication-rooms/[id]/messages/route.ts new file mode 100644 index 0000000..55fe7b3 --- /dev/null +++ b/src/app/api/communication-rooms/[id]/messages/route.ts @@ -0,0 +1,143 @@ +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 room = await db.communicationRoom.findUnique({ where: { id } }); + + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + + // Access control + if (role === 'STUDENT' && room.studentId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } else if (role === 'TEACHER' && room.teacherId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } else if (role === 'PARENT') { + const parentLink = await db.parentStudentLink.findFirst({ + where: { parentId: userId, studentId: room.studentId }, + }); + if (!parentLink) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { + if (room.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '50'); + const skip = (page - 1) * limit; + + const [messages, total] = await Promise.all([ + db.communicationMessage.findMany({ + where: { roomId: id }, + orderBy: { createdAt: 'asc' }, + skip, + take: limit, + include: { + sender: { select: { id: true, firstName: true, lastName: true, role: true } }, + }, + }), + db.communicationMessage.count({ where: { roomId: id } }), + ]); + + // Mark messages as read for the current user + await db.communicationMessage.updateMany({ + where: { + roomId: id, + senderId: { not: userId }, + isRead: false, + }, + data: { isRead: true }, + }); + + return NextResponse.json({ messages, total, page, limit }); + } catch (error) { + console.error('CommunicationMessage GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST( + 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 room = await db.communicationRoom.findUnique({ where: { id } }); + + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + + // Only student and teacher can send messages (not admin, not parent) + if (role !== 'STUDENT' && role !== 'TEACHER') { + return NextResponse.json({ error: 'Only participants can send messages' }, { status: 403 }); + } + + if (room.studentId !== userId && room.teacherId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + if (room.status !== 'active') { + return NextResponse.json({ error: 'Room is not active' }, { status: 400 }); + } + + const body = await request.json(); + const { content, messageType, fileUrl, metadata } = body; + + if (!content && !fileUrl) { + return NextResponse.json({ error: 'Content or fileUrl is required' }, { status: 400 }); + } + + const message = await db.communicationMessage.create({ + data: { + roomId: id, + senderId: userId, + content: content || '', + messageType: messageType || 'text', + fileUrl: fileUrl || null, + metadata: metadata || null, + }, + include: { + sender: { select: { id: true, firstName: true, lastName: true, role: true } }, + }, + }); + + // Update room's updatedAt + await db.communicationRoom.update({ + where: { id }, + data: { updatedAt: new Date() }, + }); + + return NextResponse.json(message, { status: 201 }); + } catch (error) { + console.error('CommunicationMessage POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/communication-rooms/[id]/route.ts b/src/app/api/communication-rooms/[id]/route.ts new file mode 100644 index 0000000..4c0b06e --- /dev/null +++ b/src/app/api/communication-rooms/[id]/route.ts @@ -0,0 +1,187 @@ +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 room = await db.communicationRoom.findUnique({ + where: { id }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + members: { + include: { user: { select: { id: true, firstName: true, lastName: true, role: true } } }, + }, + }, + }); + + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + + // Access control + if (role === 'STUDENT' && room.studentId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } else if (role === 'TEACHER' && room.teacherId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } else if (role === 'PARENT') { + const parentLink = await db.parentStudentLink.findFirst({ + where: { parentId: userId, studentId: room.studentId }, + }); + if (!parentLink) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { + if (room.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + return NextResponse.json(room); + } catch (error) { + console.error('CommunicationRoom 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 { id } = await params; + const room = await db.communicationRoom.findUnique({ where: { id } }); + + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + const body = await request.json(); + + // Teacher accepts a room + if (body.status === 'active' && room.status === 'requested') { + if (role !== 'TEACHER' || room.teacherId !== userId) { + return NextResponse.json({ error: 'Only the assigned teacher can accept' }, { status: 403 }); + } + + const updated = await db.communicationRoom.update({ + where: { id }, + data: { + status: 'active', + acceptedAt: new Date(), + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(updated); + } + + // Teacher or admin closes a room + if (body.status === 'closed') { + if (role !== 'TEACHER' || room.teacherId !== userId) { + if (role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL' && role !== 'SUPER_ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const updated = await db.communicationRoom.update({ + where: { id }, + data: { + status: 'closed', + closedAt: new Date(), + closeReason: body.closeReason || null, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(updated); + } + + // Teacher rejects a room request + if (body.status === 'rejected' && room.status === 'requested') { + if (role !== 'TEACHER' || room.teacherId !== userId) { + return NextResponse.json({ error: 'Only the assigned teacher can reject' }, { status: 403 }); + } + + const updated = await db.communicationRoom.update({ + where: { id }, + data: { + status: 'closed', + closedAt: new Date(), + closeReason: body.closeReason || 'Request rejected', + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(updated); + } + + return NextResponse.json({ error: 'Invalid status transition' }, { status: 400 }); + } catch (error) { + console.error('CommunicationRoom 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 { id } = await params; + const room = await db.communicationRoom.findUnique({ where: { id } }); + + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + const role = session.user?.role; + + // Only admin can delete rooms + if (role !== 'SUPER_ADMIN' && role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Only admins can delete rooms' }, { status: 403 }); + } + + await db.communicationMessage.deleteMany({ where: { roomId: id } }); + await db.communicationRoomMember.deleteMany({ where: { roomId: id } }); + await db.communicationRoom.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('CommunicationRoom DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/communication-rooms/route.ts b/src/app/api/communication-rooms/route.ts new file mode 100644 index 0000000..a760b09 --- /dev/null +++ b/src/app/api/communication-rooms/route.ts @@ -0,0 +1,181 @@ +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 status = searchParams.get('status'); + + const role = session.user?.role; + const userId = session.userId; + + const where: Record = {}; + + if (role === 'STUDENT') { + where.studentId = userId; + } else if (role === 'TEACHER') { + where.teacherId = userId; + } else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { + where.schoolId = session.user?.schoolId; + } else if (role === 'SUPER_ADMIN') { + // Can see all + } else if (role === 'PARENT') { + // Parents can see rooms for their children + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: userId }, + select: { studentId: true }, + }); + const childUserIds: string[] = []; + for (const link of parentLinks) { + const student = await db.student.findUnique({ + where: { id: link.studentId }, + select: { userId: true }, + }); + if (student?.userId) childUserIds.push(student.userId); + } + where.studentId = { in: childUserIds }; + } + + if (status) where.status = status; + + const rooms = await db.communicationRoom.findMany({ + where, + orderBy: { updatedAt: 'desc' }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { id: true, content: true, messageType: true, createdAt: true, senderId: true }, + }, + }, + }); + + return NextResponse.json(rooms); + } catch (error) { + console.error('CommunicationRoom 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; + const userId = session.userId; + + // Only students can request conversations + if (role !== 'STUDENT') { + return NextResponse.json({ error: 'Only students can request conversations' }, { status: 403 }); + } + + const body = await request.json(); + const { reason } = body; + + // Find the student's class and the responsible teacher + const student = await db.student.findFirst({ + where: { + OR: [ + { userId }, + { + userId: null, + firstName: session.user?.firstName, + lastName: session.user?.lastName, + schoolId: session.user?.schoolId, + }, + ], + }, + include: { + enrollments: { + where: { endDate: null }, + take: 1, + include: { + classGroup: { + include: { + responsibleTeacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + }, + }, + }); + + if (!student) { + return NextResponse.json({ error: 'Student profile not found' }, { status: 404 }); + } + + const currentEnrollment = student.enrollments[0]; + if (!currentEnrollment) { + return NextResponse.json({ error: 'No active class enrollment found' }, { status: 400 }); + } + + const teacher = currentEnrollment.classGroup.responsibleTeacher; + if (!teacher) { + return NextResponse.json({ error: 'No class teacher assigned' }, { status: 400 }); + } + + // Check if there's already an active/requested room + const existingRoom = await db.communicationRoom.findFirst({ + where: { + studentId: userId, + teacherId: teacher.id, + status: { in: ['requested', 'active'] }, + }, + }); + + if (existingRoom) { + return NextResponse.json({ error: 'Active conversation already exists with this teacher' }, { status: 400 }); + } + + const room = await db.communicationRoom.create({ + data: { + schoolId: student.schoolId, + studentId: userId, + teacherId: teacher.id, + roomType: 'chat', + status: 'requested', + requestedBy: userId, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Create room members + await db.communicationRoomMember.createMany({ + data: [ + { roomId: room.id, userId, role: 'member' }, + { roomId: room.id, userId: teacher.id, role: 'moderator' }, + ], + }); + + // If there's a reason, send it as the first message + if (reason) { + await db.communicationMessage.create({ + data: { + roomId: room.id, + senderId: userId, + content: reason, + messageType: 'text', + }, + }); + } + + return NextResponse.json(room, { status: 201 }); + } catch (error) { + console.error('CommunicationRoom POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/content-change-requests/[id]/route.ts b/src/app/api/content-change-requests/[id]/route.ts new file mode 100644 index 0000000..4d96144 --- /dev/null +++ b/src/app/api/content-change-requests/[id]/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ── PUT: Approve or reject a change request ── +const updateChangeRequestSchema = z.object({ + status: z.enum(['approved', 'rejected']), + reviewNote: z.string().max(2000).optional().nullable(), +}); + +async function updateChangeRequest( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateChangeRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const changeRequest = await db.contentChangeRequest.update({ + where: { id }, + data: { + status: parsed.data.status, + reviewedBy: session.userId, + reviewNote: parsed.data.reviewNote, + }, + include: { + content: { select: { id: true, title: true } }, + requester: { select: { id: true, firstName: true, lastName: true } }, + reviewer: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // If approved and it's an edit request, apply the proposed changes to the content + if (parsed.data.status === 'approved' && changeRequest.proposedChanges) { + try { + const proposed = JSON.parse(changeRequest.proposedChanges); + if (changeRequest.content?.id && proposed) { + await db.subjectContent.update({ + where: { id: changeRequest.contentId }, + data: proposed, + }); + } + } catch { + // If proposed changes are not valid JSON, skip auto-apply + } + } + + return NextResponse.json(changeRequest); + } catch (error) { + console.error('ContentChangeRequest PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const PUT = withRateLimit(updateChangeRequest, 'dataWrite'); diff --git a/src/app/api/content-change-requests/route.ts b/src/app/api/content-change-requests/route.ts new file mode 100644 index 0000000..b791a8c --- /dev/null +++ b/src/app/api/content-change-requests/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ── GET: List change requests ── +async function getChangeRequests(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') || session.user.schoolId; + const status = searchParams.get('status'); + + if (!schoolId) { + return NextResponse.json([]); + } + + const where: Record = { schoolId }; + if (status) where.status = status; + + // SCHOOL_ADMIN, VICE_PRINCIPAL see all; TEACHER sees own + if (session.user.role === 'TEACHER') { + where.requestedBy = session.userId; + } + + const requests = await db.contentChangeRequest.findMany({ + where, + include: { + content: { select: { id: true, title: true, contentType: true } }, + requester: { select: { id: true, firstName: true, lastName: true } }, + reviewer: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(requests); + } catch (error) { + console.error('ContentChangeRequests GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create a change request ── +const createChangeRequestSchema = z.object({ + schoolId: z.string().optional(), + contentId: z.string().min(1), + requestType: z.enum(['edit', 'add', 'delete']), + title: z.string().min(1).max(300), + description: z.string().min(1).max(5000), + proposedChanges: z.string().optional().nullable(), +}); + +async function createChangeRequest(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'TEACHER' && + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createChangeRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const schoolId = parsed.data.schoolId || session.user.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'User must belong to a school' }, { status: 400 }); + } + + const changeRequest = await db.contentChangeRequest.create({ + data: { + schoolId, + contentId: parsed.data.contentId, + requestedBy: session.userId, + requestType: parsed.data.requestType, + title: parsed.data.title, + description: parsed.data.description, + proposedChanges: parsed.data.proposedChanges, + }, + include: { + content: { select: { id: true, title: true } }, + requester: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(changeRequest, { status: 201 }); + } catch (error) { + console.error('ContentChangeRequest POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getChangeRequests, 'dataRead'); +export const POST = withRateLimit(createChangeRequest, 'dataWrite'); diff --git a/src/app/api/counseling/[id]/route.ts b/src/app/api/counseling/[id]/route.ts new file mode 100644 index 0000000..38d0c7c --- /dev/null +++ b/src/app/api/counseling/[id]/route.ts @@ -0,0 +1,155 @@ +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 appointment = await db.counselingAppointment.findUnique({ + where: { id }, + include: { + counselor: { select: { id: true, firstName: true, lastName: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (!appointment) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + if ( + role !== 'SCHOOL_ADMIN' && + role !== 'SUPER_ADMIN' && + role !== 'VICE_PRINCIPAL' && + appointment.counselorId !== userId && + appointment.studentId !== userId + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(appointment); + } catch (error) { + console.error('Counseling 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 { id } = await params; + const body = await request.json(); + const { scheduledAt, duration, notes, status, addToCalendar } = body; + + const existing = await db.counselingAppointment.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + + // Only counselor, admin, or the student can update + const isCounselor = existing.counselorId === userId; + const isStudent = existing.studentId === userId; + const isAdmin = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL'; + + if (!isCounselor && !isStudent && !isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const updateData: Record = {}; + if (scheduledAt !== undefined) updateData.scheduledAt = scheduledAt ? new Date(scheduledAt) : null; + if (duration !== undefined) updateData.duration = duration; + if (notes !== undefined) updateData.notes = notes; + if (status !== undefined) updateData.status = status; + + const appointment = await db.counselingAppointment.update({ + where: { id }, + data: updateData, + include: { + counselor: { select: { id: true, firstName: true, lastName: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Add to calendar if requested and scheduled + if (addToCalendar && appointment.scheduledAt && appointment.status === 'scheduled') { + try { + await db.calendarEvent.create({ + data: { + schoolId: appointment.schoolId, + userId: appointment.counselorId, + title: `Counseling: ${appointment.student.firstName} ${appointment.student.lastName}`, + description: `Type: ${appointment.requestType}${appointment.notes ? `\nNotes: ${appointment.notes}` : ''}`, + startTime: appointment.scheduledAt, + endTime: new Date(new Date(appointment.scheduledAt).getTime() + (appointment.duration || 30) * 60000), + category: 'meeting', + isAllDay: false, + }, + }); + } catch (calErr) { + console.error('Calendar event creation error:', calErr); + } + } + + return NextResponse.json(appointment); + } catch (error) { + console.error('Counseling 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 { id } = await params; + const existing = await db.counselingAppointment.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + const isCounselor = existing.counselorId === userId; + const isAdmin = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL'; + + if (!isCounselor && !isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const appointment = await db.counselingAppointment.update({ + where: { id }, + data: { status: 'cancelled' }, + }); + + return NextResponse.json(appointment); + } catch (error) { + console.error('Counseling DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/counseling/route.ts b/src/app/api/counseling/route.ts new file mode 100644 index 0000000..f1c7f35 --- /dev/null +++ b/src/app/api/counseling/route.ts @@ -0,0 +1,139 @@ +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 status = searchParams.get('status'); + + const role = session.user?.role; + const userId = session.userId; + + let schoolId: string | undefined; + if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { + schoolId = session.user?.schoolId ?? undefined; + } else { + schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; + } + + if (!schoolId) { + return NextResponse.json([]); + } + + const where: Record = { schoolId }; + if (status) where.status = status; + + // Role-based filtering + if (role === 'STUDENT') { + where.studentId = userId; + } else if (role === 'TEACHER') { + // Teachers see their own counseling appointments (as counselor) + where.counselorId = userId; + } + // ADMIN, VICE_PRINCIPAL, SUPER_ADMIN see all + + const appointments = await db.counselingAppointment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + counselor: { select: { id: true, firstName: true, lastName: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(appointments); + } catch (error) { + console.error('Counseling 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, requestType, description, addToCalendar, isPrivate } = body; + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + // Only students can request counseling + const role = session.user?.role; + if (role !== 'STUDENT' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Find a counselor teacher for this school + const counselor = await db.user.findFirst({ + where: { + schoolId, + role: 'TEACHER', + counselingAppointments: { some: {} }, + }, + select: { id: true }, + }); + + // If no counselor found via existing appointments, look for any teacher assigned + let counselorId = counselor?.id; + if (!counselorId) { + // Check if there's an AISettings with a counselor assigned + const aiSettings = await db.aISettings.findFirst({ + where: { schoolId }, + }); + if (aiSettings) { + // Use a generic approach - find any teacher in the school + const anyTeacher = await db.user.findFirst({ + where: { schoolId, role: 'TEACHER' }, + select: { id: true }, + }); + counselorId = anyTeacher?.id; + } + } + + if (!counselorId) { + const anyTeacher = await db.user.findFirst({ + where: { schoolId, role: 'TEACHER' }, + select: { id: true }, + }); + counselorId = anyTeacher?.id; + } + + if (!counselorId) { + return NextResponse.json({ error: 'No counselor available' }, { status: 400 }); + } + + const appointment = await db.counselingAppointment.create({ + data: { + schoolId, + studentId: session.userId, + counselorId, + requestType: requestType || 'guidance', + description: description || null, + status: 'requested', + addToCalendar: addToCalendar ?? true, + isPrivate: isPrivate ?? true, + }, + include: { + counselor: { select: { id: true, firstName: true, lastName: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(appointment, { status: 201 }); + } catch (error) { + console.error('Counseling POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/disciplinary-cases/[id]/route.ts b/src/app/api/disciplinary-cases/[id]/route.ts new file mode 100644 index 0000000..38e9e8d --- /dev/null +++ b/src/app/api/disciplinary-cases/[id]/route.ts @@ -0,0 +1,105 @@ +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 disciplinaryCase = await db.disciplinaryCase.findUnique({ + where: { id }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + reviewer: { select: { id: true, firstName: true, lastName: true } }, + committee: { + select: { + id: true, + name: true, + members: { + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + }, + }, + }); + + if (!disciplinaryCase) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(disciplinaryCase); + } catch (error) { + console.error('DisciplinaryCases 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 { id } = await params; + const body = await request.json(); + const { status, resolution, reviewedBy } = body; + + const existing = await db.disciplinaryCase.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + + // Check if user is committee member or admin + const isCommitteeMember = await db.disciplinaryCommitteeMember.findFirst({ + where: { committeeId: existing.committeeId, userId }, + }); + const isAdmin = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL'; + + if (!isCommitteeMember && !isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const updateData: Record = {}; + if (status) updateData.status = status; + if (resolution) updateData.resolution = resolution; + if (reviewedBy || status === 'under_review' || status === 'resolved' || status === 'dismissed') { + updateData.reviewedBy = reviewedBy || userId; + } + if (status === 'resolved' || status === 'dismissed') { + updateData.resolutionDate = new Date(); + } + + const updatedCase = await db.disciplinaryCase.update({ + where: { id }, + data: updateData, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + reviewer: { select: { id: true, firstName: true, lastName: true } }, + committee: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(updatedCase); + } catch (error) { + console.error('DisciplinaryCases PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/disciplinary-cases/route.ts b/src/app/api/disciplinary-cases/route.ts new file mode 100644 index 0000000..4d0f460 --- /dev/null +++ b/src/app/api/disciplinary-cases/route.ts @@ -0,0 +1,107 @@ +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 schoolId = searchParams.get('schoolId') ?? session.user?.schoolId; + const status = searchParams.get('status'); + const committeeId = searchParams.get('committeeId'); + + if (!schoolId) { + return NextResponse.json([]); + } + + const role = session.user?.role; + const userId = session.userId; + + const where: Record = { schoolId }; + if (status) where.status = status; + if (committeeId) where.committeeId = committeeId; + + // TEACHER: see cases they reported OR cases in their committee + if (role === 'TEACHER') { + const memberOf = await db.disciplinaryCommitteeMember.findMany({ + where: { userId }, + select: { committeeId: true }, + }); + const committeeIds = memberOf.map((m) => m.committeeId); + + if (committeeIds.length > 0) { + where.OR = [ + { reportedBy: userId }, + { committeeId: { in: committeeIds } }, + ]; + } else { + where.reportedBy = userId; + } + } + // ADMIN, VICE_PRINCIPAL, SUPER_ADMIN see all + + const cases = await db.disciplinaryCase.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + reviewer: { select: { id: true, firstName: true, lastName: true } }, + committee: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(cases); + } catch (error) { + console.error('DisciplinaryCases 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 { schoolId, committeeId, studentId, caseType, description, evidence } = body; + + if (!schoolId || !committeeId || !studentId || !caseType || !description) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const newCase = await db.disciplinaryCase.create({ + data: { + schoolId, + committeeId, + studentId, + reportedBy: session.userId, + caseType, + description, + evidence: evidence || null, + status: 'open', + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + committee: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(newCase, { status: 201 }); + } catch (error) { + console.error('DisciplinaryCases POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/disciplinary-committees/[id]/route.ts b/src/app/api/disciplinary-committees/[id]/route.ts new file mode 100644 index 0000000..f4e3e38 --- /dev/null +++ b/src/app/api/disciplinary-committees/[id]/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +function isAdmin(role: string | undefined): boolean { + return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL'; +} + +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 }); + } + + if (!isAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const committee = await db.disciplinaryCommittee.findUnique({ + where: { id }, + include: { + members: { + include: { + user: { select: { id: true, firstName: true, lastName: true, role: true } }, + }, + }, + cases: { + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + reviewer: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!committee) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(committee); + } catch (error) { + console.error('DisciplinaryCommittees 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 }); + } + + if (!isAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const { name, description, isActive, members } = body; + + const existing = await db.disciplinaryCommittee.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const updateData: Record = {}; + if (name !== undefined) updateData.name = name; + if (description !== undefined) updateData.description = description; + if (isActive !== undefined) updateData.isActive = isActive; + + // Handle member updates + if (members && Array.isArray(members)) { + // Delete existing members and recreate + await db.disciplinaryCommitteeMember.deleteMany({ where: { committeeId: id } }); + updateData.members = { + create: members.map((m: { userId: string; role: string; isLead: boolean }) => ({ + userId: m.userId, + role: m.role || 'member', + isLead: m.isLead || false, + })), + }; + } + + const committee = await db.disciplinaryCommittee.update({ + where: { id }, + data: updateData, + include: { + members: { + include: { + user: { select: { id: true, firstName: true, lastName: true, role: true } }, + }, + }, + }, + }); + + return NextResponse.json(committee); + } catch (error) { + console.error('DisciplinaryCommittees 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 }); + } + + if (!isAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const existing = await db.disciplinaryCommittee.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + // Deactivate instead of deleting + const committee = await db.disciplinaryCommittee.update({ + where: { id }, + data: { isActive: false }, + }); + + return NextResponse.json(committee); + } catch (error) { + console.error('DisciplinaryCommittees DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/disciplinary-committees/route.ts b/src/app/api/disciplinary-committees/route.ts new file mode 100644 index 0000000..9461b01 --- /dev/null +++ b/src/app/api/disciplinary-committees/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +function isAdmin(role: string | undefined): boolean { + return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL'; +} + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if (!isAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId; + if (!schoolId) { + return NextResponse.json([]); + } + + const committees = await db.disciplinaryCommittee.findMany({ + where: { schoolId }, + orderBy: { createdAt: 'desc' }, + include: { + members: { + include: { + user: { select: { id: true, firstName: true, lastName: true, role: true } }, + }, + }, + _count: { select: { cases: true } }, + }, + }); + + return NextResponse.json(committees); + } catch (error) { + console.error('DisciplinaryCommittees 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 }); + } + + if (!isAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { schoolId, name, description, members } = body; + + if (!schoolId || !name) { + return NextResponse.json({ error: 'schoolId and name are required' }, { status: 400 }); + } + + const committee = await db.disciplinaryCommittee.create({ + data: { + schoolId, + name, + description: description || null, + isActive: true, + members: members?.length + ? { + create: members.map((m: { userId: string; role: string; isLead: boolean }) => ({ + userId: m.userId, + role: m.role || 'member', + isLead: m.isLead || false, + })), + } + : undefined, + }, + include: { + members: { + include: { + user: { select: { id: true, firstName: true, lastName: true, role: true } }, + }, + }, + }, + }); + + return NextResponse.json(committee, { status: 201 }); + } catch (error) { + console.error('DisciplinaryCommittees POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/grading-reviews/[id]/comments/route.ts b/src/app/api/grading-reviews/[id]/comments/route.ts new file mode 100644 index 0000000..c340060 --- /dev/null +++ b/src/app/api/grading-reviews/[id]/comments/route.ts @@ -0,0 +1,73 @@ +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 comments = await db.gradingReviewComment.findMany({ + where: { reviewId: id }, + orderBy: { createdAt: 'desc' }, + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(comments); + } catch (error) { + console.error('GradingReviewComments GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST( + 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(); + const { comment, suggestedGrade, reason, resultId } = body; + + if (!comment) { + return NextResponse.json({ error: 'comment is required' }, { status: 400 }); + } + + const existing = await db.teacherGradingReview.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Review not found' }, { status: 404 }); + } + + const newComment = await db.gradingReviewComment.create({ + data: { + reviewId: id, + userId: session.userId, + resultId: resultId || null, + comment, + suggestedGrade: suggestedGrade || null, + reason: reason || null, + }, + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(newComment, { status: 201 }); + } catch (error) { + console.error('GradingReviewComments POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/grading-reviews/[id]/route.ts b/src/app/api/grading-reviews/[id]/route.ts new file mode 100644 index 0000000..ff9ffbd --- /dev/null +++ b/src/app/api/grading-reviews/[id]/route.ts @@ -0,0 +1,82 @@ +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('GradingReviews 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 { id } = await params; + const body = await request.json(); + const { status } = body; + + const existing = await db.teacherGradingReview.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const updateData: Record = {}; + if (status) updateData.status = status; + + const review = await db.teacherGradingReview.update({ + where: { id }, + data: updateData, + 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 } }, + }, + }, + }, + }); + + return NextResponse.json(review); + } catch (error) { + console.error('GradingReviews PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/grading-reviews/route.ts b/src/app/api/grading-reviews/route.ts new file mode 100644 index 0000000..0432a09 --- /dev/null +++ b/src/app/api/grading-reviews/route.ts @@ -0,0 +1,157 @@ +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 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; + const status = searchParams.get('status'); + + if (!schoolId) { + return NextResponse.json([]); + } + + const role = session.user?.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL' && role !== 'TEACHER') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { schoolId }; + if (status) where.status = status; + + if (role === 'TEACHER') { + where.teacherId = session.userId; + } + + const reviews = await db.teacherGradingReview.findMany({ + where, + orderBy: { createdAt: 'desc' }, + 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(reviews); + } catch (error) { + console.error('GradingReviews 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 !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { schoolId, assessmentId, teacherId } = body; + + if (!schoolId || !assessmentId || !teacherId) { + return NextResponse.json({ error: 'schoolId, assessmentId, and teacherId are required' }, { status: 400 }); + } + + // Get assessment results for the teacher + const assessment = await db.assessment.findUnique({ + where: { id: assessmentId }, + include: { + results: { + where: { teacherId }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + }); + + if (!assessment) { + return NextResponse.json({ error: 'Assessment not found' }, { status: 404 }); + } + + // Generate AI review + const systemPrompt = 'Du bist ein erfahrener Lehrer. Überprüfe die Benotung und gib Feedback. Prüfe auf Fairness und Konsistenz.'; + + const gradingData = assessment.results.map((r: { student: { firstName: string; lastName: string }; score: number | null; maxScore: number | null; grade: string | null }) => ({ + student: `${r.student.firstName} ${r.student.lastName}`, + score: r.score, + maxScore: r.maxScore, + grade: r.grade, + })); + + 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 folgende Benotung und gib Feedback zu Fairness und Konsistenz:\n\n${JSON.stringify(gradingData, null, 2)}`, + }, + ], + }); + + reviewResult = response?.choices?.[0]?.message?.content || 'Review could not be generated'; + } catch (aiError) { + console.error('AI review error:', aiError); + reviewResult = JSON.stringify({ + overall: 'AI review could not be generated. Manual review recommended.', + fairness: 'unknown', + consistency: 'unknown', + recommendations: ['Please review grading manually'], + }); + } + + // Count discrepancies + const scores = assessment.results + .map((r: { score: number | null; maxScore: number | null }) => r.score && r.maxScore ? (r.score / r.maxScore) * 100 : null) + .filter((s: number | null): s is number => s !== null); + if (scores.length > 0) { + const avg = scores.reduce((a: number, b: number) => a + b, 0) / scores.length; + discrepanciesFound = scores.filter((s: number) => Math.abs(s - avg) > 30).length; + } + + const review = await db.teacherGradingReview.create({ + data: { + schoolId, + assessmentId, + teacherId, + aiProvider: 'pollination', + status: 'completed', + reviewResult, + discrepanciesFound, + }, + include: { + assessment: { select: { id: true, title: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(review, { status: 201 }); + } catch (error) { + console.error('GradingReviews POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/illness-reports/[id]/route.ts b/src/app/api/illness-reports/[id]/route.ts new file mode 100644 index 0000000..9485491 --- /dev/null +++ b/src/app/api/illness-reports/[id]/route.ts @@ -0,0 +1,205 @@ +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 report = await db.illnessReport.findUnique({ + where: { id }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + approver: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (!report) { + return NextResponse.json({ error: 'Report not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + + // Access control + if (role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { + OR: [ + { userId }, + { userId: null, firstName: session.user?.firstName, lastName: session.user?.lastName, schoolId: session.user?.schoolId }, + ], + }, + select: { id: true }, + }); + if (!student || student.id !== report.studentId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (role === 'PARENT') { + const parentLink = await db.parentStudentLink.findFirst({ + where: { parentId: userId, studentId: report.studentId }, + }); + if (!parentLink) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (role === 'TEACHER') { + if (!report.isVisibleToTeacher) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { + if (!report.isVisibleToAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + return NextResponse.json(report); + } catch (error) { + console.error('IllnessReport 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 { id } = await params; + const report = await db.illnessReport.findUnique({ where: { id } }); + + if (!report) { + return NextResponse.json({ error: 'Report not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + const body = await request.json(); + + // PARENT can approve/reject pending reports + if (body.parentApprovalStatus && (role === 'PARENT' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL')) { + const newStatus = body.parentApprovalStatus; + if (newStatus !== 'approved' && newStatus !== 'rejected') { + return NextResponse.json({ error: 'Invalid approval status' }, { status: 400 }); + } + + if (role === 'PARENT') { + const parentLink = await db.parentStudentLink.findFirst({ + where: { parentId: userId, studentId: report.studentId }, + }); + if (!parentLink) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const updated = await db.illnessReport.update({ + where: { id }, + data: { + parentApprovalStatus: newStatus, + parentApprovedBy: userId, + parentApprovedAt: new Date(), + isVisibleToTeacher: newStatus === 'approved', + isVisibleToAdmin: newStatus === 'approved', + status: newStatus === 'approved' ? 'approved' : 'rejected', + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + approver: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(updated); + } + + // STUDENT can update own pending reports (before parent approval) + if (role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { + OR: [ + { userId }, + { userId: null, firstName: session.user?.firstName, lastName: session.user?.lastName, schoolId: session.user?.schoolId }, + ], + }, + select: { id: true }, + }); + if (!student || student.id !== report.studentId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (report.parentApprovalStatus !== 'pending') { + return NextResponse.json({ error: 'Cannot update report after parent approval' }, { status: 400 }); + } + + const updateData: Record = {}; + if (body.reason) updateData.reason = body.reason; + if (body.description !== undefined) updateData.description = body.description; + if (body.startDate) updateData.startDate = new Date(body.startDate); + if (body.endDate !== undefined) updateData.endDate = body.endDate ? new Date(body.endDate) : null; + if (body.documentUrl !== undefined) updateData.documentUrl = body.documentUrl; + + const updated = await db.illnessReport.update({ + where: { id }, + data: updateData, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(updated); + } + + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } catch (error) { + console.error('IllnessReport 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 { id } = await params; + const report = await db.illnessReport.findUnique({ where: { id } }); + + if (!report) { + return NextResponse.json({ error: 'Report not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + + // Only the reporter or admin can delete + if (role !== 'SUPER_ADMIN' && role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL') { + if (report.reportedBy !== userId) { + return NextResponse.json({ error: 'Only the reporter or admin can delete' }, { status: 403 }); + } + } + + await db.illnessReport.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('IllnessReport DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/illness-reports/approve/route.ts b/src/app/api/illness-reports/approve/route.ts new file mode 100644 index 0000000..0f3bb3c --- /dev/null +++ b/src/app/api/illness-reports/approve/route.ts @@ -0,0 +1,73 @@ +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 role = session.user?.role; + const userId = session.userId; + + if (role !== 'PARENT' && role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Only parents or admins can approve' }, { status: 403 }); + } + + const body = await request.json(); + const { reportId, action } = body; // action: "approve" or "reject" + + if (!reportId || !action) { + return NextResponse.json({ error: 'reportId and action are required' }, { status: 400 }); + } + + if (action !== 'approve' && action !== 'reject') { + return NextResponse.json({ error: 'Action must be "approve" or "reject"' }, { status: 400 }); + } + + const report = await db.illnessReport.findUnique({ where: { id: reportId } }); + if (!report) { + return NextResponse.json({ error: 'Report not found' }, { status: 404 }); + } + + if (report.parentApprovalStatus !== 'pending') { + return NextResponse.json({ error: 'Report already processed' }, { status: 400 }); + } + + // Verify parent is linked to this student + if (role === 'PARENT') { + const parentLink = await db.parentStudentLink.findFirst({ + where: { parentId: userId, studentId: report.studentId }, + }); + if (!parentLink) { + return NextResponse.json({ error: 'You can only approve reports for your children' }, { status: 403 }); + } + } + + const newStatus = action === 'approve' ? 'approved' : 'rejected'; + + const updated = await db.illnessReport.update({ + where: { id: reportId }, + data: { + parentApprovalStatus: newStatus, + parentApprovedBy: userId, + parentApprovedAt: new Date(), + isVisibleToTeacher: newStatus === 'approved', + isVisibleToAdmin: newStatus === 'approved', + status: newStatus === 'approved' ? 'approved' : 'rejected', + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + approver: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('IllnessReport approve error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/illness-reports/route.ts b/src/app/api/illness-reports/route.ts new file mode 100644 index 0000000..8454237 --- /dev/null +++ b/src/app/api/illness-reports/route.ts @@ -0,0 +1,174 @@ +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 studentId = searchParams.get('studentId'); + const status = searchParams.get('status'); + const parentApprovalStatus = searchParams.get('parentApprovalStatus'); + + const role = session.user?.role; + const userId = session.userId; + + const where: Record = {}; + + if (role === 'STUDENT') { + // Students can only see their own reports + const student = await db.student.findFirst({ + where: { + OR: [ + { userId }, + { + userId: null, + firstName: session.user?.firstName, + lastName: session.user?.lastName, + schoolId: session.user?.schoolId, + }, + ], + }, + select: { id: true }, + }); + if (!student) { + return NextResponse.json([]); + } + where.studentId = student.id; + } else if (role === 'PARENT') { + // Parents can see reports for their children + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: userId }, + select: { studentId: true }, + }); + const childIds = parentLinks.map((l) => l.studentId); + if (childIds.length === 0) { + return NextResponse.json([]); + } + where.studentId = { in: childIds }; + } else if (role === 'TEACHER') { + // Teachers can only see approved reports + where.isVisibleToTeacher = true; + where.schoolId = session.user?.schoolId; + } else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { + // Admin can see all approved reports + where.isVisibleToAdmin = true; + where.schoolId = session.user?.schoolId; + } else if (role === 'SUPER_ADMIN') { + // Super admin can see all + } + + if (studentId) where.studentId = studentId; + if (status) where.status = status; + if (parentApprovalStatus) where.parentApprovalStatus = parentApprovalStatus; + + const reports = await db.illnessReport.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + approver: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(reports); + } catch (error) { + console.error('IllnessReport 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; + const userId = session.userId; + + if (role !== 'STUDENT' && role !== 'PARENT') { + return NextResponse.json({ error: 'Only students and parents can report illness' }, { status: 403 }); + } + + const body = await request.json(); + const { studentId, reason, description, startDate, endDate, documentUrl } = body; + + if (!studentId || !reason || !startDate) { + return NextResponse.json( + { error: 'studentId, reason, and startDate are required' }, + { status: 400 } + ); + } + + // Verify the student exists and belongs to the same school + const student = await db.student.findUnique({ + where: { id: studentId }, + select: { schoolId: true, userId: true, firstName: true, lastName: true }, + }); + + if (!student) { + return NextResponse.json({ error: 'Student not found' }, { status: 404 }); + } + + // Authorization checks + if (role === 'STUDENT') { + const isOwnStudent = + student.userId === userId || + (student.userId === null && + student.firstName === session.user?.firstName && + student.lastName === session.user?.lastName && + student.schoolId === session.user?.schoolId); + if (!isOwnStudent) { + return NextResponse.json({ error: 'You can only report illness for yourself' }, { status: 403 }); + } + } else if (role === 'PARENT') { + const parentLink = await db.parentStudentLink.findFirst({ + where: { parentId: userId, studentId }, + }); + if (!parentLink) { + return NextResponse.json({ error: 'You can only report illness for your children' }, { status: 403 }); + } + } + + const reporterType = role === 'STUDENT' ? 'student' : 'parent'; + const isParentReport = reporterType === 'parent'; + + // When student reports: needs parent approval, not visible to teacher/admin + // When parent reports: auto-approved, visible to teacher/admin + const report = await db.illnessReport.create({ + data: { + schoolId: student.schoolId, + studentId, + reportedBy: userId, + reporterType, + reason, + description: description || null, + startDate: new Date(startDate), + endDate: endDate ? new Date(endDate) : null, + documentUrl: documentUrl || null, + parentApprovalStatus: isParentReport ? 'approved' : 'pending', + parentApprovedBy: isParentReport ? userId : null, + parentApprovedAt: isParentReport ? new Date() : null, + isVisibleToTeacher: isParentReport, + isVisibleToAdmin: isParentReport, + status: isParentReport ? 'approved' : 'pending', + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + reporter: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(report, { status: 201 }); + } catch (error) { + console.error('IllnessReport POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/subject-categories/[id]/route.ts b/src/app/api/subject-categories/[id]/route.ts new file mode 100644 index 0000000..0015714 --- /dev/null +++ b/src/app/api/subject-categories/[id]/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ── GET: Get single category ── +async function getCategory(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const category = await db.subjectCategory.findUnique({ + where: { id }, + include: { + _count: { select: { contents: { where: { isActive: true } } } }, + }, + }); + + if (!category) { + return NextResponse.json({ error: 'Category not found' }, { status: 404 }); + } + + return NextResponse.json(category); + } catch (error) { + console.error('SubjectCategory GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update category ── +const updateCategorySchema = z.object({ + name: z.string().min(1).max(200).optional(), + slug: z.string().min(1).max(200).optional(), + description: z.string().max(1000).optional().nullable(), + icon: z.string().optional().nullable(), + sortOrder: z.number().int().optional(), + isActive: z.boolean().optional(), +}); + +async function updateCategory(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateCategorySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const category = await db.subjectCategory.update({ + where: { id }, + data: parsed.data, + }); + + return NextResponse.json(category); + } catch (error) { + console.error('SubjectCategory PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── DELETE: Soft delete / deactivate category ── +async function deleteCategory(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + + // Soft delete by deactivating + const category = await db.subjectCategory.update({ + where: { id }, + data: { isActive: false }, + }); + + return NextResponse.json({ success: true, category }); + } catch (error) { + console.error('SubjectCategory DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getCategory, 'dataRead'); +export const PUT = withRateLimit(updateCategory, 'dataWrite'); +export const DELETE = withRateLimit(deleteCategory, 'dataWrite'); diff --git a/src/app/api/subject-categories/route.ts b/src/app/api/subject-categories/route.ts new file mode 100644 index 0000000..5ff8e64 --- /dev/null +++ b/src/app/api/subject-categories/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ── GET: List subject categories ── +async function getCategories(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') || session.user.schoolId; + const includeInactive = searchParams.get('includeInactive') === 'true'; + + if (!schoolId) { + return NextResponse.json([]); + } + + const where: Record = { schoolId }; + if (!includeInactive) { + where.isActive = true; + } + + const categories = await db.subjectCategory.findMany({ + where, + include: { + _count: { select: { contents: { where: { isActive: true } } } }, + }, + orderBy: { sortOrder: 'asc' }, + }); + + return NextResponse.json(categories); + } catch (error) { + console.error('SubjectCategories GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create a new subject category ── +const createCategorySchema = z.object({ + schoolId: z.string().optional(), + name: z.string().min(1).max(200), + slug: z.string().min(1).max(200), + description: z.string().max(1000).optional().nullable(), + icon: z.string().optional().nullable(), + sortOrder: z.number().int().default(0), +}); + +async function createCategory(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createCategorySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const schoolId = parsed.data.schoolId || session.user.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'User must belong to a school' }, { status: 400 }); + } + + const category = await db.subjectCategory.create({ + data: { + schoolId, + name: parsed.data.name, + slug: parsed.data.slug, + description: parsed.data.description, + icon: parsed.data.icon, + sortOrder: parsed.data.sortOrder, + }, + }); + + return NextResponse.json(category, { status: 201 }); + } catch (error) { + console.error('SubjectCategory POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getCategories, 'dataRead'); +export const POST = withRateLimit(createCategory, 'dataWrite'); diff --git a/src/app/api/subject-contents/[id]/route.ts b/src/app/api/subject-contents/[id]/route.ts new file mode 100644 index 0000000..d6e0746 --- /dev/null +++ b/src/app/api/subject-contents/[id]/route.ts @@ -0,0 +1,140 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ── GET: Get single content ── +async function getContent(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const content = await db.subjectContent.findUnique({ + where: { id }, + include: { + category: { select: { id: true, name: true, slug: true } }, + subject: { select: { id: true, name: true } }, + parent: { select: { id: true, title: true, slug: true } }, + children: { + where: { isActive: true }, + orderBy: { sortOrder: 'asc' }, + }, + }, + }); + + if (!content) { + return NextResponse.json({ error: 'Content not found' }, { status: 404 }); + } + + return NextResponse.json(content); + } catch (error) { + console.error('SubjectContent GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update content ── +const updateContentSchema = z.object({ + title: z.string().min(1).max(300).optional(), + slug: z.string().min(1).max(300).optional(), + description: z.string().max(2000).optional().nullable(), + icon: z.string().optional().nullable(), + contentType: z.enum(['topic', 'exercise', 'lesson', 'quiz', 'vocabulary']).optional(), + content: z.string().optional().nullable(), + difficulty: z.enum(['easy', 'medium', 'hard']).optional(), + questionCount: z.number().int().optional(), + sortOrder: z.number().int().optional(), + isActive: z.boolean().optional(), + isPublic: z.boolean().optional(), + sourceUrl: z.string().optional().nullable(), + subjectId: z.string().optional().nullable(), + parentId: z.string().optional().nullable(), +}); + +async function updateContent(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateContentSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + // SCHOOL_ADMIN, VICE_PRINCIPAL can edit directly; TEACHER must use change request + if (session.user.role === 'TEACHER') { + return NextResponse.json( + { error: 'Teachers must submit a change request to edit content' }, + { status: 403 } + ); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const contentItem = await db.subjectContent.update({ + where: { id }, + data: parsed.data, + include: { + category: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(contentItem); + } catch (error) { + console.error('SubjectContent PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── DELETE: Delete content ── +async function deleteContent(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + + // Soft delete by deactivating + const contentItem = await db.subjectContent.update({ + where: { id }, + data: { isActive: false }, + }); + + return NextResponse.json({ success: true, content: contentItem }); + } catch (error) { + console.error('SubjectContent DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getContent, 'dataRead'); +export const PUT = withRateLimit(updateContent, 'dataWrite'); +export const DELETE = withRateLimit(deleteContent, 'dataWrite'); diff --git a/src/app/api/subject-contents/route.ts b/src/app/api/subject-contents/route.ts new file mode 100644 index 0000000..e609107 --- /dev/null +++ b/src/app/api/subject-contents/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ── GET: List subject contents ── +async function getContents(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') || session.user.schoolId; + const categoryId = searchParams.get('categoryId'); + const subjectId = searchParams.get('subjectId'); + const contentType = searchParams.get('contentType'); + const parentId = searchParams.get('parentId'); + const isActive = searchParams.get('isActive'); + + if (!schoolId) { + return NextResponse.json([]); + } + + const where: Record = { schoolId }; + if (categoryId) where.categoryId = categoryId; + if (subjectId) where.subjectId = subjectId; + if (contentType) where.contentType = contentType; + if (parentId) where.parentId = parentId; + if (isActive !== null && isActive !== undefined) { + where.isActive = isActive === 'true'; + } + + const contents = await db.subjectContent.findMany({ + where, + include: { + category: { select: { id: true, name: true, slug: true } }, + subject: { select: { id: true, name: true } }, + _count: { select: { children: { where: { isActive: true } } } }, + }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + }); + + return NextResponse.json(contents); + } catch (error) { + console.error('SubjectContents GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create subject content ── +const createContentSchema = z.object({ + schoolId: z.string().optional(), + categoryId: z.string().min(1), + subjectId: z.string().optional().nullable(), + parentId: z.string().optional().nullable(), + title: z.string().min(1).max(300), + slug: z.string().min(1).max(300), + description: z.string().max(2000).optional().nullable(), + icon: z.string().optional().nullable(), + contentType: z.enum(['topic', 'exercise', 'lesson', 'quiz', 'vocabulary']).default('topic'), + content: z.string().optional().nullable(), + difficulty: z.enum(['easy', 'medium', 'hard']).default('medium'), + questionCount: z.number().int().default(0), + sortOrder: z.number().int().default(0), + isActive: z.boolean().default(true), + isPublic: z.boolean().default(true), + sourceUrl: z.string().optional().nullable(), +}); + +async function createContent(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'TEACHER' && + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createContentSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const schoolId = parsed.data.schoolId || session.user.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'User must belong to a school' }, { status: 400 }); + } + + const contentItem = await db.subjectContent.create({ + data: { + schoolId, + categoryId: parsed.data.categoryId, + subjectId: parsed.data.subjectId, + parentId: parsed.data.parentId, + title: parsed.data.title, + slug: parsed.data.slug, + description: parsed.data.description, + icon: parsed.data.icon, + contentType: parsed.data.contentType, + content: parsed.data.content, + difficulty: parsed.data.difficulty, + questionCount: parsed.data.questionCount, + sortOrder: parsed.data.sortOrder, + isActive: parsed.data.isActive, + isPublic: parsed.data.isPublic, + sourceUrl: parsed.data.sourceUrl, + }, + include: { + category: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(contentItem, { status: 201 }); + } catch (error) { + console.error('SubjectContent POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getContents, 'dataRead'); +export const POST = withRateLimit(createContent, 'dataWrite'); diff --git a/src/app/api/subject-seed/route.ts b/src/app/api/subject-seed/route.ts new file mode 100644 index 0000000..5592170 --- /dev/null +++ b/src/app/api/subject-seed/route.ts @@ -0,0 +1,233 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// Schlaukopf.de content structure for Gymnasium +const SCHLAUKOPF_CATEGORIES = [ + { name: 'Klasse 5', slug: 'klasse5', icon: 'GraduationCap', sortOrder: 0 }, + { name: 'Klasse 6', slug: 'klasse6', icon: 'GraduationCap', sortOrder: 1 }, + { name: 'Klasse 7', slug: 'klasse7', icon: 'GraduationCap', sortOrder: 2 }, + { name: 'Klasse 8', slug: 'klasse8', icon: 'GraduationCap', sortOrder: 3 }, + { name: 'Klasse 9', slug: 'klasse9', icon: 'GraduationCap', sortOrder: 4 }, + { name: 'Klasse 10', slug: 'klasse10', icon: 'GraduationCap', sortOrder: 5 }, + { name: 'Oberstufe', slug: 'oberstufe', icon: 'Award', sortOrder: 6 }, +]; + +const SUBJECTS_BY_CLASS: Record> = { + 'klasse5': [ + { name: 'Mathematik', slug: 'mathematik', icon: 'Calculator', topics: ['Grundwissen', 'Natuerliche Zahlen', 'Brueche', 'Geometrie', 'Groessen', 'Terme'] }, + { name: 'Deutsch', slug: 'deutsch', icon: 'BookOpen', topics: ['Grammatik', 'Rechtschreibung', 'Wortarten', 'Satzarten', 'Aufsatz', 'Lesen'] }, + { name: 'Englisch', slug: 'englisch', icon: 'Globe', topics: ['Vocabulary', 'Grammar', 'Tenses', 'Reading', 'Listening'] }, + { name: 'Franzoesisch', slug: 'franzoesisch', icon: 'Globe', topics: ['Vokabeln', 'Grammatik', 'Konjugation', 'Lesen'] }, + { name: 'Biologie', slug: 'biologie', icon: 'FlaskConical', topics: ['Lebewesen', 'Zellen', 'Mensch', 'Pflanzen', 'Tiere'] }, + { name: 'Geografie', slug: 'geografie', icon: 'Map', topics: ['Sonnensystem', 'Deutschland', 'Europa', 'Klima'] }, + { name: 'Musik', slug: 'musik', icon: 'Music', topics: ['Noten', 'Intervalle', 'Rhythmus', 'Instrumente'] }, + { name: 'Geschichte', slug: 'geschichte', icon: 'History', topics: ['Fruehgeschichte', 'Aegypten', 'Griechenland', 'Roemer'] }, + { name: 'Natur und Technik', slug: 'natur-und-technik', icon: 'Zap', topics: ['Akustik', 'Licht', 'Magnetismus', 'Elektrizitaet'] }, + { name: 'Ethik und Religion', slug: 'ethik', icon: 'Heart', topics: ['Werte', 'Religionen', 'Ethik', 'Philosophie'] }, + { name: 'Latein', slug: 'latein', icon: 'BookOpen', topics: ['Vokabeln', 'Grammatik', 'Uebersetzung', 'Kultur'] }, + ], + 'klasse6': [ + { name: 'Mathematik', slug: 'mathematik', icon: 'Calculator', topics: ['Bruchrechnung', 'Dezimalzahlen', 'Geometrie', 'Gleichungen', 'Proportionalitaet'] }, + { name: 'Deutsch', slug: 'deutsch', icon: 'BookOpen', topics: ['Grammatik', 'Rechtschreibung', 'Epik', 'Lyrik', 'Medien'] }, + { name: 'Englisch', slug: 'englisch', icon: 'Globe', topics: ['Vocabulary', 'Grammar', 'Tenses', 'Writing', 'Speaking'] }, + { name: 'Franzoesisch', slug: 'franzoesisch', icon: 'Globe', topics: ['Vokabeln', 'Grammatik', 'Passe Compose', 'Lesen'] }, + { name: 'Biologie', slug: 'biologie', icon: 'FlaskConical', topics: ['Oekologie', 'Evolution', 'Sinnesorgane', 'Fortbewegung'] }, + { name: 'Geografie', slug: 'geografie', icon: 'Map', topics: ['Naturgefaehrdungen', 'Wirtschaft', 'Bevoelkerung', 'Globalisierung'] }, + { name: 'Musik', slug: 'musik', icon: 'Music', topics: ['Tonleiter', 'Akkorde', 'Musikgeschichte', 'Komponisten'] }, + { name: 'Geschichte', slug: 'geschichte', icon: 'History', topics: ['Mittelalter', 'Kreuzzuege', 'Kloster', 'Ritter'] }, + { name: 'Natur und Technik', slug: 'natur-und-technik', icon: 'Zap', topics: ['Optik', 'Mechanik', 'Stoffe', 'Geraete'] }, + { name: 'Ethik und Religion', slug: 'ethik', icon: 'Heart', topics: ['Moral', 'Gerechtigkeit', 'Freiheit', 'Verantwortung'] }, + { name: 'Latein', slug: 'latein', icon: 'BookOpen', topics: ['Vokabeln', 'Grammatik', 'Ablativ', 'Kultur'] }, + ], + 'klasse7': [ + { name: 'Mathematik', slug: 'mathematik', icon: 'Calculator', topics: ['Terme', 'Gleichungen', 'Prozentrechnung', 'Statistik', 'Geometrie'] }, + { name: 'Deutsch', slug: 'deutsch', icon: 'BookOpen', topics: ['Grammatik', 'Literatur', 'Sachtexte', 'Schreiben', 'Rhetorik'] }, + { name: 'Englisch', slug: 'englisch', icon: 'Globe', topics: ['Vocabulary', 'Grammar', 'Tenses', 'Literature', 'Debate'] }, + { name: 'Franzoesisch', slug: 'franzoesisch', icon: 'Globe', topics: ['Vokabeln', 'Grammatik', 'Subjonctif', 'Landeskunde'] }, + { name: 'Biologie', slug: 'biologie', icon: 'FlaskConical', topics: ['Genetik', 'Zellteilung', 'Stoffwechsel', 'Nervensystem'] }, + { name: 'Geografie', slug: 'geografie', icon: 'Map', topics: ['Nordamerika', 'Suedamerika', 'Asien', 'Klimawandel'] }, + { name: 'Musik', slug: 'musik', icon: 'Music', topics: ['Musikformen', 'Jazz', 'Pop', 'Filmmusik'] }, + { name: 'Geschichte', slug: 'geschichte', icon: 'History', topics: ['Entdeckungen', 'Reformation', 'Dreissigjaehriger Krieg', 'Aufklaerung'] }, + { name: 'Natur und Technik', slug: 'natur-und-technik', icon: 'Zap', topics: ['Chemie', 'Elektrizitaet', 'Kraefte', 'Energie'] }, + { name: 'Ethik und Religion', slug: 'ethik', icon: 'Heart', topics: ['Menschenwuerde', 'Toleranz', 'Konflikte', 'Frieden'] }, + { name: 'Latein', slug: 'latein', icon: 'BookOpen', topics: ['Vokabeln', 'Konjunktiv', 'Caesar', 'Kultur'] }, + ], + 'klasse8': [ + { name: 'Mathematik', slug: 'mathematik', icon: 'Calculator', topics: ['Quadratische Gleichungen', 'Funktionen', 'Satz des Pythagoras', 'Wahrscheinlichkeit', 'Aehnlichkeit'] }, + { name: 'Deutsch', slug: 'deutsch', icon: 'BookOpen', topics: ['Dramatik', 'Lyrik', 'Epik', 'Medien', 'Kommunikation'] }, + { name: 'Englisch', slug: 'englisch', icon: 'Globe', topics: ['Vocabulary', 'Grammar', 'Conditionals', 'Reported Speech', 'Writing'] }, + { name: 'Franzoesisch', slug: 'franzoesisch', icon: 'Globe', topics: ['Vokabeln', 'Grammatik', 'Conditionnel', 'Frankophone'] }, + { name: 'Biologie', slug: 'biologie', icon: 'FlaskConical', topics: ['Immunsystem', 'Hormone', 'Evolution', 'Verhaltensbiologie'] }, + { name: 'Physik', slug: 'physik', icon: 'Zap', topics: ['Mechanik', 'Elektrizitaet', 'Optik', 'Akustik'] }, + { name: 'Chemie', slug: 'chemie', icon: 'FlaskConical', topics: ['Atombau', 'Periodensystem', 'Bindungen', 'Reaktionen'] }, + { name: 'Geschichte', slug: 'geschichte', icon: 'History', topics: ['Industrielle Revolution', 'Imperialismus', 'Erster Weltkrieg', 'Weimarer Republik'] }, + { name: 'Geografie', slug: 'geografie', icon: 'Map', topics: ['Afrika', 'Australien', 'Polarregionen', 'Nachhaltigkeit'] }, + { name: 'Ethik und Religion', slug: 'ethik', icon: 'Heart', topics: ['Ethik der Technik', 'Bioethik', 'Medienethik', 'Weltethos'] }, + { name: 'Latein', slug: 'latein', icon: 'BookOpen', topics: ['Vokabeln', 'Partizip', 'Cicero', 'Kultur'] }, + ], + 'klasse9': [ + { name: 'Mathematik', slug: 'mathematik', icon: 'Calculator', topics: ['Analysis', 'Trigonometrie', 'Stochastik', 'Vektorrechnung', 'Funktionen'] }, + { name: 'Deutsch', slug: 'deutsch', icon: 'BookOpen', topics: ['Literaturgeschichte', 'Expressionismus', 'Kurzgeschichten', 'Sachtexte', 'Rhetorik'] }, + { name: 'Englisch', slug: 'englisch', icon: 'Globe', topics: ['Vocabulary', 'Grammar', 'Shakespeare', 'Debate', 'Writing'] }, + { name: 'Franzoesisch', slug: 'franzoesisch', icon: 'Globe', topics: ['Vokabeln', 'Grammatik', 'Literatur', 'Zivilisation'] }, + { name: 'Biologie', slug: 'biologie', icon: 'FlaskConical', topics: ['Neurobiologie', 'Oekologie', 'Gentechnik', 'Evolution'] }, + { name: 'Physik', slug: 'physik', icon: 'Zap', topics: ['Elektromagnetismus', 'Kernphysik', 'Mechanik', 'Quantenphysik'] }, + { name: 'Chemie', slug: 'chemie', icon: 'FlaskConical', topics: ['Organische Chemie', 'Saeuren', 'Redoxreaktionen', 'Analytik'] }, + { name: 'Geschichte', slug: 'geschichte', icon: 'History', topics: ['Nationalsozialismus', 'Zweiter Weltkrieg', 'Holocaust', 'Nachkriegszeit'] }, + { name: 'Geografie', slug: 'geografie', icon: 'Map', topics: ['Europa', 'Globalisierung', 'Migration', 'Wirtschaftsgeografie'] }, + { name: 'Ethik und Religion', slug: 'ethik', icon: 'Heart', topics: ['Existenzphilosophie', 'Politische Ethik', 'Recht', 'Gerechtigkeit'] }, + { name: 'Latein', slug: 'latein', icon: 'BookOpen', topics: ['Vokabeln', 'Uebersetzung', 'Ovid', 'Kultur'] }, + ], + 'klasse10': [ + { name: 'Mathematik', slug: 'mathematik', icon: 'Calculator', topics: ['Analysis', 'Wahrscheinlichkeit', 'Vektorrechnung', 'Kurven', 'Grenzwerte'] }, + { name: 'Deutsch', slug: 'deutsch', icon: 'BookOpen', topics: ['Literatur', 'Klassik', 'Romantik', 'Gegenwartsliteratur', 'Erörterung'] }, + { name: 'Englisch', slug: 'englisch', icon: 'Globe', topics: ['Vocabulary', 'Grammar', 'Literature', 'Creative Writing', 'Presentations'] }, + { name: 'Franzoesisch', slug: 'franzoesisch', icon: 'Globe', topics: ['Vokabeln', 'Grammatik', 'Literatur', 'Film'] }, + { name: 'Biologie', slug: 'biologie', icon: 'FlaskConical', topics: ['Genetik', 'Immunologie', 'Verhaltensforschung', 'Oekologie'] }, + { name: 'Physik', slug: 'physik', icon: 'Zap', topics: ['Relativitaetstheorie', 'Quantenphysik', 'Astrophysik', 'Technik'] }, + { name: 'Chemie', slug: 'chemie', icon: 'FlaskConical', topics: ['Polymerchemie', 'Umweltchemie', 'Biochemie', 'Labor'] }, + { name: 'Geschichte', slug: 'geschichte', icon: 'History', topics: ['Kalter Krieg', 'Deutsche Teilung', 'Wiedervereinigung', 'Europa'] }, + { name: 'Geografie', slug: 'geografie', icon: 'Map', topics: ['Deutschland', 'Raumplanung', 'Entwicklung', 'Ressourcen'] }, + { name: 'Ethik und Religion', slug: 'ethik', icon: 'Heart', topics: ['Ethik der Zukunft', 'Kuenstliche Intelligenz', 'Nachhaltigkeit', 'Weltreligionen'] }, + { name: 'Informatik', slug: 'informatik', icon: 'Code', topics: ['Programmierung', 'Algorithmen', 'Datenbanken', 'Netzwerke'] }, + ], + 'oberstufe': [ + { name: 'Mathematik', slug: 'mathematik', icon: 'Calculator', topics: ['Analysis', 'Lineare Algebra', 'Stochastik', 'Analytische Geometrie'] }, + { name: 'Deutsch', slug: 'deutsch', icon: 'BookOpen', topics: ['Literatur', 'Sprache', 'Medien', 'Kommunikation'] }, + { name: 'Englisch', slug: 'englisch', icon: 'Globe', topics: ['Literature', 'Linguistics', 'Cultural Studies', 'Academic Writing'] }, + { name: 'Franzoesisch', slug: 'franzoesisch', icon: 'Globe', topics: ['Literature', 'Civilisation', 'Grammaire', 'Expression'] }, + { name: 'Biologie', slug: 'biologie', icon: 'FlaskConical', topics: ['Molekularbiologie', 'Neurobiologie', 'Oekologie', 'Evolution'] }, + { name: 'Physik', slug: 'physik', icon: 'Zap', topics: ['Mechanik', 'Elektrodynamik', 'Thermodynamik', 'Quantenphysik'] }, + { name: 'Chemie', slug: 'chemie', icon: 'FlaskConical', topics: ['Organische Chemie', 'Physikalische Chemie', 'Analytik', 'Umwelt'] }, + { name: 'Geschichte', slug: 'geschichte', icon: 'History', topics: ['20. Jahrhundert', 'Globalgeschichte', 'Erinnerungskultur', 'Quellenanalyse'] }, + { name: 'Geografie', slug: 'geografie', icon: 'Map', topics: ['Globalisierung', 'Klimawandel', 'Urbanisierung', 'Geoinformatik'] }, + { name: 'Informatik', slug: 'informatik', icon: 'Code', topics: ['Objektorientierung', 'Datenstrukturen', 'Datenbanken', 'Theoretische Informatik'] }, + { name: 'Wirtschaft', slug: 'wirtschaft', icon: 'TrendingUp', topics: ['Volkswirtschaft', 'Betriebswirtschaft', 'Finanzen', 'Markt'] }, + { name: 'Kunst', slug: 'kunst', icon: 'Palette', topics: ['Kunstgeschichte', 'Malerei', 'Skulptur', 'Neue Medien'] }, + { name: 'Sport', slug: 'sport', icon: 'Dumbbell', topics: ['Trainingslehre', 'Sportpsychologie', 'Bewegungsanalyse', 'Gesundheit'] }, + ], +}; + +// ── POST: Seed subject categories and content ── +async function seedData(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' && + session.user.role !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const schoolId = session.user.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'User must belong to a school' }, { status: 400 }); + } + + let categoriesCreated = 0; + let contentsCreated = 0; + + // Create categories + for (const catData of SCHLAUKOPF_CATEGORIES) { + const existing = await db.subjectCategory.findFirst({ + where: { schoolId, slug: catData.slug }, + }); + + if (existing) continue; + + const category = await db.subjectCategory.create({ + data: { + schoolId, + name: catData.name, + slug: catData.slug, + icon: catData.icon, + sortOrder: catData.sortOrder, + }, + }); + + categoriesCreated++; + + // Create subjects and topics for this class + const subjects = SUBJECTS_BY_CLASS[catData.slug] || []; + for (const subj of subjects) { + // Check if subject already exists + const existingContent = await db.subjectContent.findFirst({ + where: { schoolId, categoryId: category.id, slug: subj.slug }, + }); + + if (existingContent) continue; + + // Create the subject content + const subjectContent = await db.subjectContent.create({ + data: { + schoolId, + categoryId: category.id, + title: subj.name, + slug: subj.slug, + icon: subj.icon, + contentType: 'topic', + sortOrder: subjects.indexOf(subj), + isActive: true, + isPublic: true, + }, + }); + + contentsCreated++; + + // Create subtopics + for (let i = 0; i < subj.topics.length; i++) { + const topicName = subj.topics[i]; + const topicSlug = `${subj.slug}-${topicName.toLowerCase().replace(/[^a-z0-9]/g, '-')}`; + + const existingTopic = await db.subjectContent.findFirst({ + where: { schoolId, slug: topicSlug, parentId: subjectContent.id }, + }); + + if (existingTopic) continue; + + await db.subjectContent.create({ + data: { + schoolId, + categoryId: category.id, + parentId: subjectContent.id, + title: topicName, + slug: topicSlug, + contentType: 'topic', + sortOrder: i, + isActive: true, + isPublic: true, + questionCount: Math.floor(Math.random() * 20) + 5, + }, + }); + + contentsCreated++; + } + } + } + + // Ensure AI settings exist + const existingSettings = await db.aISettings.findUnique({ where: { schoolId } }); + if (!existingSettings) { + await db.aISettings.create({ data: { schoolId } }); + } + + return NextResponse.json({ + success: true, + categoriesCreated, + contentsCreated, + message: `Created ${categoriesCreated} categories and ${contentsCreated} content items`, + }); + } catch (error) { + console.error('Subject seed POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const POST = withRateLimit(seedData, 'dataWrite'); diff --git a/src/components/ai-chat-widget.tsx b/src/components/ai-chat-widget.tsx new file mode 100644 index 0000000..c484112 --- /dev/null +++ b/src/components/ai-chat-widget.tsx @@ -0,0 +1,435 @@ +'use client'; + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + MessageCircle, + X, + Send, + Sparkles, + HelpCircle, + Lightbulb, + Brain, + Loader2, + Trash2, + Plus, + AlertTriangle, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { t } from '@/lib/i18n'; +import { apiPost, apiGet } from '@/lib/api'; + +interface ChatMessage { + id: string; + content: string; + senderType: 'user' | 'ai' | 'system'; + createdAt: string; +} + +interface ChatHistoryMessage { + role: 'user' | 'assistant'; + content: string; +} + +export default function AIChatWidget() { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [requestsToday, setRequestsToday] = useState(0); + const [maxRequests, setMaxRequests] = useState(50); + const [chatEnabled, setChatEnabled] = useState(true); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + // Load chat history on mount + useEffect(() => { + if (isOpen) { + loadChatHistory(); + } + }, [isOpen]); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages, isLoading]); + + // Focus input when chat opens + useEffect(() => { + if (isOpen && inputRef.current) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [isOpen]); + + const loadChatHistory = useCallback(async () => { + try { + const data = await apiGet<{ + messages: ChatMessage[]; + requestsToday: number; + maxRequests: number; + chatEnabled: boolean; + }>('/api/ai/chat'); + setMessages(data.messages || []); + setRequestsToday(data.requestsToday); + setMaxRequests(data.maxRequests); + setChatEnabled(data.chatEnabled); + } catch { + // Chat might not be available yet + setChatEnabled(false); + } + }, []); + + const handleSendMessage = useCallback( + async (messageText?: string) => { + const text = (messageText || inputValue).trim(); + if (!text || isLoading || !chatEnabled) return; + + // Check rate limit + if (requestsToday >= maxRequests) { + setError(t('ai.error_rate_limit')); + return; + } + + setInputValue(''); + setError(null); + + // Add user message to UI immediately + const userMessage: ChatMessage = { + id: `temp-${Date.now()}`, + content: text, + senderType: 'user', + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, userMessage]); + setIsLoading(true); + + try { + // Build conversation history for context + const history: ChatHistoryMessage[] = messages + .filter((m) => m.senderType === 'user' || m.senderType === 'ai') + .slice(-10) + .map((m) => ({ + role: m.senderType === 'user' ? ('user' as const) : ('assistant' as const), + content: m.content, + })); + + const data = await apiPost<{ + response: string; + requestsToday: number; + maxRequests: number; + }>('/api/ai/chat', { + message: text, + conversationHistory: history, + }); + + // Add AI response + const aiMessage: ChatMessage = { + id: `ai-${Date.now()}`, + content: data.response, + senderType: 'ai', + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, aiMessage]); + setRequestsToday(data.requestsToday); + } catch (err: unknown) { + const error = err as { isRateLimit?: boolean; message?: string }; + if (error?.isRateLimit) { + setError(t('ai.error_rate_limit')); + } else { + setError(t('ai.error_generic')); + } + } finally { + setIsLoading(false); + } + }, + [inputValue, isLoading, chatEnabled, requestsToday, maxRequests, messages] + ); + + const handleQuickAction = useCallback( + (action: 'explain' | 'hint' | 'quiz') => { + const prefixes: Record = { + explain: t('ai.topic_explain') + ': ', + hint: t('ai.topic_hint') + ': ', + quiz: t('ai.topic_quiz') + ': ', + }; + setInputValue(prefixes[action]); + setTimeout(() => inputRef.current?.focus(), 50); + }, + [] + ); + + const handleClearChat = useCallback(async () => { + setMessages([]); + setError(null); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }, + [handleSendMessage] + ); + + const remainingRequests = maxRequests - requestsToday; + + return ( + + {/* Floating Chat Button */} + + {!isOpen && ( + + + + + + +

{t('ai.chat_title')}

+
+
+
+ )} +
+ + {/* Chat Dialog */} + + {isOpen && ( + +
+ {/* Header */} +
+
+
+
+ +
+
+

+ {t('ai.chat_title')} +

+

+ {t('ai.daily_limit', { used: String(requestsToday), max: String(maxRequests) })} +

+
+
+
+ + +
+
+ + {/* Messages Area */} + + {messages.length === 0 && !isLoading && ( +
+
+ +
+

+ {t('ai.welcome_message')} +

+

+ {t('ai.no_homework')} +

+
+ )} + + {messages.map((msg, idx) => ( + +
+ {msg.content} +
+
+ ))} + + {/* Typing Indicator */} + {isLoading && ( + +
+
+
+ + + +
+ + {t('ai.thinking')} + +
+
+
+ )} + + {/* Error Message */} + {error && ( + +
+ + {error} +
+
+ )} + +
+ + + {/* Quick Actions */} +
+
+ + {t('ai.quick_actions')} + +
+
+ + + + + + + + + +
+
+ + {/* Input Area */} +
+
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('ai.chat_placeholder')} + disabled={isLoading || !chatEnabled || requestsToday >= maxRequests} + className="flex-1 h-9 text-sm border-emerald-200/50 dark:border-emerald-800/30 focus:ring-emerald-500/30 bg-white dark:bg-gray-800" + /> + +
+ {requestsToday >= maxRequests && ( +

+ {t('ai.rate_limit')} +

+ )} +
+
+ + )} + + + ); +} diff --git a/src/components/ai-tests-view.tsx b/src/components/ai-tests-view.tsx new file mode 100644 index 0000000..0b9628e --- /dev/null +++ b/src/components/ai-tests-view.tsx @@ -0,0 +1,736 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Brain, + FileQuestion, + Play, + CheckCircle2, + XCircle, + ArrowRight, + ArrowLeft, + BarChart3, + Clock, + Zap, + Sparkles, + Trophy, + Send, + Settings, + RefreshCw, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Progress } from '@/components/ui/progress'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { apiGet, apiPost, apiPut } from '@/lib/api'; +import { toast } from 'sonner'; + +interface AITest { + id: string; + schoolId: string; + assessmentId: string | null; + studentId: string | null; + classGroupId: string | null; + subjectId: string | null; + testType: string; + difficulty: string; + questionCount: number; + questions: string | null; + aiProvider: string; + generatedAt: string | null; + isCompleted: boolean; + completedAt: string | null; + score: number | null; + createdAt: string; + student: { id: string; firstName: string; lastName: string } | null; + classGroup: { id: string; name: string } | null; + subject: { id: string; name: string } | null; +} + +interface Question { + question: string; + options: string[]; + correctAnswer: string; + explanation: string; +} + +export default function AITestsView() { + const currentUser = useAppStore((s) => s.currentUser); + const [tests, setTests] = useState([]); + const [loading, setLoading] = useState(true); + const [generateDialogOpen, setGenerateDialogOpen] = useState(false); + const [testTakingOpen, setTestTakingOpen] = useState(false); + const [selectedTest, setSelectedTest] = useState(null); + const [currentQuestion, setCurrentQuestion] = useState(0); + const [answers, setAnswers] = useState>({}); + const [showResults, setShowResults] = useState(false); + + // Generate form + const [topic, setTopic] = useState(''); + const [testType, setTestType] = useState('practice'); + const [difficulty, setDifficulty] = useState('medium'); + const [questionCount, setQuestionCount] = useState(10); + + const schoolId = currentUser?.schoolId; + const role = currentUser?.role; + const isStudent = role === 'STUDENT'; + const isTeacher = role === 'TEACHER'; + const isAdmin = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL'; + + const fetchTests = useCallback(async () => { + if (!schoolId) return; + setLoading(true); + try { + const data = await apiGet(`/api/ai-tests?schoolId=${schoolId}`); + setTests(data); + } catch (err) { + console.error('Failed to fetch tests:', err); + } finally { + setLoading(false); + } + }, [schoolId]); + + useEffect(() => { + fetchTests(); + }, [fetchTests]); + + const handleGenerateTest = async () => { + if (!topic) return; + try { + await apiPost('/api/ai-tests', { + schoolId, + topic, + testType, + difficulty, + questionCount, + }); + toast.success(t('ai_tests.generate')); + setGenerateDialogOpen(false); + setTopic(''); + fetchTests(); + } catch (err) { + toast.error(String(err) || 'Failed to generate test'); + } + }; + + const handleAutoGenerate = async () => { + try { + const result = await apiPost('/api/ai-tests/auto-generate', { schoolId }); + toast.success(`${t('ai_tests.auto_generate')}: ${result.generated}`); + fetchTests(); + } catch (err) { + toast.error(String(err) || 'Failed to auto-generate'); + } + }; + + const startTest = (test: AITest) => { + setSelectedTest(test); + setCurrentQuestion(0); + setAnswers({}); + setShowResults(false); + setTestTakingOpen(true); + }; + + const getQuestions = (test: AITest): Question[] => { + if (!test.questions) return []; + try { + return JSON.parse(test.questions); + } catch { + return []; + } + }; + + const handleSubmitTest = async () => { + if (!selectedTest) return; + const questions = getQuestions(selectedTest); + let correct = 0; + questions.forEach((q, i) => { + if (answers[i] === q.correctAnswer) correct++; + }); + const score = questions.length > 0 ? (correct / questions.length) * 100 : 0; + + try { + await apiPut(`/api/ai-tests/${selectedTest.id}`, { + isCompleted: true, + score, + }); + setShowResults(true); + toast.success(t('ai_tests.submit_answers')); + fetchTests(); + } catch (err) { + toast.error(String(err) || 'Failed to submit'); + } + }; + + const getTestTypeColor = (type: string) => { + switch (type) { + case 'practice': return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400'; + case 'mock_exam': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400'; + case 'review': return 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-400'; + default: return 'bg-gray-100 text-gray-800'; + } + }; + + const getDifficultyColor = (diff: string) => { + switch (diff) { + case 'easy': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'; + case 'medium': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400'; + case 'hard': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; + default: return 'bg-gray-100 text-gray-800'; + } + }; + + const pendingTests = tests.filter((t) => !t.isCompleted); + const completedTests = tests.filter((t) => t.isCompleted); + const avgScore = completedTests.length > 0 ? completedTests.reduce((a, t) => a + (t.score || 0), 0) / completedTests.length : 0; + + // Questions for current test + const questions = selectedTest ? getQuestions(selectedTest) : []; + const currentQ = questions[currentQuestion]; + + return ( +
+ {/* Header */} + +
+
+ +
+
+

{t('ai_tests.title')}

+

{t('ai_tests.practice')}

+
+
+
+ + {isAdmin && ( + + )} + +
+
+ + {/* Stats */} + + + +

{t('ai_tests.total_tests')}

+

{tests.length}

+
+
+ + +

{t('ai_tests.pending')}

+

{pendingTests.length}

+
+
+ + +

{t('ai_tests.completion_rate')}

+

{tests.length > 0 ? Math.round((completedTests.length / tests.length) * 100) : 0}%

+
+
+ + +

{t('ai_tests.average_score')}

+

{Math.round(avgScore)}%

+
+
+
+ + {/* Tabs */} + + + {t('ai_tests.take_test')} + {t('ai_tests.completed')} + {isAdmin && {t('ai_tests.test_statistics')}} + + + {/* Available Tests Tab */} + + {loading ? ( +
+ {[1, 2, 3].map((i) => ( + + +
+
+ + + ))} +
+ ) : pendingTests.length === 0 ? ( + + + +
+ +
+
+

{t('ai_tests.no_tests')}

+

{t('ai_tests.generate')}

+
+
+ ) : ( +
+ + {pendingTests.map((test, idx) => ( + + + +
+
+
+ +
+
+
+ + {t(`ai_tests.${test.testType}`)} + + + {t(`ai_tests.${test.difficulty}`)} + + + {test.questionCount} {t('ai_tests.questions')} + +
+

+ {test.subject?.name && `${t('ai_tests.subject')}: ${test.subject.name} | `} + {test.generatedAt && `${t('ai_tests.generated_on')}: ${new Date(test.generatedAt).toLocaleDateString()}`} +

+
+
+ +
+
+
+
+ ))} +
+
+ )} + + + {/* Completed Tests Tab */} + + {completedTests.length === 0 ? ( + + + +
+ +
+
+

{t('ai_tests.no_tests')}

+
+
+ ) : ( +
+ {completedTests.map((test, idx) => ( + + + +
+
+
= 70 ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400'}`}> + +
+
+
+ + {t(`ai_tests.${test.testType}`)} + + {Math.round(test.score || 0)}% +
+

+ {test.completedAt && `${t('ai_tests.completed')}: ${new Date(test.completedAt).toLocaleDateString()}`} +

+
+
+ +
+
+
+
+ ))} +
+ )} +
+ + {/* Statistics Tab */} + {isAdmin && ( + +
+ + + {t('ai_tests.test_statistics')} + + +
+
+

{t('ai_tests.average_score')}

+
+ + {Math.round(avgScore)}% +
+
+
+

{t('ai_tests.completion_rate')}

+
+ 0 ? (completedTests.length / tests.length) * 100 : 0} className="flex-1" /> + {tests.length > 0 ? Math.round((completedTests.length / tests.length) * 100) : 0}% +
+
+
+

{t('ai_tests.difficulty')}

+
+ {['easy', 'medium', 'hard'].map((d) => ( + + {t(`ai_tests.${d}`)}: {tests.filter((t) => t.difficulty === d).length} + + ))} +
+
+
+
+
+ + + {t('ai_tests.auto_settings')} + + +
+

{t('ai_tests.two_weeks_before')}

+ +
+
+
+
+
+ )} + + + {/* Generate Test Dialog */} + + + + {t('ai_tests.generate')} + {t('ai_tests.configure')} + +
+
+ + setTopic(e.target.value)} + placeholder={t('ai_tests.topic')} + /> +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + {/* Test Taking Dialog */} + + + + + {showResults ? t('ai_tests.results') : t('ai_tests.take_test')} + + + {selectedTest && !showResults && t('ai_tests.question_of', { current: currentQuestion + 1, total: questions.length })} + + + + {showResults ? ( +
+ {selectedTest && ( +
+ {/* Circular score visualization */} +
+ + + = 70 ? '#10b981' : selectedTest.score && selectedTest.score >= 40 ? '#f59e0b' : '#ef4444'} + strokeWidth="6" + strokeLinecap="round" + strokeDasharray={`${2 * Math.PI * 40}`} + initial={{ strokeDashoffset: 2 * Math.PI * 40 }} + animate={{ strokeDashoffset: 2 * Math.PI * 40 * (1 - (selectedTest.score || 0) / 100) }} + transition={{ duration: 1.2, ease: 'easeOut' }} + /> + +
+ = 70 ? 'text-emerald-600' : 'text-amber-600'}`} + initial={{ scale: 0 }} + animate={{ scale: 1 }} + transition={{ type: 'spring', delay: 0.3 }} + > + {Math.round(selectedTest.score || 0)}% + +
+
+ {/* Celebration animation for high scores */} + {selectedTest.score && selectedTest.score >= 70 && ( + + + + )} +

{t('ai_tests.score')}

+
+ )} + +
+ {questions.map((q, i) => { + const isCorrect = answers[i] === q.correctAnswer; + return ( + + +
+ {isCorrect ? ( + + ) : ( + + )} +
+

{q.question}

+

+ {t('ai_tests.your_answer')}: {answers[i] || '---'} +

+ {!isCorrect && ( +

+ {t('ai_tests.correct_answer')}: {q.correctAnswer} +

+ )} +

+ {t('ai_tests.explanation')}: {q.explanation} +

+
+
+
+
+ ); + })} +
+
+
+ ) : currentQ ? ( +
+ {/* Quiz progress bar with animated fill */} +
+ +
+ {/* Question navigation dots */} +
+ {questions.map((_, i) => ( + + ))} +
+
+

{currentQ.question}

+
+
+ {currentQ.options.map((option, i) => ( + + ))} +
+
+ + {currentQuestion < questions.length - 1 ? ( + + ) : ( + + )} +
+
+ ) : ( +
+

{t('ai_tests.no_tests')}

+
+ )} +
+
+
+ ); +} diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 7bb6f92..3b38215 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -60,6 +60,8 @@ import { Trophy, FolderOpen, Building2, + MessageSquare, + Brain, } from 'lucide-react'; import { Sidebar, @@ -156,6 +158,14 @@ import TimetableView from './timetable-view'; import ResourceLibraryView from './resource-library-view'; import CompetitionsView from './competitions-view'; import DistrictManagementView from './district-management-view'; +import IllnessReportingView from './illness-reporting-view'; +import CommunicationView from './communication-view'; +import SubjectsView from './subjects-view'; +import CounselingView from './counseling-view'; +import DisciplinaryView from './disciplinary-view'; +import AITestsView from './ai-tests-view'; +import AIChatWidget from './ai-chat-widget'; +import VirtualCharacter from './virtual-character'; // Dynamic imports for heavy components with loading skeletons const AnalyticsView = dynamic(() => import('./analytics-view'), { @@ -214,6 +224,12 @@ const navSections: NavSection[] = [ { key: 'timetable', icon: ClockIcon, labelKey: 'nav.timetable' }, { key: 'resources', icon: LibraryIcon, labelKey: 'nav.resources' }, { key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' }, + { key: 'subjects', icon: BookOpen, labelKey: 'nav.subjects' }, + { key: 'illness', icon: Heart, labelKey: 'nav.illness' }, + { key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' }, + { key: 'counseling', icon: Heart, labelKey: 'counseling.title' }, + { key: 'disciplinary', icon: Shield, labelKey: 'disciplinary.title' }, + { key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' }, ], }, { @@ -243,6 +259,11 @@ const studentNavSections: NavSection[] = [ { key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' }, { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, { key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' }, + { key: 'subjects', icon: BookOpen, labelKey: 'nav.subjects' }, + { key: 'illness', icon: Heart, labelKey: 'nav.illness' }, + { key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' }, + { key: 'counseling', icon: Heart, labelKey: 'counseling.title' }, + { key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' }, { key: 'resources', icon: FolderOpen, labelKey: 'nav.resources' }, { key: 'settings', icon: Settings, labelKey: 'nav.settings' }, ], @@ -262,6 +283,8 @@ const parentNavSections: NavSection[] = [ { key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' }, { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, { key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' }, + { key: 'illness', icon: Heart, labelKey: 'nav.illness' }, + { key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' }, ], }, ]; @@ -394,6 +417,12 @@ function renderView(view: ViewName) { case 'resources': return ; case 'competitions': return ; case 'districts': return ; + case 'subjects': return ; + case 'illness': return ; + case 'communication': return ; + case 'counseling': return ; + case 'disciplinary': return ; + case 'ai-tests': return ; default: return ; } } @@ -445,7 +474,7 @@ export default function AppLayout() { ? `${currentUser.firstName} ${currentUser.lastName}` : ''; - const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : currentUser?.role === 'STUDENT' ? 'role.student' : currentUser?.role === 'PARENT' ? 'role.parent' : 'role.super_admin'; + const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'VICE_PRINCIPAL' ? 'role.school_admin' : currentUser?.role === 'STUDENT' ? 'role.student' : currentUser?.role === 'PARENT' ? 'role.parent' : 'role.super_admin'; const isStudent = currentUser?.role === 'STUDENT'; const isParent = currentUser?.role === 'PARENT'; @@ -1482,6 +1511,12 @@ export default function AppLayout() { {/* ─── Onboarding Tour ─────────────────────────────────────── */} setOnboardingOpen(false)} /> + + {/* ─── AI Chat Widget ─────────────────────────────────────────── */} + + + {/* ─── Virtual Character ──────────────────────────────────────── */} + ); } diff --git a/src/components/auth-view.tsx b/src/components/auth-view.tsx index 725c9a8..e7eae5e 100644 --- a/src/components/auth-view.tsx +++ b/src/components/auth-view.tsx @@ -18,6 +18,7 @@ type LoginRole = 'teacher' | 'student' | 'parent'; const DEMO_ACCOUNTS = [ { email: 'demo@competencetrack.org', password: 'Demo2025!', role: 'SCHOOL_ADMIN', labelKey: 'auth.demo_admin', icon: Shield, colorClass: 'from-emerald-400 to-emerald-600 hover:from-emerald-500 hover:to-emerald-700 shadow-emerald-300/40 dark:shadow-emerald-900/40' }, + { email: 'demo.vice@competencetrack.org', password: 'Demo2025!', role: 'VICE_PRINCIPAL', labelKey: 'auth.demo_vice_principal', icon: Shield, colorClass: 'from-emerald-400 to-emerald-600 hover:from-emerald-500 hover:to-emerald-700 shadow-emerald-300/40 dark:shadow-emerald-900/40' }, { email: 'demo.teacher@competencetrack.org', password: 'Demo2025!', role: 'TEACHER', labelKey: 'auth.demo_teacher', icon: GraduationCap, colorClass: 'from-teal-400 to-teal-600 hover:from-teal-500 hover:to-teal-700 shadow-teal-300/40 dark:shadow-teal-900/40' }, { email: 'demo.student@competencetrack.org', password: 'Demo2025!', role: 'STUDENT', labelKey: 'auth.demo_student', icon: User, colorClass: 'from-amber-400 to-amber-600 hover:from-amber-500 hover:to-amber-700 shadow-amber-300/40 dark:shadow-amber-900/40' }, { email: 'demo.parent@competencetrack.org', password: 'Demo2025!', role: 'PARENT', labelKey: 'auth.demo_parent', icon: Heart, colorClass: 'from-violet-400 to-violet-600 hover:from-violet-500 hover:to-violet-700 shadow-violet-300/40 dark:shadow-violet-900/40' }, diff --git a/src/components/communication-view.tsx b/src/components/communication-view.tsx new file mode 100644 index 0000000..3a0d969 --- /dev/null +++ b/src/components/communication-view.tsx @@ -0,0 +1,899 @@ +'use client'; + +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + MessageSquare, + Plus, + Send, + Clock, + CheckCircle2, + XCircle, + Phone, + Paperclip, + Mic, + MicOff, + StickyNote, + FileText, + User, + Shield, + Eye, + X, + MessageCircle, + ChevronRight, + AlertTriangle, + Volume2, + Notebook, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api'; +import { toast } from 'sonner'; + +interface CommunicationRoom { + id: string; + schoolId: string; + studentId: string; + teacherId: string; + roomType: string; + status: string; + requestedBy: string; + acceptedAt: string | null; + closedAt: string | null; + closeReason: string | null; + createdAt: string; + updatedAt: string; + student?: { id: string; firstName: string; lastName: string }; + teacher?: { id: string; firstName: string; lastName: string }; + messages?: Array<{ + id: string; + content: string; + messageType: string; + createdAt: string; + senderId: string; + }>; +} + +interface Message { + id: string; + roomId: string; + senderId: string; + content: string; + messageType: string; + fileUrl: string | null; + metadata: string | null; + isRead: boolean; + createdAt: string; + sender?: { id: string; firstName: string; lastName: string; role: string }; +} + +export default function CommunicationView() { + const currentUser = useAppStore((s) => s.currentUser); + const [rooms, setRooms] = useState([]); + const [selectedRoomId, setSelectedRoomId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [showRequestDialog, setShowRequestDialog] = useState(false); + + const fetchRooms = useCallback(async () => { + try { + const data = await apiGet('/api/communication-rooms'); + setRooms(data); + } catch (error) { + console.error('Failed to fetch rooms:', error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchRooms(); + // Polling every 5 seconds for new messages + const interval = setInterval(fetchRooms, 5000); + return () => clearInterval(interval); + }, [fetchRooms]); + + const role = currentUser?.role; + + if (isLoading) { + return ( +
+
+
+ ); + } + + const requestedRooms = rooms.filter((r) => r.status === 'requested'); + const activeRooms = rooms.filter((r) => r.status === 'active'); + const closedRooms = rooms.filter((r) => r.status === 'closed'); + + return ( +
+ {/* Header */} + +
+

+ + {t('communication.title')} +

+

+ {role === 'STUDENT' && t('communication.active_conversations')} + {role === 'TEACHER' && t('communication.incoming_requests')} + {(role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') && t('communication.all_rooms')} +

+
+ {role === 'STUDENT' && ( + + )} +
+ + {/* Main Layout: Room List + Chat */} +
+ {/* Room List */} +
+ {/* Pending Requests (Teacher) */} + {role === 'TEACHER' && requestedRooms.length > 0 && ( + + + + + {t('communication.pending_requests')} ({requestedRooms.length}) + + + +
+ {requestedRooms.map((room) => ( + setSelectedRoomId(room.id)} + currentUserId={currentUser?.id || ''} + role={role} + /> + ))} +
+
+
+ )} + + {/* Active Rooms */} + + + + + {t('communication.active_conversations')} ({activeRooms.length}) + + + + {activeRooms.length === 0 ? ( +

{t('communication.no_conversations')}

+ ) : ( +
+ {activeRooms.map((room) => ( + setSelectedRoomId(room.id)} + currentUserId={currentUser?.id || ''} + role={role} + /> + ))} +
+ )} +
+
+ + {/* Closed Rooms */} + {closedRooms.length > 0 && ( + + + + + {t('communication.closed')} ({closedRooms.length}) + + + +
+ {closedRooms.map((room) => ( + setSelectedRoomId(room.id)} + currentUserId={currentUser?.id || ''} + role={role} + /> + ))} +
+
+
+ )} +
+ + {/* Chat Area */} +
+ {selectedRoomId ? ( + + ) : ( + +
+ +

{t('communication.select_conversation')}

+
+
+ )} +
+
+ + {/* Mobile: Show chat as full-screen overlay */} + + {selectedRoomId && ( +
+ setSelectedRoomId(null)} + /> +
+ )} +
+ + {/* Request Dialog */} + +
+ ); +} + +function RoomListItem({ + room, + isSelected, + onClick, + currentUserId, + role, +}: { + room: CommunicationRoom; + isSelected: boolean; + onClick: () => void; + currentUserId: string; + role: string | undefined; +}) { + const isStudent = room.studentId === currentUserId; + const otherPerson = isStudent ? room.teacher : room.student; + const lastMessage = room.messages?.[0]; + + const getStatusColor = () => { + switch (room.status) { + case 'requested': return 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300'; + case 'active': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'; + case 'closed': return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'; + default: return ''; + } + }; + + const getStatusLabel = () => { + switch (room.status) { + case 'requested': return t('communication.requested'); + case 'active': return t('communication.active'); + case 'closed': return t('communication.closed'); + default: return room.status; + } + }; + + return ( + +
+
+
+
+ +
+ {/* Online/offline status indicator */} +
+
+
+

+ {otherPerson ? `${otherPerson.firstName} ${otherPerson.lastName}` : 'Unknown'} +

+ {lastMessage && ( +

{lastMessage.content}

+ )} +
+
+
+ {/* Unread count badge */} + {room.messages && room.messages.length > 0 && room.status === 'active' && ( + + {room.messages.length} + + )} + + {getStatusLabel()} + +
+
+ + ); +} + +function ChatArea({ + roomId, + currentUserId, + role, + onRefresh, + isMobile, + onClose, +}: { + roomId: string; + currentUserId: string; + role: string | undefined; + onRefresh: () => void; + isMobile?: boolean; + onClose?: () => void; +}) { + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [isLoadingMessages, setIsLoadingMessages] = useState(true); + const [isSending, setIsSending] = useState(false); + const [room, setRoom] = useState(null); + const [isRecording, setIsRecording] = useState(false); + const [teacherNotes, setTeacherNotes] = useState(''); + const [showTeacherNotes, setShowTeacherNotes] = useState(false); + const messagesEndRef = useRef(null); + const mediaRecorderRef = useRef(null); + + const fetchMessages = useCallback(async () => { + try { + const data = await apiGet<{ messages: Message[]; total: number }>(`/api/communication-rooms/${roomId}/messages`); + setMessages(data.messages); + } catch (error) { + console.error('Failed to fetch messages:', error); + } finally { + setIsLoadingMessages(false); + } + }, [roomId]); + + const fetchRoom = useCallback(async () => { + try { + const data = await apiGet(`/api/communication-rooms/${roomId}`); + setRoom(data); + } catch (error) { + console.error('Failed to fetch room:', error); + } + }, [roomId]); + + useEffect(() => { + fetchMessages(); + fetchRoom(); + const interval = setInterval(fetchMessages, 5000); + return () => clearInterval(interval); + }, [fetchMessages, fetchRoom]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const sendMessage = async () => { + if (!newMessage.trim()) return; + setIsSending(true); + try { + await apiPost(`/api/communication-rooms/${roomId}/messages`, { + content: newMessage, + messageType: 'text', + }); + setNewMessage(''); + fetchMessages(); + onRefresh(); + } catch (error) { + toast.error(t('communication.error_send')); + } finally { + setIsSending(false); + } + }; + + const handleAccept = async () => { + try { + await apiPut(`/api/communication-rooms/${roomId}`, { status: 'active' }); + toast.success(t('communication.conversation_accepted')); + fetchRoom(); + onRefresh(); + } catch (error) { + toast.error('Error accepting conversation'); + } + }; + + const handleReject = async () => { + try { + await apiPut(`/api/communication-rooms/${roomId}`, { status: 'rejected' }); + toast.success(t('communication.conversation_rejected')); + onRefresh(); + } catch (error) { + toast.error('Error rejecting conversation'); + } + }; + + const handleClose = async () => { + try { + await apiPut(`/api/communication-rooms/${roomId}`, { status: 'closed', closeReason: 'Closed by teacher' }); + toast.success(t('communication.conversation_closed')); + fetchRoom(); + onRefresh(); + } catch (error) { + toast.error('Error closing conversation'); + } + }; + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mediaRecorder = new MediaRecorder(stream); + const audioChunks: BlobPart[] = []; + + mediaRecorder.ondataavailable = (event) => { + audioChunks.push(event.data); + }; + + mediaRecorder.onstop = async () => { + const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); + // In a real app, you'd upload this to a server + // For now, just send a text message indicating voice note + try { + await apiPost(`/api/communication-rooms/${roomId}/messages`, { + content: t('communication.voice_message'), + messageType: 'voice', + }); + fetchMessages(); + onRefresh(); + } catch { + toast.error(t('communication.error_send')); + } + stream.getTracks().forEach((track) => track.stop()); + }; + + mediaRecorderRef.current = mediaRecorder; + mediaRecorder.start(); + setIsRecording(true); + } catch { + toast.error('Microphone access denied'); + } + }; + + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + setIsRecording(false); + } + }; + + const isStudent = room?.studentId === currentUserId; + const otherPerson = isStudent ? room?.teacher : room?.student; + const isAdmin = role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL' || role === 'SUPER_ADMIN'; + + const formatTime = (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const isYesterday = date.toDateString() === yesterday.toDateString(); + + const time = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + if (isToday) return `${t('communication.today')} ${time}`; + if (isYesterday) return `${t('communication.yesterday')} ${time}`; + return `${date.toLocaleDateString()} ${time}`; + }; + + return ( + + {/* Chat Header */} +
+
+ {isMobile && onClose && ( + + )} +
+
+ +
+ {/* Online status */} +
+
+
+

+ {otherPerson ? `${otherPerson.firstName} ${otherPerson.lastName}` : 'Unknown'} +

+
+ + {room?.status === 'requested' ? t('communication.requested') : + room?.status === 'active' ? t('communication.active') : + t('communication.closed')} + + {isAdmin && ( + + + {t('communication.read_only')} + + )} +
+
+
+
+ {role === 'TEACHER' && room?.status === 'requested' && ( + <> + + + + )} + {role === 'TEACHER' && room?.status === 'active' && ( + <> + + + + )} +
+
+ + {/* Teacher Notes Panel */} + + {showTeacherNotes && ( + + +