Round 24: Data Import/Export, School Events, Student Wellness, Styling
- Data Import/Export View: CSV/JSON import with drag-and-drop, column mapping, data preview, validation, export with filters, database statistics - School Event Management View: Calendar/list views, event CRUD with recurring support, registration, feedback, budget tracking - Student Wellness View: Daily check-in (mood, sleep, stress, activity), wellness score gauge, health records, trend charts - 4 new Prisma models: DataImportJob, DataExportJob, WellnessCheckin, WellnessScore - Enhanced SchoolEvent model with budget, registration, recurring, and status fields - Added EventFeedback model - Enhanced auth-view styling with glassmorphism card and animated floating shapes - Enhanced dashboard-view with gradient header, animated stat counters, welcome section - Added global CSS utility classes and keyframe animations - 14+ new API route files - 300+ i18n keys added - All views verified working in browser
This commit is contained in:
1 parent
7ea8dc48c8
commit
a159ed7fe6
22 files changed
+8349
-153
No files matched your search
@@ -0,0 +1,219 @@
|
||||
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 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL'
|
||||
) {
|
||||
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({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get database statistics
|
||||
const [
|
||||
studentCount,
|
||||
teacherCount,
|
||||
gradeCount,
|
||||
attendanceCount,
|
||||
competencyCount,
|
||||
classCount,
|
||||
subjectCount,
|
||||
assessmentCount,
|
||||
reportCount,
|
||||
orphanedStudents,
|
||||
orphanedEnrollments,
|
||||
] = await Promise.all([
|
||||
db.student.count({ where: { schoolId, deletedAt: null } }),
|
||||
db.user.count({ where: { schoolId, deletedAt: null, role: 'TEACHER' } }),
|
||||
db.assessmentResult.count({
|
||||
where: { assessment: { classGroup: { schoolId } } },
|
||||
}),
|
||||
db.attendanceRecord.count({
|
||||
where: { student: { schoolId, deletedAt: null } },
|
||||
}),
|
||||
db.learningProgressEntry.count({
|
||||
where: { student: { schoolId, deletedAt: null } },
|
||||
}),
|
||||
db.classGroup.count({ where: { schoolId } }),
|
||||
db.subject.count({ where: { schoolId } }),
|
||||
db.assessment.count({ where: { classGroup: { schoolId } } }),
|
||||
db.report.count({ where: { schoolId } }),
|
||||
// Orphaned students: students without enrollments
|
||||
db.student.count({
|
||||
where: {
|
||||
schoolId,
|
||||
deletedAt: null,
|
||||
enrollments: { none: {} },
|
||||
},
|
||||
}),
|
||||
// Orphaned enrollments: enrollments pointing to deleted students
|
||||
db.enrollment.count({
|
||||
where: {
|
||||
student: { deletedAt: { not: null } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
statistics: {
|
||||
students: studentCount,
|
||||
teachers: teacherCount,
|
||||
grades: gradeCount,
|
||||
attendance: attendanceCount,
|
||||
competencies: competencyCount,
|
||||
classes: classCount,
|
||||
subjects: subjectCount,
|
||||
assessments: assessmentCount,
|
||||
reports: reportCount,
|
||||
total: studentCount + teacherCount + gradeCount + attendanceCount + competencyCount,
|
||||
},
|
||||
cleanup: {
|
||||
orphanedStudents,
|
||||
orphanedEnrollments,
|
||||
totalOrphans: orphanedStudents + orphanedEnrollments,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Data Cleanup 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 (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { operation, schoolId: bodySchoolId } = body as {
|
||||
operation: string;
|
||||
schoolId?: string;
|
||||
};
|
||||
|
||||
const schoolId = bodySchoolId || session.user?.schoolId;
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let result: Record<string, unknown> = {};
|
||||
|
||||
if (operation === 'remove_orphans') {
|
||||
// Remove orphaned enrollments (enrollments pointing to deleted students)
|
||||
const deletedEnrollments = await db.enrollment.deleteMany({
|
||||
where: {
|
||||
student: { deletedAt: { not: null } },
|
||||
},
|
||||
});
|
||||
|
||||
// Remove orphaned attendance records
|
||||
const deletedAttendance = await db.attendanceRecord.deleteMany({
|
||||
where: {
|
||||
student: { deletedAt: { not: null } },
|
||||
},
|
||||
});
|
||||
|
||||
// Remove orphaned learning progress entries
|
||||
const deletedProgress = await db.learningProgressEntry.deleteMany({
|
||||
where: {
|
||||
student: { deletedAt: { not: null } },
|
||||
},
|
||||
});
|
||||
|
||||
result = {
|
||||
deletedEnrollments: deletedEnrollments.count,
|
||||
deletedAttendance: deletedAttendance.count,
|
||||
deletedProgress: deletedProgress.count,
|
||||
totalDeleted: deletedEnrollments.count + deletedAttendance.count + deletedProgress.count,
|
||||
};
|
||||
} else if (operation === 'bulk_delete') {
|
||||
const { type, ids } = body as { type: string; ids: string[] };
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json({ error: 'ids array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
|
||||
if (type === 'STUDENT') {
|
||||
const res = await db.student.updateMany({
|
||||
where: { id: { in: ids }, schoolId },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
deleted = res.count;
|
||||
} else if (type === 'ASSESSMENT') {
|
||||
const res = await db.assessment.deleteMany({
|
||||
where: { id: { in: ids }, classGroup: { schoolId } },
|
||||
});
|
||||
deleted = res.count;
|
||||
} else if (type === 'REPORT') {
|
||||
const res = await db.report.deleteMany({
|
||||
where: { id: { in: ids }, schoolId },
|
||||
});
|
||||
deleted = res.count;
|
||||
}
|
||||
|
||||
result = { type, deleted };
|
||||
} else if (operation === 'backup') {
|
||||
// Create a simple backup by counting all records
|
||||
const tables = {
|
||||
schools: await db.school.count(),
|
||||
users: await db.user.count(),
|
||||
students: await db.student.count({ where: { deletedAt: null } }),
|
||||
classes: await db.classGroup.count(),
|
||||
subjects: await db.subject.count(),
|
||||
assessments: await db.assessment.count(),
|
||||
reports: await db.report.count(),
|
||||
};
|
||||
|
||||
result = {
|
||||
backup: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
tables,
|
||||
message: 'Backup metadata recorded. Full database backup requires server-side access.',
|
||||
};
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Unknown operation' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: operation.toUpperCase(),
|
||||
entityType: 'DataCleanup',
|
||||
entityId: null,
|
||||
metadata: JSON.stringify(result),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, result });
|
||||
} catch (error) {
|
||||
console.error('Data Cleanup POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 job = await db.dataExportJob.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: 'Export job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL' &&
|
||||
job.userId !== session.userId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(job);
|
||||
} catch (error) {
|
||||
console.error('Data Export Job GET 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 (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const job = await db.dataExportJob.findUnique({ where: { id } });
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: 'Export job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.dataExportJob.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Data Export Job DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
function escapeCsvField(field: string | number | null | undefined): string {
|
||||
if (field === null || field === undefined) return '';
|
||||
const str = String(field);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function toCsvRow(fields: (string | number | null | undefined)[]): string {
|
||||
return fields.map(escapeCsvField).join(',');
|
||||
}
|
||||
|
||||
function objectsToCsv(data: Record<string, unknown>[]): string {
|
||||
if (data.length === 0) return '';
|
||||
const headers = Object.keys(data[0]);
|
||||
const headerRow = toCsvRow(headers);
|
||||
const dataRows = data.map((item) =>
|
||||
toCsvRow(headers.map((h) => item[h] as string | number | null | undefined))
|
||||
);
|
||||
return [headerRow, ...dataRows].join('\n');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { schoolId };
|
||||
if (session.user?.role === 'TEACHER') {
|
||||
where.userId = session.userId;
|
||||
}
|
||||
|
||||
const jobs = await db.dataExportJob.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json(jobs);
|
||||
} catch (error) {
|
||||
console.error('Data Export 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 (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, format, filters } = body as {
|
||||
type: string;
|
||||
format: string;
|
||||
filters?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const schoolId = (filters?.schoolId as string) || session.user?.schoolId;
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create export job
|
||||
const exportJob = await db.dataExportJob.create({
|
||||
data: {
|
||||
schoolId,
|
||||
userId: session.userId,
|
||||
type: type || 'STUDENT',
|
||||
format: format || 'CSV',
|
||||
filters: filters ? JSON.stringify(filters) : null,
|
||||
status: 'processing',
|
||||
},
|
||||
});
|
||||
|
||||
let exportData: Record<string, unknown>[] = [];
|
||||
let fileName = '';
|
||||
|
||||
try {
|
||||
if (type === 'STUDENT') {
|
||||
const students = await db.student.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
deletedAt: null,
|
||||
...(filters?.classId ? { enrollments: { some: { classGroupId: filters.classId as string } } } : {}),
|
||||
},
|
||||
include: {
|
||||
enrollments: { include: { classGroup: { select: { name: true } } } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = students.map((s) => ({
|
||||
id: s.id,
|
||||
firstName: s.firstName,
|
||||
lastName: s.lastName,
|
||||
externalId: s.externalId || '',
|
||||
dateOfBirth: s.dateOfBirth ? s.dateOfBirth.toISOString().split('T')[0] : '',
|
||||
class: s.enrollments[0]?.classGroup?.name || '',
|
||||
}));
|
||||
fileName = `students_export_${new Date().toISOString().split('T')[0]}`;
|
||||
} else if (type === 'GRADE') {
|
||||
const results = await db.assessmentResult.findMany({
|
||||
where: {
|
||||
assessment: {
|
||||
classGroup: { schoolId },
|
||||
...(filters?.classId ? { classGroupId: filters.classId as string } : {}),
|
||||
...(filters?.subjectId ? { subjectId: filters.subjectId as string } : {}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
student: { select: { firstName: true, lastName: true } },
|
||||
assessment: { select: { title: true, type: true } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = results.map((r) => ({
|
||||
studentName: `${r.student.firstName} ${r.student.lastName}`,
|
||||
assessmentTitle: r.assessment.title,
|
||||
assessmentType: r.assessment.type,
|
||||
score: r.score ?? '',
|
||||
note: r.note || '',
|
||||
}));
|
||||
fileName = `grades_export_${new Date().toISOString().split('T')[0]}`;
|
||||
} else if (type === 'ATTENDANCE') {
|
||||
const records = await db.attendanceRecord.findMany({
|
||||
where: {
|
||||
student: { schoolId, deletedAt: null },
|
||||
...(filters?.classId ? { student: { enrollments: { some: { classGroupId: filters.classId as string } } } } : {}),
|
||||
},
|
||||
include: {
|
||||
student: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = records.map((r) => ({
|
||||
studentName: `${r.student.firstName} ${r.student.lastName}`,
|
||||
status: r.status,
|
||||
date: r.date ? new Date(r.date).toISOString().split('T')[0] : '',
|
||||
}));
|
||||
fileName = `attendance_export_${new Date().toISOString().split('T')[0]}`;
|
||||
} else if (type === 'COMPETENCY') {
|
||||
const entries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
student: { schoolId, deletedAt: null },
|
||||
...(filters?.classId ? { student: { enrollments: { some: { classGroupId: filters.classId as string } } } } : {}),
|
||||
},
|
||||
include: {
|
||||
student: { select: { firstName: true, lastName: true } },
|
||||
competency: { select: { code: true, title: true } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = entries.map((e) => ({
|
||||
studentName: `${e.student.firstName} ${e.student.lastName}`,
|
||||
competencyCode: e.competency.code,
|
||||
competencyTitle: e.competency.title,
|
||||
level: e.level,
|
||||
note: e.note || '',
|
||||
}));
|
||||
fileName = `competencies_export_${new Date().toISOString().split('T')[0]}`;
|
||||
} else if (type === 'REPORT') {
|
||||
const reports = await db.report.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
...(filters?.classId ? { classGroupId: filters.classId as string } : {}),
|
||||
},
|
||||
include: {
|
||||
student: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = reports.map((r) => ({
|
||||
studentName: `${r.student.firstName} ${r.student.lastName}`,
|
||||
title: r.title || '',
|
||||
status: r.status,
|
||||
createdAt: r.createdAt.toISOString().split('T')[0],
|
||||
}));
|
||||
fileName = `reports_export_${new Date().toISOString().split('T')[0]}`;
|
||||
}
|
||||
|
||||
// Generate file content
|
||||
let fileContent: string;
|
||||
const ext = format === 'JSON' ? 'json' : 'csv';
|
||||
|
||||
if (format === 'JSON') {
|
||||
fileContent = JSON.stringify(exportData, null, 2);
|
||||
} else {
|
||||
fileContent = objectsToCsv(exportData);
|
||||
}
|
||||
|
||||
fileName = `${fileName}.${ext}`;
|
||||
|
||||
// Update export job as completed
|
||||
await db.dataExportJob.update({
|
||||
where: { id: exportJob.id },
|
||||
data: {
|
||||
status: 'completed',
|
||||
fileName,
|
||||
fileSize: Buffer.byteLength(fileContent),
|
||||
fileData: Buffer.from(fileContent).toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: 'EXPORT',
|
||||
entityType: 'DataExportJob',
|
||||
entityId: exportJob.id,
|
||||
metadata: JSON.stringify({ type, format, recordCount: exportData.length, fileName }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: exportJob.id,
|
||||
type,
|
||||
format,
|
||||
fileName,
|
||||
fileSize: Buffer.byteLength(fileContent),
|
||||
recordCount: exportData.length,
|
||||
status: 'completed',
|
||||
fileData: Buffer.from(fileContent).toString('base64'),
|
||||
}, { status: 201 });
|
||||
} catch (processingError) {
|
||||
await db.dataExportJob.update({
|
||||
where: { id: exportJob.id },
|
||||
data: { status: 'failed' },
|
||||
});
|
||||
throw processingError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Data Export POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,6 @@ import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { withRateLimit } from '@/lib/rate-limit';
|
||||
|
||||
function escapeCsvField(field: string | number | null | undefined): string {
|
||||
if (field === null || field === undefined) return '';
|
||||
const str = String(field);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function toCsvRow(fields: (string | number | null | undefined)[]): string {
|
||||
return fields.map(escapeCsvField).join(',');
|
||||
}
|
||||
|
||||
interface ImportRow {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -60,6 +47,21 @@ function parseCsv(text: string): { headers: string[]; rows: ImportRow[] } {
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
function parseJson(text: string): { headers: string[]; rows: ImportRow[] } {
|
||||
const data = JSON.parse(text);
|
||||
const arr = Array.isArray(data) ? data : data.records || data.data || [];
|
||||
if (arr.length === 0) return { headers: [], rows: [] };
|
||||
const headers = Object.keys(arr[0]);
|
||||
const rows = arr.map((item: Record<string, unknown>) => {
|
||||
const row: ImportRow = {};
|
||||
headers.forEach((h) => {
|
||||
row[h] = item[h] !== undefined && item[h] !== null ? String(item[h]) : '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
@@ -70,6 +72,7 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
@@ -77,8 +80,9 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const type = formData.get('type') as string || 'students';
|
||||
const schoolId = formData.get('schoolId') as string || session.user?.schoolId;
|
||||
const type = (formData.get('type') as string) || 'STUDENT';
|
||||
const schoolId = (formData.get('schoolId') as string) || session.user?.schoolId;
|
||||
const columnMapping = formData.get('columnMapping') as string | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
@@ -88,22 +92,84 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create import job record
|
||||
const importJob = await db.dataImportJob.create({
|
||||
data: {
|
||||
schoolId,
|
||||
userId: session.userId,
|
||||
type,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
status: 'processing',
|
||||
},
|
||||
});
|
||||
|
||||
const text = await file.text();
|
||||
const { headers, rows } = parseCsv(text);
|
||||
let parsed: { headers: string[]; rows: ImportRow[] };
|
||||
|
||||
// Parse based on file type
|
||||
if (file.name.endsWith('.json')) {
|
||||
try {
|
||||
parsed = parseJson(text);
|
||||
} catch {
|
||||
await db.dataImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: { status: 'failed', errors: JSON.stringify([{ error: 'Invalid JSON format' }]) },
|
||||
});
|
||||
return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
parsed = parseCsv(text);
|
||||
} catch {
|
||||
await db.dataImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: { status: 'failed', errors: JSON.stringify([{ error: 'Invalid CSV format' }]) },
|
||||
});
|
||||
return NextResponse.json({ error: 'Invalid CSV format' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const { headers, rows } = parsed;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'No data rows found in CSV' }, { status: 400 });
|
||||
await db.dataImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: { status: 'failed', totalRows: 0, errors: JSON.stringify([{ error: 'No data rows found' }]) },
|
||||
});
|
||||
return NextResponse.json({ error: 'No data rows found in file' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Apply column mapping if provided
|
||||
let mappedRows = rows;
|
||||
if (columnMapping) {
|
||||
try {
|
||||
const mapping: Record<string, string> = JSON.parse(columnMapping);
|
||||
mappedRows = rows.map((row) => {
|
||||
const mapped: ImportRow = {};
|
||||
for (const [csvCol, dbField] of Object.entries(mapping)) {
|
||||
if (dbField && row[csvCol] !== undefined) {
|
||||
mapped[dbField] = row[csvCol];
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
} catch {
|
||||
// If mapping fails, use original rows
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
if (type === 'students') {
|
||||
for (const row of rows) {
|
||||
// Import based on type
|
||||
if (type === 'STUDENT') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const firstName = row['First Name'] || row['firstName'] || row['Vorname'] || '';
|
||||
const lastName = row['Last Name'] || row['lastName'] || row['Nachname'] || '';
|
||||
const externalId = row['External ID'] || row['externalId'] || row['ID'] || '';
|
||||
const dateOfBirth = row['Date of Birth'] || row['dateOfBirth'] || row['Geburtsdatum'] || '';
|
||||
const firstName = row['firstName'] || row['First Name'] || row['Vorname'] || '';
|
||||
const lastName = row['lastName'] || row['Last Name'] || row['Nachname'] || '';
|
||||
const externalId = row['externalId'] || row['External ID'] || row['ID'] || '';
|
||||
const dateOfBirth = row['dateOfBirth'] || row['Date of Birth'] || row['Geburtsdatum'] || '';
|
||||
const email = row['email'] || row['Email'] || '';
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
result.skipped++;
|
||||
@@ -134,31 +200,34 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'assessments') {
|
||||
for (const row of rows) {
|
||||
} else if (type === 'TEACHER') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const title = row['Title'] || row['title'] || row['Titel'] || '';
|
||||
const classGroupId = row['Class ID'] || row['classGroupId'] || '';
|
||||
const subjectId = row['Subject ID'] || row['subjectId'] || '';
|
||||
const date = row['Date'] || row['date'] || row['Datum'] || '';
|
||||
const assessmentType = row['Type'] || row['type'] || 'WRITTEN';
|
||||
const firstName = row['firstName'] || row['First Name'] || row['Vorname'] || '';
|
||||
const lastName = row['lastName'] || row['Last Name'] || row['Nachname'] || '';
|
||||
const email = row['email'] || row['Email'] || '';
|
||||
const role = row['role'] || row['Role'] || 'TEACHER';
|
||||
|
||||
if (!title || !classGroupId) {
|
||||
if (!firstName || !lastName || !email) {
|
||||
result.skipped++;
|
||||
result.errors.push(`Row skipped: missing title or class ID`);
|
||||
result.errors.push(`Row skipped: missing required fields`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.assessment.create({
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.user.create({
|
||||
data: {
|
||||
title,
|
||||
classGroupId,
|
||||
subjectId: subjectId || null,
|
||||
teacherId: session.userId,
|
||||
date: date ? new Date(date) : new Date(),
|
||||
type: assessmentType,
|
||||
maxScore: row['Max Score'] || row['maxScore'] ? parseFloat(row['Max Score'] || row['maxScore']) : null,
|
||||
weight: row['Weight'] || row['weight'] ? parseFloat(row['Weight'] || row['weight']) : 1.0,
|
||||
schoolId,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
role,
|
||||
passwordHash: '$2a$10$default.placeholder.hash.value',
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
@@ -166,12 +235,12 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'grades') {
|
||||
for (const row of rows) {
|
||||
} else if (type === 'GRADE') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const studentId = row['Student ID'] || row['studentId'] || '';
|
||||
const assessmentId = row['Assessment ID'] || row['assessmentId'] || '';
|
||||
const score = row['Score'] || row['score'] || '';
|
||||
const studentId = row['studentId'] || row['Student ID'] || '';
|
||||
const assessmentId = row['assessmentId'] || row['Assessment ID'] || '';
|
||||
const score = row['score'] || row['Score'] || '';
|
||||
|
||||
if (!studentId || !assessmentId) {
|
||||
result.skipped++;
|
||||
@@ -184,7 +253,60 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
studentId,
|
||||
assessmentId,
|
||||
score: score ? parseFloat(score) : null,
|
||||
note: row['Note'] || row['note'] || null,
|
||||
note: row['note'] || row['Note'] || null,
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'ATTENDANCE') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const studentId = row['studentId'] || row['Student ID'] || '';
|
||||
const date = row['date'] || row['Date'] || row['Datum'] || '';
|
||||
const status = row['status'] || row['Status'] || 'PRESENT';
|
||||
|
||||
if (!studentId || !date) {
|
||||
result.skipped++;
|
||||
result.errors.push(`Row skipped: missing student ID or date`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.attendanceRecord.create({
|
||||
data: {
|
||||
studentId,
|
||||
sessionId: row['sessionId'] || row['Session ID'] || '',
|
||||
status,
|
||||
date: new Date(date),
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'COMPETENCY') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const studentId = row['studentId'] || row['Student ID'] || '';
|
||||
const competencyId = row['competencyId'] || row['Competency ID'] || '';
|
||||
const level = row['level'] || row['Level'] || '';
|
||||
|
||||
if (!studentId || !competencyId) {
|
||||
result.skipped++;
|
||||
result.errors.push(`Row skipped: missing student ID or competency ID`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.learningProgressEntry.create({
|
||||
data: {
|
||||
studentId,
|
||||
competencyId,
|
||||
teacherId: session.userId,
|
||||
level: level ? parseInt(level) : 1,
|
||||
note: row['note'] || row['Note'] || null,
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
@@ -194,18 +316,38 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update import job
|
||||
await db.dataImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: {
|
||||
status: 'completed',
|
||||
totalRows: rows.length,
|
||||
successRows: result.created,
|
||||
errorRows: result.skipped + result.errors.length,
|
||||
errors: result.errors.length > 0 ? JSON.stringify(result.errors.slice(0, 50)) : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: session.user?.schoolId ?? null,
|
||||
action: 'IMPORT',
|
||||
entityType: 'DataImport',
|
||||
entityId: null,
|
||||
metadata: JSON.stringify({ type, created: result.created, skipped: result.skipped, errorCount: result.errors.length }),
|
||||
entityType: 'DataImportJob',
|
||||
entityId: importJob.id,
|
||||
metadata: JSON.stringify({
|
||||
type,
|
||||
created: result.created,
|
||||
skipped: result.skipped,
|
||||
errorCount: result.errors.length,
|
||||
fileName: file.name,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: importJob.id,
|
||||
type,
|
||||
totalRows: rows.length,
|
||||
created: result.created,
|
||||
@@ -228,30 +370,27 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || 'students';
|
||||
const schoolId = searchParams.get('schoolId') || session.user?.schoolId;
|
||||
|
||||
let csvContent = '';
|
||||
let filename = '';
|
||||
|
||||
if (type === 'students') {
|
||||
csvContent = 'First Name,Last Name,Date of Birth,External ID\nMax,Mustermann,2015-03-15,STU001\nAnna,Schmidt,2015-07-22,STU002\n';
|
||||
filename = 'sample_students.csv';
|
||||
} else if (type === 'assessments') {
|
||||
csvContent = 'Title,Class ID,Subject ID,Date,Type,Max Score,Weight\nMath Test 1,cls_001,subj_math,2025-01-15,WRITTEN,100,1.0\nReading Test,cls_001,subj_german,2025-01-20,WRITTEN,50,1.0\n';
|
||||
filename = 'sample_assessments.csv';
|
||||
} else if (type === 'grades') {
|
||||
csvContent = 'Student ID,Assessment ID,Score,Note\nstu_001,asmt_001,85,Good work\nstu_002,asmt_001,92,Excellent\n';
|
||||
filename = 'sample_grades.csv';
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
// For non-admin users, only show their own imports
|
||||
const where: Record<string, unknown> = { schoolId };
|
||||
if (session.user?.role === 'TEACHER') {
|
||||
where.userId = session.userId;
|
||||
}
|
||||
|
||||
const jobs = await db.dataImportJob.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json(jobs);
|
||||
} catch (error) {
|
||||
console.error('Sample CSV download error:', error);
|
||||
console.error('Data Import GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 event = await db.schoolEvent.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const feedbacks = await db.eventFeedback.findMany({
|
||||
where: { eventId: id },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Calculate average rating
|
||||
const avgRating = feedbacks.length > 0
|
||||
? feedbacks.reduce((sum, f) => sum + f.rating, 0) / feedbacks.length
|
||||
: 0;
|
||||
|
||||
return NextResponse.json({
|
||||
feedbacks,
|
||||
stats: {
|
||||
total: feedbacks.length,
|
||||
averageRating: Math.round(avgRating * 10) / 10,
|
||||
ratingDistribution: [1, 2, 3, 4, 5].map(r => ({
|
||||
rating: r,
|
||||
count: feedbacks.filter(f => f.rating === r).length,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('EventFeedback 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 event = await db.schoolEvent.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only allow feedback for completed events
|
||||
if (event.status !== 'completed' && new Date() < new Date(event.startDate)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Feedback can only be submitted after the event has ended' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { userId, rating, comment } = body;
|
||||
|
||||
if (!userId || !rating) {
|
||||
return NextResponse.json({ error: 'userId and rating are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (rating < 1 || rating > 5) {
|
||||
return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if already submitted feedback
|
||||
const existing = await db.eventFeedback.findUnique({
|
||||
where: { eventId_userId: { eventId: id, userId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Feedback already submitted' }, { status: 409 });
|
||||
}
|
||||
|
||||
const feedback = await db.eventFeedback.create({
|
||||
data: {
|
||||
eventId: id,
|
||||
userId,
|
||||
rating,
|
||||
comment: comment || null,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(feedback, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('EventFeedback POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Event does not require registration' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check registration deadline
|
||||
if (event.registrationDeadline && new Date() > new Date(event.registrationDeadline)) {
|
||||
return NextResponse.json({ error: 'Registration deadline has passed' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { userId, notes } = body;
|
||||
|
||||
@@ -49,13 +54,14 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Already registered' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Check max participants
|
||||
if (event.maxParticipants) {
|
||||
// Check capacity (use capacity field if available, otherwise maxParticipants)
|
||||
const maxCap = event.capacity ?? event.maxParticipants;
|
||||
if (maxCap) {
|
||||
const count = await db.eventRegistration.count({
|
||||
where: { eventId: id, status: 'registered' },
|
||||
});
|
||||
if (count >= event.maxParticipants) {
|
||||
return NextResponse.json({ error: 'Event is full' }, { status: 400 });
|
||||
if (count >= maxCap) {
|
||||
return NextResponse.json({ error: 'Event is full', waitlist: true }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +71,11 @@ export async function POST(
|
||||
userId,
|
||||
notes: notes || null,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(registration, { status: 201 });
|
||||
@@ -103,6 +114,11 @@ export async function PUT(
|
||||
const updated = await db.eventRegistration.update({
|
||||
where: { id: registration.id },
|
||||
data: { status },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
@@ -111,3 +127,47 @@ export async function PUT(
|
||||
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 { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const registration = await db.eventRegistration.findUnique({
|
||||
where: { eventId_userId: { eventId: id, userId } },
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
return NextResponse.json({ error: 'Registration not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Cancel registration (soft delete via status)
|
||||
const updated = await db.eventRegistration.update({
|
||||
where: { id: registration.id },
|
||||
data: { status: 'cancelled' },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('EventRegistration DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export async function GET(
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbacks: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,6 +92,14 @@ export async function PUT(
|
||||
requiresRegistration,
|
||||
maxParticipants,
|
||||
notes,
|
||||
budget,
|
||||
registrationDeadline,
|
||||
capacity,
|
||||
isRecurring,
|
||||
recurrenceRule,
|
||||
bannerImageUrl,
|
||||
status,
|
||||
feedbackForm,
|
||||
} = body;
|
||||
|
||||
const updated = await db.schoolEvent.update({
|
||||
@@ -103,6 +118,14 @@ export async function PUT(
|
||||
...(requiresRegistration !== undefined && { requiresRegistration }),
|
||||
...(maxParticipants !== undefined && { maxParticipants: maxParticipants || null }),
|
||||
...(notes !== undefined && { notes }),
|
||||
...(budget !== undefined && { budget: budget ?? null }),
|
||||
...(registrationDeadline !== undefined && { registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null }),
|
||||
...(capacity !== undefined && { capacity: capacity ?? null }),
|
||||
...(isRecurring !== undefined && { isRecurring }),
|
||||
...(recurrenceRule !== undefined && { recurrenceRule: recurrenceRule || null }),
|
||||
...(bannerImageUrl !== undefined && { bannerImageUrl: bannerImageUrl || null }),
|
||||
...(status !== undefined && { status }),
|
||||
...(feedbackForm !== undefined && { feedbackForm: feedbackForm || null }),
|
||||
},
|
||||
include: {
|
||||
organizer: {
|
||||
@@ -118,6 +141,13 @@ export async function PUT(
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbacks: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ export async function GET(request: Request) {
|
||||
const eventType = searchParams.get('eventType');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const upcoming = searchParams.get('upcoming');
|
||||
const search = searchParams.get('search');
|
||||
const status = searchParams.get('status');
|
||||
const startDateFrom = searchParams.get('startDateFrom');
|
||||
const startDateTo = searchParams.get('startDateTo');
|
||||
const requiresRegistration = searchParams.get('requiresRegistration');
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
@@ -30,11 +35,24 @@ export async function GET(request: Request) {
|
||||
|
||||
if (eventType) where.eventType = eventType;
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
if (status) where.status = status;
|
||||
if (requiresRegistration === 'true') where.requiresRegistration = true;
|
||||
|
||||
if (search) {
|
||||
where.title = { contains: search };
|
||||
}
|
||||
|
||||
if (upcoming === 'true') {
|
||||
where.startDate = { gte: new Date() };
|
||||
}
|
||||
|
||||
if (startDateFrom || startDateTo) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (startDateFrom) dateFilter.gte = new Date(startDateFrom);
|
||||
if (startDateTo) dateFilter.lte = new Date(startDateTo);
|
||||
where.startDate = dateFilter;
|
||||
}
|
||||
|
||||
const events = await db.schoolEvent.findMany({
|
||||
where,
|
||||
orderBy: { startDate: 'asc' },
|
||||
@@ -52,6 +70,13 @@ export async function GET(request: Request) {
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbacks: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,6 +111,14 @@ export async function POST(request: Request) {
|
||||
maxParticipants,
|
||||
notes,
|
||||
isDemo,
|
||||
budget,
|
||||
registrationDeadline,
|
||||
capacity,
|
||||
isRecurring,
|
||||
recurrenceRule,
|
||||
bannerImageUrl,
|
||||
status,
|
||||
feedbackForm,
|
||||
} = body;
|
||||
|
||||
if (!schoolId || !title || !eventType || !startDate) {
|
||||
@@ -116,6 +149,14 @@ export async function POST(request: Request) {
|
||||
maxParticipants: maxParticipants || null,
|
||||
notes: notes || null,
|
||||
isDemo: isDemo ?? false,
|
||||
budget: budget ?? null,
|
||||
registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null,
|
||||
capacity: capacity ?? null,
|
||||
isRecurring: isRecurring ?? false,
|
||||
recurrenceRule: recurrenceRule || null,
|
||||
bannerImageUrl: bannerImageUrl || null,
|
||||
status: status || 'draft',
|
||||
feedbackForm: feedbackForm || null,
|
||||
},
|
||||
include: {
|
||||
organizer: {
|
||||
@@ -131,6 +172,13 @@ export async function POST(request: Request) {
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbacks: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/wellness — Get wellness data for a student
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
const role = searchParams.get('role') || 'STUDENT';
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const limit = parseInt(searchParams.get('limit') || '30');
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Admin/VP: school-wide overview
|
||||
if ((role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') && !studentId) {
|
||||
const totalCheckins = await db.wellnessCheckin.count({
|
||||
where: { schoolId },
|
||||
});
|
||||
const totalStudents = await db.student.count({
|
||||
where: { schoolId, deletedAt: null },
|
||||
});
|
||||
const recentCheckins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, date: { gte: new Date(Date.now() - 7 * 86400000) } },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 50,
|
||||
include: { student: { select: { id: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
const avgScores = await db.wellnessScore.aggregate({
|
||||
where: { schoolId, date: { gte: new Date(Date.now() - 30 * 86400000) } },
|
||||
_avg: { overallScore: true, physicalScore: true, mentalScore: true, socialScore: true, academicScore: true },
|
||||
});
|
||||
const lowWellnessCount = await db.wellnessScore.count({
|
||||
where: { schoolId, overallScore: { lt: 40 }, date: { gte: new Date(Date.now() - 30 * 86400000) } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'school_overview',
|
||||
totalCheckins,
|
||||
totalStudents,
|
||||
recentCheckins,
|
||||
avgScores: {
|
||||
overall: avgScores._avg.overallScore ?? 0,
|
||||
physical: avgScores._avg.physicalScore ?? 0,
|
||||
mental: avgScores._avg.mentalScore ?? 0,
|
||||
social: avgScores._avg.socialScore ?? 0,
|
||||
academic: avgScores._avg.academicScore ?? 0,
|
||||
},
|
||||
lowWellnessAlerts: lowWellnessCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Teacher: class overview
|
||||
if (role === 'TEACHER' && classGroupId && !studentId) {
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { classGroupId, endDate: null },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const studentIds = enrollments.map(e => e.studentId);
|
||||
|
||||
const checkins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId: { in: studentIds }, date: { gte: new Date(Date.now() - 30 * 86400000) } },
|
||||
orderBy: { date: 'desc' },
|
||||
take: limit,
|
||||
include: { student: { select: { id: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
const scores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId: { in: studentIds }, date: { gte: new Date(Date.now() - 30 * 86400000) } },
|
||||
orderBy: { date: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'class_overview',
|
||||
checkins,
|
||||
scores,
|
||||
studentCount: studentIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Student or specific student view
|
||||
if (studentId) {
|
||||
const checkins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
const scores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
const latestScore = scores[0] ?? null;
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'student',
|
||||
checkins,
|
||||
scores,
|
||||
latestScore,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid parameters' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('Error fetching wellness data:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch wellness data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/wellness — Submit daily check-in
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { schoolId, studentId, mood, sleepHours, sleepQuality, stressLevel, activityType, activityMinutes, mealsCount, waterGlasses, gratitudeEntry, notes } = body;
|
||||
|
||||
if (!schoolId || !studentId || mood === undefined) {
|
||||
return NextResponse.json({ error: 'schoolId, studentId, and mood are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (mood < 1 || mood > 5) {
|
||||
return NextResponse.json({ error: 'Mood must be between 1 and 5' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check for existing check-in today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const existing = await db.wellnessCheckin.findFirst({
|
||||
where: {
|
||||
schoolId,
|
||||
studentId,
|
||||
date: { gte: today, lt: tomorrow },
|
||||
},
|
||||
});
|
||||
|
||||
let checkin;
|
||||
if (existing) {
|
||||
checkin = await db.wellnessCheckin.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
mood,
|
||||
sleepHours: sleepHours ?? null,
|
||||
sleepQuality: sleepQuality ?? null,
|
||||
stressLevel: stressLevel ?? null,
|
||||
activityType: activityType ?? null,
|
||||
activityMinutes: activityMinutes ?? null,
|
||||
mealsCount: mealsCount ?? null,
|
||||
waterGlasses: waterGlasses ?? null,
|
||||
gratitudeEntry: gratitudeEntry ?? null,
|
||||
notes: notes ?? null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
checkin = await db.wellnessCheckin.create({
|
||||
data: {
|
||||
schoolId,
|
||||
studentId,
|
||||
date: new Date(),
|
||||
mood,
|
||||
sleepHours: sleepHours ?? null,
|
||||
sleepQuality: sleepQuality ?? null,
|
||||
stressLevel: stressLevel ?? null,
|
||||
activityType: activityType ?? null,
|
||||
activityMinutes: activityMinutes ?? null,
|
||||
mealsCount: mealsCount ?? null,
|
||||
waterGlasses: waterGlasses ?? null,
|
||||
gratitudeEntry: gratitudeEntry ?? null,
|
||||
notes: notes ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate wellness score
|
||||
const recentCheckins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId, date: { gte: new Date(Date.now() - 14 * 86400000) } },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 14,
|
||||
});
|
||||
|
||||
if (recentCheckins.length >= 1) {
|
||||
const avgMood = recentCheckins.reduce((s, c) => s + c.mood, 0) / recentCheckins.length;
|
||||
const sleepCheckins = recentCheckins.filter(c => c.sleepHours !== null);
|
||||
const avgSleep = sleepCheckins.length > 0 ? sleepCheckins.reduce((s, c) => s + (c.sleepHours ?? 0), 0) / sleepCheckins.length : 7;
|
||||
const stressCheckins = recentCheckins.filter(c => c.stressLevel !== null);
|
||||
const avgStress = stressCheckins.length > 0 ? stressCheckins.reduce((s, c) => s + (c.stressLevel ?? 5), 0) / stressCheckins.length : 5;
|
||||
const activityCheckins = recentCheckins.filter(c => c.activityMinutes !== null);
|
||||
const avgActivity = activityCheckins.length > 0 ? activityCheckins.reduce((s, c) => s + (c.activityMinutes ?? 0), 0) / activityCheckins.length : 0;
|
||||
|
||||
// Physical: based on sleep + activity
|
||||
const physicalScore = Math.min(100, Math.max(0,
|
||||
((avgSleep >= 7 && avgSleep <= 9 ? 40 : avgSleep >= 6 ? 25 : 10) +
|
||||
(avgActivity >= 30 ? 30 : avgActivity >= 15 ? 20 : 10) +
|
||||
(avgMood >= 3 ? 30 : avgMood >= 2 ? 15 : 5))
|
||||
));
|
||||
|
||||
// Mental: based on mood + stress
|
||||
const mentalScore = Math.min(100, Math.max(0,
|
||||
((avgMood / 5) * 50) + ((10 - avgStress) / 10) * 50
|
||||
));
|
||||
|
||||
// Social: based on mood (proxy)
|
||||
const socialScore = Math.min(100, Math.max(0, (avgMood / 5) * 80 + 10));
|
||||
|
||||
// Academic: based on mood + stress + sleep
|
||||
const academicScore = Math.min(100, Math.max(0,
|
||||
((avgMood / 5) * 35) + ((10 - avgStress) / 10) * 30 + ((avgSleep >= 7 ? 1 : avgSleep / 7) * 35)
|
||||
));
|
||||
|
||||
const overallScore = Math.round((physicalScore * 0.25 + mentalScore * 0.3 + socialScore * 0.2 + academicScore * 0.25) * 10) / 10;
|
||||
|
||||
// Upsert wellness score
|
||||
const existingScore = await db.wellnessScore.findFirst({
|
||||
where: { schoolId, studentId, date: { gte: today, lt: tomorrow } },
|
||||
});
|
||||
|
||||
if (existingScore) {
|
||||
await db.wellnessScore.update({
|
||||
where: { id: existingScore.id },
|
||||
data: {
|
||||
overallScore,
|
||||
physicalScore: Math.round(physicalScore * 10) / 10,
|
||||
mentalScore: Math.round(mentalScore * 10) / 10,
|
||||
socialScore: Math.round(socialScore * 10) / 10,
|
||||
academicScore: Math.round(academicScore * 10) / 10,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.wellnessScore.create({
|
||||
data: {
|
||||
schoolId,
|
||||
studentId,
|
||||
date: new Date(),
|
||||
overallScore,
|
||||
physicalScore: Math.round(physicalScore * 10) / 10,
|
||||
mentalScore: Math.round(mentalScore * 10) / 10,
|
||||
socialScore: Math.round(socialScore * 10) / 10,
|
||||
academicScore: Math.round(academicScore * 10) / 10,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ checkin, success: true });
|
||||
} catch (error) {
|
||||
console.error('Error saving wellness check-in:', error);
|
||||
return NextResponse.json({ error: 'Failed to save check-in' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/wellness/summary — Get wellness summary/stats
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
const period = searchParams.get('period') || 'week'; // week or month
|
||||
|
||||
if (!schoolId || !studentId) {
|
||||
return NextResponse.json({ error: 'schoolId and studentId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const days = period === 'month' ? 30 : 7;
|
||||
const startDate = new Date(Date.now() - days * 86400000);
|
||||
|
||||
const checkins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
const scores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
if (checkins.length === 0) {
|
||||
return NextResponse.json({
|
||||
type: 'summary',
|
||||
period,
|
||||
checkinCount: 0,
|
||||
avgMood: 0,
|
||||
avgSleepHours: 0,
|
||||
avgSleepQuality: 0,
|
||||
avgStressLevel: 0,
|
||||
totalActivityMinutes: 0,
|
||||
avgMealsCount: 0,
|
||||
avgWaterGlasses: 0,
|
||||
avgOverallScore: 0,
|
||||
avgPhysicalScore: 0,
|
||||
avgMentalScore: 0,
|
||||
avgSocialScore: 0,
|
||||
avgAcademicScore: 0,
|
||||
gratitudeEntries: [],
|
||||
moodDistribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
|
||||
stressDistribution: {},
|
||||
recommendations: [],
|
||||
});
|
||||
}
|
||||
|
||||
const avgMood = checkins.reduce((s, c) => s + c.mood, 0) / checkins.length;
|
||||
const sleepCheckins = checkins.filter(c => c.sleepHours !== null);
|
||||
const avgSleepHours = sleepCheckins.length > 0 ? sleepCheckins.reduce((s, c) => s + (c.sleepHours ?? 0), 0) / sleepCheckins.length : 0;
|
||||
const sleepQualityCheckins = checkins.filter(c => c.sleepQuality !== null);
|
||||
const avgSleepQuality = sleepQualityCheckins.length > 0 ? sleepQualityCheckins.reduce((s, c) => s + (c.sleepQuality ?? 0), 0) / sleepQualityCheckins.length : 0;
|
||||
const stressCheckins = checkins.filter(c => c.stressLevel !== null);
|
||||
const avgStressLevel = stressCheckins.length > 0 ? stressCheckins.reduce((s, c) => s + (c.stressLevel ?? 0), 0) / stressCheckins.length : 0;
|
||||
const activityCheckins = checkins.filter(c => c.activityMinutes !== null);
|
||||
const totalActivityMinutes = activityCheckins.reduce((s, c) => s + (c.activityMinutes ?? 0), 0);
|
||||
const mealsCheckins = checkins.filter(c => c.mealsCount !== null);
|
||||
const avgMealsCount = mealsCheckins.length > 0 ? mealsCheckins.reduce((s, c) => s + (c.mealsCount ?? 0), 0) / mealsCheckins.length : 0;
|
||||
const waterCheckins = checkins.filter(c => c.waterGlasses !== null);
|
||||
const avgWaterGlasses = waterCheckins.length > 0 ? waterCheckins.reduce((s, c) => s + (c.waterGlasses ?? 0), 0) / waterCheckins.length : 0;
|
||||
|
||||
const avgOverallScore = scores.length > 0 ? scores.reduce((s, sc) => s + sc.overallScore, 0) / scores.length : 0;
|
||||
const avgPhysicalScore = scores.length > 0 ? scores.filter(s => s.physicalScore !== null).reduce((s, sc) => s + (sc.physicalScore ?? 0), 0) / scores.length : 0;
|
||||
const avgMentalScore = scores.length > 0 ? scores.filter(s => s.mentalScore !== null).reduce((s, sc) => s + (sc.mentalScore ?? 0), 0) / scores.length : 0;
|
||||
const avgSocialScore = scores.length > 0 ? scores.filter(s => s.socialScore !== null).reduce((s, sc) => s + (sc.socialScore ?? 0), 0) / scores.length : 0;
|
||||
const avgAcademicScore = scores.length > 0 ? scores.filter(s => s.academicScore !== null).reduce((s, sc) => s + (sc.academicScore ?? 0), 0) / scores.length : 0;
|
||||
|
||||
// Mood distribution
|
||||
const moodDistribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||
checkins.forEach(c => { moodDistribution[c.mood] = (moodDistribution[c.mood] || 0) + 1; });
|
||||
|
||||
// Stress distribution
|
||||
const stressDistribution: Record<number, number> = {};
|
||||
stressCheckins.forEach(c => {
|
||||
const level = c.stressLevel ?? 0;
|
||||
stressDistribution[level] = (stressDistribution[level] || 0) + 1;
|
||||
});
|
||||
|
||||
// Gratitude entries
|
||||
const gratitudeEntries = checkins.filter(c => c.gratitudeEntry).map(c => ({
|
||||
date: c.date,
|
||||
entry: c.gratitudeEntry,
|
||||
}));
|
||||
|
||||
// AI recommendations based on patterns
|
||||
const recommendations: string[] = [];
|
||||
if (avgSleepHours < 7) {
|
||||
recommendations.push('Try to get at least 7-8 hours of sleep per night for better recovery.');
|
||||
}
|
||||
if (avgStressLevel > 6) {
|
||||
recommendations.push('Consider stress-reduction techniques like deep breathing, meditation, or talking to a counselor.');
|
||||
}
|
||||
if (totalActivityMinutes < 150) {
|
||||
recommendations.push('Aim for at least 30 minutes of physical activity most days of the week.');
|
||||
}
|
||||
if (avgMood < 3) {
|
||||
recommendations.push('Your mood has been consistently low. Consider reaching out to a trusted adult or counselor.');
|
||||
}
|
||||
if (avgWaterGlasses < 6) {
|
||||
recommendations.push('Try to drink at least 6-8 glasses of water daily for better hydration.');
|
||||
}
|
||||
if (avgMealsCount < 3) {
|
||||
recommendations.push('Regular meals are important for energy and concentration. Try to eat at least 3 meals per day.');
|
||||
}
|
||||
if (avgSleepQuality < 3) {
|
||||
recommendations.push('Improve sleep quality by reducing screen time before bed and maintaining a consistent sleep schedule.');
|
||||
}
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push('Great job! Your wellness indicators are looking good. Keep up the healthy habits!');
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'summary',
|
||||
period,
|
||||
checkinCount: checkins.length,
|
||||
avgMood: Math.round(avgMood * 10) / 10,
|
||||
avgSleepHours: Math.round(avgSleepHours * 10) / 10,
|
||||
avgSleepQuality: Math.round(avgSleepQuality * 10) / 10,
|
||||
avgStressLevel: Math.round(avgStressLevel * 10) / 10,
|
||||
totalActivityMinutes,
|
||||
avgMealsCount: Math.round(avgMealsCount * 10) / 10,
|
||||
avgWaterGlasses: Math.round(avgWaterGlasses * 10) / 10,
|
||||
avgOverallScore: Math.round(avgOverallScore * 10) / 10,
|
||||
avgPhysicalScore: Math.round(avgPhysicalScore * 10) / 10,
|
||||
avgMentalScore: Math.round(avgMentalScore * 10) / 10,
|
||||
avgSocialScore: Math.round(avgSocialScore * 10) / 10,
|
||||
avgAcademicScore: Math.round(avgAcademicScore * 10) / 10,
|
||||
gratitudeEntries,
|
||||
moodDistribution,
|
||||
stressDistribution,
|
||||
recommendations,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching wellness summary:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch wellness summary' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/wellness/trends — Get wellness trends over time
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
const days = parseInt(searchParams.get('days') || '30');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const includeClassAvg = searchParams.get('includeClassAvg') === 'true';
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const startDate = new Date(Date.now() - days * 86400000);
|
||||
|
||||
if (studentId) {
|
||||
// Individual student trends
|
||||
const checkins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
const scores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
// Build daily data points
|
||||
const trendData = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const dayDate = new Date(startDate);
|
||||
dayDate.setDate(dayDate.getDate() + i);
|
||||
const dayStr = dayDate.toISOString().split('T')[0];
|
||||
|
||||
const dayCheckin = checkins.find(c => c.date.toISOString().split('T')[0] === dayStr);
|
||||
const dayScore = scores.find(s => s.date.toISOString().split('T')[0] === dayStr);
|
||||
|
||||
trendData.push({
|
||||
date: dayStr,
|
||||
mood: dayCheckin?.mood ?? null,
|
||||
sleepHours: dayCheckin?.sleepHours ?? null,
|
||||
sleepQuality: dayCheckin?.sleepQuality ?? null,
|
||||
stressLevel: dayCheckin?.stressLevel ?? null,
|
||||
activityMinutes: dayCheckin?.activityMinutes ?? null,
|
||||
overallScore: dayScore?.overallScore ?? null,
|
||||
physicalScore: dayScore?.physicalScore ?? null,
|
||||
mentalScore: dayScore?.mentalScore ?? null,
|
||||
socialScore: dayScore?.socialScore ?? null,
|
||||
academicScore: dayScore?.academicScore ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Class average for comparison (anonymized)
|
||||
let classAvgData: Array<{ date: string; avgOverall: number; avgMood: number; avgStress: number }> = [];
|
||||
if (includeClassAvg && classGroupId) {
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { classGroupId, endDate: null },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const classStudentIds = enrollments.map(e => e.studentId).filter(id => id !== studentId);
|
||||
|
||||
if (classStudentIds.length > 0) {
|
||||
const classScores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId: { in: classStudentIds }, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
const classCheckins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId: { in: classStudentIds }, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const dayDate = new Date(startDate);
|
||||
dayDate.setDate(dayDate.getDate() + i);
|
||||
const dayStr = dayDate.toISOString().split('T')[0];
|
||||
|
||||
const dayScores = classScores.filter(s => s.date.toISOString().split('T')[0] === dayStr);
|
||||
const dayCheckins = classCheckins.filter(c => c.date.toISOString().split('T')[0] === dayStr);
|
||||
|
||||
if (dayScores.length > 0 || dayCheckins.length > 0) {
|
||||
classAvgData.push({
|
||||
date: dayStr,
|
||||
avgOverall: dayScores.length > 0 ? Math.round(dayScores.reduce((s, sc) => s + sc.overallScore, 0) / dayScores.length * 10) / 10 : 0,
|
||||
avgMood: dayCheckins.length > 0 ? Math.round(dayCheckins.reduce((s, c) => s + c.mood, 0) / dayCheckins.length * 10) / 10 : 0,
|
||||
avgStress: dayCheckins.filter(c => c.stressLevel !== null).length > 0 ? Math.round(dayCheckins.filter(c => c.stressLevel !== null).reduce((s, c) => s + (c.stressLevel ?? 0), 0) / dayCheckins.filter(c => c.stressLevel !== null).length * 10) / 10 : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'trends',
|
||||
studentId,
|
||||
days,
|
||||
trendData,
|
||||
classAvgData,
|
||||
});
|
||||
}
|
||||
|
||||
// School-wide trends (for admin)
|
||||
const allScores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
const schoolTrendData = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const dayDate = new Date(startDate);
|
||||
dayDate.setDate(dayDate.getDate() + i);
|
||||
const dayStr = dayDate.toISOString().split('T')[0];
|
||||
|
||||
const dayScores = allScores.filter(s => s.date.toISOString().split('T')[0] === dayStr);
|
||||
|
||||
if (dayScores.length > 0) {
|
||||
schoolTrendData.push({
|
||||
date: dayStr,
|
||||
avgOverall: Math.round(dayScores.reduce((s, sc) => s + sc.overallScore, 0) / dayScores.length * 10) / 10,
|
||||
avgPhysical: Math.round(dayScores.filter(s => s.physicalScore !== null).reduce((s, sc) => s + (sc.physicalScore ?? 0), 0) / dayScores.length * 10) / 10,
|
||||
avgMental: Math.round(dayScores.filter(s => s.mentalScore !== null).reduce((s, sc) => s + (sc.mentalScore ?? 0), 0) / dayScores.length * 10) / 10,
|
||||
avgSocial: Math.round(dayScores.filter(s => s.socialScore !== null).reduce((s, sc) => s + (sc.socialScore ?? 0), 0) / dayScores.length * 10) / 10,
|
||||
avgAcademic: Math.round(dayScores.filter(s => s.academicScore !== null).reduce((s, sc) => s + (sc.academicScore ?? 0), 0) / dayScores.length * 10) / 10,
|
||||
studentCount: dayScores.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'school_trends',
|
||||
days,
|
||||
trendData: schoolTrendData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching wellness trends:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch wellness trends' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user