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
125 changes: 125 additions & 0 deletions app/api/_utils/subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { getServerFirestore } from './firebaseAdmin';

export interface UserSubscriptionInfo {
isPro: boolean;
plan: 'free' | 'pro' | 'api';
status: 'active' | 'inactive' | 'cancelled' | 'expired';
subscriptionId?: string;
}

export const getUserSubscription = async (uid: string): Promise<UserSubscriptionInfo> => {
try {
const db = getServerFirestore();
const userDoc = await db.collection('users').doc(uid).get();

if (!userDoc.exists) {
return { isPro: false, plan: 'free', status: 'inactive' };
}

const userData = userDoc.data();
const sub = userData?.subscription;

if (sub && (sub.status === 'active' || sub.status === 'succeeded' || sub.status === 'trialing')) {
return {
isPro: true,
plan: sub.plan || 'pro',
status: 'active',
subscriptionId: sub.subscription_id,
};
}

return {
isPro: false,
plan: 'free',
status: sub?.status || 'inactive',
};
} catch (error) {
console.error('Error fetching user subscription:', error);
return { isPro: false, plan: 'free', status: 'inactive' };
}
};

export const checkResourceLimit = async (
uid: string
): Promise<{ isLimited: boolean; count: number; max: number }> => {
const subInfo = await getUserSubscription(uid);

// Pro and API tier users have unlimited resources
if (subInfo.isPro) {
return { isLimited: false, count: 0, max: Infinity };
}

const FREE_RESOURCE_LIMIT = 50;
const db = getServerFirestore();
const resourcesSnapshot = await db
.collection('resources')
.where('user_id', '==', uid)
.select()
.get();

const count = resourcesSnapshot.size;

return {
isLimited: count >= FREE_RESOURCE_LIMIT,
count,
max: FREE_RESOURCE_LIMIT,
};
};

const getYearMonthKey = (): string => {
const date = new Date();
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
return `${year}-${month}`;
};

export const checkAiQueryLimit = async (
uid: string
): Promise<{ isLimited: boolean; count: number; max: number }> => {
const subInfo = await getUserSubscription(uid);

// Pro users get 300 queries/month, API plan gets 500
const max = subInfo.isPro ? (subInfo.plan === 'api' ? 500 : 300) : 15;
const db = getServerFirestore();
const ymKey = getYearMonthKey();

const usageDoc = await db
.collection('users')
.doc(uid)
.collection('ai_usage')
.doc(ymKey)
.get();

const count = usageDoc.exists ? usageDoc.data()?.count || 0 : 0;

return {
isLimited: count >= max,
count,
max,
};
};

export const incrementAiQueryCount = async (uid: string): Promise<void> => {
try {
const db = getServerFirestore();
const ymKey = getYearMonthKey();
const usageRef = db.collection('users').doc(uid).collection('ai_usage').doc(ymKey);

const doc = await usageRef.get();
if (doc.exists) {
await usageRef.update({
count: (doc.data()?.count || 0) + 1,
updated_at: new Date(),
});
} else {
await usageRef.set({
count: 1,
year_month: ymKey,
created_at: new Date(),
updated_at: new Date(),
});
}
} catch (error) {
console.error('Error incrementing AI query count:', error);
}
};
17 changes: 17 additions & 0 deletions app/api/ai/ask/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { buildAnswerContext, normalizeSearchMode, searchResourceChunks } from '.
import { isAuthError, requireAuth, unauthorizedResponse } from '../../_utils/auth';
import { generateAnswer } from '../../_utils/gemini';
import { checkAiRateLimit } from '../../_utils/rateLimit';
import { checkAiQueryLimit, incrementAiQueryCount } from '../../_utils/subscription';

export async function POST(request: NextRequest) {
try {
Expand All @@ -11,6 +12,19 @@ export async function POST(request: NextRequest) {
// Rate limit: 10 AI queries per minute per authenticated user
const rateLimitResponse = await checkAiRateLimit(request, authUser.uid);
if (rateLimitResponse) return rateLimitResponse;

// Check monthly AI query tier limit
const aiQueryLimitCheck = await checkAiQueryLimit(authUser.uid);
if (aiQueryLimitCheck.isLimited) {
return NextResponse.json(
{
error: `Monthly AI query limit (${aiQueryLimitCheck.max}) reached. Upgrade to Pro for 300 queries/month.`,
code: 'UPGRADE_REQUIRED',
},
{ status: 403 }
);
}

const { question, mode, limit } = await request.json();

if (!question || typeof question !== 'string') {
Expand All @@ -34,6 +48,9 @@ export async function POST(request: NextRequest) {

const answer = await generateAnswer(question, buildAnswerContext(results));

// Increment AI query usage count
await incrementAiQueryCount(authUser.uid);

return NextResponse.json({
success: true,
answer,
Expand Down
13 changes: 13 additions & 0 deletions app/api/resources/pdf/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getServerFirestore } from '../../_utils/firebaseAdmin';
import { extractTextFromPdf } from '../../_utils/pdfExtractor';
import { checkAuthenticatedRateLimit } from '../../_utils/rateLimit';
import { indexResource } from '../../_utils/resourceIndexer';
import { checkResourceLimit } from '../../_utils/subscription';

export async function POST(request: NextRequest) {
try {
Expand All @@ -13,6 +14,18 @@ export async function POST(request: NextRequest) {
const authRateLimitResponse = await checkAuthenticatedRateLimit(request, authUser.uid);
if (authRateLimitResponse) return authRateLimitResponse;

// Check Free tier resource limit
const resourceLimitCheck = await checkResourceLimit(authUser.uid);
if (resourceLimitCheck.isLimited) {
return NextResponse.json(
{
error: `Free tier resource limit (${resourceLimitCheck.max}) reached. Upgrade to Pro for unlimited resource saves.`,
code: 'UPGRADE_REQUIRED',
},
{ status: 403 }
);
}

const formData = await request.formData();
const file = formData.get('file') as File | null;
const customTitle = (formData.get('title') as string) || '';
Expand Down
14 changes: 14 additions & 0 deletions app/api/resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getServerFirestore } from '../_utils/firebaseAdmin';
import { getPreviewFromUrl } from '../_utils/linkPreview';
import { checkAuthenticatedRateLimit, checkPublicRateLimit } from '../_utils/rateLimit';
import { indexResource } from '../_utils/resourceIndexer';
import { checkResourceLimit } from '../_utils/subscription';

// GET /api/resources - Get the authenticated user's resources
export async function GET(request: NextRequest) {
Expand Down Expand Up @@ -121,6 +122,19 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const authUser = await requireAuth(request);

// Check Free tier resource limit
const resourceLimitCheck = await checkResourceLimit(authUser.uid);
if (resourceLimitCheck.isLimited) {
return NextResponse.json(
{
error: `Free tier resource limit (${resourceLimitCheck.max}) reached. Upgrade to Pro for unlimited resource saves.`,
code: 'UPGRADE_REQUIRED',
},
{ status: 403 }
);
}

const body = await request.json();
const { title, link, note, tag, is_public, collection_ids, new_collection, captured_text } = body;

Expand Down
152 changes: 152 additions & 0 deletions app/api/webhooks/dodopayments/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
import { getServerFirestore } from '../../_utils/firebaseAdmin';

const verifyWebhookSignature = (
rawBody: string,
signatureHeader: string | null,
secret: string
): boolean => {
if (!signatureHeader) return false;

try {
const hmac = crypto.createHmac('sha256', secret);
const calculatedSignature = hmac.update(rawBody).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(calculatedSignature),
Buffer.from(signatureHeader)
);
} catch (err) {
console.error('Error verifying webhook signature:', err);
return false;
}
};

export async function POST(request: NextRequest) {
try {
const rawBody = await request.text();
const webhookSecret = process.env.DODOPAYMENTS_WEBHOOK_SECRET;

if (webhookSecret) {
const signature =
request.headers.get('webhook-signature') ||
request.headers.get('x-dodopayments-signature');

if (!verifyWebhookSignature(rawBody, signature, webhookSecret)) {
console.warn('DodoPayments webhook signature verification failed');
return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 401 });
}
}

let payload: any;
try {
payload = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 });
}

const eventType: string = payload.type || payload.event || '';
const eventData = payload.data || payload;

const metadata = eventData.metadata || {};
const userId = metadata.user_id || metadata.uid || metadata.userId;
const customerEmail =
eventData.customer?.email || eventData.customer_email || payload.customer_email;
const subscriptionId =
eventData.subscription_id || eventData.id || payload.subscription_id || `sub_${Date.now()}`;
const productId = eventData.product_id || metadata.product_id;

const db = getServerFirestore();

// Find target user ID
let targetUid = userId;
if (!targetUid && customerEmail) {
const userSnapshot = await db
.collection('users')
.where('email', '==', customerEmail)
.limit(1)
.get();

if (!userSnapshot.empty) {
targetUid = userSnapshot.docs[0].id;
}
}

if (!targetUid) {
console.warn('DodoPayments webhook received but could not resolve user_id:', {
eventType,
customerEmail,
metadata,
});
return NextResponse.json(
{ success: true, message: 'Webhook logged but user_id not matched' },
{ status: 200 }
);
}

const now = new Date();
const isSuccessEvent = [
'payment.succeeded',
'subscription.active',
'subscription.created',
'subscription.renewed',
'checkout.session.completed',
].includes(eventType);

const isCancellationEvent = [
'subscription.cancelled',
'subscription.expired',
'subscription.failed',
'payment.failed',
].includes(eventType);

let newStatus: 'active' | 'cancelled' | 'inactive' = 'inactive';
if (isSuccessEvent) {
newStatus = 'active';
} else if (isCancellationEvent) {
newStatus = 'cancelled';
}

const plan = productId?.includes('api') ? 'api' : 'pro';

// Update user profile with subscription status
const userRef = db.collection('users').doc(targetUid);
await userRef.set(
{
subscription: {
status: newStatus,
plan: newStatus === 'active' ? plan : 'free',
subscription_id: subscriptionId,
customer_email: customerEmail || null,
product_id: productId || null,
updated_at: now,
},
updated_at: now,
},
{ merge: true }
);

// Save event log to subscriptions collection
const subLogRef = db.collection('subscriptions').doc(`${subscriptionId}_${Date.now()}`);
await subLogRef.set({
subscription_id: subscriptionId,
user_id: targetUid,
customer_email: customerEmail || null,
event_type: eventType,
status: newStatus,
plan,
payload,
created_at: now,
});

console.log(`DodoPayments webhook processed successfully for user ${targetUid}: ${eventType} -> ${newStatus}`);

return NextResponse.json({
success: true,
message: `Webhook processed for user ${targetUid}`,
});
} catch (error) {
console.error('Error processing DodoPayments webhook:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Loading
Loading