auth checkpoint

auth dependecies not instaled in dev container
This commit is contained in:
dimitar committed 2026-02-04 19:24:03 +01:00
1 parent 7378d37b36
commit 42002f8e6f
50 files changed
+3680 -636

No files matched your search

+11 -1
View File
@@ -22,7 +22,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json",
"dev:docker": "nest start --watch",
"dev:local": "cp -f .env.local .env && nest start --watch",
"dev:reset-env": "cp -f .env.docker .env"
"dev:reset-env": "cp -f .env.docker .env",
"seed:admin": "ts-node scripts/seed-admin.ts"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
@@ -30,10 +31,19 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"@types/bcrypt": "^6.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.18.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
+26
View File
@@ -0,0 +1,26 @@
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
async function resetDatabase() {
console.log('Resetting database...');
try {
// Drop and recreate the database
const { stdout, stderr } = await execAsync(`
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "DROP DATABASE IF EXISTS placebo_backend_db;"
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "CREATE DATABASE placebo_backend_db;"
`);
if (stderr && !stderr.includes('warning')) {
console.error('Error:', stderr);
return;
}
console.log('Database reset successfully!');
} catch (error) {
console.error('Failed to reset database:', error.message);
}
}
resetDatabase();
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
echo "Resetting PostgreSQL database..."
# Connect to PostgreSQL and reset the database (without -t for TTY)
docker exec -i placebo-postgres-dev psql -U placebo_user -d postgres <<EOF
DROP DATABASE IF EXISTS placebo_backend_db;
CREATE DATABASE placebo_backend_db;
EOF
echo "Database reset complete!"
echo "The new schema will be created when the backend starts with DATABASE_SYNCHRONIZE=true"
+41
View File
@@ -0,0 +1,41 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { UserService } from '../src/modules/users/user.service';
import { UserRole } from '../src/modules/entities';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
const userService = app.get(UserService);
try {
// Check if admin user already exists
const existingAdmin = await userService.findByUsername('admin');
if (existingAdmin) {
console.log('Admin user already exists');
await app.close();
return;
}
// Create admin user
const adminUser = await userService.create({
username: 'admin',
email: 'admin@placebo.mk',
password: 'admin123', // Change this in production!
role: UserRole.ADMIN,
isActive: true,
});
console.log('Admin user created successfully:');
console.log(`Username: ${adminUser.username}`);
console.log(`Email: ${adminUser.email}`);
console.log(`Role: ${adminUser.role}`);
console.log('\n⚠️ IMPORTANT: Change the default password immediately!');
} catch (error) {
console.error('Error creating admin user:', error);
} finally {
await app.close();
}
}
bootstrap();
+21 -3
View File
@@ -6,12 +6,18 @@ import { AppService } from './app.service';
import { ArticlesModule } from './modules/articles.module';
import { StrapiModule } from './modules/strapi.module';
import { LiveBlogModule } from './modules/live-blog.module';
import { UserModule } from './modules/users/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { CommentModule } from './modules/comment/comment.module';
import {
Article,
Author,
Category,
LiveBlog,
LiveBlogUpdate,
User,
Comment,
Reaction,
} from './modules/entities';
@Module({
@@ -26,13 +32,25 @@ import {
username: process.env.DATABASE_USERNAME || 'placebo_user',
password: process.env.DATABASE_PASSWORD || 'placebo_password',
database: process.env.DATABASE_NAME || 'placebo_backend_db',
entities: [Article, Author, Category, LiveBlog, LiveBlogUpdate],
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV === 'development',
entities: [
Article,
Author,
Category,
LiveBlog,
LiveBlogUpdate,
User,
Comment,
Reaction,
],
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
logging: process.env.DATABASE_LOGGING === 'true',
}),
ArticlesModule,
StrapiModule,
LiveBlogModule,
UserModule,
AuthModule,
CommentModule,
],
controllers: [AppController],
providers: [AppService],
+16 -1
View File
@@ -1,5 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { NestFactory, Reflector } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@@ -16,6 +18,19 @@ async function bootstrap() {
app.setGlobalPrefix('api/v1');
// Apply global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Apply global authentication guard
const reflector = app.get(Reflector);
app.useGlobalGuards(new JwtAuthPublicGuard(reflector));
const port = process.env.PORT ?? 3000;
const host = '0.0.0.0'; // Bind to all interfaces for Docker
await app.listen(port, host);
+25 -12
View File
@@ -8,7 +8,7 @@ import {
Body,
Param,
Query,
ValidationPipe,
UseGuards,
} from '@nestjs/common';
import { ArticlesService } from './articles.service';
import {
@@ -16,54 +16,67 @@ import {
UpdateArticleDto,
FindArticlesDto,
} from './articles.dto';
import { ArticleStatus } from './entities';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
import { Roles } from './auth/roles.decorator';
import { Public } from './auth/public.decorator';
import { UserRole } from './entities';
@Controller('articles')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Post()
create(@Body(new ValidationPipe({ transform: true })) dto: CreateArticleDto) {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
create(@Body() dto: CreateArticleDto) {
return this.articlesService.create(dto);
}
@Get()
findAll(
@Query(new ValidationPipe({ transform: true })) dto: FindArticlesDto,
) {
@Public()
findAll(@Query() dto: FindArticlesDto) {
return this.articlesService.findAll(dto);
}
@Get(':id')
@Public()
findOne(@Param('id') id: string) {
return this.articlesService.findOne(id);
}
@Get('slug/:slug')
@Public()
findBySlug(@Param('slug') slug: string) {
return this.articlesService.findBySlug(slug);
}
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateArticleDto,
) {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
update(@Param('id') id: string, @Body() dto: UpdateArticleDto) {
return this.articlesService.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
remove(@Param('id') id: string) {
return this.articlesService.remove(id);
}
@Patch(':id/archive')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
archive(@Param('id') id: string) {
return this.articlesService.archive(id);
}
@Patch(':id/publish')
publish(@Param('id') id: string, @Query('status') status?: ArticleStatus) {
return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED);
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
publish(@Param('id') id: string) {
return this.articlesService.publish(id);
}
}
@@ -0,0 +1,31 @@
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginUserDto, CreateUserDto } from '../users/user.dto';
import { LocalAuthGuard } from './local-auth.guard';
import { Public } from './public.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@UseGuards(LocalAuthGuard)
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginUserDto: LoginUserDto) {
return this.authService.login(loginUserDto);
}
@Public()
@Post('register')
async register(@Body() createUserDto: CreateUserDto) {
return this.authService.register(createUserDto);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UserModule } from '../users/user.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { LocalStrategy } from './local.strategy';
@Module({
imports: [
UserModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => {
const expiration = configService.get<string>('JWT_EXPIRATION') || '7d';
return {
secret:
configService.get<string>('JWT_SECRET') || 'default-secret-key',
signOptions: {
expiresIn: expiration ? parseInt(expiration) : 3600, // Convert to number
},
};
},
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+80
View File
@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UserService } from '../users/user.service';
import { CreateUserDto, LoginUserDto } from '../users/user.dto';
import { AuthResponse, JwtPayload } from './types';
import { UserRole } from '../entities';
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async validateUser(
username: string,
password: string,
): Promise<{
id: string;
username: string;
email: string;
role: string;
} | null> {
return this.userService.validateUser(username, password);
}
async login(loginUserDto: LoginUserDto): Promise<AuthResponse> {
const user = await this.validateUser(
loginUserDto.username,
loginUserDto.password,
);
if (!user) {
throw new Error('Invalid credentials');
}
const payload: JwtPayload = {
sub: user.id,
username: user.username,
email: user.email,
role: user.role as UserRole,
};
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role as UserRole,
},
};
}
async register(createUserDto: CreateUserDto): Promise<AuthResponse> {
// Default new users to USER role
const userDto = {
...createUserDto,
role: UserRole.USER,
};
const user = await this.userService.create(userDto);
const payload: JwtPayload = {
sub: user.id,
username: user.username,
email: user.email,
role: user.role,
};
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
};
}
}
@@ -0,0 +1,24 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from './public.decorator';
@Injectable()
export class JwtAuthPublicGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
@@ -0,0 +1,9 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { JwtPayload } from './types';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey:
configService.get<string>('JWT_SECRET') || 'default-secret-key',
});
}
async validate(payload: JwtPayload) {
await Promise.resolve(); // Add await to satisfy eslint rule
return {
id: payload.sub,
username: payload.username,
email: payload.email,
role: payload.role,
};
}
}
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
@@ -0,0 +1,22 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(
username: string,
password: string,
): Promise<{ id: string; username: string; email: string; role: string }> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
}
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../entities';
export const Roles = (...roles: UserRole[]) => SetMetadata('roles', roles);
+31
View File
@@ -0,0 +1,31 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../entities';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
'roles',
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) {
return true;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const request = context.switchToHttp().getRequest();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const user = request.user as
| { id: string; username: string; email: string; role: UserRole }
| undefined;
if (!user) {
return false;
}
return requiredRoles.includes(user.role);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { UserRole } from '../entities';
export interface JwtPayload {
sub: string;
username: string;
email: string;
role: UserRole;
}
export interface RequestWithUser extends Request {
user: {
id: string;
username: string;
email: string;
role: UserRole;
};
}
export interface AuthResponse {
access_token: string;
user: {
id: string;
username: string;
email: string;
role: UserRole;
};
}
@@ -0,0 +1,148 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { CommentService } from './comment.service';
import {
CreateCommentDto,
UpdateCommentDto,
CommentResponseDto,
FindCommentsDto,
CreateReactionDto,
} from './comment.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { Public } from '../auth/public.decorator';
import { UserRole } from '../entities';
import type { RequestWithUser } from '../auth/types';
@Controller('comments')
export class CommentController {
constructor(private readonly commentService: CommentService) {}
@Post()
@UseGuards(JwtAuthGuard)
async create(
@Body() createCommentDto: CreateCommentDto,
@Request() req: RequestWithUser,
): Promise<CommentResponseDto> {
return this.commentService.create(createCommentDto, req.user.id);
}
@Get()
@Public()
async findAll(
@Query() findCommentsDto: FindCommentsDto,
): Promise<CommentResponseDto[]> {
return this.commentService.findAll(findCommentsDto);
}
@Get(':id')
@Public()
async findOne(@Param('id') id: string): Promise<CommentResponseDto> {
return this.commentService.findOne(id);
}
@Put(':id')
@UseGuards(JwtAuthGuard)
async update(
@Param('id') id: string,
@Body() updateCommentDto: UpdateCommentDto,
@Request() req: RequestWithUser,
): Promise<CommentResponseDto> {
return this.commentService.update(
id,
updateCommentDto,
req.user.id,
req.user.role,
);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async remove(
@Param('id') id: string,
@Request() req: RequestWithUser,
): Promise<void> {
return this.commentService.remove(id, req.user.id, req.user.role);
}
@Post('reactions')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async addReaction(
@Body() createReactionDto: CreateReactionDto,
@Request() req: RequestWithUser,
): Promise<void> {
return this.commentService.addReaction(createReactionDto, req.user.id);
}
@Get('reactions/user')
@UseGuards(JwtAuthGuard)
async getUserReaction(
@Request() req: RequestWithUser,
@Query('articleId') articleId?: string,
@Query('liveBlogId') liveBlogId?: string,
@Query('commentId') commentId?: string,
): Promise<{ type: string | null }> {
const reaction = await this.commentService.getUserReaction(
req.user.id,
articleId,
liveBlogId,
commentId,
);
return { type: reaction };
}
@Get('reactions/counts')
@Public()
async getReactionCounts(
@Query('articleId') articleId?: string,
@Query('liveBlogId') liveBlogId?: string,
@Query('commentId') commentId?: string,
): Promise<{ likes: number; dislikes: number }> {
return this.commentService.getReactionCounts(
articleId,
liveBlogId,
commentId,
);
}
// Admin endpoints
@Patch(':id/hide')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async hideComment(@Param('id') id: string): Promise<CommentResponseDto> {
return this.commentService.update(
id,
{ isVisible: false },
'admin',
'admin',
);
}
@Patch(':id/show')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async showComment(@Param('id') id: string): Promise<CommentResponseDto> {
return this.commentService.update(
id,
{ isVisible: true },
'admin',
'admin',
);
}
}
+145
View File
@@ -0,0 +1,145 @@
import {
IsString,
IsOptional,
IsUUID,
IsBoolean,
IsInt,
Min,
Max,
} from 'class-validator';
interface CommentEntity {
id: string;
content: string;
articleId?: string;
liveBlogId?: string;
parentId?: string;
userId: string;
likeCount: number;
dislikeCount: number;
isVisible: boolean;
createdAt: Date;
updatedAt: Date;
user?: {
id: string;
username: string;
};
replies?: CommentEntity[];
}
export class CreateCommentDto {
@IsString()
@Max(5000)
content: string;
@IsUUID()
@IsOptional()
articleId?: string;
@IsUUID()
@IsOptional()
liveBlogId?: string;
@IsUUID()
@IsOptional()
parentId?: string;
}
export class UpdateCommentDto {
@IsString()
@Max(5000)
@IsOptional()
content?: string;
@IsBoolean()
@IsOptional()
isVisible?: boolean;
}
export class CommentResponseDto {
id: string;
content: string;
articleId?: string;
liveBlogId?: string;
parentId?: string;
userId: string;
likeCount: number;
dislikeCount: number;
isVisible: boolean;
createdAt: Date;
updatedAt: Date;
user: {
id: string;
username: string;
};
replies?: CommentResponseDto[];
constructor(comment: CommentEntity) {
this.id = comment.id;
this.content = comment.content;
this.articleId = comment.articleId;
this.liveBlogId = comment.liveBlogId;
this.parentId = comment.parentId;
this.userId = comment.userId;
this.likeCount = comment.likeCount;
this.dislikeCount = comment.dislikeCount;
this.isVisible = comment.isVisible;
this.createdAt = comment.createdAt;
this.updatedAt = comment.updatedAt;
if (comment.user) {
this.user = {
id: comment.user.id,
username: comment.user.username,
};
}
if (comment.replies) {
this.replies = comment.replies.map(
(reply) => new CommentResponseDto(reply),
);
}
}
}
export class FindCommentsDto {
@IsUUID()
@IsOptional()
articleId?: string;
@IsUUID()
@IsOptional()
liveBlogId?: string;
@IsUUID()
@IsOptional()
parentId?: string;
@IsInt()
@Min(1)
@IsOptional()
page?: number = 1;
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
}
export class CreateReactionDto {
@IsString()
type: 'like' | 'dislike';
@IsUUID()
@IsOptional()
articleId?: string;
@IsUUID()
@IsOptional()
liveBlogId?: string;
@IsUUID()
@IsOptional()
commentId?: string;
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Comment, Reaction } from '../entities';
import { CommentService } from './comment.service';
import { CommentController } from './comment.controller';
@Module({
imports: [TypeOrmModule.forFeature([Comment, Reaction])],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
})
export class CommentModule {}
@@ -0,0 +1,298 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Comment, Reaction, ReactionType } from '../entities';
import {
CreateCommentDto,
UpdateCommentDto,
CommentResponseDto,
FindCommentsDto,
CreateReactionDto,
} from './comment.dto';
@Injectable()
export class CommentService {
constructor(
@InjectRepository(Comment)
private commentRepository: Repository<Comment>,
@InjectRepository(Reaction)
private reactionRepository: Repository<Reaction>,
) {}
async create(
createCommentDto: CreateCommentDto,
userId: string,
): Promise<CommentResponseDto> {
// Validate that comment is attached to either article or live blog
if (!createCommentDto.articleId && !createCommentDto.liveBlogId) {
throw new BadRequestException(
'Comment must be attached to either an article or live blog',
);
}
// If parentId is provided, verify it exists
if (createCommentDto.parentId) {
const parent = await this.commentRepository.findOne({
where: { id: createCommentDto.parentId },
});
if (!parent) {
throw new NotFoundException('Parent comment not found');
}
}
const comment = this.commentRepository.create({
...createCommentDto,
userId,
});
const savedComment = await this.commentRepository.save(comment);
return this.findOne(savedComment.id);
}
async findAll(
findCommentsDto: FindCommentsDto,
): Promise<CommentResponseDto[]> {
const {
articleId,
liveBlogId,
parentId,
page = 1,
limit = 20,
} = findCommentsDto;
const skip = (page - 1) * limit;
const query = this.commentRepository
.createQueryBuilder('comment')
.leftJoinAndSelect('comment.user', 'user')
.leftJoinAndSelect('comment.replies', 'replies')
.leftJoinAndSelect('replies.user', 'replyUser')
.where('comment.isVisible = :isVisible', { isVisible: true });
if (articleId) {
query.andWhere('comment.articleId = :articleId', { articleId });
}
if (liveBlogId) {
query.andWhere('comment.liveBlogId = :liveBlogId', { liveBlogId });
}
if (parentId) {
query.andWhere('comment.parentId = :parentId', { parentId });
} else {
query.andWhere('comment.parentId IS NULL');
}
query.orderBy('comment.createdAt', 'DESC').skip(skip).take(limit);
const comments = await query.getMany();
return comments.map((comment) => new CommentResponseDto(comment));
}
async findOne(id: string): Promise<CommentResponseDto> {
const comment = await this.commentRepository
.createQueryBuilder('comment')
.leftJoinAndSelect('comment.user', 'user')
.leftJoinAndSelect('comment.replies', 'replies')
.leftJoinAndSelect('replies.user', 'replyUser')
.where('comment.id = :id', { id })
.andWhere('comment.isVisible = :isVisible', { isVisible: true })
.getOne();
if (!comment) {
throw new NotFoundException('Comment not found');
}
return new CommentResponseDto(comment);
}
async update(
id: string,
updateCommentDto: UpdateCommentDto,
userId: string,
userRole: string,
): Promise<CommentResponseDto> {
const comment = await this.commentRepository.findOne({
where: { id },
relations: ['user'],
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
// Only allow comment owner or admin to update
if (comment.userId !== userId && userRole !== 'admin') {
throw new BadRequestException('You can only update your own comments');
}
Object.assign(comment, updateCommentDto);
const updatedComment = await this.commentRepository.save(comment);
return this.findOne(updatedComment.id);
}
async remove(id: string, userId: string, userRole: string): Promise<void> {
const comment = await this.commentRepository.findOne({
where: { id },
relations: ['user'],
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
// Only allow comment owner or admin to delete
if (comment.userId !== userId && userRole !== 'admin') {
throw new BadRequestException('You can only delete your own comments');
}
await this.commentRepository.remove(comment);
}
async addReaction(
createReactionDto: CreateReactionDto,
userId: string,
): Promise<void> {
// Validate that reaction is attached to either article, live blog, or comment
const { articleId, liveBlogId, commentId, type } = createReactionDto;
if (!articleId && !liveBlogId && !commentId) {
throw new BadRequestException(
'Reaction must be attached to an article, live blog, or comment',
);
}
// Check if user already reacted to this item
const whereClause: {
userId: string;
articleId?: string;
liveBlogId?: string;
commentId?: string;
} = { userId };
if (articleId) whereClause.articleId = articleId;
if (liveBlogId) whereClause.liveBlogId = liveBlogId;
if (commentId) whereClause.commentId = commentId;
const existingReaction = await this.reactionRepository.findOne({
where: whereClause,
});
if (existingReaction) {
// If same reaction type, remove it (toggle)
if (existingReaction.type === (type as ReactionType)) {
await this.reactionRepository.remove(existingReaction);
// Update comment counts if reacting to comment
if (commentId) {
await this.updateCommentReactionCount(commentId, type, -1);
}
return;
} else {
// Change reaction type
existingReaction.type = type as ReactionType;
await this.reactionRepository.save(existingReaction);
// Update comment counts if reacting to comment
if (commentId) {
// Decrement old reaction count
await this.updateCommentReactionCount(
commentId,
existingReaction.type === ReactionType.LIKE
? ReactionType.DISLIKE
: ReactionType.LIKE,
-1,
);
// Increment new reaction count
await this.updateCommentReactionCount(commentId, type, 1);
}
return;
}
}
// Create new reaction
const reaction = this.reactionRepository.create({
...createReactionDto,
userId,
type: type as ReactionType,
});
await this.reactionRepository.save(reaction);
// Update comment counts if reacting to comment
if (commentId) {
await this.updateCommentReactionCount(commentId, type, 1);
}
}
private async updateCommentReactionCount(
commentId: string,
type: 'like' | 'dislike',
change: number,
): Promise<void> {
const comment = await this.commentRepository.findOne({
where: { id: commentId },
});
if (!comment) {
return;
}
if (type === 'like') {
comment.likeCount = Math.max(0, comment.likeCount + change);
} else {
comment.dislikeCount = Math.max(0, comment.dislikeCount + change);
}
await this.commentRepository.save(comment);
}
async getUserReaction(
userId: string,
articleId?: string,
liveBlogId?: string,
commentId?: string,
): Promise<ReactionType | null> {
const whereClause: {
userId: string;
articleId?: string;
liveBlogId?: string;
commentId?: string;
} = { userId };
if (articleId) whereClause.articleId = articleId;
if (liveBlogId) whereClause.liveBlogId = liveBlogId;
if (commentId) whereClause.commentId = commentId;
const reaction = await this.reactionRepository.findOne({
where: whereClause,
});
return reaction ? reaction.type : null;
}
async getReactionCounts(
articleId?: string,
liveBlogId?: string,
commentId?: string,
): Promise<{ likes: number; dislikes: number }> {
const query = this.reactionRepository.createQueryBuilder('reaction');
if (articleId) {
query.where('reaction.articleId = :articleId', { articleId });
} else if (liveBlogId) {
query.where('reaction.liveBlogId = :liveBlogId', { liveBlogId });
} else if (commentId) {
query.where('reaction.commentId = :commentId', { commentId });
} else {
return { likes: 0, dislikes: 0 };
}
const reactions = await query.getMany();
return {
likes: reactions.filter((r) => r.type === ReactionType.LIKE).length,
dislikes: reactions.filter((r) => r.type === ReactionType.DISLIKE).length,
};
}
}
+150
View File
@@ -56,6 +56,17 @@ export enum VideoPosition {
NONE = 'none',
}
export enum UserRole {
ADMIN = 'admin',
CONTRIBUTOR = 'contributor',
USER = 'user',
}
export enum ReactionType {
LIKE = 'like',
DISLIKE = 'dislike',
}
@Entity('authors')
export class Author {
@PrimaryGeneratedColumn('uuid')
@@ -114,6 +125,36 @@ export class Category {
parent: Category;
}
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column({ unique: true })
username: string;
@Column()
passwordHash: string;
@Column({
type: 'text',
default: 'user',
})
role: UserRole;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@Entity('articles')
export class Article {
@PrimaryGeneratedColumn('uuid')
@@ -196,6 +237,12 @@ export class Article {
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'categoryId' })
category: Category;
@OneToMany(() => Comment, (comment) => comment.article)
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.article)
reactions: Reaction[];
}
@Entity('live_blogs')
@@ -278,6 +325,12 @@ export class LiveBlog {
cascade: true,
})
updates: LiveBlogUpdate[];
@OneToMany(() => Comment, (comment) => comment.liveBlog)
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.liveBlog)
reactions: Reaction[];
}
@Entity('live_blog_updates')
@@ -314,3 +367,100 @@ export class LiveBlogUpdate {
@JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog;
}
@Entity('comments')
export class Comment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('text')
content: string;
@Column({ nullable: true })
articleId: string;
@Column({ nullable: true })
liveBlogId: string;
@Column({ nullable: true })
parentId: string;
@Column()
userId: string;
@Column({ default: 0 })
likeCount: number;
@Column({ default: 0 })
dislikeCount: number;
@Column({ default: true })
isVisible: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'articleId' })
article: Article;
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog;
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'parentId' })
parent: Comment;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@OneToMany(() => Comment, (comment) => comment.parent)
replies: Comment[];
}
@Entity('reactions')
export class Reaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'text',
})
type: ReactionType;
@Column({ nullable: true })
articleId: string;
@Column({ nullable: true })
liveBlogId: string;
@Column({ nullable: true })
commentId: string;
@Column()
userId: string;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'articleId' })
article: Article;
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog;
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'commentId' })
comment: Comment;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
}
+35 -14
View File
@@ -8,10 +8,10 @@ import {
Body,
Param,
Query,
ValidationPipe,
Res,
Logger,
Headers,
UseGuards,
} from '@nestjs/common';
import type { Response as ExpressResponse } from 'express';
import { LiveBlogService } from './live-blog.service';
@@ -22,7 +22,11 @@ import {
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} from './articles.dto';
import { LiveBlogStatus } from './entities';
import { LiveBlogStatus, UserRole } from './entities';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
import { Roles } from './auth/roles.decorator';
import { Public } from './auth/public.decorator';
@Controller('live-blogs')
export class LiveBlogController {
@@ -32,68 +36,76 @@ export class LiveBlogController {
// Live Blog CRUD operations
@Get('featured')
@Public()
getFeatured() {
this.logger.log('GET /featured called');
return this.liveBlogService.findPinned();
}
@Get('active')
@Public()
getActive() {
return this.liveBlogService.findActive();
}
@Get('recent')
@Public()
getRecent() {
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
}
@Post()
create(
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto,
) {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
create(@Body() dto: CreateLiveBlogDto) {
return this.liveBlogService.create(dto);
}
@Get()
findAll(
@Query(new ValidationPipe({ transform: true })) dto: FindLiveBlogsDto,
) {
@Public()
findAll(@Query() dto: FindLiveBlogsDto) {
return this.liveBlogService.findAll(dto);
}
@Get(':id')
@Public()
findOne(@Param('id') id: string) {
return this.liveBlogService.findOne(id);
}
@Get('slug/:slug')
@Public()
findBySlug(@Param('slug') slug: string) {
return this.liveBlogService.findBySlug(slug);
}
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogDto,
) {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
update(@Param('id') id: string, @Body() dto: UpdateLiveBlogDto) {
return this.liveBlogService.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
remove(@Param('id') id: string) {
return this.liveBlogService.remove(id);
}
// Live Blog Updates CRUD operations
@Post(':id/updates')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
createUpdate(
@Param('id') liveBlogId: string,
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogUpdateDto,
@Body() dto: CreateLiveBlogUpdateDto,
) {
return this.liveBlogService.createUpdate(dto, liveBlogId);
}
@Get(':id/updates')
@Public()
findUpdates(
@Param('id') liveBlogId: string,
@Query('page') page = 1,
@@ -107,15 +119,19 @@ export class LiveBlogController {
}
@Put(':id/updates/:updateId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
updateUpdate(
@Param('id') liveBlogId: string,
@Param('updateId') updateId: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogUpdateDto,
@Body() dto: UpdateLiveBlogUpdateDto,
) {
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
}
@Delete(':id/updates/:updateId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
removeUpdate(
@Param('id') liveBlogId: string,
@Param('updateId') updateId: string,
@@ -124,17 +140,22 @@ export class LiveBlogController {
}
@Patch(':id/archive')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
archive(@Param('id') id: string) {
return this.liveBlogService.archive(id);
}
@Patch(':id/publish')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
publish(@Param('id') id: string, @Query('status') status?: LiveBlogStatus) {
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
}
// SSE endpoint for real-time updates
@Get(':id/stream')
@Public()
stream(
@Param('id') liveBlogId: string,
@Res() response: ExpressResponse,
@@ -0,0 +1,104 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UserService } from './user.service';
import {
CreateUserDto,
UpdateUserDto,
UserResponseDto,
ChangePasswordDto,
} from './user.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { UserRole } from '../entities';
import type { RequestWithUser } from '../auth/types';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.userService.create(createUserDto);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async findAll(): Promise<UserResponseDto[]> {
return this.userService.findAll();
}
@Get('profile')
@UseGuards(JwtAuthGuard)
async getProfile(@Request() req: RequestWithUser): Promise<UserResponseDto> {
return this.userService.findOne(req.user.id);
}
@Get(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.findOne(id);
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
return this.userService.update(id, updateUserDto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string): Promise<void> {
return this.userService.remove(id);
}
@Post(':id/change-password')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async changePassword(
@Param('id') id: string,
@Body() changePasswordDto: ChangePasswordDto,
@Request() req: RequestWithUser,
): Promise<void> {
// Users can only change their own password, unless they're admin
if (req.user.role !== UserRole.ADMIN && req.user.id !== id) {
throw new Error('You can only change your own password');
}
return this.userService.changePassword(id, changePasswordDto);
}
@Patch(':id/deactivate')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async deactivate(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.update(id, { isActive: false });
}
@Patch(':id/activate')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async activate(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.update(id, { isActive: true });
}
}
+111
View File
@@ -0,0 +1,111 @@
import {
IsEmail,
IsString,
IsEnum,
IsBoolean,
IsOptional,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
import { UserRole } from '../entities';
interface UserEntity {
id: string;
email: string;
username: string;
role: UserRole;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(3)
@MaxLength(30)
@Matches(/^[a-zA-Z0-9_]+$/, {
message: 'Username can only contain letters, numbers, and underscores',
})
username: string;
@IsString()
@MinLength(6)
password: string;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole = UserRole.USER;
@IsBoolean()
@IsOptional()
isActive?: boolean = true;
}
export class UpdateUserDto {
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@MinLength(3)
@MaxLength(30)
@Matches(/^[a-zA-Z0-9_]+$/, {
message: 'Username can only contain letters, numbers, and underscores',
})
@IsOptional()
username?: string;
@IsString()
@MinLength(6)
@IsOptional()
password?: string;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class LoginUserDto {
@IsString()
username: string;
@IsString()
password: string;
}
export class UserResponseDto {
id: string;
email: string;
username: string;
role: UserRole;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
constructor(user: UserEntity) {
this.id = user.id;
this.email = user.email;
this.username = user.username;
this.role = user.role;
this.isActive = user.isActive;
this.createdAt = user.createdAt;
this.updatedAt = user.updatedAt;
}
}
export class ChangePasswordDto {
@IsString()
currentPassword: string;
@IsString()
@MinLength(6)
newPassword: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities';
import { UserService } from './user.service';
import { UserController } from './user.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
+212
View File
@@ -0,0 +1,212 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User, UserRole } from '../entities';
import {
CreateUserDto,
UpdateUserDto,
UserResponseDto,
ChangePasswordDto,
} from './user.dto';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
// Check if email already exists
const existingEmail = await this.userRepository.findOne({
where: { email: createUserDto.email },
});
if (existingEmail) {
throw new ConflictException('Email already exists');
}
// Check if username already exists
const existingUsername = await this.userRepository.findOne({
where: { username: createUserDto.username },
});
if (existingUsername) {
throw new ConflictException('Username already exists');
}
// Hash password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(createUserDto.password, saltRounds);
// Create user
const user = this.userRepository.create({
...createUserDto,
passwordHash,
});
const savedUser = await this.userRepository.save(user);
return new UserResponseDto(savedUser);
}
async findAll(): Promise<UserResponseDto[]> {
const users = await this.userRepository.find({
order: { createdAt: 'DESC' },
});
return users.map((user) => new UserResponseDto(user));
}
async findOne(id: string): Promise<UserResponseDto> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
return new UserResponseDto(user);
}
async findByUsername(username: string): Promise<User | null> {
return this.userRepository.findOne({ where: { username } });
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}
async update(
id: string,
updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
// Check if email is being changed and if it already exists
if (updateUserDto.email && updateUserDto.email !== user.email) {
const existingEmail = await this.userRepository.findOne({
where: { email: updateUserDto.email },
});
if (existingEmail) {
throw new ConflictException('Email already exists');
}
}
// Check if username is being changed and if it already exists
if (updateUserDto.username && updateUserDto.username !== user.username) {
const existingUsername = await this.userRepository.findOne({
where: { username: updateUserDto.username },
});
if (existingUsername) {
throw new ConflictException('Username already exists');
}
}
// Hash new password if provided
if (updateUserDto.password) {
const saltRounds = 10;
updateUserDto.password = await bcrypt.hash(
updateUserDto.password,
saltRounds,
);
}
// Update user
Object.assign(user, updateUserDto);
if (updateUserDto.password) {
user.passwordHash = updateUserDto.password;
delete updateUserDto.password;
}
const updatedUser = await this.userRepository.save(user);
return new UserResponseDto(updatedUser);
}
async remove(id: string): Promise<void> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
// Prevent deleting the last admin
if (user.role === UserRole.ADMIN) {
const adminCount = await this.userRepository.count({
where: { role: UserRole.ADMIN },
});
if (adminCount <= 1) {
throw new BadRequestException('Cannot delete the last admin user');
}
}
await this.userRepository.remove(user);
}
async changePassword(
id: string,
changePasswordDto: ChangePasswordDto,
): Promise<void> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
// Verify current password
const isPasswordValid = await bcrypt.compare(
changePasswordDto.currentPassword,
user.passwordHash,
);
if (!isPasswordValid) {
throw new BadRequestException('Current password is incorrect');
}
// Hash new password
const saltRounds = 10;
user.passwordHash = await bcrypt.hash(
changePasswordDto.newPassword,
saltRounds,
);
await this.userRepository.save(user);
}
async validateUser(username: string, password: string): Promise<User | null> {
const user = await this.userRepository.findOne({
where: { username, isActive: true },
});
if (!user) {
return null;
}
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return null;
}
return user;
}
async createAdminIfNotExists(): Promise<void> {
const adminExists = await this.userRepository.findOne({
where: { role: UserRole.ADMIN },
});
if (!adminExists) {
const saltRounds = 10;
const passwordHash = await bcrypt.hash('admin123', saltRounds);
const adminUser = this.userRepository.create({
email: 'admin@placebo.mk',
username: 'admin',
passwordHash,
role: UserRole.ADMIN,
isActive: true,
});
await this.userRepository.save(adminUser);
console.log('Default admin user created: admin / admin123');
}
}
}