Use a singleton pattern to avoid multiple connections in serverless:
import mongoose from "mongoose";
const MONGODB_URI = process.env.MONGODB_URI!;
if (!MONGODB_URI) {
throw new Error("MONGODB_URI environment variable is not set");
}
let cached = (global as any).mongoose;
if (!cached) {
cached = (global as any).mongoose = { conn: null, promise: null };
}
export async function connectDB() {
if (cached.conn) return cached.conn;
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI, {
bufferCommands: false,
});
}
cached.conn = await cached.promise;
return cached.conn;
}Stores user accounts. Managed jointly by NextAuth and custom fields.
// src/models/User.ts
import mongoose, { Schema, Document } from "mongoose";
export interface IUser extends Document {
_id: mongoose.Types.ObjectId;
email: string;
emailVerified: Date | null;
name: string;
image: string | null; // Google profile picture URL
hashedPassword: string | null; // null for OAuth-only users
provider: "google" | "credentials"; // Primary auth method
// Subscription
tier: "free" | "pro" | "team";
stripeCustomerId: string | null; // Stripe customer ID (cus_xxx)
stripeSubscriptionId: string | null; // Active subscription (sub_xxx)
// Usage tracking
pathsGeneratedThisMonth: number; // Resets on billing cycle
usagePeriodStart: Date; // Start of current billing period
// Password reset (credentials users only)
resetToken: string | null; // SHA-256 hashed token
resetTokenExpiry: Date | null; // 1-hour expiry
// Metadata
createdAt: Date;
updatedAt: Date;
lastLoginAt: Date;
}
const UserSchema = new Schema<IUser>(
{
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
index: true,
},
emailVerified: { type: Date, default: null },
name: { type: String, required: true, trim: true, maxlength: 100 },
image: { type: String, default: null },
hashedPassword: { type: String, default: null },
provider: {
type: String,
enum: ["google", "credentials"],
required: true,
},
tier: {
type: String,
enum: ["free", "pro", "team"],
default: "free",
},
stripeCustomerId: { type: String, default: null, sparse: true },
stripeSubscriptionId: { type: String, default: null, sparse: true },
pathsGeneratedThisMonth: { type: Number, default: 0 },
usagePeriodStart: { type: Date, default: Date.now },
resetToken: { type: String, default: null },
resetTokenExpiry: { type: Date, default: null },
lastLoginAt: { type: Date, default: Date.now },
},
{ timestamps: true }
);
// Indexes
UserSchema.index({ stripeCustomerId: 1 }, { sparse: true });
UserSchema.index({ email: 1 }, { unique: true });Stores the AI-generated learning path data. One document per path.
// src/models/LearningPath.ts
import mongoose, { Schema, Document } from "mongoose";
export interface ILearningPath extends Document {
_id: mongoose.Types.ObjectId;
userId: mongoose.Types.ObjectId; // Owner (ref: users)
// Form inputs that generated this path
formData: {
topic: string;
skillLevel: string;
learningGoal: string;
videoLengths: string[];
teachingStyles: string[];
creatorTypes: string[];
hoursPerWeek: string;
timeline: string;
excludeFilters: string[];
includeFilters: string[];
};
// Path summary (computed on creation)
summary: {
topic: string;
totalVideos: number;
totalVideoHours: number;
totalPracticeHours: number;
totalHours: number;
completionWeeks: number;
startDate: string; // ISO date string
finishDate: string; // ISO date string
};
// Modules (embedded array)
modules: Array<{
id: string; // "mod_1", "mod_2", etc.
number: number;
name: string;
description: string;
estimatedHours: number;
estimatedWeeks: number;
videos: Array<{
videoId: string; // YouTube video ID
title: string;
channelName: string;
duration: string; // "45:00" format
durationSeconds: number;
thumbnailUrl: string;
viewCount: number;
whyIncluded: string;
keyTakeaways: string[];
prerequisites: string[];
practiceSuggestion: string;
}>;
moduleCheck: {
description: string;
items: string[];
};
practiceProject?: {
title: string;
description: string;
whyThisProject: string;
difficulty: "beginner" | "intermediate" | "advanced";
estimatedHours: number;
};
}>;
// Path variants
variants: Array<{
name: "fast_track" | "standard" | "deep_dive";
label: string;
description: string;
totalVideos: number;
totalHours: number;
moduleIds: string[];
}>;
// Weekly schedule
schedule: Array<{
week: number;
videoIds: string[];
totalHours: number;
}>;
// External resources
supplementaryResources: Array<{
title: string;
url: string;
type: "documentation" | "platform" | "community" | "cheatsheet" | "reading";
description: string;
}>;
// Metadata
createdAt: Date;
updatedAt: Date;
}
const LearningPathSchema = new Schema<ILearningPath>(
{
userId: {
type: Schema.Types.ObjectId,
ref: "User",
required: true,
index: true,
},
formData: { type: Schema.Types.Mixed, required: true },
summary: { type: Schema.Types.Mixed, required: true },
modules: { type: [Schema.Types.Mixed], required: true },
variants: { type: [Schema.Types.Mixed], required: true },
schedule: { type: [Schema.Types.Mixed], required: true },
supplementaryResources: { type: [Schema.Types.Mixed], default: [] },
},
{ timestamps: true }
);
// Indexes
LearningPathSchema.index({ userId: 1, createdAt: -1 });
LearningPathSchema.index({ "summary.topic": "text" });Tracks user progress on each learning path. One document per user-path pair.
// src/models/PathProgress.ts
import mongoose, { Schema, Document } from "mongoose";
export interface IPathProgress extends Document {
_id: mongoose.Types.ObjectId;
userId: mongoose.Types.ObjectId; // Owner (ref: users)
pathId: mongoose.Types.ObjectId; // Ref: learning_paths
activeVariant: "fast_track" | "standard" | "deep_dive";
// Per-video progress (keyed by YouTube videoId)
videoProgress: Map<string, {
status: "unwatched" | "watching" | "watched" | "skipped";
watchedAt: Date | null;
notes: string;
timestamps: Array<{ time: string; note: string }>;
}>;
// Module checklist completion (keyed by module ID like "mod_1")
moduleChecks: Map<string, boolean[]>;
// Completed project titles
projectsCompleted: string[];
// Streak tracking
streakDays: number;
lastStreakDate: string; // "YYYY-MM-DD"
// Metadata
startedAt: Date;
lastActivityAt: Date;
}
const PathProgressSchema = new Schema<IPathProgress>(
{
userId: {
type: Schema.Types.ObjectId,
ref: "User",
required: true,
},
pathId: {
type: Schema.Types.ObjectId,
ref: "LearningPath",
required: true,
},
activeVariant: {
type: String,
enum: ["fast_track", "standard", "deep_dive"],
default: "standard",
},
videoProgress: {
type: Map,
of: {
status: {
type: String,
enum: ["unwatched", "watching", "watched", "skipped"],
default: "unwatched",
},
watchedAt: { type: Date, default: null },
notes: { type: String, default: "" },
timestamps: { type: [{ time: String, note: String }], default: [] },
},
default: new Map(),
},
moduleChecks: {
type: Map,
of: [Boolean],
default: new Map(),
},
projectsCompleted: { type: [String], default: [] },
streakDays: { type: Number, default: 0 },
lastStreakDate: { type: String, default: "" },
startedAt: { type: Date, default: Date.now },
lastActivityAt: { type: Date, default: Date.now },
},
{ timestamps: false }
);
// Indexes
PathProgressSchema.index({ userId: 1, pathId: 1 }, { unique: true });
PathProgressSchema.index({ pathId: 1 });NextAuth auto-creates this for OAuth providers. Do NOT manually modify.
{
userId: ObjectId, // Ref: users
type: "oauth",
provider: "google",
providerAccountId: string, // Google user ID
access_token: string,
refresh_token: string,
expires_at: number,
token_type: "Bearer",
scope: string,
id_token: string,
}
Index: { provider: 1, providerAccountId: 1 } (unique, created by NextAuth)
NextAuth session storage. Do NOT manually modify.
{
sessionToken: string, // Unique session ID
userId: ObjectId, // Ref: users
expires: Date,
}
Index: { sessionToken: 1 } (unique, created by NextAuth)
| Collection | Index | Type | Purpose |
|---|---|---|---|
users |
{ email: 1 } |
Unique | Login lookup |
users |
{ stripeCustomerId: 1 } |
Sparse | Stripe webhook lookup |
learning_paths |
{ userId: 1, createdAt: -1 } |
Compound | List user's paths (newest first) |
learning_paths |
{ "summary.topic": "text" } |
Text | Future: search paths by topic |
path_progress |
{ userId: 1, pathId: 1 } |
Unique compound | One progress doc per user-path |
path_progress |
{ pathId: 1 } |
Single | Lookup progress by path |
- Tier: M0 (free) for dev, M10+ for production
- Region: Same as Vercel deployment (e.g.,
us-east-1) - Version: MongoDB 7.0+
- Add Vercel's IP ranges (or
0.0.0.0/0with strong auth) - Add developer IPs for local development
- Create a dedicated user with
readWriterole on theytlearningdatabase - Never use the admin user for the application
| Collection | Avg Doc Size | Docs/User | Notes |
|---|---|---|---|
users |
~0.5 KB | 1 | Small, grows slowly |
learning_paths |
~15-30 KB | 2-5 typical | Embedded modules/videos make docs larger |
path_progress |
~5-10 KB | 1 per path | Grows as user adds notes/timestamps |
accounts |
~1 KB | 1 per provider | NextAuth managed |
sessions |
~0.3 KB | 1 active | NextAuth managed, expires |
At 10,000 users: ~500 MB total estimated. Well within M10 tier limits.