From c1291a5ecfdf556bf17a4cde1399e9c6454e57f1 Mon Sep 17 00:00:00 2001 From: Milos Date: Mon, 7 Oct 2024 21:48:55 +0200 Subject: [PATCH 1/2] feature/hard-delete-user: added hard delete user ability --- .../1728062638428-users-relations.ts | 35 +++++++++++++++++++ .../CC Portal develop.postman_collection.json | 34 ++++++++++++++++++ .../governance/entities/rationale.entity.ts | 7 ++++ .../src/governance/entities/vote.entity.ts | 7 ++++ .../users/api/request/remove-user.request.ts | 12 +++++++ backend/src/users/api/users.controller.ts | 34 ++++++++++++++++++ .../src/users/entities/hotaddress.entity.ts | 4 ++- backend/src/users/entities/user.entity.ts | 12 +++++++ backend/src/users/facade/users.facade.ts | 17 +++++++++ backend/src/users/services/users.service.ts | 5 +++ 10 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/1728062638428-users-relations.ts create mode 100644 backend/src/users/api/request/remove-user.request.ts diff --git a/backend/migrations/1728062638428-users-relations.ts b/backend/migrations/1728062638428-users-relations.ts new file mode 100644 index 00000000..2ca1c5f0 --- /dev/null +++ b/backend/migrations/1728062638428-users-relations.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UsersRelations1728062638428 implements MigrationInterface { + name = 'UsersRelations1728062638428'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hot_addresses" DROP CONSTRAINT "FK_e125763f26d4736a5701f6c4d4b"`, + ); + await queryRunner.query( + `ALTER TABLE "hot_addresses" ADD CONSTRAINT "FK_e125763f26d4736a5701f6c4d4b" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "votes" ADD CONSTRAINT "FK_27be2cab62274f6876ad6a31641" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "rationales" ADD CONSTRAINT "FK_182656ae5052a7efd72a02c64e9" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "rationales" DROP CONSTRAINT "FK_182656ae5052a7efd72a02c64e9"`, + ); + await queryRunner.query( + `ALTER TABLE "votes" DROP CONSTRAINT "FK_27be2cab62274f6876ad6a31641"`, + ); + await queryRunner.query( + `ALTER TABLE "hot_addresses" DROP CONSTRAINT "FK_e125763f26d4736a5701f6c4d4b"`, + ); + await queryRunner.query( + `ALTER TABLE "hot_addresses" ADD CONSTRAINT "FK_e125763f26d4736a5701f6c4d4b" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/backend/postman/CC Portal develop.postman_collection.json b/backend/postman/CC Portal develop.postman_collection.json index 803bc80a..90b6489d 100644 --- a/backend/postman/CC Portal develop.postman_collection.json +++ b/backend/postman/CC Portal develop.postman_collection.json @@ -244,6 +244,40 @@ } }, "response": [] + }, + { + "name": "Delete User", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "{{accessToken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"user_id\": \"a132f533-875f-466a-b194-79f4afe0937b\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base-url}}/api/users/{{userId}}", + "host": [ + "{{base-url}}" + ], + "path": [ + "api", + "users", + "{{userId}}" + ] + } + }, + "response": [] } ] }, diff --git a/backend/src/governance/entities/rationale.entity.ts b/backend/src/governance/entities/rationale.entity.ts index 2caab91f..80e0c7ca 100644 --- a/backend/src/governance/entities/rationale.entity.ts +++ b/backend/src/governance/entities/rationale.entity.ts @@ -8,10 +8,17 @@ import { Unique, } from 'typeorm'; import { GovActionProposal } from './gov-action-proposal.entity'; +import { User } from '../../users/entities/user.entity'; @Entity('rationales') @Unique(['userId', 'govActionProposalId']) export class Rationale extends CommonEntity { + @ManyToOne(() => User, (user) => user.rationales, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'user_id' }) + user: User; + @PrimaryColumn({ name: 'user_id', type: 'uuid', diff --git a/backend/src/governance/entities/vote.entity.ts b/backend/src/governance/entities/vote.entity.ts index a307436b..c609c3dc 100644 --- a/backend/src/governance/entities/vote.entity.ts +++ b/backend/src/governance/entities/vote.entity.ts @@ -8,6 +8,7 @@ import { PrimaryColumn, } from 'typeorm'; import { GovActionProposal } from './gov-action-proposal.entity'; +import { User } from '../../users/entities/user.entity'; @Entity('votes') export class Vote extends CommonEntity { @@ -24,6 +25,12 @@ export class Vote extends CommonEntity { }) userId: string; + @ManyToOne(() => User, (user) => user.votes, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'user_id' }) + user: User; + @Column({ name: 'hot_address', type: 'varchar', diff --git a/backend/src/users/api/request/remove-user.request.ts b/backend/src/users/api/request/remove-user.request.ts new file mode 100644 index 00000000..3a140c0f --- /dev/null +++ b/backend/src/users/api/request/remove-user.request.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class RemoveUserRequest { + @ApiProperty({ + description: 'Identification number of the user', + example: '82dbbfb1-2552-4aaf-a9a7-1195497410c0', + name: 'user_id', + }) + @IsUUID() + userId: string; +} diff --git a/backend/src/users/api/users.controller.ts b/backend/src/users/api/users.controller.ts index 9c086d03..12cb0dd4 100644 --- a/backend/src/users/api/users.controller.ts +++ b/backend/src/users/api/users.controller.ts @@ -13,6 +13,7 @@ import { UseGuards, Request, Delete, + BadRequestException, } from '@nestjs/common'; import { UsersFacade } from '../facade/users.facade'; import { UpdateUserRequest } from './request/update-user.request'; @@ -40,6 +41,8 @@ import { PermissionEnum } from '../enums/permission.enum'; import { PermissionGuard } from 'src/auth/guard/permission.guard'; import { ToggleStatusRequest } from './request/toggle-status.request'; import { ApiConditionalExcludeEndpoint } from 'src/common/decorators/api-conditional-exclude-endpoint.decorator'; +import { Permissions } from 'src/auth/guard/permission.decorator'; +import { RemoveUserRequest } from './request/remove-user.request'; @ApiTags('Users') @Controller('users') @@ -272,4 +275,35 @@ export class UsersController { permissions, ); } + + @ApiConditionalExcludeEndpoint() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete user' }) + @ApiResponse({ status: 200, description: 'User deleted successfully' }) + @ApiResponse({ status: 400, description: 'Bad requset' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Not found' }) + @ApiParam({ + name: 'id', + type: String, + description: 'identifactor of user', + }) + @ApiBody({ type: RemoveUserRequest }) + @HttpCode(200) + @Permissions(PermissionEnum.MANAGE_ADMINS) // Superadmin only + @UseGuards(JwtAuthGuard, UserPathGuard, PermissionGuard) + @Delete(':id') + async removeUser( + @Param('id', ParseUUIDPipe) id: string, + @Body() removeUserRequest: RemoveUserRequest, + ) { + if (id === removeUserRequest.userId) { + throw new BadRequestException(`You cannot delete yourself`); + } + await this.usersFacade.removeUser(removeUserRequest.userId); + return { + success: true, + message: 'User deleted successfully', + }; + } } diff --git a/backend/src/users/entities/hotaddress.entity.ts b/backend/src/users/entities/hotaddress.entity.ts index 8b29238e..c226eb66 100644 --- a/backend/src/users/entities/hotaddress.entity.ts +++ b/backend/src/users/entities/hotaddress.entity.ts @@ -20,7 +20,9 @@ export class HotAddress extends CommonEntity { }) address: string; - @ManyToOne(() => User, (user) => user.hotAddresses) + @ManyToOne(() => User, (user) => user.hotAddresses, { + onDelete: 'CASCADE', + }) @JoinColumn({ name: 'user_id' }) user: User; } diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index e636a57a..6cbecf6d 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -14,6 +14,8 @@ import { CommonEntity } from '../../common/entities/common.entity'; import { Permission } from './permission.entity'; import { HotAddress } from './hotaddress.entity'; import { UserStatusEnum } from '../enums/user-status.enum'; +import { Rationale } from '../../governance/entities/rationale.entity'; +import { Vote } from '../../governance/entities/vote.entity'; @Entity('users') export class User extends CommonEntity { @@ -65,6 +67,16 @@ export class User extends CommonEntity { }) hotAddresses: HotAddress[]; + @OneToMany(() => Rationale, (rationale) => rationale.user, { + cascade: true, + }) + rationales: Rationale[]; + + @OneToMany(() => Vote, (vote) => vote.user, { + cascade: true, + }) + votes: Vote[]; + @Index('users_role_id_idx') @ManyToOne(() => Role, (role) => role.users, { eager: true, diff --git a/backend/src/users/facade/users.facade.ts b/backend/src/users/facade/users.facade.ts index 947d40c4..496fb4ca 100644 --- a/backend/src/users/facade/users.facade.ts +++ b/backend/src/users/facade/users.facade.ts @@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, Injectable, + Logger, } from '@nestjs/common'; import { UpdateUserRequest } from '../api/request/update-user.request'; import { UsersService } from '../services/users.service'; @@ -21,6 +22,7 @@ import { ToggleStatusRequest } from '../api/request/toggle-status.request'; import { UserStatusEnum } from '../enums/user-status.enum'; @Injectable() export class UsersFacade { + private logger = new Logger(UsersService.name); constructor( private readonly usersService: UsersService, private readonly s3Service: S3Service, @@ -112,4 +114,19 @@ export class UsersFacade { ); return UserMapper.mapUserDtoToResponse(result); } + + async removeUser(userId: string): Promise { + const userDto = await this.usersService.findById(userId); + try { + await this.usersService.removeUser(userDto.id); + if (userDto.profilePhotoUrl) { + const fileName = S3Service.extractFileNameFromUrl( + userDto.profilePhotoUrl, + ); + await this.s3Service.deleteFile(fileName); + } + } catch (e) { + this.logger.error(`Error when removing user: ${e.message}`); + } + } } diff --git a/backend/src/users/services/users.service.ts b/backend/src/users/services/users.service.ts index 4f926f9f..cc190992 100644 --- a/backend/src/users/services/users.service.ts +++ b/backend/src/users/services/users.service.ts @@ -287,4 +287,9 @@ export class UsersService { throw new ForbiddenException(`You have no permission for this action`); } } + + async removeUser(userId: string): Promise { + const user = await this.findEntityById(userId); + await this.userRepository.remove(user); + } } From 7f5a41539a6546a2767d4c9ec446ee03984571eb Mon Sep 17 00:00:00 2001 From: Milos Date: Tue, 8 Oct 2024 11:37:56 +0200 Subject: [PATCH 2/2] feature/hard-delete-user: updated swagger --- backend/src/users/api/users.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/users/api/users.controller.ts b/backend/src/users/api/users.controller.ts index 12cb0dd4..71f851c3 100644 --- a/backend/src/users/api/users.controller.ts +++ b/backend/src/users/api/users.controller.ts @@ -282,6 +282,7 @@ export class UsersController { @ApiResponse({ status: 200, description: 'User deleted successfully' }) @ApiResponse({ status: 400, description: 'Bad requset' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden resource' }) @ApiResponse({ status: 404, description: 'Not found' }) @ApiParam({ name: 'id',