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
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user