Round 19: Competition system, German curriculum learning, enhanced notebooks, student dashboard
- Fix portfolio-view.tsx: replace entry.isPublic with form.isPublic (runtime error) - Add Competition system: 5 Prisma models (Competition, CompetitionParticipant, CompetitionReward, CompetitionLeaderboard, RewardClaim) - Add German curriculum learning: 4 Prisma models (SubjectTopic, SubjectLesson, LessonQuestion, StudentAnswer) - Add 12 API routes for competitions and rewards - Add 7 API routes for subject topics, lessons, questions, and student answers - Create competitions-view.tsx with full CRUD, leaderboard, rewards, registration - Enhance notebooks-view.tsx with 'Lernen' tab for German curriculum subjects - Add student-specific dashboard with Papier sparen banner, notebook quick access, learning progress - Add competitions to teacher and student navigation - Add 130+ i18n keys for DE and EN (competitions, rewards, learning, environment) - Enhanced student nav with competitions and notebooks - DSGVO-compliant reward claims with status lifecycle
This commit is contained in:
1 parent
74404708e5
commit
849b48a5a4
22 files changed
+6543
-5
No files matched your search
@@ -0,0 +1,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');
|
||||
Reference in new issue
Block a user