Round 20: Student Portal, District Management, userId field, styling enhancements, bug fixes
- Added userId field to Student model with User relation - Created Student Portal view with 6 tabs (competencies, goals, achievements, schedule, homework, feedback) - Created StudentGoal model + CRUD API with role-based access - Created District Management view with CRUD, analytics, school assignment - Enhanced auth page: animated gradient, floating particles, glassmorphism, staggered entrance - Enhanced dashboard: time-based greeting, animated stat cards, class overview, school branding - Enhanced student portal: hero section, circular progress, streak counter, celebration particles - Fixed student-goals API 403 for STUDENT role (name-matching fallback for legacy data) - Added userId filter to students API with OR fallback - Added nav.student-portal i18n key - 100+ new i18n keys in DE and EN - All lint checks pass
This commit is contained in:
1 parent
9680cc9244
commit
04ae7aa80c
23 files changed
+4624
-2868
No files matched your search
@@ -58,3 +58,38 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Failed to assign school to district' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/districts/[id]/schools — remove school from district
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(_req.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify the school belongs to this district
|
||||
const school = await db.school.findFirst({
|
||||
where: { id: schoolId, districtId: id },
|
||||
});
|
||||
|
||||
if (!school) {
|
||||
return NextResponse.json({ error: 'School not found in this district' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.school.update({
|
||||
where: { id: schoolId },
|
||||
data: { districtId: null },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[districts/[id]/schools] DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Failed to remove school from district' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,15 @@ export async function GET() {
|
||||
where: { deletedAt: null },
|
||||
include: {
|
||||
schools: {
|
||||
select: { id: true, name: true, schoolType: true, country: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
schoolType: true,
|
||||
country: true,
|
||||
_count: {
|
||||
select: { students: true, classGroups: true, users: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
@@ -18,7 +26,9 @@ export async function GET() {
|
||||
const result = districts.map((d) => ({
|
||||
...d,
|
||||
schoolCount: d.schools.length,
|
||||
totalStudents: 0, // Would need aggregation query in production
|
||||
totalStudents: d.schools.reduce((acc, s) => acc + s._count.students, 0),
|
||||
totalTeachers: d.schools.reduce((acc, s) => acc + s._count.users, 0),
|
||||
totalClasses: d.schools.reduce((acc, s) => acc + s._count.classGroups, 0),
|
||||
}));
|
||||
|
||||
return NextResponse.json(result);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Check if a student belongs to the current user (STUDENT role).
|
||||
* Uses userId (direct link) with name-matching fallback for legacy data.
|
||||
*/
|
||||
function isOwnStudent(
|
||||
student: { userId: string | null; firstName: string; lastName: string; schoolId: string | null },
|
||||
user: { id: string; firstName: string; lastName: string; schoolId: string | null }
|
||||
): boolean {
|
||||
if (student.userId === user.id) return true;
|
||||
// Fallback: match by name + school for legacy records without userId
|
||||
if (
|
||||
student.userId === null &&
|
||||
student.firstName === user.firstName &&
|
||||
student.lastName === user.lastName &&
|
||||
student.schoolId === user.schoolId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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 goal = await db.studentGoal.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify access
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
if (!isOwnStudent(goal.student, session.user)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
} else if (session.user?.role !== 'SUPER_ADMIN') {
|
||||
if (goal.student.schoolId !== session.user?.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(goal);
|
||||
} catch (error) {
|
||||
console.error('StudentGoal GET by ID error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, description, targetDate, progress, status } = body;
|
||||
|
||||
const existingGoal = await db.studentGoal.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify access
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
if (!isOwnStudent(existingGoal.student, session.user)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
} else if (session.user?.role !== 'SUPER_ADMIN') {
|
||||
if (existingGoal.student.schoolId !== session.user?.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (targetDate !== undefined) updateData.targetDate = targetDate ? new Date(targetDate) : null;
|
||||
if (progress !== undefined) updateData.progress = Math.min(100, Math.max(0, progress));
|
||||
if (status !== undefined) updateData.status = status;
|
||||
|
||||
// Auto-complete if progress reaches 100
|
||||
if (progress !== undefined && progress >= 100) {
|
||||
updateData.status = 'completed';
|
||||
updateData.progress = 100;
|
||||
}
|
||||
|
||||
const goal = await db.studentGoal.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json(goal);
|
||||
} catch (error) {
|
||||
console.error('StudentGoal 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 existingGoal = await db.studentGoal.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify access
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
if (!isOwnStudent(existingGoal.student, session.user)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
} else if (session.user?.role !== 'SUPER_ADMIN') {
|
||||
if (existingGoal.student.schoolId !== session.user?.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
await db.studentGoal.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('StudentGoal DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studentId = searchParams.get('studentId');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
if (!studentId) {
|
||||
return NextResponse.json({ error: 'studentId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify the student belongs to the same school or is the student themselves
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { userId: true, schoolId: true },
|
||||
});
|
||||
if (!student || student.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
} else if (session.user?.role !== 'SUPER_ADMIN') {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { schoolId: true },
|
||||
});
|
||||
if (!student || student.schoolId !== session.user?.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { studentId };
|
||||
if (status) where.status = status;
|
||||
|
||||
const goals = await db.studentGoal.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(goals);
|
||||
} catch (error) {
|
||||
console.error('StudentGoal 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 { studentId, title, description, targetDate, progress, status } = body;
|
||||
|
||||
if (!studentId || !title) {
|
||||
return NextResponse.json(
|
||||
{ error: 'studentId and title are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the student belongs to the same school or is the student themselves
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { userId: true, schoolId: true, firstName: true, lastName: true },
|
||||
});
|
||||
if (!student) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
// Check by userId (direct link) or by name matching (fallback for legacy data)
|
||||
const isOwnStudent = student.userId === session.user.id ||
|
||||
(student.userId === null &&
|
||||
student.firstName === session.user.firstName &&
|
||||
student.lastName === session.user.lastName &&
|
||||
student.schoolId === session.user.schoolId);
|
||||
if (!isOwnStudent) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
} else if (session.user?.role !== 'SUPER_ADMIN') {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { schoolId: true },
|
||||
});
|
||||
if (!student || student.schoolId !== session.user?.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const goal = await db.studentGoal.create({
|
||||
data: {
|
||||
studentId,
|
||||
title,
|
||||
description: description || null,
|
||||
targetDate: targetDate ? new Date(targetDate) : null,
|
||||
progress: progress || 0,
|
||||
status: status || 'active',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(goal, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('StudentGoal POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,31 @@ export async function GET(request: Request) {
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const search = searchParams.get('search');
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
const where: Record<string, unknown> = { deletedAt: null };
|
||||
|
||||
if (schoolId) where.schoolId = schoolId;
|
||||
|
||||
// Filter by userId (direct link to User account)
|
||||
let userIdFilter: Record<string, unknown>[] | null = null;
|
||||
if (userId) {
|
||||
// For STUDENT role, also match by name + school as fallback for legacy data
|
||||
if (session.user?.role === 'STUDENT' && session.user.id === userId) {
|
||||
userIdFilter = [
|
||||
{ userId },
|
||||
{
|
||||
userId: null,
|
||||
firstName: session.user.firstName,
|
||||
lastName: session.user.lastName,
|
||||
schoolId: session.user.schoolId,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
where.userId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
if (classGroupId) {
|
||||
where.enrollments = {
|
||||
some: { classGroupId, endDate: null },
|
||||
@@ -37,11 +57,22 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
const searchOr = [
|
||||
{ firstName: { contains: search } },
|
||||
{ lastName: { contains: search } },
|
||||
{ externalId: { contains: search } },
|
||||
];
|
||||
// If userId filter also uses OR, combine with AND
|
||||
if (userIdFilter) {
|
||||
where.AND = [
|
||||
{ OR: userIdFilter },
|
||||
{ OR: searchOr },
|
||||
];
|
||||
} else {
|
||||
where.OR = searchOr;
|
||||
}
|
||||
} else if (userIdFilter) {
|
||||
where.OR = userIdFilter;
|
||||
}
|
||||
|
||||
const students = await db.student.findMany({
|
||||
|
||||
Reference in new issue
Block a user