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:
Mustafa Cetin committed 2026-07-29 18:45:41 +00:00
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 });
}
}
+12 -2
View File
@@ -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);
+175
View File
@@ -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 });
}
}
+115
View File
@@ -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 });
}
}
+32 -1
View File
@@ -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({