From 0768b8d4db6fcca1656c4b0ff4d05f784c8302b4 Mon Sep 17 00:00:00 2001 From: Son Thien Nguyen Date: Mon, 9 Mar 2026 16:00:53 +0100 Subject: [PATCH] feat(backend): account deletion, admin user mgmt, activity logging --- .gitignore | 4 + backend/src/auth/auth.service.ts | 2 +- backend/src/logs/logs.controller.ts | 37 ++++++++ backend/src/logs/logs.module.ts | 10 +- backend/src/logs/logs.service.ts | 57 ++++++++++++ backend/src/users/dto/update-role.dto.ts | 7 ++ backend/src/users/users.controller.ts | 74 ++++++++++++++- backend/src/users/users.module.ts | 3 +- backend/src/users/users.service.ts | 113 ++++++++++++++++++++++- 9 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 backend/src/logs/logs.controller.ts create mode 100644 backend/src/logs/logs.service.ts create mode 100644 backend/src/users/dto/update-role.dto.ts diff --git a/.gitignore b/.gitignore index 13c09a4..4ef69bd 100644 --- a/.gitignore +++ b/.gitignore @@ -332,3 +332,7 @@ planning/ #uploaded files backend/uploads/ + +# Allow NestJS source modules named 'logs' (overrides the Node 'logs' rule above) +!backend/src/logs/ +!backend/src/logs/** diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 06c9efd..3d3bd06 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -47,7 +47,7 @@ export class AuthService { where: { email: dto.email }, }); - if (!user) throw new UnauthorizedException('Invalid credentials'); + if (!user || user.deleted_at) throw new UnauthorizedException('Invalid credentials'); const valid = await bcrypt.compare(dto.password, user.password_hash); if (!valid) throw new UnauthorizedException('Invalid credentials'); diff --git a/backend/src/logs/logs.controller.ts b/backend/src/logs/logs.controller.ts new file mode 100644 index 0000000..9ec07e8 --- /dev/null +++ b/backend/src/logs/logs.controller.ts @@ -0,0 +1,37 @@ +import { + Controller, + Get, + Query, + UseGuards, +} from '@nestjs/common'; +import { LogsService } from './logs.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { LogLevel, Role } from '@prisma/client'; + +@Controller('logs') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.ADMIN) +export class LogsController { + constructor(private logsService: LogsService) {} + + /** + * R27: GET /api/logs + * Admin-only. Supports ?level=INFO|WARN|ERROR, ?userId=N, ?limit=N, ?offset=N + */ + @Get() + findAll( + @Query('level') level?: LogLevel, + @Query('userId') userId?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.logsService.findAll({ + level, + userId: userId ? parseInt(userId, 10) : undefined, + limit: limit ? parseInt(limit, 10) : 100, + offset: offset ? parseInt(offset, 10) : 0, + }); + } +} diff --git a/backend/src/logs/logs.module.ts b/backend/src/logs/logs.module.ts index 956d8d1..50780af 100644 --- a/backend/src/logs/logs.module.ts +++ b/backend/src/logs/logs.module.ts @@ -1,4 +1,12 @@ import { Module } from '@nestjs/common'; +import { LogsService } from './logs.service'; +import { LogsController } from './logs.controller'; +import { PrismaModule } from '../prisma/prisma.module'; -@Module({}) +@Module({ + imports: [PrismaModule], + controllers: [LogsController], + providers: [LogsService], + exports: [LogsService], +}) export class LogsModule {} diff --git a/backend/src/logs/logs.service.ts b/backend/src/logs/logs.service.ts new file mode 100644 index 0000000..c4a474c --- /dev/null +++ b/backend/src/logs/logs.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { LogLevel } from '@prisma/client'; + +@Injectable() +export class LogsService { + constructor(private prisma: PrismaService) {} + + /** + * Write a log entry. Call this from other services to record activity. + * userId is optional (e.g. for system-level events). + */ + async create(level: LogLevel, message: string, userId?: number) { + return this.prisma.log.create({ + data: { + level, + message, + user_id: userId ?? null, + }, + }); + } + + /** R27: Admin – retrieve all logs with optional filters */ + async findAll(opts?: { + level?: LogLevel; + userId?: number; + limit?: number; + offset?: number; + }) { + const { level, userId, limit = 100, offset = 0 } = opts ?? {}; + + const [logs, total] = await Promise.all([ + this.prisma.log.findMany({ + where: { + ...(level ? { level } : {}), + ...(userId ? { user_id: userId } : {}), + }, + include: { + user: { + select: { user_id: true, username: true, email: true }, + }, + }, + orderBy: { created_at: 'desc' }, + take: limit, + skip: offset, + }), + this.prisma.log.count({ + where: { + ...(level ? { level } : {}), + ...(userId ? { user_id: userId } : {}), + }, + }), + ]); + + return { total, limit, offset, logs }; + } +} diff --git a/backend/src/users/dto/update-role.dto.ts b/backend/src/users/dto/update-role.dto.ts new file mode 100644 index 0000000..1b4bf7a --- /dev/null +++ b/backend/src/users/dto/update-role.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { Role } from '@prisma/client'; + +export class UpdateRoleDto { + @IsEnum(Role) + role: Role; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 80e2061..def722a 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,15 +1,34 @@ -import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Patch, + Delete, + Post, + Body, + Param, + Query, + ParseIntPipe, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; import { UsersService } from './users.service'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; import { GetUser } from '../auth/decorators/get-user.decorator'; import type { JwtPayload } from '../auth/decorators/get-user.decorator'; +import { Role } from '@prisma/client'; @Controller('users') @UseGuards(JwtAuthGuard) export class UsersController { constructor(private usersService: UsersService) {} + // ─── Self-service (any authenticated user) ──────────────────────────────── + // R29 — View own profile @Get('me') getProfile(@GetUser() user: JwtPayload) { @@ -24,4 +43,57 @@ export class UsersController { ) { return this.usersService.updateProfile(user.sub, dto); } + + // R23 — Delete own account + @Delete('me') + @HttpCode(HttpStatus.OK) + deleteAccount(@GetUser() user: JwtPayload) { + return this.usersService.deleteAccount(user.sub); + } + + // ─── Admin endpoints (ADMIN role only) ───────────────────────────── + + // R25 — List all users + @Get() + @UseGuards(RolesGuard) + @Roles(Role.ADMIN) + findAll(@Query('search') search?: string) { + return this.usersService.findAll(search); + } + + // R25 — Get user by ID + @Get(':id') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.usersService.findOne(id); + } + + // R25 — Update user role + @Patch(':id/role') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN) + updateRole( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateRoleDto, + ) { + return this.usersService.updateRole(id, dto.role); + } + + // R25 — Deactivate user (soft-delete) + @Delete(':id') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN) + @HttpCode(HttpStatus.OK) + deactivateUser(@Param('id', ParseIntPipe) id: number) { + return this.usersService.deactivateUser(id); + } + + // R25 — Reactivate user + @Post(':id/reactivate') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN) + reactivateUser(@Param('id', ParseIntPipe) id: number) { + return this.usersService.reactivateUser(id); + } } diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 6236366..b6c8ea2 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -3,9 +3,10 @@ import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; +import { LogsModule } from '../logs/logs.module'; @Module({ - imports: [PrismaModule, AuthModule], + imports: [PrismaModule, AuthModule, LogsModule], providers: [UsersService], controllers: [UsersController], exports: [UsersService], diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 5b22b21..3459ba1 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -6,12 +6,17 @@ import { UnauthorizedException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { LogsService } from '../logs/logs.service'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { Role, LogLevel } from '@prisma/client'; import * as bcrypt from 'bcrypt'; @Injectable() export class UsersService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private logs: LogsService, + ) {} // R29 — Get own profile async getProfile(userId: number) { @@ -81,4 +86,110 @@ export class UsersService { }, }); } + + // ─── R23: Delete own account (soft delete) ──────────────────────────────── + + async deleteAccount(userId: number) { + const user = await this.prisma.user.findUnique({ where: { user_id: userId } }); + if (!user) throw new NotFoundException('User not found'); + if (user.deleted_at) throw new ConflictException('Account already deleted'); + + await this.prisma.user.update({ + where: { user_id: userId }, + data: { deleted_at: new Date() }, + }); + + await this.logs.create(LogLevel.INFO, `User ${userId} deleted their account`, userId); + return { message: 'Account deleted successfully' }; + } + + // ─── R25: Admin – list all users ────────────────────────────────────────── + + async findAll(search?: string) { + return this.prisma.user.findMany({ + where: search + ? { + OR: [ + { username: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + ], + } + : undefined, + select: { + user_id: true, + username: true, + email: true, + role: true, + created_at: true, + deleted_at: true, + }, + orderBy: { created_at: 'desc' }, + }); + } + + // ─── R25: Admin – get single user ───────────────────────────────────────── + + async findOne(userId: number) { + const user = await this.prisma.user.findUnique({ + where: { user_id: userId }, + select: { + user_id: true, + username: true, + email: true, + role: true, + created_at: true, + deleted_at: true, + _count: { select: { events: true, registrations: true } }, + }, + }); + if (!user) throw new NotFoundException('User not found'); + return user; + } + + // ─── R25: Admin – update user role ──────────────────────────────────────── + + async updateRole(userId: number, role: Role) { + const user = await this.prisma.user.findUnique({ where: { user_id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + const updated = await this.prisma.user.update({ + where: { user_id: userId }, + data: { role }, + select: { user_id: true, username: true, email: true, role: true }, + }); + + await this.logs.create(LogLevel.INFO, `Admin changed user ${userId} role to ${role}`); + return updated; + } + + // ─── R25: Admin – deactivate user ───────────────────────────────────────── + + async deactivateUser(userId: number) { + const user = await this.prisma.user.findUnique({ where: { user_id: userId } }); + if (!user) throw new NotFoundException('User not found'); + if (user.deleted_at) throw new ConflictException('User already deactivated'); + + await this.prisma.user.update({ + where: { user_id: userId }, + data: { deleted_at: new Date() }, + }); + + await this.logs.create(LogLevel.WARN, `Admin deactivated user ${userId}`); + return { message: 'User deactivated successfully' }; + } + + // ─── R25: Admin – reactivate user ───────────────────────────────────────── + + async reactivateUser(userId: number) { + const user = await this.prisma.user.findUnique({ where: { user_id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + await this.prisma.user.update({ + where: { user_id: userId }, + data: { deleted_at: null }, + }); + + await this.logs.create(LogLevel.INFO, `Admin reactivated user ${userId}`); + return { message: 'User reactivated successfully' }; + } }