Skip to content

Commit b87d023

Browse files
author
rajiv_rago
committed
feat(ai): Subscription-tier-based rate limiting with credits and questions
Replace role-based AI rate limiting with subscription tiers (free/plus/admin) and two currencies: questions (AI tutor chat) and credits (content generation). Credits support variable cost — module generation charges 1 credit per lesson.
1 parent 345a2f4 commit b87d023

File tree

12 files changed

+132
-56
lines changed

12 files changed

+132
-56
lines changed

app/api/ai/chat/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export async function POST(request: NextRequest) {
3535
}
3636

3737
// Rate limit check
38-
const rateCheck = await enforceAIRateLimit(user.userId, user.role, "chat");
38+
const subTier = user.role === "admin" ? "admin" as const : user.subscriptionTier;
39+
const rateCheck = await enforceAIRateLimit(user.userId, subTier, "questions");
3940
if (rateCheck.blocked) return rateCheck.response;
4041

4142
const body = await request.json();

app/api/ai/generate/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export async function POST(request: NextRequest) {
4141
}
4242

4343
// Rate limit check
44-
const rateCheck = await enforceAIRateLimit(user.userId, user.role, "generate");
44+
const subTier = user.role === "admin" ? "admin" as const : user.subscriptionTier;
45+
const rateCheck = await enforceAIRateLimit(user.userId, subTier, "credits");
4546
if (rateCheck.blocked) return rateCheck.response;
4647

4748
if (user.role !== "teacher" && user.role !== "admin") {

app/api/courses/ai/[courseId]/generate-all/route.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import mongoose from "mongoose";
33
import { dbConnect } from "@/lib/db";
4-
import { Course, Module } from "@/lib/models";
4+
import { Course, Module, Lesson } from "@/lib/models";
55
import { authenticate } from "@/lib/auth";
66
import { AIProviderName, AITier } from "@/lib/ai/types";
77
import { resolveProvider } from "@/lib/ai/utils/providerResolver";
@@ -22,10 +22,6 @@ export async function POST(
2222
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
2323
}
2424

25-
// Single rate limit check for the entire batch
26-
const rateCheck = await enforceAIRateLimit(user.userId, user.role, "course_generation");
27-
if (rateCheck.blocked) return rateCheck.response;
28-
2925
const { courseId } = await params;
3026

3127
if (!mongoose.Types.ObjectId.isValid(courseId)) {
@@ -61,6 +57,31 @@ export async function POST(
6157
);
6258
}
6359

60+
// Find eligible modules (skeleton or failed — skip generating and completed)
61+
const eligibleModules = await Module.find({
62+
course: courseId,
63+
contentStatus: { $in: ["skeleton", "failed"] },
64+
}).sort({ order: 1 });
65+
66+
if (eligibleModules.length === 0) {
67+
return NextResponse.json({
68+
jobs: [],
69+
message: "All modules already completed or generating",
70+
});
71+
}
72+
73+
// Count total lessons across all eligible modules to determine credit cost
74+
const eligibleModuleIds = eligibleModules.map((m) => m._id);
75+
const totalLessonCount = await Lesson.countDocuments({
76+
module: { $in: eligibleModuleIds },
77+
});
78+
const creditCost = Math.max(totalLessonCount, 1);
79+
80+
// Rate limit check — costs 1 credit per lesson across all eligible modules
81+
const subTier = user.role === "admin" ? "admin" as const : user.subscriptionTier;
82+
const rateCheck = await enforceAIRateLimit(user.userId, subTier, "credits", creditCost);
83+
if (rateCheck.blocked) return rateCheck.response;
84+
6485
// Fail fast: verify provider before enqueueing any jobs
6586
const { tier: reqTier, provider: reqProvider, model: reqModel } = validation.data;
6687
const userPreferences = (reqTier || reqProvider) ? undefined : await getUserAIPreferences(user.userId);
@@ -85,19 +106,6 @@ export async function POST(
85106
);
86107
}
87108

88-
// Find eligible modules (skeleton or failed — skip generating and completed)
89-
const eligibleModules = await Module.find({
90-
course: courseId,
91-
contentStatus: { $in: ["skeleton", "failed"] },
92-
}).sort({ order: 1 });
93-
94-
if (eligibleModules.length === 0) {
95-
return NextResponse.json({
96-
jobs: [],
97-
message: "All modules already completed or generating",
98-
});
99-
}
100-
101109
// Enqueue a job for each eligible module
102110
const jobs = [];
103111
for (const mod of eligibleModules) {

app/api/courses/ai/[courseId]/lessons/[lessonId]/generate/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ export async function POST(
2222
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
2323
}
2424

25-
// Rate limit check
26-
const rateCheck = await enforceAIRateLimit(user.userId, user.role, "course_generation");
25+
// Rate limit check — single lesson costs 1 credit
26+
const subTier = user.role === "admin" ? "admin" as const : user.subscriptionTier;
27+
const rateCheck = await enforceAIRateLimit(user.userId, subTier, "credits");
2728
if (rateCheck.blocked) return rateCheck.response;
2829

2930
const { courseId, lessonId } = await params;
@@ -114,6 +115,7 @@ export async function POST(
114115
tier: reqTier,
115116
provider: reqProvider,
116117
model: reqModel,
118+
feedback: validation.data.feedback,
117119
},
118120
userId: user.userId,
119121
});

app/api/courses/ai/[courseId]/modules/[moduleId]/generate/route.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import mongoose from "mongoose";
33
import { dbConnect } from "@/lib/db";
4-
import { Course, Module } from "@/lib/models";
4+
import { Course, Module, Lesson } from "@/lib/models";
55
import { authenticate } from "@/lib/auth";
66
import { AIProviderName, AITier } from "@/lib/ai/types";
77
import { resolveProvider } from "@/lib/ai/utils/providerResolver";
@@ -22,10 +22,6 @@ export async function POST(
2222
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
2323
}
2424

25-
// Rate limit check
26-
const rateCheck = await enforceAIRateLimit(user.userId, user.role, "course_generation");
27-
if (rateCheck.blocked) return rateCheck.response;
28-
2925
const { courseId, moduleId } = await params;
3026

3127
if (
@@ -73,6 +69,15 @@ export async function POST(
7369
return NextResponse.json({ error: "Module not found" }, { status: 404 });
7470
}
7571

72+
// Count lessons in this module to determine credit cost
73+
const lessonCount = await Lesson.countDocuments({ module: moduleId });
74+
const creditCost = Math.max(lessonCount, 1);
75+
76+
// Rate limit check — costs 1 credit per lesson in the module
77+
const subTier = user.role === "admin" ? "admin" as const : user.subscriptionTier;
78+
const rateCheck = await enforceAIRateLimit(user.userId, subTier, "credits", creditCost);
79+
if (rateCheck.blocked) return rateCheck.response;
80+
7681
// Fail fast: verify provider
7782
const { tier: reqTier, provider: reqProvider, model: reqModel } = validation.data;
7883
const userPreferences = (reqTier || reqProvider) ? undefined : await getUserAIPreferences(user.userId);

app/api/courses/ai/syllabus/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export async function POST(request: NextRequest) {
3333
}
3434

3535
// Rate limit check
36-
const rateCheck = await enforceAIRateLimit(user.userId, user.role, "course_generation");
36+
const subTier = user.role === "admin" ? "admin" as const : user.subscriptionTier;
37+
const rateCheck = await enforceAIRateLimit(user.userId, subTier, "credits");
3738
if (rateCheck.blocked) return rateCheck.response;
3839

3940
const body = await request.json();

lib/ai/rateLimit.ts

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
import { NextResponse } from "next/server";
22
import AIUsage, { AIUsageCategory } from "@/lib/models/AIUsage";
33
import { dbConnect } from "@/lib/db";
4-
5-
type UserRole = "student" | "teacher" | "admin";
4+
import type { SubscriptionTier } from "@/lib/auth/jwt";
65

76
/**
8-
* Daily request limits by role and category.
7+
* Daily limits by category and subscription tier.
8+
* Infinity means unlimited (skip DB entirely).
99
*/
10-
const DAILY_LIMITS: Record<AIUsageCategory, Record<UserRole, number>> = {
11-
chat: { student: 50, teacher: 200, admin: 10_000 },
12-
generate: { student: 10, teacher: 50, admin: 10_000 },
13-
course_generation: { student: 5, teacher: 20, admin: 10_000 },
10+
const DAILY_LIMITS: Record<AIUsageCategory, Record<SubscriptionTier, number>> = {
11+
questions: { free: 50, plus: Infinity, admin: Infinity },
12+
credits: { free: 10, plus: 100, admin: Infinity },
1413
};
1514

1615
/**
@@ -25,22 +24,36 @@ export interface RateLimitResult {
2524
limit: number;
2625
used: number;
2726
remaining: number;
27+
cost: number;
2828
resetAt: string; // ISO timestamp of next UTC midnight
2929
}
3030

3131
/**
3232
* Checks and atomically increments the AI rate limit counter.
33-
* Race-condition-free: uses conditional $inc so two concurrent requests
34-
* cannot both sneak past the limit.
33+
* Supports variable cost (e.g. 1 credit per lesson in a module).
34+
* For unlimited tiers, skips the DB entirely.
3535
*/
3636
export async function checkAIRateLimit(
3737
userId: string,
38-
role: UserRole,
39-
category: AIUsageCategory
38+
tier: SubscriptionTier,
39+
category: AIUsageCategory,
40+
cost: number = 1
4041
): Promise<RateLimitResult> {
42+
const limit = DAILY_LIMITS[category][tier];
43+
44+
// Compute next UTC midnight for reset header
45+
const tomorrow = new Date();
46+
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
47+
tomorrow.setUTCHours(0, 0, 0, 0);
48+
const resetAt = tomorrow.toISOString();
49+
50+
// Unlimited tier — skip DB entirely
51+
if (!isFinite(limit)) {
52+
return { allowed: true, limit, used: 0, remaining: Infinity, cost, resetAt };
53+
}
54+
4155
await dbConnect();
4256

43-
const limit = DAILY_LIMITS[category][role];
4457
const dateKey = getDateKey();
4558

4659
// Ensure document exists
@@ -50,31 +63,26 @@ export async function checkAIRateLimit(
5063
{ upsert: true }
5164
);
5265

53-
// Conditionally increment — only if under the limit
66+
// Conditionally increment — only if there's enough headroom for the full cost
5467
const result = await AIUsage.findOneAndUpdate(
55-
{ user: userId, category, dateKey, count: { $lt: limit } },
56-
{ $inc: { count: 1 } },
68+
{ user: userId, category, dateKey, count: { $lte: limit - cost } },
69+
{ $inc: { count: cost } },
5770
{ new: true }
5871
);
5972

60-
// Compute next UTC midnight for reset header
61-
const tomorrow = new Date();
62-
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
63-
tomorrow.setUTCHours(0, 0, 0, 0);
64-
const resetAt = tomorrow.toISOString();
65-
6673
if (!result) {
6774
// Limit reached — fetch current count for headers
6875
const doc = await AIUsage.findOne({ user: userId, category, dateKey });
6976
const used = doc?.count ?? limit;
70-
return { allowed: false, limit, used, remaining: 0, resetAt };
77+
return { allowed: false, limit, used, remaining: Math.max(0, limit - used), cost, resetAt };
7178
}
7279

7380
return {
7481
allowed: true,
7582
limit,
7683
used: result.count,
7784
remaining: limit - result.count,
85+
cost,
7886
resetAt,
7987
};
8088
}
@@ -84,20 +92,23 @@ export async function checkAIRateLimit(
8492
*/
8593
export async function enforceAIRateLimit(
8694
userId: string,
87-
role: UserRole,
88-
category: AIUsageCategory
95+
tier: SubscriptionTier,
96+
category: AIUsageCategory,
97+
cost: number = 1
8998
): Promise<
9099
| { blocked: true; response: NextResponse }
91100
| { blocked: false; result: RateLimitResult }
92101
> {
93-
const result = await checkAIRateLimit(userId, role, category);
102+
const result = await checkAIRateLimit(userId, tier, category, cost);
94103

95104
if (!result.allowed) {
96105
const response = NextResponse.json(
97106
{
98107
error: "Daily AI rate limit exceeded. Please try again tomorrow.",
99108
limit: result.limit,
100109
used: result.used,
110+
remaining: result.remaining,
111+
cost: result.cost,
101112
resetAt: result.resetAt,
102113
},
103114
{ status: 429 }

lib/auth/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { signToken, verifyToken, verifyTokenForRefresh, decodeToken } from "./jwt";
2-
export type { JWTPayload } from "./jwt";
2+
export type { JWTPayload, SubscriptionTier } from "./jwt";
33
export {
44
authenticate,
55
getAuthenticatedUser,

lib/auth/jwt.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ const JWT_SECRET: string = process.env.JWT_SECRET;
1010
const JWT_EXPIRES_IN = (process.env.JWT_EXPIRES_IN || "7d") as SignOptions["expiresIn"];
1111
const REFRESH_GRACE_PERIOD_SECONDS = 60 * 60; // 1 hour in seconds
1212

13+
export type SubscriptionTier = "free" | "plus" | "admin";
14+
1315
export interface JWTPayload {
1416
userId: string;
1517
email: string;
1618
role: "student" | "teacher" | "admin";
19+
subscriptionTier: SubscriptionTier;
1720
iat?: number;
1821
exp?: number;
1922
}
@@ -23,6 +26,7 @@ export function signToken(user: IUser): string {
2326
userId: user._id.toString(),
2427
email: user.email,
2528
role: user.role,
29+
subscriptionTier: user.role === "admin" ? "admin" : (user.subscriptionTier || "free"),
2630
};
2731

2832
return jwt.sign(payload, JWT_SECRET, {

lib/models/AIUsage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import mongoose, { Schema, Document, Types } from "mongoose";
22

3-
export type AIUsageCategory = "chat" | "generate" | "course_generation";
3+
export type AIUsageCategory = "questions" | "credits";
44

55
export interface IAIUsage extends Document {
66
_id: Types.ObjectId;
@@ -22,7 +22,7 @@ const AIUsageSchema = new Schema<IAIUsage>(
2222
},
2323
category: {
2424
type: String,
25-
enum: ["chat", "generate", "course_generation"],
25+
enum: ["questions", "credits"],
2626
required: true,
2727
},
2828
dateKey: {

0 commit comments

Comments
 (0)