diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4e21996..361eca3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,6 +76,8 @@ model School { newsletters Newsletter[] competitions Competition[] rewardClaims RewardClaim[] + rewards Reward[] + rewardPoints RewardPoints[] subjectTopics SubjectTopic[] district SchoolDistrict? @relation(fields: [districtId], references: [id]) districtId String? @@ -128,6 +130,8 @@ model User { competitionsCreated Competition[] competitionParticipations CompetitionParticipant[] competitionClaims RewardClaim[] + rewardRedemptions RewardRedemption[] + rewardPoints RewardPoints[] studentAnswers StudentAnswer[] } @@ -1845,3 +1849,66 @@ model StudentAnswer { @@index([studentId]) @@map("student_answers") } + +// ─── Digital Reward Catalog & Points System ────────────────────────── + +model Reward { + id String @id @default(cuid()) + schoolId String + title String + description String? + category String // streaming, shopping, experience, merchandise, privilege + pointsCost Int + image String? + stock Int? + isActive Boolean @default(true) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + redemptions RewardRedemption[] + + @@index([schoolId]) + @@index([category]) + @@map("rewards") +} + +model RewardRedemption { + id String @id @default(cuid()) + rewardId String + userId String + pointsSpent Int + status String @default("pending") // pending, approved, rejected, fulfilled + note String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + reward Reward @relation(fields: [rewardId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([rewardId]) + @@index([userId]) + @@index([status]) + @@map("reward_redemptions") +} + +model RewardPoints { + id String @id @default(cuid()) + userId String + schoolId String + points Int @default(0) + source String // competition, grade, attendance, homework + sourceId String? // ID of the source entity + description String? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + school School @relation(fields: [schoolId], references: [id]) + + @@index([userId]) + @@index([schoolId]) + @@index([source]) + @@map("reward_points") +} diff --git a/qa-screenshots/01-dashboard.png b/qa-screenshots/01-dashboard.png new file mode 100644 index 0000000..ec852aa Binary files /dev/null and b/qa-screenshots/01-dashboard.png differ diff --git a/qa-screenshots/02-auth-page.png b/qa-screenshots/02-auth-page.png new file mode 100644 index 0000000..c0b79bb Binary files /dev/null and b/qa-screenshots/02-auth-page.png differ diff --git a/qa-screenshots/03-auth-page-with-build-error.png b/qa-screenshots/03-auth-page-with-build-error.png new file mode 100644 index 0000000..0c9a603 Binary files /dev/null and b/qa-screenshots/03-auth-page-with-build-error.png differ diff --git a/qa-screenshots/04-auth-page-fixed.png b/qa-screenshots/04-auth-page-fixed.png new file mode 100644 index 0000000..91eaadb Binary files /dev/null and b/qa-screenshots/04-auth-page-fixed.png differ diff --git a/qa-screenshots/05-auth-parent-tab.png b/qa-screenshots/05-auth-parent-tab.png new file mode 100644 index 0000000..7e2a80d Binary files /dev/null and b/qa-screenshots/05-auth-parent-tab.png differ diff --git a/qa-screenshots/06-runtime-error-legend.png b/qa-screenshots/06-runtime-error-legend.png new file mode 100644 index 0000000..350b4d6 Binary files /dev/null and b/qa-screenshots/06-runtime-error-legend.png differ diff --git a/qa-screenshots/07-teacher-dashboard.png b/qa-screenshots/07-teacher-dashboard.png new file mode 100644 index 0000000..097c694 Binary files /dev/null and b/qa-screenshots/07-teacher-dashboard.png differ diff --git a/qa-screenshots/08-teacher-dashboard-working.png b/qa-screenshots/08-teacher-dashboard-working.png new file mode 100644 index 0000000..dc5a81b Binary files /dev/null and b/qa-screenshots/08-teacher-dashboard-working.png differ diff --git a/qa-screenshots/09-notebooks-view.png b/qa-screenshots/09-notebooks-view.png new file mode 100644 index 0000000..aa64947 Binary files /dev/null and b/qa-screenshots/09-notebooks-view.png differ diff --git a/qa-screenshots/10-notebooks-templates.png b/qa-screenshots/10-notebooks-templates.png new file mode 100644 index 0000000..fc2bd13 Binary files /dev/null and b/qa-screenshots/10-notebooks-templates.png differ diff --git a/qa-screenshots/11-competitions-view.png b/qa-screenshots/11-competitions-view.png new file mode 100644 index 0000000..74eef6f Binary files /dev/null and b/qa-screenshots/11-competitions-view.png differ diff --git a/qa-screenshots/12-attendance-misnavigation.png b/qa-screenshots/12-attendance-misnavigation.png new file mode 100644 index 0000000..f1463d2 Binary files /dev/null and b/qa-screenshots/12-attendance-misnavigation.png differ diff --git a/qa-screenshots/13-attendance-view.png b/qa-screenshots/13-attendance-view.png new file mode 100644 index 0000000..a46a6ca Binary files /dev/null and b/qa-screenshots/13-attendance-view.png differ diff --git a/qa-screenshots/14-portfolio-view.png b/qa-screenshots/14-portfolio-view.png new file mode 100644 index 0000000..8215109 Binary files /dev/null and b/qa-screenshots/14-portfolio-view.png differ diff --git a/qa-screenshots/15-teacher-dashboard-full.png b/qa-screenshots/15-teacher-dashboard-full.png new file mode 100644 index 0000000..5f54e7b Binary files /dev/null and b/qa-screenshots/15-teacher-dashboard-full.png differ diff --git a/qa-screenshots/16-mobile-375-dashboard.png b/qa-screenshots/16-mobile-375-dashboard.png new file mode 100644 index 0000000..2b049c4 Binary files /dev/null and b/qa-screenshots/16-mobile-375-dashboard.png differ diff --git a/qa-screenshots/17-mobile-sidebar-open.png b/qa-screenshots/17-mobile-sidebar-open.png new file mode 100644 index 0000000..eb3f7d1 Binary files /dev/null and b/qa-screenshots/17-mobile-sidebar-open.png differ diff --git a/qa-screenshots/18-new-notebook-dialog.png b/qa-screenshots/18-new-notebook-dialog.png new file mode 100644 index 0000000..9d2707e Binary files /dev/null and b/qa-screenshots/18-new-notebook-dialog.png differ diff --git a/qa-screenshots/19-notebooks-learn-tab.png b/qa-screenshots/19-notebooks-learn-tab.png new file mode 100644 index 0000000..aea028a Binary files /dev/null and b/qa-screenshots/19-notebooks-learn-tab.png differ diff --git a/src/app/api/attendance/route.ts b/src/app/api/attendance/route.ts index 7435f37..283f7a6 100644 --- a/src/app/api/attendance/route.ts +++ b/src/app/api/attendance/route.ts @@ -54,7 +54,7 @@ export async function GET(request: Request) { } if ( - session.user?.role === 'SCHOOL_ADMIN' && + (session.user?.role === 'SCHOOL_ADMIN' || session.user?.role === 'TEACHER') && session.user.schoolId && classGroup.schoolId !== session.user.schoolId ) { @@ -200,7 +200,7 @@ export async function POST(request: Request) { } if ( - session.user?.role === 'SCHOOL_ADMIN' && + (session.user?.role === 'SCHOOL_ADMIN' || session.user?.role === 'TEACHER') && session.user.schoolId && classGroup.schoolId !== session.user.schoolId ) { diff --git a/src/app/api/competitions/[id]/leaderboard/route.ts b/src/app/api/competitions/[id]/leaderboard/route.ts index 685a7a0..d5b23a3 100644 --- a/src/app/api/competitions/[id]/leaderboard/route.ts +++ b/src/app/api/competitions/[id]/leaderboard/route.ts @@ -24,13 +24,25 @@ async function getLeaderboard( return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); } - // Access control + // Access control: authenticated users can see their own school's competitions or public ones + // Also allow participants (e.g. inter-school competitions) and users without a schoolId viewing public ones + const userSchoolId = session.user?.schoolId; + const isSameSchool = userSchoolId === competition.schoolId; if ( session.user?.role !== 'SUPER_ADMIN' && - session.user?.schoolId !== competition.schoolId && + !isSameSchool && !competition.isPublic ) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + // Check if the user is a participant in this competition + const participant = await db.competitionParticipant.findFirst({ + where: { + competitionId: id, + userId: session.user!.id, + }, + }); + if (!participant) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } } const leaderboard = await db.competitionLeaderboard.findMany({ diff --git a/src/app/api/competitions/[id]/rewards/route.ts b/src/app/api/competitions/[id]/rewards/route.ts index ee419c6..344b86b 100644 --- a/src/app/api/competitions/[id]/rewards/route.ts +++ b/src/app/api/competitions/[id]/rewards/route.ts @@ -49,13 +49,25 @@ async function getRewards( return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); } - // Access control + // Access control: authenticated users can see their own school's competitions or public ones + // Also allow participants (e.g. inter-school competitions) and users without a schoolId viewing public ones + const userSchoolId = session.user?.schoolId; + const isSameSchool = userSchoolId === competition.schoolId; if ( session.user?.role !== 'SUPER_ADMIN' && - session.user?.schoolId !== competition.schoolId && + !isSameSchool && !competition.isPublic ) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + // Check if the user is a participant in this competition + const participant = await db.competitionParticipant.findFirst({ + where: { + competitionId: id, + userId: session.user!.id, + }, + }); + if (!participant) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } } const rewards = await db.competitionReward.findMany({ diff --git a/src/app/api/competitions/[id]/route.ts b/src/app/api/competitions/[id]/route.ts index c73f6fc..7d5ef0e 100644 --- a/src/app/api/competitions/[id]/route.ts +++ b/src/app/api/competitions/[id]/route.ts @@ -88,11 +88,19 @@ async function getCompetition( return NextResponse.json({ error: 'Competition not found' }, { status: 404 }); } - // Access control: non-admins can only see their own school's competitions or public ones + // Access control: authenticated users can see their own school's competitions or public ones + // Users without a schoolId (e.g. some PARENTs) can still see public competitions + // Participants can always view the competition they are in + const userSchoolId = session.user?.schoolId; + const isSameSchool = userSchoolId === competition.schoolId; + const isParticipant = competition.participants?.some( + (p) => p.userId === session.user!.id || p.participantId === session.user!.id + ); if ( session.user?.role !== 'SUPER_ADMIN' && - session.user?.schoolId !== competition.schoolId && - !competition.isPublic + !isSameSchool && + !competition.isPublic && + !isParticipant ) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } diff --git a/src/app/api/competitions/route.ts b/src/app/api/competitions/route.ts index f9555cb..718b02d 100644 --- a/src/app/api/competitions/route.ts +++ b/src/app/api/competitions/route.ts @@ -53,13 +53,72 @@ async function getCompetitions(request: Request): Promise { const offset = parseInt(searchParams.get('offset') || '0'); let schoolId: string | undefined; - if (session.user?.role === 'SCHOOL_ADMIN') { + if (session.user?.role === 'SUPER_ADMIN') { + // Super admins can query any school or see all + schoolId = schoolIdParam ?? undefined; + } else if (session.user?.role === 'SCHOOL_ADMIN') { schoolId = session.user.schoolId ?? undefined; } else { - schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; + // TEACHER, STUDENT, PARENT — always use the session schoolId to prevent + // cross-school access which would lead to 403 on sub-resources + schoolId = session.user?.schoolId ?? undefined; } - if (!schoolId) return NextResponse.json({ competitions: [], total: 0 }); + if (!schoolId) { + // SUPER_ADMIN without a schoolId filter, or users without a schoolId + if (session.user?.role === 'SUPER_ADMIN') { + // Super admins can see all competitions + const where: Record = { deletedAt: null }; + if (statusParam) where.status = statusParam; + if (competitionTypeParam) where.competitionType = competitionTypeParam; + if (categoryParam) where.category = categoryParam; + + const isPublicParam = searchParams.get('isPublic'); + if (isPublicParam === 'true') where.isPublic = true; + + 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 }); + } + + // Other users without a schoolId can still see public competitions + const where: Record = { isPublic: true, deletedAt: null }; + if (statusParam) where.status = statusParam; + if (competitionTypeParam) where.competitionType = competitionTypeParam; + if (categoryParam) where.category = categoryParam; + + 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 }); + } const where: Record = { schoolId, deletedAt: null }; if (statusParam) where.status = statusParam; diff --git a/src/app/api/gdpr-export/route.ts b/src/app/api/gdpr-export/route.ts index 8b845b1..1659d8d 100644 --- a/src/app/api/gdpr-export/route.ts +++ b/src/app/api/gdpr-export/route.ts @@ -55,8 +55,9 @@ export async function GET() { select: { id: true, competencyId: true, - level: true, - comment: true, + masteryLevelValue: true, + note: true, + date: true, createdAt: true, updatedAt: true, }, @@ -83,12 +84,12 @@ export async function GET() { where: { generatedByUserId: userId }, select: { id: true, - type: true, + period: true, status: true, - createdAt: true, + generatedAt: true, }, take: 200, - orderBy: { createdAt: 'desc' }, + orderBy: { generatedAt: 'desc' }, }); // Fetch audit log entries @@ -97,12 +98,12 @@ export async function GET() { select: { id: true, action: true, - entity: true, + entityType: true, entityId: true, - createdAt: true, + timestamp: true, }, take: 200, - orderBy: { createdAt: 'desc' }, + orderBy: { timestamp: 'desc' }, }); // Fetch data export requests @@ -121,7 +122,7 @@ export async function GET() { // Fetch teacher notes const teacherNotes = await db.teacherNote.findMany({ - where: { userId }, + where: { teacherId: userId }, select: { id: true, content: true, @@ -154,7 +155,7 @@ export async function GET() { type: true, title: true, message: true, - read: true, + isRead: true, createdAt: true, }, take: 200, @@ -176,7 +177,7 @@ export async function GET() { // Fetch homework const homeworks = await db.homework.findMany({ - where: { createdById: userId }, + where: { teacherId: userId }, select: { id: true, title: true, @@ -191,13 +192,17 @@ export async function GET() { let studentData = null; if (user.role === 'STUDENT') { const student = await db.student.findFirst({ - where: { email: user.email, deletedAt: null }, + where: { + firstName: user.firstName, + lastName: user.lastName, + schoolId: user.schoolId ?? undefined, + deletedAt: null, + }, select: { id: true, firstName: true, lastName: true, dateOfBirth: true, - gender: true, enrollments: { select: { id: true, @@ -209,8 +214,8 @@ export async function GET() { learningProgressEntries: { select: { id: true, - level: true, - comment: true, + masteryLevelValue: true, + note: true, createdAt: true, }, take: 200, @@ -220,11 +225,9 @@ export async function GET() { select: { id: true, score: true, - comment: true, - createdAt: true, + note: true, }, take: 200, - orderBy: { createdAt: 'desc' }, }, }, }); @@ -235,7 +238,7 @@ export async function GET() { let parentLinks = null; if (user.role === 'PARENT') { parentLinks = await db.parentStudentLink.findMany({ - where: { userId }, + where: { parentId: userId }, select: { id: true, relationship: true, diff --git a/src/app/api/reward-points/route.ts b/src/app/api/reward-points/route.ts new file mode 100644 index 0000000..ffcf42b --- /dev/null +++ b/src/app/api/reward-points/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// GET /api/reward-points — Get points balance and history +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userId = session.userId; + const schoolId = session.user?.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'No school associated' }, { status: 400 }); + } + + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get('limit') || '50', 10); + + // Fetch points entries + const pointsEntries = await db.rewardPoints.findMany({ + where: { userId, schoolId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + + // Fetch redemptions (spent points) + const redemptions = await db.rewardRedemption.findMany({ + where: { userId, status: { in: ['pending', 'approved', 'fulfilled'] } }, + include: { reward: { select: { title: true, category: true } } }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + + // Calculate balance + const totalEarned = pointsEntries.reduce((sum, e) => sum + e.points, 0); + const totalSpent = redemptions.reduce((sum, r) => sum + r.pointsSpent, 0); + const balance = totalEarned - totalSpent; + + return NextResponse.json({ + balance, + totalEarned, + totalSpent, + pointsHistory: pointsEntries, + redemptions, + }); + } catch (error) { + console.error('Error fetching reward points:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// POST /api/reward-points — Award points to a user +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const role = session.user?.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const schoolId = session.user?.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'No school associated' }, { status: 400 }); + } + + const body = await request.json(); + const { userId, points, source, sourceId, description } = body; + + if (!userId || !points || !source) { + return NextResponse.json({ error: 'userId, points, and source are required' }, { status: 400 }); + } + + const validSources = ['competition', 'grade', 'attendance', 'homework', 'bonus']; + if (!validSources.includes(source)) { + return NextResponse.json({ error: 'Invalid source' }, { status: 400 }); + } + + // Verify the target user belongs to the same school + const targetUser = await db.user.findFirst({ + where: { id: userId, schoolId, deletedAt: null }, + }); + if (!targetUser) { + return NextResponse.json({ error: 'Target user not found in your school' }, { status: 404 }); + } + + const pointsEntry = await db.rewardPoints.create({ + data: { + userId, + schoolId, + points: parseInt(String(points), 10), + source, + sourceId: sourceId || null, + description: description || null, + }, + }); + + // Create audit log + await db.auditLog.create({ + data: { + userId: session.userId, + schoolId, + action: 'POINTS_AWARDED', + entityType: 'RewardPoints', + entityId: pointsEntry.id, + changes: JSON.stringify({ + targetUserId: userId, + points: pointsEntry.points, + source, + description: description || null, + }), + }, + }); + + return NextResponse.json(pointsEntry, { status: 201 }); + } catch (error) { + console.error('Error awarding points:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/rewards/[id]/route.ts b/src/app/api/rewards/[id]/route.ts new file mode 100644 index 0000000..bbec03f --- /dev/null +++ b/src/app/api/rewards/[id]/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// GET /api/rewards/[id] — Get a single reward +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const reward = await db.reward.findFirst({ + where: { id, deletedAt: null }, + include: { + _count: { + select: { redemptions: true }, + }, + }, + }); + + if (!reward) { + return NextResponse.json({ error: 'Reward not found' }, { status: 404 }); + } + + return NextResponse.json(reward); + } catch (error) { + console.error('Error fetching reward:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// PUT /api/rewards/[id] — Update a reward +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const role = session.user?.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const { id } = await params; + const existing = await db.reward.findFirst({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Reward not found' }, { status: 404 }); + } + + const body = await request.json(); + const { title, description, category, pointsCost, image, stock, isActive } = body; + + const reward = await db.reward.update({ + where: { id }, + data: { + ...(title !== undefined && { title }), + ...(description !== undefined && { description: description || null }), + ...(category !== undefined && { category }), + ...(pointsCost !== undefined && { pointsCost: parseInt(String(pointsCost), 10) }), + ...(image !== undefined && { image: image || null }), + ...(stock !== undefined && { stock: stock != null ? parseInt(String(stock), 10) : null }), + ...(isActive !== undefined && { isActive }), + }, + }); + + return NextResponse.json(reward); + } catch (error) { + console.error('Error updating reward:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// DELETE /api/rewards/[id] — Soft delete a reward +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const role = session.user?.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const { id } = await params; + const existing = await db.reward.findFirst({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Reward not found' }, { status: 404 }); + } + + await db.reward.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting reward:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/rewards/redeem/route.ts b/src/app/api/rewards/redeem/route.ts new file mode 100644 index 0000000..81b5ff9 --- /dev/null +++ b/src/app/api/rewards/redeem/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// POST /api/rewards/redeem — Redeem a reward +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userId = session.userId; + const schoolId = session.user?.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'No school associated' }, { status: 400 }); + } + + const body = await request.json(); + const { rewardId } = body; + + if (!rewardId) { + return NextResponse.json({ error: 'rewardId is required' }, { status: 400 }); + } + + // Fetch the reward + const reward = await db.reward.findFirst({ + where: { id: rewardId, deletedAt: null, isActive: true }, + }); + + if (!reward) { + return NextResponse.json({ error: 'Reward not found or inactive' }, { status: 404 }); + } + + // Check stock + if (reward.stock !== null && reward.stock <= 0) { + return NextResponse.json({ error: 'Out of stock' }, { status: 400 }); + } + + // Calculate user's current points balance + const pointsEntries = await db.rewardPoints.findMany({ + where: { userId, schoolId }, + select: { points: true }, + }); + const totalEarned = pointsEntries.reduce((sum, e) => sum + e.points, 0); + + const redemptions = await db.rewardRedemption.findMany({ + where: { userId, status: { in: ['pending', 'approved', 'fulfilled'] } }, + select: { pointsSpent: true }, + }); + const totalSpent = redemptions.reduce((sum, r) => sum + r.pointsSpent, 0); + const currentBalance = totalEarned - totalSpent; + + // Check if user has enough points + if (currentBalance < reward.pointsCost) { + return NextResponse.json( + { error: 'Insufficient points', currentBalance, pointsCost: reward.pointsCost }, + { status: 400 } + ); + } + + // Create redemption + const redemption = await db.rewardRedemption.create({ + data: { + rewardId, + userId, + pointsSpent: reward.pointsCost, + status: 'pending', + }, + }); + + // Decrement stock if applicable + if (reward.stock !== null) { + await db.reward.update({ + where: { id: rewardId }, + data: { stock: reward.stock - 1 }, + }); + } + + // Create audit log + await db.auditLog.create({ + data: { + userId, + schoolId, + action: 'REWARD_REDEEMED', + entityType: 'Reward', + entityId: rewardId, + changes: JSON.stringify({ + rewardTitle: reward.title, + pointsSpent: reward.pointsCost, + redemptionId: redemption.id, + }), + }, + }); + + return NextResponse.json({ + ...redemption, + newBalance: currentBalance - reward.pointsCost, + }, { status: 201 }); + } catch (error) { + console.error('Error redeeming reward:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/rewards/route.ts b/src/app/api/rewards/route.ts new file mode 100644 index 0000000..33483f8 --- /dev/null +++ b/src/app/api/rewards/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// GET /api/rewards — List rewards for the user's school +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const category = searchParams.get('category'); + const includeInactive = searchParams.get('includeInactive') === 'true'; + + const schoolId = session.user?.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'No school associated' }, { status: 400 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (!includeInactive) { + where.isActive = true; + } + + if (category && category !== 'all') { + where.category = category; + } + + const rewards = await db.reward.findMany({ + where, + include: { + _count: { + select: { redemptions: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(rewards); + } catch (error) { + console.error('Error fetching rewards:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// POST /api/rewards — Create a new reward +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const role = session.user?.role; + if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const schoolId = session.user?.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'No school associated' }, { status: 400 }); + } + + const body = await request.json(); + const { title, description, category, pointsCost, image, stock, isActive, isDemo } = body; + + if (!title || !category || !pointsCost) { + return NextResponse.json({ error: 'Title, category, and pointsCost are required' }, { status: 400 }); + } + + const validCategories = ['streaming', 'shopping', 'experience', 'merchandise', 'privilege']; + if (!validCategories.includes(category)) { + return NextResponse.json({ error: 'Invalid category' }, { status: 400 }); + } + + const reward = await db.reward.create({ + data: { + schoolId, + title, + description: description || null, + category, + pointsCost: parseInt(String(pointsCost), 10), + image: image || null, + stock: stock != null ? parseInt(String(stock), 10) : null, + isActive: isActive !== false, + isDemo: isDemo === true, + }, + }); + + return NextResponse.json(reward, { status: 201 }); + } catch (error) { + console.error('Error creating reward:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 77b4234..9a6317f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2786,4 +2786,430 @@ background: linear-gradient(90deg, #10b981, #14b8a6); border-radius: 0.75rem 0.75rem 0 0; } + + /* ─── Task 9: Deep Styling Polish ──────────────────────────────────── */ + + /* Activity Timeline with animated dots */ + .activity-timeline { + position: relative; + padding-left: 1.75rem; + } + .activity-timeline::before { + content: ''; + position: absolute; + left: 0.55rem; + top: 0.25rem; + bottom: 0.25rem; + width: 2px; + background: linear-gradient(to bottom, oklch(0.55 0.17 155 / 0.3), oklch(0.55 0.17 155 / 0.08)); + border-radius: 1px; + } + .activity-timeline-item { + position: relative; + padding-bottom: 1rem; + } + .activity-timeline-item::before { + content: ''; + position: absolute; + left: -1.35rem; + top: 0.4rem; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: oklch(0.55 0.17 155); + box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.15); + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + .activity-timeline-item:hover::before { + transform: scale(1.4); + box-shadow: 0 0 0 5px oklch(0.55 0.17 155 / 0.2); + } + .activity-timeline-item:last-child { + padding-bottom: 0; + } + .dark .activity-timeline::before { + background: linear-gradient(to bottom, oklch(0.65 0.17 155 / 0.3), oklch(0.65 0.17 155 / 0.08)); + } + .dark .activity-timeline-item::before { + background: oklch(0.65 0.17 155); + box-shadow: 0 0 0 3px oklch(0.65 0.17 155 / 0.2); + } + + /* Gradient Card Header */ + .card-header-gradient { + position: relative; + overflow: hidden; + border-radius: 0.75rem 0.75rem 0 0; + } + .card-header-gradient::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.08) 0%, oklch(0.55 0.14 175 / 0.05) 100%); + pointer-events: none; + } + .dark .card-header-gradient::before { + background: linear-gradient(135deg, oklch(0.65 0.17 155 / 0.1) 0%, oklch(0.65 0.14 175 / 0.06) 100%); + } + + /* Progress Ring (SVG-based) */ + .progress-ring-circle { + transition: stroke-dashoffset 0.6s ease-out; + transform: rotate(-90deg); + transform-origin: 50% 50%; + } + + /* Glassmorphism Toolbar (for notebooks, editors) */ + .glass-toolbar { + background: oklch(1 0 0 / 0.75); + backdrop-filter: blur(16px) saturate(180%); + -webkit-backdrop-filter: blur(16px) saturate(180%); + border: 1px solid oklch(1 0 0 / 0.35); + box-shadow: + 0 4px 16px -4px oklch(0 0 0 / 0.1), + inset 0 0 0 1px oklch(1 0 0 / 0.2); + border-radius: 0.75rem; + } + .dark .glass-toolbar { + background: oklch(0.18 0.015 260 / 0.7); + border-color: oklch(1 0 0 / 0.08); + box-shadow: + 0 4px 16px -4px oklch(0 0 0 / 0.4), + inset 0 0 0 1px oklch(1 0 0 / 0.05); + } + + /* Floating Action Button */ + .fab { + position: fixed; + bottom: 5rem; + right: 1.5rem; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + width: 3.5rem; + height: 3.5rem; + border-radius: 9999px; + background: linear-gradient(135deg, #10b981, #14b8a6); + color: white; + box-shadow: 0 6px 20px -4px oklch(0.55 0.17 155 / 0.45), 0 0 0 1px oklch(0.55 0.17 155 / 0.2); + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; + } + .fab:hover { + transform: translateY(-2px) scale(1.05); + box-shadow: 0 10px 30px -6px oklch(0.55 0.17 155 / 0.5), 0 0 0 1px oklch(0.55 0.17 155 / 0.3); + } + .fab:active { + transform: scale(0.95); + } + .dark .fab { + box-shadow: 0 6px 20px -4px oklch(0 0 0 / 0.5), 0 0 0 1px oklch(0.65 0.17 155 / 0.3); + background: linear-gradient(135deg, #10b981, #14b8a6); + } + .dark .fab:hover { + box-shadow: 0 10px 30px -6px oklch(0 0 0 / 0.6), 0 0 0 1px oklch(0.65 0.17 155 / 0.4); + } + + /* Notebook Cover Gradient Effects */ + .notebook-cover { + position: relative; + overflow: hidden; + border-radius: 0.75rem; + } + .notebook-cover::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 40%; + background: linear-gradient(180deg, oklch(0.55 0.17 155 / 0.12) 0%, transparent 100%); + pointer-events: none; + transition: height 0.3s ease; + } + .notebook-cover:hover::before { + height: 55%; + } + .dark .notebook-cover::before { + background: linear-gradient(180deg, oklch(0.65 0.17 155 / 0.15) 0%, transparent 100%); + } + + /* Grade Badge Color Coding */ + .grade-badge-excellent { + background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.15), oklch(0.55 0.14 175 / 0.1)); + color: oklch(0.45 0.17 155); + border: 1px solid oklch(0.55 0.17 155 / 0.25); + } + .dark .grade-badge-excellent { + background: linear-gradient(135deg, oklch(0.65 0.17 155 / 0.2), oklch(0.65 0.14 175 / 0.15)); + color: oklch(0.75 0.17 155); + border-color: oklch(0.65 0.17 155 / 0.3); + } + .grade-badge-good { + background: linear-gradient(135deg, oklch(0.55 0.14 175 / 0.15), oklch(0.60 0.16 195 / 0.1)); + color: oklch(0.45 0.14 175); + border: 1px solid oklch(0.55 0.14 175 / 0.25); + } + .dark .grade-badge-good { + background: linear-gradient(135deg, oklch(0.65 0.14 175 / 0.2), oklch(0.70 0.16 195 / 0.15)); + color: oklch(0.75 0.14 175); + border-color: oklch(0.65 0.14 175 / 0.3); + } + .grade-badge-average { + background: linear-gradient(135deg, oklch(0.72 0.19 85 / 0.15), oklch(0.72 0.19 85 / 0.1)); + color: oklch(0.55 0.19 85); + border: 1px solid oklch(0.72 0.19 85 / 0.25); + } + .dark .grade-badge-average { + background: linear-gradient(135deg, oklch(0.72 0.19 85 / 0.2), oklch(0.72 0.19 85 / 0.15)); + color: oklch(0.82 0.19 85); + border-color: oklch(0.72 0.19 85 / 0.3); + } + .grade-badge-below { + background: linear-gradient(135deg, oklch(0.65 0.22 15 / 0.15), oklch(0.65 0.22 15 / 0.1)); + color: oklch(0.55 0.22 15); + border: 1px solid oklch(0.65 0.22 15 / 0.25); + } + .dark .grade-badge-below { + background: linear-gradient(135deg, oklch(0.65 0.22 15 / 0.2), oklch(0.65 0.22 15 / 0.15)); + color: oklch(0.75 0.22 15); + border-color: oklch(0.65 0.22 15 / 0.3); + } + + /* Competence Flower Petal Animation */ + @keyframes petal-bloom { + 0% { transform: scale(0.3); opacity: 0; } + 60% { transform: scale(1.05); opacity: 0.9; } + 100% { transform: scale(1); opacity: 1; } + } + .animate-petal-bloom { + animation: petal-bloom 0.6s ease-out backwards; + } + + /* Radar Chart Gradient Fill */ + .radar-gradient-fill { + fill: oklch(0.55 0.17 155 / 0.2); + stroke: oklch(0.55 0.17 155); + stroke-width: 2; + } + .dark .radar-gradient-fill { + fill: oklch(0.65 0.17 155 / 0.25); + stroke: oklch(0.65 0.17 155); + } + + /* Mastery Level Indicator */ + .mastery-indicator { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + .mastery-indicator:hover { + transform: scale(1.05); + } + .mastery-indicator-1 { + background: oklch(0.55 0.22 25 / 0.1); + color: oklch(0.55 0.22 25); + border: 1px solid oklch(0.55 0.22 25 / 0.2); + } + .mastery-indicator-2 { + background: oklch(0.72 0.19 85 / 0.1); + color: oklch(0.60 0.18 50); + border: 1px solid oklch(0.72 0.19 85 / 0.2); + } + .mastery-indicator-3 { + background: oklch(0.55 0.17 155 / 0.1); + color: oklch(0.45 0.17 155); + border: 1px solid oklch(0.55 0.17 155 / 0.2); + } + .mastery-indicator-4 { + background: oklch(0.55 0.14 175 / 0.1); + color: oklch(0.45 0.14 175); + border: 1px solid oklch(0.55 0.14 175 / 0.2); + } + .dark .mastery-indicator-1 { + background: oklch(0.55 0.22 25 / 0.15); + color: oklch(0.75 0.19 22); + border-color: oklch(0.55 0.22 25 / 0.3); + } + .dark .mastery-indicator-2 { + background: oklch(0.72 0.19 85 / 0.15); + color: oklch(0.82 0.19 85); + border-color: oklch(0.72 0.19 85 / 0.3); + } + .dark .mastery-indicator-3 { + background: oklch(0.65 0.17 155 / 0.15); + color: oklch(0.75 0.17 155); + border-color: oklch(0.65 0.17 155 / 0.3); + } + .dark .mastery-indicator-4 { + background: oklch(0.65 0.14 175 / 0.15); + color: oklch(0.75 0.14 175); + border-color: oklch(0.65 0.14 175 / 0.3); + } + + /* Animated Progress Bar with Gradient */ + .progress-bar-animated { + position: relative; + height: 0.5rem; + border-radius: 9999px; + overflow: hidden; + background: oklch(0.93 0.005 155); + } + .dark .progress-bar-animated { + background: oklch(0.25 0.02 155); + } + .progress-bar-animated-fill { + height: 100%; + border-radius: 9999px; + background: linear-gradient(90deg, #10b981, #14b8a6); + transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + } + .progress-bar-animated-fill::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.3), transparent); + animation: shimmer-progress 2s ease-in-out infinite; + } + + /* Gradient Header Bar */ + .header-gradient { + background: linear-gradient(135deg, oklch(0.99 0.002 155) 0%, oklch(0.97 0.01 155) 50%, oklch(0.99 0.002 175) 100%); + border-bottom: 1px solid oklch(0.93 0.005 155); + } + .dark .header-gradient { + background: linear-gradient(135deg, oklch(0.15 0.01 260) 0%, oklch(0.13 0.01 260) 50%, oklch(0.15 0.015 155) 100%); + border-bottom-color: oklch(1 0 0 / 8%); + } + + /* Sidebar Active Indicator with Animated Underline */ + .sidebar-active-indicator { + position: relative; + } + .sidebar-active-indicator::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 2px; + background: linear-gradient(90deg, #10b981, #14b8a6); + border-radius: 1px; + transition: width 0.3s ease; + } + .sidebar-active-indicator.active::after { + width: 60%; + } + + /* Notification Bell Pulse */ + @keyframes bell-ring { + 0% { transform: rotate(0deg); } + 10% { transform: rotate(14deg); } + 20% { transform: rotate(-12deg); } + 30% { transform: rotate(10deg); } + 40% { transform: rotate(-8deg); } + 50% { transform: rotate(4deg); } + 60% { transform: rotate(0deg); } + 100% { transform: rotate(0deg); } + } + .animate-bell-ring { + animation: bell-ring 0.8s ease-in-out; + } + + /* Notification Badge Pulse */ + @keyframes badge-pulse { + 0%, 100% { box-shadow: 0 0 0 0 oklch(0.55 0.17 155 / 0.4); } + 50% { box-shadow: 0 0 0 6px oklch(0.55 0.17 155 / 0); } + } + .animate-badge-pulse { + animation: badge-pulse 2s ease-in-out infinite; + } + + /* Card Shadow Transition on Hover */ + .card-shadow-transition { + transition: transform 0.2s ease-out, box-shadow 0.2s ease-out, border-color 0.2s ease-out; + } + .card-shadow-transition:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px -6px oklch(0.55 0.17 155 / 0.12), 0 0 0 1px oklch(0.55 0.17 155 / 0.06); + border-color: oklch(0.55 0.17 155 / 0.2); + } + .dark .card-shadow-transition:hover { + box-shadow: 0 8px 24px -6px oklch(0 0 0 / 0.4), 0 0 0 1px oklch(0.65 0.17 155 / 0.12); + border-color: oklch(0.65 0.17 155 / 0.2); + } + + /* Chart Gradient Fill */ + .chart-gradient-fill { + fill: url(#emeraldGradient); + } + + /* Breadcrumb Current View Indicator */ + .breadcrumb-active-view { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.75rem; + border-radius: 0.5rem; + background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.08), oklch(0.55 0.14 175 / 0.05)); + color: oklch(0.45 0.17 155); + font-weight: 600; + font-size: 0.875rem; + border: 1px solid oklch(0.55 0.17 155 / 0.12); + } + .dark .breadcrumb-active-view { + background: linear-gradient(135deg, oklch(0.65 0.17 155 / 0.12), oklch(0.65 0.14 175 / 0.08)); + color: oklch(0.75 0.17 155); + border-color: oklch(0.65 0.17 155 / 0.2); + } + + /* Student Grid Animation */ + @keyframes student-appear { + from { opacity: 0; transform: scale(0.9) translateY(4px); } + to { opacity: 1; transform: scale(1) translateY(0); } + } + .animate-student-appear { + animation: student-appear 0.3s ease-out backwards; + } + + /* Enhanced Tooltip/Popover Styling */ + .popover-enhanced { + box-shadow: + 0 8px 30px -6px oklch(0 0 0 / 0.12), + 0 0 0 1px oklch(0.55 0.17 155 / 0.08); + border-radius: 0.75rem; + } + .dark .popover-enhanced { + box-shadow: + 0 8px 30px -6px oklch(0 0 0 / 0.5), + 0 0 0 1px oklch(0.65 0.17 155 / 0.12); + } + + /* Gradient border for active class cards */ + .class-card-active { + position: relative; + border: 2px solid transparent; + background-clip: padding-box; + } + .class-card-active::before { + content: ''; + position: absolute; + inset: -2px; + border-radius: calc(0.75rem + 2px); + background: linear-gradient(135deg, #10b981, #14b8a6, #10b981); + z-index: -1; + opacity: 0.6; + transition: opacity 0.3s ease; + } + .class-card-active:hover::before { + opacity: 1; + } } diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 7e397a1..2fe0d08 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -827,7 +827,7 @@ export default function AppLayout() { {currentView === item.key && ( - + )} - {t(item.labelKey)} @@ -970,7 +974,7 @@ export default function AppLayout() { {/* Announcement Banner */} -
+
@@ -985,7 +989,7 @@ export default function AppLayout() { - + {breadcrumbLabel} @@ -1066,7 +1070,7 @@ export default function AppLayout() { initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 500, damping: 15 }} - className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-0.5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30" + className="animate-badge-pulse absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-0.5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30" > {notifData.unreadCount > 9 ? '9+' : notifData.unreadCount} @@ -1207,7 +1211,7 @@ export default function AppLayout() {
-
+
-