feat: Round 23 - Practice exercises, enhanced virtual character, inter-school federation, voice chat
- Practice exercises: AI-generated multiple choice questions, practice mode with score tracking - Enhanced virtual character: More animations, accessories, AI chat integration, XP system - Inter-school federation: School leaderboard, team scores, federation competitions - Enhanced communication: Voice chat, file sharing, note sharing, teacher notes panel - Added isFederation and federationSchedule fields to Competition model - 170+ new i18n keys - All lints pass
This commit is contained in:
1 parent
2f266a2143
commit
44ff170fca
12 files changed
+3655
-476
No files matched your search
@@ -66,7 +66,7 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { characterId, name, color, mood } = body;
|
||||
const { characterId, name, color, mood, accessories } = body;
|
||||
|
||||
// Get existing character
|
||||
const existing = await db.virtualCharacter.findUnique({
|
||||
@@ -85,6 +85,7 @@ export async function PUT(request: Request) {
|
||||
level: 1,
|
||||
xp: 0,
|
||||
mood: mood || 'happy',
|
||||
accessories: accessories || null,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(character);
|
||||
@@ -98,6 +99,7 @@ export async function PUT(request: Request) {
|
||||
...(name ? { name } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(mood ? { mood } : {}),
|
||||
...(accessories !== undefined ? { accessories } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// ─── GET /api/communication-rooms/[id]/share-notes ──────────────────
|
||||
// Get shared notebooks in a communication room
|
||||
|
||||
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 room = await db.communicationRoom.findUnique({ where: { id } });
|
||||
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
const userId = session.userId;
|
||||
|
||||
// Access control
|
||||
if (role === 'STUDENT' && room.studentId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
} else if (role === 'TEACHER' && room.teacherId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
} else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') {
|
||||
if (room.schoolId !== session.user?.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Get messages of type note_share in this room
|
||||
const sharedNotes = await db.communicationMessage.findMany({
|
||||
where: {
|
||||
roomId: id,
|
||||
messageType: 'note_share',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
sender: { select: { id: true, firstName: true, lastName: true, role: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Parse metadata for each shared note
|
||||
const enrichedNotes = sharedNotes.map((msg) => {
|
||||
let metadata: Record<string, unknown> = {};
|
||||
try {
|
||||
metadata = msg.metadata ? JSON.parse(msg.metadata) : {};
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return {
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
senderId: msg.senderId,
|
||||
sender: msg.sender,
|
||||
createdAt: msg.createdAt,
|
||||
notebookId: metadata.notebookId as string | undefined,
|
||||
notebookTitle: metadata.notebookTitle as string | undefined,
|
||||
notebookColor: metadata.notebookColor as string | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ sharedNotes: enrichedNotes });
|
||||
} catch (error) {
|
||||
console.error('Share-notes GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /api/communication-rooms/[id]/share-notes ─────────────────
|
||||
// Share a notebook with the teacher in the room
|
||||
|
||||
export async function POST(
|
||||
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 room = await db.communicationRoom.findUnique({ where: { id } });
|
||||
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
const userId = session.userId;
|
||||
|
||||
// Only the student can share notes in their room
|
||||
if (room.studentId !== userId && room.teacherId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (room.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Room is not active' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { notebookId } = body;
|
||||
|
||||
if (!notebookId) {
|
||||
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify the notebook exists and belongs to the user
|
||||
const notebook = await db.notebook.findFirst({
|
||||
where: {
|
||||
id: notebookId,
|
||||
ownerId: userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
pages: {
|
||||
select: { id: true, title: true, textContent: true, pageNumber: true },
|
||||
orderBy: { pageNumber: 'asc' },
|
||||
take: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json({ error: 'Notebook not found or not owned by you' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create a note_share message
|
||||
const metadata = JSON.stringify({
|
||||
notebookId: notebook.id,
|
||||
notebookTitle: notebook.title,
|
||||
notebookColor: notebook.color,
|
||||
notebookType: notebook.notebookType,
|
||||
pageCount: notebook.pages.length,
|
||||
sharedPages: notebook.pages.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
pageNumber: p.pageNumber,
|
||||
})),
|
||||
});
|
||||
|
||||
const message = await db.communicationMessage.create({
|
||||
data: {
|
||||
roomId: id,
|
||||
senderId: userId,
|
||||
content: notebook.title,
|
||||
messageType: 'note_share',
|
||||
metadata,
|
||||
},
|
||||
include: {
|
||||
sender: { select: { id: true, firstName: true, lastName: true, role: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Update room's updatedAt
|
||||
await db.communicationRoom.update({
|
||||
where: { id },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json(message, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Share-notes POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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';
|
||||
|
||||
// ─── Validation ─────────────────────────────────────────────────────
|
||||
|
||||
const federationCategoryEnum = z.enum([
|
||||
'math_olympiad', 'science_bowl', 'language_quiz', 'general_knowledge',
|
||||
]);
|
||||
|
||||
const scheduleEnum = z.enum(['weekly', 'monthly', 'quarterly']);
|
||||
|
||||
const createFederationSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(5000).optional().nullable(),
|
||||
category: federationCategoryEnum,
|
||||
subjectId: z.string().optional().nullable(),
|
||||
schedule: scheduleEnum,
|
||||
startDate: z.string().min(1),
|
||||
endDate: z.string().min(1),
|
||||
registrationDeadline: z.string().optional().nullable(),
|
||||
maxParticipants: z.number().int().min(1).optional().nullable(),
|
||||
rules: z.string().max(20000).optional().nullable(),
|
||||
isPublic: z.boolean().default(true),
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||
}
|
||||
|
||||
// ─── GET /api/competitions/federation — list federation competitions ─
|
||||
|
||||
async function getFederationCompetitions(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const categoryParam = searchParams.get('category');
|
||||
const scheduleParam = searchParams.get('schedule');
|
||||
const statusParam = searchParams.get('status');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
|
||||
// Find the user's school and its district
|
||||
const userSchoolId = session.user?.schoolId;
|
||||
|
||||
let districtSchoolIds: string[] = [];
|
||||
|
||||
if (userSchoolId) {
|
||||
const school = await db.school.findUnique({
|
||||
where: { id: userSchoolId },
|
||||
select: { districtId: true },
|
||||
});
|
||||
|
||||
if (school?.districtId) {
|
||||
const districtSchools = await db.school.findMany({
|
||||
where: { districtId: school.districtId, deletedAt: null },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
districtSchoolIds = districtSchools.map((s) => s.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Build where clause: federation competitions that are either from the user's
|
||||
// district or public
|
||||
const where: Record<string, unknown> = {
|
||||
isFederation: true,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (districtSchoolIds.length > 0) {
|
||||
where.OR = [
|
||||
{ schoolId: { in: districtSchoolIds } },
|
||||
{ isPublic: true },
|
||||
];
|
||||
} else {
|
||||
where.isPublic = true;
|
||||
}
|
||||
|
||||
if (categoryParam) where.category = categoryParam;
|
||||
if (scheduleParam) where.federationSchedule = scheduleParam;
|
||||
if (statusParam) where.status = statusParam;
|
||||
|
||||
const [competitions, total] = await Promise.all([
|
||||
db.competition.findMany({
|
||||
where,
|
||||
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: offset,
|
||||
take: limit,
|
||||
include: {
|
||||
school: { select: { id: true, name: true, primaryColor: true } },
|
||||
createdBy: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
_count: { select: { participants: true, rewards: true } },
|
||||
},
|
||||
}),
|
||||
db.competition.count({ where }),
|
||||
]);
|
||||
|
||||
// Compute school leaderboard for federation competitions
|
||||
const schoolLeaderboard: Array<{
|
||||
schoolId: string;
|
||||
schoolName: string;
|
||||
teamScore: number;
|
||||
participantCount: number;
|
||||
rank: number;
|
||||
}> = [];
|
||||
|
||||
if (districtSchoolIds.length > 0) {
|
||||
// Get all participants from district schools in federation competitions
|
||||
const participants = await db.competitionParticipant.findMany({
|
||||
where: {
|
||||
competition: { isFederation: true, deletedAt: null },
|
||||
participantType: 'student',
|
||||
},
|
||||
include: {
|
||||
competition: {
|
||||
select: { id: true, schoolId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Group by school and sum top scores
|
||||
const schoolScores: Record<string, { name: string; scores: number[] }> = {};
|
||||
|
||||
for (const sid of districtSchoolIds) {
|
||||
const school = await db.school.findUnique({
|
||||
where: { id: sid },
|
||||
select: { name: true },
|
||||
});
|
||||
if (school) {
|
||||
schoolScores[sid] = { name: school.name, scores: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Get students from each school and their scores
|
||||
for (const p of participants) {
|
||||
const compSchoolId = p.competition.schoolId;
|
||||
if (schoolScores[compSchoolId]) {
|
||||
schoolScores[compSchoolId].scores.push(p.score);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate team score (sum of top 10 student scores)
|
||||
const leaderboardEntries = Object.entries(schoolScores).map(([sid, data]) => {
|
||||
const sortedScores = [...data.scores].sort((a, b) => b - a);
|
||||
const teamScore = sortedScores.slice(0, 10).reduce((sum, s) => sum + s, 0);
|
||||
return {
|
||||
schoolId: sid,
|
||||
schoolName: data.name,
|
||||
teamScore,
|
||||
participantCount: data.scores.length,
|
||||
rank: 0,
|
||||
};
|
||||
});
|
||||
|
||||
leaderboardEntries.sort((a, b) => b.teamScore - a.teamScore);
|
||||
leaderboardEntries.forEach((entry, idx) => {
|
||||
entry.rank = idx + 1;
|
||||
});
|
||||
|
||||
schoolLeaderboard.push(...leaderboardEntries);
|
||||
}
|
||||
|
||||
return NextResponse.json({ competitions, total, schoolLeaderboard });
|
||||
} catch (error) {
|
||||
console.error('Federation GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /api/competitions/federation — create federation comp ─────
|
||||
|
||||
async function createFederationCompetition(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createFederationSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { schoolId, startDate, endDate, registrationDeadline, schedule, category } = parsed.data;
|
||||
|
||||
// Verify school access
|
||||
const school = await db.school.findUnique({ where: { id: schoolId } });
|
||||
if (!school) {
|
||||
return NextResponse.json({ error: 'School not found' }, { status: 404 });
|
||||
}
|
||||
if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Verify school is part of a district
|
||||
if (!school.districtId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'School must be part of a district to create federation competitions' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate dates
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
if (end <= start) {
|
||||
return NextResponse.json(
|
||||
{ error: 'End date must be after start date' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const competition = await db.competition.create({
|
||||
data: {
|
||||
schoolId,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
competitionType: 'inter_school',
|
||||
category,
|
||||
subjectId: parsed.data.subjectId ?? null,
|
||||
status: 'registration',
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null,
|
||||
maxParticipants: parsed.data.maxParticipants ?? null,
|
||||
scoringType: 'points',
|
||||
rules: parsed.data.rules ?? null,
|
||||
isPublic: parsed.data.isPublic,
|
||||
isFederation: true,
|
||||
federationSchedule: schedule,
|
||||
createdById: session.user.id,
|
||||
},
|
||||
include: {
|
||||
school: { select: { id: true, name: true, primaryColor: true } },
|
||||
createdBy: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
_count: { select: { participants: true, rewards: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Create leaderboard entries for all schools in the district
|
||||
const districtSchools = await db.school.findMany({
|
||||
where: { districtId: school.districtId, deletedAt: null },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
for (const ds of districtSchools) {
|
||||
await db.competitionLeaderboard.create({
|
||||
data: {
|
||||
competitionId: competition.id,
|
||||
participantType: 'school',
|
||||
participantId: ds.id,
|
||||
participantName: ds.name,
|
||||
score: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(competition, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Federation POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRateLimit(getFederationCompetitions, 'dataRead');
|
||||
export const POST = withRateLimit(createFederationCompetition, 'dataWrite');
|
||||
@@ -0,0 +1,314 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import ZAI from 'z-ai-web-dev-sdk';
|
||||
|
||||
// Rate limit: max 10 requests per day per user
|
||||
const MAX_EXERCISE_GENERATIONS_PER_DAY = 10;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { schoolId } = session.user;
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'No school assigned' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { contentId, topic, gradeLevel, count = 5, difficulty = 'medium' } = body;
|
||||
|
||||
if (!contentId && !topic) {
|
||||
return NextResponse.json(
|
||||
{ error: 'contentId or topic is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Count today's exercise generation requests from chat messages
|
||||
const todayRequestCount = await db.chatMessage.count({
|
||||
where: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
senderType: 'system',
|
||||
metadata: { contains: 'exercise_generation' },
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
});
|
||||
|
||||
if (todayRequestCount >= MAX_EXERCISE_GENERATIONS_PER_DAY) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily exercise generation limit reached',
|
||||
retryAfter: 86400,
|
||||
limit: MAX_EXERCISE_GENERATIONS_PER_DAY,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get content item if contentId provided
|
||||
let contentItem = null;
|
||||
let topicName = topic || '';
|
||||
let effectiveGradeLevel = gradeLevel || '5';
|
||||
let effectiveDifficulty = difficulty || 'medium';
|
||||
|
||||
if (contentId) {
|
||||
contentItem = await db.subjectContent.findUnique({
|
||||
where: { id: contentId },
|
||||
});
|
||||
|
||||
if (!contentItem) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Content not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
topicName = contentItem.title;
|
||||
effectiveGradeLevel = gradeLevel || extractGradeLevel(contentItem);
|
||||
effectiveDifficulty = difficulty || contentItem.difficulty || 'medium';
|
||||
}
|
||||
|
||||
// Build AI prompt
|
||||
const countStr = Math.min(Math.max(count, 1), 10);
|
||||
const systemPrompt = `Du bist ein Lehrer. Erstelle Uebungsfragen zum Thema ${topicName} fuer die Klasse ${effectiveGradeLevel}. Erstelle ${countStr} Fragen. Format: JSON array of {question, options (4 choices), correctAnswer (0-3), explanation}. Antworte NUR mit dem JSON-Array, kein Markdown, kein Code-Block.`;
|
||||
|
||||
const zai = await ZAI.create();
|
||||
const completion = await zai.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Erstelle ${countStr} ${effectiveDifficulty === 'easy' ? 'einfache' : effectiveDifficulty === 'hard' ? 'schwere' : 'mittelschwere'} Multiple-Choice-Fragen zum Thema "${topicName}" fuer die ${effectiveGradeLevel}. Klasse. Jede Frage hat 4 Antwortmoeglichkeiten. Gib das Ergebnis als JSON-Array zurueck.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const aiResponse = completion.choices?.[0]?.message?.content || '';
|
||||
|
||||
// Parse the AI response to extract questions
|
||||
let questions: Array<{
|
||||
question: string;
|
||||
options: string[];
|
||||
correctAnswer: number;
|
||||
explanation: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
// Try to parse the response directly
|
||||
let jsonStr = aiResponse.trim();
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (jsonStr.startsWith('```')) {
|
||||
jsonStr = jsonStr.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '');
|
||||
}
|
||||
|
||||
questions = JSON.parse(jsonStr);
|
||||
|
||||
if (!Array.isArray(questions)) {
|
||||
throw new Error('Response is not an array');
|
||||
}
|
||||
|
||||
// Validate and clean questions
|
||||
questions = questions
|
||||
.filter(
|
||||
(q) =>
|
||||
q.question &&
|
||||
Array.isArray(q.options) &&
|
||||
q.options.length === 4 &&
|
||||
typeof q.correctAnswer === 'number' &&
|
||||
q.correctAnswer >= 0 &&
|
||||
q.correctAnswer <= 3
|
||||
)
|
||||
.map((q) => ({
|
||||
question: String(q.question),
|
||||
options: q.options.map(String),
|
||||
correctAnswer: Number(q.correctAnswer),
|
||||
explanation: String(q.explanation || ''),
|
||||
}));
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse AI questions:', parseError);
|
||||
console.error('AI response was:', aiResponse.substring(0, 500));
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to parse generated questions', raw: aiResponse.substring(0, 200) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (questions.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No valid questions generated' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save questions to the content item's content field
|
||||
if (contentItem) {
|
||||
const existingContent = contentItem.content
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(contentItem.content);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})()
|
||||
: {};
|
||||
|
||||
const updatedContent = {
|
||||
...existingContent,
|
||||
questions,
|
||||
lastGenerated: new Date().toISOString(),
|
||||
gradeLevel: effectiveGradeLevel,
|
||||
difficulty: effectiveDifficulty,
|
||||
};
|
||||
|
||||
await db.subjectContent.update({
|
||||
where: { id: contentItem.id },
|
||||
data: {
|
||||
content: JSON.stringify(updatedContent),
|
||||
questionCount: questions.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Track rate limit via a system chat message
|
||||
await db.chatMessage.create({
|
||||
data: {
|
||||
schoolId,
|
||||
userId: session.userId,
|
||||
roomId: null,
|
||||
content: `Exercise generation: ${questions.length} questions for "${topicName}"`,
|
||||
senderType: 'system',
|
||||
metadata: JSON.stringify({
|
||||
type: 'exercise_generation',
|
||||
contentId: contentItem?.id || null,
|
||||
topic: topicName,
|
||||
questionCount: questions.length,
|
||||
}),
|
||||
isRead: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Award XP to virtual character
|
||||
const virtualChar = await db.virtualCharacter.findUnique({
|
||||
where: { userId: session.userId },
|
||||
});
|
||||
if (virtualChar) {
|
||||
const xpGain = 10;
|
||||
const newXp = virtualChar.xp + xpGain;
|
||||
const xpPerLevel = 100;
|
||||
const newLevel = Math.floor(newXp / xpPerLevel) + 1;
|
||||
await db.virtualCharacter.update({
|
||||
where: { id: virtualChar.id },
|
||||
data: {
|
||||
xp: newXp,
|
||||
level: newLevel,
|
||||
mood: newLevel > virtualChar.level ? 'celebrating' : 'happy',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
questions,
|
||||
contentId: contentItem?.id || null,
|
||||
topic: topicName,
|
||||
gradeLevel: effectiveGradeLevel,
|
||||
difficulty: effectiveDifficulty,
|
||||
requestsToday: todayRequestCount + 1,
|
||||
maxRequests: MAX_EXERCISE_GENERATIONS_PER_DAY,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Exercise generation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET: Fetch existing questions for a content item
|
||||
export async function GET(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 contentId = searchParams.get('contentId');
|
||||
|
||||
if (!contentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'contentId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const contentItem = await db.subjectContent.findUnique({
|
||||
where: { id: contentId },
|
||||
});
|
||||
|
||||
if (!contentItem) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Content not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
let questions: Array<{
|
||||
question: string;
|
||||
options: string[];
|
||||
correctAnswer: number;
|
||||
explanation: string;
|
||||
}> = [];
|
||||
|
||||
if (contentItem.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(contentItem.content);
|
||||
if (Array.isArray(parsed.questions)) {
|
||||
questions = parsed.questions;
|
||||
} else if (Array.isArray(parsed)) {
|
||||
questions = parsed;
|
||||
}
|
||||
} catch {
|
||||
// Content is not valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
questions,
|
||||
contentId,
|
||||
topic: contentItem.title,
|
||||
questionCount: questions.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Exercise GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function extractGradeLevel(content: { slug?: string; title?: string }): string {
|
||||
const slug = content.slug || '';
|
||||
const title = content.title || '';
|
||||
|
||||
// Try to extract from slug (e.g., "klasse-5-mathematik")
|
||||
const slugMatch = slug.match(/klasse[_-]?(\d+)/i);
|
||||
if (slugMatch) return slugMatch[1];
|
||||
|
||||
// Try to extract from title (e.g., "Mathematik Klasse 5")
|
||||
const titleMatch = title.match(/klasse\s*(\d+)/i);
|
||||
if (titleMatch) return titleMatch[1];
|
||||
|
||||
return '5';
|
||||
}
|
||||
Reference in new issue
Block a user