Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 47 additions & 16 deletions app/admin/qa-queue/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
22 changes: 13 additions & 9 deletions app/api/quests/submissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
231 changes: 231 additions & 0 deletions app/api/user/confirm-email-change/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import crypto from 'crypto';

export async function GET(req: Request) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a GET handler that mutates state (changes the user's email, marks tokens confirmed/expired). Once real email delivery is wired up (see the other comment), email link-scanners, antivirus products, and browser prefetching all commonly auto-visit GET links found in emails — which would silently trigger this confirmation without the user ever clicking. Recommend making the actual state change a POST from a confirmation page the GET renders (GET shows "click to confirm", POST does the mutation), which is the standard pattern for exactly this kind of link-triggered mutation.

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 = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Changed Successfully</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 400px;
}
.success-icon {
font-size: 48px;
color: #10b981;
margin-bottom: 1rem;
}
h1 {
color: #1f2937;
margin-bottom: 1rem;
}
p {
color: #6b7280;
margin-bottom: 2rem;
line-height: 1.5;
}
.login-button {
background: #667eea;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.login-button:hover {
background: #5a67d8;
}
</style>
</head>
<body>
<div class="container">
<div class="success-icon">✓</div>
<h1>Email Changed Successfully!</h1>
<p>Your email has been updated to <strong>${emailChangeRequest.newEmail}</strong>.</p>
<p>For security reasons, you have been logged out. Please log in again with your new email address.</p>
<a href="/login" class="login-button">Go to Login</a>
</div>
</body>
</html>
`;

return new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html',
},
});

} catch (error) {
console.error('Failed to confirm email change:', error);

const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error Confirming Email Change</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #f56565 0%, #ed8936 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 400px;
}
.error-icon {
font-size: 48px;
color: #f56565;
margin-bottom: 1rem;
}
h1 {
color: #1f2937;
margin-bottom: 1rem;
}
p {
color: #6b7280;
margin-bottom: 2rem;
line-height: 1.5;
}
.home-button {
background: #f56565;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.home-button:hover {
background: #e53e3e;
}
</style>
</head>
<body>
<div class="container">
<div class="error-icon">✗</div>
<h1>Error Confirming Email Change</h1>
<p>There was an error processing your email change request. Please try requesting a new email change.</p>
<a href="/dashboard/settings" class="home-button">Go to Settings</a>
</div>
</body>
</html>
`;

return new NextResponse(html, {
status: 500,
headers: {
'Content-Type': 'text/html',
},
});
}
}
Loading