diff --git a/dictation_server/src/common/notify/code.ts b/dictation_server/src/common/notify/code.ts new file mode 100644 index 0000000..032ae21 --- /dev/null +++ b/dictation_server/src/common/notify/code.ts @@ -0,0 +1,13 @@ +/* +通知メッセージコード作成方針 +M+6桁(数字)で構成する。 +- 1~2桁目の値は種類(正常系、以上系...) +- 3~4桁目の値は関連機能(タスク、ユーザー、ファイル...) +- 5~6桁目の値は任意の重複しない値 +ex) +E00XXXX : 正常系メッセージ +E01XXXX : 以上系メッセージ +EXX00XX : 全般 +EXX01XX : タスク関連 +*/ +export const NotifyMessageCodes = ['M000101'] as const; diff --git a/dictation_server/src/common/notify/makeNotifyMessage.ts b/dictation_server/src/common/notify/makeNotifyMessage.ts new file mode 100644 index 0000000..b5f2bf3 --- /dev/null +++ b/dictation_server/src/common/notify/makeNotifyMessage.ts @@ -0,0 +1,7 @@ +import { notifyMessages } from './message'; +import { NotifyMessageCodeType } from './types/types'; + +export const makeNotifyMessage = (code: NotifyMessageCodeType): string => { + const msg = notifyMessages[code]; + return msg; +}; diff --git a/dictation_server/src/common/notify/message.ts b/dictation_server/src/common/notify/message.ts new file mode 100644 index 0000000..2b12876 --- /dev/null +++ b/dictation_server/src/common/notify/message.ts @@ -0,0 +1,6 @@ +import { NotifyMessages } from './types/types'; + +// エラーコードとメッセージ対応表 +export const notifyMessages: NotifyMessages = { + M000101: 'You are assigned to Typist.', +}; diff --git a/dictation_server/src/common/notify/types/types.ts b/dictation_server/src/common/notify/types/types.ts new file mode 100644 index 0000000..de01f01 --- /dev/null +++ b/dictation_server/src/common/notify/types/types.ts @@ -0,0 +1,7 @@ +import { NotifyMessageCodes } from '../code'; + +export type NotifyMessageCodeType = (typeof NotifyMessageCodes)[number]; + +export type NotifyMessages = { + [P in NotifyMessageCodeType]: string; +}; diff --git a/dictation_server/src/constants/index.ts b/dictation_server/src/constants/index.ts index f41cc4d..33607ce 100644 --- a/dictation_server/src/constants/index.ts +++ b/dictation_server/src/constants/index.ts @@ -174,6 +174,13 @@ export const TASK_LIST_SORTABLE_ATTRIBUTES = [ */ export const SORT_DIRECTIONS = ['ASC', 'DESC'] as const; +/** + * 通知タグの最大個数 + * NotificationHubの仕様上タグ式のOR条件で使えるタグは20個まで + * https://learn.microsoft.com/ja-jp/azure/notification-hubs/notification-hubs-tags-segment-push-message#tag-expressions + */ +export const TAG_MAX_COUNT = 20; + /** * 通知のプラットフォーム種別文字列 */ diff --git a/dictation_server/src/features/tasks/tasks.module.ts b/dictation_server/src/features/tasks/tasks.module.ts index f3d94ac..ce184ae 100644 --- a/dictation_server/src/features/tasks/tasks.module.ts +++ b/dictation_server/src/features/tasks/tasks.module.ts @@ -4,9 +4,17 @@ import { TasksController } from './tasks.controller'; import { UsersRepositoryModule } from '../../repositories/users/users.repository.module'; import { TasksRepositoryModule } from '../../repositories/tasks/tasks.repository.module'; import { AdB2cModule } from '../../gateways/adb2c/adb2c.module'; +import { UserGroupsRepositoryModule } from '../../repositories/user_groups/user_groups.repository.module'; +import { NotificationhubModule } from '../../gateways/notificationhub/notificationhub.module'; @Module({ - imports: [UsersRepositoryModule, TasksRepositoryModule, AdB2cModule], + imports: [ + UsersRepositoryModule, + UserGroupsRepositoryModule, + TasksRepositoryModule, + AdB2cModule, + NotificationhubModule, + ], providers: [TasksService], controllers: [TasksController], }) diff --git a/dictation_server/src/features/tasks/tasks.service.spec.ts b/dictation_server/src/features/tasks/tasks.service.spec.ts index 54271c0..babfbed 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -1,6 +1,8 @@ import { makeDefaultAdb2cServiceMockValue, + makeDefaultNotificationhubServiceMockValue, makeDefaultTasksRepositoryMockValue, + makeDefaultUserGroupsRepositoryMockValue, makeDefaultUsersRepositoryMockValue, makeTasksServiceMock, } from './test/tasks.service.mock'; @@ -16,19 +18,25 @@ import { createUserGroup, getCheckoutPermissions, getTask, + makeTaskTestingModule, } from './test/utility'; import { Adb2cTooManyRequestsError } from '../../gateways/adb2c/adb2c.service'; -import { makeTestingModule } from '../../common/test/modules'; describe('TasksService', () => { it('タスク一覧を取得できる(admin)', async () => { const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; @@ -90,12 +98,19 @@ describe('TasksService', () => { it('アカウント情報の取得に失敗した場合、エラーを返却する', async () => { const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); usersRepositoryMockValue.findUserByExternalId = new Error('DB failed'); const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; @@ -124,12 +139,18 @@ describe('TasksService', () => { it('タスク一覧の取得に失敗した場合、エラーを返却する(admin)', async () => { const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); tasksRepositoryMockValue.getTasksFromAccountId = new Error('DB failed'); const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; @@ -193,11 +214,17 @@ describe('TasksService', () => { count: 1, }; const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; const offset = 0; @@ -225,7 +252,11 @@ describe('TasksService', () => { it('タスク一覧を取得できる(author)', async () => { const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); if (usersRepositoryMockValue.findUserByExternalId instanceof Error) { return; } @@ -233,7 +264,9 @@ describe('TasksService', () => { const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'author', tier: 5 }; @@ -300,14 +333,20 @@ describe('TasksService', () => { it('タスク一覧の取得に失敗した場合、エラーを返却する(author)', async () => { const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); tasksRepositoryMockValue.getTasksFromAuthorIdAndAccountId = new Error( 'DB failed', ); const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'author', tier: 5 }; @@ -336,7 +375,11 @@ describe('TasksService', () => { it('タスク一覧を取得できる(typist)', async () => { const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); if (usersRepositoryMockValue.findUserByExternalId instanceof Error) { return; } @@ -345,7 +388,9 @@ describe('TasksService', () => { const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'typist', tier: 5 }; @@ -412,14 +457,20 @@ describe('TasksService', () => { it('タスク一覧の取得に失敗した場合、エラーを返却する(typist)', async () => { const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); tasksRepositoryMockValue.getTasksFromTypistRelations = new Error( 'DB failed', ); const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'typist', tier: 5 }; @@ -448,11 +499,17 @@ describe('TasksService', () => { it('想定外のRoleの場合、エラーを返却する', async () => { const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'XXX', tier: 5 }; @@ -481,12 +538,18 @@ describe('TasksService', () => { it('AdB2Cのリクエスト上限超過時、専用のエラーを返却する', async () => { const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const userGroupsRepositoryMockValue = + makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); adb2cServiceMockValue.getUsers = new Adb2cTooManyRequestsError(); const service = await makeTasksServiceMock( tasksRepositoryMockValue, usersRepositoryMockValue, + userGroupsRepositoryMockValue, adb2cServiceMockValue, + notificationhubServiceMockValue, ); const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; @@ -531,7 +594,12 @@ describe('TasksService', () => { }); it('[Admin] Taskが0件であっても実行できる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { externalId } = await createUser( source, @@ -560,7 +628,12 @@ describe('TasksService', () => { expect(total).toEqual(0); }); it('[Author] Authorは自分が作成者のTask一覧を取得できる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId } = await createUser( source, @@ -618,7 +691,12 @@ describe('TasksService', () => { } }); it('[Author] Authorは同一アカウントであっても自分以外のAuhtorのTaskは取得できない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: userId_1 } = await createUser( source, @@ -700,7 +778,12 @@ describe('changeCheckoutPermission', () => { }); it('タスクのチェックアウト権限を変更できる。(個人指定)', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId_1 } = await createUser( source, @@ -757,7 +840,12 @@ describe('changeCheckoutPermission', () => { }); it('タスクのチェックアウト権限を変更できる。(グループ指定)', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId_1 } = await createUser( source, @@ -820,7 +908,12 @@ describe('changeCheckoutPermission', () => { }); it('タスクのチェックアウト権限を変更できる。(チェックアウト権限を外す)', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId_1 } = await createUser( source, @@ -862,7 +955,12 @@ describe('changeCheckoutPermission', () => { }); it('ユーザーが存在しない場合、タスクのチェックアウト権限を変更できない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId_1 } = await createUser( source, @@ -910,7 +1008,12 @@ describe('changeCheckoutPermission', () => { }); it('ユーザーグループが存在しない場合、タスクのチェックアウト権限を変更できない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId_1 } = await createUser( source, @@ -958,7 +1061,12 @@ describe('changeCheckoutPermission', () => { }); it('タスクが存在しない場合、タスクのチェックアウト権限を変更できない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -988,7 +1096,12 @@ describe('changeCheckoutPermission', () => { }); it('タスクのステータスがUploadedでない場合、タスクのチェックアウト権限を変更できない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1028,7 +1141,12 @@ describe('changeCheckoutPermission', () => { }); it('ユーザーのRoleがAuthorでタスクのAuthorIDと自身のAuthorIDが一致しない場合、タスクのチェックアウト権限を変更できない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1066,6 +1184,69 @@ describe('changeCheckoutPermission', () => { new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST), ); }); + + it('通知に失敗した場合、エラーとなる', async () => { + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + notificationhubServiceMockValue.notify = new Error('Notify Error.'); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); + const { accountId } = await createAccount(source); + const { userId: typistUserId_1 } = await createUser( + source, + accountId, + 'typist-user-external-id', + 'typist', + ); + const { userId: typistUserId_2 } = await createUser( + source, + accountId, + 'typist-user-2-external-id', + 'typist', + ); + const { userId: authorUserId } = await createUser( + source, + accountId, + 'author-user-external-id', + 'author', + 'MY_AUTHOR_ID', + ); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'Uploaded', + ); + const { userGroupId } = await createUserGroup( + source, + accountId, + 'USER_GROUP_A', + [typistUserId_1], + ); + await createCheckoutPermissions(source, taskId, typistUserId_1); + await createCheckoutPermissions(source, taskId, undefined, userGroupId); + const service = module.get(TasksService); + + await expect( + service.changeCheckoutPermission( + 1, + [{ typistName: 'typist-user-2', typistUserId: typistUserId_2 }], + 'author-user-external-id', + ['admin'], + ), + ).rejects.toEqual( + new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + }); }); describe('checkout', () => { @@ -1087,7 +1268,12 @@ describe('checkout', () => { }); it('ユーザーのRoleがTypistで、タスクのチェックアウト権限が個人指定である時、タスクをチェックアウトできる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1145,7 +1331,12 @@ describe('checkout', () => { }); it('ユーザーのRoleがTypistで、タスクのチェックアウト権限がグループ指定である時、タスクをチェックアウトできる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1203,7 +1394,12 @@ describe('checkout', () => { }); it('ユーザーのRoleがTypistで、タスクのステータスがPendingである時、タスクをチェックアウトできる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1255,7 +1451,12 @@ describe('checkout', () => { }); it('ユーザーのRoleがTypistで、対象のタスクのStatus[Uploaded,Inprogress,Pending]以外の時、タスクをチェックアウトできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser( source, @@ -1291,7 +1492,12 @@ describe('checkout', () => { }); it('ユーザーのRoleがTypistで、チェックアウト権限が存在しない時、タスクをチェックアウトできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser( source, @@ -1327,7 +1533,12 @@ describe('checkout', () => { }); it('ユーザーのRoleがAuthorで、アップロードした音声ファイルに紐づいたタスクをチェックアウトできる(Uploaded)', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: authorUserId } = await createUser( source, @@ -1354,7 +1565,12 @@ describe('checkout', () => { }); it('ユーザーのRoleがAuthorで、アップロードした音声ファイルに紐づいたタスクをチェックアウトできる(Finished)', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: authorUserId } = await createUser( source, @@ -1381,7 +1597,12 @@ describe('checkout', () => { }); it('ユーザーのRoleがAuthorで、アップロードした音声ファイルに紐づいたタスクが存在しない場合、タスクをチェックアウトできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser( source, @@ -1400,7 +1621,12 @@ describe('checkout', () => { }); it('ユーザーのRoleがAuthorで、音声ファイルに紐づいたタスクでユーザーと一致するAuthorIDでない場合、タスクをチェックアウトできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: authorUserId } = await createUser( source, @@ -1429,7 +1655,12 @@ describe('checkout', () => { }); it('ユーザーのRoleに[Typist,author]が設定されていない時、タスクをチェックアウトできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser( source, @@ -1467,7 +1698,12 @@ describe('checkin', () => { }); it('API実行者が文字起こし実行中のタスクである場合、タスクをチェックインできる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1507,7 +1743,12 @@ describe('checkin', () => { }); it('タスクのステータスがInprogressでない時、タスクをチェックインできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1542,7 +1783,12 @@ describe('checkin', () => { }); it('API実行者が文字起こし実行中のタスクでない場合、タスクをチェックインできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser(source, accountId, 'typist-user-external-id', 'typist'); const { userId: anotherTypistUserId } = await createUser( @@ -1579,7 +1825,12 @@ describe('checkin', () => { }); it('タスクがない時、タスクをチェックインできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser(source, accountId, 'typist-user-external-id', 'typist'); @@ -1618,7 +1869,12 @@ describe('suspend', () => { }); it('API実行者が文字起こし実行中のタスクである場合、タスクを中断できる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1654,7 +1910,12 @@ describe('suspend', () => { }); it('タスクのステータスがInprogressでない時、タスクを中断できない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1689,7 +1950,12 @@ describe('suspend', () => { }); it('API実行者が文字起こし実行中のタスクでない場合、タスクを中断できない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser(source, accountId, 'typist-user-external-id', 'typist'); const { userId: anotherTypistUserId } = await createUser( @@ -1726,7 +1992,12 @@ describe('suspend', () => { }); it('タスクがない時、タスクを中断できない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser(source, accountId, 'typist-user-external-id', 'typist'); @@ -1765,7 +2036,12 @@ describe('cancel', () => { }); it('API実行者のRoleがTypistの場合、自身が文字起こし実行中のタスクをキャンセルできる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1805,7 +2081,12 @@ describe('cancel', () => { }); it('API実行者のRoleがTypistの場合、自身が文字起こし中断しているタスクをキャンセルできる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1845,7 +2126,12 @@ describe('cancel', () => { }); it('API実行者のRoleがAdminの場合、文字起こし実行中のタスクをキャンセルできる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1886,7 +2172,12 @@ describe('cancel', () => { }); it('API実行者のRoleがAdminの場合、文字起こし中断しているタスクをキャンセルできる', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1927,7 +2218,12 @@ describe('cancel', () => { }); it('タスクのステータスが[Inprogress,Pending]でない時、タスクをキャンセルできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); const { userId: typistUserId } = await createUser( source, @@ -1964,7 +2260,12 @@ describe('cancel', () => { }); it('API実行者のRoleがTypistの場合、他人が文字起こし実行中のタスクをキャンセルできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser(source, accountId, 'typist-user-external-id', 'typist'); const { userId: anotherTypistUserId } = await createUser( @@ -2003,7 +2304,12 @@ describe('cancel', () => { }); it('タスクがない時、タスクをキャンセルできない', async () => { - const module = await makeTestingModule(source); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModule( + source, + notificationhubServiceMockValue, + ); const { accountId } = await createAccount(source); await createUser(source, accountId, 'typist-user-external-id', 'typist'); diff --git a/dictation_server/src/features/tasks/tasks.service.ts b/dictation_server/src/features/tasks/tasks.service.ts index 3207d48..3c4bc4f 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -29,6 +29,9 @@ import { } from '../../repositories/tasks/errors/types'; import { Roles } from '../../common/types/role'; import { InvalidRoleError } from './errors/types'; +import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; +import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service'; +import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage'; @Injectable() export class TasksService { @@ -36,7 +39,9 @@ export class TasksService { constructor( private readonly taskRepository: TasksRepositoryService, private readonly usersRepository: UsersRepositoryService, + private readonly userGroupsRepositoryService: UserGroupsRepositoryService, private readonly adB2cService: AdB2cService, + private readonly notificationhubService: NotificationhubService, ) {} // TODO [Task2244] 引数にAccessTokenがあるのは不適切なのでController側で分解したい @@ -405,6 +410,43 @@ export class TasksService { role, assignees, ); + + // すべての割り当て候補ユーザーを取得する + const assigneesGroupIds = assignees + .filter((x) => x.typistGroupId) + .map((x) => x.typistGroupId); + const assigneesUserIds = assignees + .filter((x) => x.typistUserId) + .map((x) => x.typistUserId); + + const groupMembers = + await this.userGroupsRepositoryService.getGroupMembersFromGroupIds( + assigneesGroupIds, + ); + + // 重複のない割り当て候補ユーザーID一覧を取得する + const distinctUserIds = [ + ...new Set([ + ...assigneesUserIds, + ...groupMembers.map((x) => x.user_id), + ]), + ]; + + // 割り当てられたユーザーがいない場合は通知不要 + if (distinctUserIds.length === 0) { + this.logger.log('No user assigned.'); + return; + } + + // タグを生成 + const tags = distinctUserIds.map((x) => `user_${x}`); + this.logger.log(`tags: ${tags}`); + + // タグ対象に通知送信 + await this.notificationhubService.notify( + tags, + makeNotifyMessage('M000101'), + ); } catch (e) { this.logger.error(`error=${e}`); if (e instanceof Error) { diff --git a/dictation_server/src/features/tasks/test/tasks.service.mock.ts b/dictation_server/src/features/tasks/test/tasks.service.mock.ts index 9918b24..1908047 100644 --- a/dictation_server/src/features/tasks/test/tasks.service.mock.ts +++ b/dictation_server/src/features/tasks/test/tasks.service.mock.ts @@ -12,6 +12,9 @@ import { import { AdB2cService } from '../../../gateways/adb2c/adb2c.service'; import { AdB2cUser } from '../../../gateways/adb2c/types/types'; import { Assignee } from '../types/types'; +import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity'; +import { NotificationhubService } from '../../../gateways/notificationhub/notificationhub.service'; +import { UserGroupsRepositoryService } from '../../../repositories/user_groups/user_groups.repository.service'; export type TasksRepositoryMockValue = { getTasksFromAccountId: @@ -46,10 +49,20 @@ export type UsersRepositoryMockValue = { findUserByExternalId: User | Error; }; +export type UserGroupsRepositoryMockValue = { + getGroupMembersFromGroupIds: UserGroupMember[] | Error; +}; + +export type NotificationhubServiceMockValue = { + notify: undefined | Error; +}; + export const makeTasksServiceMock = async ( tasksRepositoryMockValue: TasksRepositoryMockValue, usersRepositoryMockValue: UsersRepositoryMockValue, + userGroupsRepositoryMockValue: UserGroupsRepositoryMockValue, adB2CServiceMockValue: AdB2CServiceMockValue, + notificationhubServiceMockValue: NotificationhubServiceMockValue, ): Promise<{ tasksService: TasksService; taskRepoService: TasksRepositoryService; @@ -63,8 +76,14 @@ export const makeTasksServiceMock = async ( return makeTasksRepositoryMock(tasksRepositoryMockValue); case UsersRepositoryService: return makeUsersRepositoryMock(usersRepositoryMockValue); + case UserGroupsRepositoryService: + return makeUserGroupsRepositoryMock(userGroupsRepositoryMockValue); case AdB2cService: return makeAdb2cServiceMock(adB2CServiceMockValue); + case NotificationhubService: + return makeNotificationhubServiceMock( + notificationhubServiceMockValue, + ); } }) .compile(); @@ -197,6 +216,86 @@ export const makeDefaultAdb2cServiceMockValue = (): AdB2CServiceMockValue => { }; }; +export const makeNotificationhubServiceMock = ( + value: NotificationhubServiceMockValue, +) => { + const { notify } = value; + + return { + notify: + notify instanceof Error + ? jest.fn, []>().mockRejectedValue(notify) + : jest.fn, []>().mockResolvedValue(notify), + }; +}; + +export const makeDefaultNotificationhubServiceMockValue = + (): NotificationhubServiceMockValue => { + return { + notify: undefined, + }; + }; + +export const makeUserGroupsRepositoryMock = ( + value: UserGroupsRepositoryMockValue, +) => { + const { getGroupMembersFromGroupIds } = value; + + return { + getGroupMembersFromGroupIds: + getGroupMembersFromGroupIds instanceof Error + ? jest + .fn, []>() + .mockRejectedValue(getGroupMembersFromGroupIds) + : jest + .fn, []>() + .mockResolvedValue(getGroupMembersFromGroupIds), + }; +}; + +export const makeDefaultUserGroupsRepositoryMockValue = + (): UserGroupsRepositoryMockValue => { + return { + getGroupMembersFromGroupIds: [ + { + id: 1, + user_group_id: 1, + user_id: 1, + created_by: 'test', + updated_by: 'test', + }, + { + id: 2, + user_group_id: 1, + user_id: 2, + created_by: 'test', + updated_by: 'test', + }, + { + id: 3, + user_group_id: 2, + user_id: 1, + created_by: 'test', + updated_by: 'test', + }, + { + id: 4, + user_group_id: 3, + user_id: 1, + created_by: 'test', + updated_by: 'test', + }, + { + id: 5, + user_group_id: 3, + user_id: 3, + created_by: 'test', + updated_by: 'test', + }, + ], + }; + }; + export const makeDefaultUsersRepositoryMockValue = (): UsersRepositoryMockValue => { const user1 = new User(); diff --git a/dictation_server/src/features/tasks/test/utility.ts b/dictation_server/src/features/tasks/test/utility.ts index 5c48d7b..5ea37eb 100644 --- a/dictation_server/src/features/tasks/test/utility.ts +++ b/dictation_server/src/features/tasks/test/utility.ts @@ -1,4 +1,6 @@ import { DataSource } from 'typeorm'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; import { User } from '../../../repositories/users/entity/user.entity'; import { Account } from '../../../repositories/accounts/entity/account.entity'; import { Task } from '../../../repositories/tasks/entity/task.entity'; @@ -6,6 +8,99 @@ import { AudioFile } from '../../../repositories/audio_files/entity/audio_file.e import { CheckoutPermission } from '../../../repositories/checkout_permissions/entity/checkout_permission.entity'; import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity'; import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity'; +import { UserGroupsRepositoryModule } from '../../../repositories/user_groups/user_groups.repository.module'; +import { TasksRepositoryModule } from '../../../repositories/tasks/tasks.repository.module'; +import { AuthModule } from '../../../features/auth/auth.module'; +import { AdB2cModule } from '../../../gateways/adb2c/adb2c.module'; +import { AccountsModule } from '../../../features/accounts/accounts.module'; +import { UsersModule } from '../../../features/users/users.module'; +import { FilesModule } from '../../../features/files/files.module'; +import { TasksModule } from '../../../features/tasks/tasks.module'; +import { SendGridModule } from '../../../features/../gateways/sendgrid/sendgrid.module'; +import { LicensesModule } from '../../../features/licenses/licenses.module'; +import { AccountsRepositoryModule } from '../../../repositories/accounts/accounts.repository.module'; +import { UsersRepositoryModule } from '../../../repositories/users/users.repository.module'; +import { LicensesRepositoryModule } from '../../../repositories/licenses/licenses.repository.module'; +import { AudioFilesRepositoryModule } from '../../../repositories/audio_files/audio_files.repository.module'; +import { AudioOptionItemsRepositoryModule } from '../../../repositories/audio_option_items/audio_option_items.repository.module'; +import { CheckoutPermissionsRepositoryModule } from '../../../repositories/checkout_permissions/checkout_permissions.repository.module'; +import { NotificationModule } from '../../../features//notification/notification.module'; +import { NotificationhubModule } from '../../../gateways/notificationhub/notificationhub.module'; +import { BlobstorageModule } from '../../../gateways/blobstorage/blobstorage.module'; +import { AuthGuardsModule } from '../../../common/guards/auth/authguards.module'; +import { SortCriteriaRepositoryModule } from '../../../repositories/sort_criteria/sort_criteria.repository.module'; +import { AuthService } from '../../../features/auth/auth.service'; +import { AccountsService } from '../../../features/accounts/accounts.service'; +import { UsersService } from '../../../features/users/users.service'; +import { NotificationhubService } from '../../../gateways/notificationhub/notificationhub.service'; +import { FilesService } from '../../../features/files/files.service'; +import { LicensesService } from '../../../features/licenses/licenses.service'; +import { TasksService } from '../../../features/tasks/tasks.service'; +import { + NotificationhubServiceMockValue, + makeNotificationhubServiceMock, +} from './tasks.service.mock'; + +export const makeTaskTestingModule = async ( + datasource: DataSource, + notificationhubServiceMockValue: NotificationhubServiceMockValue, +): Promise => { + try { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + envFilePath: ['.env.local', '.env'], + isGlobal: true, + }), + AuthModule, + AdB2cModule, + AccountsModule, + UsersModule, + FilesModule, + TasksModule, + UsersModule, + SendGridModule, + LicensesModule, + AccountsRepositoryModule, + UsersRepositoryModule, + LicensesRepositoryModule, + AudioFilesRepositoryModule, + AudioOptionItemsRepositoryModule, + TasksRepositoryModule, + CheckoutPermissionsRepositoryModule, + UserGroupsRepositoryModule, + UserGroupsRepositoryModule, + NotificationModule, + NotificationhubModule, + BlobstorageModule, + AuthGuardsModule, + SortCriteriaRepositoryModule, + ], + providers: [ + AuthService, + AccountsService, + UsersService, + NotificationhubService, + FilesService, + TasksService, + LicensesService, + ], + }) + .useMocker(async (token) => { + switch (token) { + case DataSource: + return datasource; + } + }) + .overrideProvider(NotificationhubService) + .useValue(makeNotificationhubServiceMock(notificationhubServiceMockValue)) + .compile(); + + return module; + } catch (e) { + console.log(e); + } +}; export const createAccount = async ( datasource: DataSource, diff --git a/dictation_server/src/gateways/notificationhub/notificationhub.service.ts b/dictation_server/src/gateways/notificationhub/notificationhub.service.ts index 684c92d..6c0958a 100644 --- a/dictation_server/src/gateways/notificationhub/notificationhub.service.ts +++ b/dictation_server/src/gateways/notificationhub/notificationhub.service.ts @@ -2,10 +2,17 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NotificationHubsClient, + createAppleNotificationBody, + createAppleNotification, + createTagExpression, + createFcmLegacyNotification, + createWindowsToastNotification, + createFirebaseLegacyNotificationBody, + createWindowsInstallation, createAppleInstallation, createFcmLegacyInstallation, - createWindowsInstallation, } from '@azure/notification-hubs'; +import { TAG_MAX_COUNT } from '../../constants'; import { PNS } from '../../constants'; @Injectable() export class NotificationhubService { @@ -70,4 +77,72 @@ export class NotificationhubService { this.logger.log(`[OUT] ${this.register.name}`); } } + + /** + * 指定したタグのユーザーに通知を送信する + * @param tags + * @param message + * @returns notify + */ + async notify(tags: string[], message: string): Promise { + this.logger.log(`[IN] ${this.notify.name}`); + + try { + // OR条件によるtag指定は20個までなので分割して送信する + const chunkTags = splitArrayInChunks(tags, TAG_MAX_COUNT); + + for (let index = 0; index < chunkTags.length; index++) { + const currentTags = chunkTags[index]; + const tagExpression = createTagExpression(currentTags); + + // Windows + try { + const body = `${message}`; + const notification = createWindowsToastNotification({ body }); + const result = await this.client.sendNotification(notification, { + tagExpression, + }); + this.logger.log(result); + } catch (e) { + this.logger.error(`error=${e}`); + } + // Apple + try { + const body = createAppleNotificationBody({ aps: { alert: message } }); + const notification = createAppleNotification({ body }); + const result = await this.client.sendNotification(notification, { + tagExpression, + }); + this.logger.log(result); + } catch (e) { + this.logger.error(`error=${e}`); + } + // Android + try { + const body = createFirebaseLegacyNotificationBody({ + data: { message: message }, + }); + const notification = createFcmLegacyNotification({ body }); + const result = await this.client.sendNotification(notification, { + tagExpression, + }); + this.logger.log(result); + } catch (e) { + this.logger.error(`error=${e}`); + } + } + } catch (e) { + throw e; + } finally { + this.logger.log(`[OUT] ${this.notify.name}`); + } + } } + +const splitArrayInChunks = (arr: string[], size: number): string[][] => { + const result: string[][] = []; + for (let i = 0; i < arr.length; i += size) { + result.push(arr.slice(i, i + size)); + } + return result; +}; diff --git a/dictation_server/src/repositories/user_groups/user_groups.repository.service.ts b/dictation_server/src/repositories/user_groups/user_groups.repository.service.ts index 491bfba..dbb167c 100644 --- a/dictation_server/src/repositories/user_groups/user_groups.repository.service.ts +++ b/dictation_server/src/repositories/user_groups/user_groups.repository.service.ts @@ -1,6 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { DataSource, IsNull } from 'typeorm'; +import { DataSource, In, IsNull } from 'typeorm'; import { UserGroup } from './entity/user_group.entity'; +import { Assignee } from '../../features/tasks/types/types'; +import { UserGroupMember } from './entity/user_group_member.entity'; +import { User } from '../users/entity/user.entity'; @Injectable() export class UserGroupsRepositoryService { @@ -19,4 +22,27 @@ export class UserGroupsRepositoryService { }); return value; } + /** + * ユーザーグループIDからユーザー所属一覧を取得する + * @param groupIds + * @returns users from groups + */ + async getGroupMembersFromGroupIds( + groupIds: number[], + ): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const groupMemberRepo = entityManager.getRepository(UserGroupMember); + + const groupMembers = await groupMemberRepo.find({ + relations: { + user: true, + }, + where: { + user_group_id: In(groupIds), + }, + }); + + return groupMembers; + }); + } }