Round 19: Competition system, German curriculum learning, enhanced notebooks, student dashboard
- Fix portfolio-view.tsx: replace entry.isPublic with form.isPublic (runtime error) - Add Competition system: 5 Prisma models (Competition, CompetitionParticipant, CompetitionReward, CompetitionLeaderboard, RewardClaim) - Add German curriculum learning: 4 Prisma models (SubjectTopic, SubjectLesson, LessonQuestion, StudentAnswer) - Add 12 API routes for competitions and rewards - Add 7 API routes for subject topics, lessons, questions, and student answers - Create competitions-view.tsx with full CRUD, leaderboard, rewards, registration - Enhance notebooks-view.tsx with 'Lernen' tab for German curriculum subjects - Add student-specific dashboard with Papier sparen banner, notebook quick access, learning progress - Add competitions to teacher and student navigation - Add 130+ i18n keys for DE and EN (competitions, rewards, learning, environment) - Enhanced student nav with competitions and notebooks - DSGVO-compliant reward claims with status lifecycle
This commit is contained in:
1 parent
74404708e5
commit
849b48a5a4
22 files changed
+6543
-5
No files matched your search
@@ -0,0 +1,220 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { withRateLimit } from '@/lib/rate-limit';
|
||||
|
||||
// ── GET: List student answers (for the current student) ──
|
||||
async function getAnswers(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const questionId = searchParams.get('questionId');
|
||||
const lessonId = searchParams.get('lessonId');
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
if (session.user.role === 'STUDENT') {
|
||||
// Students see only their own answers
|
||||
const where: Record<string, unknown> = {
|
||||
studentId: userId,
|
||||
};
|
||||
|
||||
if (questionId) where.questionId = questionId;
|
||||
|
||||
// If lessonId is provided, filter by questions in that lesson
|
||||
if (lessonId) {
|
||||
const answers = await db.studentAnswer.findMany({
|
||||
where: {
|
||||
studentId: userId,
|
||||
question: {
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
question: {
|
||||
select: {
|
||||
id: true,
|
||||
questionType: true,
|
||||
question: true,
|
||||
correctAnswer: true,
|
||||
explanation: true,
|
||||
points: true,
|
||||
lessonId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return NextResponse.json(answers);
|
||||
}
|
||||
|
||||
const answers = await db.studentAnswer.findMany({
|
||||
where,
|
||||
include: {
|
||||
question: {
|
||||
select: {
|
||||
id: true,
|
||||
questionType: true,
|
||||
question: true,
|
||||
correctAnswer: true,
|
||||
explanation: true,
|
||||
points: true,
|
||||
lessonId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(answers);
|
||||
}
|
||||
|
||||
// Teachers / Admins: can see answers for their school
|
||||
const schoolId = session.user.schoolId;
|
||||
if (!schoolId) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (questionId) where.questionId = questionId;
|
||||
|
||||
if (lessonId) {
|
||||
const answers = await db.studentAnswer.findMany({
|
||||
where: {
|
||||
question: {
|
||||
lessonId,
|
||||
},
|
||||
question: {
|
||||
lesson: {
|
||||
topic: { schoolId },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
question: {
|
||||
select: {
|
||||
id: true,
|
||||
questionType: true,
|
||||
question: true,
|
||||
correctAnswer: true,
|
||||
explanation: true,
|
||||
points: true,
|
||||
lessonId: true,
|
||||
},
|
||||
},
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return NextResponse.json(answers);
|
||||
}
|
||||
|
||||
// For teachers without lessonId filter, return empty (too broad)
|
||||
return NextResponse.json([]);
|
||||
} catch (error) {
|
||||
console.error('StudentAnswers list error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST: Submit a student answer ──
|
||||
const submitAnswerSchema = z.object({
|
||||
questionId: z.string().min(1),
|
||||
answer: z.string().min(1),
|
||||
timeTakenMs: z.number().int().optional().nullable(),
|
||||
});
|
||||
|
||||
async function submitAnswer(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = submitAnswerSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify question exists and belongs to user's school
|
||||
const question = await db.lessonQuestion.findUnique({
|
||||
where: { id: parsed.data.questionId },
|
||||
include: {
|
||||
lesson: {
|
||||
select: { topic: { select: { schoolId: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!question) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Question not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (question.lesson.topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if the answer is correct
|
||||
const isCorrect =
|
||||
parsed.data.answer.trim().toLowerCase() ===
|
||||
question.correctAnswer.trim().toLowerCase();
|
||||
|
||||
// Upsert the answer (unique constraint on questionId + studentId)
|
||||
const studentAnswer = await db.studentAnswer.upsert({
|
||||
where: {
|
||||
questionId_studentId: {
|
||||
questionId: parsed.data.questionId,
|
||||
studentId: session.user.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
questionId: parsed.data.questionId,
|
||||
studentId: session.user.id,
|
||||
answer: parsed.data.answer,
|
||||
isCorrect,
|
||||
timeTakenMs: parsed.data.timeTakenMs,
|
||||
attempts: 1,
|
||||
},
|
||||
update: {
|
||||
answer: parsed.data.answer,
|
||||
isCorrect,
|
||||
timeTakenMs: parsed.data.timeTakenMs,
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Return the answer with correctness info (for immediate feedback)
|
||||
return NextResponse.json({
|
||||
...studentAnswer,
|
||||
correctAnswer: question.correctAnswer,
|
||||
explanation: question.explanation,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('StudentAnswer submit error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRateLimit(getAnswers, 'dataRead');
|
||||
export const POST = withRateLimit(submitAnswer, 'dataWrite');
|
||||
Reference in new issue
Block a user