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,186 @@
|
||||
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: Get a single lesson question ──
|
||||
async function getQuestion(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const question = await db.lessonQuestion.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
lesson: {
|
||||
select: { id: true, topic: { select: { schoolId: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!question) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Question not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Access check: same school
|
||||
if (question.lesson.topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// For students: hide correct answer and explanation
|
||||
if (session.user.role === 'STUDENT') {
|
||||
const sanitized = {
|
||||
...question,
|
||||
correctAnswer: '',
|
||||
explanation: null,
|
||||
};
|
||||
return NextResponse.json(sanitized);
|
||||
}
|
||||
|
||||
return NextResponse.json(question);
|
||||
} catch (error) {
|
||||
console.error('LessonQuestion get error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT: Update a lesson question ──
|
||||
const updateQuestionSchema = z.object({
|
||||
questionType: z.enum(['multiple_choice', 'true_false', 'fill_blank', 'short_answer', 'matching']).optional(),
|
||||
question: z.string().min(1).optional(),
|
||||
options: z.string().optional().nullable(),
|
||||
correctAnswer: z.string().min(1).optional(),
|
||||
explanation: z.string().optional().nullable(),
|
||||
points: z.number().int().min(1).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
async function updateQuestion(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user.role !== 'TEACHER' &&
|
||||
session.user.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.lessonQuestion.findUnique({
|
||||
where: { id },
|
||||
include: { lesson: { select: { topic: { select: { schoolId: true } } } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Question not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.lesson.topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updateQuestionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const question = await db.lessonQuestion.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
});
|
||||
|
||||
return NextResponse.json(question);
|
||||
} catch (error) {
|
||||
console.error('LessonQuestion update error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE: Delete a lesson question ──
|
||||
async function deleteQuestion(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user.role !== 'TEACHER' &&
|
||||
session.user.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.lessonQuestion.findUnique({
|
||||
where: { id },
|
||||
include: { lesson: { select: { topic: { select: { schoolId: true } } } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Question not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.lesson.topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.lessonQuestion.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Question deleted',
|
||||
id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('LessonQuestion delete error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRateLimit(getQuestion, 'dataRead');
|
||||
export const PUT = withRateLimit(updateQuestion, 'dataWrite');
|
||||
export const DELETE = withRateLimit(deleteQuestion, 'dataWrite');
|
||||
@@ -0,0 +1,160 @@
|
||||
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 lesson questions ──
|
||||
async function getQuestions(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 lessonId = searchParams.get('lessonId');
|
||||
const questionType = searchParams.get('questionType');
|
||||
|
||||
if (!lessonId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'lessonId query parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify lesson belongs to user's school
|
||||
const lesson = await db.subjectLesson.findUnique({
|
||||
where: { id: lessonId, deletedAt: null },
|
||||
include: { topic: { select: { schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Lesson not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (lesson.topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { lessonId };
|
||||
|
||||
if (questionType) where.questionType = questionType;
|
||||
|
||||
const questions = await db.lessonQuestion.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
// For students: hide correct answers and explanations
|
||||
if (session.user.role === 'STUDENT') {
|
||||
const sanitized = questions.map((q) => ({
|
||||
...q,
|
||||
correctAnswer: '',
|
||||
explanation: null,
|
||||
}));
|
||||
return NextResponse.json(sanitized);
|
||||
}
|
||||
|
||||
return NextResponse.json(questions);
|
||||
} catch (error) {
|
||||
console.error('LessonQuestions list error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST: Create a new lesson question ──
|
||||
const createQuestionSchema = z.object({
|
||||
lessonId: z.string().min(1),
|
||||
questionType: z.enum(['multiple_choice', 'true_false', 'fill_blank', 'short_answer', 'matching']),
|
||||
question: z.string().min(1),
|
||||
options: z.string().optional().nullable(), // JSON array string
|
||||
correctAnswer: z.string().min(1),
|
||||
explanation: z.string().optional().nullable(),
|
||||
points: z.number().int().min(1).default(1),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
async function createQuestion(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user.role !== 'TEACHER' &&
|
||||
session.user.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createQuestionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify lesson belongs to user's school
|
||||
const lesson = await db.subjectLesson.findUnique({
|
||||
where: { id: parsed.data.lessonId, deletedAt: null },
|
||||
include: { topic: { select: { schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Lesson not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (lesson.topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Validate options for multiple_choice and matching
|
||||
if (
|
||||
(parsed.data.questionType === 'multiple_choice' || parsed.data.questionType === 'matching') &&
|
||||
!parsed.data.options
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Options are required for multiple_choice and matching questions' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const question = await db.lessonQuestion.create({
|
||||
data: {
|
||||
lessonId: parsed.data.lessonId,
|
||||
questionType: parsed.data.questionType,
|
||||
question: parsed.data.question,
|
||||
options: parsed.data.options,
|
||||
correctAnswer: parsed.data.correctAnswer,
|
||||
explanation: parsed.data.explanation,
|
||||
points: parsed.data.points,
|
||||
sortOrder: parsed.data.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(question, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('LessonQuestion create error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRateLimit(getQuestions, 'dataRead');
|
||||
export const POST = withRateLimit(createQuestion, 'dataWrite');
|
||||
Reference in new issue
Block a user