From 114ded790e3a8995cdfbb7ac35d8c1c67a1d060a Mon Sep 17 00:00:00 2001 From: "maruyama.t" Date: Tue, 26 Mar 2024 06:22:07 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20855:=20API=20IF=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88=E3=83=91=E3=83=BC=E3=83=88=E3=83=8A=E3=83=BC=E3=82=92?= =?UTF-8?q?=E7=B7=A8=E9=9B=86=E3=81=97=E3=81=9F=E3=81=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task3930: API IF実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3930) - 元PBI or タスクへのリンク(内容・目的などはそちらにあるはず) - 新規追加API2本のIFを作成、controllerの返却値は仮実装(別タスクで実装) - 影響範囲(他の機能にも影響があるか)  新規追加のみなので影響はなし ## レビューポイント - 特筆する点はありません ## UIの変更 なし ## クエリの変更 なし ## 動作確認状況 - ローカルで確認  バリデーションテストとPOSTMANからの起動の確認 - 行った修正がデグレを発生させていないことを確認できるか - 具体的にどのような確認をしたか - どのケースに対してどのような手段でデグレがないことを担保しているか 完全新規のIFの実装のみなのでデグレはない想定 ## 補足 - 相談、参考資料などがあれば --- dictation_server/src/api/odms/openapi.json | 161 ++++++++++++++++ .../accounts/accounts.controller.spec.ts | 148 +++++++++++++++ .../features/accounts/accounts.controller.ts | 173 ++++++++++++++++++ .../src/features/accounts/types/types.ts | 58 ++++++ 4 files changed, 540 insertions(+) diff --git a/dictation_server/src/api/odms/openapi.json b/dictation_server/src/api/odms/openapi.json index 6940f83..1d10bdb 100644 --- a/dictation_server/src/api/odms/openapi.json +++ b/dictation_server/src/api/odms/openapi.json @@ -1772,6 +1772,118 @@ "security": [{ "bearer": [] }] } }, + "/accounts/partner/users": { + "post": { + "operationId": "getPartnerUsers", + "summary": "", + "description": "パートナーアカウントのユーザー情報を取得します(開発規約に基づき、他のAPIと合わせてGETではなくPOSTを使用)", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetPartnerUsersRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功時のレスポンス", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetPartnerUsersResponse" + } + } + } + }, + "400": { + "description": "パラメータ不正/API実行者と取得対象が親子関係ではない", + "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": [] }] + } + }, + "/accounts/partner/update": { + "post": { + "operationId": "updatePartnerInfo", + "summary": "", + "description": "パートナーアカウントの情報を更新します", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePartnerInfoRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功時のレスポンス", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePartnerInfoResponse" + } + } + } + }, + "400": { + "description": "パラメータ不正/API実行者と取得対象が親子関係ではない/アカウントが不在/プライマリ管理者が同一アカウント内にいない", + "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", @@ -4677,6 +4789,55 @@ "required": ["targetAccountId"] }, "DeletePartnerAccountResponse": { "type": "object", "properties": {} }, + "GetPartnerUsersRequest": { + "type": "object", + "properties": { + "targetAccountId": { + "type": "number", + "description": "取得対象のアカウントID" + } + }, + "required": ["targetAccountId"] + }, + "PartnerUser": { + "type": "object", + "properties": { + "id": { "type": "number", "description": "ユーザーID" }, + "name": { "type": "string", "description": "ユーザー名" }, + "email": { "type": "string", "description": "メールアドレス" }, + "isPrimaryAdmin": { + "type": "boolean", + "description": "プライマリ管理者かどうか" + } + }, + "required": ["id", "name", "email", "isPrimaryAdmin"] + }, + "GetPartnerUsersResponse": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { "$ref": "#/components/schemas/PartnerUser" } + } + }, + "required": ["users"] + }, + "UpdatePartnerInfoRequest": { + "type": "object", + "properties": { + "targetAccountId": { + "type": "number", + "description": "変更対象アカウントID" + }, + "primaryAdminUserId": { + "type": "number", + "description": "プライマリ管理者ID" + }, + "companyName": { "type": "string", "description": "会社名" } + }, + "required": ["targetAccountId", "primaryAdminUserId", "companyName"] + }, + "UpdatePartnerInfoResponse": { "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 3b89aef..454fd63 100644 --- a/dictation_server/src/features/accounts/accounts.controller.spec.ts +++ b/dictation_server/src/features/accounts/accounts.controller.spec.ts @@ -6,6 +6,8 @@ import { AuthService } from '../auth/auth.service'; import { SwitchParentRequest, DeletePartnerAccountRequest, + GetPartnerUsersRequest, + UpdatePartnerInfoRequest, } from './types/types'; import { plainToClass } from 'class-transformer'; import { validate } from 'class-validator'; @@ -129,4 +131,150 @@ describe('AccountsController', () => { expect(errors.length).toBe(1); }); }); + describe('valdation getPartnerUsers', () => { + it('最低限の有効なリクエストが成功する', async () => { + const request = { + targetAccountId: 1, + }; + + const valdationObject = plainToClass(GetPartnerUsersRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(0); + }); + + it('取得対象アカウントが指定されていない場合、リクエストが失敗する', async () => { + const request = {}; + + const valdationObject = plainToClass(GetPartnerUsersRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + + it('取得対象アカウントが0の場合、リクエストが失敗する', async () => { + const request = { + userId: 0, + }; + + const valdationObject = plainToClass(GetPartnerUsersRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + + it('取得対象アカウントが文字列(数値以外)の場合、リクエストが失敗する', async () => { + const request = { + userId: 'a', + }; + + const valdationObject = plainToClass(GetPartnerUsersRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + }); + describe('valdation updatePartnerInfo', () => { + it('最低限の有効なリクエストが成功する', async () => { + const request = { + targetAccountId: 1, + primaryAdminUserId: 2, + companyName: 'test', + }; + + const valdationObject = plainToClass(UpdatePartnerInfoRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(0); + }); + + it('更新対象アカウントが指定されていない場合、リクエストが失敗する', async () => { + const request = { + targetAccountId: undefined, + primaryAdminUserId: 2, + companyName: 'test', + }; + + const valdationObject = plainToClass(UpdatePartnerInfoRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + + it('更新対象アカウントが0の場合、リクエストが失敗する', async () => { + const request = { + targetAccountId: 0, + primaryAdminUserId: 2, + companyName: 'test', + }; + + const valdationObject = plainToClass(UpdatePartnerInfoRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + + it('更新対象アカウントが文字列(数値以外)の場合、リクエストが失敗する', async () => { + const request = { + targetAccountId: 'a', + primaryAdminUserId: 2, + companyName: 'test', + }; + + const valdationObject = plainToClass(UpdatePartnerInfoRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + }); + // primaryAdminUserIdのテスト + it('更新対象アカウントが指定されていない場合、リクエストが失敗する', async () => { + const request = { + targetAccountId: 1, + primaryAdminUserId: undefined, + companyName: 'test', + }; + + const valdationObject = plainToClass(UpdatePartnerInfoRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + it('更新対象アカウントが0の場合、リクエストが失敗する', async () => { + const request = { + targetAccountId: 1, + primaryAdminUserId: 0, + companyName: 'test', + }; + + const valdationObject = plainToClass(UpdatePartnerInfoRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + it('更新対象アカウントが文字列(数値以外)の場合、リクエストが失敗する', async () => { + const request = { + targetAccountId: 1, + primaryAdminUserId: 'a', + companyName: 'test', + }; + + const valdationObject = plainToClass(UpdatePartnerInfoRequest, request); + + const errors = await validate(valdationObject); + expect(errors.length).toBe(1); + }); + // companyNameのテスト + it('更新対象アカウントが文字列以外場合、リクエストが失敗する', async () => { + const request = { + targetAccountId: 1, + primaryAdminUserId: 2, + companyName: 1, + }; + + const valdationObject = plainToClass(UpdatePartnerInfoRequest, 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 0f47c2e..db02299 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -81,6 +81,10 @@ import { SwitchParentResponse, DeletePartnerAccountRequest, DeletePartnerAccountResponse, + GetPartnerUsersResponse, + GetPartnerUsersRequest, + UpdatePartnerInfoRequest, + UpdatePartnerInfoResponse, } from './types/types'; import { USER_ROLES, ADMIN_ROLES, TIERS } from '../../constants'; import { AuthGuard } from '../../common/guards/auth/authguards'; @@ -2479,4 +2483,173 @@ export class AccountsController { return {}; } + + @Post('partner/users') + @ApiResponse({ + status: HttpStatus.OK, + type: GetPartnerUsersResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: '認証エラー', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'パラメータ不正/API実行者と取得対象が親子関係ではない', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ + operationId: 'getPartnerUsers', + description: + 'パートナーアカウントのユーザー情報を取得します(開発規約に基づき、他のAPIと合わせてGETではなくPOSTを使用)', + }) + @ApiBearerAuth() + @UseGuards(AuthGuard) + @UseGuards( + RoleGuard.requireds({ + roles: [ADMIN_ROLES.ADMIN], + tiers: [TIERS.TIER1, TIERS.TIER2, TIERS.TIER3], + }), + ) + async getPartnerUsers( + @Req() req: Request, + @Body() body: GetPartnerUsersRequest, + ): 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: 仮実装 + /*await this.accountService.getPartnerUsers( + context, + targetAccountId, + ); + */ + //仮の返却値 + return { users: [] }; + } + + @Post('partner/update') + @ApiResponse({ + status: HttpStatus.OK, + type: UpdatePartnerInfoResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: '認証エラー', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: + 'パラメータ不正/API実行者と取得対象が親子関係ではない/アカウントが不在/プライマリ管理者が同一アカウント内にいない', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ + operationId: 'updatePartnerInfo', + description: 'パートナーアカウントの情報を更新します', + }) + @ApiBearerAuth() + @UseGuards(AuthGuard) + @UseGuards( + RoleGuard.requireds({ + roles: [ADMIN_ROLES.ADMIN], + tiers: [TIERS.TIER1, TIERS.TIER2, TIERS.TIER3], + }), + ) + async updatePartnerInfo( + @Req() req: Request, + @Body() body: UpdatePartnerInfoRequest, + ): 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: 仮実装 + /*await this.accountService.updatePartnerAccount( + context, + targetAccountId, + ); + */ + return {}; + } } diff --git a/dictation_server/src/features/accounts/types/types.ts b/dictation_server/src/features/accounts/types/types.ts index 2ba21c6..03c2954 100644 --- a/dictation_server/src/features/accounts/types/types.ts +++ b/dictation_server/src/features/accounts/types/types.ts @@ -13,6 +13,9 @@ import { ArrayMaxSize, ValidateNested, Max, + IsString, + IsNotEmpty, + IsBoolean, } from 'class-validator'; import { IsAdminPasswordvalid } from '../../../common/validators/admin.validator'; import { IsUnique } from '../../../common/validators/IsUnique.validator'; @@ -398,6 +401,31 @@ export class DeletePartnerAccountRequest { targetAccountId: number; } +export class GetPartnerUsersRequest { + @ApiProperty({ description: '取得対象のアカウントID' }) + @Type(() => Number) + @IsInt() + @Min(1) + targetAccountId: number; +} + +export class UpdatePartnerInfoRequest { + @ApiProperty({ description: '変更対象アカウントID' }) + @Type(() => Number) + @IsInt() + @Min(1) + targetAccountId: number; + + @ApiProperty({ description: 'プライマリ管理者ID' }) + @Type(() => Number) + @IsInt() + @Min(1) + primaryAdminUserId: number; + + @ApiProperty({ description: '会社名' }) + @MaxLength(255) + companyName: string; +} // ============================== // RESPONSE // ============================== @@ -718,7 +746,37 @@ export class UpdateRestrictionStatusResponse {} export class SwitchParentResponse {} export class DeletePartnerAccountResponse {} +export class PartnerUser { + @ApiProperty({ description: 'ユーザーID' }) + @Type(() => Number) + @IsInt() + @IsNotEmpty() + id: number; + @ApiProperty({ description: 'ユーザー名' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ description: 'メールアドレス' }) + @IsEmail({ blacklisted_chars: '*' }) + @IsNotEmpty() + email: string; + + @ApiProperty({ description: 'プライマリ管理者かどうか' }) + @Type(() => Boolean) + isPrimaryAdmin: boolean; +} + +export class GetPartnerUsersResponse { + @ApiProperty({ type: [PartnerUser] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PartnerUser) + users: PartnerUser[]; +} + +export class UpdatePartnerInfoResponse {} // ============================== // Request/Response外の型 // TODO: Request/Response/その他の型を別ファイルに分ける