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 (
+
+
+
+ {/* 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 && (