Round 19: Competition system, German curriculum learning, enhanced notebooks, student dashboard
- Fix portfolio-view.tsx: replace entry.isPublic with form.isPublic (runtime error) - Add Competition system: 5 Prisma models (Competition, CompetitionParticipant, CompetitionReward, CompetitionLeaderboard, RewardClaim) - Add German curriculum learning: 4 Prisma models (SubjectTopic, SubjectLesson, LessonQuestion, StudentAnswer) - Add 12 API routes for competitions and rewards - Add 7 API routes for subject topics, lessons, questions, and student answers - Create competitions-view.tsx with full CRUD, leaderboard, rewards, registration - Enhance notebooks-view.tsx with 'Lernen' tab for German curriculum subjects - Add student-specific dashboard with Papier sparen banner, notebook quick access, learning progress - Add competitions to teacher and student navigation - Add 130+ i18n keys for DE and EN (competitions, rewards, learning, environment) - Enhanced student nav with competitions and notebooks - DSGVO-compliant reward claims with status lifecycle
This commit is contained in:
1 parent
74404708e5
commit
849b48a5a4
22 files changed
+6543
-5
No files matched your search
+228
-1
@@ -74,6 +74,9 @@ model School {
|
|||||||
badges Badge[]
|
badges Badge[]
|
||||||
studentBadges StudentBadge[]
|
studentBadges StudentBadge[]
|
||||||
newsletters Newsletter[]
|
newsletters Newsletter[]
|
||||||
|
competitions Competition[]
|
||||||
|
rewardClaims RewardClaim[]
|
||||||
|
subjectTopics SubjectTopic[]
|
||||||
district SchoolDistrict? @relation(fields: [districtId], references: [id])
|
district SchoolDistrict? @relation(fields: [districtId], references: [id])
|
||||||
districtId String?
|
districtId String?
|
||||||
}
|
}
|
||||||
@@ -122,6 +125,10 @@ model User {
|
|||||||
eventRegistrations EventRegistration[]
|
eventRegistrations EventRegistration[]
|
||||||
awardedBadges StudentBadge[]
|
awardedBadges StudentBadge[]
|
||||||
newsletters Newsletter[]
|
newsletters Newsletter[]
|
||||||
|
competitionsCreated Competition[]
|
||||||
|
competitionParticipations CompetitionParticipant[]
|
||||||
|
competitionClaims RewardClaim[]
|
||||||
|
studentAnswers StudentAnswer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SchoolYear {
|
model SchoolYear {
|
||||||
@@ -266,6 +273,8 @@ model Subject {
|
|||||||
homeworks Homework[]
|
homeworks Homework[]
|
||||||
timetableSlots TimetableSlot[]
|
timetableSlots TimetableSlot[]
|
||||||
resources Resource[]
|
resources Resource[]
|
||||||
|
topics SubjectTopic[]
|
||||||
|
competitions Competition[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CompetencyTemplate {
|
model CompetencyTemplate {
|
||||||
@@ -850,7 +859,7 @@ model NotebookPage {
|
|||||||
notebookId String
|
notebookId String
|
||||||
pageNumber Int
|
pageNumber Int
|
||||||
title String?
|
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
|
textContent String? // markdown/rich text content
|
||||||
drawingData String? // JSON with strokes/paths for canvas
|
drawingData String? // JSON with strokes/paths for canvas
|
||||||
background String @default("lined") // lined | grid | blank | dotted | music
|
background String @default("lined") // lined | grid | blank | dotted | music
|
||||||
@@ -1593,6 +1602,134 @@ model StudentBadge {
|
|||||||
@@map("student_badges")
|
@@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 ──────────────────────────────────────
|
// ─── Newsletter / School Blog ──────────────────────────────────────
|
||||||
|
|
||||||
model Newsletter {
|
model Newsletter {
|
||||||
@@ -1618,3 +1755,93 @@ model Newsletter {
|
|||||||
@@index([schoolId, isPublished, publishedAt])
|
@@index([schoolId, isPublished, publishedAt])
|
||||||
@@map("newsletters")
|
@@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")
|
||||||
|
}
|
||||||
@@ -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<NextResponse> {
|
||||||
|
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');
|
||||||
@@ -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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { 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');
|
||||||
@@ -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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { 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<string, unknown> = {};
|
||||||
|
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<NextResponse> {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { 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<NextResponse> {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { 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');
|
||||||
@@ -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<NextResponse> {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { 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<string, unknown> = { 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<NextResponse> {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = 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');
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { withRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
// ── GET: Get a single lesson question ──
|
||||||
|
async function getQuestion(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const question = await db.lessonQuestion.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
lesson: {
|
||||||
|
select: { id: true, topic: { select: { schoolId: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!question) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Question not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access check: same school
|
||||||
|
if (question.lesson.topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For students: hide correct answer and explanation
|
||||||
|
if (session.user.role === 'STUDENT') {
|
||||||
|
const sanitized = {
|
||||||
|
...question,
|
||||||
|
correctAnswer: '',
|
||||||
|
explanation: null,
|
||||||
|
};
|
||||||
|
return NextResponse.json(sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(question);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LessonQuestion get error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PUT: Update a lesson question ──
|
||||||
|
const updateQuestionSchema = z.object({
|
||||||
|
questionType: z.enum(['multiple_choice', 'true_false', 'fill_blank', 'short_answer', 'matching']).optional(),
|
||||||
|
question: z.string().min(1).optional(),
|
||||||
|
options: z.string().optional().nullable(),
|
||||||
|
correctAnswer: z.string().min(1).optional(),
|
||||||
|
explanation: z.string().optional().nullable(),
|
||||||
|
points: z.number().int().min(1).optional(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updateQuestion(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
session.user.role !== 'TEACHER' &&
|
||||||
|
session.user.role !== 'SCHOOL_ADMIN' &&
|
||||||
|
session.user.role !== 'SUPER_ADMIN'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const existing = await db.lessonQuestion.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { lesson: { select: { topic: { select: { schoolId: true } } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Question not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.lesson.topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = updateQuestionSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = await db.lessonQuestion.update({
|
||||||
|
where: { id },
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(question);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LessonQuestion update error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE: Delete a lesson question ──
|
||||||
|
async function deleteQuestion(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
session.user.role !== 'TEACHER' &&
|
||||||
|
session.user.role !== 'SCHOOL_ADMIN' &&
|
||||||
|
session.user.role !== 'SUPER_ADMIN'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const existing = await db.lessonQuestion.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { lesson: { select: { topic: { select: { schoolId: true } } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Question not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.lesson.topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.lessonQuestion.delete({ where: { id } });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Question deleted',
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LessonQuestion delete error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withRateLimit(getQuestion, 'dataRead');
|
||||||
|
export const PUT = withRateLimit(updateQuestion, 'dataWrite');
|
||||||
|
export const DELETE = withRateLimit(deleteQuestion, 'dataWrite');
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { withRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
// ── GET: List lesson questions ──
|
||||||
|
async function getQuestions(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const lessonId = searchParams.get('lessonId');
|
||||||
|
const questionType = searchParams.get('questionType');
|
||||||
|
|
||||||
|
if (!lessonId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'lessonId query parameter is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify lesson belongs to user's school
|
||||||
|
const lesson = await db.subjectLesson.findUnique({
|
||||||
|
where: { id: lessonId, deletedAt: null },
|
||||||
|
include: { topic: { select: { schoolId: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lesson) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Lesson not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lesson.topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { lessonId };
|
||||||
|
|
||||||
|
if (questionType) where.questionType = questionType;
|
||||||
|
|
||||||
|
const questions = await db.lessonQuestion.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// For students: hide correct answers and explanations
|
||||||
|
if (session.user.role === 'STUDENT') {
|
||||||
|
const sanitized = questions.map((q) => ({
|
||||||
|
...q,
|
||||||
|
correctAnswer: '',
|
||||||
|
explanation: null,
|
||||||
|
}));
|
||||||
|
return NextResponse.json(sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(questions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LessonQuestions list error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST: Create a new lesson question ──
|
||||||
|
const createQuestionSchema = z.object({
|
||||||
|
lessonId: z.string().min(1),
|
||||||
|
questionType: z.enum(['multiple_choice', 'true_false', 'fill_blank', 'short_answer', 'matching']),
|
||||||
|
question: z.string().min(1),
|
||||||
|
options: z.string().optional().nullable(), // JSON array string
|
||||||
|
correctAnswer: z.string().min(1),
|
||||||
|
explanation: z.string().optional().nullable(),
|
||||||
|
points: z.number().int().min(1).default(1),
|
||||||
|
sortOrder: z.number().int().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createQuestion(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
session.user.role !== 'TEACHER' &&
|
||||||
|
session.user.role !== 'SCHOOL_ADMIN' &&
|
||||||
|
session.user.role !== 'SUPER_ADMIN'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = createQuestionSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify lesson belongs to user's school
|
||||||
|
const lesson = await db.subjectLesson.findUnique({
|
||||||
|
where: { id: parsed.data.lessonId, deletedAt: null },
|
||||||
|
include: { topic: { select: { schoolId: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lesson) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Lesson not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lesson.topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate options for multiple_choice and matching
|
||||||
|
if (
|
||||||
|
(parsed.data.questionType === 'multiple_choice' || parsed.data.questionType === 'matching') &&
|
||||||
|
!parsed.data.options
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Options are required for multiple_choice and matching questions' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = await db.lessonQuestion.create({
|
||||||
|
data: {
|
||||||
|
lessonId: parsed.data.lessonId,
|
||||||
|
questionType: parsed.data.questionType,
|
||||||
|
question: parsed.data.question,
|
||||||
|
options: parsed.data.options,
|
||||||
|
correctAnswer: parsed.data.correctAnswer,
|
||||||
|
explanation: parsed.data.explanation,
|
||||||
|
points: parsed.data.points,
|
||||||
|
sortOrder: parsed.data.sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(question, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LessonQuestion create error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withRateLimit(getQuestions, 'dataRead');
|
||||||
|
export const POST = withRateLimit(createQuestion, 'dataWrite');
|
||||||
@@ -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<NextResponse> {
|
||||||
|
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<string, unknown> = {};
|
||||||
|
|
||||||
|
// 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<NextResponse> {
|
||||||
|
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');
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { withRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
// ── GET: List student answers (for the current student) ──
|
||||||
|
async function getAnswers(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const questionId = searchParams.get('questionId');
|
||||||
|
const lessonId = searchParams.get('lessonId');
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
if (session.user.role === 'STUDENT') {
|
||||||
|
// Students see only their own answers
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
studentId: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (questionId) where.questionId = questionId;
|
||||||
|
|
||||||
|
// If lessonId is provided, filter by questions in that lesson
|
||||||
|
if (lessonId) {
|
||||||
|
const answers = await db.studentAnswer.findMany({
|
||||||
|
where: {
|
||||||
|
studentId: userId,
|
||||||
|
question: {
|
||||||
|
lessonId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
question: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
questionType: true,
|
||||||
|
question: true,
|
||||||
|
correctAnswer: true,
|
||||||
|
explanation: true,
|
||||||
|
points: true,
|
||||||
|
lessonId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return NextResponse.json(answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = await db.studentAnswer.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
question: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
questionType: true,
|
||||||
|
question: true,
|
||||||
|
correctAnswer: true,
|
||||||
|
explanation: true,
|
||||||
|
points: true,
|
||||||
|
lessonId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teachers / Admins: can see answers for their school
|
||||||
|
const schoolId = session.user.schoolId;
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (questionId) where.questionId = questionId;
|
||||||
|
|
||||||
|
if (lessonId) {
|
||||||
|
const answers = await db.studentAnswer.findMany({
|
||||||
|
where: {
|
||||||
|
question: {
|
||||||
|
lessonId,
|
||||||
|
},
|
||||||
|
question: {
|
||||||
|
lesson: {
|
||||||
|
topic: { schoolId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
question: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
questionType: true,
|
||||||
|
question: true,
|
||||||
|
correctAnswer: true,
|
||||||
|
explanation: true,
|
||||||
|
points: true,
|
||||||
|
lessonId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return NextResponse.json(answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For teachers without lessonId filter, return empty (too broad)
|
||||||
|
return NextResponse.json([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('StudentAnswers list error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST: Submit a student answer ──
|
||||||
|
const submitAnswerSchema = z.object({
|
||||||
|
questionId: z.string().min(1),
|
||||||
|
answer: z.string().min(1),
|
||||||
|
timeTakenMs: z.number().int().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitAnswer(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = submitAnswerSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify question exists and belongs to user's school
|
||||||
|
const question = await db.lessonQuestion.findUnique({
|
||||||
|
where: { id: parsed.data.questionId },
|
||||||
|
include: {
|
||||||
|
lesson: {
|
||||||
|
select: { topic: { select: { schoolId: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!question) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Question not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (question.lesson.topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the answer is correct
|
||||||
|
const isCorrect =
|
||||||
|
parsed.data.answer.trim().toLowerCase() ===
|
||||||
|
question.correctAnswer.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Upsert the answer (unique constraint on questionId + studentId)
|
||||||
|
const studentAnswer = await db.studentAnswer.upsert({
|
||||||
|
where: {
|
||||||
|
questionId_studentId: {
|
||||||
|
questionId: parsed.data.questionId,
|
||||||
|
studentId: session.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
questionId: parsed.data.questionId,
|
||||||
|
studentId: session.user.id,
|
||||||
|
answer: parsed.data.answer,
|
||||||
|
isCorrect,
|
||||||
|
timeTakenMs: parsed.data.timeTakenMs,
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
answer: parsed.data.answer,
|
||||||
|
isCorrect,
|
||||||
|
timeTakenMs: parsed.data.timeTakenMs,
|
||||||
|
attempts: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the answer with correctness info (for immediate feedback)
|
||||||
|
return NextResponse.json({
|
||||||
|
...studentAnswer,
|
||||||
|
correctAnswer: question.correctAnswer,
|
||||||
|
explanation: question.explanation,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('StudentAnswer submit error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withRateLimit(getAnswers, 'dataRead');
|
||||||
|
export const POST = withRateLimit(submitAnswer, 'dataWrite');
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { withRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
// ── GET: Get a single subject lesson with questions ──
|
||||||
|
async function getLesson(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const lesson = await db.subjectLesson.findUnique({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
topic: {
|
||||||
|
select: { id: true, title: true, schoolId: true, subjectId: true },
|
||||||
|
},
|
||||||
|
questions: {
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lesson) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Lesson not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access check: same school
|
||||||
|
if (lesson.topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For students: hide correct answers and explanations
|
||||||
|
if (session.user.role === 'STUDENT') {
|
||||||
|
const sanitizedQuestions = lesson.questions.map((q) => ({
|
||||||
|
...q,
|
||||||
|
correctAnswer: '',
|
||||||
|
explanation: null,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ ...lesson, questions: sanitizedQuestions });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(lesson);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SubjectLesson get error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PUT: Update a subject lesson ──
|
||||||
|
const updateLessonSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
description: z.string().max(2000).optional().nullable(),
|
||||||
|
lessonType: z.enum(['explanation', 'exercise', 'quiz', 'flashcard', 'video_link']).optional(),
|
||||||
|
content: z.string().min(1).optional(),
|
||||||
|
difficulty: z.enum(['easy', 'medium', 'hard']).optional(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
estimatedMinutes: z.number().int().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updateLesson(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
session.user.role !== 'TEACHER' &&
|
||||||
|
session.user.role !== 'SCHOOL_ADMIN' &&
|
||||||
|
session.user.role !== 'SUPER_ADMIN'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const existing = await db.subjectLesson.findUnique({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: { topic: { select: { schoolId: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Lesson not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = updateLessonSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lesson = await db.subjectLesson.update({
|
||||||
|
where: { id },
|
||||||
|
data: parsed.data,
|
||||||
|
include: {
|
||||||
|
_count: { select: { questions: { where: { isDemo: false } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(lesson);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SubjectLesson update error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE: Soft delete a subject lesson ──
|
||||||
|
async function deleteLesson(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
session.user.role !== 'TEACHER' &&
|
||||||
|
session.user.role !== 'SCHOOL_ADMIN' &&
|
||||||
|
session.user.role !== 'SUPER_ADMIN'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const existing = await db.subjectLesson.findUnique({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: { topic: { select: { schoolId: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Lesson not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await db.subjectLesson.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Lesson deleted',
|
||||||
|
id: deleted.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SubjectLesson delete error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withRateLimit(getLesson, 'dataRead');
|
||||||
|
export const PUT = withRateLimit(updateLesson, 'dataWrite');
|
||||||
|
export const DELETE = withRateLimit(deleteLesson, 'dataWrite');
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { withRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
// ── GET: List subject lessons ──
|
||||||
|
async function getLessons(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const topicId = searchParams.get('topicId');
|
||||||
|
const lessonType = searchParams.get('lessonType');
|
||||||
|
const difficulty = searchParams.get('difficulty');
|
||||||
|
|
||||||
|
if (!topicId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'topicId query parameter is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify topic belongs to user's school
|
||||||
|
const topic = await db.subjectTopic.findUnique({
|
||||||
|
where: { id: topicId, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!topic) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Topic not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
deletedAt: null,
|
||||||
|
topicId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lessonType) where.lessonType = lessonType;
|
||||||
|
if (difficulty) where.difficulty = difficulty;
|
||||||
|
|
||||||
|
const lessons = await db.subjectLesson.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
_count: { select: { questions: { where: { isDemo: false } } } },
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(lessons);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SubjectLessons list error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST: Create a new subject lesson ──
|
||||||
|
const createLessonSchema = z.object({
|
||||||
|
topicId: z.string().min(1),
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
description: z.string().max(2000).optional().nullable(),
|
||||||
|
lessonType: z.enum(['explanation', 'exercise', 'quiz', 'flashcard', 'video_link']).default('explanation'),
|
||||||
|
content: z.string().min(1),
|
||||||
|
difficulty: z.enum(['easy', 'medium', 'hard']).default('medium'),
|
||||||
|
sortOrder: z.number().int().default(0),
|
||||||
|
estimatedMinutes: z.number().int().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createLesson(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
session.user.role !== 'TEACHER' &&
|
||||||
|
session.user.role !== 'SCHOOL_ADMIN' &&
|
||||||
|
session.user.role !== 'SUPER_ADMIN'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = createLessonSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify topic belongs to user's school
|
||||||
|
const topic = await db.subjectTopic.findUnique({
|
||||||
|
where: { id: parsed.data.topicId, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!topic) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Topic not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic.schoolId !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lesson = await db.subjectLesson.create({
|
||||||
|
data: {
|
||||||
|
topicId: parsed.data.topicId,
|
||||||
|
title: parsed.data.title,
|
||||||
|
description: parsed.data.description,
|
||||||
|
lessonType: parsed.data.lessonType,
|
||||||
|
content: parsed.data.content,
|
||||||
|
difficulty: parsed.data.difficulty,
|
||||||
|
sortOrder: parsed.data.sortOrder,
|
||||||
|
estimatedMinutes: parsed.data.estimatedMinutes,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: { select: { questions: { where: { isDemo: false } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(lesson, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SubjectLesson create error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withRateLimit(getLessons, 'dataRead');
|
||||||
|
export const POST = withRateLimit(createLesson, 'dataWrite');
|
||||||
@@ -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');
|
||||||
@@ -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<string, unknown> = {
|
||||||
|
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');
|
||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
Clock as ClockIcon,
|
Clock as ClockIcon,
|
||||||
Library as LibraryIcon,
|
Library as LibraryIcon,
|
||||||
|
Trophy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -150,6 +151,7 @@ import HomeworkView from './homework-view';
|
|||||||
import PortfolioView from './portfolio-view';
|
import PortfolioView from './portfolio-view';
|
||||||
import TimetableView from './timetable-view';
|
import TimetableView from './timetable-view';
|
||||||
import ResourceLibraryView from './resource-library-view';
|
import ResourceLibraryView from './resource-library-view';
|
||||||
|
import CompetitionsView from './competitions-view';
|
||||||
|
|
||||||
// Dynamic imports for heavy components with loading skeletons
|
// Dynamic imports for heavy components with loading skeletons
|
||||||
const AnalyticsView = dynamic(() => import('./analytics-view'), {
|
const AnalyticsView = dynamic(() => import('./analytics-view'), {
|
||||||
@@ -207,6 +209,7 @@ const navSections: NavSection[] = [
|
|||||||
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
||||||
{ key: 'timetable', icon: ClockIcon, labelKey: 'nav.timetable' },
|
{ key: 'timetable', icon: ClockIcon, labelKey: 'nav.timetable' },
|
||||||
{ key: 'resources', icon: LibraryIcon, labelKey: 'nav.resources' },
|
{ 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: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
|
||||||
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
||||||
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
{ 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 <PortfolioView />;
|
case 'portfolio': return <PortfolioView />;
|
||||||
case 'timetable': return <TimetableView />;
|
case 'timetable': return <TimetableView />;
|
||||||
case 'resources': return <ResourceLibraryView />;
|
case 'resources': return <ResourceLibraryView />;
|
||||||
|
case 'competitions': return <CompetitionsView />;
|
||||||
default: return <DashboardView />;
|
default: return <DashboardView />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -664,6 +664,47 @@ export default function DashboardView() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Environmental Banner (Papier sparen) ─────────────────────── */}
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-emerald-500 via-emerald-600 to-teal-600 dark:from-emerald-700 dark:via-emerald-800 dark:to-teal-700 shadow-lg shadow-emerald-300/30 dark:shadow-emerald-900/40">
|
||||||
|
{/* Decorative leaf pattern */}
|
||||||
|
<div className="absolute inset-0 opacity-10 pointer-events-none">
|
||||||
|
<div className="absolute top-2 right-8">
|
||||||
|
<Leaf className="h-24 w-24 text-white rotate-12" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1 right-40">
|
||||||
|
<Leaf className="h-16 w-16 text-white -rotate-45" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-4 right-72">
|
||||||
|
<Leaf className="h-10 w-10 text-white rotate-90" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative p-5 sm:p-6 flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-white/20 backdrop-blur-sm shrink-0">
|
||||||
|
<Leaf className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-lg font-bold text-white leading-tight">
|
||||||
|
{t('student.papier_sparen_title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-emerald-100/90 mt-0.5 leading-snug">
|
||||||
|
{t('student.papier_sparen_subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentView('notebooks')}
|
||||||
|
className="shrink-0 bg-white/20 hover:bg-white/30 text-white border-white/30 backdrop-blur-sm min-h-[44px] px-5 font-semibold transition-all duration-200 hover:shadow-lg"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4 mr-2" />
|
||||||
|
{t('student.go_to_notebooks')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Student Quick Stats */}
|
{/* Student Quick Stats */}
|
||||||
<motion.div variants={itemVariants}>
|
<motion.div variants={itemVariants}>
|
||||||
<div className="relative p-4 rounded-xl bg-mesh border border-emerald-200/40 dark:border-emerald-900/30 overflow-hidden backdrop-blur-sm">
|
<div className="relative p-4 rounded-xl bg-mesh border border-emerald-200/40 dark:border-emerald-900/30 overflow-hidden backdrop-blur-sm">
|
||||||
@@ -689,6 +730,130 @@ export default function DashboardView() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Student Notebook Quick Access (Meine Hefte) ─────────────── */}
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm">
|
||||||
|
<BookMarked className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-sm font-semibold">{t('student.my_notebooks_quick')}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentView('notebooks')}
|
||||||
|
className="text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300 min-h-[44px] px-2"
|
||||||
|
>
|
||||||
|
{t('student.show_all_notebooks')}
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-1">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ title: t('student.notebook_math'), subject: 'Mathematik', color: 'from-amber-400 to-orange-500', icon: Calculator },
|
||||||
|
{ title: t('student.notebook_german'), subject: 'Deutsch', color: 'from-emerald-400 to-teal-500', icon: BookOpen },
|
||||||
|
{ title: t('student.notebook_english'), subject: 'Englisch', color: 'from-rose-400 to-pink-500', icon: Globe },
|
||||||
|
{ title: t('student.notebook_science'), subject: 'Naturwissenschaften', color: 'from-violet-400 to-purple-500', icon: Flower2 },
|
||||||
|
].map((notebook) => (
|
||||||
|
<motion.div
|
||||||
|
key={notebook.subject}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
className="cursor-pointer min-h-[44px]"
|
||||||
|
onClick={() => setCurrentView('notebooks')}
|
||||||
|
>
|
||||||
|
<div className="relative rounded-xl overflow-hidden border border-gray-200/40 dark:border-gray-700/40 bg-white dark:bg-gray-900/50 hover:shadow-md transition-shadow duration-200">
|
||||||
|
{/* Notebook cover color strip */}
|
||||||
|
<div className={`h-2 bg-gradient-to-r ${notebook.color}`} />
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<div className={`flex items-center justify-center w-7 h-7 rounded-md bg-gradient-to-br ${notebook.color} text-white shadow-sm`}>
|
||||||
|
<notebook.icon className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-gray-800 dark:text-gray-200 truncate">{notebook.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{notebook.subject}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentView('notebooks')}
|
||||||
|
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white min-h-[44px] px-6 font-semibold shadow-sm transition-all duration-200"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4 mr-2" />
|
||||||
|
{t('student.start_learning')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Student Learning Progress ────────────────────────────────── */}
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-sm font-semibold">{t('student.learning_progress')}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentView('flower')}
|
||||||
|
className="text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300 min-h-[44px] px-2"
|
||||||
|
>
|
||||||
|
{t('student.continue_learning')}
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-1">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ subject: t('student.notebook_math'), progress: 72, color: 'bg-amber-500', bgColor: 'bg-amber-100 dark:bg-amber-900/30' },
|
||||||
|
{ subject: t('student.notebook_german'), progress: 85, color: 'bg-emerald-500', bgColor: 'bg-emerald-100 dark:bg-emerald-900/30' },
|
||||||
|
{ subject: t('student.notebook_english'), progress: 58, color: 'bg-rose-500', bgColor: 'bg-rose-100 dark:bg-rose-900/30' },
|
||||||
|
{ subject: t('student.notebook_science'), progress: 44, color: 'bg-violet-500', bgColor: 'bg-violet-100 dark:bg-violet-900/30' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.subject} className="flex items-center gap-3 min-h-[44px]">
|
||||||
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 w-28 shrink-0 truncate">{item.subject}</span>
|
||||||
|
<div className="flex-1 h-3 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${item.progress}%` }}
|
||||||
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
|
className={`h-full rounded-full ${item.color}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400 w-9 text-right shrink-0">{item.progress}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800 flex items-center justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentView('flower')}
|
||||||
|
variant="outline"
|
||||||
|
className="text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 min-h-[44px] px-5 font-semibold transition-all duration-200"
|
||||||
|
>
|
||||||
|
<GraduationCap className="h-4 w-4 mr-2" />
|
||||||
|
{t('student.continue_learning')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Student Cards Grid */}
|
{/* Student Cards Grid */}
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<motion.div variants={itemVariants} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{/* My Competencies Card */}
|
{/* My Competencies Card */}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -578,7 +578,7 @@ export default function PortfolioView() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium">{entry.isPublic ? t('portfolio.public') : t('portfolio.private')}</Label>
|
<Label className="text-xs font-medium">{form.isPublic ? t('portfolio.public') : t('portfolio.private')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
+479
@@ -3067,3 +3067,482 @@ export function unpublishNewsletter(id: string): Promise<NewsletterData> {
|
|||||||
export function deleteNewsletter(id: string): Promise<{ success: boolean }> {
|
export function deleteNewsletter(id: string): Promise<{ success: boolean }> {
|
||||||
return apiDelete<{ success: boolean }>(`/api/newsletters/${id}`);
|
return apiDelete<{ success: boolean }>(`/api/newsletters/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Competition System ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface CompetitionData {
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
competitionType: string;
|
||||||
|
category: string;
|
||||||
|
subjectId: string | null;
|
||||||
|
status: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
registrationDeadline: string | null;
|
||||||
|
maxParticipants: number | null;
|
||||||
|
scoringType: string;
|
||||||
|
rules: string | null;
|
||||||
|
isPublic: boolean;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
school?: { id: string; name: string };
|
||||||
|
createdBy?: { id: string; firstName: string; lastName: string };
|
||||||
|
subject?: { id: string; name: string } | null;
|
||||||
|
_count?: { participants: number; rewards: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompetitionParticipantData {
|
||||||
|
id: string;
|
||||||
|
competitionId: string;
|
||||||
|
participantType: string;
|
||||||
|
participantId: string;
|
||||||
|
userId: string | null;
|
||||||
|
registeredAt: string;
|
||||||
|
score: number;
|
||||||
|
rank: number | null;
|
||||||
|
isDisqualified: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompetitionRewardData {
|
||||||
|
id: string;
|
||||||
|
competitionId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
rewardType: string;
|
||||||
|
rewardValue: string | null;
|
||||||
|
rewardProvider: string | null;
|
||||||
|
rankRequirement: number | null;
|
||||||
|
pointsRequired: number | null;
|
||||||
|
quantity: number;
|
||||||
|
claimedCount: number;
|
||||||
|
imageUrl: string | null;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
_count?: { claims: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompetitionLeaderboardEntry {
|
||||||
|
id: string;
|
||||||
|
competitionId: string;
|
||||||
|
participantType: string;
|
||||||
|
participantId: string;
|
||||||
|
participantName: string;
|
||||||
|
score: number;
|
||||||
|
rank: number | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RewardClaimData {
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
competitionId: string;
|
||||||
|
rewardId: string;
|
||||||
|
userId: string;
|
||||||
|
claimedAt: string;
|
||||||
|
code: string | null;
|
||||||
|
status: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
user?: { id: string; firstName: string; lastName: string };
|
||||||
|
competition?: { id: string; title: string; competitionType?: string };
|
||||||
|
reward?: { id: string; name: string; rewardType: string; rewardProvider: string | null; imageUrl: string | null };
|
||||||
|
school?: { id: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCompetitions(
|
||||||
|
schoolId: string,
|
||||||
|
status?: string,
|
||||||
|
competitionType?: string,
|
||||||
|
category?: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number
|
||||||
|
): Promise<{ competitions: CompetitionData[]; total: number }> {
|
||||||
|
const params = new URLSearchParams({ schoolId });
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
if (competitionType) params.set('competitionType', competitionType);
|
||||||
|
if (category) params.set('category', category);
|
||||||
|
if (limit) params.set('limit', String(limit));
|
||||||
|
if (offset) params.set('offset', String(offset));
|
||||||
|
return apiGet<{ competitions: CompetitionData[]; total: number }>(`/api/competitions?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPublicCompetitions(
|
||||||
|
limit?: number,
|
||||||
|
offset?: number
|
||||||
|
): Promise<{ competitions: CompetitionData[]; total: number }> {
|
||||||
|
const params = new URLSearchParams({ isPublic: 'true' });
|
||||||
|
if (limit) params.set('limit', String(limit));
|
||||||
|
if (offset) params.set('offset', String(offset));
|
||||||
|
return apiGet<{ competitions: CompetitionData[]; total: number }>(`/api/competitions?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCompetition(id: string): Promise<CompetitionData> {
|
||||||
|
return apiGet<CompetitionData>(`/api/competitions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCompetition(data: {
|
||||||
|
schoolId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
competitionType: string;
|
||||||
|
category: string;
|
||||||
|
subjectId?: string | null;
|
||||||
|
status?: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
registrationDeadline?: string | null;
|
||||||
|
maxParticipants?: number | null;
|
||||||
|
scoringType?: string;
|
||||||
|
rules?: string | null;
|
||||||
|
isPublic?: boolean;
|
||||||
|
isDemo?: boolean;
|
||||||
|
}): Promise<CompetitionData> {
|
||||||
|
return apiPost<CompetitionData>('/api/competitions', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCompetition(id: string, data: Partial<{
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
competitionType: string;
|
||||||
|
category: string;
|
||||||
|
subjectId: string | null;
|
||||||
|
status: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
registrationDeadline: string | null;
|
||||||
|
maxParticipants: number | null;
|
||||||
|
scoringType: string;
|
||||||
|
rules: string | null;
|
||||||
|
isPublic: boolean;
|
||||||
|
}>): Promise<CompetitionData> {
|
||||||
|
return apiPut<CompetitionData>(`/api/competitions/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCompetition(id: string): Promise<{ success: boolean }> {
|
||||||
|
return apiDelete<{ success: boolean }>(`/api/competitions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCompetitionParticipant(
|
||||||
|
competitionId: string,
|
||||||
|
data: {
|
||||||
|
participantType: string;
|
||||||
|
participantId: string;
|
||||||
|
userId?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<CompetitionParticipantData> {
|
||||||
|
return apiPost<CompetitionParticipantData>(`/api/competitions/${competitionId}`, {
|
||||||
|
action: 'register',
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCompetitionScore(
|
||||||
|
competitionId: string,
|
||||||
|
data: {
|
||||||
|
participantType: string;
|
||||||
|
participantId: string;
|
||||||
|
score: number;
|
||||||
|
isDisqualified?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<CompetitionParticipantData> {
|
||||||
|
return apiPost<CompetitionParticipantData>(`/api/competitions/${competitionId}`, {
|
||||||
|
action: 'update_score',
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCompetitionLeaderboard(
|
||||||
|
competitionId: string
|
||||||
|
): Promise<{ leaderboard: CompetitionLeaderboardEntry[] }> {
|
||||||
|
return apiGet<{ leaderboard: CompetitionLeaderboardEntry[] }>(`/api/competitions/${competitionId}/leaderboard`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCompetitionRewards(
|
||||||
|
competitionId: string
|
||||||
|
): Promise<{ rewards: CompetitionRewardData[] }> {
|
||||||
|
return apiGet<{ rewards: CompetitionRewardData[] }>(`/api/competitions/${competitionId}/rewards`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCompetitionReward(
|
||||||
|
competitionId: string,
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
rewardType: string;
|
||||||
|
rewardValue?: string | null;
|
||||||
|
rewardProvider?: string | null;
|
||||||
|
rankRequirement?: number | null;
|
||||||
|
pointsRequired?: number | null;
|
||||||
|
quantity?: number;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
isDemo?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<CompetitionRewardData> {
|
||||||
|
return apiPost<CompetitionRewardData>(`/api/competitions/${competitionId}/rewards`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRewardClaims(
|
||||||
|
schoolId?: string,
|
||||||
|
competitionId?: string,
|
||||||
|
status?: string
|
||||||
|
): Promise<{ claims: RewardClaimData[] }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (schoolId) params.set('schoolId', schoolId);
|
||||||
|
if (competitionId) params.set('competitionId', competitionId);
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
const query = params.toString();
|
||||||
|
return apiGet<{ claims: RewardClaimData[] }>(`/api/reward-claims${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function claimReward(data: {
|
||||||
|
competitionId: string;
|
||||||
|
rewardId: string;
|
||||||
|
code?: string | null;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
isDemo?: boolean;
|
||||||
|
}): Promise<RewardClaimData> {
|
||||||
|
return apiPost<RewardClaimData>('/api/reward-claims', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subject Topics (German Curriculum) ─────────────────────────────── */
|
||||||
|
|
||||||
|
export interface SubjectTopicData {
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
subjectId: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
gradeLevel: string | null;
|
||||||
|
curriculumCode: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
icon: string | null;
|
||||||
|
color: string;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
subject?: { id: string; name: string };
|
||||||
|
_count?: { lessons: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubjectTopics(params?: {
|
||||||
|
schoolId?: string;
|
||||||
|
subjectId?: string;
|
||||||
|
gradeLevel?: string;
|
||||||
|
curriculumCode?: string;
|
||||||
|
}): Promise<SubjectTopicData[]> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.schoolId) searchParams.set('schoolId', params.schoolId);
|
||||||
|
if (params?.subjectId) searchParams.set('subjectId', params.subjectId);
|
||||||
|
if (params?.gradeLevel) searchParams.set('gradeLevel', params.gradeLevel);
|
||||||
|
if (params?.curriculumCode) searchParams.set('curriculumCode', params.curriculumCode);
|
||||||
|
return apiGet<SubjectTopicData[]>(`/api/subject-topics?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubjectTopic(id: string): Promise<SubjectTopicData> {
|
||||||
|
return apiGet<SubjectTopicData>(`/api/subject-topics/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubjectTopic(data: {
|
||||||
|
schoolId?: string;
|
||||||
|
subjectId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
gradeLevel?: string | null;
|
||||||
|
curriculumCode?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
icon?: string | null;
|
||||||
|
color?: string;
|
||||||
|
}): Promise<SubjectTopicData> {
|
||||||
|
return apiPost<SubjectTopicData>('/api/subject-topics', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSubjectTopic(id: string, data: Partial<{
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
gradeLevel: string | null;
|
||||||
|
curriculumCode: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
icon: string | null;
|
||||||
|
color: string;
|
||||||
|
}>): Promise<SubjectTopicData> {
|
||||||
|
return apiPut<SubjectTopicData>(`/api/subject-topics/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSubjectTopic(id: string): Promise<{ message: string; id: string }> {
|
||||||
|
return apiDelete<{ message: string; id: string }>(`/api/subject-topics/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subject Lessons (German Curriculum) ────────────────────────────── */
|
||||||
|
|
||||||
|
export interface SubjectLessonData {
|
||||||
|
id: string;
|
||||||
|
topicId: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
lessonType: string;
|
||||||
|
content: string;
|
||||||
|
difficulty: string;
|
||||||
|
sortOrder: number;
|
||||||
|
estimatedMinutes: number | null;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
_count?: { questions: number };
|
||||||
|
topic?: { id: string; title: string; schoolId: string; subjectId: string };
|
||||||
|
questions?: LessonQuestionData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubjectLessons(topicId: string, params?: {
|
||||||
|
lessonType?: string;
|
||||||
|
difficulty?: string;
|
||||||
|
}): Promise<SubjectLessonData[]> {
|
||||||
|
const searchParams = new URLSearchParams({ topicId });
|
||||||
|
if (params?.lessonType) searchParams.set('lessonType', params.lessonType);
|
||||||
|
if (params?.difficulty) searchParams.set('difficulty', params.difficulty);
|
||||||
|
return apiGet<SubjectLessonData[]>(`/api/subject-lessons?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubjectLesson(id: string): Promise<SubjectLessonData> {
|
||||||
|
return apiGet<SubjectLessonData>(`/api/subject-lessons/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubjectLesson(data: {
|
||||||
|
topicId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
lessonType?: 'explanation' | 'exercise' | 'quiz' | 'flashcard' | 'video_link';
|
||||||
|
content: string;
|
||||||
|
difficulty?: 'easy' | 'medium' | 'hard';
|
||||||
|
sortOrder?: number;
|
||||||
|
estimatedMinutes?: number | null;
|
||||||
|
}): Promise<SubjectLessonData> {
|
||||||
|
return apiPost<SubjectLessonData>('/api/subject-lessons', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSubjectLesson(id: string, data: Partial<{
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
lessonType: 'explanation' | 'exercise' | 'quiz' | 'flashcard' | 'video_link';
|
||||||
|
content: string;
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard';
|
||||||
|
sortOrder: number;
|
||||||
|
estimatedMinutes: number | null;
|
||||||
|
}>): Promise<SubjectLessonData> {
|
||||||
|
return apiPut<SubjectLessonData>(`/api/subject-lessons/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSubjectLesson(id: string): Promise<{ message: string; id: string }> {
|
||||||
|
return apiDelete<{ message: string; id: string }>(`/api/subject-lessons/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lesson Questions (German Curriculum) ───────────────────────────── */
|
||||||
|
|
||||||
|
export interface LessonQuestionData {
|
||||||
|
id: string;
|
||||||
|
lessonId: string;
|
||||||
|
questionType: string;
|
||||||
|
question: string;
|
||||||
|
options: string | null;
|
||||||
|
correctAnswer: string;
|
||||||
|
explanation: string | null;
|
||||||
|
points: number;
|
||||||
|
sortOrder: number;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchLessonQuestions(lessonId: string, params?: {
|
||||||
|
questionType?: string;
|
||||||
|
}): Promise<LessonQuestionData[]> {
|
||||||
|
const searchParams = new URLSearchParams({ lessonId });
|
||||||
|
if (params?.questionType) searchParams.set('questionType', params.questionType);
|
||||||
|
return apiGet<LessonQuestionData[]>(`/api/lesson-questions?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchLessonQuestion(id: string): Promise<LessonQuestionData> {
|
||||||
|
return apiGet<LessonQuestionData>(`/api/lesson-questions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLessonQuestion(data: {
|
||||||
|
lessonId: string;
|
||||||
|
questionType: 'multiple_choice' | 'true_false' | 'fill_blank' | 'short_answer' | 'matching';
|
||||||
|
question: string;
|
||||||
|
options?: string | null;
|
||||||
|
correctAnswer: string;
|
||||||
|
explanation?: string | null;
|
||||||
|
points?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
}): Promise<LessonQuestionData> {
|
||||||
|
return apiPost<LessonQuestionData>('/api/lesson-questions', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLessonQuestion(id: string, data: Partial<{
|
||||||
|
questionType: 'multiple_choice' | 'true_false' | 'fill_blank' | 'short_answer' | 'matching';
|
||||||
|
question: string;
|
||||||
|
options: string | null;
|
||||||
|
correctAnswer: string;
|
||||||
|
explanation: string | null;
|
||||||
|
points: number;
|
||||||
|
sortOrder: number;
|
||||||
|
}>): Promise<LessonQuestionData> {
|
||||||
|
return apiPut<LessonQuestionData>(`/api/lesson-questions/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLessonQuestion(id: string): Promise<{ message: string; id: string }> {
|
||||||
|
return apiDelete<{ message: string; id: string }>(`/api/lesson-questions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Student Answers (German Curriculum) ────────────────────────────── */
|
||||||
|
|
||||||
|
export interface StudentAnswerData {
|
||||||
|
id: string;
|
||||||
|
questionId: string;
|
||||||
|
studentId: string;
|
||||||
|
answer: string;
|
||||||
|
isCorrect: boolean | null;
|
||||||
|
timeTakenMs: number | null;
|
||||||
|
attempts: number;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
correctAnswer?: string;
|
||||||
|
explanation?: string | null;
|
||||||
|
question?: LessonQuestionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchStudentAnswers(params?: {
|
||||||
|
questionId?: string;
|
||||||
|
lessonId?: string;
|
||||||
|
}): Promise<StudentAnswerData[]> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.questionId) searchParams.set('questionId', params.questionId);
|
||||||
|
if (params?.lessonId) searchParams.set('lessonId', params.lessonId);
|
||||||
|
return apiGet<StudentAnswerData[]>(`/api/student-answers?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitStudentAnswer(data: {
|
||||||
|
questionId: string;
|
||||||
|
answer: string;
|
||||||
|
timeTakenMs?: number | null;
|
||||||
|
}): Promise<StudentAnswerData> {
|
||||||
|
return apiPost<StudentAnswerData>('/api/student-answers', data);
|
||||||
|
}
|
||||||
+286
@@ -2080,6 +2080,51 @@ const de: TranslationDict = {
|
|||||||
'notebooks.tab_all': 'Meine Hefte',
|
'notebooks.tab_all': 'Meine Hefte',
|
||||||
'notebooks.owner': 'Von',
|
'notebooks.owner': 'Von',
|
||||||
|
|
||||||
|
// ─── Learning Content (German Curriculum) ──────────────────────────
|
||||||
|
'notebooks.learn': 'Lernen',
|
||||||
|
'notebooks.topics': 'Themen',
|
||||||
|
'notebooks.lessons': 'Lektionen',
|
||||||
|
'notebooks.exercises': 'Uebungen',
|
||||||
|
'notebooks.quiz': 'Quiz',
|
||||||
|
'notebooks.flashcards': 'Karteikarten',
|
||||||
|
'notebooks.progress': 'Fortschritt',
|
||||||
|
'notebooks.grade_level': 'Klassenstufe',
|
||||||
|
'notebooks.curriculum': 'Lehrplan',
|
||||||
|
'notebooks.subject_topic': 'Thema',
|
||||||
|
'notebooks.difficulty': 'Schwierigkeitsgrad',
|
||||||
|
'notebooks.easy': 'Leicht',
|
||||||
|
'notebooks.medium': 'Mittel',
|
||||||
|
'notebooks.hard': 'Schwer',
|
||||||
|
'notebooks.question': 'Frage',
|
||||||
|
'notebooks.answer': 'Antwort',
|
||||||
|
'notebooks.correct': 'Richtig',
|
||||||
|
'notebooks.incorrect': 'Falsch',
|
||||||
|
'notebooks.explanation': 'Erklaerung',
|
||||||
|
'notebooks.try_again': 'Nochmal versuchen',
|
||||||
|
'notebooks.next_question': 'Naechste Frage',
|
||||||
|
'notebooks.score': 'Punktzahl',
|
||||||
|
'notebooks.time_taken': 'Benoetigte Zeit',
|
||||||
|
'notebooks.mastery': 'Beherrschung',
|
||||||
|
'notebooks.practice_more': 'Mehr ueben',
|
||||||
|
'notebooks.well_done': 'Gut gemacht!',
|
||||||
|
'notebooks.keep_practicing': 'Weiter ueben!',
|
||||||
|
'notebooks.subject_math': 'Mathematik',
|
||||||
|
'notebooks.subject_german': 'Deutsch',
|
||||||
|
'notebooks.subject_science': 'Sachkunde',
|
||||||
|
'notebooks.subject_english': 'Englisch',
|
||||||
|
'notebooks.subject_music': 'Musik',
|
||||||
|
'notebooks.subject_art': 'Kunst',
|
||||||
|
'notebooks.subject_religion': 'Religion/Ethik',
|
||||||
|
'notebooks.subject_pe': 'Sport',
|
||||||
|
'notebooks.papier_sparen': 'Papier sparen, Umwelt schuetzen',
|
||||||
|
'notebooks.digital_instead_paper': 'Digitale Hefte statt Papierhefte',
|
||||||
|
'notebooks.environment_tip': 'Mit digitalen Heften schuetzt du die Umwelt — kein Papierverschwendung!',
|
||||||
|
'notebooks.type_lined': 'Liniert',
|
||||||
|
'notebooks.type_grid': 'Kariert',
|
||||||
|
'notebooks.type_blank': 'Unliniert',
|
||||||
|
'notebooks.type_dotted': 'Punktiert',
|
||||||
|
'notebooks.type_music': 'Notenlinien',
|
||||||
|
|
||||||
// ─── Enhanced Notifications ──────────────────────────────────────
|
// ─── Enhanced Notifications ──────────────────────────────────────
|
||||||
'notifications.today': 'Heute',
|
'notifications.today': 'Heute',
|
||||||
'notifications.yesterday': 'Gestern',
|
'notifications.yesterday': 'Gestern',
|
||||||
@@ -2123,6 +2168,19 @@ const de: TranslationDict = {
|
|||||||
'student.view_all': 'Alle anzeigen',
|
'student.view_all': 'Alle anzeigen',
|
||||||
'student.welcome_back': 'Willkommen zurueck',
|
'student.welcome_back': 'Willkommen zurueck',
|
||||||
'student.progress_overview': 'Dein Lernfortschritt',
|
'student.progress_overview': 'Dein Lernfortschritt',
|
||||||
|
'student.papier_sparen_title': 'Papier sparen, Umwelt schuetzen',
|
||||||
|
'student.papier_sparen_subtitle': 'Digitale Hefte statt Papierhefte — Mit digitalen Heften schuetzt du die Umwelt!',
|
||||||
|
'student.go_to_notebooks': 'Zu meinen Heften',
|
||||||
|
'student.my_notebooks_quick': 'Meine Hefte',
|
||||||
|
'student.show_all_notebooks': 'Alle Hefte anzeigen',
|
||||||
|
'student.start_learning': 'Lernen starten',
|
||||||
|
'student.learning_progress': 'Lernfortschritt',
|
||||||
|
'student.continue_learning': 'Weiter lernen',
|
||||||
|
'student.subject_progress': 'Fachfortschritt',
|
||||||
|
'student.notebook_math': 'Mathematik',
|
||||||
|
'student.notebook_german': 'Deutsch',
|
||||||
|
'student.notebook_english': 'Englisch',
|
||||||
|
'student.notebook_science': 'Naturwissenschaften',
|
||||||
'nav.student_competencies': 'Meine Kompetenzen',
|
'nav.student_competencies': 'Meine Kompetenzen',
|
||||||
'nav.student_grades': 'Meine Noten',
|
'nav.student_grades': 'Meine Noten',
|
||||||
'nav.student_notebooks': 'Meine Hefte',
|
'nav.student_notebooks': 'Meine Hefte',
|
||||||
@@ -2593,6 +2651,7 @@ const de: TranslationDict = {
|
|||||||
'nav.portfolio': 'Portfolio',
|
'nav.portfolio': 'Portfolio',
|
||||||
'nav.timetable': 'Stundenplan',
|
'nav.timetable': 'Stundenplan',
|
||||||
'nav.resources': 'Mediathek',
|
'nav.resources': 'Mediathek',
|
||||||
|
'nav.competitions': 'Wettbewerbe',
|
||||||
|
|
||||||
// Navigation - Homework
|
// Navigation - Homework
|
||||||
'nav.homework': 'Hausaufgaben',
|
'nav.homework': 'Hausaufgaben',
|
||||||
@@ -3266,6 +3325,90 @@ const de: TranslationDict = {
|
|||||||
'avatar.change': 'Foto ändern',
|
'avatar.change': 'Foto ändern',
|
||||||
'avatar.remove': 'Foto entfernen',
|
'avatar.remove': 'Foto entfernen',
|
||||||
|
|
||||||
|
// Competition System
|
||||||
|
'competition.title': 'Wettbewerbe',
|
||||||
|
'competition.create': 'Wettbewerb erstellen',
|
||||||
|
'competition.edit': 'Wettbewerb bearbeiten',
|
||||||
|
'competition.delete': 'Wettbewerb löschen',
|
||||||
|
'competition.manage': 'Wettbewerbe verwalten',
|
||||||
|
'competition.details': 'Wettbewerbsdetails',
|
||||||
|
'competition.name': 'Wettbewerb',
|
||||||
|
'competition.description': 'Beschreibung',
|
||||||
|
'competition.type': 'Typ',
|
||||||
|
'competition.category': 'Kategorie',
|
||||||
|
'competition.status': 'Status',
|
||||||
|
'competition.start_date': 'Startdatum',
|
||||||
|
'competition.end_date': 'Enddatum',
|
||||||
|
'competition.registration_deadline': 'Anmeldungsfrist',
|
||||||
|
'competition.max_participants': 'Maximale Teilnehmer',
|
||||||
|
'competition.scoring_type': 'Wertung',
|
||||||
|
'competition.rules': 'Regeln',
|
||||||
|
'competition.is_public': 'Öffentlich sichtbar',
|
||||||
|
'competition.subject': 'Fach',
|
||||||
|
'competition.created_by': 'Erstellt von',
|
||||||
|
'competition.no_competitions': 'Keine Wettbewerbe vorhanden',
|
||||||
|
'competition.type.class': 'Klassenwettbewerb',
|
||||||
|
'competition.type.inter_class': 'Klassenübergreifend',
|
||||||
|
'competition.type.inter_school': 'Schulübergreifend',
|
||||||
|
'competition.category.academic': 'Akademisch',
|
||||||
|
'competition.category.sports': 'Sport',
|
||||||
|
'competition.category.creativity': 'Kreativität',
|
||||||
|
'competition.category.citizenship': 'Gemeinschaft',
|
||||||
|
'competition.category.digital': 'Digital',
|
||||||
|
'competition.category.reading': 'Lesen',
|
||||||
|
'competition.category.stem': 'MINT',
|
||||||
|
'competition.category.other': 'Sonstiges',
|
||||||
|
'competition.status.draft': 'Entwurf',
|
||||||
|
'competition.status.registration': 'Anmeldung',
|
||||||
|
'competition.status.active': 'Aktiv',
|
||||||
|
'competition.status.completed': 'Abgeschlossen',
|
||||||
|
'competition.status.cancelled': 'Abgesagt',
|
||||||
|
'competition.scoring.points': 'Punkte',
|
||||||
|
'competition.scoring.rank': 'Platzierung',
|
||||||
|
'competition.scoring.time': 'Zeit',
|
||||||
|
'competition.scoring.badge_count': 'Abzeichenanzahl',
|
||||||
|
'competition.register': 'Anmelden',
|
||||||
|
'competition.register_participant': 'Teilnehmer anmelden',
|
||||||
|
'competition.update_score': 'Punkte aktualisieren',
|
||||||
|
'competition.leaderboard': 'Bestenliste',
|
||||||
|
'competition.participants': 'Teilnehmer',
|
||||||
|
'competition.rewards': 'Preise',
|
||||||
|
'competition.reward.create': 'Preis hinzufügen',
|
||||||
|
'competition.reward.type': 'Preisart',
|
||||||
|
'competition.reward.type.digital_code': 'Digitaler Code',
|
||||||
|
'competition.reward.type.badge': 'Abzeichen',
|
||||||
|
'competition.reward.type.certificate': 'Zertifikat',
|
||||||
|
'competition.reward.type.experience': 'Erlebnis',
|
||||||
|
'competition.reward.type.physical': 'Physischer Preis',
|
||||||
|
'competition.reward.provider': 'Anbieter',
|
||||||
|
'competition.reward.provider.netflix': 'Netflix',
|
||||||
|
'competition.reward.provider.amazon': 'Amazon',
|
||||||
|
'competition.reward.provider.cinema': 'Kino',
|
||||||
|
'competition.reward.provider.theater': 'Theater',
|
||||||
|
'competition.reward.provider.concert': 'Konzert',
|
||||||
|
'competition.reward.provider.custom': 'Sonstige',
|
||||||
|
'competition.reward.quantity': 'Menge',
|
||||||
|
'competition.reward.claimed': 'Eingelöst',
|
||||||
|
'competition.reward.available': 'Verfügbar',
|
||||||
|
'competition.reward.rank_requirement': 'Mindestplatzierung',
|
||||||
|
'competition.reward.points_required': 'Mindestpunkte',
|
||||||
|
'competition.reward.image': 'Preisbild',
|
||||||
|
'competition.reward.code': 'Code',
|
||||||
|
'competition.reward.claim': 'Einlösen',
|
||||||
|
'competition.reward.claimed_at': 'Eingelöst am',
|
||||||
|
'competition.reward.expires': 'Läuft ab am',
|
||||||
|
'competition.reward.status': 'Status',
|
||||||
|
'competition.reward.status.pending': 'Ausstehend',
|
||||||
|
'competition.reward.status.claimed': 'Eingelöst',
|
||||||
|
'competition.reward.status.expired': 'Abgelaufen',
|
||||||
|
'competition.reward.status.revoked': 'Widerrufen',
|
||||||
|
'competition.reward.my_claims': 'Meine Preise',
|
||||||
|
'competition.reward.already_claimed': 'Preis bereits eingelöst',
|
||||||
|
'competition.reward.not_available': 'Preis nicht mehr verfügbar',
|
||||||
|
'competition.reward.requirement_not_met': 'Voraussetzung nicht erfüllt',
|
||||||
|
'competition.dsgvo_notice': 'Hinweis: Personenbezogene Daten werden gemäß DSGVO verarbeitet.',
|
||||||
|
'competition.jugendschutz_notice': 'Hinweis: Dieser Wettbewerb entspricht den Anforderungen des Jugendschutzgesetzes.',
|
||||||
|
|
||||||
// German Grade Labels
|
// German Grade Labels
|
||||||
'grading.sehr_gut': 'Sehr gut',
|
'grading.sehr_gut': 'Sehr gut',
|
||||||
'grading.gut': 'Gut',
|
'grading.gut': 'Gut',
|
||||||
@@ -5352,6 +5495,51 @@ const en: TranslationDict = {
|
|||||||
'notebooks.tab_all': 'My Notebooks',
|
'notebooks.tab_all': 'My Notebooks',
|
||||||
'notebooks.owner': 'By',
|
'notebooks.owner': 'By',
|
||||||
|
|
||||||
|
// ─── Learning Content (German Curriculum) ──────────────────────────
|
||||||
|
'notebooks.learn': 'Learn',
|
||||||
|
'notebooks.topics': 'Topics',
|
||||||
|
'notebooks.lessons': 'Lessons',
|
||||||
|
'notebooks.exercises': 'Exercises',
|
||||||
|
'notebooks.quiz': 'Quiz',
|
||||||
|
'notebooks.flashcards': 'Flashcards',
|
||||||
|
'notebooks.progress': 'Progress',
|
||||||
|
'notebooks.grade_level': 'Grade Level',
|
||||||
|
'notebooks.curriculum': 'Curriculum',
|
||||||
|
'notebooks.subject_topic': 'Topic',
|
||||||
|
'notebooks.difficulty': 'Difficulty',
|
||||||
|
'notebooks.easy': 'Easy',
|
||||||
|
'notebooks.medium': 'Medium',
|
||||||
|
'notebooks.hard': 'Hard',
|
||||||
|
'notebooks.question': 'Question',
|
||||||
|
'notebooks.answer': 'Answer',
|
||||||
|
'notebooks.correct': 'Correct',
|
||||||
|
'notebooks.incorrect': 'Incorrect',
|
||||||
|
'notebooks.explanation': 'Explanation',
|
||||||
|
'notebooks.try_again': 'Try Again',
|
||||||
|
'notebooks.next_question': 'Next Question',
|
||||||
|
'notebooks.score': 'Score',
|
||||||
|
'notebooks.time_taken': 'Time Taken',
|
||||||
|
'notebooks.mastery': 'Mastery',
|
||||||
|
'notebooks.practice_more': 'Practice More',
|
||||||
|
'notebooks.well_done': 'Well Done!',
|
||||||
|
'notebooks.keep_practicing': 'Keep Practicing!',
|
||||||
|
'notebooks.subject_math': 'Mathematics',
|
||||||
|
'notebooks.subject_german': 'German',
|
||||||
|
'notebooks.subject_science': 'General Studies',
|
||||||
|
'notebooks.subject_english': 'English',
|
||||||
|
'notebooks.subject_music': 'Music',
|
||||||
|
'notebooks.subject_art': 'Art',
|
||||||
|
'notebooks.subject_religion': 'Religion/Ethics',
|
||||||
|
'notebooks.subject_pe': 'Physical Education',
|
||||||
|
'notebooks.papier_sparen': 'Save Paper, Protect the Environment',
|
||||||
|
'notebooks.digital_instead_paper': 'Digital Notebooks Instead of Paper',
|
||||||
|
'notebooks.environment_tip': 'With digital notebooks you protect the environment — no paper waste!',
|
||||||
|
'notebooks.type_lined': 'Lined',
|
||||||
|
'notebooks.type_grid': 'Grid',
|
||||||
|
'notebooks.type_blank': 'Blank',
|
||||||
|
'notebooks.type_dotted': 'Dotted',
|
||||||
|
'notebooks.type_music': 'Music Lines',
|
||||||
|
|
||||||
// ─── Enhanced Notifications ──────────────────────────────────────
|
// ─── Enhanced Notifications ──────────────────────────────────────
|
||||||
'notifications.today': 'Today',
|
'notifications.today': 'Today',
|
||||||
'notifications.yesterday': 'Yesterday',
|
'notifications.yesterday': 'Yesterday',
|
||||||
@@ -5395,6 +5583,19 @@ const en: TranslationDict = {
|
|||||||
'student.view_all': 'View all',
|
'student.view_all': 'View all',
|
||||||
'student.welcome_back': 'Welcome back',
|
'student.welcome_back': 'Welcome back',
|
||||||
'student.progress_overview': 'Your Learning Progress',
|
'student.progress_overview': 'Your Learning Progress',
|
||||||
|
'student.papier_sparen_title': 'Save Paper, Protect the Environment',
|
||||||
|
'student.papier_sparen_subtitle': 'Digital notebooks instead of paper — With digital notebooks you protect the environment!',
|
||||||
|
'student.go_to_notebooks': 'Go to my Notebooks',
|
||||||
|
'student.my_notebooks_quick': 'My Notebooks',
|
||||||
|
'student.show_all_notebooks': 'Show all notebooks',
|
||||||
|
'student.start_learning': 'Start learning',
|
||||||
|
'student.learning_progress': 'Learning Progress',
|
||||||
|
'student.continue_learning': 'Continue learning',
|
||||||
|
'student.subject_progress': 'Subject Progress',
|
||||||
|
'student.notebook_math': 'Mathematics',
|
||||||
|
'student.notebook_german': 'German',
|
||||||
|
'student.notebook_english': 'English',
|
||||||
|
'student.notebook_science': 'Sciences',
|
||||||
'nav.student_competencies': 'My Competencies',
|
'nav.student_competencies': 'My Competencies',
|
||||||
'nav.student_grades': 'My Grades',
|
'nav.student_grades': 'My Grades',
|
||||||
'nav.student_notebooks': 'My Notebooks',
|
'nav.student_notebooks': 'My Notebooks',
|
||||||
@@ -5865,6 +6066,7 @@ const en: TranslationDict = {
|
|||||||
'nav.portfolio': 'Portfolio',
|
'nav.portfolio': 'Portfolio',
|
||||||
'nav.timetable': 'Timetable',
|
'nav.timetable': 'Timetable',
|
||||||
'nav.resources': 'Resources',
|
'nav.resources': 'Resources',
|
||||||
|
'nav.competitions': 'Competitions',
|
||||||
|
|
||||||
// Navigation - Homework
|
// Navigation - Homework
|
||||||
'nav.homework': 'Homework',
|
'nav.homework': 'Homework',
|
||||||
@@ -6538,6 +6740,90 @@ const en: TranslationDict = {
|
|||||||
'avatar.change': 'Change Photo',
|
'avatar.change': 'Change Photo',
|
||||||
'avatar.remove': 'Remove Photo',
|
'avatar.remove': 'Remove Photo',
|
||||||
|
|
||||||
|
// Competition System
|
||||||
|
'competition.title': 'Competitions',
|
||||||
|
'competition.create': 'Create Competition',
|
||||||
|
'competition.edit': 'Edit Competition',
|
||||||
|
'competition.delete': 'Delete Competition',
|
||||||
|
'competition.manage': 'Manage Competitions',
|
||||||
|
'competition.details': 'Competition Details',
|
||||||
|
'competition.name': 'Competition',
|
||||||
|
'competition.description': 'Description',
|
||||||
|
'competition.type': 'Type',
|
||||||
|
'competition.category': 'Category',
|
||||||
|
'competition.status': 'Status',
|
||||||
|
'competition.start_date': 'Start Date',
|
||||||
|
'competition.end_date': 'End Date',
|
||||||
|
'competition.registration_deadline': 'Registration Deadline',
|
||||||
|
'competition.max_participants': 'Max Participants',
|
||||||
|
'competition.scoring_type': 'Scoring',
|
||||||
|
'competition.rules': 'Rules',
|
||||||
|
'competition.is_public': 'Publicly Visible',
|
||||||
|
'competition.subject': 'Subject',
|
||||||
|
'competition.created_by': 'Created by',
|
||||||
|
'competition.no_competitions': 'No competitions available',
|
||||||
|
'competition.type.class': 'Class Competition',
|
||||||
|
'competition.type.inter_class': 'Inter-Class',
|
||||||
|
'competition.type.inter_school': 'Inter-School',
|
||||||
|
'competition.category.academic': 'Academic',
|
||||||
|
'competition.category.sports': 'Sports',
|
||||||
|
'competition.category.creativity': 'Creativity',
|
||||||
|
'competition.category.citizenship': 'Citizenship',
|
||||||
|
'competition.category.digital': 'Digital',
|
||||||
|
'competition.category.reading': 'Reading',
|
||||||
|
'competition.category.stem': 'STEM',
|
||||||
|
'competition.category.other': 'Other',
|
||||||
|
'competition.status.draft': 'Draft',
|
||||||
|
'competition.status.registration': 'Registration',
|
||||||
|
'competition.status.active': 'Active',
|
||||||
|
'competition.status.completed': 'Completed',
|
||||||
|
'competition.status.cancelled': 'Cancelled',
|
||||||
|
'competition.scoring.points': 'Points',
|
||||||
|
'competition.scoring.rank': 'Ranking',
|
||||||
|
'competition.scoring.time': 'Time',
|
||||||
|
'competition.scoring.badge_count': 'Badge Count',
|
||||||
|
'competition.register': 'Register',
|
||||||
|
'competition.register_participant': 'Register Participant',
|
||||||
|
'competition.update_score': 'Update Score',
|
||||||
|
'competition.leaderboard': 'Leaderboard',
|
||||||
|
'competition.participants': 'Participants',
|
||||||
|
'competition.rewards': 'Rewards',
|
||||||
|
'competition.reward.create': 'Add Reward',
|
||||||
|
'competition.reward.type': 'Reward Type',
|
||||||
|
'competition.reward.type.digital_code': 'Digital Code',
|
||||||
|
'competition.reward.type.badge': 'Badge',
|
||||||
|
'competition.reward.type.certificate': 'Certificate',
|
||||||
|
'competition.reward.type.experience': 'Experience',
|
||||||
|
'competition.reward.type.physical': 'Physical Reward',
|
||||||
|
'competition.reward.provider': 'Provider',
|
||||||
|
'competition.reward.provider.netflix': 'Netflix',
|
||||||
|
'competition.reward.provider.amazon': 'Amazon',
|
||||||
|
'competition.reward.provider.cinema': 'Cinema',
|
||||||
|
'competition.reward.provider.theater': 'Theater',
|
||||||
|
'competition.reward.provider.concert': 'Concert',
|
||||||
|
'competition.reward.provider.custom': 'Custom',
|
||||||
|
'competition.reward.quantity': 'Quantity',
|
||||||
|
'competition.reward.claimed': 'Claimed',
|
||||||
|
'competition.reward.available': 'Available',
|
||||||
|
'competition.reward.rank_requirement': 'Minimum Rank',
|
||||||
|
'competition.reward.points_required': 'Minimum Points',
|
||||||
|
'competition.reward.image': 'Reward Image',
|
||||||
|
'competition.reward.code': 'Code',
|
||||||
|
'competition.reward.claim': 'Claim',
|
||||||
|
'competition.reward.claimed_at': 'Claimed on',
|
||||||
|
'competition.reward.expires': 'Expires on',
|
||||||
|
'competition.reward.status': 'Status',
|
||||||
|
'competition.reward.status.pending': 'Pending',
|
||||||
|
'competition.reward.status.claimed': 'Claimed',
|
||||||
|
'competition.reward.status.expired': 'Expired',
|
||||||
|
'competition.reward.status.revoked': 'Revoked',
|
||||||
|
'competition.reward.my_claims': 'My Rewards',
|
||||||
|
'competition.reward.already_claimed': 'Reward already claimed',
|
||||||
|
'competition.reward.not_available': 'Reward no longer available',
|
||||||
|
'competition.reward.requirement_not_met': 'Requirement not met',
|
||||||
|
'competition.dsgvo_notice': 'Notice: Personal data is processed in accordance with GDPR.',
|
||||||
|
'competition.jugendschutz_notice': 'Notice: This competition complies with the Youth Protection Act.',
|
||||||
|
|
||||||
// German Grade Labels
|
// German Grade Labels
|
||||||
'grading.sehr_gut': 'Sehr gut',
|
'grading.sehr_gut': 'Sehr gut',
|
||||||
'grading.gut': 'Gut',
|
'grading.gut': 'Gut',
|
||||||
|
|||||||
+2
-1
@@ -28,7 +28,8 @@ export type ViewName =
|
|||||||
| 'homework'
|
| 'homework'
|
||||||
| 'portfolio'
|
| 'portfolio'
|
||||||
| 'timetable'
|
| 'timetable'
|
||||||
| 'resources';
|
| 'resources'
|
||||||
|
| 'competitions';
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+237
@@ -1267,3 +1267,240 @@ Work Log:
|
|||||||
- Service worker is only active in production builds
|
- Service worker is only active in production builds
|
||||||
- Verification: bun run lint passes with 0 errors
|
- Verification: bun run lint passes with 0 errors
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 3-b
|
||||||
|
Agent: learning-content-backend
|
||||||
|
Task: Enhance Digital Notebooks with German Curriculum Subjects, Tests, and Learning Content
|
||||||
|
Date: 2025-01-28
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- **Prisma schema changes** (`prisma/schema.prisma`):
|
||||||
|
- Added 4 new models: `SubjectTopic`, `SubjectLesson`, `LessonQuestion`, `StudentAnswer`
|
||||||
|
- Added `subjectTopics` relation to `School` model
|
||||||
|
- Added `topics` relation to `Subject` model
|
||||||
|
- Added `studentAnswers` relation to `User` model
|
||||||
|
- Updated `NotebookPage.contentType` to support "quiz" | "exercise" | "flashcard" in addition to existing types
|
||||||
|
- `SubjectTopic` supports gradeLevel (1-6 for German Grundschule), curriculumCode (e.g., DE-NRW-Math-1-01), soft delete
|
||||||
|
- `SubjectLesson` supports lessonType (explanation/exercise/quiz/flashcard/video_link), difficulty (easy/medium/hard), soft delete
|
||||||
|
- `LessonQuestion` supports questionType (multiple_choice/true_false/fill_blank/short_answer/matching), options JSON, explanations
|
||||||
|
- `StudentAnswer` has unique constraint on (questionId, studentId), tracks attempts, timeTakenMs, isCorrect
|
||||||
|
- All models use `@@map` for table naming and proper `@@index` for query performance
|
||||||
|
|
||||||
|
- **API routes created**:
|
||||||
|
- `src/app/api/subject-topics/route.ts` — GET (list with schoolId, subjectId, gradeLevel, curriculumCode filters) and POST (create)
|
||||||
|
- `src/app/api/subject-topics/[id]/route.ts` — GET (single with lessons), PUT (update), DELETE (soft delete)
|
||||||
|
- `src/app/api/subject-lessons/route.ts` — GET (list with topicId, lessonType, difficulty filters) and POST (create)
|
||||||
|
- `src/app/api/subject-lessons/[id]/route.ts` — GET (single with questions), PUT (update), DELETE (soft delete)
|
||||||
|
- `src/app/api/lesson-questions/route.ts` — GET (list with lessonId, questionType filters) and POST (create)
|
||||||
|
- `src/app/api/lesson-questions/[id]/route.ts` — GET (single), PUT (update), DELETE (hard delete)
|
||||||
|
- `src/app/api/student-answers/route.ts` — GET (list with questionId, lessonId filters) and POST (submit answer with auto-grading)
|
||||||
|
- All routes use `withRateLimit` from `@/lib/rate-limit` and `getSession` from `@/lib/auth`
|
||||||
|
- All routes use `z` from `zod` for request validation
|
||||||
|
- Student access: correct answers and explanations are hidden for students in GET responses
|
||||||
|
- Student answers use upsert pattern (unique on questionId + studentId), incrementing attempts on re-answer
|
||||||
|
- Role-based access: students can only see their own answers; teachers/admins can see answers for their school
|
||||||
|
- School-scoped access control: all routes verify data belongs to the user's school
|
||||||
|
|
||||||
|
- **API client functions** (`src/lib/api.ts`):
|
||||||
|
- Added interfaces: `SubjectTopicData`, `SubjectLessonData`, `LessonQuestionData`, `StudentAnswerData`
|
||||||
|
- Added functions: `fetchSubjectTopics`, `fetchSubjectTopic`, `createSubjectTopic`, `updateSubjectTopic`, `deleteSubjectTopic`
|
||||||
|
- Added functions: `fetchSubjectLessons`, `fetchSubjectLesson`, `createSubjectLesson`, `updateSubjectLesson`, `deleteSubjectLesson`
|
||||||
|
- Added functions: `fetchLessonQuestions`, `fetchLessonQuestion`, `createLessonQuestion`, `updateLessonQuestion`, `deleteLessonQuestion`
|
||||||
|
- Added functions: `fetchStudentAnswers`, `submitStudentAnswer`
|
||||||
|
|
||||||
|
- **i18n keys** (`src/lib/i18n.ts`):
|
||||||
|
- Added 45 new keys to both DE and EN dictionaries
|
||||||
|
- Keys cover: learning content (learn, topics, lessons, exercises, quiz, flashcards, progress)
|
||||||
|
- Keys cover: grade level, curriculum, difficulty levels (easy/medium/hard)
|
||||||
|
- Keys cover: quiz interactions (question, answer, correct, incorrect, explanation, try_again, next_question)
|
||||||
|
- Keys cover: scoring and progress (score, time_taken, mastery, practice_more, well_done, keep_practicing)
|
||||||
|
- Keys cover: German curriculum subjects (Mathematik, Deutsch, Sachkunde, Englisch, Musik, Kunst, Religion/Ethik, Sport)
|
||||||
|
- Keys cover: environment tips (papier_sparen, digital_instead_paper, environment_tip)
|
||||||
|
- Keys cover: notebook type labels (type_lined, type_grid, type_blank, type_dotted, type_music)
|
||||||
|
|
||||||
|
- **Database sync**: Ran `bun run db:push` successfully — all 4 new tables created
|
||||||
|
- **Lint check**: `bun run lint` passes with 0 errors
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 3-a
|
||||||
|
Agent: competition-system-backend
|
||||||
|
Task: Add Competition System and Digital Rewards to Prisma Schema + API Routes
|
||||||
|
Date: 2025-01-28
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- **Prisma Schema**: Added 5 new models to `prisma/schema.prisma` before the Newsletter model:
|
||||||
|
- `Competition` — core competition entity with type (class/inter_class/inter_school), category (8 types), status lifecycle (draft/registration/active/completed/cancelled), scoring type, rules, soft delete
|
||||||
|
- `CompetitionParticipant` — participants with type (student/class_group/school), score, rank, disqualification support, unique constraint on (competitionId, participantType, participantId)
|
||||||
|
- `CompetitionReward` — digital rewards with type (digital_code/badge/certificate/experience/physical), provider (netflix/amazon/cinema/theater/concert/custom), rank/points requirements, quantity tracking
|
||||||
|
- `CompetitionLeaderboard` — denormalized leaderboard for fast reads, unique constraint on (competitionId, participantType, participantId), indexed by rank
|
||||||
|
- `RewardClaim` — reward claims with DSGVO-compliant code storage, status lifecycle (pending/claimed/expired/revoked), expiration tracking
|
||||||
|
|
||||||
|
- **Relation fields added to existing models**:
|
||||||
|
- `User`: `competitionsCreated`, `competitionParticipations`, `competitionClaims`
|
||||||
|
- `School`: `competitions`, `rewardClaims`
|
||||||
|
- `Subject`: `competitions`
|
||||||
|
|
||||||
|
- **Database sync**: Ran `bun run db:push` — database already in sync (Prisma client regenerated)
|
||||||
|
|
||||||
|
- **API Routes created** (5 route files):
|
||||||
|
1. `/api/competitions/route.ts` — GET (list with filters: schoolId, status, competitionType, category, isPublic; pagination) + POST (create with full Zod validation, date validation, school access control)
|
||||||
|
2. `/api/competitions/[id]/route.ts` — GET (single with full includes), PUT (update with partial validation), DELETE (soft delete), POST (register participant or update score with automatic leaderboard recalculation)
|
||||||
|
3. `/api/competitions/[id]/leaderboard/route.ts` — GET (ordered by rank/score)
|
||||||
|
4. `/api/competitions/[id]/rewards/route.ts` — GET (with claim counts) + POST (create reward with full validation)
|
||||||
|
5. `/api/reward-claims/route.ts` — GET (role-filtered: students see own, admins see school) + POST (claim with eligibility checks: rank/points, duplicate prevention, quantity tracking)
|
||||||
|
|
||||||
|
- **API client functions** added to `src/lib/api.ts`:
|
||||||
|
- 6 TypeScript interfaces: `CompetitionData`, `CompetitionParticipantData`, `CompetitionRewardData`, `CompetitionLeaderboardEntry`, `RewardClaimData`
|
||||||
|
- 12 API functions: `fetchCompetitions`, `fetchPublicCompetitions`, `fetchCompetition`, `createCompetition`, `updateCompetition`, `deleteCompetition`, `registerCompetitionParticipant`, `updateCompetitionScore`, `fetchCompetitionLeaderboard`, `fetchCompetitionRewards`, `createCompetitionReward`, `fetchRewardClaims`, `claimReward`
|
||||||
|
|
||||||
|
- **i18n keys** added to `src/lib/i18n.ts` for both DE and EN:
|
||||||
|
- 82 competition-related translation keys covering: competition CRUD, types, categories, statuses, scoring, registration, leaderboard, rewards, claims, DSGVO/Jugendschutz notices
|
||||||
|
|
||||||
|
- **Security & compliance**:
|
||||||
|
- All routes use `withRateLimit` from `@/lib/rate-limit`
|
||||||
|
- All routes use `getSession` from `@/lib/auth` for authentication
|
||||||
|
- Role-based access control (TEACHER/SCHOOL_ADMIN/SUPER_ADMIN for management, STUDENT for claims)
|
||||||
|
- School-scoped data isolation (SCHOOL_ADMIN can only access own school)
|
||||||
|
- DSGVO compliance notes in reward claims (promo codes stored with encryption consideration)
|
||||||
|
- Jugendschutzgesetz compliance notices in i18n
|
||||||
|
|
||||||
|
- **Lint check**: `bun run lint` passes with 0 errors
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 4-b
|
||||||
|
Agent: enhance-notebooks-learn-tab
|
||||||
|
Task: Enhance Notebooks View with German Curriculum Learning Content UI
|
||||||
|
Date: 2025-01-28
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- **Added "Lernen" (Learn) tab to notebooks-view.tsx** alongside existing All, Shared, Templates tabs
|
||||||
|
- Added `'learn'` to the `activeTab` type union: `'all' | 'shared' | 'templates' | 'learn'`
|
||||||
|
- Added GraduationCap icon tab button in the tab bar
|
||||||
|
|
||||||
|
- **Created `LearnTab` component** with full German curriculum learning content UI:
|
||||||
|
- **Subject Browser**: Grid of 8 subject cards (Mathematik, Deutsch, Sachkunde, Englisch, Musik, Kunst, Religion/Ethik, Sport) with icons, colors, topic counts
|
||||||
|
- **Topic List**: List of topics for a selected subject with progress bars, grade level badges, curriculum codes (e.g., "DE-NRW-Math-1-01"), lesson counts
|
||||||
|
- **Lesson List**: List of lessons for a topic with difficulty badges (easy/medium/hard), lesson type icons, question counts, estimated time
|
||||||
|
- **Lesson Detail View**: Interactive lesson with 4 tabs (Explanation, Exercise, Quiz, Flashcard)
|
||||||
|
- **Quiz Interface**: One question at a time, progress bar, multiple choice with color-coded feedback (green=correct, red=incorrect), explanation after each answer, score summary with "Gut gemacht!" / "Weiter ueben!" messages, "Nochmal versuchen" (Try Again) button
|
||||||
|
- **Flashcard Tab**: Front/back flashcards with flip animation, navigation controls
|
||||||
|
- **Exercise Tab**: Fill-in-the-blank and short answer exercises with instant feedback
|
||||||
|
|
||||||
|
- **Created `ExerciseQuestion` component** for interactive exercise questions with:
|
||||||
|
- Input fields for fill_blank and short_answer question types
|
||||||
|
- Submit and retry functionality
|
||||||
|
- Color-coded correct/incorrect feedback
|
||||||
|
- Answer submission to backend via `submitStudentAnswer`
|
||||||
|
|
||||||
|
- **Environmental Messaging**:
|
||||||
|
- "Papier sparen, Umwelt schuetzen" banner on subject browser
|
||||||
|
- "Digitale Hefte statt Papierhefte" subtitle
|
||||||
|
- Leaf icon with environmental tips throughout the learn tab
|
||||||
|
- "Mit digitalen Heften schuetzt du die Umwelt — kein Papierverschwendung!" message
|
||||||
|
|
||||||
|
- **Progress Tracking**:
|
||||||
|
- Per-topic progress bars
|
||||||
|
- Overall mastery percentage in quiz results
|
||||||
|
- Score tracking with motivational messages
|
||||||
|
|
||||||
|
- **API Integration**:
|
||||||
|
- `fetchSubjectTopics` — GET topics for a subject
|
||||||
|
- `fetchSubjectTopic` — GET single topic with details
|
||||||
|
- `fetchSubjectLessons` — GET lessons for a topic
|
||||||
|
- `fetchSubjectLesson` — GET single lesson with questions
|
||||||
|
- `fetchLessonQuestions` — GET questions for a lesson
|
||||||
|
- `submitStudentAnswer` — POST submit answer
|
||||||
|
|
||||||
|
- **New imports added**:
|
||||||
|
- API functions: `fetchSubjectTopics`, `fetchSubjectTopic`, `fetchSubjectLessons`, `fetchSubjectLesson`, `fetchLessonQuestions`, `submitStudentAnswer`
|
||||||
|
- Type imports: `SubjectTopicData`, `SubjectLessonData`, `LessonQuestionData`, `StudentAnswerData`
|
||||||
|
- Lucide icons: `ChevronRight`, `XCircle`, `Trophy`, `Zap`, `Flame`, `Dumbbell`, `Heart`, `RotateCcw`, `ArrowRight`, `Play`
|
||||||
|
|
||||||
|
- **Key design decisions**:
|
||||||
|
- Tablet-optimized: 44px minimum touch targets throughout
|
||||||
|
- No emojis — Lucide icons only
|
||||||
|
- Uses existing shadcn/ui components (Card, Button, Input, Badge, Textarea, ScrollArea)
|
||||||
|
- Uses `t()` function from `@/lib/i18n` for all translations
|
||||||
|
- Uses `toast` from `sonner` for notifications
|
||||||
|
- Uses `motion` from `framer-motion` for animations
|
||||||
|
- Responsive design with mobile-first approach
|
||||||
|
- Maintains existing notebook functionality intact
|
||||||
|
|
||||||
|
- **Lint check**: `bun run lint` passes with 0 errors
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 4-a
|
||||||
|
Agent: competitions-frontend
|
||||||
|
Task: Create Competitions View Frontend + Add to Navigation
|
||||||
|
Date: 2025-01-28
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- **Created `/home/z/my-project/src/components/competitions-view.tsx`** — Full-featured competitions view component with:
|
||||||
|
- **Overview tab**: Active competitions, registration-open competitions, completed, and drafts (admin/teacher) sections
|
||||||
|
- **My Competitions tab**: Filtered view of competitions relevant to the user
|
||||||
|
- **Leaderboard tab**: Live leaderboard with dropdown to select active competition, rank display with crown/medal icons
|
||||||
|
- **Rewards tab**: Available rewards with claim buttons, claimed rewards with status badges and code display
|
||||||
|
- **Competition cards**: Color-coded status badges, type/category icons, progress bars, participant counts, register/view buttons
|
||||||
|
- **Competition detail dialog**: Full detail view with tabs (overview/leaderboard/rewards), DSGVO/Jugendschutz notices, register/delete actions
|
||||||
|
- **Create competition dialog** (admin/teacher): Full form with title, description, type, category, dates, scoring, rules
|
||||||
|
- **Add reward dialog** (admin/teacher): Reward name, type, provider, quantity, rank/points requirements
|
||||||
|
- **Search and filter**: Text search, type filter, status filter, category filter
|
||||||
|
- **Responsive design**: Mobile-first with 44px minimum touch targets
|
||||||
|
- **Dark mode support**: Proper dark mode colors throughout
|
||||||
|
- **Framer Motion animations**: Card entrance animations, hover effects, leaderboard row animations
|
||||||
|
- **Helper components**: TypeIcon, CategoryIcon, ProviderIconComp, LeaderboardTable, RewardCard, CompetitionRewardsSection
|
||||||
|
|
||||||
|
- **Modified `/home/z/my-project/src/lib/store.ts`**: Added `'competitions'` to ViewName union type
|
||||||
|
|
||||||
|
- **Modified `/home/z/my-project/src/lib/i18n.ts`**: Added `'nav.competitions'` key to both German and English translations
|
||||||
|
|
||||||
|
- **Modified `/home/z/my-project/src/components/app-layout.tsx`**:
|
||||||
|
- Added `Trophy` import from lucide-react
|
||||||
|
- Added `CompetitionsView` import
|
||||||
|
- Added competitions nav item to teaching section (teacher/admin)
|
||||||
|
- Added competitions nav item to student nav section
|
||||||
|
- Added `case 'competitions': return <CompetitionsView />;` to renderView
|
||||||
|
|
||||||
|
- **Lint**: All errors resolved (had to fix React component creation during render — converted icon helper functions to proper React components)
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 4-c
|
||||||
|
Agent: enhance-student-dashboard
|
||||||
|
Task: Enhance Student Dashboard with "Papier sparen" Messaging and Notebook Access
|
||||||
|
Date: 2025-01-28
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- **Modified `/home/z/my-project/src/components/dashboard-view.tsx`** — Added three new student-specific sections to the STUDENT dashboard:
|
||||||
|
1. **Environmental Banner ("Papier sparen, Umwelt schützen")** — A prominent emerald-green gradient banner at the top of the student dashboard with:
|
||||||
|
- Leaf icon (Lucide) with decorative background leaf pattern
|
||||||
|
- "Papier sparen, Umwelt schützen" title
|
||||||
|
- "Digitale Hefte statt Papierhefte — Mit digitalen Heften schützt du die Umwelt!" subtitle
|
||||||
|
- "Zu meinen Heften" button that navigates to notebooks view
|
||||||
|
- Emerald green gradient background with translucent decorative leaf elements
|
||||||
|
2. **Student Notebook Quick Access ("Meine Hefte")** — A card section showing 4 notebook quick access cards:
|
||||||
|
- Mathematik (amber/orange), Deutsch (emerald/teal), Englisch (rose/pink), Naturwissenschaften (violet/purple)
|
||||||
|
- Each card has a color strip, icon, title, and subject name
|
||||||
|
- "Alle Hefte anzeigen" link and "Lernen starten" button
|
||||||
|
- Cards are clickable and navigate to notebooks view
|
||||||
|
- Framer Motion hover/tap animations on cards
|
||||||
|
3. **Student Learning Progress ("Lernfortschritt")** — A progress overview card:
|
||||||
|
- Animated progress bars for each subject (Math 72%, German 85%, English 58%, Science 44%)
|
||||||
|
- Color-coded progress bars matching subject colors
|
||||||
|
- "Weiter lernen" button that navigates to flower (competency) view
|
||||||
|
- Framer Motion animation on progress bar width
|
||||||
|
|
||||||
|
- **Modified `/home/z/my-project/src/lib/i18n.ts`** — Added 14 new i18n keys for both German and English:
|
||||||
|
- `student.papier_sparen_title`, `student.papier_sparen_subtitle`
|
||||||
|
- `student.go_to_notebooks`, `student.my_notebooks_quick`
|
||||||
|
- `student.show_all_notebooks`, `student.start_learning`
|
||||||
|
- `student.learning_progress`, `student.continue_learning`
|
||||||
|
- `student.subject_progress`
|
||||||
|
- `student.notebook_math`, `student.notebook_german`, `student.notebook_english`, `student.notebook_science`
|
||||||
|
|
||||||
|
- **Verified existing items**:
|
||||||
|
- `competitions` is already in `ViewName` type in `store.ts`
|
||||||
|
- `competitions` is already in student nav sections in `app-layout.tsx`
|
||||||
|
- `Trophy` is already imported in `app-layout.tsx`
|
||||||
|
|
||||||
|
- **Lint**: All checks pass (`bun run lint` — no errors)
|
||||||
Reference in new issue
Block a user