Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ForbiddenException } from '@nestjs/common';

export class LastAdminDeleteException extends ForbiddenException {
constructor() {
super('Cannot delete the last admin in the system');
this.name = 'LastAdminDeleteException';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ForbiddenException } from '@nestjs/common';

export class UserDeleteForbiddenException extends ForbiddenException {
constructor() {
super('You can only delete your own account');
this.name = 'UserDeleteForbiddenException';
}
}
15 changes: 15 additions & 0 deletions backend/src/modules/users/dto/delete-user-success.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';

export class DeleteUserSuccessDto {
@ApiProperty({
description: 'Indicates if the operation was successful',
example: true,
})
success: boolean;

@ApiProperty({
description: 'Success message with user email',
example: 'User [email protected] has been deleted successfully',
})
message: string;
}
27 changes: 27 additions & 0 deletions backend/src/modules/users/types/user-operations.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Role } from '@prisma/client';

/**
* Type definition for user deletion operation parameters
*
* Provides type safety for the complex user deletion process
* that involves multiple related entities and database operations.
*/
export interface DeleteUserOperation {
userId: string;
userEmail: string;
userRole: Role;
studentId?: string;
supervisorId?: string;
}

/**
* Type definition for user deletion operation results
*
* Contains statistics about what was deleted/modified during
* the user deletion process for logging and audit purposes.
*/
export interface DeleteUserResult {
deletedTagsCount: number;
deletedBlocksCount: number;
profileUpdated: boolean;
}
29 changes: 19 additions & 10 deletions backend/src/modules/users/users.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ describe('UsersController', () => {

const USER_UUID = '123e4567-e89b-12d3-a456-426614174000';
const USER_UUID_2 = '123e4567-e89b-12d3-a456-426614174001';
const USER_UUID_3 = '123e4567-e89b-12d3-a456-426614174003';
const ADMIN_UUID = '123e4567-e89b-12d3-a456-426614174002';
const TAG_UUID_1 = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
const TAG_UUID_2 = 'a1b2c3d4-e5f6-7890-1234-567890abcdef';
const CLERK_ID = 'user_2NUj8tGhSFhTLD9sdP0q4P7VoJM';
Expand All @@ -56,7 +58,7 @@ describe('UsersController', () => {

const mockAdmin: User = {
...mockUser,
id: 'admin-id',
id: ADMIN_UUID,
email: '[email protected]',
role: Role.ADMIN,
};
Expand Down Expand Up @@ -235,9 +237,16 @@ describe('UsersController', () => {

describe('deleteUser', () => {
it('should delete a user', async () => {
mockUsersService.deleteUser.mockResolvedValue(mockUser);
await controller.deleteUser(USER_UUID);
expect(mockUsersService.deleteUser).toHaveBeenCalledWith(USER_UUID);
const mockDeleteResult = {
success: true,
message: 'User has been deleted successfully',
};
mockUsersService.deleteUser.mockResolvedValue(mockDeleteResult);

const result = await controller.deleteUser(USER_UUID, mockUser);

expect(result).toEqual(mockDeleteResult);
expect(mockUsersService.deleteUser).toHaveBeenCalledWith(USER_UUID, mockUser);
});
});

Expand Down Expand Up @@ -340,12 +349,12 @@ describe('UsersController', () => {

it("should throw UnauthorizedException when a student tries to view another student's blocked supervisors", async () => {
// Arrange
const otherStudentId = 'other-student-id'; // This is different from mockUser.id (USER_UUID)
const otherStudentUserId = USER_UUID_3;
const currentUser = mockUser; // Student with id USER_UUID

try {
// Act
await controller.findBlockedSupervisorsByStudentUserId(otherStudentId, currentUser);
await controller.findBlockedSupervisorsByStudentUserId(otherStudentUserId, currentUser);
// If we get here, fail the test
fail('Expected an UnauthorizedException to be thrown');
} catch (error) {
Expand Down Expand Up @@ -399,13 +408,13 @@ describe('UsersController', () => {

it('should throw UnauthorizedException when a student tries to block a supervisor on behalf of another student', async () => {
// Arrange
const otherStudentId = 'other-student-id'; // This is different from mockUser.id (USER_UUID)
const otherStudentUserId = USER_UUID_3;
const dto: CreateUserBlockDto = { blocked_id: USER_UUID_2 };
const currentUser = mockUser; // Student with id USER_UUID

try {
// Act
await controller.createUserBlock(otherStudentId, dto, currentUser);
await controller.createUserBlock(otherStudentUserId, dto, currentUser);
// If we get here, fail the test
fail('Expected an UnauthorizedException to be thrown');
} catch (error) {
Expand Down Expand Up @@ -464,13 +473,13 @@ describe('UsersController', () => {

it('should throw UnauthorizedException when a student tries to unblock a supervisor on behalf of another student', async () => {
// Arrange
const otherStudentId = 'other-student-id';
const otherStudentUserId = USER_UUID_3;
const supervisorUserId = USER_UUID_2;
const currentUser = mockUser; // Student

// Act & Assert
await expect(
controller.removeUserBlock(otherStudentId, supervisorUserId, currentUser),
controller.removeUserBlock(otherStudentUserId, supervisorUserId, currentUser),
).rejects.toThrow(UnauthorizedException);
expect(mockUsersService.deleteUserBlock).not.toHaveBeenCalled();
});
Expand Down
23 changes: 17 additions & 6 deletions backend/src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { DeleteUserSuccessDto } from './dto/delete-user-success.dto';
import {
ApiTags,
ApiOperation,
Expand Down Expand Up @@ -396,12 +397,11 @@ export class UsersController {
}

@Delete(':id')
@Roles(Role.ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Delete user (Soft Delete)',
description:
'Soft delete a user. Preserves data but marks user as deleted and they will no longer appear in regular queries.',
'Soft delete a user. Users can delete their own accounts, admins can delete any user. The last admin cannot delete themselves.',
})
@ApiParam({
name: 'id',
Expand All @@ -411,11 +411,22 @@ export class UsersController {
required: true,
example: '123e4567-e89b-12d3-a456-426614174000',
})
@ApiResponse({ status: 204, description: 'User has been successfully deleted.' })
@ApiResponse({
status: 200,
description: 'User has been successfully deleted.',
type: DeleteUserSuccessDto,
})
@ApiResponse({ status: 404, description: 'User not found.' })
@ApiResponse({ status: 400, description: 'Bad request - Invalid User ID format.' })
deleteUser(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
return this.usersService.deleteUser(id);
@ApiResponse({
status: 403,
description: 'Forbidden - Not authorized to delete this user or last admin.',
})
deleteUser(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() currentUser: User,
): Promise<DeleteUserSuccessDto> {
return this.usersService.deleteUser(id, currentUser);
}

/**
Expand Down
Loading