From 1cc7a0141d0f638d4a0aafc50073d1e7e11fcfb4 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Tue, 3 Oct 2023 01:14:18 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20453:=20=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E4=B8=80=E8=A6=A7=E5=8F=96?= =?UTF-8?q?=E5=BE=97API=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2736: ワークフロー一覧取得API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2736) - ワークフロー一覧取得APIとテストを実装しました ## レビューポイント - リポジトリの取得処理は適切か(リレーションなど) - ADB2Cからの取得処理は適切か - サービスでのワークフローの整形処理は適切か - テストケースは適切か ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- dictation_server/src/app.module.ts | 2 + .../src/features/workflows/test/utility.ts | 61 +++++ .../workflows/workflows.controller.ts | 5 +- .../features/workflows/workflows.module.ts | 4 +- .../workflows/workflows.service.spec.ts | 225 ++++++++++++++++++ .../features/workflows/workflows.service.ts | 106 ++++++++- .../workflows/entity/workflow.entity.ts | 59 +++++ .../entity/workflow_typists.entity.ts | 51 ++++ .../workflows/workflows.repository.module.ts | 12 + .../workflows/workflows.repository.service.ts | 34 +++ 10 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 dictation_server/src/features/workflows/test/utility.ts create mode 100644 dictation_server/src/features/workflows/workflows.service.spec.ts create mode 100644 dictation_server/src/repositories/workflows/entity/workflow.entity.ts create mode 100644 dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts create mode 100644 dictation_server/src/repositories/workflows/workflows.repository.module.ts create mode 100644 dictation_server/src/repositories/workflows/workflows.repository.service.ts diff --git a/dictation_server/src/app.module.ts b/dictation_server/src/app.module.ts index c99f882..ee977ec 100644 --- a/dictation_server/src/app.module.ts +++ b/dictation_server/src/app.module.ts @@ -48,6 +48,7 @@ import { WorkflowsModule } from './features/workflows/workflows.module'; import { WorkflowsController } from './features/workflows/workflows.controller'; import { WorkflowsService } from './features/workflows/workflows.service'; import { validate } from './common/validators/env.validator'; +import { WorkflowsRepositoryModule } from './repositories/workflows/workflows.repository.module'; @Module({ imports: [ @@ -86,6 +87,7 @@ import { validate } from './common/validators/env.validator'; CheckoutPermissionsRepositoryModule, UserGroupsRepositoryModule, TemplateFilesRepositoryModule, + WorkflowsRepositoryModule, TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ diff --git a/dictation_server/src/features/workflows/test/utility.ts b/dictation_server/src/features/workflows/test/utility.ts new file mode 100644 index 0000000..6ff9f29 --- /dev/null +++ b/dictation_server/src/features/workflows/test/utility.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; +import { Workflow } from '../../../repositories/workflows/entity/workflow.entity'; +import { WorkflowTypist } from '../../../repositories/workflows/entity/workflow_typists.entity'; + +// Workflowを作成する +export const createWorkflow = async ( + datasource: DataSource, + accountId: number, + authorId: number, + worktypeId?: number | undefined, + templateId?: number | undefined, +): Promise => { + const { identifiers } = await datasource.getRepository(Workflow).insert({ + account_id: accountId, + author_id: authorId, + worktype_id: worktypeId ?? undefined, + template_id: templateId ?? undefined, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const workflow = identifiers.pop() as Workflow; + + return workflow; +}; + +// Workflowを取得する +export const getWorkflows = async ( + datasource: DataSource, + accountId: number, +): Promise => { + return await datasource.getRepository(Workflow).find({ + where: { + account_id: accountId, + }, + }); +}; + +// Workflowを作成する +export const createWorkflowTypist = async ( + datasource: DataSource, + workflowId: number, + typistUserId?: number | undefined, + typistGroupId?: number | undefined, +): Promise => { + const { identifiers } = await datasource + .getRepository(WorkflowTypist) + .insert({ + workflow_id: workflowId, + typist_id: typistUserId ?? undefined, + typist_group_id: typistGroupId ?? undefined, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const workflow = identifiers.pop() as Workflow; + + return workflow; +}; diff --git a/dictation_server/src/features/workflows/workflows.controller.ts b/dictation_server/src/features/workflows/workflows.controller.ts index 9b256a7..a1521c4 100644 --- a/dictation_server/src/features/workflows/workflows.controller.ts +++ b/dictation_server/src/features/workflows/workflows.controller.ts @@ -62,9 +62,10 @@ export class WorkflowsController { const { userId } = jwt.decode(token, { json: true }) as AccessToken; const context = makeContext(userId); - console.log(context.trackingId); - return { workflows: [] }; + const workflows = await this.workflowsService.getWorkflows(context, userId); + + return { workflows }; } @ApiResponse({ diff --git a/dictation_server/src/features/workflows/workflows.module.ts b/dictation_server/src/features/workflows/workflows.module.ts index f0547ff..9a25872 100644 --- a/dictation_server/src/features/workflows/workflows.module.ts +++ b/dictation_server/src/features/workflows/workflows.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { UsersRepositoryModule } from '../../repositories/users/users.repository.module'; import { WorkflowsController } from './workflows.controller'; import { WorkflowsService } from './workflows.service'; +import { WorkflowsRepositoryModule } from '../../repositories/workflows/workflows.repository.module'; +import { AdB2cModule } from '../../gateways/adb2c/adb2c.module'; @Module({ - imports: [UsersRepositoryModule], + imports: [UsersRepositoryModule, WorkflowsRepositoryModule, AdB2cModule], providers: [WorkflowsService], controllers: [WorkflowsController], }) diff --git a/dictation_server/src/features/workflows/workflows.service.spec.ts b/dictation_server/src/features/workflows/workflows.service.spec.ts new file mode 100644 index 0000000..0873a5a --- /dev/null +++ b/dictation_server/src/features/workflows/workflows.service.spec.ts @@ -0,0 +1,225 @@ +import { DataSource } from 'typeorm'; +import { makeTestingModule } from '../../common/test/modules'; +import { makeTestAccount, makeTestUser } from '../../common/test/utility'; +import { makeContext } from '../../common/log'; +import { WorkflowsService } from './workflows.service'; +import { USER_ROLES } from '../../constants'; +import { createTemplateFile } from '../templates/test/utility'; +import { createWorktype } from '../accounts/test/utility'; +import { + createWorkflow, + createWorkflowTypist, + getWorkflows, +} from './test/utility'; +import { createUserGroup } from '../users/test/utility'; +import { overrideAdB2cService } from '../../common/test/overrides'; +import { WorkflowsRepositoryService } from '../../repositories/workflows/workflows.repository.service'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; + +describe('getWorktypes', () => { + let source: DataSource = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); + }); + + afterEach(async () => { + await source.destroy(); + source = null; + }); + + it('アカウント内のWorkflow一覧を取得できる', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorId1 } = await makeTestUser(source, { + external_id: 'author1', + author_id: 'AUTHOR1', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: authorId2 } = await makeTestUser(source, { + external_id: 'author2', + author_id: 'AUTHOR2', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: authorId3 } = await makeTestUser(source, { + external_id: 'author3', + author_id: 'AUTHOR3', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: typistId, external_id: typistExternalId } = await makeTestUser( + source, + { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }, + ); + const { userGroupId } = await createUserGroup( + source, + account.id, + 'group1', + [typistId], + ); + + const { id: worktypeId1 } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId1 } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + const workflow1 = await createWorkflow( + source, + account.id, + authorId1, + worktypeId1, + templateId1, + ); + const workflow2 = await createWorkflow( + source, + account.id, + authorId2, + undefined, + templateId1, + ); + const workflow3 = await createWorkflow( + source, + account.id, + authorId3, + worktypeId1, + undefined, + ); + + await createWorkflowTypist(source, workflow1.id, typistId, undefined); + await createWorkflowTypist(source, workflow2.id, undefined, userGroupId); + await createWorkflowTypist(source, workflow3.id, undefined, userGroupId); + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(3); + expect(workflows[0].id).toBe(workflow1.id); + expect(workflows[0].author_id).toBe(authorId1); + expect(workflows[0].worktype_id).toBe(worktypeId1); + expect(workflows[0].template_id).toBe(templateId1); + + expect(workflows[1].id).toBe(workflow2.id); + expect(workflows[1].author_id).toBe(authorId2); + expect(workflows[1].worktype_id).toBe(null); + expect(workflows[1].template_id).toBe(templateId1); + + expect(workflows[2].id).toBe(workflow3.id); + expect(workflows[2].author_id).toBe(authorId3); + expect(workflows[2].worktype_id).toBe(worktypeId1); + expect(workflows[2].template_id).toBe(null); + } + + overrideAdB2cService(service, { + getUsers: async () => [{ id: typistExternalId, displayName: 'typist1' }], + }); + + const resWorkflows = await service.getWorkflows(context, admin.external_id); + + //実行結果を確認 + { + expect(resWorkflows.length).toBe(3); + expect(resWorkflows[0].id).toBe(workflow1.id); + expect(resWorkflows[0].author.id).toBe(authorId1); + expect(resWorkflows[0].author.authorId).toBe('AUTHOR1'); + expect(resWorkflows[0].worktype.id).toBe(worktypeId1); + expect(resWorkflows[0].worktype.worktypeId).toBe('worktype1'); + expect(resWorkflows[0].template.id).toBe(templateId1); + expect(resWorkflows[0].template.fileName).toBe('fileName1'); + expect(resWorkflows[0].typists.length).toBe(1); + expect(resWorkflows[0].typists[0].typistUserId).toBe(typistId); + expect(resWorkflows[0].typists[0].typistName).toBe('typist1'); + + expect(resWorkflows[1].id).toBe(workflow2.id); + expect(resWorkflows[1].author.id).toBe(authorId2); + expect(resWorkflows[1].author.authorId).toBe('AUTHOR2'); + expect(resWorkflows[1].worktype).toBe(undefined); + expect(resWorkflows[1].template.id).toBe(templateId1); + expect(resWorkflows[1].template.fileName).toBe('fileName1'); + expect(resWorkflows[1].typists.length).toBe(1); + expect(resWorkflows[1].typists[0].typistGroupId).toBe(userGroupId); + expect(resWorkflows[1].typists[0].typistName).toBe('group1'); + + expect(resWorkflows[2].id).toBe(workflow3.id); + expect(resWorkflows[2].author.id).toBe(authorId3); + expect(resWorkflows[2].author.authorId).toBe('AUTHOR3'); + expect(resWorkflows[2].worktype.id).toBe(worktypeId1); + expect(resWorkflows[2].worktype.worktypeId).toBe('worktype1'); + expect(resWorkflows[2].template).toBe(undefined); + expect(resWorkflows[2].typists.length).toBe(1); + expect(resWorkflows[2].typists[0].typistGroupId).toBe(userGroupId); + expect(resWorkflows[2].typists[0].typistName).toBe('group1'); + } + }); + + it('アカウント内のWorkflow一覧を取得できる(0件)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + const { admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + overrideAdB2cService(service, { + getUsers: async () => [], + }); + + const resWorkflows = await service.getWorkflows(context, admin.external_id); + + //実行結果を確認 + { + expect(resWorkflows.length).toBe(0); + } + }); + + it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //DBアクセスに失敗するようにする + const templatesService = module.get( + WorkflowsRepositoryService, + ); + templatesService.getWorkflows = jest.fn().mockRejectedValue('DB failed'); + + //実行結果を確認 + try { + await service.getWorkflows(context, admin.external_id); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + }); +}); diff --git a/dictation_server/src/features/workflows/workflows.service.ts b/dictation_server/src/features/workflows/workflows.service.ts index 9b32526..9c41266 100644 --- a/dictation_server/src/features/workflows/workflows.service.ts +++ b/dictation_server/src/features/workflows/workflows.service.ts @@ -1,7 +1,109 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +import { Context } from '../../common/log'; +import { Workflow } from './types/types'; +import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; +import { WorkflowsRepositoryService } from '../../repositories/workflows/workflows.repository.service'; +import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; @Injectable() export class WorkflowsService { private readonly logger = new Logger(WorkflowsService.name); - constructor() {} + constructor( + private readonly usersRepository: UsersRepositoryService, + private readonly workflowsRepository: WorkflowsRepositoryService, + private readonly adB2cService: AdB2cService, + ) {} + /** + * ワークフロー一覧を取得する + * @param context + * @param externalId + * @returns workflows + */ + async getWorkflows( + context: Context, + externalId: string, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.getWorkflows.name} | params: { externalId: ${externalId} };`, + ); + try { + const { account_id: accountId } = + await this.usersRepository.findUserByExternalId(externalId); + + // DBからワークフロー一覧を取得 + const workflowRecords = await this.workflowsRepository.getWorkflows( + accountId, + ); + + // ワークフロー一覧からtypistのexternalIdを取得 + const externalIds = workflowRecords.flatMap((workflow) => { + const workflowTypists = workflow.workflowTypists.flatMap( + (workflowTypist) => { + const { typist } = workflowTypist; + return typist ? [typist?.external_id] : []; + }, + ); + return workflowTypists; + }); + const distinctedExternalIds = [...new Set(externalIds)]; + + // ADB2Cからユーザー一覧を取得 + const adb2cUsers = await this.adB2cService.getUsers( + context, + distinctedExternalIds, + ); + + // DBから取得したワークフロー一覧を整形 + const workflows = workflowRecords.map((workflow) => { + const { id, author, worktype, template, workflowTypists } = workflow; + + const authorId = { id: author.id, authorId: author.author_id }; + const worktypeId = worktype + ? { id: worktype.id, worktypeId: worktype.custom_worktype_id } + : undefined; + const templateId = template + ? { id: template.id, fileName: template.file_name } + : undefined; + + // ルーティング候補を整形 + const typists = workflowTypists.map((workflowTypist) => { + const { typist, typistGroup } = workflowTypist; + + // typistがユーザーの場合はADB2Cからユーザー名を取得 + const typistName = typist + ? adb2cUsers.find( + (adb2cUser) => adb2cUser.id === typist.external_id, + ).displayName + : typistGroup.name; + + return { + typistUserId: typist?.id, + typistGroupId: typistGroup?.id, + typistName, + }; + }); + + return { + id, + author: authorId, + worktype: worktypeId, + template: templateId, + typists, + }; + }); + + return workflows; + } catch (e) { + this.logger.error(`[${context.trackingId}] error=${e}`); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + this.logger.log( + `[OUT] [${context.trackingId}] ${this.getWorkflows.name}`, + ); + } + } } diff --git a/dictation_server/src/repositories/workflows/entity/workflow.entity.ts b/dictation_server/src/repositories/workflows/entity/workflow.entity.ts new file mode 100644 index 0000000..e3bac8e --- /dev/null +++ b/dictation_server/src/repositories/workflows/entity/workflow.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + JoinColumn, + ManyToOne, +} from 'typeorm'; +import { WorkflowTypist } from './workflow_typists.entity'; +import { Worktype } from '../../worktypes/entity/worktype.entity'; +import { TemplateFile } from '../../template_files/entity/template_file.entity'; +import { User } from '../../users/entity/user.entity'; + +@Entity({ name: 'workflows' }) +export class Workflow { + @PrimaryGeneratedColumn() + id: number; + + @Column() + account_id: number; + + @Column() + author_id: number; + + @Column({ nullable: true }) + worktype_id?: number; + + @Column({ nullable: true }) + template_id?: number; + + @Column({ nullable: true }) + created_by: string; + + @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + created_at: Date; + + @Column({ nullable: true }) + updated_by?: string; + + @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + updated_at: Date; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'author_id' }) + author?: User; + + @ManyToOne(() => Worktype, (worktype) => worktype.id) + @JoinColumn({ name: 'worktype_id' }) + worktype?: Worktype; + + @ManyToOne(() => TemplateFile, (templateFile) => templateFile.id) + @JoinColumn({ name: 'template_id' }) + template?: TemplateFile; + + @OneToMany(() => WorkflowTypist, (workflowTypist) => workflowTypist.workflow) + workflowTypists?: WorkflowTypist[]; +} diff --git a/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts b/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts new file mode 100644 index 0000000..b3d7139 --- /dev/null +++ b/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Workflow } from './workflow.entity'; +import { User } from '../../users/entity/user.entity'; +import { UserGroup } from '../../user_groups/entity/user_group.entity'; + +@Entity({ name: 'workflow_typists' }) +export class WorkflowTypist { + @PrimaryGeneratedColumn() + id: number; + + @Column() + workflow_id: number; + + @Column({ nullable: true }) + typist_id?: number; + + @Column({ nullable: true }) + typist_group_id?: number; + + @Column({ nullable: true }) + created_by: string; + + @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + created_at: Date; + + @Column({ nullable: true }) + updated_by?: string; + + @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + updated_at: Date; + + @ManyToOne(() => Workflow, (workflow) => workflow.id) + @JoinColumn({ name: 'workflow_id' }) + workflow?: Workflow; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'typist_id' }) + typist?: User; + + @ManyToOne(() => UserGroup, (userGroup) => userGroup.id) + @JoinColumn({ name: 'typist_group_id' }) + typistGroup?: UserGroup; +} diff --git a/dictation_server/src/repositories/workflows/workflows.repository.module.ts b/dictation_server/src/repositories/workflows/workflows.repository.module.ts new file mode 100644 index 0000000..6877efd --- /dev/null +++ b/dictation_server/src/repositories/workflows/workflows.repository.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { WorkflowTypist } from './entity/workflow_typists.entity'; +import { Workflow } from './entity/workflow.entity'; +import { WorkflowsRepositoryService } from './workflows.repository.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Workflow, WorkflowTypist])], + providers: [WorkflowsRepositoryService], + exports: [WorkflowsRepositoryService], +}) +export class WorkflowsRepositoryModule {} diff --git a/dictation_server/src/repositories/workflows/workflows.repository.service.ts b/dictation_server/src/repositories/workflows/workflows.repository.service.ts new file mode 100644 index 0000000..3b6bd34 --- /dev/null +++ b/dictation_server/src/repositories/workflows/workflows.repository.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Workflow } from './entity/workflow.entity'; + +@Injectable() +export class WorkflowsRepositoryService { + constructor(private dataSource: DataSource) {} + + /** + * ワークフロー一を取得する + * @param externalId + * @returns worktypes and active worktype id + */ + async getWorkflows(accountId: number): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const workflowRepo = entityManager.getRepository(Workflow); + + const workflows = await workflowRepo.find({ + where: { account_id: accountId }, + relations: { + author: true, + worktype: true, + template: true, + workflowTypists: { + typist: true, + typistGroup: true, + }, + }, + }); + + return workflows; + }); + } +}