diff --git a/AGENTS.md b/AGENTS.md index 679234b..d5193a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,39 +8,75 @@ News site in Macedonia with sarcastic tone. Minimalistic design using TanStack s - **Backend**: NestJS + TypeORM + SQLite - **CMS**: Strapi (in /cms) +## Root-Level Commands +```bash +# Docker development +npm run docker:up # Start all services +npm run docker:down # Stop all services +npm run docker:build # Rebuild containers +npm run docker:logs # View logs +npm run dev:docker # Start docker dev (alias) + +# Local development +npm run dev:local # Start backend & frontend locally +npm run dev:backend:local # Backend only (local env) +npm run dev:frontend:local # Frontend only (local env) + +# Code quality +npm run lint # Lint both projects +npm run lint:fix # Auto-fix lint issues +npm run type-check # Type check both projects + +# Database operations +npm run db:backup # Backup database +npm run db:restore # Restore database +npm run db:reset # Reset database + +# Environment +npm run reset:env # Reset to docker environment +``` + ## Build Commands ### Backend (NestJS) ```bash cd backend npm install -npm run start:dev # Watch mode +npm run start:dev # Watch mode +npm run start:debug # Debug mode npm run build npm run start:prod -npm run lint # Lint -npm run lint:fix # Auto-fix -npm run type-check # TypeScript check -npm test # All tests +npm run lint # Lint +npm run lint:fix # Auto-fix +npm run type-check # TypeScript check +npm test # All tests +npm test:watch # Watch mode tests +npm test:cov # Coverage +npm test:e2e # E2E tests npm test app.service.spec.ts # Single file -npm test -t "should" # By test name -npm run test:cov # Coverage -npm run test:e2e # E2E tests +npm test -t "should return" # By test name pattern +npm run format # Format with Prettier +npm run dev:local # Local env with .env.local +npm run dev:docker # Docker env with .env.docker ``` ### Frontend (TanStack) ```bash cd frontend npm install -npm run dev # Vite dev server +npm run dev # Vite dev server npm run build npm run preview -npm run lint -npm run lint:fix -npm run type-check -npm test # All tests -npm test Header.test.tsx # Single file -npm test -t "renders" # By name -npm run test:ui # Vitest UI +npm run lint # Lint +npm run lint:fix # Auto-fix +npm run type-check # TypeScript check +npm test # All tests (Vitest) +npm test:ui # Vitest UI +npm test:coverage # Test coverage +npm test Header.test.tsx # Single file +npm test -t "renders" # By test name pattern +npm run dev:local # Local env with .env.local +npm run dev:docker # Docker env with .env.docker ``` ## Code Style @@ -51,6 +87,8 @@ npm run test:ui # Vitest UI - Explicit return types for public methods - Prefer interfaces over types for objects - Use `readonly` for immutable properties +- Backend: `noImplicitAny: true`, `strictNullChecks: true` +- Frontend: Path alias `@/*` maps to `./src/*` ### Naming Conventions - **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`) @@ -62,6 +100,20 @@ npm run test:ui # Vitest UI ### Import Order - External libraries first, then internal modules, then relative imports +- Group imports with blank lines between groups +- Example: + ```typescript + // External + import { Module } from '@nestjs/common'; + import { TypeOrmModule } from '@nestjs/typeorm'; + + // Internal modules + import { AuthModule } from '../auth/auth.module'; + + // Relative imports + import { UserService } from './user.service'; + import { UserController } from './user.controller'; + ``` ### File Structure @@ -104,12 +156,16 @@ frontend/src/ ``` ### Testing +- **Backend**: Jest with ts-jest transformer +- **Frontend**: Vitest with React Testing Library patterns - Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert) - Integration tests for API endpoints - E2E tests for critical user flows -- Descriptive test names that explain what is tested +- Descriptive test names that explain what is tested (e.g., `should return user data when valid ID provided`) - Mock external dependencies (use Jest for backend, Vitest for frontend) - Run `npm test` for all tests or target specific files/names +- Test files: `*.spec.ts` (backend), `*.test.tsx` (frontend) +- Coverage reports: `npm test:cov` (backend), `npm test:coverage` (frontend) ### Git Workflow - Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:` @@ -147,5 +203,16 @@ frontend/src/ - Example backend env: `DATABASE_PATH=./data.db` - Store sensitive config in `.env` files - Use `DATABASE_PATH` for SQLite location +- Use `@nestjs/config` for backend environment management +- Frontend env vars must be prefixed with `VITE_` + +## Agent-Specific Instructions +- **ALWAYS** run `npm run lint` and `npm run type-check` after making changes +- **NEVER** commit changes without explicit user request +- **ALWAYS** follow existing code patterns and conventions +- **PREFER** editing existing files over creating new ones +- **VERIFY** tests pass before considering work complete +- **USE** the root-level commands for common operations +- **CHECK** both backend and frontend when making API changes diff --git a/backend/package.json b/backend/package.json index b0ac5dc..7b768c9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/scripts/reset-db.js b/backend/scripts/reset-db.js new file mode 100644 index 0000000..c605ef8 --- /dev/null +++ b/backend/scripts/reset-db.js @@ -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(); \ No newline at end of file diff --git a/backend/scripts/reset-db.sh b/backend/scripts/reset-db.sh new file mode 100755 index 0000000..0c181d8 --- /dev/null +++ b/backend/scripts/reset-db.sh @@ -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 < { + const expiration = configService.get('JWT_EXPIRATION') || '7d'; + return { + secret: + configService.get('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 {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..e038367 --- /dev/null +++ b/backend/src/modules/auth/auth.service.ts @@ -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 { + 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 { + // 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, + }, + }; + } +} diff --git a/backend/src/modules/auth/jwt-auth-public.guard.ts b/backend/src/modules/auth/jwt-auth-public.guard.ts new file mode 100644 index 0000000..0f4a07a --- /dev/null +++ b/backend/src/modules/auth/jwt-auth-public.guard.ts @@ -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(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } +} diff --git a/backend/src/modules/auth/jwt-auth.guard.ts b/backend/src/modules/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..f9516ee --- /dev/null +++ b/backend/src/modules/auth/jwt-auth.guard.ts @@ -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); + } +} diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts new file mode 100644 index 0000000..4c43731 --- /dev/null +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -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('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, + }; + } +} diff --git a/backend/src/modules/auth/local-auth.guard.ts b/backend/src/modules/auth/local-auth.guard.ts new file mode 100644 index 0000000..ccf962b --- /dev/null +++ b/backend/src/modules/auth/local-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class LocalAuthGuard extends AuthGuard('local') {} diff --git a/backend/src/modules/auth/local.strategy.ts b/backend/src/modules/auth/local.strategy.ts new file mode 100644 index 0000000..7cca2c4 --- /dev/null +++ b/backend/src/modules/auth/local.strategy.ts @@ -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; + } +} diff --git a/backend/src/modules/auth/public.decorator.ts b/backend/src/modules/auth/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/backend/src/modules/auth/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/src/modules/auth/roles.decorator.ts b/backend/src/modules/auth/roles.decorator.ts new file mode 100644 index 0000000..d754667 --- /dev/null +++ b/backend/src/modules/auth/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../entities'; + +export const Roles = (...roles: UserRole[]) => SetMetadata('roles', roles); diff --git a/backend/src/modules/auth/roles.guard.ts b/backend/src/modules/auth/roles.guard.ts new file mode 100644 index 0000000..439f559 --- /dev/null +++ b/backend/src/modules/auth/roles.guard.ts @@ -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( + '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); + } +} diff --git a/backend/src/modules/auth/types.ts b/backend/src/modules/auth/types.ts new file mode 100644 index 0000000..b217186 --- /dev/null +++ b/backend/src/modules/auth/types.ts @@ -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; + }; +} diff --git a/backend/src/modules/comment/comment.controller.ts b/backend/src/modules/comment/comment.controller.ts new file mode 100644 index 0000000..00f5bc6 --- /dev/null +++ b/backend/src/modules/comment/comment.controller.ts @@ -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 { + return this.commentService.create(createCommentDto, req.user.id); + } + + @Get() + @Public() + async findAll( + @Query() findCommentsDto: FindCommentsDto, + ): Promise { + return this.commentService.findAll(findCommentsDto); + } + + @Get(':id') + @Public() + async findOne(@Param('id') id: string): Promise { + return this.commentService.findOne(id); + } + + @Put(':id') + @UseGuards(JwtAuthGuard) + async update( + @Param('id') id: string, + @Body() updateCommentDto: UpdateCommentDto, + @Request() req: RequestWithUser, + ): Promise { + 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 { + 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 { + 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 { + 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 { + return this.commentService.update( + id, + { isVisible: true }, + 'admin', + 'admin', + ); + } +} diff --git a/backend/src/modules/comment/comment.dto.ts b/backend/src/modules/comment/comment.dto.ts new file mode 100644 index 0000000..222a220 --- /dev/null +++ b/backend/src/modules/comment/comment.dto.ts @@ -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; +} diff --git a/backend/src/modules/comment/comment.module.ts b/backend/src/modules/comment/comment.module.ts new file mode 100644 index 0000000..e9e2211 --- /dev/null +++ b/backend/src/modules/comment/comment.module.ts @@ -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 {} diff --git a/backend/src/modules/comment/comment.service.ts b/backend/src/modules/comment/comment.service.ts new file mode 100644 index 0000000..8fb87db --- /dev/null +++ b/backend/src/modules/comment/comment.service.ts @@ -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, + @InjectRepository(Reaction) + private reactionRepository: Repository, + ) {} + + async create( + createCommentDto: CreateCommentDto, + userId: string, + ): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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, + }; + } +} diff --git a/backend/src/modules/entities.ts b/backend/src/modules/entities.ts index d8b3bf6..184a4e2 100644 --- a/backend/src/modules/entities.ts +++ b/backend/src/modules/entities.ts @@ -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; +} diff --git a/backend/src/modules/live-blog.controller.ts b/backend/src/modules/live-blog.controller.ts index 84f3e81..7a45474 100644 --- a/backend/src/modules/live-blog.controller.ts +++ b/backend/src/modules/live-blog.controller.ts @@ -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, diff --git a/backend/src/modules/users/user.controller.ts b/backend/src/modules/users/user.controller.ts new file mode 100644 index 0000000..6671517 --- /dev/null +++ b/backend/src/modules/users/user.controller.ts @@ -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 { + return this.userService.create(createUserDto); + } + + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async findAll(): Promise { + return this.userService.findAll(); + } + + @Get('profile') + @UseGuards(JwtAuthGuard) + async getProfile(@Request() req: RequestWithUser): Promise { + return this.userService.findOne(req.user.id); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async findOne(@Param('id') id: string): Promise { + return this.userService.findOne(id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async update( + @Param('id') id: string, + @Body() updateUserDto: UpdateUserDto, + ): Promise { + 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 { + 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 { + // 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 { + return this.userService.update(id, { isActive: false }); + } + + @Patch(':id/activate') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async activate(@Param('id') id: string): Promise { + return this.userService.update(id, { isActive: true }); + } +} diff --git a/backend/src/modules/users/user.dto.ts b/backend/src/modules/users/user.dto.ts new file mode 100644 index 0000000..8ce8f63 --- /dev/null +++ b/backend/src/modules/users/user.dto.ts @@ -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; +} diff --git a/backend/src/modules/users/user.module.ts b/backend/src/modules/users/user.module.ts new file mode 100644 index 0000000..0610d18 --- /dev/null +++ b/backend/src/modules/users/user.module.ts @@ -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 {} diff --git a/backend/src/modules/users/user.service.ts b/backend/src/modules/users/user.service.ts new file mode 100644 index 0000000..2b1d9e0 --- /dev/null +++ b/backend/src/modules/users/user.service.ts @@ -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, + ) {} + + async create(createUserDto: CreateUserDto): Promise { + // 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 { + const users = await this.userRepository.find({ + order: { createdAt: 'DESC' }, + }); + return users.map((user) => new UserResponseDto(user)); + } + + async findOne(id: string): Promise { + 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 { + return this.userRepository.findOne({ where: { username } }); + } + + async findByEmail(email: string): Promise { + return this.userRepository.findOne({ where: { email } }); + } + + async update( + id: string, + updateUserDto: UpdateUserDto, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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'); + } + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5e5395d..9f455ca 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -24,9 +24,9 @@ services: dockerfile: Dockerfile.dev container_name: placebo-backend-dev env_file: - - ./backend/.env # Use the .env file for configuration + - ./backend/.env.docker # Use the .env.docker file for Docker configuration environment: - # Only override if needed, most config is in .env file + # Only override if needed, most config is in .env.docker file NODE_ENV: development ports: - "3000:3000" @@ -36,7 +36,7 @@ services: - ./backend/src:/app/src - ./backend/package.json:/app/package.json - ./backend/package-lock.json:/app/package-lock.json - - ./backend/.env:/app/.env # Mount .env file into container + - ./backend/.env.docker:/app/.env # Mount .env.docker file as .env in container command: npm run dev:docker networks: - placebo-network-dev diff --git a/docker-compose.yml b/docker-compose.yml index 2ea328d..74effe0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: image: postgres:16-alpine container_name: placebo-postgres environment: - POSTGRES_DB: placebo_db + POSTGRES_DB: placebo_backend_db POSTGRES_USER: placebo_user POSTGRES_PASSWORD: placebo_password volumes: @@ -15,7 +15,7 @@ services: ports: - "5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_db"] + test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_backend_db"] interval: 10s timeout: 5s retries: 5 @@ -28,20 +28,18 @@ services: context: ./backend dockerfile: Dockerfile container_name: placebo-backend - environment: - NODE_ENV: production - DATABASE_TYPE: postgres - DATABASE_HOST: postgres - DATABASE_PORT: 5432 - DATABASE_USERNAME: placebo_user - DATABASE_PASSWORD: placebo_password - DATABASE_NAME: placebo_db - DATABASE_SYNCHRONIZE: "false" - DATABASE_LOGGING: "false" - JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key-change-in-production} - JWT_EXPIRATION: 3600 - CORS_ORIGIN: http://localhost:5173,http://localhost:3001 - PORT: 3000 + environment: + NODE_ENV: production + DATABASE_TYPE: postgres + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + DATABASE_USERNAME: placebo_user + DATABASE_PASSWORD: placebo_password + DATABASE_NAME: placebo_backend_db + DATABASE_SYNCHRONIZE: "false" + DATABASE_LOGGING: "false" + CORS_ORIGIN: http://localhost:5173,http://localhost:3001 + PORT: 3000 ports: - "3000:3000" depends_on: diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..a1bb63b --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import { useAuth } from '../../contexts/AuthContext'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; +import { Label } from '../ui/label'; + +interface LoginFormProps { + onSuccess?: () => void; + onSwitchToRegister?: () => void; +} + +export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) { + const { login, isLoading } = useAuth(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + try { + await login(username, password); + onSuccess?.(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } + }; + + return ( + + + Login + + Enter your credentials to access your account + + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + placeholder="Enter your username" + required + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + disabled={isLoading} + /> +
+ + + + {onSwitchToRegister && ( +
+ Don't have an account?{' '} + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..8806dc9 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Navigate } from '@tanstack/react-router'; +import { useAuth } from '../../contexts/AuthContext'; + +interface ProtectedRouteProps { + children: React.ReactNode; + requiredRole?: 'admin' | 'contributor' | 'user'; + redirectTo?: string; +} + +export function ProtectedRoute({ + children, + requiredRole, + redirectTo = '/' +}: ProtectedRouteProps) { + const { isAuthenticated, user, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (requiredRole && user?.role !== requiredRole) { + return ; + } + + return <>{children}; +} \ No newline at end of file diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..a865811 --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { useAuth } from '../../contexts/AuthContext'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; +import { Label } from '../ui/label'; + +interface RegisterFormProps { + onSuccess?: () => void; + onSwitchToLogin?: () => void; +} + +export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) { + const { register, isLoading } = useAuth(); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + try { + await register(username, email, password); + onSuccess?.(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed'); + } + }; + + return ( + + + Register + + Create a new account to start commenting and reacting + + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + placeholder="Choose a username" + required + disabled={isLoading} + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="Enter your email" + required + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Choose a password (min 6 characters)" + required + disabled={isLoading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + required + disabled={isLoading} + /> +
+ + + + {onSwitchToLogin && ( +
+ Already have an account?{' '} + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/features/comments/CommentSection.tsx b/frontend/src/components/features/comments/CommentSection.tsx new file mode 100644 index 0000000..695e795 --- /dev/null +++ b/frontend/src/components/features/comments/CommentSection.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { useAuth } from '../../../contexts/AuthContext'; +import { useComments, useCreateComment } from '../../../queries/comments'; +import { Button } from '../../ui/button'; +import { Textarea } from '../../ui/textarea'; +import { Card, CardContent } from '../../ui/card'; +import { format } from 'date-fns'; +import { mk } from 'date-fns/locale'; + +interface CommentSectionProps { + articleId?: string; + liveBlogId?: string; +} + +export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) { + const { isAuthenticated } = useAuth(); + const [newComment, setNewComment] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data: commentsData, isLoading } = useComments({ + articleId, + liveBlogId, + limit: 50, + }); + + const createCommentMutation = useCreateComment(); + + const comments = commentsData?.data || []; + + const handleSubmitComment = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!newComment.trim() || !isAuthenticated) return; + + setIsSubmitting(true); + try { + await createCommentMutation.mutateAsync({ + content: newComment, + articleId, + liveBlogId, + }); + setNewComment(''); + } catch (error) { + console.error('Failed to post comment:', error); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+

Коментари

+ + {isAuthenticated ? ( + + +
+