-
Notifications
You must be signed in to change notification settings - Fork 0
fix: resolve critical build, security, and billing issues for production readiness #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // 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; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Exclude
- if (difficulty && difficulty !== 'beginner') {
+ if (difficulty && difficulty !== 'beginner' && difficulty !== 'all') {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🧰 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. (prototype-pollution-recursive-merge-typescript) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ questions: sanitized, total }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||
| console.error('Questions GET error:', error); | ||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: 'Failed to fetch questions' }, { status: 500 }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| 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 } | ||
| ); | ||
| } |
There was a problem hiding this comment.
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:
Repository: projectamazonph/Interview-lab
Length of output: 6884
🏁 Script executed:
Repository: projectamazonph/Interview-lab
Length of output: 1818
🏁 Script executed:
Repository: projectamazonph/Interview-lab
Length of output: 495
🏁 Script executed:
Repository: projectamazonph/Interview-lab
Length of output: 3730
🏁 Script executed:
Repository: projectamazonph/Interview-lab
Length of output: 4245
Make user creation and verification-token issuance atomic. If
createVerificationTokenfails afterdb.user.create, the request returns500but 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