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,195 @@
|
||||
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 subject lesson with questions ──
|
||||
async function getLesson(
|
||||
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 lesson = await db.subjectLesson.findUnique({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
topic: {
|
||||
select: { id: true, title: true, schoolId: true, subjectId: true },
|
||||
},
|
||||
questions: {
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Lesson not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Access check: same school
|
||||
if (lesson.topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// For students: hide correct answers and explanations
|
||||
if (session.user.role === 'STUDENT') {
|
||||
const sanitizedQuestions = lesson.questions.map((q) => ({
|
||||
...q,
|
||||
correctAnswer: '',
|
||||
explanation: null,
|
||||
}));
|
||||
return NextResponse.json({ ...lesson, questions: sanitizedQuestions });
|
||||
}
|
||||
|
||||
return NextResponse.json(lesson);
|
||||
} catch (error) {
|
||||
console.error('SubjectLesson get error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT: Update a subject lesson ──
|
||||
const updateLessonSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
lessonType: z.enum(['explanation', 'exercise', 'quiz', 'flashcard', 'video_link']).optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
difficulty: z.enum(['easy', 'medium', 'hard']).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
estimatedMinutes: z.number().int().optional().nullable(),
|
||||
});
|
||||
|
||||
async function updateLesson(
|
||||
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.subjectLesson.findUnique({
|
||||
where: { id, deletedAt: null },
|
||||
include: { topic: { select: { schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Lesson not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updateLessonSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const lesson = await db.subjectLesson.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
include: {
|
||||
_count: { select: { questions: { where: { isDemo: false } } } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(lesson);
|
||||
} catch (error) {
|
||||
console.error('SubjectLesson update error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE: Soft delete a subject lesson ──
|
||||
async function deleteLesson(
|
||||
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.subjectLesson.findUnique({
|
||||
where: { id, deletedAt: null },
|
||||
include: { topic: { select: { schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Lesson not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const deleted = await db.subjectLesson.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Lesson deleted',
|
||||
id: deleted.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SubjectLesson delete error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRateLimit(getLesson, 'dataRead');
|
||||
export const PUT = withRateLimit(updateLesson, 'dataWrite');
|
||||
export const DELETE = withRateLimit(deleteLesson, 'dataWrite');
|
||||
@@ -0,0 +1,148 @@
|
||||
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 subject lessons ──
|
||||
async function getLessons(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 topicId = searchParams.get('topicId');
|
||||
const lessonType = searchParams.get('lessonType');
|
||||
const difficulty = searchParams.get('difficulty');
|
||||
|
||||
if (!topicId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'topicId query parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify topic belongs to user's school
|
||||
const topic = await db.subjectTopic.findUnique({
|
||||
where: { id: topicId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!topic) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Topic not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
deletedAt: null,
|
||||
topicId,
|
||||
};
|
||||
|
||||
if (lessonType) where.lessonType = lessonType;
|
||||
if (difficulty) where.difficulty = difficulty;
|
||||
|
||||
const lessons = await db.subjectLesson.findMany({
|
||||
where,
|
||||
include: {
|
||||
_count: { select: { questions: { where: { isDemo: false } } } },
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
return NextResponse.json(lessons);
|
||||
} catch (error) {
|
||||
console.error('SubjectLessons list error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST: Create a new subject lesson ──
|
||||
const createLessonSchema = z.object({
|
||||
topicId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
lessonType: z.enum(['explanation', 'exercise', 'quiz', 'flashcard', 'video_link']).default('explanation'),
|
||||
content: z.string().min(1),
|
||||
difficulty: z.enum(['easy', 'medium', 'hard']).default('medium'),
|
||||
sortOrder: z.number().int().default(0),
|
||||
estimatedMinutes: z.number().int().optional().nullable(),
|
||||
});
|
||||
|
||||
async function createLesson(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 = createLessonSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify topic belongs to user's school
|
||||
const topic = await db.subjectTopic.findUnique({
|
||||
where: { id: parsed.data.topicId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!topic) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Topic not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (topic.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const lesson = await db.subjectLesson.create({
|
||||
data: {
|
||||
topicId: parsed.data.topicId,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description,
|
||||
lessonType: parsed.data.lessonType,
|
||||
content: parsed.data.content,
|
||||
difficulty: parsed.data.difficulty,
|
||||
sortOrder: parsed.data.sortOrder,
|
||||
estimatedMinutes: parsed.data.estimatedMinutes,
|
||||
},
|
||||
include: {
|
||||
_count: { select: { questions: { where: { isDemo: false } } } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(lesson, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('SubjectLesson create error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRateLimit(getLessons, 'dataRead');
|
||||
export const POST = withRateLimit(createLesson, 'dataWrite');
|
||||
Reference in new issue
Block a user