project init

This commit is contained in:
2026-02-06 02:17:59 +03:00
commit b9d9b9ed17
129 changed files with 22835 additions and 0 deletions

View File

@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { join } from 'path';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { CoursesModule } from './courses/courses.module';
import { GenerationModule } from './generation/generation.module';
import { PaymentsModule } from './payments/payments.module';
import { SearchModule } from './search/search.module';
import { PrismaModule } from './common/prisma/prisma.module';
@Module({
imports: [
// Configuration - load from project root
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [
join(__dirname, '../../../.env.local'),
join(__dirname, '../../../.env'),
'.env.local',
'.env',
],
}),
// BullMQ for job queues
BullModule.forRoot({
connection: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6395', 10),
},
}),
// Database
PrismaModule,
// Feature modules
AuthModule,
UsersModule,
CoursesModule,
GenerationModule,
PaymentsModule,
SearchModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,43 @@
import {
Controller,
Post,
Get,
Body,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';
import { User } from '@coursecraft/database';
import { ExchangeTokenDto } from './dto/exchange-token.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@Post('exchange')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Exchange Supabase token for API token' })
async exchangeToken(@Body() dto: ExchangeTokenDto) {
const user = await this.authService.validateSupabaseToken(dto.supabaseToken);
return this.authService.generateTokens(user);
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user' })
async getCurrentUser(@CurrentUser() user: User) {
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl,
subscriptionTier: user.subscriptionTier,
createdAt: user.createdAt,
};
}
}

View File

@ -0,0 +1,40 @@
import { Module, Global } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { SupabaseService } from './supabase.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UsersModule } from '../users/users.module';
@Global()
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: {
expiresIn: '7d',
},
}),
inject: [ConfigService],
}),
UsersModule,
],
controllers: [AuthController],
providers: [
AuthService,
SupabaseService,
JwtAuthGuard,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
exports: [AuthService, SupabaseService, JwtAuthGuard],
})
export class AuthModule {}

View File

@ -0,0 +1,65 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SupabaseService } from './supabase.service';
import { UsersService } from '../users/users.service';
import { User } from '@coursecraft/database';
export interface JwtPayload {
sub: string; // user id
email: string;
supabaseId: string;
}
@Injectable()
export class AuthService {
constructor(
private supabaseService: SupabaseService,
private usersService: UsersService,
private jwtService: JwtService
) {}
async validateSupabaseToken(token: string): Promise<User> {
const supabaseUser = await this.supabaseService.verifyToken(token);
if (!supabaseUser) {
throw new UnauthorizedException('Invalid token');
}
// Find or create user in our database
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name: supabaseUser.user_metadata?.full_name || supabaseUser.user_metadata?.name || null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
return user;
}
async generateTokens(user: User) {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
supabaseId: user.supabaseId,
};
return {
accessToken: this.jwtService.sign(payload),
user: {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl,
subscriptionTier: user.subscriptionTier,
},
};
}
async validateJwtPayload(payload: JwtPayload): Promise<User | null> {
return this.usersService.findById(payload.sub);
}
}

View File

@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '@coursecraft/database';
export const CurrentUser = createParamDecorator(
(data: keyof User | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as User;
return data ? user?.[data] : user;
}
);

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,12 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ExchangeTokenDto {
@ApiProperty({
description: 'Supabase access token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
supabaseToken: string;
}

View File

@ -0,0 +1,83 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from '../auth.service';
import { SupabaseService } from '../supabase.service';
import { UsersService } from '../../users/users.service';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { JwtPayload } from '../auth.service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private reflector: Reflector,
private jwtService: JwtService,
private authService: AuthService,
private supabaseService: SupabaseService,
private usersService: UsersService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
const token = authHeader.replace('Bearer ', '');
try {
// 1) Try our own JWT (from POST /auth/exchange)
try {
const payload = this.jwtService.verify<JwtPayload>(token);
const user = await this.authService.validateJwtPayload(payload);
if (user) {
request.user = user;
return true;
}
} catch {
// Not our JWT or expired — try Supabase below
}
// 2) Fallback: Supabase access_token (for backward compatibility)
const supabaseUser = await this.supabaseService.verifyToken(token);
if (!supabaseUser) {
throw new UnauthorizedException('Invalid token');
}
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name:
supabaseUser.user_metadata?.full_name ||
supabaseUser.user_metadata?.name ||
null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
request.user = user;
return true;
} catch (error) {
if (error instanceof UnauthorizedException) throw error;
throw new UnauthorizedException('Token validation failed');
}
}
}

View File

@ -0,0 +1,72 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SupabaseService } from '../supabase.service';
import { UsersService } from '../../users/users.service';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class SupabaseAuthGuard implements CanActivate {
constructor(
private reflector: Reflector,
private supabaseService: SupabaseService,
private usersService: UsersService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Check if route is public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
const token = authHeader.replace('Bearer ', '');
try {
// Validate token with Supabase
const supabaseUser = await this.supabaseService.verifyToken(token);
if (!supabaseUser) {
throw new UnauthorizedException('Invalid token');
}
// Find or create user in our database
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
// Auto-create user on first API call
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name:
supabaseUser.user_metadata?.full_name ||
supabaseUser.user_metadata?.name ||
null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
// Attach user to request
request.user = user;
return true;
} catch (error) {
throw new UnauthorizedException('Token validation failed');
}
}
}

View File

@ -0,0 +1,62 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { SupabaseService } from '../supabase.service';
import { UsersService } from '../../users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private supabaseService: SupabaseService,
private usersService: UsersService
) {
// Use Supabase JWT secret for validation
const supabaseUrl = configService.get<string>('NEXT_PUBLIC_SUPABASE_URL');
const jwtSecret = configService.get<string>('SUPABASE_JWT_SECRET') ||
configService.get<string>('JWT_SECRET');
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtSecret,
// Pass the request to validate method
passReqToCallback: true,
});
}
async validate(req: any, payload: any) {
// Extract token from header
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new UnauthorizedException('No authorization header');
}
const token = authHeader.replace('Bearer ', '');
// Validate with Supabase
const supabaseUser = await this.supabaseService.verifyToken(token);
if (!supabaseUser) {
throw new UnauthorizedException('Invalid token');
}
// Find or create user in our database
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
// Auto-create user on first API call
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name: supabaseUser.user_metadata?.full_name ||
supabaseUser.user_metadata?.name ||
null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
return user;
}
}

View File

@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, SupabaseClient, User as SupabaseUser } from '@supabase/supabase-js';
@Injectable()
export class SupabaseService {
private supabase: SupabaseClient;
constructor(private configService: ConfigService) {
const supabaseUrl = this.configService.get<string>('NEXT_PUBLIC_SUPABASE_URL');
const supabaseServiceKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('Supabase configuration is missing');
}
this.supabase = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
getClient(): SupabaseClient {
return this.supabase;
}
async verifyToken(token: string): Promise<SupabaseUser | null> {
try {
const { data, error } = await this.supabase.auth.getUser(token);
if (error || !data.user) {
return null;
}
return data.user;
} catch {
return null;
}
}
async getUserById(userId: string): Promise<SupabaseUser | null> {
try {
const { data, error } = await this.supabase.auth.admin.getUserById(userId);
if (error || !data.user) {
return null;
}
return data.user;
} catch {
return null;
}
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,19 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@coursecraft/database';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,67 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ChaptersService } from './chapters.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { CreateChapterDto } from './dto/create-chapter.dto';
import { UpdateChapterDto } from './dto/update-chapter.dto';
@ApiTags('chapters')
@Controller('courses/:courseId/chapters')
@ApiBearerAuth()
export class ChaptersController {
constructor(private chaptersService: ChaptersService) {}
@Post()
@ApiOperation({ summary: 'Create a new chapter' })
async create(
@Param('courseId') courseId: string,
@CurrentUser() user: User,
@Body() dto: CreateChapterDto
) {
return this.chaptersService.create(courseId, user.id, dto);
}
@Get()
@ApiOperation({ summary: 'Get all chapters for a course' })
async findAll(@Param('courseId') courseId: string) {
return this.chaptersService.findAllByCourse(courseId);
}
@Patch(':chapterId')
@ApiOperation({ summary: 'Update chapter' })
async update(
@Param('chapterId') chapterId: string,
@CurrentUser() user: User,
@Body() dto: UpdateChapterDto
) {
return this.chaptersService.update(chapterId, user.id, dto);
}
@Delete(':chapterId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete chapter' })
async delete(@Param('chapterId') chapterId: string, @CurrentUser() user: User) {
await this.chaptersService.delete(chapterId, user.id);
}
@Post('reorder')
@ApiOperation({ summary: 'Reorder chapters' })
async reorder(
@Param('courseId') courseId: string,
@CurrentUser() user: User,
@Body('chapterIds') chapterIds: string[]
) {
return this.chaptersService.reorder(courseId, user.id, chapterIds);
}
}

View File

@ -0,0 +1,131 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Chapter } from '@coursecraft/database';
import { CoursesService } from './courses.service';
import { CreateChapterDto } from './dto/create-chapter.dto';
import { UpdateChapterDto } from './dto/update-chapter.dto';
@Injectable()
export class ChaptersService {
constructor(
private prisma: PrismaService,
private coursesService: CoursesService
) {}
async create(courseId: string, userId: string, dto: CreateChapterDto): Promise<Chapter> {
// Check ownership
const isOwner = await this.coursesService.checkOwnership(courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Get max order
const maxOrder = await this.prisma.chapter.aggregate({
where: { courseId },
_max: { order: true },
});
const order = (maxOrder._max.order ?? -1) + 1;
return this.prisma.chapter.create({
data: {
courseId,
title: dto.title,
description: dto.description,
order,
},
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
});
}
async findAllByCourse(courseId: string): Promise<Chapter[]> {
return this.prisma.chapter.findMany({
where: { courseId },
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
});
}
async findById(id: string): Promise<Chapter | null> {
return this.prisma.chapter.findUnique({
where: { id },
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
});
}
async update(
chapterId: string,
userId: string,
dto: UpdateChapterDto
): Promise<Chapter> {
const chapter = await this.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.chapter.update({
where: { id: chapterId },
data: {
title: dto.title,
description: dto.description,
},
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
});
}
async delete(chapterId: string, userId: string): Promise<void> {
const chapter = await this.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await this.prisma.chapter.delete({
where: { id: chapterId },
});
}
async reorder(courseId: string, userId: string, chapterIds: string[]): Promise<Chapter[]> {
const isOwner = await this.coursesService.checkOwnership(courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Update order for each chapter
await Promise.all(
chapterIds.map((id, index) =>
this.prisma.chapter.update({
where: { id },
data: { order: index },
})
)
);
return this.findAllByCourse(courseId);
}
}

View File

@ -0,0 +1,78 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { CoursesService } from './courses.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User, CourseStatus } from '@coursecraft/database';
import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto';
@ApiTags('courses')
@Controller('courses')
@ApiBearerAuth()
export class CoursesController {
constructor(private coursesService: CoursesService) {}
@Post()
@ApiOperation({ summary: 'Create a new course' })
async create(@CurrentUser() user: User, @Body() dto: CreateCourseDto): Promise<any> {
return this.coursesService.create(user.id, dto);
}
@Get()
@ApiOperation({ summary: 'Get all courses for current user' })
@ApiQuery({ name: 'status', required: false, enum: CourseStatus })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async findAll(
@CurrentUser() user: User,
@Query('status') status?: CourseStatus,
@Query('page') page?: number,
@Query('limit') limit?: number
) {
return this.coursesService.findAllByAuthor(user.id, { status, page, limit });
}
@Get(':id')
@ApiOperation({ summary: 'Get course by ID' })
async findOne(@Param('id') id: string): Promise<any> {
return this.coursesService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update course' })
async update(
@Param('id') id: string,
@CurrentUser() user: User,
@Body() dto: UpdateCourseDto
): Promise<any> {
return this.coursesService.update(id, user.id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete course' })
async delete(@Param('id') id: string, @CurrentUser() user: User) {
await this.coursesService.delete(id, user.id);
}
@Patch(':id/status')
@ApiOperation({ summary: 'Update course status' })
async updateStatus(
@Param('id') id: string,
@CurrentUser() user: User,
@Body('status') status: CourseStatus
): Promise<any> {
return this.coursesService.updateStatus(id, user.id, status);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { CoursesController } from './courses.controller';
import { CoursesService } from './courses.service';
import { ChaptersController } from './chapters.controller';
import { ChaptersService } from './chapters.service';
import { LessonsController } from './lessons.controller';
import { LessonsService } from './lessons.service';
@Module({
controllers: [CoursesController, ChaptersController, LessonsController],
providers: [CoursesService, ChaptersService, LessonsService],
exports: [CoursesService, ChaptersService, LessonsService],
})
export class CoursesModule {}

View File

@ -0,0 +1,231 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Course, CourseStatus } from '@coursecraft/database';
import { generateUniqueSlug } from '@coursecraft/shared';
import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto';
@Injectable()
export class CoursesService {
constructor(private prisma: PrismaService) {}
async create(authorId: string, dto: CreateCourseDto): Promise<Course> {
const slug = generateUniqueSlug(dto.title);
return this.prisma.course.create({
data: {
authorId,
title: dto.title,
description: dto.description,
slug,
status: CourseStatus.DRAFT,
},
include: {
chapters: {
include: {
lessons: true,
},
orderBy: { order: 'asc' },
},
},
});
}
async findAllByAuthor(
authorId: string,
options?: {
status?: CourseStatus;
page?: number;
limit?: number;
}
) {
const page = options?.page || 1;
const limit = options?.limit || 10;
const skip = (page - 1) * limit;
const where = {
authorId,
...(options?.status && { status: options.status }),
};
const [courses, total] = await Promise.all([
this.prisma.course.findMany({
where,
include: {
_count: {
select: {
chapters: true,
},
},
chapters: {
include: {
_count: {
select: { lessons: true },
},
},
},
},
orderBy: { updatedAt: 'desc' },
skip,
take: limit,
}),
this.prisma.course.count({ where }),
]);
// Transform to include counts
const transformedCourses = courses.map((course) => ({
id: course.id,
title: course.title,
description: course.description,
slug: course.slug,
coverImage: course.coverImage,
status: course.status,
chaptersCount: course._count.chapters,
lessonsCount: course.chapters.reduce((acc, ch) => acc + ch._count.lessons, 0),
createdAt: course.createdAt,
updatedAt: course.updatedAt,
}));
return {
data: transformedCourses,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(id: string): Promise<Course | null> {
return this.prisma.course.findUnique({
where: { id },
include: {
author: {
select: {
id: true,
name: true,
avatarUrl: true,
},
},
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
category: true,
},
});
}
async findBySlug(slug: string): Promise<Course | null> {
return this.prisma.course.findUnique({
where: { slug },
include: {
author: {
select: {
id: true,
name: true,
avatarUrl: true,
},
},
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
},
});
}
async update(id: string, userId: string, dto: UpdateCourseDto): Promise<Course> {
const course = await this.findById(id);
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.course.update({
where: { id },
data: {
title: dto.title,
description: dto.description,
coverImage: dto.coverImage,
status: dto.status,
tags: dto.tags,
difficulty: dto.difficulty,
estimatedHours: dto.estimatedHours,
metaTitle: dto.metaTitle,
metaDescription: dto.metaDescription,
},
include: {
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
},
});
}
async delete(id: string, userId: string): Promise<void> {
const course = await this.findById(id);
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('You can only delete your own courses');
}
await this.prisma.course.delete({
where: { id },
});
}
async updateStatus(id: string, userId: string, status: CourseStatus): Promise<Course> {
const course = await this.findById(id);
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('You can only edit your own courses');
}
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status };
if (status === CourseStatus.PUBLISHED && !course.publishedAt) {
updateData.publishedAt = new Date();
}
return this.prisma.course.update({
where: { id },
data: updateData,
});
}
async checkOwnership(courseId: string, userId: string): Promise<boolean> {
const course = await this.prisma.course.findUnique({
where: { id: courseId },
select: { authorId: true },
});
return course?.authorId === userId;
}
}

View File

@ -0,0 +1,22 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class CreateChapterDto {
@ApiProperty({
description: 'Chapter title',
example: 'Introduction to Digital Marketing',
})
@IsString()
@MinLength(VALIDATION.CHAPTER.TITLE_MIN)
@MaxLength(VALIDATION.CHAPTER.TITLE_MAX)
title: string;
@ApiPropertyOptional({
description: 'Chapter description',
example: 'Learn the basics of digital marketing strategies',
})
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,25 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class CreateCourseDto {
@ApiProperty({
description: 'Course title',
example: 'Introduction to Marketing',
minLength: VALIDATION.COURSE.TITLE_MIN,
maxLength: VALIDATION.COURSE.TITLE_MAX,
})
@IsString()
@MinLength(VALIDATION.COURSE.TITLE_MIN)
@MaxLength(VALIDATION.COURSE.TITLE_MAX)
title: string;
@ApiPropertyOptional({
description: 'Course description',
example: 'Learn the fundamentals of digital marketing...',
})
@IsOptional()
@IsString()
@MaxLength(VALIDATION.COURSE.DESCRIPTION_MAX)
description?: string;
}

View File

@ -0,0 +1,30 @@
import { IsString, IsOptional, IsObject, IsNumber, MinLength, MaxLength, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class CreateLessonDto {
@ApiProperty({
description: 'Lesson title',
example: 'What is Digital Marketing?',
})
@IsString()
@MinLength(VALIDATION.LESSON.TITLE_MIN)
@MaxLength(VALIDATION.LESSON.TITLE_MAX)
title: string;
@ApiPropertyOptional({
description: 'Lesson content in TipTap JSON format',
})
@IsOptional()
@IsObject()
content?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Estimated duration in minutes',
example: 15,
})
@IsOptional()
@IsNumber()
@Min(0)
durationMinutes?: number;
}

View File

@ -0,0 +1,17 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class UpdateChapterDto {
@ApiPropertyOptional({ description: 'Chapter title' })
@IsOptional()
@IsString()
@MinLength(VALIDATION.CHAPTER.TITLE_MIN)
@MaxLength(VALIDATION.CHAPTER.TITLE_MAX)
title?: string;
@ApiPropertyOptional({ description: 'Chapter description' })
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,71 @@
import {
IsString,
IsOptional,
IsArray,
IsNumber,
IsEnum,
IsUrl,
MinLength,
MaxLength,
Min,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { CourseStatus } from '@coursecraft/database';
import { VALIDATION } from '@coursecraft/shared';
export class UpdateCourseDto {
@ApiPropertyOptional({ description: 'Course title' })
@IsOptional()
@IsString()
@MinLength(VALIDATION.COURSE.TITLE_MIN)
@MaxLength(VALIDATION.COURSE.TITLE_MAX)
title?: string;
@ApiPropertyOptional({ description: 'Course description' })
@IsOptional()
@IsString()
@MaxLength(VALIDATION.COURSE.DESCRIPTION_MAX)
description?: string;
@ApiPropertyOptional({ description: 'Cover image URL' })
@IsOptional()
@IsUrl()
coverImage?: string;
@ApiPropertyOptional({ description: 'Course status', enum: CourseStatus })
@IsOptional()
@IsEnum(CourseStatus)
status?: CourseStatus;
@ApiPropertyOptional({ description: 'Course tags', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({
description: 'Course difficulty',
enum: ['beginner', 'intermediate', 'advanced'],
})
@IsOptional()
@IsString()
difficulty?: string;
@ApiPropertyOptional({ description: 'Estimated hours to complete' })
@IsOptional()
@IsNumber()
@Min(0)
estimatedHours?: number;
@ApiPropertyOptional({ description: 'SEO meta title' })
@IsOptional()
@IsString()
@MaxLength(100)
metaTitle?: string;
@ApiPropertyOptional({ description: 'SEO meta description' })
@IsOptional()
@IsString()
@MaxLength(300)
metaDescription?: string;
}

View File

@ -0,0 +1,34 @@
import { IsString, IsOptional, IsObject, IsNumber, IsUrl, MinLength, MaxLength, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class UpdateLessonDto {
@ApiPropertyOptional({ description: 'Lesson title' })
@IsOptional()
@IsString()
@MinLength(VALIDATION.LESSON.TITLE_MIN)
@MaxLength(VALIDATION.LESSON.TITLE_MAX)
title?: string;
@ApiPropertyOptional({
description: 'Lesson content in TipTap JSON format',
})
@IsOptional()
@IsObject()
content?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Estimated duration in minutes',
})
@IsOptional()
@IsNumber()
@Min(0)
durationMinutes?: number;
@ApiPropertyOptional({
description: 'Video URL for the lesson',
})
@IsOptional()
@IsUrl()
videoUrl?: string;
}

View File

@ -0,0 +1,67 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LessonsService } from './lessons.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { CreateLessonDto } from './dto/create-lesson.dto';
import { UpdateLessonDto } from './dto/update-lesson.dto';
@ApiTags('lessons')
@Controller('courses/:courseId')
@ApiBearerAuth()
export class LessonsController {
constructor(private lessonsService: LessonsService) {}
@Post('chapters/:chapterId/lessons')
@ApiOperation({ summary: 'Create a new lesson' })
async create(
@Param('chapterId') chapterId: string,
@CurrentUser() user: User,
@Body() dto: CreateLessonDto
): Promise<any> {
return this.lessonsService.create(chapterId, user.id, dto);
}
@Get('lessons/:lessonId')
@ApiOperation({ summary: 'Get lesson by ID' })
async findOne(@Param('lessonId') lessonId: string): Promise<any> {
return this.lessonsService.findById(lessonId);
}
@Patch('lessons/:lessonId')
@ApiOperation({ summary: 'Update lesson' })
async update(
@Param('lessonId') lessonId: string,
@CurrentUser() user: User,
@Body() dto: UpdateLessonDto
): Promise<any> {
return this.lessonsService.update(lessonId, user.id, dto);
}
@Delete('lessons/:lessonId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete lesson' })
async delete(@Param('lessonId') lessonId: string, @CurrentUser() user: User): Promise<void> {
await this.lessonsService.delete(lessonId, user.id);
}
@Post('chapters/:chapterId/lessons/reorder')
@ApiOperation({ summary: 'Reorder lessons in a chapter' })
async reorder(
@Param('chapterId') chapterId: string,
@CurrentUser() user: User,
@Body('lessonIds') lessonIds: string[]
): Promise<any> {
return this.lessonsService.reorder(chapterId, user.id, lessonIds);
}
}

View File

@ -0,0 +1,141 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Lesson } from '@coursecraft/database';
import { CoursesService } from './courses.service';
import { ChaptersService } from './chapters.service';
import { CreateLessonDto } from './dto/create-lesson.dto';
import { UpdateLessonDto } from './dto/update-lesson.dto';
@Injectable()
export class LessonsService {
constructor(
private prisma: PrismaService,
private coursesService: CoursesService,
private chaptersService: ChaptersService
) {}
async create(chapterId: string, userId: string, dto: CreateLessonDto): Promise<Lesson> {
const chapter = await this.chaptersService.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Get max order
const maxOrder = await this.prisma.lesson.aggregate({
where: { chapterId },
_max: { order: true },
});
const order = (maxOrder._max.order ?? -1) + 1;
return this.prisma.lesson.create({
data: {
chapterId,
title: dto.title,
content: dto.content as any,
order,
durationMinutes: dto.durationMinutes,
},
});
}
async findById(id: string): Promise<Lesson | null> {
return this.prisma.lesson.findUnique({
where: { id },
include: {
chapter: {
select: {
id: true,
title: true,
courseId: true,
},
},
},
});
}
async update(lessonId: string, userId: string, dto: UpdateLessonDto): Promise<Lesson> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: {
chapter: {
select: { courseId: true },
},
},
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.lesson.update({
where: { id: lessonId },
data: {
title: dto.title,
content: dto.content as any,
durationMinutes: dto.durationMinutes,
videoUrl: dto.videoUrl,
},
});
}
async delete(lessonId: string, userId: string): Promise<void> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: {
chapter: {
select: { courseId: true },
},
},
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await this.prisma.lesson.delete({
where: { id: lessonId },
});
}
async reorder(chapterId: string, userId: string, lessonIds: string[]): Promise<Lesson[]> {
const chapter = await this.chaptersService.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await Promise.all(
lessonIds.map((id, index) =>
this.prisma.lesson.update({
where: { id },
data: { order: index },
})
)
);
return this.prisma.lesson.findMany({
where: { chapterId },
orderBy: { order: 'asc' },
});
}
}

View File

@ -0,0 +1,15 @@
import { IsObject } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AnswerQuestionsDto {
@ApiProperty({
description: 'Object with question IDs as keys and answers as values',
example: {
target_audience: 'Начинающие',
course_depth: 'Стандартный',
specific_topics: 'React, TypeScript',
},
})
@IsObject()
answers!: Record<string, string | string[]>;
}

View File

@ -0,0 +1,16 @@
import { IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class StartGenerationDto {
@ApiProperty({
description: 'Prompt describing the course you want to create',
example: 'Сделай курс по маркетингу для начинающих',
minLength: VALIDATION.PROMPT.MIN,
maxLength: VALIDATION.PROMPT.MAX,
})
@IsString()
@MinLength(VALIDATION.PROMPT.MIN)
@MaxLength(VALIDATION.PROMPT.MAX)
prompt: string;
}

View File

@ -0,0 +1,90 @@
import {
Controller,
Get,
Post,
Body,
Param,
Sse,
MessageEvent,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Observable, interval, map, takeWhile, switchMap, from, startWith } from 'rxjs';
import { GenerationService } from './generation.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User, GenerationStatus } from '@coursecraft/database';
import { StartGenerationDto } from './dto/start-generation.dto';
import { AnswerQuestionsDto } from './dto/answer-questions.dto';
@ApiTags('generation')
@Controller('generation')
@ApiBearerAuth()
export class GenerationController {
constructor(private generationService: GenerationService) {}
@Post('start')
@ApiOperation({ summary: 'Start course generation' })
async startGeneration(
@CurrentUser() user: User,
@Body() dto: StartGenerationDto
): Promise<any> {
return this.generationService.startGeneration(user.id, dto);
}
@Get(':id/status')
@ApiOperation({ summary: 'Get generation status' })
async getStatus(
@Param('id') id: string,
@CurrentUser() user: User
): Promise<any> {
return this.generationService.getStatus(id, user.id);
}
@Sse(':id/stream')
@ApiOperation({ summary: 'Stream generation progress (SSE)' })
streamProgress(
@Param('id') id: string,
@CurrentUser() user: User
): Observable<MessageEvent> {
const terminalStatuses: string[] = [
GenerationStatus.COMPLETED,
GenerationStatus.FAILED,
GenerationStatus.CANCELLED,
];
let isComplete = false;
return interval(1000).pipe(
startWith(0),
switchMap(() => from(this.generationService.getStatus(id, user.id))),
takeWhile((status) => {
if (terminalStatuses.includes(status.status as string)) {
isComplete = true;
return true; // Include the final status
}
return !isComplete;
}, true),
map((status) => ({
data: JSON.stringify(status),
}))
);
}
@Post(':id/answer')
@ApiOperation({ summary: 'Answer clarifying questions' })
async answerQuestions(
@Param('id') id: string,
@CurrentUser() user: User,
@Body() dto: AnswerQuestionsDto
) {
return this.generationService.answerQuestions(id, user.id, dto);
}
@Post(':id/cancel')
@ApiOperation({ summary: 'Cancel generation' })
async cancelGeneration(
@Param('id') id: string,
@CurrentUser() user: User
) {
return this.generationService.cancelGeneration(id, user.id);
}
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { GenerationController } from './generation.controller';
import { GenerationService } from './generation.service';
import { UsersModule } from '../users/users.module';
import { CoursesModule } from '../courses/courses.module';
/**
* Only ai-service processes course-generation jobs (single worker).
* API only enqueues jobs via GenerationService.
*/
@Module({
imports: [
BullModule.registerQueue({
name: 'course-generation',
}),
UsersModule,
CoursesModule,
],
controllers: [GenerationController],
providers: [GenerationService],
exports: [GenerationService],
})
export class GenerationModule {}

View File

@ -0,0 +1,226 @@
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PrismaService } from '../common/prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { GenerationStatus, SubscriptionTier } from '@coursecraft/database';
import { SUBSCRIPTION_LIMITS } from '@coursecraft/shared';
import { StartGenerationDto } from './dto/start-generation.dto';
import { AnswerQuestionsDto } from './dto/answer-questions.dto';
@Injectable()
export class GenerationService {
constructor(
@InjectQueue('course-generation') private generationQueue: Queue,
private prisma: PrismaService,
private usersService: UsersService
) {}
async startGeneration(userId: string, dto: StartGenerationDto) {
// Check if user can create more courses (skip in dev/test when BYPASS_COURSE_LIMIT=true)
const bypassLimit = process.env.BYPASS_COURSE_LIMIT === 'true';
if (!bypassLimit) {
const canCreate = await this.usersService.canCreateCourse(userId);
if (!canCreate) {
throw new ForbiddenException('You have reached your monthly course limit');
}
}
// Get user's AI model preference
const settings = await this.usersService.getSettings(userId);
const user = await this.usersService.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
// Determine AI model to use
const tierLimits = SUBSCRIPTION_LIMITS[user.subscriptionTier as keyof typeof SUBSCRIPTION_LIMITS];
const aiModel = settings.customAiModel || tierLimits.defaultAiModel;
// Create generation record
const generation = await this.prisma.courseGeneration.create({
data: {
userId,
initialPrompt: dto.prompt,
aiModel,
status: GenerationStatus.PENDING,
},
});
// Add job to queue
const job = await this.generationQueue.add(
'generate-course',
{
generationId: generation.id,
userId,
prompt: dto.prompt,
aiModel,
},
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
}
);
// Update with job ID
await this.prisma.courseGeneration.update({
where: { id: generation.id },
data: { jobId: job.id },
});
return {
id: generation.id,
status: generation.status,
progress: 0,
};
}
async getStatus(generationId: string, userId: string): Promise<any> {
const generation = await this.prisma.courseGeneration.findUnique({
where: { id: generationId },
include: {
course: {
select: {
id: true,
slug: true,
},
},
},
});
if (!generation) {
throw new NotFoundException('Generation not found');
}
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
return {
id: generation.id,
status: generation.status,
progress: generation.progress,
currentStep: generation.currentStep,
questions: generation.questions,
generatedOutline: generation.generatedOutline,
errorMessage: generation.errorMessage,
course: generation.course,
};
}
async answerQuestions(generationId: string, userId: string, dto: AnswerQuestionsDto) {
const generation = await this.prisma.courseGeneration.findUnique({
where: { id: generationId },
});
if (!generation) {
throw new NotFoundException('Generation not found');
}
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (generation.status !== GenerationStatus.WAITING_FOR_ANSWERS) {
throw new BadRequestException('Generation is not waiting for answers');
}
// Save answers and continue generation
await this.prisma.courseGeneration.update({
where: { id: generationId },
data: {
answers: dto.answers as any,
status: GenerationStatus.RESEARCHING,
},
});
// Continue the job
await this.generationQueue.add(
'continue-generation',
{
generationId,
stage: 'after-questions',
},
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
}
);
return { success: true };
}
async cancelGeneration(generationId: string, userId: string) {
const generation = await this.prisma.courseGeneration.findUnique({
where: { id: generationId },
});
if (!generation) {
throw new NotFoundException('Generation not found');
}
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Cancel the job if possible
if (generation.jobId) {
const job = await this.generationQueue.getJob(generation.jobId);
if (job) {
await job.remove();
}
}
await this.prisma.courseGeneration.update({
where: { id: generationId },
data: {
status: GenerationStatus.CANCELLED,
},
});
return { success: true };
}
async updateProgress(
generationId: string,
status: GenerationStatus,
progress: number,
currentStep?: string,
additionalData?: {
questions?: unknown;
generatedOutline?: unknown;
errorMessage?: string;
}
): Promise<any> {
const updateData: Record<string, unknown> = {
status,
progress,
currentStep,
};
if (additionalData?.questions) {
updateData.questions = additionalData.questions;
}
if (additionalData?.generatedOutline) {
updateData.generatedOutline = additionalData.generatedOutline;
}
if (additionalData?.errorMessage) {
updateData.errorMessage = additionalData.errorMessage;
}
if (status === GenerationStatus.COMPLETED) {
updateData.completedAt = new Date();
}
return this.prisma.courseGeneration.update({
where: { id: generationId },
data: updateData,
});
}
}

60
apps/api/src/main.ts Normal file
View File

@ -0,0 +1,60 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Security
app.use(helmet());
// CORS (веб на порту 3125)
const allowedOrigins = [
configService.get('NEXT_PUBLIC_APP_URL'),
'http://localhost:3125',
'http://localhost:3000',
].filter(Boolean) as string[];
app.enableCors({
origin: allowedOrigins.length ? allowedOrigins : 'http://localhost:3125',
credentials: true,
});
// Global prefix
app.setGlobalPrefix('api');
// Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
// Swagger
if (configService.get('NODE_ENV') !== 'production') {
const config = new DocumentBuilder()
.setTitle('CourseCraft API')
.setDescription('AI-powered course creation platform API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
}
const port = configService.get('PORT') || 3001;
await app.listen(port);
console.log(`🚀 API is running on: http://localhost:${port}/api`);
console.log(`📚 Swagger docs: http://localhost:${port}/docs`);
}
bootstrap();

View File

@ -0,0 +1,41 @@
import {
Controller,
Get,
Post,
Body,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { User } from '@coursecraft/database';
@ApiTags('subscriptions')
@Controller('subscriptions')
export class PaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Get('plans')
@Public()
@ApiOperation({ summary: 'Get available subscription plans' })
async getPlans() {
return this.paymentsService.getPlans();
}
@Post('checkout')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create Stripe checkout session' })
async createCheckoutSession(
@CurrentUser() user: User,
@Body('tier') tier: 'PREMIUM' | 'PRO'
) {
return this.paymentsService.createCheckoutSession(user.id, tier);
}
@Post('portal')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create Stripe customer portal session' })
async createPortalSession(@CurrentUser() user: User) {
return this.paymentsService.createPortalSession(user.id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { StripeService } from './stripe.service';
import { WebhooksController } from './webhooks.controller';
@Module({
controllers: [PaymentsController, WebhooksController],
providers: [PaymentsService, StripeService],
exports: [PaymentsService, StripeService],
})
export class PaymentsModule {}

View File

@ -0,0 +1,231 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../common/prisma/prisma.service';
import { StripeService } from './stripe.service';
import { SubscriptionTier } from '@coursecraft/database';
import { SUBSCRIPTION_PLANS } from '@coursecraft/shared';
@Injectable()
export class PaymentsService {
constructor(
private prisma: PrismaService,
private stripeService: StripeService,
private configService: ConfigService
) {}
async getPlans() {
return SUBSCRIPTION_PLANS.map((plan) => ({
tier: plan.tier,
name: plan.name,
nameRu: plan.nameRu,
description: plan.description,
descriptionRu: plan.descriptionRu,
price: plan.price,
currency: plan.currency,
features: plan.features,
featuresRu: plan.featuresRu,
}));
}
async createCheckoutSession(userId: string, tier: 'PREMIUM' | 'PRO') {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { subscription: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
// Get or create Stripe customer
let stripeCustomerId = user.subscription?.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await this.stripeService.createCustomer(user.email, user.name || undefined);
stripeCustomerId = customer.id;
await this.prisma.subscription.upsert({
where: { userId },
create: {
userId,
tier: SubscriptionTier.FREE,
stripeCustomerId: customer.id,
},
update: {
stripeCustomerId: customer.id,
},
});
}
// Get price ID for tier
const priceId =
tier === 'PREMIUM'
? this.configService.get<string>('STRIPE_PRICE_PREMIUM')
: this.configService.get<string>('STRIPE_PRICE_PRO');
if (!priceId) {
throw new Error(`Price ID not configured for tier: ${tier}`);
}
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000';
const session = await this.stripeService.createCheckoutSession({
customerId: stripeCustomerId,
priceId,
successUrl: `${appUrl}/dashboard/billing?success=true`,
cancelUrl: `${appUrl}/dashboard/billing?canceled=true`,
metadata: {
userId,
tier,
},
});
return { url: session.url };
}
async createPortalSession(userId: string) {
const subscription = await this.prisma.subscription.findUnique({
where: { userId },
});
if (!subscription?.stripeCustomerId) {
throw new NotFoundException('No subscription found');
}
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000';
const session = await this.stripeService.createPortalSession(
subscription.stripeCustomerId,
`${appUrl}/dashboard/billing`
);
return { url: session.url };
}
async handleWebhookEvent(event: { type: string; data: { object: unknown } }) {
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as {
customer: string;
subscription: string;
metadata: { userId: string; tier: string };
});
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as {
id: string;
customer: string;
status: string;
current_period_end: number;
cancel_at_period_end: boolean;
items: { data: Array<{ price: { id: string } }> };
});
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as {
customer: string;
});
break;
}
}
private async handleCheckoutCompleted(session: {
customer: string;
subscription: string;
metadata: { userId: string; tier: string };
}) {
const { customer, subscription: subscriptionId, metadata } = session;
const tier = metadata.tier as SubscriptionTier;
const stripeSubscription = await this.stripeService.getSubscription(subscriptionId);
await this.prisma.subscription.update({
where: { stripeCustomerId: customer },
data: {
tier,
stripeSubscriptionId: subscriptionId,
stripePriceId: stripeSubscription.items.data[0]?.price.id,
status: 'active',
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
coursesCreatedThisMonth: 0, // Reset on new subscription
},
});
// Update user's subscription tier
await this.prisma.user.update({
where: { id: metadata.userId },
data: { subscriptionTier: tier },
});
}
private async handleSubscriptionUpdated(subscription: {
id: string;
customer: string;
status: string;
current_period_end: number;
cancel_at_period_end: boolean;
items: { data: Array<{ price: { id: string } }> };
}) {
const priceId = subscription.items.data[0]?.price.id;
// Determine tier from price ID
const premiumPriceId = this.configService.get<string>('STRIPE_PRICE_PREMIUM');
const proPriceId = this.configService.get<string>('STRIPE_PRICE_PRO');
let tier: SubscriptionTier = SubscriptionTier.FREE;
if (priceId === premiumPriceId) tier = SubscriptionTier.PREMIUM;
else if (priceId === proPriceId) tier = SubscriptionTier.PRO;
await this.prisma.subscription.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
tier,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
stripePriceId: priceId,
},
});
// Update user's tier
const sub = await this.prisma.subscription.findUnique({
where: { stripeCustomerId: subscription.customer as string },
});
if (sub) {
await this.prisma.user.update({
where: { id: sub.userId },
data: { subscriptionTier: tier },
});
}
}
private async handleSubscriptionDeleted(subscription: { customer: string }) {
await this.prisma.subscription.update({
where: { stripeCustomerId: subscription.customer },
data: {
tier: SubscriptionTier.FREE,
status: 'canceled',
stripeSubscriptionId: null,
stripePriceId: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
},
});
// Update user's tier
const sub = await this.prisma.subscription.findUnique({
where: { stripeCustomerId: subscription.customer },
});
if (sub) {
await this.prisma.user.update({
where: { id: sub.userId },
data: { subscriptionTier: SubscriptionTier.FREE },
});
}
}
}

View File

@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
@Injectable()
export class StripeService {
private stripe: Stripe;
constructor(private configService: ConfigService) {
this.stripe = new Stripe(this.configService.get<string>('STRIPE_SECRET_KEY')!, {
apiVersion: '2023-10-16',
});
}
getClient(): Stripe {
return this.stripe;
}
async createCustomer(email: string, name?: string): Promise<Stripe.Customer> {
return this.stripe.customers.create({
email,
name: name || undefined,
});
}
async createCheckoutSession(params: {
customerId: string;
priceId: string;
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Checkout.Session> {
return this.stripe.checkout.sessions.create({
customer: params.customerId,
mode: 'subscription',
line_items: [
{
price: params.priceId,
quantity: 1,
},
],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata,
});
}
async createPortalSession(customerId: string, returnUrl: string): Promise<Stripe.BillingPortal.Session> {
return this.stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
}
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.retrieve(subscriptionId);
}
async cancelSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event {
const webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET')!;
return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
}
}

View File

@ -0,0 +1,52 @@
import {
Controller,
Post,
Headers,
Req,
HttpCode,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger';
import { Request } from 'express';
import { PaymentsService } from './payments.service';
import { StripeService } from './stripe.service';
@ApiTags('webhooks')
@Controller('webhooks')
export class WebhooksController {
constructor(
private paymentsService: PaymentsService,
private stripeService: StripeService
) {}
@Post('stripe')
@HttpCode(HttpStatus.OK)
@ApiExcludeEndpoint()
@ApiOperation({ summary: 'Handle Stripe webhooks' })
async handleStripeWebhook(
@Req() req: Request,
@Headers('stripe-signature') signature: string
) {
if (!signature) {
throw new BadRequestException('Missing stripe-signature header');
}
let event;
try {
// req.body should be raw buffer for webhook verification
const rawBody = (req as Request & { rawBody?: Buffer }).rawBody;
if (!rawBody) {
throw new BadRequestException('Missing raw body');
}
event = this.stripeService.constructWebhookEvent(rawBody, signature);
} catch (err) {
throw new BadRequestException(`Webhook signature verification failed: ${err}`);
}
await this.paymentsService.handleWebhookEvent(event);
return { received: true };
}
}

View File

@ -0,0 +1,156 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MeiliSearch, Index } from 'meilisearch';
export interface CourseDocument {
id: string;
title: string;
description: string | null;
slug: string;
authorId: string;
authorName: string | null;
status: string;
categoryId: string | null;
categoryName: string | null;
tags: string[];
difficulty: string | null;
price: number | null;
isPublished: boolean;
createdAt: number;
updatedAt: number;
}
@Injectable()
export class MeilisearchService implements OnModuleInit {
private readonly logger = new Logger(MeilisearchService.name);
private client: MeiliSearch;
private coursesIndex: Index<CourseDocument> | null = null;
private isAvailable = false;
constructor(private configService: ConfigService) {
this.client = new MeiliSearch({
host: this.configService.get<string>('MEILISEARCH_HOST') || 'http://localhost:7700',
apiKey: this.configService.get<string>('MEILISEARCH_API_KEY'),
});
}
async onModuleInit() {
try {
await this.setupIndexes();
this.isAvailable = true;
this.logger.log('Meilisearch connected successfully');
} catch (error) {
this.logger.warn('Meilisearch is not available. Search functionality will be disabled.');
this.isAvailable = false;
}
}
private async setupIndexes() {
// Create courses index
try {
await this.client.createIndex('courses', { primaryKey: 'id' });
} catch {
// Index might already exist
}
this.coursesIndex = this.client.index<CourseDocument>('courses');
// Configure searchable attributes
await this.coursesIndex.updateSearchableAttributes([
'title',
'description',
'tags',
'authorName',
'categoryName',
]);
// Configure filterable attributes
await this.coursesIndex.updateFilterableAttributes([
'authorId',
'status',
'categoryId',
'tags',
'difficulty',
'isPublished',
'price',
]);
// Configure sortable attributes
await this.coursesIndex.updateSortableAttributes([
'createdAt',
'updatedAt',
'price',
'title',
]);
// Configure ranking rules
await this.coursesIndex.updateRankingRules([
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
]);
}
async indexCourse(course: CourseDocument): Promise<void> {
if (!this.isAvailable || !this.coursesIndex) return;
await this.coursesIndex.addDocuments([course]);
}
async updateCourse(course: CourseDocument): Promise<void> {
if (!this.isAvailable || !this.coursesIndex) return;
await this.coursesIndex.updateDocuments([course]);
}
async deleteCourse(courseId: string): Promise<void> {
if (!this.isAvailable || !this.coursesIndex) return;
await this.coursesIndex.deleteDocument(courseId);
}
async searchCourses(
query: string,
options?: {
filter?: string;
sort?: string[];
limit?: number;
offset?: number;
}
) {
if (!this.isAvailable || !this.coursesIndex) {
return {
hits: [],
query,
processingTimeMs: 0,
total: 0,
};
}
const results = await this.coursesIndex.search(query, {
filter: options?.filter,
sort: options?.sort,
limit: options?.limit || 20,
offset: options?.offset || 0,
attributesToHighlight: ['title', 'description'],
});
return {
hits: results.hits,
query: results.query,
processingTimeMs: results.processingTimeMs,
total: results.estimatedTotalHits,
};
}
async indexAllCourses(courses: CourseDocument[]): Promise<void> {
if (!this.isAvailable || !this.coursesIndex || courses.length === 0) return;
// Batch index in chunks of 1000
const chunkSize = 1000;
for (let i = 0; i < courses.length; i += chunkSize) {
const chunk = courses.slice(i, i + chunkSize);
await this.coursesIndex.addDocuments(chunk);
}
}
}

View File

@ -0,0 +1,58 @@
import {
Controller,
Get,
Post,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { SearchService } from './search.service';
import { Public } from '../auth/decorators/public.decorator';
@ApiTags('search')
@Controller('search')
export class SearchController {
constructor(private searchService: SearchService) {}
@Get('courses')
@Public()
@ApiOperation({ summary: 'Search courses' })
@ApiQuery({ name: 'q', required: true, description: 'Search query' })
@ApiQuery({ name: 'categoryId', required: false })
@ApiQuery({ name: 'difficulty', required: false, enum: ['beginner', 'intermediate', 'advanced'] })
@ApiQuery({ name: 'tags', required: false, type: [String] })
@ApiQuery({ name: 'priceMin', required: false, type: Number })
@ApiQuery({ name: 'priceMax', required: false, type: Number })
@ApiQuery({ name: 'sort', required: false, enum: ['newest', 'oldest', 'price_asc', 'price_desc', 'title_asc', 'title_desc'] })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async searchCourses(
@Query('q') query: string,
@Query('categoryId') categoryId?: string,
@Query('difficulty') difficulty?: string,
@Query('tags') tags?: string[],
@Query('priceMin') priceMin?: number,
@Query('priceMax') priceMax?: number,
@Query('sort') sort?: string,
@Query('page') page?: number,
@Query('limit') limit?: number
) {
return this.searchService.searchCourses(query, {
categoryId,
difficulty,
tags: tags ? (Array.isArray(tags) ? tags : [tags]) : undefined,
priceMin,
priceMax,
sort,
page,
limit,
publishedOnly: true,
});
}
@Post('reindex')
@ApiBearerAuth()
@ApiOperation({ summary: 'Reindex all courses (admin only)' })
async reindexAll() {
return this.searchService.reindexAllCourses();
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { MeilisearchService } from './meilisearch.service';
@Module({
controllers: [SearchController],
providers: [SearchService, MeilisearchService],
exports: [SearchService, MeilisearchService],
})
export class SearchModule {}

View File

@ -0,0 +1,181 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { MeilisearchService, CourseDocument } from './meilisearch.service';
@Injectable()
export class SearchService {
constructor(
private prisma: PrismaService,
private meilisearch: MeilisearchService
) {}
async searchCourses(
query: string,
options?: {
categoryId?: string;
difficulty?: string;
tags?: string[];
priceMin?: number;
priceMax?: number;
authorId?: string;
publishedOnly?: boolean;
sort?: string;
page?: number;
limit?: number;
}
) {
const filters: string[] = [];
if (options?.publishedOnly !== false) {
filters.push('isPublished = true');
}
if (options?.categoryId) {
filters.push(`categoryId = "${options.categoryId}"`);
}
if (options?.difficulty) {
filters.push(`difficulty = "${options.difficulty}"`);
}
if (options?.tags && options.tags.length > 0) {
const tagFilters = options.tags.map((tag) => `tags = "${tag}"`).join(' OR ');
filters.push(`(${tagFilters})`);
}
if (options?.priceMin !== undefined) {
filters.push(`price >= ${options.priceMin}`);
}
if (options?.priceMax !== undefined) {
filters.push(`price <= ${options.priceMax}`);
}
if (options?.authorId) {
filters.push(`authorId = "${options.authorId}"`);
}
// Parse sort option
let sort: string[] | undefined;
if (options?.sort) {
switch (options.sort) {
case 'newest':
sort = ['createdAt:desc'];
break;
case 'oldest':
sort = ['createdAt:asc'];
break;
case 'price_asc':
sort = ['price:asc'];
break;
case 'price_desc':
sort = ['price:desc'];
break;
case 'title_asc':
sort = ['title:asc'];
break;
case 'title_desc':
sort = ['title:desc'];
break;
}
}
const page = options?.page || 1;
const limit = options?.limit || 20;
const offset = (page - 1) * limit;
const results = await this.meilisearch.searchCourses(query, {
filter: filters.length > 0 ? filters.join(' AND ') : undefined,
sort,
limit,
offset,
});
return {
data: results.hits,
meta: {
query: results.query,
processingTimeMs: results.processingTimeMs,
total: results.total,
page,
limit,
totalPages: Math.ceil((results.total || 0) / limit),
},
};
}
async indexCourse(courseId: string) {
const course = await this.prisma.course.findUnique({
where: { id: courseId },
include: {
author: {
select: { id: true, name: true },
},
category: {
select: { id: true, name: true },
},
},
});
if (!course) return;
const document: CourseDocument = {
id: course.id,
title: course.title,
description: course.description,
slug: course.slug,
authorId: course.authorId,
authorName: course.author.name,
status: course.status,
categoryId: course.categoryId,
categoryName: course.category?.name || null,
tags: course.tags,
difficulty: course.difficulty,
price: course.price ? Number(course.price) : null,
isPublished: course.isPublished,
createdAt: course.createdAt.getTime(),
updatedAt: course.updatedAt.getTime(),
};
await this.meilisearch.indexCourse(document);
}
async deleteCourseFromIndex(courseId: string) {
await this.meilisearch.deleteCourse(courseId);
}
async reindexAllCourses() {
const courses = await this.prisma.course.findMany({
include: {
author: {
select: { id: true, name: true },
},
category: {
select: { id: true, name: true },
},
},
});
const documents: CourseDocument[] = courses.map((course) => ({
id: course.id,
title: course.title,
description: course.description,
slug: course.slug,
authorId: course.authorId,
authorName: course.author.name,
status: course.status,
categoryId: course.categoryId,
categoryName: course.category?.name || null,
tags: course.tags,
difficulty: course.difficulty,
price: course.price ? Number(course.price) : null,
isPublished: course.isPublished,
createdAt: course.createdAt.getTime(),
updatedAt: course.updatedAt.getTime(),
}));
await this.meilisearch.indexAllCourses(documents);
return { indexed: documents.length };
}
}

View File

@ -0,0 +1,17 @@
import { IsString, IsEmail, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsString()
supabaseId: string;
@IsEmail()
email: string;
@IsOptional()
@IsString()
name?: string | null;
@IsOptional()
@IsString()
avatarUrl?: string | null;
}

View File

@ -0,0 +1,35 @@
import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateSettingsDto {
@ApiPropertyOptional({
description: 'Custom AI model override (e.g., "qwen/qwen3-coder-next")',
example: 'anthropic/claude-3.5-sonnet',
})
@IsOptional()
@IsString()
customAiModel?: string;
@ApiPropertyOptional({ description: 'Enable email notifications' })
@IsOptional()
@IsBoolean()
emailNotifications?: boolean;
@ApiPropertyOptional({ description: 'Enable marketing emails' })
@IsOptional()
@IsBoolean()
marketingEmails?: boolean;
@ApiPropertyOptional({
description: 'UI theme',
enum: ['light', 'dark', 'system'],
})
@IsOptional()
@IsIn(['light', 'dark', 'system'])
theme?: 'light' | 'dark' | 'system';
@ApiPropertyOptional({ description: 'Preferred language' })
@IsOptional()
@IsString()
language?: string;
}

View File

@ -0,0 +1,14 @@
import { IsString, IsOptional, IsUrl } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateUserDto {
@ApiPropertyOptional({ description: 'User display name' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: 'User avatar URL' })
@IsOptional()
@IsUrl()
avatarUrl?: string;
}

View File

@ -0,0 +1,60 @@
import {
Controller,
Get,
Patch,
Body,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateSettingsDto } from './dto/update-settings.dto';
@ApiTags('users')
@Controller('users')
@ApiBearerAuth()
export class UsersController {
constructor(private usersService: UsersService) {}
@Get('profile')
@ApiOperation({ summary: 'Get user profile' })
async getProfile(@CurrentUser() user: User) {
const fullUser = await this.usersService.findById(user.id);
const subscription = await this.usersService.getSubscriptionInfo(user.id);
return {
id: fullUser!.id,
email: fullUser!.email,
name: fullUser!.name,
avatarUrl: fullUser!.avatarUrl,
subscriptionTier: fullUser!.subscriptionTier,
subscription,
createdAt: fullUser!.createdAt,
};
}
@Patch('profile')
@ApiOperation({ summary: 'Update user profile' })
async updateProfile(
@CurrentUser() user: User,
@Body() dto: UpdateUserDto
) {
return this.usersService.update(user.id, dto);
}
@Get('settings')
@ApiOperation({ summary: 'Get user settings' })
async getSettings(@CurrentUser() user: User) {
return this.usersService.getSettings(user.id);
}
@Patch('settings')
@ApiOperation({ summary: 'Update user settings' })
async updateSettings(
@CurrentUser() user: User,
@Body() dto: UpdateSettingsDto
) {
return this.usersService.updateSettings(user.id, dto);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,150 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { User, UserSettings, SubscriptionTier } from '@coursecraft/database';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateSettingsDto } from './dto/update-settings.dto';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async create(data: CreateUserDto): Promise<User> {
return this.prisma.user.create({
data: {
supabaseId: data.supabaseId,
email: data.email,
name: data.name,
avatarUrl: data.avatarUrl,
settings: {
create: {}, // Create default settings
},
subscription: {
create: {
tier: SubscriptionTier.FREE,
},
},
},
include: {
settings: true,
subscription: true,
},
});
}
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: { id },
include: {
settings: true,
subscription: true,
},
});
}
async findBySupabaseId(supabaseId: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: { supabaseId },
include: {
settings: true,
subscription: true,
},
});
}
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: { email },
});
}
async update(id: string, data: UpdateUserDto): Promise<User> {
const user = await this.findById(id);
if (!user) {
throw new NotFoundException('User not found');
}
return this.prisma.user.update({
where: { id },
data: {
name: data.name,
avatarUrl: data.avatarUrl,
},
});
}
async getSettings(userId: string): Promise<UserSettings> {
const settings = await this.prisma.userSettings.findUnique({
where: { userId },
});
if (!settings) {
// Create default settings if not found
return this.prisma.userSettings.create({
data: { userId },
});
}
return settings;
}
async updateSettings(userId: string, data: UpdateSettingsDto): Promise<UserSettings> {
return this.prisma.userSettings.upsert({
where: { userId },
create: {
userId,
...data,
},
update: data,
});
}
async getSubscriptionInfo(userId: string) {
const subscription = await this.prisma.subscription.findUnique({
where: { userId },
});
if (!subscription) {
// Return default FREE subscription info
return {
tier: SubscriptionTier.FREE,
status: 'active',
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
coursesCreatedThisMonth: 0,
coursesLimit: 2,
};
}
const limits = {
[SubscriptionTier.FREE]: 2,
[SubscriptionTier.PREMIUM]: 5,
[SubscriptionTier.PRO]: 15,
};
return {
tier: subscription.tier,
status: subscription.status,
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
coursesCreatedThisMonth: subscription.coursesCreatedThisMonth,
coursesLimit: limits[subscription.tier],
};
}
async canCreateCourse(userId: string): Promise<boolean> {
const subscription = await this.getSubscriptionInfo(userId);
return subscription.coursesCreatedThisMonth < subscription.coursesLimit;
}
async incrementCoursesCreated(userId: string): Promise<void> {
await this.prisma.subscription.update({
where: { userId },
data: {
coursesCreatedThisMonth: {
increment: 1,
},
},
});
}
}