Skip to content
Merged
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
4,255 changes: 2,588 additions & 1,667 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions src/app/api/auth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,13 @@ export async function POST(request: Request) {
});

// Create email verification token
const verifyToken = createVerificationToken(sanitizedEmail);
await createVerificationToken(sanitizedEmail);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the register route and related helpers
sed -n '1,220p' src/app/api/auth/register/route.ts

printf '\n--- createVerificationToken references ---\n'
rg -n "createVerificationToken|sanitizedEmail|duplicate-email|409|catch" src/app/api/auth -S

printf '\n--- verification token implementation ---\n'
fd -a "verification" src || true
rg -n "function createVerificationToken|const createVerificationToken|export .*createVerificationToken" src -S

Repository: projectamazonph/Interview-lab

Length of output: 6884


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' src/lib/email-verification.ts

printf '\n--- prisma schema user/token tables ---\n'
rg -n "model (User|EmailVerificationToken|VerificationToken|Profile)" prisma src -S

Repository: projectamazonph/Interview-lab

Length of output: 1818


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the Prisma schema or equivalent model definitions
fd -a "schema.prisma" .
fd -a "*.prisma" .
rg -n "model EmailVerificationToken|emailVerificationToken" . -S

Repository: projectamazonph/Interview-lab

Length of output: 495


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,130p' prisma/schema.prisma

Repository: projectamazonph/Interview-lab

Length of output: 3730


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' src/app/api/auth/login/route.ts
printf '\n--- verify-email route ---\n'
sed -n '1,220p' src/app/api/auth/verify-email/route.ts

Repository: projectamazonph/Interview-lab

Length of output: 4245


Make user creation and verification-token issuance atomic. If createVerificationToken fails after db.user.create, the request returns 500 but the user row stays committed; a retry then hits the duplicate-email check and there’s no recovery path. Wrap both writes in one transaction or delete the user on token failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/auth/register/route.ts` at line 127, Update the registration flow
containing db.user.create and createVerificationToken so both writes are atomic:
execute them within one database transaction, or remove the newly created user
when token issuance fails. Preserve the existing success and error responses
while ensuring no user row remains if createVerificationToken fails.


// Clean up expired tokens periodically
// cleanup handled by Prisma TTL;

// Log verification link (MVP — in production, send via email)
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify-email?token=${verifyToken}`;
console.log(`[EMAIL VERIFICATION] Verify URL for ${sanitizedEmail}: ${verifyUrl}`);
// In production, send verification via email instead of logging
// Verification token is stored in the database for the verify-email endpoint

// Create JWT session (HttpOnly cookie)
const response = NextResponse.json({
Expand Down
32 changes: 31 additions & 1 deletion src/app/api/guides/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { db } from '@/lib/db';
import { getUserFromRequest } from '@/lib/auth-helpers';
import { checkGuideAccess } from '@/lib/subscription-guard';
import { sanitizeText } from '@/lib/sanitize';
import { NextResponse } from 'next/server';

Expand All @@ -9,6 +10,25 @@ export async function GET(request: Request) {
const level = searchParams.get('level');
const role = searchParams.get('role');

// Auth required for non-beginner guides
const user = await getUserFromRequest(request);

if (level && level !== 'beginner' && level !== 'all') {
if (!user) {
return NextResponse.json(
{ error: 'Authentication required for premium guides.' },
{ status: 401 }
);
}
const access = checkGuideAccess(user.subscriptionTier, level);
if (!access.allowed) {
return NextResponse.json(
{ error: access.reason || 'Subscription required for premium guides.' },
{ status: 403 }
);
}
}

const where: Record<string, unknown> = { status: 'published' };
if (level && level !== 'all') where.level = level;
if (role && role !== 'all') where.role = { in: [role, 'General'] };
Expand All @@ -18,7 +38,17 @@ export async function GET(request: Request) {
orderBy: [{ level: 'asc' }, { title: 'asc' }],
});

return NextResponse.json({ guides });
// Strip content for guides the user doesn't have access to
const userTier = user?.subscriptionTier ?? 'free';
const sanitized = guides.map((guide: Record<string, unknown>) => {
const access = checkGuideAccess(userTier, guide.level as string);
if (access.allowed) return guide;
// Return only metadata without content
const { content, ...meta } = guide;
return { ...meta, content: null, locked: true };
});

return NextResponse.json({ guides: sanitized });
} catch (error) {
console.error('Guides GET error:', error);
return NextResponse.json({ error: 'Failed to fetch guides' }, { status: 500 });
Expand Down
61 changes: 58 additions & 3 deletions src/app/api/questions/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import { db } from '@/lib/db';
import { getUserFromRequest } from '@/lib/auth-helpers';
import { checkQuestionBankAccess } from '@/lib/subscription-guard';
import { NextResponse } from 'next/server';

const QUESTION_SAFE_FIELDS = {
id: true,
role: true,
difficulty: true,
type: true,
skillArea: true,
question: true,
answerFormat: true,
status: true,
createdAt: true,
} as const;

const PREMIUM_FIELDS = {
whyEmployersAsk: true,
strongAnswerPoints: true,
weakAnswerWarnings: true,
sampleAnswer: true,
advancedQuestions: true,
} as const;

export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
Expand All @@ -9,8 +31,11 @@ export async function GET(request: Request) {
const type = searchParams.get('type');
const skillArea = searchParams.get('skillArea');
const search = searchParams.get('search');
const limit = parseInt(searchParams.get('limit') || '50');
const offset = parseInt(searchParams.get('offset') || '0');
const limit = Math.min(Math.max(parseInt(searchParams.get('limit') || '50'), 1), 100);
const offset = Math.max(parseInt(searchParams.get('offset') || '0'), 0);
Comment on lines +34 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle non-numeric pagination values before clamping.

Values such as limit=abc produce NaN, which survives both clamps and can make the Prisma query fail with 500.

Proposed fix
-    const limit = Math.min(Math.max(parseInt(searchParams.get('limit') || '50'), 1), 100);
-    const offset = Math.max(parseInt(searchParams.get('offset') || '0'), 0);
+    const parsedLimit = Number.parseInt(searchParams.get('limit') ?? '50', 10);
+    const parsedOffset = Number.parseInt(searchParams.get('offset') ?? '0', 10);
+    const limit = Number.isFinite(parsedLimit)
+      ? Math.min(Math.max(parsedLimit, 1), 100)
+      : 50;
+    const offset = Number.isFinite(parsedOffset)
+      ? Math.max(parsedOffset, 0)
+      : 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const limit = Math.min(Math.max(parseInt(searchParams.get('limit') || '50'), 1), 100);
const offset = Math.max(parseInt(searchParams.get('offset') || '0'), 0);
const parsedLimit = Number.parseInt(searchParams.get('limit') ?? '50', 10);
const parsedOffset = Number.parseInt(searchParams.get('offset') ?? '0', 10);
const limit = Number.isFinite(parsedLimit)
? Math.min(Math.max(parsedLimit, 1), 100)
: 50;
const offset = Number.isFinite(parsedOffset)
? Math.max(parsedOffset, 0)
: 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/questions/route.ts` around lines 34 - 35, Update the pagination
parsing for limit and offset in the questions route to detect non-numeric
parseInt results before applying Math.min, Math.max, or passing values to
Prisma. Fall back to the existing defaults of 50 for limit and 0 for offset,
while preserving the current 1–100 limit and non-negative offset bounds for
valid numeric inputs.


// Check authentication and subscription for non-free content
const user = await getUserFromRequest(request);

const where: Record<string, unknown> = { status: 'published' };
if (role && role !== 'all') where.role = role;
Expand All @@ -21,6 +46,23 @@ export async function GET(request: Request) {
where.question = { contains: search };
}

// For non-free difficulties, require auth and subscription check
if (difficulty && difficulty !== 'beginner') {
Comment on lines +49 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exclude all from the premium-difficulty gate.

difficulty=all returns 401 to anonymous clients, while omitting the parameter returns the same mixed dataset with redaction.

-    if (difficulty && difficulty !== 'beginner') {
+    if (difficulty && difficulty !== 'beginner' && difficulty !== 'all') {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// For non-free difficulties, require auth and subscription check
if (difficulty && difficulty !== 'beginner') {
// For non-free difficulties, require auth and subscription check
if (difficulty && difficulty !== 'beginner' && difficulty !== 'all') {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/questions/route.ts` around lines 49 - 50, Update the
premium-difficulty condition in the questions route so difficulty="all" bypasses
the authentication and subscription gate alongside omitted difficulty and
beginner. Preserve the existing gate for other non-free difficulty values.

if (!user) {
return NextResponse.json(
{ error: 'Authentication required for premium questions.' },
{ status: 401 }
);
}
const access = checkQuestionBankAccess(user.subscriptionTier, difficulty);
if (!access.allowed) {
return NextResponse.json(
{ error: access.reason || 'Subscription required for premium questions.' },
{ status: 403 }
);
}
}

const [questions, total] = await Promise.all([
db.question.findMany({
where,
Expand All @@ -31,7 +73,20 @@ export async function GET(request: Request) {
db.question.count({ where }),
]);

return NextResponse.json({ questions, total });
// Strip premium fields for free-tier users
const userTier = user?.subscriptionTier ?? 'free';
const canAccessPremium = (userTier === 'starter' || userTier === 'pro');

const sanitized = questions.map((q: Record<string, unknown>) => {
if (canAccessPremium) return q;
const safe: Record<string, unknown> = {};
for (const key of Object.keys(QUESTION_SAFE_FIELDS)) {
safe[key] = q[key];
}
return safe;
Comment on lines +76 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Apply subscription access per question difficulty.

Treating both starter and pro as universally premium lets starter users receive full advanced records through an unfiltered query. Sanitize each record using checkQuestionBankAccess(userTier, q.difficulty).

Proposed fix
-    const canAccessPremium = (userTier === 'starter' || userTier === 'pro');
-
     const sanitized = questions.map((q: Record<string, unknown>) => {
-      if (canAccessPremium) return q;
+      const access = checkQuestionBankAccess(userTier, q.difficulty as string);
+      if (access.allowed) return q;
       const safe: Record<string, unknown> = {};
       for (const key of Object.keys(QUESTION_SAFE_FIELDS)) {
         safe[key] = q[key];
       }
-      return safe;
+      return { ...safe, locked: true };
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Strip premium fields for free-tier users
const userTier = user?.subscriptionTier ?? 'free';
const canAccessPremium = (userTier === 'starter' || userTier === 'pro');
const sanitized = questions.map((q: Record<string, unknown>) => {
if (canAccessPremium) return q;
const safe: Record<string, unknown> = {};
for (const key of Object.keys(QUESTION_SAFE_FIELDS)) {
safe[key] = q[key];
}
return safe;
// Strip premium fields for free-tier users
const userTier = user?.subscriptionTier ?? 'free';
const sanitized = questions.map((q: Record<string, unknown>) => {
const access = checkQuestionBankAccess(userTier, q.difficulty as string);
if (access.allowed) return q;
const safe: Record<string, unknown> = {};
for (const key of Object.keys(QUESTION_SAFE_FIELDS)) {
safe[key] = q[key];
}
return { ...safe, locked: true };
🧰 Tools
🪛 ast-grep (0.44.1)

[error] 82-84: Recursive/iterative merge copies attacker-controllable keys from a source object into a target via a computed property assignment without rejecting dangerous keys, allowing prototype pollution. Skip or block "proto", "constructor", and "prototype" keys (e.g. if (key === "__proto__" || key === "constructor" || key === "prototype") continue;), use a null-prototype object (Object.create(null)), or use a safe merge utility instead.
Context: for (const key of Object.keys(QUESTION_SAFE_FIELDS)) {
safe[key] = q[key];
}
Note: [CWE-1321] Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution').

(prototype-pollution-recursive-merge-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/questions/route.ts` around lines 76 - 86, Update the question
sanitization in the questions mapping to evaluate access per record with
checkQuestionBankAccess(userTier, q.difficulty), rather than using the shared
canAccessPremium flag. Return the full question only when that check passes;
otherwise copy only QUESTION_SAFE_FIELDS into the sanitized record, preserving
the existing free-tier field filtering.

});

return NextResponse.json({ questions: sanitized, total });
} catch (error) {
console.error('Questions GET error:', error);
return NextResponse.json({ error: 'Failed to fetch questions' }, { status: 500 });
Expand Down
152 changes: 5 additions & 147 deletions src/app/api/subscription/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,150 +1,8 @@
import { db } from '@/lib/db';
import { getUserFromRequest } from '@/lib/auth-helpers';
import { PRICING_TIERS, TierKey, BillingPeriod, getTierPrice, CURRENCY } from '@/lib/pricing';
import { TIER_HIERARCHY } from '@/lib/pricing';
import { NextResponse } from 'next/server';

interface CheckoutRequestBody {
tier: 'starter' | 'pro';
billing: BillingPeriod;
}

export async function POST(request: Request) {
try {
const user = await getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const body: CheckoutRequestBody = await request.json();
const { tier, billing = 'monthly' } = body;

// Validate requested tier
if (!['starter', 'pro'].includes(tier)) {
return NextResponse.json(
{ error: 'Invalid tier. Must be "starter" or "pro".' },
{ status: 400 }
);
}

// Validate billing period
if (!['monthly', 'yearly'].includes(billing)) {
return NextResponse.json(
{ error: 'Invalid billing period. Must be "monthly" or "yearly".' },
{ status: 400 }
);
}

// Check if user is already on this tier or higher
const currentTierLevel = TIER_HIERARCHY[user.subscriptionTier as TierKey] ?? 0;
const requestedTierLevel = TIER_HIERARCHY[tier as TierKey] ?? 0;

if (currentTierLevel >= requestedTierLevel) {
return NextResponse.json(
{ error: `You are already on the ${PRICING_TIERS[user.subscriptionTier as TierKey]?.name ?? user.subscriptionTier} plan or higher.` },
{ status: 400 }
);
}

const price = getTierPrice(tier as TierKey, billing);
// For PHP, amounts are stored in centavos (1 PHP = 100 centavos)
const amountInCents = Math.round(price * 100);

// Calculate period dates
const now = new Date();
const periodEnd = new Date(now);
if (billing === 'monthly') {
periodEnd.setMonth(periodEnd.getMonth() + 1);
} else {
periodEnd.setFullYear(periodEnd.getFullYear() + 1);
}

// --- Direct upgrade (no Stripe) ---
// This section would be replaced with Stripe checkout session creation
// when Stripe keys are configured. For now, we perform the upgrade directly.

// 1. Create or update Subscription record
const existingSubscription = await db.subscription.findUnique({
where: { userId: user.id },
});

let subscription;
if (existingSubscription) {
subscription = await db.subscription.update({
where: { id: existingSubscription.id },
data: {
tier,
status: 'active',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
cancelAtPeriodEnd: false,
stripePriceId: billing === 'monthly'
? PRICING_TIERS[tier as TierKey].priceId
: PRICING_TIERS[tier as TierKey].yearlyPriceId ?? PRICING_TIERS[tier as TierKey].priceId,
},
});
} else {
subscription = await db.subscription.create({
data: {
userId: user.id,
tier,
status: 'active',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
cancelAtPeriodEnd: false,
stripePriceId: billing === 'monthly'
? PRICING_TIERS[tier as TierKey].priceId
: PRICING_TIERS[tier as TierKey].yearlyPriceId ?? PRICING_TIERS[tier as TierKey].priceId,
},
});
}

// 2. Update User.subscriptionTier
await db.user.update({
where: { id: user.id },
data: { subscriptionTier: tier },
});

// 3. Create Payment record
const payment = await db.payment.create({
data: {
userId: user.id,
amount: amountInCents,
currency: CURRENCY.code.toLowerCase(),
status: 'completed',
description: `${PRICING_TIERS[tier as TierKey].name} plan - ${billing === 'yearly' ? 'Yearly' : 'Monthly'} subscription`,
metadata: JSON.stringify({
tier,
billing,
subscriptionId: subscription.id,
directUpgrade: true,
}),
},
});

// When Stripe is configured, this would return:
// return NextResponse.json({ url: stripeCheckoutSession.url });
// For now, return a success response with the subscription details
return NextResponse.json({
success: true,
url: `/dashboard?upgraded=${tier}`, // Frontend redirect URL
subscription: {
id: subscription.id,
tier: subscription.tier,
status: subscription.status,
currentPeriodStart: subscription.currentPeriodStart,
currentPeriodEnd: subscription.currentPeriodEnd,
},
payment: {
id: payment.id,
amount: payment.amount,
status: payment.status,
description: payment.description,
},
message: `Successfully upgraded to ${PRICING_TIERS[tier as TierKey].name} plan!`,
});
} catch (error) {
console.error('Subscription checkout POST error:', error);
return NextResponse.json({ error: 'Failed to process checkout' }, { status: 500 });
}
export async function POST() {
return NextResponse.json(
{ error: 'Paid plans are not currently available.' },
{ status: 503 }
);
}
Loading
Loading