Merged PR 547: 音声ファイルアップロード完了API修正(repository実装含む)

## 概要
[Task2971: 音声ファイルアップロード完了API修正(repository実装含む)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2971)

- 音声ファイルアップロード完了API修正
  - 自動ルーティング処理を追加
    - authorIDとworktypeの組み合わせでワークフロー(ルーティングルール)を取得し、そのワークフローに従って、タスクのチェックアウト候補を設定する。
  - チェックアウト候補に設定したユーザーに対して通知を行う処理を追加

## レビューポイント
- 自動ルーティング処理を実装しているメソッドのメソッド名はこれでよいか
  - ほかに思いつかなかったので
- AudioOptionItemのentityの定義はあっている?
  - がタスクにあるaudio_file_idに紐づいている感じになっている
- 自動ルーティング処理で失敗したときの挙動は認識あっているか
  - エラーログだけ出してAPIとしては成功とする
- テストケースは足りているか
- 古い形式で記述されていたタスク作成のテストを新しい形で作り替えたが、反映漏れている部分はあるか

## UIの変更
- Before/Afterのスクショなど
- スクショ置き場

## 動作確認状況
- ローカルで確認

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
saito.k 2023-11-07 08:48:53 +00:00
parent 8a2ca2b786
commit 36716dc408
15 changed files with 941 additions and 114 deletions

View File

@ -1 +1 @@
export const ADB2C_PREFIX = "adb2c-external-id:"
export const ADB2C_PREFIX = 'adb2c-external-id:';

View File

@ -7,7 +7,7 @@ import { ADB2C_PREFIX } from './constants';
*/
export const makeADB2CKey = (externalId: string): string => {
return `${ADB2C_PREFIX}${externalId}`;
}
};
/**
* ADB2Cのユーザー格納用のキーから外部ユーザーIDを取得する
@ -16,4 +16,4 @@ export const makeADB2CKey = (externalId: string): string => {
*/
export const restoreAdB2cID = (key: string): string => {
return key.replace(ADB2C_PREFIX, '');
}
};

View File

@ -7,11 +7,5 @@ import type {
} from './types';
import { isIDToken } from './typeguard';
export type {
AccessToken,
B2cMetadata,
IDToken,
JwkSignKey,
RefreshToken,
};
export type { AccessToken, B2cMetadata, IDToken, JwkSignKey, RefreshToken };
export { isIDToken };

View File

@ -150,8 +150,9 @@ export const createWorktype = async (
},
);
}
return worktype;
return (await datasource
.getRepository(Worktype)
.findOne({ where: { id: worktype.id } })) as Worktype;
};
// Worktypeを取得する

View File

@ -7,6 +7,8 @@ import { AudioOptionItemsRepositoryModule } from '../../repositories/audio_optio
import { TasksRepositoryModule } from '../../repositories/tasks/tasks.repository.module';
import { BlobstorageModule } from '../../gateways/blobstorage/blobstorage.module';
import { TemplateFilesRepositoryModule } from '../../repositories/template_files/template_files.repository.module';
import { UserGroupsRepositoryModule } from '../../repositories/user_groups/user_groups.repository.module';
import { NotificationhubModule } from '../../gateways/notificationhub/notificationhub.module';
@Module({
imports: [
@ -16,6 +18,8 @@ import { TemplateFilesRepositoryModule } from '../../repositories/template_files
TasksRepositoryModule,
BlobstorageModule,
TemplateFilesRepositoryModule,
UserGroupsRepositoryModule,
NotificationhubModule,
],
providers: [FilesService],
controllers: [FilesController],

View File

@ -7,7 +7,12 @@ import {
makeFilesServiceMock,
} from './test/files.service.mock';
import { DataSource } from 'typeorm';
import { createTask, makeTestingModuleWithBlob } from './test/utility';
import {
createTask,
createUserGroupAndMember,
getTaskFromJobNumber,
makeTestingModuleWithBlobAndNotification,
} from './test/utility';
import { FilesService } from './files.service';
import { makeContext } from '../../common/log';
import {
@ -22,6 +27,16 @@ import {
getTemplateFiles,
} from '../templates/test/utility';
import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service';
import { makeDefaultNotificationhubServiceMockValue } from '../tasks/test/tasks.service.mock';
import {
createWorkflow,
createWorkflowTypist,
} from '../workflows/test/utility';
import { createWorktype } from '../accounts/test/utility';
import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service';
import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { getCheckoutPermissions, getTask } from '../tasks/test/utility';
describe('音声ファイルアップロードURL取得', () => {
it('アップロードSASトークンが乗っているURLを返却する', async () => {
@ -109,23 +124,84 @@ describe('音声ファイルアップロードURL取得', () => {
});
});
describe('タスク作成', () => {
it('文字起こしタスクを作成できる', async () => {
const blobParam = makeBlobstorageServiceMockValue();
const userRepoParam = makeDefaultUsersRepositoryMockValue();
const taskRepoParam = makeDefaultTasksRepositoryMockValue();
const service = await makeFilesServiceMock(
blobParam,
userRepoParam,
taskRepoParam,
);
describe('タスク作成から自動ルーティング(DB使用)', () => {
let source: DataSource | null = null;
beforeEach(async () => {
source = new DataSource({
type: 'sqlite',
database: ':memory:',
logging: false,
entities: [__dirname + '/../../**/*.entity{.ts,.js}'],
synchronize: true, // trueにすると自動的にmigrationが行われるため注意
});
return source.initialize();
});
expect(
await service.uploadFinished(
afterEach(async () => {
if (!source) return;
await source.destroy();
source = null;
});
it('タスク作成時に、自動ルーティングを行うことができるAPIの引数として渡されたAuthorIDとworkType', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
author_id: undefined,
});
// ワークタイプを作成
const { id: worktypeId, custom_worktype_id } = await createWorktype(
source,
accountId,
'worktypeId',
);
// テンプレートファイルを作成
const { id: templateFileId } = await createTemplateFile(
source,
accountId,
'templateFile',
'http://blob/url/templateFile.zip',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
authorUserId,
worktypeId,
templateFileId,
);
// ワークフロータイピストを作成
await createWorkflowTypist(source, workflowId, typistUserId);
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
const result = await service.uploadFinished(
makeContext('trackingId'),
'userId',
authorExternalId,
'http://blob/url/file.zip',
'AUTHOR_01',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
@ -135,29 +211,331 @@ describe('タスク作成', () => {
'01',
'DS2',
'comment',
'workTypeID',
custom_worktype_id,
optionItemList,
false,
),
).toEqual({ jobNumber: '00000001' });
);
expect(result).toEqual({ jobNumber: '00000001' });
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${typistUserId}`],
makeNotifyMessage('M000101'),
);
// 作成したタスクを取得
const resultTask = await getTaskFromJobNumber(source, result.jobNumber);
// タスクのチェックアウト権限を取得
const resultCheckoutPermission = await getCheckoutPermissions(
source,
resultTask?.id ?? 0,
);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toEqual(templateFileId);
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(resultCheckoutPermission.length).toEqual(1);
expect(resultCheckoutPermission[0].user_id).toEqual(typistUserId);
});
it('日付フォーマットが不正な場合、エラーを返却する', async () => {
const blobParam = makeBlobstorageServiceMockValue();
const userRepoParam = makeDefaultUsersRepositoryMockValue();
const taskRepoParam = makeDefaultTasksRepositoryMockValue();
const service = await makeFilesServiceMock(
blobParam,
userRepoParam,
taskRepoParam,
it('別のタスクが既に存在する場合、タスク作成時に、自動ルーティングを行うことができるAPIの引数として渡されたAuthorIDとworkType', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
author_id: undefined,
});
//タスクを作成
await createTask(
source,
accountId,
'http://blob/url/file.zip',
'file.zip',
'01',
typistUserId,
authorAuthorId ?? '',
);
// ワークタイプを作成
const { id: worktypeId, custom_worktype_id } = await createWorktype(
source,
accountId,
'worktypeId',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
authorUserId,
worktypeId,
);
// ワークフロータイピストを作成
await createWorkflowTypist(source, workflowId, typistUserId);
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
const result = await service.uploadFinished(
makeContext('trackingId'),
authorExternalId,
'http://blob/url/file.zip',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
256,
'01',
'DS2',
'comment',
custom_worktype_id,
optionItemList,
false,
);
expect(result).toEqual({ jobNumber: '00000002' });
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${typistUserId}`],
makeNotifyMessage('M000101'),
);
// 作成したタスクを取得
const resultTask = await getTaskFromJobNumber(source, result.jobNumber);
// タスクのチェックアウト権限を取得
const resultCheckoutPermission = await getCheckoutPermissions(
source,
resultTask?.id ?? 0,
);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toBeNull();
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(resultCheckoutPermission.length).toEqual(1);
expect(resultCheckoutPermission[0].user_id).toEqual(typistUserId);
});
it('タスク作成時に、自動ルーティングを行うことができるAPI実行者のAuthorIDとworkType', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
// 音声ファイルの録音者のユーザー
const { author_id: authorAuthorId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
// ルーティング先のタイピストのユーザー
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
author_id: undefined,
});
// API実行者のユーザー
const { external_id: myExternalId, id: myUserId } = await makeTestUser(
source,
{
account_id: accountId,
external_id: 'my-author-user-external-id',
role: 'author',
author_id: 'MY_AUTHOR_ID',
},
);
// ワークタイプを作成
const { id: worktypeId, custom_worktype_id } = await createWorktype(
source,
accountId,
'worktypeId',
);
// テンプレートファイルを作成
const { id: templateFileId } = await createTemplateFile(
source,
accountId,
'templateFile',
'http://blob/url/templateFile.zip',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
myUserId, // API実行者のユーザーIDを設定
worktypeId,
templateFileId,
);
// ユーザーグループを作成
const { userGroupId } = await createUserGroupAndMember(
source,
accountId,
'userGroupName',
typistUserId, // ルーティング先のタイピストのユーザーIDを設定
);
// ワークフロータイピストを作成
await createWorkflowTypist(
source,
workflowId,
undefined,
userGroupId, // ルーティング先のユーザーグループIDを設定
);
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
const result = await service.uploadFinished(
makeContext('trackingId'),
myExternalId, // API実行者のユーザーIDを設定
'http://blob/url/file.zip',
authorAuthorId ?? '', // 音声ファイルの情報には、録音者のAuthorIDが入る
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
256,
'01',
'DS2',
'comment',
custom_worktype_id,
optionItemList,
false,
);
expect(result).toEqual({ jobNumber: '00000001' });
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${typistUserId}`],
makeNotifyMessage('M000101'),
);
// 作成したタスクを取得
const resultTask = await getTaskFromJobNumber(source, result.jobNumber);
// タスクのチェックアウト権限を取得
const resultCheckoutPermission = await getCheckoutPermissions(
source,
resultTask?.id ?? 0,
);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toEqual(templateFileId);
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(resultCheckoutPermission.length).toEqual(1);
expect(resultCheckoutPermission[0].user_group_id).toEqual(userGroupId);
});
it('ワークフローが見つからない場合、タスク作成時に、自動ルーティングを行うことができない', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
// 音声ファイルの録音者のユーザー
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const result = await service.uploadFinished(
makeContext('trackingId'),
authorExternalId, // API実行者のユーザーIDを設定
'http://blob/url/file.zip',
authorAuthorId ?? '', // 音声ファイルの情報には、録音者のAuthorIDが入る
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
256,
'01',
'DS2',
'comment',
'worktypeId',
optionItemList,
false,
);
expect(result).toEqual({ jobNumber: '00000001' });
// タスクを取得
const resultTask = await getTaskFromJobNumber(source, result.jobNumber);
// タスクのチェックアウト権限を取得
const resultCheckoutPermission = await getCheckoutPermissions(
source,
resultTask?.id ?? 0,
);
// タスクがあることを確認
expect(resultTask).not.toBeNull();
// 自動ルーティングが行われていないことを確認
expect(resultCheckoutPermission.length).toEqual(0);
});
it('日付フォーマットが不正な場合、エラーを返却する', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
await expect(
service.uploadFinished(
makeContext('trackingId'),
'userId',
authorExternalId,
'http://blob/url/file.zip',
'AUTHOR_01',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'yyyy-05-26T11:22:33.444',
@ -175,23 +553,36 @@ describe('タスク作成', () => {
new HttpException(makeErrorResponse('E010001'), HttpStatus.BAD_REQUEST),
);
});
it('オプションアイテムが10個ない場合、エラーを返却する', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const blobParam = makeBlobstorageServiceMockValue();
const userRepoParam = makeDefaultUsersRepositoryMockValue();
const taskRepoParam = makeDefaultTasksRepositoryMockValue();
const service = await makeFilesServiceMock(
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
userRepoParam,
taskRepoParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
await expect(
service.uploadFinished(
makeContext('trackingId'),
'userId',
authorExternalId,
'http://blob/url/file.zip',
'AUTHOR_01',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
@ -214,25 +605,25 @@ describe('タスク作成', () => {
new HttpException(makeErrorResponse('E010001'), HttpStatus.BAD_REQUEST),
);
});
it('タスク追加でユーザー情報の取得に失敗した場合、エラーを返却する', async () => {
if (!source) fail();
const blobParam = makeBlobstorageServiceMockValue();
const taskRepoParam = makeDefaultTasksRepositoryMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const service = await makeFilesServiceMock(
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
{
findUserByExternalId: new Error(''),
},
taskRepoParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
await expect(
service.uploadFinished(
makeContext('trackingId'),
'userId',
'authorExternalId',
'http://blob/url/file.zip',
'AUTHOR_01',
'authorAuthorId',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
@ -253,21 +644,37 @@ describe('タスク作成', () => {
),
);
});
it('タスクのDBへの追加に失敗した場合、エラーを返却する', async () => {
if (!source) fail();
const blobParam = makeBlobstorageServiceMockValue();
const userRepoParam = makeDefaultUsersRepositoryMockValue();
const service = await makeFilesServiceMock(blobParam, userRepoParam, {
create: new Error(''),
getTasksFromAccountId: new Error(),
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const taskRepoService = module.get<TasksRepositoryService>(
TasksRepositoryService,
);
taskRepoService.create = jest.fn().mockRejectedValue(new Error(''));
const { id: accountId } = await makeTestSimpleAccount(source);
const { external_id: authorExternalId, author_id: authorAuthorId } =
await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
await expect(
service.uploadFinished(
makeContext('trackingId'),
'userId',
authorExternalId,
'http://blob/url/file.zip',
'AUTHOR_01',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
@ -338,7 +745,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -381,7 +793,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -430,7 +847,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -470,7 +892,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -497,7 +924,12 @@ describe('音声ファイルダウンロードURL取得', () => {
const blobParam = makeBlobstorageServiceMockValue();
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -541,7 +973,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = false;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -604,7 +1041,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -641,7 +1083,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -686,7 +1133,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -726,7 +1178,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -753,7 +1210,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
const blobParam = makeBlobstorageServiceMockValue();
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -796,7 +1258,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = false;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);

View File

@ -24,6 +24,10 @@ import {
import { Context } from '../../common/log';
import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service';
import { AccountNotFoundError } from '../../repositories/accounts/errors/types';
import { Task } from '../../repositories/tasks/entity/task.entity';
import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service';
@Injectable()
export class FilesService {
@ -34,6 +38,8 @@ export class FilesService {
private readonly tasksRepositoryService: TasksRepositoryService,
private readonly templateFilesRepository: TemplateFilesRepositoryService,
private readonly blobStorageService: BlobstorageService,
private readonly userGroupsRepositoryService: UserGroupsRepositoryService,
private readonly notificationhubService: NotificationhubService,
) {}
/**
@ -157,7 +163,7 @@ export class FilesService {
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
let task: Task;
try {
// URLにSASトークンがついている場合は取り除く
const urlObj = new URL(url);
@ -167,7 +173,7 @@ export class FilesService {
// 文字起こしタスク追加(音声ファイルとオプションアイテムも同時に追加)
// 追加時に末尾のJOBナンバーにインクリメントする
const task = await this.tasksRepositoryService.create(
task = await this.tasksRepositoryService.create(
user.account_id,
user.id,
priority,
@ -185,13 +191,57 @@ export class FilesService {
isEncrypted,
optionItemList,
);
return { jobNumber: task.job_number };
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// ルーティング設定に従い、チェックアウト権限を付与する
const { typistGroupIds, typistIds } =
await this.tasksRepositoryService.autoRouting(
task.audio_file_id,
user.account_id,
workType,
user.author_id ?? undefined,
);
const groupMembers =
await this.userGroupsRepositoryService.getGroupMembersFromGroupIds(
typistGroupIds,
);
// 重複のない割り当て候補ユーザーID一覧を取得する
const distinctUserIds = [
...new Set([...typistIds, ...groupMembers.map((x) => x.user_id)]),
];
// 割り当てられたユーザーがいない場合は通知不要
if (distinctUserIds.length === 0) {
this.logger.log('No user assigned.');
return { jobNumber: task.job_number };
}
// タグを生成
const tags = distinctUserIds.map((x) => `user_${x}`);
this.logger.log(`tags: ${tags}`);
// タグ対象に通知送信
await this.notificationhubService.notify(
context,
tags,
makeNotifyMessage('M000101'),
);
// 追加したタスクのJOBナンバーを返却
return { jobNumber: task.job_number };
} catch (error) {
// 処理の本筋はタスク生成のため自動ルーティングに失敗してもエラーにしない
this.logger.error(`Automatic routing or notification failed.`);
this.logger.error(`error=${error}`);
return { jobNumber: task.job_number };
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.uploadFinished.name}`,

View File

@ -6,6 +6,8 @@ import { FilesService } from '../files.service';
import { TasksRepositoryService } from '../../../repositories/tasks/tasks.repository.service';
import { Task } from '../../../repositories/tasks/entity/task.entity';
import { TemplateFilesRepositoryService } from '../../../repositories/template_files/template_files.repository.service';
import { NotificationhubService } from '../../../gateways/notificationhub/notificationhub.service';
import { UserGroupsRepositoryService } from '../../../repositories/user_groups/user_groups.repository.service';
export type BlobstorageServiceMockValue = {
createContainer: void | Error;
@ -42,6 +44,10 @@ export const makeFilesServiceMock = async (
return makeTasksRepositoryMock(tasksRepositoryMockValue);
case TemplateFilesRepositoryService:
return {};
case NotificationhubService:
return {};
case UserGroupsRepositoryService:
return {};
}
})
.compile();
@ -186,6 +192,9 @@ export const makeDefaultTasksRepositoryMockValue =
option_items: null,
template_file: null,
typist_user: null,
created_by: null,
updated_by: null,
updated_at: new Date(),
},
getTasksFromAccountId: {
tasks: [],

View File

@ -37,6 +37,12 @@ import {
makeBlobstorageServiceMock,
} from './files.service.mock';
import { TemplateFile } from '../../../repositories/template_files/entity/template_file.entity';
import {
NotificationhubServiceMockValue,
makeNotificationhubServiceMock,
} from '../../tasks/test/tasks.service.mock';
import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity';
import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity';
export const createTask = async (
datasource: DataSource,
@ -95,9 +101,51 @@ export const createTask = async (
return { audioFileId: audioFile.id };
};
export const makeTestingModuleWithBlob = async (
export const getTaskFromJobNumber = async (
datasource: DataSource,
jobNumber: string,
): Promise<Task | null> => {
const task = await datasource.getRepository(Task).findOne({
where: {
job_number: jobNumber,
},
});
return task;
};
// ユーザーグループとユーザーグループメンバーを作成する
export const createUserGroupAndMember = async (
datasource: DataSource,
accountId: number,
name: string,
userId: number,
): Promise<{ userGroupId: number }> => {
const { identifiers } = await datasource.getRepository(UserGroup).insert({
account_id: accountId,
name: name,
deleted_at: null,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const userGroup = identifiers.pop() as UserGroup;
// ユーザーグループメンバーを作成する
await datasource.getRepository(UserGroupMember).insert({
user_group_id: userGroup.id,
user_id: userId,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
return { userGroupId: userGroup.id };
};
export const makeTestingModuleWithBlobAndNotification = async (
datasource: DataSource,
blobStorageService: BlobstorageServiceMockValue,
notificationhubService: NotificationhubServiceMockValue,
): Promise<TestingModule | undefined> => {
try {
const module: TestingModule = await Test.createTestingModule({
@ -148,6 +196,8 @@ export const makeTestingModuleWithBlob = async (
})
.overrideProvider(BlobstorageService)
.useValue(makeBlobstorageServiceMock(blobStorageService))
.overrideProvider(NotificationhubService)
.useValue(makeNotificationhubServiceMock(notificationhubService))
.compile();
return module;

View File

@ -208,6 +208,9 @@ describe('TasksService', () => {
template_file_id: null,
typist_user: null,
template_file: null,
created_by: null,
updated_by: null,
updated_at: new Date('2023-01-01T01:01:01.000'),
file: {
id: 1,
account_id: 1,

View File

@ -362,6 +362,9 @@ const defaultTasksRepositoryMockValue: {
template_file_id: null,
typist_user: null,
template_file: null,
created_by: null,
updated_by: null,
updated_at: new Date('2023-01-01T01:01:01.000Z'),
option_items: [
{
id: 1,

View File

@ -337,7 +337,7 @@ export const makeDefaultAdB2cMockValue = (): AdB2cMockValue => {
},
createUser: '001',
getUser: {
id: "xxxx-xxxxx-xxxxx-xxxx",
id: 'xxxx-xxxxx-xxxxx-xxxx',
displayName: 'Hanako Sato',
},
getUsers: AdB2cMockUsers,

View File

@ -10,6 +10,8 @@ import {
JoinColumn,
OneToMany,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { bigintTransformer } from '../../../common/entity';
@ -37,8 +39,24 @@ export class Task {
started_at: Date | null;
@Column({ nullable: true, type: 'datetime' })
finished_at: Date | null;
@Column({})
@Column({ nullable: true, type: 'datetime' })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date;
@Column({ nullable: true, type: 'datetime' })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
@OneToOne(() => AudioFile, (audiofile) => audiofile.task)
@JoinColumn({ name: 'audio_file_id' })
file: AudioFile | null;

View File

@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import {
DataSource,
EntityManager,
FindOptionsOrder,
FindOptionsOrderValue,
In,
IsNull,
Repository,
} from 'typeorm';
import { Task } from './entity/task.entity';
import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants';
@ -35,6 +37,8 @@ import {
import { Roles } from '../../common/types/role';
import { TaskStatus, isTaskStatus } from '../../common/types/taskStatus';
import { SortCriteria } from '../sort_criteria/entity/sort_criteria.entity';
import { Workflow } from '../workflows/entity/workflow.entity';
import { Worktype } from '../worktypes/entity/worktype.entity';
@Injectable()
export class TasksRepositoryService {
@ -710,18 +714,6 @@ export class TasksRepositoryService {
task.audio_file_id = savedAudioFile.id;
const optionItems = paramOptionItems.map((x) => {
return {
audio_file_id: savedAudioFile.id,
label: x.optionItemLabel,
value: x.optionItemValue,
};
});
const optionItemRepo = entityManager.getRepository(AudioOptionItem);
const newAudioOptionItems = optionItemRepo.create(optionItems);
await optionItemRepo.save(newAudioOptionItems);
const taskRepo = entityManager.getRepository(Task);
// アカウント内でJOBナンバーが有効なタスクのうち最新のものを取得
@ -743,8 +735,19 @@ export class TasksRepositoryService {
}
task.job_number = newJobNumber;
const newTask = taskRepo.create(task);
const persisted = await taskRepo.save(newTask);
const persisted = await taskRepo.save(task);
const optionItems = paramOptionItems.map((x) => {
return {
audio_file_id: persisted.audio_file_id,
label: x.optionItemLabel,
value: x.optionItemValue,
};
});
const optionItemRepo = entityManager.getRepository(AudioOptionItem);
const newAudioOptionItems = optionItemRepo.create(optionItems);
await optionItemRepo.save(newAudioOptionItems);
return persisted;
},
);
@ -952,6 +955,231 @@ export class TasksRepositoryService {
return tasks;
});
}
/**
* worktypeIdをもとにルーティングルールを取得し
* @param audioFileId
* @param accountId
* @param worktypeId
* @param [myAuthorId]
* @returns typistIds: タイピストIDの一覧 / typistGroupIds: タイピストグループIDの一覧
*/
async autoRouting(
audioFileId: number,
accountId: number,
worktypeId: string, // ユーザーが任意につけるworktypeId(DBのcustom_worktype_id)
myAuthorId?: string, // API実行者のAuthorId
): Promise<{ typistIds: number[]; typistGroupIds: number[] }> {
return await this.dataSource.transaction(async (entityManager) => {
// 音声ファイルを取得
const audioFileRepo = entityManager.getRepository(AudioFile);
const audioFile = await audioFileRepo.findOne({
relations: {
task: true,
},
where: {
id: audioFileId,
account_id: accountId,
},
});
if (!audioFile) {
throw new Error(
`audio file not found. audio_file_id:${audioFileId}, accountId:${accountId}`,
);
}
const { task } = audioFile;
if (!task) {
throw new Error(
`task not found. audio_file_id:${audioFileId}, accountId:${accountId}`,
);
}
// authorIdをもとにユーザーを取得
const userRepo = entityManager.getRepository(User);
const authorUser = await userRepo.findOne({
where: {
author_id: audioFile.author_id,
account_id: accountId,
},
});
if (!authorUser) {
throw new Error(
`user not found. authorId:${audioFile.author_id}, accountId:${accountId}`,
);
}
// ユーザーが任意につけるworktypeIdをもとにworktypeを取得
const worktypeRepo = entityManager.getRepository(Worktype);
const worktypeRecord = await worktypeRepo.findOne({
where: {
custom_worktype_id: worktypeId,
account_id: accountId,
},
});
if (!worktypeRecord) {
throw new Error(
`worktype not found. worktype:${worktypeId}, accountId:${accountId}`,
);
}
// Workflowルーティングルールを取得
const workflowRepo = entityManager.getRepository(Workflow);
const workflow = await workflowRepo.findOne({
relations: {
workflowTypists: true,
},
where: {
account_id: accountId,
author_id: authorUser.id,
worktype_id: worktypeRecord.id,
},
});
// Workflowルーティングルールがあればタスクのチェックアウト権限を設定する
if (workflow) {
return await this.setCheckoutPermissionAndTemplate(
workflow,
task,
accountId,
entityManager,
userRepo,
);
}
// 音声ファイルの情報からルーティングルールを取得できない場合は、
// API実行者のAuthorIdと音声ファイルのWorktypeをもとにルーティングルールを取得する
// API実行者のAuthorIdがない場合はエラーを出して終了
if (!myAuthorId) {
throw new Error(`There is no AuthorId for the API executor.`);
}
// API実行者のAuthorIdをもとにユーザーを取得
const myAuthorUser = await userRepo.findOne({
where: {
author_id: myAuthorId,
account_id: accountId,
},
});
if (!myAuthorUser) {
throw new Error(
`user not found. authorId:${myAuthorId}, accountId:${accountId}`,
);
}
const defaultWorkflow = await workflowRepo.findOne({
relations: {
workflowTypists: true,
},
where: {
account_id: accountId,
author_id: myAuthorUser.id,
worktype_id: worktypeRecord.id,
},
});
// API実行者のAuthorIdと音声ファイルのWorktypeをもとにルーティングルールを取得できない場合はエラーを出して終了
if (!defaultWorkflow) {
throw new Error(
`workflow not found. authorUserId:${myAuthorUser.id}, accountId:${accountId}, worktype:${worktypeId}`,
);
}
// Workflowルーティングルールがあればタスクのチェックアウト権限を設定する
return await this.setCheckoutPermissionAndTemplate(
defaultWorkflow,
task,
accountId,
entityManager,
userRepo,
);
});
}
/**
* workflowに紐づけられているタイピスト
* workflowに紐づけられているテンプレートファイルIDをタスクに設定
*
* @param workflow
* @param task
* @param accountId
* @param entityManager
* @param userRepo
* @returns checkout permission
*/
private async setCheckoutPermissionAndTemplate(
workflow: Workflow,
task: Task,
accountId: number,
entityManager: EntityManager,
userRepo: Repository<User>,
): Promise<{ typistIds: number[]; typistGroupIds: number[] }> {
const { workflowTypists, template_id } = workflow;
if (!workflowTypists) {
throw new Error(`workflowTypists not found. workflowId:${workflow.id}`);
}
// タスクのテンプレートIDを更新
const taskRepo = entityManager.getRepository(Task);
await taskRepo.update(
{ id: task.id },
{
template_file_id: template_id,
},
);
// 取得したルーティングルールのタイピストまたはタイピストグループをチェックアウト権限に設定する
// ルーティング候補ユーザーの存在確認
const typistIds = workflowTypists.flatMap((typist) =>
typist.typist_id ? [typist.typist_id] : [],
);
const typistUsers = await userRepo.find({
where: { account_id: accountId, id: In(typistIds) },
});
if (typistUsers.length !== typistIds.length) {
throw new Error(`typist not found. ids: ${typistIds}`);
}
// ルーティング候補ユーザーグループの存在確認
const groupIds = workflowTypists.flatMap((typist) => {
return typist.typist_group_id ? [typist.typist_group_id] : [];
});
const userGroupRepo = entityManager.getRepository(UserGroup);
const typistGroups = await userGroupRepo.find({
where: { account_id: accountId, id: In(groupIds) },
});
if (typistGroups.length !== groupIds.length) {
throw new Error(`typist group not found. ids: ${groupIds}`);
}
const checkoutPermissionRepo =
entityManager.getRepository(CheckoutPermission);
// 当該タスクに紐づく既存checkoutPermissionをdelete
await checkoutPermissionRepo.delete({
task_id: task.id,
});
// ルーティング候補ユーザーのチェックアウト権限を作成
const typistPermissions = typistUsers.map((typistUser) => {
const permission = new CheckoutPermission();
permission.task_id = task.id;
permission.user_id = typistUser.id;
return permission;
});
// ルーティング候補ユーザーグループのチェックアウト権限を作成
const typistGroupPermissions = typistGroups.map((typistGroup) => {
const permission = new CheckoutPermission();
permission.task_id = task.id;
permission.user_group_id = typistGroup.id;
return permission;
});
const permissions = [...typistPermissions, ...typistGroupPermissions];
await checkoutPermissionRepo.save(permissions);
// user_idsとuser_group_idsを返却する
return {
typistIds: typistIds,
typistGroupIds: groupIds,
};
}
}
// ソート用オブジェクトを生成する