project init
This commit is contained in:
46
apps/api/src/app.module.ts
Normal file
46
apps/api/src/app.module.ts
Normal 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 {}
|
||||
43
apps/api/src/auth/auth.controller.ts
Normal file
43
apps/api/src/auth/auth.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
40
apps/api/src/auth/auth.module.ts
Normal file
40
apps/api/src/auth/auth.module.ts
Normal 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 {}
|
||||
65
apps/api/src/auth/auth.service.ts
Normal file
65
apps/api/src/auth/auth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
11
apps/api/src/auth/decorators/current-user.decorator.ts
Normal 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;
|
||||
}
|
||||
);
|
||||
4
apps/api/src/auth/decorators/public.decorator.ts
Normal file
4
apps/api/src/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
12
apps/api/src/auth/dto/exchange-token.dto.ts
Normal file
12
apps/api/src/auth/dto/exchange-token.dto.ts
Normal 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;
|
||||
}
|
||||
83
apps/api/src/auth/guards/jwt-auth.guard.ts
Normal file
83
apps/api/src/auth/guards/jwt-auth.guard.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
72
apps/api/src/auth/guards/supabase-auth.guard.ts
Normal file
72
apps/api/src/auth/guards/supabase-auth.guard.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
62
apps/api/src/auth/strategies/jwt.strategy.ts
Normal file
62
apps/api/src/auth/strategies/jwt.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
56
apps/api/src/auth/supabase.service.ts
Normal file
56
apps/api/src/auth/supabase.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/api/src/common/prisma/prisma.module.ts
Normal file
9
apps/api/src/common/prisma/prisma.module.ts
Normal 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 {}
|
||||
19
apps/api/src/common/prisma/prisma.service.ts
Normal file
19
apps/api/src/common/prisma/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
67
apps/api/src/courses/chapters.controller.ts
Normal file
67
apps/api/src/courses/chapters.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
131
apps/api/src/courses/chapters.service.ts
Normal file
131
apps/api/src/courses/chapters.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
78
apps/api/src/courses/courses.controller.ts
Normal file
78
apps/api/src/courses/courses.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/courses/courses.module.ts
Normal file
14
apps/api/src/courses/courses.module.ts
Normal 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 {}
|
||||
231
apps/api/src/courses/courses.service.ts
Normal file
231
apps/api/src/courses/courses.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
apps/api/src/courses/dto/create-chapter.dto.ts
Normal file
22
apps/api/src/courses/dto/create-chapter.dto.ts
Normal 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;
|
||||
}
|
||||
25
apps/api/src/courses/dto/create-course.dto.ts
Normal file
25
apps/api/src/courses/dto/create-course.dto.ts
Normal 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;
|
||||
}
|
||||
30
apps/api/src/courses/dto/create-lesson.dto.ts
Normal file
30
apps/api/src/courses/dto/create-lesson.dto.ts
Normal 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;
|
||||
}
|
||||
17
apps/api/src/courses/dto/update-chapter.dto.ts
Normal file
17
apps/api/src/courses/dto/update-chapter.dto.ts
Normal 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;
|
||||
}
|
||||
71
apps/api/src/courses/dto/update-course.dto.ts
Normal file
71
apps/api/src/courses/dto/update-course.dto.ts
Normal 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;
|
||||
}
|
||||
34
apps/api/src/courses/dto/update-lesson.dto.ts
Normal file
34
apps/api/src/courses/dto/update-lesson.dto.ts
Normal 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;
|
||||
}
|
||||
67
apps/api/src/courses/lessons.controller.ts
Normal file
67
apps/api/src/courses/lessons.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
141
apps/api/src/courses/lessons.service.ts
Normal file
141
apps/api/src/courses/lessons.service.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
15
apps/api/src/generation/dto/answer-questions.dto.ts
Normal file
15
apps/api/src/generation/dto/answer-questions.dto.ts
Normal 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[]>;
|
||||
}
|
||||
16
apps/api/src/generation/dto/start-generation.dto.ts
Normal file
16
apps/api/src/generation/dto/start-generation.dto.ts
Normal 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;
|
||||
}
|
||||
90
apps/api/src/generation/generation.controller.ts
Normal file
90
apps/api/src/generation/generation.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
apps/api/src/generation/generation.module.ts
Normal file
24
apps/api/src/generation/generation.module.ts
Normal 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 {}
|
||||
226
apps/api/src/generation/generation.service.ts
Normal file
226
apps/api/src/generation/generation.service.ts
Normal 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
60
apps/api/src/main.ts
Normal 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();
|
||||
41
apps/api/src/payments/payments.controller.ts
Normal file
41
apps/api/src/payments/payments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/payments/payments.module.ts
Normal file
12
apps/api/src/payments/payments.module.ts
Normal 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 {}
|
||||
231
apps/api/src/payments/payments.service.ts
Normal file
231
apps/api/src/payments/payments.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
69
apps/api/src/payments/stripe.service.ts
Normal file
69
apps/api/src/payments/stripe.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
apps/api/src/payments/webhooks.controller.ts
Normal file
52
apps/api/src/payments/webhooks.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
156
apps/api/src/search/meilisearch.service.ts
Normal file
156
apps/api/src/search/meilisearch.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
apps/api/src/search/search.controller.ts
Normal file
58
apps/api/src/search/search.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
11
apps/api/src/search/search.module.ts
Normal file
11
apps/api/src/search/search.module.ts
Normal 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 {}
|
||||
181
apps/api/src/search/search.service.ts
Normal file
181
apps/api/src/search/search.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
17
apps/api/src/users/dto/create-user.dto.ts
Normal file
17
apps/api/src/users/dto/create-user.dto.ts
Normal 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;
|
||||
}
|
||||
35
apps/api/src/users/dto/update-settings.dto.ts
Normal file
35
apps/api/src/users/dto/update-settings.dto.ts
Normal 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;
|
||||
}
|
||||
14
apps/api/src/users/dto/update-user.dto.ts
Normal file
14
apps/api/src/users/dto/update-user.dto.ts
Normal 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;
|
||||
}
|
||||
60
apps/api/src/users/users.controller.ts
Normal file
60
apps/api/src/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/users/users.module.ts
Normal file
10
apps/api/src/users/users.module.ts
Normal 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 {}
|
||||
150
apps/api/src/users/users.service.ts
Normal file
150
apps/api/src/users/users.service.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user