Round 22: Exam Calendar, Tablet Grading, Student Achievements, Notification Center, Announcements, Styling

- Exam Calendar View: Teacher exam planning with calendar integration
  - Teachers create/modify exams, admin full CRUD, students read-only
  - CalendarEvent integration, 2-week warning, AI test availability badge
  - Monthly/weekly calendar view, list view, statistics

- Tablet Grading View: Stylus annotation and AI grading audit
  - HTML5 Canvas with PointerEvent pressure sensitivity
  - Tool palette: pen, highlighter, eraser, text, stamp
  - Quick grade buttons (German 1-6 scale)
  - AI grading audit with detailed feedback and suggestions

- Student Achievements View: Badges, XP, leaderboard
  - Badge collection grid with category filters
  - Level/XP system with progress bars
  - Leaderboard with opt-in
  - Daily/weekly challenges

- Student Study Planner View: Pomodoro timer, weekly schedule
  - Pomodoro timer with focus score
  - Weekly study schedule with drag-and-drop
  - AI study recommendations
  - Session history and statistics

- Notification Center View: Categories, filters, bulk actions
  - 6 categories, 4 priority levels
  - Real-time polling (10 seconds)
  - Preferences with quiet hours
  - Statistics charts

- School Announcements View: CRUD, read receipts
  - 6 announcement types with gradient headers
  - Pin-to-top, read receipts, target audience
  - Admin/teacher create, students/parents view

- Bug fixes: AnnouncementRead model user relation, User API schoolId field
- i18n: 200+ new keys, breadcrumb navigation fixes
- Styling: Gradient headers, animations, hover effects across all new views
This commit is contained in:
Mustafa Cetin committed 2026-07-29 22:56:40 +00:00
1 parent 9e22214470
commit 490bd0650f
30 files changed
+10844 -31

No files matched your search

@@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const review = await db.teacherGradingReview.findUnique({
where: { id },
include: {
assessment: { select: { id: true, title: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
comments: {
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
},
},
});
if (!review) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(review);
} catch (error) {
console.error('AI Grading Audit GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+133
View File
@@ -0,0 +1,133 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import ZAI from 'z-ai-web-dev-sdk';
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user?.role;
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { schoolId, assessmentId, studentId } = body;
if (!schoolId || !assessmentId || !studentId) {
return NextResponse.json({ error: 'schoolId, assessmentId, and studentId are required' }, { status: 400 });
}
// Get assessment and student data
const assessment = await db.assessment.findUnique({
where: { id: assessmentId },
include: {
results: {
where: { studentId },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
},
},
});
if (!assessment) {
return NextResponse.json({ error: 'Assessment not found' }, { status: 404 });
}
// Get annotations for this student's submission
const annotations = await db.gradingAnnotation.findMany({
where: { assessmentId, studentId },
orderBy: { createdAt: 'asc' },
});
const studentResult = assessment.results[0];
const student = studentResult?.student;
// Generate AI grading audit
const systemPrompt = `Du bist ein erfahrener Lehrer und Prüfer an einer deutschen Schule.
Du überprüfst die Benotung eines Schülers und gibst detailliertes Feedback.
Die deutsche Notenskala: 1 (sehr gut), 2 (gut), 3 (befriedigend), 4 (ausreichend), 5 (mangelhaft), 6 (ungenügend).
Analysiere die Leistung und die gegebene Benotung. Wenn die Benotung nicht angemessen ist, schlage eine Korrektur vor mit Begründung.
Antworte immer auf Deutsch und im JSON-Format.`;
const gradingInfo = {
studentName: student ? `${student.firstName} ${student.lastName}` : 'Unknown',
assessmentTitle: assessment.title,
assessmentType: assessment.type,
maxScore: assessment.maxScore,
score: studentResult?.score ?? null,
note: studentResult?.note ?? null,
annotationsCount: annotations.length,
annotationTypes: annotations.map((a: { type: string; content: string | null }) => ({ type: a.type, content: a.content })),
teacherAnnotations: annotations.filter((a: { type: string }) => a.type === 'stamp' || a.type === 'text').map((a: { type: string; content: string | null; color: string }) => ({ type: a.type, content: a.content, color: a.color })),
};
let reviewResult: string;
let discrepanciesFound = 0;
try {
const ai = await ZAI.create();
const response = await ai.chat.completions.create({
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: `Überprüfe die Benotung für diesen Schüler und gib Feedback:\n\n${JSON.stringify(gradingInfo, null, 2)}\n\nGib deine Antwort als JSON mit folgenden Feldern:\n- currentGrade: die aktuelle Benotung\n- suggestedGrade: deine vorgeschlagene Note\n- reason: detaillierte Begründung auf Deutsch\n- discrepancies: Liste der gefundenen Unstimmigkeiten\n- overallAssessment: Gesamtbewertung der Benotung`,
},
],
});
reviewResult = response?.choices?.[0]?.message?.content || 'Review could not be generated';
} catch (aiError) {
console.error('AI grading audit error:', aiError);
reviewResult = JSON.stringify({
currentGrade: studentResult?.score ?? 'N/A',
suggestedGrade: 'N/A',
reason: 'AI review could not be generated. Manual review recommended.',
discrepancies: ['AI analysis unavailable — please review manually'],
overallAssessment: 'AI audit could not be completed due to a technical error. Please review the grading manually.',
});
}
// Count discrepancies
try {
const parsed = typeof reviewResult === 'string' ? JSON.parse(reviewResult) : reviewResult;
discrepanciesFound = Array.isArray(parsed.discrepancies) ? parsed.discrepancies.length : 0;
} catch {
discrepanciesFound = 0;
}
// Create or update the grading review
const review = await db.teacherGradingReview.create({
data: {
schoolId,
assessmentId,
teacherId: session.userId,
aiProvider: 'pollination',
status: 'completed',
reviewResult,
discrepanciesFound,
},
include: {
assessment: { select: { id: true, title: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
comments: {
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
},
},
});
return NextResponse.json(review, { status: 201 });
} catch (error) {
console.error('AI Grading Audit POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,69 @@
// CompetenceTrack — Announcement Read Receipt API
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
// Verify announcement exists
const announcement = await db.announcement.findUnique({
where: { id, deletedAt: null },
});
if (!announcement) {
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
}
// Upsert read receipt
const readReceipt = await db.announcementRead.upsert({
where: {
announcementId_userId: {
announcementId: id,
userId: session.user.id,
},
},
update: {
readAt: new Date(),
},
create: {
announcementId: id,
userId: session.user.id,
},
});
return NextResponse.json({ readReceipt });
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
// Get all read receipts for this announcement
const reads = await db.announcementRead.findMany({
where: { announcementId: id },
include: {
user: {
select: { id: true, firstName: true, lastName: true },
},
},
orderBy: { readAt: 'desc' },
});
return NextResponse.json({ reads, totalReads: reads.length });
}
+25 -2
View File
@@ -5,11 +5,13 @@ import { getSession } from '@/lib/auth';
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']);
const announcementTypeEnum = z.enum(['general', 'urgent', 'event', 'holiday', 'exam', 'deadline']);
const updateAnnouncementSchema = z.object({
title: z.string().min(1).max(200).optional(),
content: z.string().min(1).max(10000).optional(),
priority: priorityEnum.optional(),
announcementType: announcementTypeEnum.optional(),
targetAudience: targetAudienceEnum.optional(),
classGroupId: z.string().optional().nullable(),
isPinned: z.boolean().optional(),
@@ -17,7 +19,7 @@ const updateAnnouncementSchema = z.object({
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
}
export async function GET(
@@ -36,6 +38,12 @@ export async function GET(
include: {
author: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
reads: {
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { readAt: 'desc' },
},
},
});
@@ -43,7 +51,21 @@ export async function GET(
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
}
return NextResponse.json(announcement);
// Check if current user has read it
const readReceipt = await db.announcementRead.findUnique({
where: {
announcementId_userId: {
announcementId: id,
userId: session.userId,
},
},
});
return NextResponse.json({
...announcement,
isReadByCurrentUser: !!readReceipt,
totalReads: announcement.reads.length,
});
} catch (error) {
console.error('Announcement GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -87,6 +109,7 @@ export async function PUT(
if (parsed.data.title !== undefined) updateData.title = parsed.data.title;
if (parsed.data.content !== undefined) updateData.content = parsed.data.content;
if (parsed.data.priority !== undefined) updateData.priority = parsed.data.priority;
if (parsed.data.announcementType !== undefined) updateData.announcementType = parsed.data.announcementType;
if (parsed.data.targetAudience !== undefined) updateData.targetAudience = parsed.data.targetAudience;
if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId || null;
if (parsed.data.isPinned !== undefined) updateData.isPinned = parsed.data.isPinned;
+51 -3
View File
@@ -5,12 +5,14 @@ import { getSession } from '@/lib/auth';
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']);
const announcementTypeEnum = z.enum(['general', 'urgent', 'event', 'holiday', 'exam', 'deadline']);
const createAnnouncementSchema = z.object({
schoolId: z.string().min(1),
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
priority: priorityEnum.optional().default('normal'),
announcementType: announcementTypeEnum.optional().default('general'),
targetAudience: targetAudienceEnum.optional().default('all'),
classGroupId: z.string().optional().nullable(),
isPinned: z.boolean().optional().default(false),
@@ -18,7 +20,7 @@ const createAnnouncementSchema = z.object({
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
}
export async function GET(request: Request) {
@@ -34,10 +36,12 @@ export async function GET(request: Request) {
const targetAudience = searchParams.get('targetAudience');
const classGroupId = searchParams.get('classGroupId');
const isPinned = searchParams.get('isPinned');
const announcementType = searchParams.get('announcementType');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const includeReads = searchParams.get('includeReads') === 'true';
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
if (session.user?.role === 'SCHOOL_ADMIN' || session.user?.role === 'VICE_PRINCIPAL') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
@@ -59,6 +63,7 @@ export async function GET(request: Request) {
if (targetAudience && targetAudience !== 'all') where.targetAudience = targetAudience;
if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId;
if (isPinned === 'true') where.isPinned = true;
if (announcementType && announcementType !== 'all') where.announcementType = announcementType;
// Filter by role-based target audience
if (session.user?.role === 'STUDENT') {
@@ -83,10 +88,52 @@ export async function GET(request: Request) {
include: {
author: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
...(includeReads ? {
reads: {
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { readAt: 'desc' as const },
},
} : {}),
},
});
return NextResponse.json(announcements);
// Add read status for the current user
const currentUserId = session.userId;
const announcementsWithReadStatus = await Promise.all(
announcements.map(async (a) => {
if (!includeReads) {
const readReceipt = await db.announcementRead.findUnique({
where: {
announcementId_userId: {
announcementId: a.id,
userId: currentUserId,
},
},
});
return {
...a,
isReadByCurrentUser: !!readReceipt,
};
}
const readReceipt = await db.announcementRead.findUnique({
where: {
announcementId_userId: {
announcementId: a.id,
userId: currentUserId,
},
},
});
return {
...a,
isReadByCurrentUser: !!readReceipt,
totalReads: a.reads?.length ?? 0,
};
})
);
return NextResponse.json(announcementsWithReadStatus);
} catch (error) {
console.error('Announcements GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -138,6 +185,7 @@ export async function POST(request: Request) {
title: rest.title,
content: rest.content,
priority: rest.priority,
announcementType: rest.announcementType,
targetAudience: rest.targetAudience,
classGroupId: classGroupId || null,
isPinned: rest.isPinned,
+200
View File
@@ -0,0 +1,200 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: Get single exam plan ──────────────────────────────────────────
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const examPlan = await db.examPlan.findUnique({
where: { id },
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
calendarEvent: { select: { id: true } },
assessment: { select: { id: true, title: true } },
},
});
if (!examPlan) {
return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 });
}
// Role-based access check
if (
session.user?.role === 'STUDENT' ||
session.user?.role === 'PARENT'
) {
// Students and parents can view but not modify
} else if (session.user?.role === 'TEACHER') {
// Teachers can only view their own exams
if (examPlan.teacherId !== session.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
return NextResponse.json(examPlan);
} catch (error) {
console.error('ExamPlan GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── PUT: Update exam plan ──────────────────────────────────────────────
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const userRole = session.user?.role;
if (
userRole !== 'TEACHER' &&
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const existing = await db.examPlan.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 });
}
// Teachers can only edit their own exams
if (userRole === 'TEACHER' && existing.teacherId !== session.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
title,
date,
startTime,
endTime,
subjectId,
classGroupId,
room,
topics,
weight,
status,
notes,
} = body;
const updateData: Record<string, unknown> = {};
if (title !== undefined) updateData.title = title;
if (date !== undefined) updateData.date = new Date(date);
if (startTime !== undefined) updateData.startTime = startTime || null;
if (endTime !== undefined) updateData.endTime = endTime || null;
if (subjectId !== undefined) updateData.subjectId = subjectId;
if (classGroupId !== undefined) updateData.classGroupId = classGroupId;
if (room !== undefined) updateData.room = room || null;
if (topics !== undefined) updateData.topics = topics ? JSON.stringify(topics) : null;
if (weight !== undefined) updateData.weight = weight;
if (status !== undefined) updateData.status = status;
if (notes !== undefined) updateData.notes = notes || null;
const updated = await db.examPlan.update({
where: { id },
data: updateData,
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
calendarEvent: { select: { id: true } },
assessment: { select: { id: true, title: true } },
},
});
// Also update the linked CalendarEvent
if (existing.calendarEventId) {
const calendarUpdateData: Record<string, unknown> = {};
if (title !== undefined) calendarUpdateData.title = `[Exam] ${title}`;
if (date !== undefined) calendarUpdateData.date = new Date(date);
if (startTime !== undefined) calendarUpdateData.startTime = startTime || null;
if (endTime !== undefined) calendarUpdateData.endTime = endTime || null;
if (subjectId !== undefined) calendarUpdateData.subjectId = subjectId;
if (classGroupId !== undefined) calendarUpdateData.classGroupId = classGroupId;
if (notes !== undefined) calendarUpdateData.notes = notes || null;
if (startTime !== undefined || endTime !== undefined) {
calendarUpdateData.allDay = !startTime && !endTime;
}
if (Object.keys(calendarUpdateData).length > 0) {
await db.calendarEvent.update({
where: { id: existing.calendarEventId },
data: calendarUpdateData,
});
}
}
return NextResponse.json(updated);
} catch (error) {
console.error('ExamPlan PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── DELETE: Delete exam plan ───────────────────────────────────────────
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const userRole = session.user?.role;
if (
userRole !== 'TEACHER' &&
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const existing = await db.examPlan.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 });
}
// Teachers can only delete their own exams
if (userRole === 'TEACHER' && existing.teacherId !== session.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Delete the linked CalendarEvent first
if (existing.calendarEventId) {
await db.calendarEvent.delete({ where: { id: existing.calendarEventId } }).catch(() => {});
}
await db.examPlan.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('ExamPlan DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+316
View File
@@ -0,0 +1,316 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { differenceInCalendarDays } from 'date-fns';
// ── GET: List exam plans with filters ──────────────────────────────────
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
const classId = searchParams.get('classId');
const subjectId = searchParams.get('subjectId');
const teacherId = searchParams.get('teacherId');
const status = searchParams.get('status');
const dateFrom = searchParams.get('dateFrom');
const dateTo = searchParams.get('dateTo');
const viewMode = searchParams.get('viewMode') ?? 'all'; // all, upcoming, past
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
const where: Record<string, unknown> = { schoolId };
if (classId) where.classGroupId = classId;
if (subjectId) where.subjectId = subjectId;
if (teacherId) where.teacherId = teacherId;
if (status) where.status = status;
// Date filters
const dateFilter: Record<string, Date> = {};
if (dateFrom) dateFilter.gte = new Date(dateFrom);
if (dateTo) dateFilter.lte = new Date(dateTo);
if (Object.keys(dateFilter).length > 0) where.date = dateFilter;
// View mode filters
const now = new Date();
if (viewMode === 'upcoming') {
where.date = { ...((where.date as Record<string, Date>) || {}), gte: now };
} else if (viewMode === 'past') {
where.date = { ...((where.date as Record<string, Date>) || {}), lt: now };
}
// Role-based access
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { userId: session.userId },
select: { id: true },
});
if (student) {
const enrollments = await db.enrollment.findMany({
where: { studentId: student.id },
select: { classGroupId: true },
});
const classIds = enrollments.map((e) => e.classGroupId);
where.classGroupId = { in: classIds };
}
} else if (session.user?.role === 'PARENT') {
const parentLinks = await db.parentStudentLink.findMany({
where: { parentId: session.userId },
select: { studentId: true },
});
const studentIds = parentLinks.map((l) => l.studentId);
const enrollments = await db.enrollment.findMany({
where: { studentId: { in: studentIds } },
select: { classGroupId: true },
});
const classIds = enrollments.map((e) => e.classGroupId);
where.classGroupId = { in: classIds };
} else if (session.user?.role === 'TEACHER') {
// Teachers see their own exams by default, or all if explicitly requested
if (!teacherId) {
where.teacherId = session.userId;
}
}
const examPlans = await db.examPlan.findMany({
where,
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
calendarEvent: { select: { id: true } },
assessment: { select: { id: true, title: true } },
},
orderBy: { date: 'asc' },
});
// Add computed fields
const enriched = examPlans.map((plan) => {
const daysUntil = differenceInCalendarDays(new Date(plan.date), now);
const isWithinTwoWeeks = daysUntil >= 0 && daysUntil <= 14;
return {
...plan,
daysUntil,
isWithinTwoWeeks,
countdownLabel:
daysUntil === 0
? 'today'
: daysUntil === 1
? 'tomorrow'
: daysUntil < 7
? `${daysUntil} days`
: daysUntil < 14
? `${Math.floor(daysUntil / 7)} week`
: `${Math.floor(daysUntil / 7)} weeks`,
};
});
// Check for AI test availability for each exam
const examIdsWithTests = await db.aITestGeneration.findMany({
where: {
classGroupId: { in: enriched.map((e) => e.classGroupId) },
subjectId: { in: enriched.map((e) => e.subjectId) },
isCompleted: true,
},
select: { classGroupId: true, subjectId: true },
});
const testSet = new Set(
examIdsWithTests.map((t) => `${t.classGroupId}-${t.subjectId}`)
);
const result = enriched.map((plan) => ({
...plan,
hasAITest: testSet.has(`${plan.classGroupId}-${plan.subjectId}`),
}));
// Statistics (for admin view)
let stats = null;
if (
session.user?.role === 'SCHOOL_ADMIN' ||
session.user?.role === 'VICE_PRINCIPAL' ||
session.user?.role === 'SUPER_ADMIN'
) {
const totalExams = await db.examPlan.count({ where: { schoolId } });
const upcomingExams = await db.examPlan.count({
where: { schoolId, date: { gte: now } },
});
const plannedExams = await db.examPlan.count({
where: { schoolId, status: 'planned' },
});
const confirmedExams = await db.examPlan.count({
where: { schoolId, status: 'confirmed' },
});
const completedExams = await db.examPlan.count({
where: { schoolId, status: 'completed' },
});
const cancelledExams = await db.examPlan.count({
where: { schoolId, status: 'cancelled' },
});
// Exams per subject
const examsPerSubject = await db.examPlan.groupBy({
by: ['subjectId'],
where: { schoolId },
_count: { id: true },
});
const subjectNames = await db.subject.findMany({
where: {
id: { in: examsPerSubject.map((e) => e.subjectId) },
},
select: { id: true, name: true },
});
const subjectMap = Object.fromEntries(
subjectNames.map((s) => [s.id, s.name])
);
// Exams per class
const examsPerClass = await db.examPlan.groupBy({
by: ['classGroupId'],
where: { schoolId },
_count: { id: true },
});
const classNames = await db.classGroup.findMany({
where: {
id: { in: examsPerClass.map((e) => e.classGroupId) },
},
select: { id: true, name: true },
});
const classMap = Object.fromEntries(
classNames.map((c) => [c.id, c.name])
);
stats = {
totalExams,
upcomingExams,
byStatus: { planned: plannedExams, confirmed: confirmedExams, completed: completedExams, cancelled: cancelledExams },
bySubject: examsPerSubject.map((e) => ({
subjectId: e.subjectId,
subjectName: subjectMap[e.subjectId] || e.subjectId,
count: e._count.id,
})),
byClass: examsPerClass.map((e) => ({
classGroupId: e.classGroupId,
className: classMap[e.classGroupId] || e.classGroupId,
count: e._count.id,
})),
};
}
return NextResponse.json({ exams: result, stats });
} catch (error) {
console.error('ExamPlans GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── POST: Create an exam plan ──────────────────────────────────────────
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'TEACHER' &&
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
title,
date,
startTime,
endTime,
subjectId,
classGroupId,
schoolId,
room,
topics,
weight,
status,
notes,
} = body;
if (!title || !date || !schoolId || !subjectId || !classGroupId) {
return NextResponse.json(
{ error: 'title, date, schoolId, subjectId, and classGroupId are required' },
{ status: 400 }
);
}
// Create the exam plan
const examPlan = await db.examPlan.create({
data: {
schoolId,
teacherId: session.userId,
subjectId,
classGroupId,
title,
date: new Date(date),
startTime: startTime || null,
endTime: endTime || null,
room: room || null,
topics: topics ? JSON.stringify(topics) : null,
weight: weight ?? 1.0,
status: status || 'planned',
notes: notes || null,
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
// Also create a CalendarEvent for the exam
const calendarEvent = await db.calendarEvent.create({
data: {
schoolId,
teacherId: session.userId,
title: `[Exam] ${title}`,
date: new Date(date),
startTime: startTime || null,
endTime: endTime || null,
eventType: 'exam',
subjectId,
classGroupId,
notes: notes || null,
allDay: !startTime && !endTime,
},
});
// Link the calendar event to the exam plan
await db.examPlan.update({
where: { id: examPlan.id },
data: { calendarEventId: calendarEvent.id },
});
const result = {
...examPlan,
calendarEventId: calendarEvent.id,
};
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error('ExamPlans POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,129 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const updateAnnotationSchema = z.object({
type: z.enum(['drawing', 'text', 'highlight', 'stamp']).optional(),
content: z.string().optional(),
positionX: z.number().optional(),
positionY: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
color: z.string().optional(),
strokeWidth: z.number().optional(),
page: z.number().optional(),
pathData: z.string().optional(),
});
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const annotation = await db.gradingAnnotation.findUnique({
where: { id },
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!annotation) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(annotation);
} catch (error) {
console.error('GradingAnnotation GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user?.role;
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateAnnotationSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.gradingAnnotation.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
// Only the creator or admin can update
if (existing.teacherId !== session.userId && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const annotation = await db.gradingAnnotation.update({
where: { id },
data: parsed.data,
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(annotation);
} catch (error) {
console.error('GradingAnnotation PUT [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user?.role;
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.gradingAnnotation.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (existing.teacherId !== session.userId && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.gradingAnnotation.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('GradingAnnotation DELETE [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createAnnotationSchema = z.object({
schoolId: z.string().min(1),
assessmentId: z.string().min(1),
studentId: z.string().min(1),
resultId: z.string().optional(),
type: z.enum(['drawing', 'text', 'highlight', 'stamp']).default('drawing'),
content: z.string().optional(),
positionX: z.number().default(0),
positionY: z.number().default(0),
width: z.number().optional(),
height: z.number().optional(),
color: z.string().default('#ef4444'),
strokeWidth: z.number().default(2),
page: z.number().default(1),
pathData: z.string().optional(),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const assessmentId = searchParams.get('assessmentId');
const studentId = searchParams.get('studentId');
const page = searchParams.get('page');
if (!assessmentId) {
return NextResponse.json({ error: 'assessmentId is required' }, { status: 400 });
}
const where: Record<string, unknown> = { assessmentId };
if (studentId) where.studentId = studentId;
if (page) where.page = parseInt(page, 10);
const annotations = await db.gradingAnnotation.findMany({
where,
orderBy: { createdAt: 'asc' },
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(annotations);
} catch (error) {
console.error('GradingAnnotations GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user?.role;
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createAnnotationSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const data = parsed.data;
const annotation = await db.gradingAnnotation.create({
data: {
schoolId: data.schoolId,
assessmentId: data.assessmentId,
studentId: data.studentId,
resultId: data.resultId,
teacherId: session.userId,
type: data.type,
content: data.content,
positionX: data.positionX,
positionY: data.positionY,
width: data.width,
height: data.height,
color: data.color,
strokeWidth: data.strokeWidth,
page: data.page,
pathData: data.pathData,
},
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(annotation, { status: 201 });
} catch (error) {
console.error('GradingAnnotations POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+103
View File
@@ -0,0 +1,103 @@
// CompetenceTrack — Single Notification API
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const notification = await db.notification.findFirst({
where: { id, userId: session.user.id, deletedAt: null },
});
if (!notification) {
return NextResponse.json({ error: 'Notification not found' }, { status: 404 });
}
return NextResponse.json(notification);
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { id } = await params;
const body = await request.json();
const { isRead, isArchived, action } = body as {
isRead?: boolean;
isArchived?: boolean;
action?: 'read' | 'unread' | 'archive' | 'unarchive' | 'dismiss';
};
const existing = await db.notification.findFirst({
where: { id, userId: session.user.id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Notification not found' }, { status: 404 });
}
const updateData: Record<string, unknown> = {};
if (action === 'read' || isRead === true) {
updateData.isRead = true;
} else if (action === 'unread' || isRead === false) {
updateData.isRead = false;
}
if (action === 'archive' || isArchived === true) {
updateData.isArchived = true;
} else if (action === 'unarchive' || isArchived === false) {
updateData.isArchived = false;
}
if (action === 'dismiss') {
updateData.deletedAt = new Date();
}
const notification = await db.notification.update({
where: { id },
data: updateData,
});
return NextResponse.json({ notification });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const existing = await db.notification.findFirst({
where: { id, userId: session.user.id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Notification not found' }, { status: 404 });
}
await db.notification.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
}
@@ -0,0 +1,100 @@
// CompetenceTrack — Notification Preferences API
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET() {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const schoolId = session.user.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'No school assigned' }, { status: 400 });
}
let prefs = await db.notificationPreferences.findUnique({
where: { userId: session.user.id },
});
// Create default preferences if none exist
if (!prefs) {
prefs = await db.notificationPreferences.create({
data: {
userId: session.user.id,
schoolId,
},
});
}
return NextResponse.json(prefs);
}
export async function PUT(request: Request) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const schoolId = session.user.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'No school assigned' }, { status: 400 });
}
try {
const body = await request.json();
const {
academicEnabled,
behavioralEnabled,
administrativeEnabled,
calendarEnabled,
communicationEnabled,
systemEnabled,
quietHoursStart,
quietHoursEnd,
quietHoursEnabled,
emailDigestEnabled,
emailDigestFrequency,
} = body as {
academicEnabled?: boolean;
behavioralEnabled?: boolean;
administrativeEnabled?: boolean;
calendarEnabled?: boolean;
communicationEnabled?: boolean;
systemEnabled?: boolean;
quietHoursStart?: string | null;
quietHoursEnd?: string | null;
quietHoursEnabled?: boolean;
emailDigestEnabled?: boolean;
emailDigestFrequency?: string;
};
const updateData: Record<string, unknown> = {};
if (academicEnabled !== undefined) updateData.academicEnabled = academicEnabled;
if (behavioralEnabled !== undefined) updateData.behavioralEnabled = behavioralEnabled;
if (administrativeEnabled !== undefined) updateData.administrativeEnabled = administrativeEnabled;
if (calendarEnabled !== undefined) updateData.calendarEnabled = calendarEnabled;
if (communicationEnabled !== undefined) updateData.communicationEnabled = communicationEnabled;
if (systemEnabled !== undefined) updateData.systemEnabled = systemEnabled;
if (quietHoursStart !== undefined) updateData.quietHoursStart = quietHoursStart;
if (quietHoursEnd !== undefined) updateData.quietHoursEnd = quietHoursEnd;
if (quietHoursEnabled !== undefined) updateData.quietHoursEnabled = quietHoursEnabled;
if (emailDigestEnabled !== undefined) updateData.emailDigestEnabled = emailDigestEnabled;
if (emailDigestFrequency !== undefined) updateData.emailDigestFrequency = emailDigestFrequency;
const prefs = await db.notificationPreferences.upsert({
where: { userId: session.user.id },
update: updateData,
create: {
userId: session.user.id,
schoolId,
...updateData,
},
});
return NextResponse.json(prefs);
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
+140 -23
View File
@@ -1,9 +1,9 @@
// CompetenceTrack — Notifications API (DB-backed)
// CompetenceTrack — Notifications API (DB-backed, Enhanced)
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET() {
export async function GET(request: Request) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -19,18 +19,54 @@ export async function GET() {
});
}
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const priority = searchParams.get('priority');
const isRead = searchParams.get('isRead');
const isArchived = searchParams.get('isArchived');
const dateFrom = searchParams.get('dateFrom');
const dateTo = searchParams.get('dateTo');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const offset = parseInt(searchParams.get('offset') || '0', 10);
// Build where clause for DB notifications
const where: Record<string, unknown> = {
userId,
deletedAt: null,
};
if (category && category !== 'all') where.category = category;
if (priority && priority !== 'all') where.priority = priority;
if (isRead === 'true') where.isRead = true;
else if (isRead === 'false') where.isRead = false;
if (isArchived === 'true') where.isArchived = true;
else if (isArchived === 'false') where.isArchived = false;
else if (!isArchived) where.isArchived = false; // default: not archived
if (dateFrom || dateTo) {
const dateFilter: Record<string, unknown> = {};
if (dateFrom) dateFilter.gte = new Date(dateFrom);
if (dateTo) dateFilter.lte = new Date(dateTo);
where.createdAt = dateFilter;
}
// Fetch DB notifications for the current user
const notifications = await db.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
});
const unreadCount = await db.notification.count({
where: {
userId,
deletedAt: null,
isRead: false,
isArchived: false,
},
orderBy: { createdAt: 'desc' },
take: 50,
});
const unreadCount = notifications.filter((n) => !n.isRead).length;
// Also generate dynamic notifications from existing data
const now = new Date();
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
@@ -71,12 +107,15 @@ export async function GET() {
}
// Upcoming assessments (next 7 days)
const upcomingAssessments: Array<{
const dynamicNotifications: Array<{
id: string;
type: string;
category: string;
priority: string;
title: string;
message: string;
isRead: boolean;
isArchived: boolean;
actionUrl: string | null;
relatedId: string | null;
createdAt: string;
@@ -94,26 +133,25 @@ export async function GET() {
include: {
classGroup: { select: { name: true } },
subject: { select: { name: true } },
assessmentResults: { select: { id: true } },
},
orderBy: { date: 'asc' },
take: 10,
});
for (const a of assessments) {
const diffMs = new Date(a.date).getTime() - now.getTime();
const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
// Check if we already have a DB notification for this assessment
const existing = notifications.find(
(n) => n.type === 'ASSESSMENT_DUE' && n.relatedId === a.id
);
if (!existing) {
upcomingAssessments.push({
dynamicNotifications.push({
id: `assessment-${a.id}`,
type: 'ASSESSMENT_DUE',
category: 'academic',
priority: 'high',
title: a.title,
message: `${a.classGroup.name} - ${a.subject.name}`,
isRead: false,
isArchived: false,
actionUrl: 'assessments',
relatedId: a.id,
createdAt: new Date().toISOString(),
@@ -153,12 +191,15 @@ export async function GET() {
(n) => n.type === 'MISSING_OBSERVATION' && n.relatedId === e.student.id
);
if (!existing) {
upcomingAssessments.push({
dynamicNotifications.push({
id: `missing-${e.student.id}-${e.classGroupId}`,
type: 'MISSING_OBSERVATION',
category: 'academic',
priority: 'normal',
title: `${e.student.firstName} ${e.student.lastName}`,
message: e.classGroup.name,
isRead: false,
isArchived: false,
actionUrl: 'progress',
relatedId: e.student.id,
createdAt: new Date().toISOString(),
@@ -173,19 +214,41 @@ export async function GET() {
const dbNotifications = notifications.map((n) => ({
id: n.id,
type: n.type,
category: n.category,
priority: n.priority,
title: n.title,
message: n.message,
isRead: n.isRead,
isArchived: n.isArchived,
actionUrl: n.actionUrl,
relatedId: n.relatedId,
createdAt: n.createdAt.toISOString(),
}));
const allNotifications = [...dbNotifications, ...upcomingAssessments];
const allNotifications = [...dbNotifications, ...dynamicNotifications];
// Get statistics
const totalByCategory = await db.notification.groupBy({
by: ['category'],
where: {
userId,
deletedAt: null,
isArchived: false,
},
_count: { id: true },
});
const stats = {
byCategory: totalByCategory.map((item) => ({
category: item.category,
count: item._count.id,
})),
};
return NextResponse.json({
notifications: allNotifications,
unreadCount: allNotifications.filter((n) => !n.isRead).length,
unreadCount,
stats,
});
}
@@ -197,10 +260,15 @@ export async function PUT(request: Request) {
try {
const body = await request.json();
const { ids, markAll } = body as { ids?: string[]; markAll?: boolean };
const { ids, markAll, action, archiveAll, deleteIds } = body as {
ids?: string[];
markAll?: boolean;
action?: 'read' | 'unread' | 'archive' | 'unarchive';
archiveAll?: boolean;
deleteIds?: string[];
};
if (markAll) {
// Mark all notifications as read
await db.notification.updateMany({
where: {
userId: session.user.id,
@@ -212,16 +280,48 @@ export async function PUT(request: Request) {
return NextResponse.json({ success: true });
}
if (archiveAll) {
await db.notification.updateMany({
where: {
userId: session.user.id,
deletedAt: null,
isArchived: false,
},
data: { isArchived: true },
});
return NextResponse.json({ success: true });
}
if (ids && Array.isArray(ids)) {
// Mark specific DB notifications as read
const dbIds = ids.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-'));
if (dbIds.length > 0) {
const updateData: Record<string, unknown> = {};
if (action === 'read') updateData.isRead = true;
else if (action === 'unread') updateData.isRead = false;
else if (action === 'archive') updateData.isArchived = true;
else if (action === 'unarchive') updateData.isArchived = false;
else updateData.isRead = true; // default: mark read
await db.notification.updateMany({
where: {
id: { in: dbIds },
userId: session.user.id,
},
data: updateData,
});
}
return NextResponse.json({ success: true });
}
if (deleteIds && Array.isArray(deleteIds)) {
const dbIds = deleteIds.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-'));
if (dbIds.length > 0) {
await db.notification.updateMany({
where: {
id: { in: dbIds },
userId: session.user.id,
},
data: { isRead: true },
data: { deletedAt: new Date() },
});
}
return NextResponse.json({ success: true });
@@ -241,10 +341,12 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { schoolId, userId, type, title, message, actionUrl, relatedId } = body as {
const { schoolId, userId, type, category, priority, title, message, actionUrl, relatedId } = body as {
schoolId: string;
userId: string;
type: string;
category?: string;
priority?: string;
title: string;
message: string;
actionUrl?: string;
@@ -255,11 +357,27 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Determine category from type if not provided
const categoryMap: Record<string, string> = {
ASSESSMENT_DUE: 'academic',
MISSING_OBSERVATION: 'academic',
NOTEBOOK_SHARED: 'academic',
BEHAVIOR_ALERT: 'behavioral',
GRADE_COMPUTED: 'academic',
ATTENDANCE_ALERT: 'administrative',
REPORT_READY: 'administrative',
CALENDAR_EVENT: 'calendar',
COMMUNICATION: 'communication',
SYSTEM: 'system',
};
const notification = await db.notification.create({
data: {
schoolId,
userId,
type,
category: category || categoryMap[type] || 'system',
priority: priority || 'normal',
title,
message,
actionUrl: actionUrl ?? null,
@@ -284,16 +402,15 @@ export async function DELETE(request: Request) {
const { ids } = body as { ids?: string[] };
if (ids && Array.isArray(ids)) {
// Soft-delete specific notifications
const dbIds = ids.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-'));
await db.notification.updateMany({
where: {
id: { in: ids },
id: { in: dbIds },
userId: session.user.id,
},
data: { deletedAt: new Date() },
});
} else {
// Soft-delete all notifications for the user
await db.notification.updateMany({
where: {
userId: session.user.id,
+269
View File
@@ -0,0 +1,269 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
const studentIdParam = searchParams.get('studentId');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) return NextResponse.json([]);
// If student is requesting their own data
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { userId: session.userId, schoolId, deletedAt: null },
select: { id: true },
});
if (!student) return NextResponse.json({ badges: [], character: null, leaderboard: [] });
const studentId = student.id;
// Get student's earned badges
const studentBadges = await db.studentBadge.findMany({
where: { schoolId, studentId },
orderBy: { awardedAt: 'desc' },
include: {
badge: true,
awardedByUser: { select: { id: true, firstName: true, lastName: true } },
},
});
// Get all available badges for the school
const allBadges = await db.badge.findMany({
where: { schoolId, deletedAt: null },
orderBy: { category: 'asc' },
});
// Get virtual character
const character = await db.virtualCharacter.findUnique({
where: { userId: session.userId },
});
// Get class-level leaderboard (students in same class, ordered by XP)
const enrollment = await db.enrollment.findFirst({
where: { studentId, endDate: null },
select: { classGroupId: true },
});
let leaderboard: Array<{ studentId: string; firstName: string; lastName: string; xp: number; level: number; badgeCount: number }> = [];
if (enrollment) {
const classStudents = await db.enrollment.findMany({
where: { classGroupId: enrollment.classGroupId, endDate: null },
select: { studentId: true },
});
const classStudentIds = classStudents.map(e => e.studentId);
const studentData = await db.student.findMany({
where: { id: { in: classStudentIds }, deletedAt: null },
select: {
id: true,
firstName: true,
lastName: true,
userId: true,
studentBadges: { select: { id: true } },
},
});
const charData = await db.virtualCharacter.findMany({
where: { userId: { in: studentData.map(s => s.userId).filter(Boolean) as string[] } },
select: { userId: true, xp: true, level: true },
});
const charMap = new Map(charData.map(c => [c.userId, c]));
leaderboard = studentData
.map(s => {
const char = s.userId ? charMap.get(s.userId) : null;
return {
studentId: s.id,
firstName: s.firstName,
lastName: s.lastName,
xp: char?.xp ?? 0,
level: char?.level ?? 1,
badgeCount: s.studentBadges.length,
};
})
.sort((a, b) => b.xp - a.xp)
.slice(0, 20);
}
return NextResponse.json({
badges: allBadges.map(b => ({
...b,
earned: studentBadges.some(sb => sb.badgeId === b.id),
earnedAt: studentBadges.find(sb => sb.badgeId === b.id)?.awardedAt ?? null,
})),
earnedBadges: studentBadges,
character,
leaderboard,
totalXP: character?.xp ?? 0,
level: character?.level ?? 1,
});
}
// Teacher/Admin view
if (studentIdParam) {
const studentBadges = await db.studentBadge.findMany({
where: { schoolId, studentId: studentIdParam },
orderBy: { awardedAt: 'desc' },
include: {
badge: true,
student: { select: { id: true, firstName: true, lastName: true } },
awardedByUser: { select: { id: true, firstName: true, lastName: true } },
},
});
const allBadges = await db.badge.findMany({
where: { schoolId, deletedAt: null },
orderBy: { category: 'asc' },
});
return NextResponse.json({
badges: allBadges.map(b => ({
...b,
earned: studentBadges.some(sb => sb.badgeId === b.id),
earnedAt: studentBadges.find(sb => sb.badgeId === b.id)?.awardedAt ?? null,
})),
earnedBadges: studentBadges,
});
}
// Return all badges for school
const allBadges = await db.badge.findMany({
where: { schoolId, deletedAt: null },
orderBy: { category: 'asc' },
});
return NextResponse.json({ badges: allBadges });
} catch (error) {
console.error('StudentAchievements GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const body = await request.json();
const { action, schoolId, studentId, badgeId, xpAmount, characterData } = body;
if (action === 'award_badge') {
if (!schoolId || !studentId || !badgeId) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Check if already awarded
const existing = await db.studentBadge.findUnique({
where: { studentId_badgeId: { studentId, badgeId } },
});
if (existing) return NextResponse.json({ error: 'Badge already awarded' }, { status: 409 });
const badge = await db.badge.findUnique({ where: { id: badgeId, deletedAt: null } });
if (!badge) return NextResponse.json({ error: 'Badge not found' }, { status: 404 });
const studentBadge = await db.studentBadge.create({
data: {
schoolId,
studentId,
badgeId,
awardedBy: session.userId,
},
include: { badge: true },
});
// Award XP for badge
const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } });
if (student?.userId) {
await upsertVirtualCharacter(student.userId, schoolId, 50);
}
return NextResponse.json(studentBadge, { status: 201 });
}
if (action === 'add_xp') {
if (!schoolId || !studentId || xpAmount === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } });
if (!student?.userId) return NextResponse.json({ error: 'Student has no user account' }, { status: 400 });
const character = await upsertVirtualCharacter(student.userId, schoolId, xpAmount);
return NextResponse.json(character);
}
if (action === 'update_character') {
if (!characterData) {
return NextResponse.json({ error: 'Missing characterData' }, { status: 400 });
}
const character = await db.virtualCharacter.upsert({
where: { userId: session.userId },
update: {
characterId: characterData.characterId ?? undefined,
name: characterData.name ?? undefined,
color: characterData.color ?? undefined,
mood: characterData.mood ?? undefined,
},
create: {
userId: session.userId,
schoolId: schoolId ?? session.user?.schoolId ?? '',
characterId: characterData.characterId ?? 'owl',
name: characterData.name ?? 'My Companion',
color: characterData.color ?? '#10b981',
mood: characterData.mood ?? 'happy',
},
});
return NextResponse.json(character);
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} catch (error) {
console.error('StudentAchievements POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
async function upsertVirtualCharacter(userId: string, schoolId: string, xpGain: number) {
const existing = await db.virtualCharacter.findUnique({ where: { userId } });
if (existing) {
const newXp = existing.xp + xpGain;
const newLevel = Math.floor(newXp / 100) + 1;
return db.virtualCharacter.update({
where: { userId },
data: {
xp: newXp,
level: newLevel,
mood: newLevel > existing.level ? 'celebrating' : 'happy',
},
});
}
return db.virtualCharacter.create({
data: {
userId,
schoolId,
characterId: 'owl',
name: 'My Companion',
xp: xpGain,
level: Math.floor(xpGain / 100) + 1,
mood: 'happy',
},
});
}
@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const { id } = await params;
const body = await request.json();
// Verify the plan belongs to the student
const existing = await db.studyPlan.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { userId: session.userId, schoolId: existing.schoolId, deletedAt: null },
select: { id: true },
});
if (!student || student.id !== existing.studentId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const plan = await db.studyPlan.update({
where: { id },
data: {
title: body.title ?? undefined,
description: body.description ?? undefined,
subjectId: body.subjectId ?? undefined,
subjectName: body.subjectName ?? undefined,
dayOfWeek: body.dayOfWeek ?? undefined,
startTime: body.startTime ?? undefined,
duration: body.duration ?? undefined,
isRecurring: body.isRecurring ?? undefined,
specificDate: body.specificDate ? new Date(body.specificDate) : undefined,
priority: body.priority ?? undefined,
status: body.status ?? undefined,
color: body.color ?? undefined,
notes: body.notes ?? undefined,
},
include: {
subject: { select: { id: true, name: true } },
},
});
return NextResponse.json(plan);
} catch (error) {
console.error('StudyPlan PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const { id } = await params;
const existing = await db.studyPlan.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { userId: session.userId, schoolId: existing.schoolId, deletedAt: null },
select: { id: true },
});
if (!student || student.id !== existing.studentId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
// Soft delete by setting status to cancelled
await db.studyPlan.update({
where: { id },
data: { status: 'cancelled' },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('StudyPlan DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+166
View File
@@ -0,0 +1,166 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
const studentIdParam = searchParams.get('studentId');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) return NextResponse.json([]);
let studentId = studentIdParam;
// If student is requesting their own data
if (session.user?.role === 'STUDENT' && !studentId) {
const student = await db.student.findFirst({
where: { userId: session.userId, schoolId, deletedAt: null },
select: { id: true },
});
if (!student) return NextResponse.json([]);
studentId = student.id;
}
if (!studentId) return NextResponse.json([]);
// Verify access
if (session.user?.role === 'STUDENT') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { userId: true },
});
if (!student || student.userId !== session.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const studyPlans = await db.studyPlan.findMany({
where: { schoolId, studentId, status: 'active' },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
include: {
subject: { select: { id: true, name: true } },
},
});
// Get recent sessions
const recentSessions = await db.studySession.findMany({
where: { schoolId, studentId },
orderBy: { startTime: 'desc' },
take: 10,
include: {
subject: { select: { id: true, name: true } },
},
});
// Get weekly stats
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const weekSessions = await db.studySession.findMany({
where: {
schoolId,
studentId,
status: 'completed',
startTime: { gte: oneWeekAgo },
},
});
const totalMinutesThisWeek = weekSessions.reduce((sum, s) => sum + s.duration, 0);
const subjectTimeMap = new Map<string, number>();
weekSessions.forEach(s => {
const name = s.subjectName ?? 'Other';
subjectTimeMap.set(name, (subjectTimeMap.get(name) ?? 0) + s.duration);
});
return NextResponse.json({
plans: studyPlans,
recentSessions,
weeklyStats: {
totalMinutes: totalMinutesThisWeek,
sessionsCount: weekSessions.length,
subjectBreakdown: Object.fromEntries(subjectTimeMap),
},
});
} catch (error) {
console.error('StudyPlanner GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const body = await request.json();
const {
schoolId,
studentId,
title,
description,
subjectId,
subjectName,
dayOfWeek,
startTime,
duration,
isRecurring,
specificDate,
priority,
color,
notes,
} = body;
if (!schoolId || !studentId || !title || dayOfWeek === undefined || !startTime || !duration) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Verify access
if (session.user?.role === 'STUDENT') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { userId: true, schoolId: true },
});
if (!student || student.userId !== session.userId || student.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const plan = await db.studyPlan.create({
data: {
schoolId,
studentId,
title,
description: description || null,
subjectId: subjectId || null,
subjectName: subjectName || null,
dayOfWeek,
startTime,
duration,
isRecurring: isRecurring ?? true,
specificDate: specificDate ? new Date(specificDate) : null,
priority: priority || 'medium',
color: color || null,
notes: notes || null,
},
include: {
subject: { select: { id: true, name: true } },
},
});
return NextResponse.json(plan, { status: 201 });
} catch (error) {
console.error('StudyPlanner POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+147
View File
@@ -0,0 +1,147 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const body = await request.json();
const {
schoolId,
studentId,
studyPlanId,
subjectId,
subjectName,
startTime,
plannedDuration,
type,
pomodoroLength,
breakLength,
notes,
} = body;
if (!schoolId || !studentId) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Verify access
if (session.user?.role === 'STUDENT') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { userId: true, schoolId: true },
});
if (!student || student.userId !== session.userId || student.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const studySession = await db.studySession.create({
data: {
schoolId,
studentId,
studyPlanId: studyPlanId || null,
subjectId: subjectId || null,
subjectName: subjectName || null,
startTime: startTime ? new Date(startTime) : new Date(),
plannedDuration: plannedDuration || null,
type: type || 'pomodoro',
pomodoroLength: pomodoroLength || 25,
breakLength: breakLength || 5,
notes: notes || null,
status: 'in_progress',
},
});
// Award XP for starting a study session
const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } });
if (student?.userId) {
const existing = await db.virtualCharacter.findUnique({ where: { userId: student.userId } });
if (existing) {
await db.virtualCharacter.update({
where: { userId: student.userId },
data: { xp: existing.xp + 5, level: Math.floor((existing.xp + 5) / 100) + 1 },
});
} else {
await db.virtualCharacter.create({
data: {
userId: student.userId,
schoolId,
characterId: 'owl',
name: 'My Companion',
xp: 5,
level: 1,
mood: 'happy',
},
});
}
}
return NextResponse.json(studySession, { status: 201 });
} catch (error) {
console.error('StudySession POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const body = await request.json();
const { sessionId, endTime, duration, pomodorosCompleted, focusScore, status, notes } = body;
if (!sessionId) {
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 });
}
const existing = await db.studySession.findUnique({ where: { id: sessionId } });
if (!existing) return NextResponse.json({ error: 'Session not found' }, { status: 404 });
// Verify access
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { userId: session.userId, schoolId: existing.schoolId, deletedAt: null },
select: { id: true },
});
if (!student || student.id !== existing.studentId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const updated = await db.studySession.update({
where: { id: sessionId },
data: {
endTime: endTime ? new Date(endTime) : undefined,
duration: duration ?? undefined,
pomodorosCompleted: pomodorosCompleted ?? undefined,
focusScore: focusScore ?? undefined,
status: status ?? undefined,
notes: notes ?? undefined,
},
});
// Award XP for completing a study session
if (status === 'completed' && duration) {
const xpGain = Math.floor(duration / 5); // 1 XP per 5 minutes
const student = await db.student.findUnique({ where: { id: existing.studentId }, select: { userId: true } });
if (student?.userId) {
const char = await db.virtualCharacter.findUnique({ where: { userId: student.userId } });
if (char) {
const newXp = char.xp + xpGain;
await db.virtualCharacter.update({
where: { userId: student.userId },
data: { xp: newXp, level: Math.floor(newXp / 100) + 1 },
});
}
}
}
return NextResponse.json(updated);
} catch (error) {
console.error('StudySession PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+1
View File
@@ -9,6 +9,7 @@ const updateUserSchema = z.object({
email: z.string().email().optional(),
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN', 'VICE_PRINCIPAL']).optional(),
locale: z.string().optional(),
schoolId: z.string().optional(),
});
export async function GET(