diff --git a/app/admin/qa-queue/page.tsx b/app/admin/qa-queue/page.tsx index ead465e..cd5615a 100644 --- a/app/admin/qa-queue/page.tsx +++ b/app/admin/qa-queue/page.tsx @@ -19,6 +19,7 @@ import { import { Shield, Clock, CheckCircle, XCircle, Loader2, ArrowLeft, ExternalLink, DollarSign, Ban } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { fetchWithAuth } from '@/lib/fetch-with-auth'; +import { toast } from 'sonner'; interface QueueItem { id: string; @@ -101,27 +102,57 @@ export default function QAQueuePage() { const handleApprove = async (assignmentId: string) => { setSubmitting(true); - await fetchWithAuth(`/api/admin/qa-queue/${assignmentId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'approve' }), - }); - await fetchQueue(); - setSubmitting(false); + try { + const res = await fetchWithAuth(`/api/admin/qa-queue/${assignmentId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'approve' }), + }); + const data = await res.json(); + + if (!res.ok || !data.success) { + toast.error(data.error || 'Failed to approve submission'); + setSubmitting(false); + return; + } + + toast.success('Submission approved successfully'); + await fetchQueue(); + } catch (error) { + console.error('Approve error:', error); + toast.error('Failed to approve submission - network error'); + } finally { + setSubmitting(false); + } }; const handleReject = async () => { if (!rejectTarget || !rejectNotes.trim()) return; setSubmitting(true); - await fetchWithAuth(`/api/admin/qa-queue/${rejectTarget.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'reject', notes: rejectNotes.trim() }), - }); - await fetchQueue(); - setSubmitting(false); - setRejectTarget(null); - setRejectNotes(''); + try { + const res = await fetchWithAuth(`/api/admin/qa-queue/${rejectTarget.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'reject', notes: rejectNotes.trim() }), + }); + const data = await res.json(); + + if (!res.ok || !data.success) { + toast.error(data.error || 'Failed to reject submission'); + setSubmitting(false); + return; + } + + toast.success('Submission rejected - returned to student for revision'); + await fetchQueue(); + } catch (error) { + console.error('Reject error:', error); + toast.error('Failed to reject submission - network error'); + } finally { + setSubmitting(false); + setRejectTarget(null); + setRejectNotes(''); + } }; const openPaymentModal = (item: QueueItem) => { diff --git a/app/api/quests/submissions/route.ts b/app/api/quests/submissions/route.ts index 26f82b4..429a6a3 100644 --- a/app/api/quests/submissions/route.ts +++ b/app/api/quests/submissions/route.ts @@ -336,21 +336,25 @@ export async function PUT(request: NextRequest) { } } + // Process XP and skills INSIDE the transaction to prevent permanent XP loss + if (rewardsPayload) { + const { updateUserXpAndSkills } = await import('@/lib/xp-utils'); + await updateUserXpAndSkills( + rewardsPayload.userId, + rewardsPayload.xpReward, + rewardsPayload.skillPointsReward, + assignmentData.questId, + tx // Pass transaction client to ensure atomic operation + ); + } + return { submission: updatedSubmission, rewardsPayload, paymentInfo }; }, { maxWait: 10_000, timeout: 20_000 } ); - // Process XP and skills (outside transaction) + // Process referral milestone and bootcamp tracking AFTER transaction completes if (reviewResult.rewardsPayload) { - const { updateUserXpAndSkills } = await import('@/lib/xp-utils'); - await updateUserXpAndSkills( - reviewResult.rewardsPayload.userId, - reviewResult.rewardsPayload.xpReward, - reviewResult.rewardsPayload.skillPointsReward, - assignmentData.questId - ); - // Referral milestone check — award XP to the referrer if applicable const completionCount = await prisma.questCompletion.count({ where: { userId: reviewResult.rewardsPayload.userId }, diff --git a/app/api/user/confirm-email-change/route.ts b/app/api/user/confirm-email-change/route.ts new file mode 100644 index 0000000..ab77912 --- /dev/null +++ b/app/api/user/confirm-email-change/route.ts @@ -0,0 +1,231 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import crypto from 'crypto'; + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const token = url.searchParams.get('token'); + + if (!token) { + return NextResponse.json({ error: 'Token is required' }, { status: 400 }); + } + + // Find the email change request + const emailChangeRequest = await prisma.emailChangeRequest.findUnique({ + where: { token }, + include: { user: true }, + }); + + if (!emailChangeRequest) { + return NextResponse.json({ error: 'Invalid or expired token' }, { status: 404 }); + } + + // Check if token is already used + if (emailChangeRequest.status === 'confirmed') { + return NextResponse.json({ error: 'This token has already been used' }, { status: 400 }); + } + + // Check if token is expired + if (emailChangeRequest.expiresAt < new Date()) { + // Update status to expired + await prisma.emailChangeRequest.update({ + where: { id: emailChangeRequest.id }, + data: { status: 'expired' }, + }); + return NextResponse.json({ error: 'Token has expired' }, { status: 400 }); + } + + // Check if new email is still available (another user might have registered with it) + const existingUser = await prisma.user.findUnique({ + where: { email: emailChangeRequest.newEmail }, + }); + + if (existingUser && existingUser.id !== emailChangeRequest.userId) { + return NextResponse.json( + { error: 'This email is now in use by another account. Please request a new email change.' }, + { status: 400 } + ); + } + + // Use transaction to ensure atomic update + await prisma.$transaction(async (tx) => { + // Update user's email + await tx.user.update({ + where: { id: emailChangeRequest.userId }, + data: { + email: emailChangeRequest.newEmail, + // Force session invalidation by updating updatedAt timestamp + updatedAt: new Date(), + }, + }); + + // Mark email change request as confirmed + await tx.emailChangeRequest.update({ + where: { id: emailChangeRequest.id }, + data: { status: 'confirmed' }, + }); + + // Invalidate any other pending email change requests for this user + await tx.emailChangeRequest.updateMany({ + where: { + userId: emailChangeRequest.userId, + id: { not: emailChangeRequest.id }, + status: 'pending', + }, + data: { status: 'expired' }, + }); + }); + + // Return success page or redirect to login + const html = ` + + + + + + Email Changed Successfully + + + +
+
+

Email Changed Successfully!

+

Your email has been updated to ${emailChangeRequest.newEmail}.

+

For security reasons, you have been logged out. Please log in again with your new email address.

+ Go to Login +
+ + + `; + + return new NextResponse(html, { + status: 200, + headers: { + 'Content-Type': 'text/html', + }, + }); + + } catch (error) { + console.error('Failed to confirm email change:', error); + + const html = ` + + + + + + Error Confirming Email Change + + + +
+
+

Error Confirming Email Change

+

There was an error processing your email change request. Please try requesting a new email change.

+ Go to Settings +
+ + + `; + + return new NextResponse(html, { + status: 500, + headers: { + 'Content-Type': 'text/html', + }, + }); + } +} \ No newline at end of file diff --git a/app/api/user/request-email-change/route.ts b/app/api/user/request-email-change/route.ts new file mode 100644 index 0000000..6df6d2f --- /dev/null +++ b/app/api/user/request-email-change/route.ts @@ -0,0 +1,131 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/db'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; + +// Rate limiting store (in-memory for simplicity, could use Redis in production) +const rateLimitStore = new Map(); + +const requestEmailChangeSchema = z.object({ + newEmail: z.string().email('Please provide a valid email address'), + currentPassword: z.string().min(1, 'Current password is required'), +}); + +export async function POST(req: Request) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const userId = session.user.id; + + // Rate limiting: max 3 requests per hour per user + const now = Date.now(); + const userRateLimit = rateLimitStore.get(userId); + + if (userRateLimit && userRateLimit.resetTime > now) { + if (userRateLimit.count >= 3) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ); + } + rateLimitStore.set(userId, { ...userRateLimit, count: userRateLimit.count + 1 }); + } else { + // Reset or create rate limit (1 hour) + rateLimitStore.set(userId, { count: 1, resetTime: now + 60 * 60 * 1000 }); + } + + const json = await req.json(); + const { newEmail, currentPassword } = requestEmailChangeSchema.parse(json); + const normalizedNewEmail = newEmail.trim().toLowerCase(); + + // Fetch user to verify password and get current email + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { passwordHash: true, email: true }, + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // OAuth users have no password — email change via this endpoint is not supported for them + if (!user.passwordHash) { + return NextResponse.json( + { error: 'Email change is not available for social login accounts. Please contact support.' }, + { status: 400 } + ); + } + + // Require current password re-verification (security fix for account takeover) + const isValid = await bcrypt.compare(currentPassword, user.passwordHash); + if (!isValid) { + return NextResponse.json({ error: 'Incorrect password' }, { status: 403 }); + } + + // Prevent changing to the same email + if (user.email.toLowerCase() === normalizedNewEmail) { + return NextResponse.json({ error: 'New email must be different from current email' }, { status: 400 }); + } + + // Check if new email is already in use + const existingUser = await prisma.user.findUnique({ + where: { email: normalizedNewEmail }, + }); + + if (existingUser) { + return NextResponse.json( + { error: 'This email is already in use by another account' }, + { status: 400 } + ); + } + + // Generate secure token + const token = crypto.randomBytes(32).toString('hex'); + + // Set expiration to 24 hours from now + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + // Create email change request + const emailChangeRequest = await prisma.emailChangeRequest.create({ + data: { + userId, + newEmail: normalizedNewEmail, + token, + expiresAt, + status: 'pending', + }, + }); + + // TODO: Send verification email to OLD email address + // In production, this would integrate with an email service like SendGrid, Resend, etc. + const verificationLink = `${process.env.NEXTAUTH_URL}/api/user/confirm-email-change?token=${token}`; + + console.log(`Email change verification link for user ${userId}: ${verificationLink}`); + console.log(`Email should be sent to OLD email: ${user.email}`); + console.log(`New email requested: ${normalizedNewEmail}`); + + // For development/testing purposes, return the verification link + // In production, remove this and only return success message + return NextResponse.json({ + success: true, + message: 'Verification email sent to your current email address.', + verificationLink: process.env.NODE_ENV === 'development' ? verificationLink : undefined + }); + + } catch (error) { + console.error('Failed to request email change:', error); + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }); + } + return NextResponse.json( + { error: 'Something went wrong while processing your request' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/user/update-email/route.ts b/app/api/user/update-email/route.ts index 820e261..7f97467 100644 --- a/app/api/user/update-email/route.ts +++ b/app/api/user/update-email/route.ts @@ -62,13 +62,15 @@ export async function POST(req: Request) { ); } - // Update user's email - await prisma.user.update({ - where: { id: session.user.id }, - data: { email: normalizedEmail }, - }); + // Redirect to new secure email change flow + return NextResponse.json({ + success: false, + message: 'Please use the new secure email change flow. Redirecting...', + redirectTo: '/api/user/request-email-change', + // For backward compatibility, we could automatically forward the request + // but for now, we'll just inform the client to use the new endpoint + }, { status: 400 }); - return NextResponse.json({ success: true, email: normalizedEmail }); } catch (error) { console.error('Failed to update email:', error); if (error instanceof z.ZodError) { @@ -79,4 +81,4 @@ export async function POST(req: Request) { { status: 500 } ); } -} +} \ No newline at end of file diff --git a/app/dashboard/quests/[id]/page.tsx b/app/dashboard/quests/[id]/page.tsx index e420329..6c13efc 100644 --- a/app/dashboard/quests/[id]/page.tsx +++ b/app/dashboard/quests/[id]/page.tsx @@ -25,6 +25,80 @@ import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; import 'highlight.js/styles/github.css'; +interface Submission { + id: string; + status: string; + submissionContent: string; + submissionNotes: string; + reviewNotes: unknown; + criteriaResults: unknown; + qualityScore: number | null; + reviewedAt: string | null; + reviewerId: string | null; +} + +// Rework feedback panel component +function ReworkFeedbackPanel({ submission }: { submission: Submission }) { + const criteriaResults = submission.criteriaResults as Array<{ criterion: string; met: boolean; note?: string }> | null; + const reviewNotes = submission.reviewNotes as string[] | null; + const qualityScore = submission.qualityScore; + + // If no feedback data, show fallback message + if (!criteriaResults && !reviewNotes && !qualityScore) { + return ( +
+

Rework Required

+

Your submission needs revision. Please review the reviewer notes and resubmit.

+
+ ); + } + + return ( +
+
+ +

Rework Required

+
+ + {/* Criteria Results */} + {criteriaResults && criteriaResults.length > 0 && ( +
+

Criteria that need fixing:

+
    + {criteriaResults.map((item, index) => ( +
  • + {item.met ? '✓' : '✗'} {item.criterion} + {item.note && ( +

    {item.note}

    + )} +
  • + ))} +
+
+ )} + + {/* Quality Score */} + {qualityScore !== null && ( +
+ Quality Score: {qualityScore}/10 +
+ )} + + {/* General Review Notes */} + {reviewNotes && reviewNotes.length > 0 && ( +
+

Reviewer Note:

+

{Array.isArray(reviewNotes) ? reviewNotes.join(' ') : reviewNotes}

+
+ )} + +

+ Resubmit when you've addressed the feedback above. +

+
+ ); +} + interface Quest { id: string; title: string; @@ -62,6 +136,18 @@ interface Assignment { progress?: number; completedTasks?: string[]; lastUpdateAt?: string; + // Added for rework feedback display + questSubmissions?: Array<{ + id: string; + status: string; + submissionContent: string; + submissionNotes: string; + reviewNotes: unknown; + criteriaResults: unknown; + qualityScore: number | null; + reviewedAt: string | null; + reviewerId: string | null; + }>; } function assignmentStatusClass(status: string) { @@ -505,6 +591,10 @@ export default function QuestDetailPage() { ? 'Quest claimed! Start working to unlock the submission form.' : 'Quest in progress. Submit your work using the form below.'} + {/* Rework Feedback Panel */} + {assignment.status === 'needs_rework' && assignment.questSubmissions && assignment.questSubmissions.length > 0 && ( + + )} {canStart && (