diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a9720ca..4e21996 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,6 +74,9 @@ model School { badges Badge[] studentBadges StudentBadge[] newsletters Newsletter[] + competitions Competition[] + rewardClaims RewardClaim[] + subjectTopics SubjectTopic[] district SchoolDistrict? @relation(fields: [districtId], references: [id]) districtId String? } @@ -122,6 +125,10 @@ model User { eventRegistrations EventRegistration[] awardedBadges StudentBadge[] newsletters Newsletter[] + competitionsCreated Competition[] + competitionParticipations CompetitionParticipant[] + competitionClaims RewardClaim[] + studentAnswers StudentAnswer[] } model SchoolYear { @@ -266,6 +273,8 @@ model Subject { homeworks Homework[] timetableSlots TimetableSlot[] resources Resource[] + topics SubjectTopic[] + competitions Competition[] } model CompetencyTemplate { @@ -850,7 +859,7 @@ model NotebookPage { notebookId String pageNumber Int title String? - contentType String @default("text") // text | drawing | mixed + contentType String @default("text") // text | drawing | mixed | quiz | exercise | flashcard textContent String? // markdown/rich text content drawingData String? // JSON with strokes/paths for canvas background String @default("lined") // lined | grid | blank | dotted | music @@ -1593,6 +1602,134 @@ model StudentBadge { @@map("student_badges") } +// ─── Competition System ────────────────────────────────────────────── + +model Competition { + id String @id @default(cuid()) + schoolId String + title String + description String? + competitionType String // "class" | "inter_class" | "inter_school" + category String // "academic" | "sports" | "creativity" | "citizenship" | "digital" | "reading" | "stem" | "other" + subjectId String? // optional subject link + status String @default("draft") // "draft" | "registration" | "active" | "completed" | "cancelled" + startDate DateTime + endDate DateTime + registrationDeadline DateTime? + maxParticipants Int? + scoringType String @default("points") // "points" | "rank" | "time" | "badge_count" + rules String? // markdown rules/description + isPublic Boolean @default(false) // visible to all schools? + isDemo Boolean @default(false) + createdById String // teacher/admin who created + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + createdBy User @relation(fields: [createdById], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + participants CompetitionParticipant[] + rewards CompetitionReward[] + leaderboard CompetitionLeaderboard[] + claims RewardClaim[] + + @@index([schoolId]) + @@index([status]) + @@index([competitionType]) + @@index([startDate]) + @@map("competitions") +} + +model CompetitionParticipant { + id String @id @default(cuid()) + competitionId String + participantType String // "student" | "class_group" | "school" + participantId String // studentId, classGroupId, or schoolId depending on type + userId String? // the student's userId (for individual participation) + registeredAt DateTime @default(now()) + score Int @default(0) + rank Int? + isDisqualified Boolean @default(false) + notes String? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id]) + + @@unique([competitionId, participantType, participantId]) + @@index([competitionId]) + @@index([userId]) + @@map("competition_participants") +} + +model CompetitionReward { + id String @id @default(cuid()) + competitionId String + name String + description String? + rewardType String // "digital_code" | "badge" | "certificate" | "experience" | "physical" + rewardValue String? // promo code, badge ID, certificate URL, etc. + rewardProvider String? // "netflix" | "amazon" | "cinema" | "theater" | "concert" | "custom" + rankRequirement Int? // minimum rank to receive (1=first place, etc.) + pointsRequired Int? // minimum points to receive + quantity Int @default(1) // how many available + claimedCount Int @default(0) + imageUrl String? // reward image + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) + claims RewardClaim[] + + @@index([competitionId]) + @@map("competition_rewards") +} + +model CompetitionLeaderboard { + id String @id @default(cuid()) + competitionId String + participantType String // "student" | "class_group" | "school" + participantId String + participantName String // denormalized for fast reads + score Int @default(0) + rank Int? + updatedAt DateTime @updatedAt + + competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) + + @@unique([competitionId, participantType, participantId]) + @@index([competitionId, rank]) + @@map("competition_leaderboard") +} + +model RewardClaim { + id String @id @default(cuid()) + schoolId String + competitionId String + rewardId String + userId String // student who claimed + claimedAt DateTime @default(now()) + code String? // the actual promo code (encrypted for DSGVO) + status String @default("pending") // "pending" | "claimed" | "expired" | "revoked" + expiresAt DateTime? + notes String? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + + school School @relation(fields: [schoolId], references: [id]) + user User @relation(fields: [userId], references: [id]) + competition Competition @relation(fields: [competitionId], references: [id]) + reward CompetitionReward @relation(fields: [rewardId], references: [id]) + + @@index([schoolId, userId]) + @@index([competitionId]) + @@map("reward_claims") +} + // ─── Newsletter / School Blog ────────────────────────────────────── model Newsletter { @@ -1618,3 +1755,93 @@ model Newsletter { @@index([schoolId, isPublished, publishedAt]) @@map("newsletters") } + +// ─── Learning Content (German Curriculum) ───────────────────────────── + +model SubjectTopic { + id String @id @default(cuid()) + schoolId String + subjectId String + title String + description String? + gradeLevel String? // "1" | "2" | "3" | "4" | "5" | "6" (German Grundschule) + curriculumCode String? // e.g., "DE-NRW-Math-1-01" (German state curriculum code) + sortOrder Int @default(0) + icon String? // Lucide icon name + color String @default("#10b981") + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + subject Subject @relation(fields: [subjectId], references: [id]) + lessons SubjectLesson[] + + @@index([schoolId, subjectId]) + @@index([gradeLevel]) + @@map("subject_topics") +} + +model SubjectLesson { + id String @id @default(cuid()) + topicId String + title String + description String? + lessonType String @default("explanation") // "explanation" | "exercise" | "quiz" | "flashcard" | "video_link" + content String // markdown content for the lesson + difficulty String @default("medium") // "easy" | "medium" | "hard" + sortOrder Int @default(0) + estimatedMinutes Int? // estimated time to complete + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + topic SubjectTopic @relation(fields: [topicId], references: [id], onDelete: Cascade) + questions LessonQuestion[] + + @@index([topicId]) + @@map("subject_lessons") +} + +model LessonQuestion { + id String @id @default(cuid()) + lessonId String + questionType String // "multiple_choice" | "true_false" | "fill_blank" | "short_answer" | "matching" + question String // the question text + options String? // JSON array of options for multiple choice + correctAnswer String // the correct answer(s) + explanation String? // why this answer is correct + points Int @default(1) + sortOrder Int @default(0) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + lesson SubjectLesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + answers StudentAnswer[] + + @@index([lessonId]) + @@map("lesson_questions") +} + +model StudentAnswer { + id String @id @default(cuid()) + questionId String + studentId String + answer String // the student's answer + isCorrect Boolean? + timeTakenMs Int? // how long the student took + attempts Int @default(1) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + question LessonQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) + student User @relation(fields: [studentId], references: [id]) + + @@unique([questionId, studentId]) + @@index([studentId]) + @@map("student_answers") +} diff --git a/src/app/api/competitions/[id]/leaderboard/route.ts b/src/app/api/competitions/[id]/leaderboard/route.ts new file mode 100644 index 0000000..685a7a0 --- /dev/null +++ b/src/app/api/competitions/[id]/leaderboard/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ─── GET /api/competitions/[id]/leaderboard ───────────────────────── + +async function getLeaderboard( + _request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { id } = await params; + + const competition = await db.competition.findUnique({ + where: { id }, + select: { id: true, schoolId: true, isPublic: true, deletedAt: true }, + }); + + if (!competition || competition.deletedAt) { + return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); + } + + // Access control + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.schoolId !== competition.schoolId && + !competition.isPublic + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const leaderboard = await db.competitionLeaderboard.findMany({ + where: { competitionId: id }, + orderBy: [{ rank: 'asc' }, { score: 'desc' }], + }); + + return NextResponse.json({ leaderboard }); + } catch (error) { + console.error('Leaderboard GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getLeaderboard, 'dataRead'); diff --git a/src/app/api/competitions/[id]/rewards/route.ts b/src/app/api/competitions/[id]/rewards/route.ts new file mode 100644 index 0000000..ee419c6 --- /dev/null +++ b/src/app/api/competitions/[id]/rewards/route.ts @@ -0,0 +1,138 @@ +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 rewardTypeEnum = z.enum(['digital_code', 'badge', 'certificate', 'experience', 'physical']); +const rewardProviderEnum = z.enum([ + 'netflix', 'amazon', 'cinema', 'theater', 'concert', 'custom', +]); + +const createRewardSchema = z.object({ + name: z.string().min(1).max(200), + description: z.string().max(2000).optional().nullable(), + rewardType: rewardTypeEnum, + rewardValue: z.string().max(1000).optional().nullable(), + rewardProvider: rewardProviderEnum.optional().nullable(), + rankRequirement: z.number().int().min(1).optional().nullable(), + pointsRequired: z.number().int().min(0).optional().nullable(), + quantity: z.number().int().min(1).default(1), + imageUrl: z.string().max(500).optional().nullable(), + isDemo: z.boolean().default(false), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +// ─── GET /api/competitions/[id]/rewards ───────────────────────────── + +async function getRewards( + _request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { id } = await params; + + const competition = await db.competition.findUnique({ + where: { id }, + select: { id: true, schoolId: true, isPublic: true, deletedAt: true }, + }); + + if (!competition || competition.deletedAt) { + return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); + } + + // Access control + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.schoolId !== competition.schoolId && + !competition.isPublic + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const rewards = await db.competitionReward.findMany({ + where: { competitionId: id }, + orderBy: { createdAt: 'asc' }, + include: { + _count: { select: { claims: true } }, + }, + }); + + return NextResponse.json({ rewards }); + } catch (error) { + console.error('Competition rewards GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ─── POST /api/competitions/[id]/rewards ──────────────────────────── + +async function createReward( + request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + 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 { id } = await params; + + const competition = await db.competition.findUnique({ + where: { id }, + select: { id: true, schoolId: true, deletedAt: true }, + }); + + if (!competition || competition.deletedAt) { + return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); + } + + // Verify access + if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId !== competition.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createRewardSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const reward = await db.competitionReward.create({ + data: { + competitionId: id, + name: parsed.data.name, + description: parsed.data.description ?? null, + rewardType: parsed.data.rewardType, + rewardValue: parsed.data.rewardValue ?? null, + rewardProvider: parsed.data.rewardProvider ?? null, + rankRequirement: parsed.data.rankRequirement ?? null, + pointsRequired: parsed.data.pointsRequired ?? null, + quantity: parsed.data.quantity, + imageUrl: parsed.data.imageUrl ?? null, + isDemo: parsed.data.isDemo, + }, + }); + + return NextResponse.json(reward, { status: 201 }); + } catch (error) { + console.error('Competition rewards POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getRewards, 'dataRead'); +export const POST = withRateLimit(createReward, 'dataWrite'); diff --git a/src/app/api/competitions/[id]/route.ts b/src/app/api/competitions/[id]/route.ts new file mode 100644 index 0000000..c73f6fc --- /dev/null +++ b/src/app/api/competitions/[id]/route.ts @@ -0,0 +1,433 @@ +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 competitionTypeEnum = z.enum(['class', 'inter_class', 'inter_school']); +const categoryEnum = z.enum([ + 'academic', 'sports', 'creativity', 'citizenship', + 'digital', 'reading', 'stem', 'other', +]); +const statusEnum = z.enum(['draft', 'registration', 'active', 'completed', 'cancelled']); +const scoringTypeEnum = z.enum(['points', 'rank', 'time', 'badge_count']); + +const updateCompetitionSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(5000).optional().nullable(), + competitionType: competitionTypeEnum.optional(), + category: categoryEnum.optional(), + subjectId: z.string().optional().nullable(), + status: statusEnum.optional(), + startDate: z.string().min(1).optional(), + endDate: z.string().min(1).optional(), + registrationDeadline: z.string().optional().nullable(), + maxParticipants: z.number().int().min(1).optional().nullable(), + scoringType: scoringTypeEnum.optional(), + rules: z.string().max(20000).optional().nullable(), + isPublic: z.boolean().optional(), +}); + +const registerParticipantSchema = z.object({ + action: z.literal('register'), + participantType: z.enum(['student', 'class_group', 'school']), + participantId: z.string().min(1), + userId: z.string().optional().nullable(), + notes: z.string().max(1000).optional().nullable(), +}); + +const updateScoreSchema = z.object({ + action: z.literal('update_score'), + participantId: z.string().min(1), + participantType: z.enum(['student', 'class_group', 'school']), + score: z.number().int().min(0), + isDisqualified: z.boolean().optional(), + notes: z.string().max(1000).optional().nullable(), +}); + +const actionSchema = z.discriminatedUnion('action', [ + registerParticipantSchema, + updateScoreSchema, +]); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +// ─── GET /api/competitions/[id] — get single competition ──────────── + +async function getCompetition( + _request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { id } = await params; + const competition = await db.competition.findUnique({ + where: { id }, + include: { + school: { select: { id: true, name: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + participants: { + orderBy: { score: 'desc' }, + }, + rewards: true, + leaderboard: { + orderBy: { rank: 'asc' }, + }, + _count: { select: { claims: true } }, + }, + }); + + if (!competition || competition.deletedAt) { + return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); + } + + // Access control: non-admins can only see their own school's competitions or public ones + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.schoolId !== competition.schoolId && + !competition.isPublic + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(competition); + } catch (error) { + console.error('Competition GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ─── PUT /api/competitions/[id] — update competition ──────────────── + +async function updateCompetition( + request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + 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 { id } = await params; + const existing = await db.competition.findUnique({ where: { id } }); + if (!existing || existing.deletedAt) { + return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); + } + + // Verify access + if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user?.role === 'TEACHER' && existing.createdById !== session.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = updateCompetitionSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const data: Record = {}; + if (parsed.data.title !== undefined) data.title = parsed.data.title; + if (parsed.data.description !== undefined) data.description = parsed.data.description; + if (parsed.data.competitionType !== undefined) data.competitionType = parsed.data.competitionType; + if (parsed.data.category !== undefined) data.category = parsed.data.category; + if (parsed.data.subjectId !== undefined) data.subjectId = parsed.data.subjectId; + if (parsed.data.status !== undefined) data.status = parsed.data.status; + if (parsed.data.startDate !== undefined) data.startDate = new Date(parsed.data.startDate); + if (parsed.data.endDate !== undefined) data.endDate = new Date(parsed.data.endDate); + if (parsed.data.registrationDeadline !== undefined) { + data.registrationDeadline = parsed.data.registrationDeadline + ? new Date(parsed.data.registrationDeadline) + : null; + } + if (parsed.data.maxParticipants !== undefined) data.maxParticipants = parsed.data.maxParticipants; + if (parsed.data.scoringType !== undefined) data.scoringType = parsed.data.scoringType; + if (parsed.data.rules !== undefined) data.rules = parsed.data.rules; + if (parsed.data.isPublic !== undefined) data.isPublic = parsed.data.isPublic; + + const competition = await db.competition.update({ + where: { id }, + data, + include: { + school: { select: { id: true, name: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + _count: { select: { participants: true, rewards: true } }, + }, + }); + + return NextResponse.json(competition); + } catch (error) { + console.error('Competition PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ─── POST /api/competitions/[id] — register participant or update score ── + +async function competitionAction( + request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + 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 { id } = await params; + const competition = await db.competition.findUnique({ where: { id } }); + if (!competition || competition.deletedAt) { + return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); + } + + // Verify access + if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId !== competition.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = actionSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + if (parsed.data.action === 'register') { + // Check if already registered + const existingParticipant = await db.competitionParticipant.findUnique({ + where: { + competitionId_participantType_participantId: { + competitionId: id, + participantType: parsed.data.participantType, + participantId: parsed.data.participantId, + }, + }, + }); + + if (existingParticipant) { + return NextResponse.json( + { error: 'Participant already registered' }, + { status: 409 } + ); + } + + // Check max participants + if (competition.maxParticipants) { + const currentCount = await db.competitionParticipant.count({ + where: { competitionId: id }, + }); + if (currentCount >= competition.maxParticipants) { + return NextResponse.json( + { error: 'Maximum participants reached' }, + { status: 400 } + ); + } + } + + // Check registration deadline + if (competition.registrationDeadline && new Date() > competition.registrationDeadline) { + return NextResponse.json( + { error: 'Registration deadline has passed' }, + { status: 400 } + ); + } + + const participant = await db.competitionParticipant.create({ + data: { + competitionId: id, + participantType: parsed.data.participantType, + participantId: parsed.data.participantId, + userId: parsed.data.userId ?? null, + notes: parsed.data.notes ?? null, + }, + }); + + // Also create leaderboard entry + let participantName = parsed.data.participantId; + if (parsed.data.participantType === 'student' && parsed.data.userId) { + const user = await db.user.findUnique({ + where: { id: parsed.data.userId }, + select: { firstName: true, lastName: true }, + }); + if (user) participantName = `${user.firstName} ${user.lastName}`; + } else if (parsed.data.participantType === 'class_group') { + const classGroup = await db.classGroup.findUnique({ + where: { id: parsed.data.participantId }, + select: { name: true }, + }); + if (classGroup) participantName = classGroup.name; + } else if (parsed.data.participantType === 'school') { + const school = await db.school.findUnique({ + where: { id: parsed.data.participantId }, + select: { name: true }, + }); + if (school) participantName = school.name; + } + + await db.competitionLeaderboard.upsert({ + where: { + competitionId_participantType_participantId: { + competitionId: id, + participantType: parsed.data.participantType, + participantId: parsed.data.participantId, + }, + }, + create: { + competitionId: id, + participantType: parsed.data.participantType, + participantId: parsed.data.participantId, + participantName, + score: 0, + }, + update: { + participantName, + }, + }); + + return NextResponse.json(participant, { status: 201 }); + } + + if (parsed.data.action === 'update_score') { + const participant = await db.competitionParticipant.findUnique({ + where: { + competitionId_participantType_participantId: { + competitionId: id, + participantType: parsed.data.participantType, + participantId: parsed.data.participantId, + }, + }, + }); + + if (!participant) { + return NextResponse.json( + { error: 'Participant not found' }, + { status: 404 } + ); + } + + const updatedParticipant = await db.competitionParticipant.update({ + where: { id: participant.id }, + data: { + score: parsed.data.score, + isDisqualified: parsed.data.isDisqualified ?? participant.isDisqualified, + notes: parsed.data.notes ?? participant.notes, + }, + }); + + // Update leaderboard + await db.competitionLeaderboard.upsert({ + where: { + competitionId_participantType_participantId: { + competitionId: id, + participantType: parsed.data.participantType, + participantId: parsed.data.participantId, + }, + }, + create: { + competitionId: id, + participantType: parsed.data.participantType, + participantId: parsed.data.participantId, + participantName: parsed.data.participantId, + score: parsed.data.score, + }, + update: { + score: parsed.data.score, + }, + }); + + // Recalculate ranks for this competition + const entries = await db.competitionLeaderboard.findMany({ + where: { competitionId: id }, + orderBy: { score: 'desc' }, + }); + + for (let i = 0; i < entries.length; i++) { + await db.competitionLeaderboard.update({ + where: { id: entries[i].id }, + data: { rank: i + 1 }, + }); + } + + // Also update participant rank + const rank = entries.findIndex( + (e) => + e.participantType === parsed.data.participantType && + e.participantId === parsed.data.participantId + ) + 1; + + await db.competitionParticipant.update({ + where: { id: participant.id }, + data: { rank: rank || null }, + }); + + return NextResponse.json(updatedParticipant); + } + + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } catch (error) { + console.error('Competition POST action error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ─── DELETE /api/competitions/[id] — soft delete competition ──────── + +async function deleteCompetition( + _request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + 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 { id } = await params; + const existing = await db.competition.findUnique({ where: { id } }); + if (!existing || existing.deletedAt) { + return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); + } + + // Verify access + if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user?.role === 'TEACHER' && existing.createdById !== session.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.competition.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Competition DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getCompetition, 'dataRead'); +export const PUT = withRateLimit(updateCompetition, 'dataWrite'); +export const POST = withRateLimit(competitionAction, 'dataWrite'); +export const DELETE = withRateLimit(deleteCompetition, 'dataWrite'); diff --git a/src/app/api/competitions/route.ts b/src/app/api/competitions/route.ts new file mode 100644 index 0000000..33ec617 --- /dev/null +++ b/src/app/api/competitions/route.ts @@ -0,0 +1,177 @@ +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 competitionTypeEnum = z.enum(['class', 'inter_class', 'inter_school']); +const categoryEnum = z.enum([ + 'academic', 'sports', 'creativity', 'citizenship', + 'digital', 'reading', 'stem', 'other', +]); +const statusEnum = z.enum(['draft', 'registration', 'active', 'completed', 'cancelled']); +const scoringTypeEnum = z.enum(['points', 'rank', 'time', 'badge_count']); + +const createCompetitionSchema = z.object({ + schoolId: z.string().min(1), + title: z.string().min(1).max(200), + description: z.string().max(5000).optional().nullable(), + competitionType: competitionTypeEnum, + category: categoryEnum, + subjectId: z.string().optional().nullable(), + status: statusEnum.default('draft'), + startDate: z.string().min(1), + endDate: z.string().min(1), + registrationDeadline: z.string().optional().nullable(), + maxParticipants: z.number().int().min(1).optional().nullable(), + scoringType: scoringTypeEnum.default('points'), + rules: z.string().max(20000).optional().nullable(), + isPublic: z.boolean().default(false), + isDemo: z.boolean().default(false), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +// ─── GET /api/competitions — list competitions ────────────────────── + +async function getCompetitions(request: Request): Promise { + 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 { searchParams } = new URL(request.url); + const schoolIdParam = searchParams.get('schoolId'); + const statusParam = searchParams.get('status'); + const competitionTypeParam = searchParams.get('competitionType'); + const categoryParam = searchParams.get('category'); + const limit = parseInt(searchParams.get('limit') || '20'); + const offset = parseInt(searchParams.get('offset') || '0'); + + let schoolId: string | undefined; + if (session.user?.role === 'SCHOOL_ADMIN') { + schoolId = session.user.schoolId ?? undefined; + } else { + schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; + } + + if (!schoolId) return NextResponse.json({ competitions: [], total: 0 }); + + const where: Record = { schoolId, deletedAt: null }; + if (statusParam) where.status = statusParam; + if (competitionTypeParam) where.competitionType = competitionTypeParam; + if (categoryParam) where.category = categoryParam; + + // For inter-school competitions, also show public ones from other schools + const isPublicParam = searchParams.get('isPublic'); + if (isPublicParam === 'true') { + delete where.schoolId; + where.isPublic = true; + where.deletedAt = null; + } + + 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 } }, + createdBy: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + _count: { select: { participants: true, rewards: true } }, + }, + }), + db.competition.count({ where }), + ]); + + return NextResponse.json({ competitions, total }); + } catch (error) { + console.error('Competitions GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ─── POST /api/competitions — create competition ──────────────────── + +async function createCompetition(request: Request): Promise { + 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 = createCompetitionSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { schoolId, startDate, endDate, registrationDeadline } = 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 }); + } + + // 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: parsed.data.competitionType, + category: parsed.data.category, + subjectId: parsed.data.subjectId ?? null, + status: parsed.data.status, + startDate: start, + endDate: end, + registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null, + maxParticipants: parsed.data.maxParticipants ?? null, + scoringType: parsed.data.scoringType, + rules: parsed.data.rules ?? null, + isPublic: parsed.data.isPublic, + isDemo: parsed.data.isDemo, + createdById: session.user.id, + }, + include: { + school: { select: { id: true, name: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + _count: { select: { participants: true, rewards: true } }, + }, + }); + + return NextResponse.json(competition, { status: 201 }); + } catch (error) { + console.error('Competitions POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getCompetitions, 'dataRead'); +export const POST = withRateLimit(createCompetition, 'dataWrite'); diff --git a/src/app/api/lesson-questions/[id]/route.ts b/src/app/api/lesson-questions/[id]/route.ts new file mode 100644 index 0000000..ed859f4 --- /dev/null +++ b/src/app/api/lesson-questions/[id]/route.ts @@ -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'); diff --git a/src/app/api/lesson-questions/route.ts b/src/app/api/lesson-questions/route.ts new file mode 100644 index 0000000..ac6e059 --- /dev/null +++ b/src/app/api/lesson-questions/route.ts @@ -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 = { 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'); diff --git a/src/app/api/reward-claims/route.ts b/src/app/api/reward-claims/route.ts new file mode 100644 index 0000000..a445679 --- /dev/null +++ b/src/app/api/reward-claims/route.ts @@ -0,0 +1,192 @@ +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 claimRewardSchema = z.object({ + competitionId: z.string().min(1), + rewardId: z.string().min(1), + code: z.string().max(500).optional().nullable(), + expiresAt: z.string().optional().nullable(), + notes: z.string().max(1000).optional().nullable(), + isDemo: z.boolean().default(false), +}); + +// ─── GET /api/reward-claims — list my claims (or all for admin) ───── + +async function getRewardClaims(request: Request): Promise { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const competitionIdParam = searchParams.get('competitionId'); + const statusParam = searchParams.get('status'); + + const where: Record = {}; + + // Students can only see their own claims + if (session.user?.role === 'STUDENT') { + where.userId = session.user.id; + } else if (session.user?.role === 'PARENT') { + // Parents can see claims linked to their children + where.userId = session.user.id; + } else { + // Teachers/admins can see claims for their school + const schoolIdParam = searchParams.get('schoolId'); + const schoolId = session.user?.role === 'SCHOOL_ADMIN' + ? session.user.schoolId + : schoolIdParam ?? session.user?.schoolId; + if (schoolId) where.schoolId = schoolId; + } + + if (competitionIdParam) where.competitionId = competitionIdParam; + if (statusParam) where.status = statusParam; + + const claims = await db.rewardClaim.findMany({ + where, + orderBy: { claimedAt: 'desc' }, + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + competition: { select: { id: true, title: true, competitionType: true } }, + reward: { select: { id: true, name: true, rewardType: true, rewardProvider: true, imageUrl: true } }, + school: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json({ claims }); + } catch (error) { + console.error('Reward claims GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ─── POST /api/reward-claims — claim a reward ────────────────────── + +async function claimReward(request: Request): Promise { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const body = await request.json(); + const parsed = claimRewardSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { competitionId, rewardId, code, expiresAt, notes, isDemo } = parsed.data; + + // Get the reward and competition + const [reward, competition] = await Promise.all([ + db.competitionReward.findUnique({ where: { id: rewardId } }), + db.competition.findUnique({ where: { id: competitionId } }), + ]); + + if (!reward) { + return NextResponse.json({ error: 'Reward not found' }, { status: 404 }); + } + if (!competition || competition.deletedAt) { + return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); + } + + // Verify competition is completed or active + if (competition.status !== 'completed' && competition.status !== 'active') { + return NextResponse.json( + { error: 'Competition is not active or completed' }, + { status: 400 } + ); + } + + // Check if reward is still available + if (reward.claimedCount >= reward.quantity) { + return NextResponse.json( + { error: 'Reward is no longer available' }, + { status: 400 } + ); + } + + // Check if user already claimed this reward + const existingClaim = await db.rewardClaim.findFirst({ + where: { + rewardId, + userId: session.user.id, + status: { notIn: ['revoked', 'expired'] }, + }, + }); + + if (existingClaim) { + return NextResponse.json( + { error: 'Reward already claimed' }, + { status: 409 } + ); + } + + // Check eligibility (rank or points requirement) + if (session.user?.role === 'STUDENT' || session.user?.role === 'TEACHER') { + const participant = await db.competitionParticipant.findFirst({ + where: { + competitionId, + userId: session.user.id, + isDisqualified: false, + }, + }); + + if (reward.rankRequirement && participant?.rank && participant.rank > reward.rankRequirement) { + return NextResponse.json( + { error: 'Rank requirement not met' }, + { status: 403 } + ); + } + + if (reward.pointsRequired && participant && participant.score < reward.pointsRequired) { + return NextResponse.json( + { error: 'Points requirement not met' }, + { status: 403 } + ); + } + } + + // DSGVO compliance: For digital codes, the code field stores the promo code + // In production, this should be encrypted at rest + const schoolId = session.user?.schoolId || competition.schoolId; + + const claim = await db.rewardClaim.create({ + data: { + schoolId, + competitionId, + rewardId, + userId: session.user.id, + code: code ?? null, + status: 'claimed', + expiresAt: expiresAt ? new Date(expiresAt) : null, + notes: notes ?? null, + isDemo: isDemo, + }, + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + competition: { select: { id: true, title: true } }, + reward: { select: { id: true, name: true, rewardType: true, rewardProvider: true, imageUrl: true } }, + }, + }); + + // Increment claimed count + await db.competitionReward.update({ + where: { id: rewardId }, + data: { claimedCount: { increment: 1 } }, + }); + + return NextResponse.json(claim, { status: 201 }); + } catch (error) { + console.error('Reward claim POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getRewardClaims, 'dataRead'); +export const POST = withRateLimit(claimReward, 'dataWrite'); diff --git a/src/app/api/student-answers/route.ts b/src/app/api/student-answers/route.ts new file mode 100644 index 0000000..eaeec9b --- /dev/null +++ b/src/app/api/student-answers/route.ts @@ -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 = { + 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 = {}; + + 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'); diff --git a/src/app/api/subject-lessons/[id]/route.ts b/src/app/api/subject-lessons/[id]/route.ts new file mode 100644 index 0000000..17d80bd --- /dev/null +++ b/src/app/api/subject-lessons/[id]/route.ts @@ -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'); diff --git a/src/app/api/subject-lessons/route.ts b/src/app/api/subject-lessons/route.ts new file mode 100644 index 0000000..232ec97 --- /dev/null +++ b/src/app/api/subject-lessons/route.ts @@ -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 = { + 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'); diff --git a/src/app/api/subject-topics/[id]/route.ts b/src/app/api/subject-topics/[id]/route.ts new file mode 100644 index 0000000..2050975 --- /dev/null +++ b/src/app/api/subject-topics/[id]/route.ts @@ -0,0 +1,187 @@ +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 topic with lessons ── +async function getTopic( + 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 topic = await db.subjectTopic.findUnique({ + where: { id, deletedAt: null }, + include: { + subject: { select: { id: true, name: true } }, + lessons: { + where: { deletedAt: null }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + include: { + _count: { select: { questions: { where: { isDemo: false } } } }, + }, + }, + }, + }); + + if (!topic) { + return NextResponse.json( + { error: 'Topic not found' }, + { status: 404 } + ); + } + + // Access check: same school + if (topic.schoolId !== session.user.schoolId) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + return NextResponse.json(topic); + } catch (error) { + console.error('SubjectTopic get error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// ── PUT: Update a subject topic ── +const updateTopicSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(2000).optional().nullable(), + gradeLevel: z.string().optional().nullable(), + curriculumCode: z.string().optional().nullable(), + sortOrder: z.number().int().optional(), + icon: z.string().optional().nullable(), + color: z.string().optional(), +}); + +async function updateTopic( + 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.subjectTopic.findUnique({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json( + { error: 'Topic not found' }, + { status: 404 } + ); + } + + if (existing.schoolId !== session.user.schoolId) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = updateTopicSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const topic = await db.subjectTopic.update({ + where: { id }, + data: parsed.data, + include: { + subject: { select: { id: true, name: true } }, + _count: { select: { lessons: { where: { deletedAt: null } } } }, + }, + }); + + return NextResponse.json(topic); + } catch (error) { + console.error('SubjectTopic update error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// ── DELETE: Soft delete a subject topic ── +async function deleteTopic( + 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.subjectTopic.findUnique({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json( + { error: 'Topic not found' }, + { status: 404 } + ); + } + + if (existing.schoolId !== session.user.schoolId) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Soft delete the topic (cascade will handle lessons) + const deleted = await db.subjectTopic.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ + message: 'Topic deleted', + id: deleted.id, + }); + } catch (error) { + console.error('SubjectTopic delete error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export const GET = withRateLimit(getTopic, 'dataRead'); +export const PUT = withRateLimit(updateTopic, 'dataWrite'); +export const DELETE = withRateLimit(deleteTopic, 'dataWrite'); diff --git a/src/app/api/subject-topics/route.ts b/src/app/api/subject-topics/route.ts new file mode 100644 index 0000000..ec69222 --- /dev/null +++ b/src/app/api/subject-topics/route.ts @@ -0,0 +1,128 @@ +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 topics ── +async function getTopics(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 schoolId = searchParams.get('schoolId'); + const subjectId = searchParams.get('subjectId'); + const gradeLevel = searchParams.get('gradeLevel'); + const curriculumCode = searchParams.get('curriculumCode'); + + const effectiveSchoolId = schoolId || session.user.schoolId; + if (!effectiveSchoolId) { + return NextResponse.json([]); + } + + const where: Record = { + deletedAt: null, + schoolId: effectiveSchoolId, + }; + + if (subjectId) where.subjectId = subjectId; + if (gradeLevel) where.gradeLevel = gradeLevel; + if (curriculumCode) where.curriculumCode = curriculumCode; + + const topics = await db.subjectTopic.findMany({ + where, + include: { + subject: { select: { id: true, name: true } }, + _count: { select: { lessons: { where: { deletedAt: null } } } }, + }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + }); + + return NextResponse.json(topics); + } catch (error) { + console.error('SubjectTopics list error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// ── POST: Create a new subject topic ── +const createTopicSchema = z.object({ + schoolId: z.string().optional(), + subjectId: z.string().min(1), + title: z.string().min(1).max(200), + description: z.string().max(2000).optional().nullable(), + gradeLevel: z.string().optional().nullable(), + curriculumCode: z.string().optional().nullable(), + sortOrder: z.number().int().default(0), + icon: z.string().optional().nullable(), + color: z.string().default('#10b981'), +}); + +async function createTopic(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 = createTopicSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const schoolId = parsed.data.schoolId || session.user.schoolId; + if (!schoolId) { + return NextResponse.json( + { error: 'User must belong to a school to create topics' }, + { status: 400 } + ); + } + + const topic = await db.subjectTopic.create({ + data: { + schoolId, + subjectId: parsed.data.subjectId, + title: parsed.data.title, + description: parsed.data.description, + gradeLevel: parsed.data.gradeLevel, + curriculumCode: parsed.data.curriculumCode, + sortOrder: parsed.data.sortOrder, + icon: parsed.data.icon, + color: parsed.data.color, + }, + include: { + subject: { select: { id: true, name: true } }, + _count: { select: { lessons: { where: { deletedAt: null } } } }, + }, + }); + + return NextResponse.json(topic, { status: 201 }); + } catch (error) { + console.error('SubjectTopic create error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export const GET = withRateLimit(getTopics, 'dataRead'); +export const POST = withRateLimit(createTopic, 'dataWrite'); diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 64094c1..a28abb3 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -57,6 +57,7 @@ import { Briefcase, Clock as ClockIcon, Library as LibraryIcon, + Trophy, } from 'lucide-react'; import { Sidebar, @@ -150,6 +151,7 @@ import HomeworkView from './homework-view'; import PortfolioView from './portfolio-view'; import TimetableView from './timetable-view'; import ResourceLibraryView from './resource-library-view'; +import CompetitionsView from './competitions-view'; // Dynamic imports for heavy components with loading skeletons const AnalyticsView = dynamic(() => import('./analytics-view'), { @@ -207,6 +209,7 @@ const navSections: NavSection[] = [ { key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' }, { key: 'timetable', icon: ClockIcon, labelKey: 'nav.timetable' }, { key: 'resources', icon: LibraryIcon, labelKey: 'nav.resources' }, + { key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' }, ], }, { @@ -233,6 +236,7 @@ const studentNavSections: NavSection[] = [ { key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' }, { key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' }, { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, + { key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' }, ], }, ]; @@ -376,6 +380,7 @@ function renderView(view: ViewName) { case 'portfolio': return ; case 'timetable': return ; case 'resources': return ; + case 'competitions': return ; default: return ; } } diff --git a/src/components/competitions-view.tsx b/src/components/competitions-view.tsx new file mode 100644 index 0000000..6bd0358 --- /dev/null +++ b/src/components/competitions-view.tsx @@ -0,0 +1,1793 @@ +'use client'; + +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Trophy, + Medal, + Award, + Gift, + Clock, + Users, + Target, + Crown, + Star, + Flame, + Zap, + TrendingUp, + BookOpen, + Plus, + Search, + Filter, + ChevronDown, + ChevronUp, + X, + Check, + Calendar, + Eye, + Trash2, + Edit3, + AlertTriangle, + Info, + ExternalLink, + BarChart3, + Sparkles, + Shield, + Timer, + UserPlus, + XCircle, + Play, + Pause, + CheckCircle2, + CircleDot, + Lock, + Globe, + Ticket, + Film, + Music, + Theater, + ShoppingBag, + Tag, +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Separator } from '@/components/ui/separator'; +import { Progress } from '@/components/ui/progress'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useAppStore, type CurrentUser } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { + fetchCompetitions, + fetchCompetition, + createCompetition, + updateCompetition, + deleteCompetition, + registerCompetitionParticipant, + fetchCompetitionLeaderboard, + fetchCompetitionRewards, + createCompetitionReward, + fetchRewardClaims, + claimReward, + type CompetitionData, + type CompetitionLeaderboardEntry, + type CompetitionRewardData, + type RewardClaimData, +} from '@/lib/api'; +import { toast } from 'sonner'; + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +const COMPETITION_TYPES = ['class', 'inter_class', 'inter_school'] as const; +const CATEGORIES = ['academic', 'sports', 'creativity', 'citizenship', 'digital', 'reading', 'stem', 'other'] as const; +const STATUSES = ['draft', 'registration', 'active', 'completed', 'cancelled'] as const; +const SCORING_TYPES = ['points', 'rank', 'time', 'badge_count'] as const; +const REWARD_TYPES = ['digital_code', 'badge', 'certificate', 'experience', 'physical'] as const; +const REWARD_PROVIDERS = ['netflix', 'amazon', 'cinema', 'theater', 'concert', 'custom'] as const; + +function getTypeLabel(type: string): string { + switch (type) { + case 'class': return t('competition.type.class'); + case 'inter_class': return t('competition.type.inter_class'); + case 'inter_school': return t('competition.type.inter_school'); + default: return type; + } +} + +function getCategoryLabel(category: string): string { + switch (category) { + case 'academic': return t('competition.category.academic'); + case 'sports': return t('competition.category.sports'); + case 'creativity': return t('competition.category.creativity'); + case 'citizenship': return t('competition.category.citizenship'); + case 'digital': return t('competition.category.digital'); + case 'reading': return t('competition.category.reading'); + case 'stem': return t('competition.category.stem'); + case 'other': return t('competition.category.other'); + default: return category; + } +} + +function getStatusLabel(status: string): string { + switch (status) { + case 'draft': return t('competition.status.draft'); + case 'registration': return t('competition.status.registration'); + case 'active': return t('competition.status.active'); + case 'completed': return t('competition.status.completed'); + case 'cancelled': return t('competition.status.cancelled'); + default: return status; + } +} + +function getStatusColor(status: string): string { + switch (status) { + case 'draft': return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'; + case 'registration': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'; + case 'active': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300'; + case 'completed': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300'; + case 'cancelled': return 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300'; + default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'; + } +} + +function getStatusBorderColor(status: string): string { + switch (status) { + case 'draft': return 'border-l-gray-400'; + case 'registration': return 'border-l-blue-400'; + case 'active': return 'border-l-emerald-500'; + case 'completed': return 'border-l-amber-400'; + case 'cancelled': return 'border-l-red-400'; + default: return 'border-l-gray-400'; + } +} + +function TypeIcon({ type, className }: { type: string; className?: string }) { + switch (type) { + case 'class': return ; + case 'inter_class': return ; + case 'inter_school': return ; + default: return ; + } +} + +function CategoryIcon({ category, className }: { category: string; className?: string }) { + switch (category) { + case 'academic': return ; + case 'sports': return ; + case 'creativity': return ; + case 'citizenship': return ; + case 'digital': return ; + case 'reading': return ; + case 'stem': return ; + case 'other': return ; + default: return ; + } +} + +function ProviderIconComp({ provider, className }: { provider: string | null; className?: string }) { + switch (provider) { + case 'netflix': return ; + case 'amazon': return ; + case 'cinema': return ; + case 'theater': return ; + case 'concert': return ; + case 'custom': return ; + default: return ; + } +} + +function getRewardTypeLabel(type: string): string { + switch (type) { + case 'digital_code': return t('competition.reward.type.digital_code'); + case 'badge': return t('competition.reward.type.badge'); + case 'certificate': return t('competition.reward.type.certificate'); + case 'experience': return t('competition.reward.type.experience'); + case 'physical': return t('competition.reward.type.physical'); + default: return type; + } +} + +function getRewardProviderLabel(provider: string | null): string { + switch (provider) { + case 'netflix': return t('competition.reward.provider.netflix'); + case 'amazon': return t('competition.reward.provider.amazon'); + case 'cinema': return t('competition.reward.provider.cinema'); + case 'theater': return t('competition.reward.provider.theater'); + case 'concert': return t('competition.reward.provider.concert'); + case 'custom': return t('competition.reward.provider.custom'); + default: return ''; + } +} + +function getScoringLabel(type: string): string { + switch (type) { + case 'points': return t('competition.scoring.points'); + case 'rank': return t('competition.scoring.rank'); + case 'time': return t('competition.scoring.time'); + case 'badge_count': return t('competition.scoring.badge_count'); + default: return type; + } +} + +function getTimeProgress(startDate: string, endDate: string): number { + const now = Date.now(); + const start = new Date(startDate).getTime(); + const end = new Date(endDate).getTime(); + if (now < start) return 0; + if (now > end) return 100; + const total = end - start; + if (total <= 0) return 100; + return Math.round(((now - start) / total) * 100); +} + +function getTimeRemaining(endDate: string): string { + const now = Date.now(); + const end = new Date(endDate).getTime(); + const diff = end - now; + if (diff <= 0) return t('competition.status.completed'); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + if (days > 0) return `${days}d ${hours}h`; + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + return `${hours}h ${minutes}m`; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + }); +} + +function formatDateTime(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +/* ── Sub-components ──────────────────────────────────────────────── */ + +function CompetitionCard({ + competition, + onView, + onRegister, +}: { + competition: CompetitionData; + onView: (c: CompetitionData) => void; + onRegister: (c: CompetitionData) => void; +}) { + const progress = getTimeProgress(competition.startDate, competition.endDate); + const isActive = competition.status === 'active'; + const isRegistration = competition.status === 'registration'; + const isCompleted = competition.status === 'completed'; + const participantCount = competition._count?.participants ?? 0; + + return ( + + onView(competition)} + > + +
+
+
+ +
+
+ {competition.title} + + + {getCategoryLabel(competition.category)} + +
+
+ + {getStatusLabel(competition.status)} + +
+
+ + {competition.description && ( +

{competition.description}

+ )} + +
+ + + {participantCount} + {competition.maxParticipants ? ` / ${competition.maxParticipants}` : ''} + + + + {formatDate(competition.startDate)} + + + + {getTypeLabel(competition.competitionType)} + +
+ + {(isActive || isRegistration) && ( +
+
+ + + {getTimeRemaining(competition.endDate)} + + {progress}% +
+ +
+ )} +
+ + + {isRegistration && ( + + )} + {isActive && ( + + )} + +
+
+ ); +} + +function LeaderboardTable({ entries }: { entries: CompetitionLeaderboardEntry[] }) { + if (entries.length === 0) { + return ( +
+ +

{t('competition.no_competitions')}

+
+ ); + } + + return ( +
+ + + + + + + + + + {entries.map((entry, idx) => { + const rank = entry.rank ?? idx + 1; + return ( + + + + + + ); + })} + +
{t('competition.scoring.rank')}{t('competition.participants')}{t('competition.scoring.points')}
+
+ {rank === 1 ? ( + + ) : rank === 2 ? ( + + ) : rank === 3 ? ( + + ) : ( + {rank} + )} +
+
+ {entry.participantName} + + {entry.score} +
+
+ ); +} + +function RewardCard({ + reward, + onClaim, + canClaim, + isClaiming, +}: { + reward: CompetitionRewardData; + onClaim: (r: CompetitionRewardData) => void; + canClaim: boolean; + isClaiming: boolean; +}) { + const available = reward.quantity - reward.claimedCount; + + return ( + + + +
+
+ +
+
+

{reward.name}

+

+ {getRewardTypeLabel(reward.rewardType)} + {reward.rewardProvider && ` - ${getRewardProviderLabel(reward.rewardProvider)}`} +

+ {reward.description && ( +

{reward.description}

+ )} +
+ + + {available} {t('competition.reward.available')} + + {reward.rankRequirement && ( + + + #{reward.rankRequirement} + + )} + {reward.pointsRequired && ( + + + {reward.pointsRequired} pts + + )} +
+
+
+ {canClaim && available > 0 && ( + + )} + {available <= 0 && ( +

+ {t('competition.reward.not_available')} +

+ )} +
+
+
+ ); +} + +/* ── Main Component ──────────────────────────────────────────────── */ + +export default function CompetitionsView() { + const currentUser = useAppStore((s) => s.currentUser); + const [activeTab, setActiveTab] = useState('overview'); + const [competitions, setCompetitions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + const [filterCategory, setFilterCategory] = useState('all'); + + // Detail dialog + const [selectedCompetition, setSelectedCompetition] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [detailTab, setDetailTab] = useState('overview'); + const [leaderboard, setLeaderboard] = useState([]); + const [rewards, setRewards] = useState([]); + const [isLoadingDetail, setIsLoadingDetail] = useState(false); + + // Create dialog + const [createOpen, setCreateOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [createForm, setCreateForm] = useState({ + title: '', + description: '', + competitionType: 'class', + category: 'academic', + startDate: '', + endDate: '', + registrationDeadline: '', + maxParticipants: '', + scoringType: 'points', + rules: '', + isPublic: false, + }); + + // Add reward dialog + const [addRewardOpen, setAddRewardOpen] = useState(false); + const [isAddingReward, setIsAddingReward] = useState(false); + const [rewardForm, setRewardForm] = useState({ + name: '', + description: '', + rewardType: 'digital_code', + rewardValue: '', + rewardProvider: 'custom', + rankRequirement: '', + pointsRequired: '', + quantity: '1', + }); + + // Reward claims + const [myClaims, setMyClaims] = useState([]); + const [claimingRewardId, setClaimingRewardId] = useState(null); + + // Register + const [isRegistering, setIsRegistering] = useState(false); + + const isAdmin = currentUser?.role === 'admin'; + const isTeacher = currentUser?.role === 'teacher'; + const isStudent = currentUser?.role === 'student'; + const canCreate = isAdmin || isTeacher; + const schoolId = currentUser?.schoolId; + + /* ── Fetch competitions ──────────────────────────────────────── */ + const loadCompetitions = useCallback(async () => { + if (!schoolId) return; + setIsLoading(true); + try { + const result = await fetchCompetitions(schoolId, undefined, undefined, undefined, 100); + setCompetitions(result.competitions); + } catch (err) { + console.error('Failed to load competitions:', err); + toast.error('Failed to load competitions'); + } finally { + setIsLoading(false); + } + }, [schoolId]); + + useEffect(() => { + loadCompetitions(); + }, [loadCompetitions]); + + /* ── Fetch my claims ─────────────────────────────────────────── */ + const loadMyClaims = useCallback(async () => { + if (!schoolId) return; + try { + const result = await fetchRewardClaims(schoolId, undefined, undefined); + setMyClaims(result.claims.filter((c) => c.userId === currentUser?.id)); + } catch { + // silently fail + } + }, [schoolId, currentUser?.id]); + + useEffect(() => { + loadMyClaims(); + }, [loadMyClaims]); + + /* ── Filtered competitions ───────────────────────────────────── */ + const filteredCompetitions = useMemo(() => { + let result = competitions; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (c) => + c.title.toLowerCase().includes(q) || + (c.description?.toLowerCase().includes(q) ?? false) + ); + } + if (filterType !== 'all') { + result = result.filter((c) => c.competitionType === filterType); + } + if (filterStatus !== 'all') { + result = result.filter((c) => c.status === filterStatus); + } + if (filterCategory !== 'all') { + result = result.filter((c) => c.category === filterCategory); + } + return result; + }, [competitions, searchQuery, filterType, filterStatus, filterCategory]); + + const activeCompetitions = useMemo(() => filteredCompetitions.filter((c) => c.status === 'active'), [filteredCompetitions]); + const registrationCompetitions = useMemo(() => filteredCompetitions.filter((c) => c.status === 'registration'), [filteredCompetitions]); + const completedCompetitions = useMemo(() => filteredCompetitions.filter((c) => c.status === 'completed'), [filteredCompetitions]); + const draftCompetitions = useMemo(() => filteredCompetitions.filter((c) => c.status === 'draft'), [filteredCompetitions]); + + /* ── My competitions ─────────────────────────────────────────── */ + const myCompetitions = useMemo(() => { + if (canCreate) { + return filteredCompetitions.filter((c) => c.createdById === currentUser?.id); + } + // For students, show competitions they're registered in (we can't easily tell without a participants endpoint) + // For now, show registration and active competitions + return filteredCompetitions.filter((c) => c.status === 'registration' || c.status === 'active'); + }, [filteredCompetitions, canCreate, currentUser?.id]); + + /* ── View detail ─────────────────────────────────────────────── */ + const handleViewCompetition = useCallback(async (comp: CompetitionData) => { + setSelectedCompetition(comp); + setDetailOpen(true); + setDetailTab('overview'); + setIsLoadingDetail(true); + + try { + const [lbResult, rwResult] = await Promise.all([ + fetchCompetitionLeaderboard(comp.id).catch(() => ({ leaderboard: [] })), + fetchCompetitionRewards(comp.id).catch(() => ({ rewards: [] })), + ]); + setLeaderboard(lbResult.leaderboard); + setRewards(rwResult.rewards); + } catch { + // ignore + } finally { + setIsLoadingDetail(false); + } + }, []); + + /* ── Register ────────────────────────────────────────────────── */ + const handleRegister = useCallback(async (comp: CompetitionData) => { + if (!currentUser || !schoolId) return; + setIsRegistering(true); + try { + await registerCompetitionParticipant(comp.id, { + participantType: 'user', + participantId: currentUser.id, + userId: currentUser.id, + }); + toast.success(t('competition.register')); + loadCompetitions(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to register'; + toast.error(message); + } finally { + setIsRegistering(false); + } + }, [currentUser, schoolId, loadCompetitions]); + + /* ── Create competition ──────────────────────────────────────── */ + const handleCreate = useCallback(async () => { + if (!schoolId) return; + if (!createForm.title.trim()) { + toast.error('Title is required'); + return; + } + if (!createForm.startDate || !createForm.endDate) { + toast.error('Start and end dates are required'); + return; + } + setIsCreating(true); + try { + await createCompetition({ + schoolId, + title: createForm.title.trim(), + description: createForm.description.trim() || null, + competitionType: createForm.competitionType, + category: createForm.category, + startDate: new Date(createForm.startDate).toISOString(), + endDate: new Date(createForm.endDate).toISOString(), + registrationDeadline: createForm.registrationDeadline ? new Date(createForm.registrationDeadline).toISOString() : null, + maxParticipants: createForm.maxParticipants ? parseInt(createForm.maxParticipants, 10) : null, + scoringType: createForm.scoringType, + rules: createForm.rules.trim() || null, + isPublic: createForm.isPublic, + status: 'registration', + }); + toast.success(t('competition.create')); + setCreateOpen(false); + setCreateForm({ + title: '', + description: '', + competitionType: 'class', + category: 'academic', + startDate: '', + endDate: '', + registrationDeadline: '', + maxParticipants: '', + scoringType: 'points', + rules: '', + isPublic: false, + }); + loadCompetitions(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create competition'; + toast.error(message); + } finally { + setIsCreating(false); + } + }, [schoolId, createForm, loadCompetitions]); + + /* ── Add reward ──────────────────────────────────────────────── */ + const handleAddReward = useCallback(async () => { + if (!selectedCompetition) return; + if (!rewardForm.name.trim()) { + toast.error('Reward name is required'); + return; + } + setIsAddingReward(true); + try { + await createCompetitionReward(selectedCompetition.id, { + name: rewardForm.name.trim(), + description: rewardForm.description.trim() || null, + rewardType: rewardForm.rewardType, + rewardValue: rewardForm.rewardValue.trim() || null, + rewardProvider: rewardForm.rewardProvider, + rankRequirement: rewardForm.rankRequirement ? parseInt(rewardForm.rankRequirement, 10) : null, + pointsRequired: rewardForm.pointsRequired ? parseInt(rewardForm.pointsRequired, 10) : null, + quantity: parseInt(rewardForm.quantity, 10) || 1, + }); + toast.success(t('competition.reward.create')); + setAddRewardOpen(false); + setRewardForm({ + name: '', + description: '', + rewardType: 'digital_code', + rewardValue: '', + rewardProvider: 'custom', + rankRequirement: '', + pointsRequired: '', + quantity: '1', + }); + // Refresh rewards + const rwResult = await fetchCompetitionRewards(selectedCompetition.id); + setRewards(rwResult.rewards); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to add reward'; + toast.error(message); + } finally { + setIsAddingReward(false); + } + }, [selectedCompetition, rewardForm]); + + /* ── Claim reward ────────────────────────────────────────────── */ + const handleClaimReward = useCallback(async (reward: CompetitionRewardData) => { + if (!selectedCompetition || !currentUser) return; + setClaimingRewardId(reward.id); + try { + await claimReward({ + competitionId: selectedCompetition.id, + rewardId: reward.id, + }); + toast.success(t('competition.reward.claim')); + // Refresh rewards + const rwResult = await fetchCompetitionRewards(selectedCompetition.id); + setRewards(rwResult.rewards); + loadMyClaims(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to claim reward'; + toast.error(message); + } finally { + setClaimingRewardId(null); + } + }, [selectedCompetition, currentUser, loadMyClaims]); + + /* ── Delete competition ──────────────────────────────────────── */ + const handleDelete = useCallback(async (compId: string) => { + if (!confirm(t('competition.delete') + '?')) return; + try { + await deleteCompetition(compId); + toast.success(t('competition.delete')); + setDetailOpen(false); + setSelectedCompetition(null); + loadCompetitions(); + } catch { + toast.error('Failed to delete competition'); + } + }, [loadCompetitions]); + + /* ── Render ──────────────────────────────────────────────────── */ + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{t('competition.title')}

+

+ {filteredCompetitions.length} {t('competition.name').toLowerCase()} +

+
+
+ {canCreate && ( + + )} +
+ + {/* Search & Filters */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 min-h-[44px]" + /> +
+ + + +
+ + {/* Tabs */} + + + {t('competition.details')} + {t('competition.manage')} + {t('competition.leaderboard')} + {t('competition.rewards')} + + + {/* Overview Tab */} + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : ( + <> + {/* Registration Open */} + {registrationCompetitions.length > 0 && ( +
+
+ +

{t('competition.status.registration')}

+ + {registrationCompetitions.length} + +
+
+ + {registrationCompetitions.map((comp) => ( + + ))} + +
+
+ )} + + {/* Active Competitions */} + {activeCompetitions.length > 0 && ( +
+
+ +

{t('competition.status.active')}

+ + {activeCompetitions.length} + +
+
+ + {activeCompetitions.map((comp) => ( + + ))} + +
+
+ )} + + {/* Completed */} + {completedCompetitions.length > 0 && ( +
+
+ +

{t('competition.status.completed')}

+ + {completedCompetitions.length} + +
+
+ + {completedCompetitions.map((comp) => ( + + ))} + +
+
+ )} + + {/* Drafts (admin/teacher only) */} + {canCreate && draftCompetitions.length > 0 && ( +
+
+ +

{t('competition.status.draft')}

+ + {draftCompetitions.length} + +
+
+ + {draftCompetitions.map((comp) => ( + + ))} + +
+
+ )} + + {/* Empty state */} + {filteredCompetitions.length === 0 && ( + + +

{t('competition.no_competitions')}

+ {canCreate && ( + + )} +
+ )} + + )} +
+ + {/* My Competitions Tab */} + + {myCompetitions.length === 0 ? ( + + +

{t('competition.no_competitions')}

+
+ ) : ( +
+ + {myCompetitions.map((comp) => ( + + ))} + +
+ )} +
+ + {/* Leaderboard Tab */} + + + + + + {t('competition.leaderboard')} + + + Select a competition to view the leaderboard + + + + {activeCompetitions.length === 0 ? ( +
+ +

{t('competition.no_competitions')}

+
+ ) : ( +
+ + {leaderboard.length > 0 && ( + + )} +
+ )} +
+
+
+ + {/* Rewards Tab */} + + {/* My Claims */} + {myClaims.length > 0 && ( +
+

+ + {t('competition.reward.my_claims')} +

+
+ {myClaims.map((claim) => { + return ( + + +
+
+ +
+
+

{claim.reward?.name}

+

+ {claim.competition?.title} +

+
+ + {claim.status === 'claimed' ? t('competition.reward.status.claimed') : claim.status === 'pending' ? t('competition.reward.status.pending') : claim.status} + + {claim.code && ( + + {claim.code} + + )} +
+ {claim.expiresAt && ( +

+ + {t('competition.reward.expires')}: {formatDate(claim.expiresAt)} +

+ )} +
+
+
+
+ ); + })} +
+
+ )} + + {/* Available rewards from competitions */} +
+

+ + {t('competition.rewards')} +

+ {activeCompetitions.length === 0 && completedCompetitions.length === 0 ? ( +
+ +

{t('competition.no_competitions')}

+
+ ) : ( +
+ {[...activeCompetitions, ...completedCompetitions].map((comp) => ( + + ))} +
+ )} +
+
+
+ + {/* ── Competition Detail Dialog ──────────────────────────── */} + + + {selectedCompetition && ( + <> + +
+
+ +
+
+ {selectedCompetition.title} + + {getTypeLabel(selectedCompetition.competitionType)} · {getCategoryLabel(selectedCompetition.category)} + +
+ + {getStatusLabel(selectedCompetition.status)} + +
+
+ + + + {t('competition.details')} + {t('competition.leaderboard')} + {t('competition.rewards')} + + + + {selectedCompetition.description && ( +
+

{t('competition.description')}

+

{selectedCompetition.description}

+
+ )} + +
+
+ +

{formatDate(selectedCompetition.startDate)}

+
+
+ +

{formatDate(selectedCompetition.endDate)}

+
+
+ +

{getScoringLabel(selectedCompetition.scoringType)}

+
+
+ +

{selectedCompetition.maxParticipants ?? 'Unlimited'}

+
+ {selectedCompetition.registrationDeadline && ( +
+ +

{formatDate(selectedCompetition.registrationDeadline)}

+
+ )} +
+ +

{selectedCompetition._count?.participants ?? 0}

+
+
+ + {selectedCompetition.rules && ( +
+

{t('competition.rules')}

+
+ {selectedCompetition.rules} +
+
+ )} + + {(selectedCompetition.status === 'active' || selectedCompetition.status === 'registration') && ( +
+
+ {t('competition.status.active')} + {getTimeRemaining(selectedCompetition.endDate)} +
+ +
+ )} + + {/* DSGVO / Jugendschutz notices */} +
+
+ + {t('competition.dsgvo_notice')} +
+
+ + {t('competition.jugendschutz_notice')} +
+
+ + {/* Actions */} +
+ {selectedCompetition.status === 'registration' && isStudent && ( + + )} + {canCreate && selectedCompetition.createdById === currentUser?.id && ( + + )} +
+
+ + + {isLoadingDetail ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : ( + + )} +
+ + + {isLoadingDetail ? ( +
+ {[1, 2].map((i) => ( + + ))} +
+ ) : rewards.length === 0 ? ( +
+ +

{t('competition.no_competitions')}

+
+ ) : ( +
+ {rewards.map((reward) => ( + + ))} +
+ )} + {canCreate && ( + + )} +
+
+ + )} +
+
+ + {/* ── Create Competition Dialog ──────────────────────────── */} + + + + + + {t('competition.create')} + + + Create a new competition for your school + + + +
+
+ + setCreateForm((f) => ({ ...f, title: e.target.value }))} + placeholder="Competition title" + className="mt-1 min-h-[44px]" + /> +
+ +
+ +