Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
2 changes: 1 addition & 1 deletion backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
37 changes: 37 additions & 0 deletions backend/src/logs/logs.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
10 changes: 9 additions & 1 deletion backend/src/logs/logs.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
57 changes: 57 additions & 0 deletions backend/src/logs/logs.service.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
7 changes: 7 additions & 0 deletions backend/src/users/dto/update-role.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { Role } from '@prisma/client';

export class UpdateRoleDto {
@IsEnum(Role)
role: Role;
}
74 changes: 73 additions & 1 deletion backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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);
}
}
3 changes: 2 additions & 1 deletion backend/src/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
113 changes: 112 additions & 1 deletion backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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' };
}
}