diff --git a/dictation_server/src/api/odms/openapi.json b/dictation_server/src/api/odms/openapi.json index c1b24bf..6940f83 100644 --- a/dictation_server/src/api/odms/openapi.json +++ b/dictation_server/src/api/odms/openapi.json @@ -1717,6 +1717,61 @@ "security": [{ "bearer": [] }] } }, + "/accounts/partner/delete": { + "post": { + "operationId": "deletePartnerAccount", + "summary": "", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletePartnerAccountRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功時のレスポンス", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletePartnerAccountResponse" + } + } + } + }, + "400": { + "description": "実施者との親子関係不正や下位アカウント存在など削除実施条件に合致しない", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "401": { + "description": "認証エラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "500": { + "description": "想定外のサーバーエラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + }, + "tags": ["accounts"], + "security": [{ "bearer": [] }] + } + }, "/users/confirm": { "post": { "operationId": "confirmUser", @@ -4611,6 +4666,17 @@ "required": ["to", "children"] }, "SwitchParentResponse": { "type": "object", "properties": {} }, + "DeletePartnerAccountRequest": { + "type": "object", + "properties": { + "targetAccountId": { + "type": "number", + "description": "削除対象のアカウントID" + } + }, + "required": ["targetAccountId"] + }, + "DeletePartnerAccountResponse": { "type": "object", "properties": {} }, "ConfirmRequest": { "type": "object", "properties": { "token": { "type": "string" } }, diff --git a/dictation_server/src/features/accounts/accounts.controller.spec.ts b/dictation_server/src/features/accounts/accounts.controller.spec.ts index 0ccef3d..3b89aef 100644 --- a/dictation_server/src/features/accounts/accounts.controller.spec.ts +++ b/dictation_server/src/features/accounts/accounts.controller.spec.ts @@ -3,7 +3,10 @@ import { AccountsController } from './accounts.controller'; import { AccountsService } from './accounts.service'; import { ConfigModule } from '@nestjs/config'; import { AuthService } from '../auth/auth.service'; -import { SwitchParentRequest } from './types/types'; +import { + SwitchParentRequest, + DeletePartnerAccountRequest, +} from './types/types'; import { plainToClass } from 'class-transformer'; import { validate } from 'class-validator'; @@ -70,4 +73,60 @@ describe('AccountsController', () => { expect(errors.length).toBe(1); }); }); + + describe('valdation deletePartnerAccount', () => { + it('最低限の有効なリクエストが成功する', async () => { + const request = new DeletePartnerAccountRequest(); + request.targetAccountId = 1; + + const valdationObject = plainToClass( + DeletePartnerAccountRequest, + request, + ); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(0); + }); + + it('削除対象アカウントが指定されていない場合、リクエストが失敗する', async () => { + const request = new DeletePartnerAccountRequest(); + + const valdationObject = plainToClass( + DeletePartnerAccountRequest, + request, + ); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + + it('削除対象アカウントが0の場合、リクエストが失敗する', async () => { + const request = new DeletePartnerAccountRequest(); + request.targetAccountId = 0; + + const valdationObject = plainToClass( + DeletePartnerAccountRequest, + request, + ); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + + it('削除対象アカウントが文字列(数値以外)の場合、リクエストが失敗する', async () => { + class DeletePartnerAccountRequestString { + targetAccountId: string; + } + const request = new DeletePartnerAccountRequestString(); + request.targetAccountId = 'a'; + + const valdationObject = plainToClass( + DeletePartnerAccountRequest, + request, + ); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + }); }); diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 3cb553a..29f63b5 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -79,6 +79,8 @@ import { UpdateRestrictionStatusResponse, SwitchParentRequest, SwitchParentResponse, + DeletePartnerAccountRequest, + DeletePartnerAccountResponse, } from './types/types'; import { USER_ROLES, ADMIN_ROLES, TIERS } from '../../constants'; import { AuthGuard } from '../../common/guards/auth/authguards'; @@ -2401,4 +2403,82 @@ export class AccountsController { return {}; } + + @Post('partner/delete') + @ApiResponse({ + status: HttpStatus.OK, + type: DeletePartnerAccountResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: '認証エラー', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: + '実施者との親子関係不正や下位アカウント存在など削除実施条件に合致しない', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ operationId: 'deletePartnerAccount' }) + @ApiBearerAuth() + @UseGuards(AuthGuard) + @UseGuards( + RoleGuard.requireds({ + roles: [ADMIN_ROLES.ADMIN], + tiers: [TIERS.TIER1, TIERS.TIER2, TIERS.TIER3], + }), + ) + async deletePartnerAccount( + @Req() req: Request, + @Body() body: DeletePartnerAccountRequest, + ): Promise { + const { targetAccountId } = body; + + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + + const ip = retrieveIp(req); + if (!ip) { + throw new HttpException( + makeErrorResponse('E000401'), + HttpStatus.UNAUTHORIZED, + ); + } + + const requestId = retrieveRequestId(req); + if (!requestId) { + throw new HttpException( + makeErrorResponse('E000501'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; + + const context = makeContext(userId, requestId); + this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`); + + // TODO:service層を呼び出す。本実装時に以下は削除する。 + // await this.accountService.deletePartnerAccount(context, userId, targetAccountId); + + return {}; + } } diff --git a/dictation_server/src/features/accounts/types/types.ts b/dictation_server/src/features/accounts/types/types.ts index 110d25d..2ba21c6 100644 --- a/dictation_server/src/features/accounts/types/types.ts +++ b/dictation_server/src/features/accounts/types/types.ts @@ -390,6 +390,14 @@ export class SwitchParentRequest { children: number[]; } +export class DeletePartnerAccountRequest { + @ApiProperty({ description: '削除対象のアカウントID' }) + @Type(() => Number) + @IsInt() + @Min(1) + targetAccountId: number; +} + // ============================== // RESPONSE // ============================== @@ -709,6 +717,8 @@ export class UpdateRestrictionStatusResponse {} export class SwitchParentResponse {} +export class DeletePartnerAccountResponse {} + // ============================== // Request/Response外の型 // TODO: Request/Response/その他の型を別ファイルに分ける