import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service'; import { AccessToken } from '../../common/token'; import { Assignee, Task } from './types/types'; import { Task as TaskEntity } from '../../repositories/tasks/entity/task.entity'; import { createTasks } from './types/convert'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { SortDirection, TaskListSortableAttribute, } from '../../common/types/sort'; import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants'; import { AdB2cService, Adb2cTooManyRequestsError, } from '../../gateways/adb2c/adb2c.service'; import { AdB2cUser } from '../../gateways/adb2c/types/types'; import { CheckoutPermission } from '../../repositories/checkout_permissions/entity/checkout_permission.entity'; import { AccountNotMatchError, AlreadyHasInProgressTaskError, CheckoutPermissionNotFoundError, StatusNotMatchError, TaskAuthorIdNotMatchError, TasksNotFoundError, TypistUserGroupNotFoundError, TypistUserNotFoundError, TypistUserNotMatchError, } 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 { Context } from '../../common/log'; import { User } from '../../repositories/users/entity/user.entity'; @Injectable() export class TasksService { private readonly logger = new Logger(TasksService.name); constructor( private readonly taskRepository: TasksRepositoryService, private readonly usersRepository: UsersRepositoryService, private readonly userGroupsRepositoryService: UserGroupsRepositoryService, private readonly adB2cService: AdB2cService, private readonly notificationhubService: NotificationhubService, ) {} async getTasks( context: Context, userId: string, roles: Roles[], offset: number, limit: number, status?: string[], paramName?: TaskListSortableAttribute, direction?: SortDirection, ): Promise<{ tasks: Task[]; total: number }> { this.logger.log( `[IN] [${context.getTrackingId()}] ${this.getTasks.name} | params: { ` + `userId: ${userId}, ` + `roles: ${roles}, ` + `offset: ${offset},` + `limit: ${limit}, ` + `status: ${status}, ` + `paramName: ${paramName}, ` + `direction: ${direction} };`, ); // パラメータが省略された場合のデフォルト値: 保存するソート条件の値の初期値と揃える const defaultParamName: TaskListSortableAttribute = 'JOB_NUMBER'; const defaultDirection: SortDirection = 'ASC'; // statusが省略された場合のデフォルト値: 全てのステータス const defaultStatus = Object.values(TASK_STATUS); try { const { account_id, author_id } = await this.usersRepository.findUserByExternalId(userId); if (roles.includes(ADMIN_ROLES.ADMIN)) { const result = await this.taskRepository.getTasksFromAccountId( account_id, offset, limit, paramName ?? defaultParamName, direction ?? defaultDirection, status ?? defaultStatus, ); // B2Cからユーザー名を取得する const b2cUsers = await this.getB2cUsers( context, result.tasks, result.permissions, ); const tasks = createTasks(result.tasks, result.permissions, b2cUsers); return { tasks: tasks, total: result.count }; } if (roles.includes(USER_ROLES.AUTHOR)) { // API実行者がAuthorで、AuthorIDが存在しないことは想定外のため、エラーとする if (!author_id) { throw new Error('AuthorID not found'); } const result = await this.taskRepository.getTasksFromAuthorIdAndAccountId( author_id, account_id, offset, limit, paramName ?? defaultParamName, direction ?? defaultDirection, status ?? defaultStatus, ); // B2Cからユーザー名を取得する const b2cUsers = await this.getB2cUsers( context, result.tasks, result.permissions, ); const tasks = createTasks(result.tasks, result.permissions, b2cUsers); return { tasks: tasks, total: result.count }; } if (roles.includes(USER_ROLES.TYPIST)) { const result = await this.taskRepository.getTasksFromTypistRelations( userId, offset, limit, paramName ?? defaultParamName, direction ?? defaultDirection, status ?? defaultStatus, ); // B2Cからユーザー名を取得する const b2cUsers = await this.getB2cUsers( context, result.tasks, result.permissions, ); const tasks = createTasks(result.tasks, result.permissions, b2cUsers); return { tasks: tasks, total: result.count }; } throw new Error(`invalid roles: ${roles.join(',')}`); } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); if (e instanceof Error) { if (e.constructor === Adb2cTooManyRequestsError) { throw new HttpException( makeErrorResponse('E000301'), HttpStatus.INTERNAL_SERVER_ERROR, ); } } throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { this.logger.log( `[OUT] [${context.getTrackingId()}] ${this.getTasks.name}`, ); } } /** * 完了したタスクの次のタスクを取得します * @param context * @param externalId * @param fileId * @returns next task */ async getNextTask( context: Context, externalId: string, fileId: number, ): Promise { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.getNextTask.name } | params: { externalId: ${externalId}, fileId: ${fileId} };`, ); try { const { account_id: accountId, id } = await this.usersRepository.findUserByExternalId(externalId); // タスク一覧を取得する const tasks = await this.taskRepository.getSortedTasks( accountId, id, fileId, ); // 指定タスクのインデックスを取得する const targetTaskIndex = tasks.findIndex( (x) => x.audio_file_id === fileId, ); // 指定したタスクが見つからない場合はエラーとする(リポジトリからは必ず取得できる想定) if (targetTaskIndex === -1) { throw new TasksNotFoundError(`task not found: ${fileId}`); } // ソート順に並んだタスクについて、指定した完了済みタスクの次のタスクを取得する let nextTaskIndex = targetTaskIndex + 1; // 次のタスクがない場合は先頭のタスクを返す if (tasks.length - 1 < nextTaskIndex) { nextTaskIndex = 0; } const nextTask = tasks[nextTaskIndex]; // 先頭のタスクが指定した完了済みタスクの場合は次のタスクがないためundefinedを返す return nextTask.audio_file_id === fileId ? undefined : nextTask.audio_file_id; } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); if (e instanceof Error) { switch (e.constructor) { case TasksNotFoundError: throw new HttpException( makeErrorResponse('E010603'), HttpStatus.BAD_REQUEST, ); default: throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } } throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { this.logger.log( `[OUT] [${context.getTrackingId()}] ${this.getNextTask.name}`, ); } } /** * 指定した音声ファイルに紐づくタスクをcheckoutする * @param audioFileId * @param roles * @param externalId * @returns checkout */ async checkout( context: Context, audioFileId: number, roles: Roles[], externalId: string, ): Promise { try { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.checkout.name } | params: { audioFileId: ${audioFileId}, roles: ${roles}, externalId: ${externalId} };`, ); const { id, account_id, author_id } = await this.usersRepository.findUserByExternalId(externalId); if (roles.includes(USER_ROLES.AUTHOR)) { // API実行者がAuthorで、AuthorIDが存在しないことは想定外のため、エラーとする if (!author_id) { throw new Error('AuthorID not found'); } await this.taskRepository.getTaskFromAudioFileId( audioFileId, account_id, author_id, ); return; } if (roles.includes(USER_ROLES.TYPIST)) { return await this.taskRepository.checkout(audioFileId, account_id, id, [ TASK_STATUS.UPLOADED, TASK_STATUS.PENDING, TASK_STATUS.IN_PROGRESS, ]); } throw new InvalidRoleError(`invalid roles: ${roles.join(',')}`); } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); if (e instanceof Error) { switch (e.constructor) { case CheckoutPermissionNotFoundError: case TaskAuthorIdNotMatchError: case InvalidRoleError: throw new HttpException( makeErrorResponse('E010602'), HttpStatus.BAD_REQUEST, ); case TasksNotFoundError: throw new HttpException( makeErrorResponse('E010601'), HttpStatus.NOT_FOUND, ); case AccountNotMatchError: case StatusNotMatchError: case AlreadyHasInProgressTaskError: throw new HttpException( makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST, ); default: throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } } throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { this.logger.log( `[OUT] [${context.getTrackingId()}] ${this.checkout.name}`, ); } } /** * 指定した音声ファイルに紐づくタスクをcheckinする * @param audioFileId * @param externalId * @returns checkin */ async checkin( context: Context, audioFileId: number, externalId: string, ): Promise { try { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.checkin.name } | params: { audioFileId: ${audioFileId}, externalId: ${externalId} };`, ); const { id } = await this.usersRepository.findUserByExternalId( externalId, ); return await this.taskRepository.checkin( audioFileId, id, TASK_STATUS.IN_PROGRESS, ); } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); if (e instanceof Error) { switch (e.constructor) { case TasksNotFoundError: throw new HttpException( makeErrorResponse('E010603'), HttpStatus.NOT_FOUND, ); case StatusNotMatchError: case TypistUserNotMatchError: throw new HttpException( makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST, ); default: throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } } throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { this.logger.log( `[OUT] [${context.getTrackingId()}] ${this.checkin.name}`, ); } } /** * 指定した音声ファイルに紐づくタスクをキャンセルする * @param audioFileId * @param externalId * @param role * @returns cancel */ async cancel( context: Context, audioFileId: number, externalId: string, role: Roles[], ): Promise { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.cancel.name } | params: { audioFileId: ${audioFileId}, externalId: ${externalId}, role: ${role} };`, ); let user: User; try { // ユーザー取得 user = await this.usersRepository.findUserByExternalId(externalId); } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); this.logger.log(`[OUT] [${context.getTrackingId()}] ${this.cancel.name}`); throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } try { // roleにAdminが含まれていれば、文字起こし担当でなくてもキャンセルできるため、ユーザーIDは指定しない await this.taskRepository.cancel( audioFileId, [TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING], user.account_id, role.includes(ADMIN_ROLES.ADMIN) ? undefined : user.id, ); } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); if (e instanceof Error) { switch (e.constructor) { case TasksNotFoundError: throw new HttpException( makeErrorResponse('E010603'), HttpStatus.NOT_FOUND, ); case StatusNotMatchError: case TypistUserNotMatchError: throw new HttpException( makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST, ); default: throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } } throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } try { // キャンセルしたタスクに自動ルーティングを行う const { typistGroupIds, typistIds } = await this.taskRepository.autoRouting( audioFileId, user.account_id, user.author_id ?? undefined, ); // 通知を送信する await this.sendNotify( context, typistIds, typistGroupIds, audioFileId, user.account_id, ); } catch (e) { // 処理の本筋はタスクキャンセルのため自動ルーティングに失敗してもエラーにしない this.logger.error( `[${context.getTrackingId()}] Automatic routing or notification failed.`, ); this.logger.error(`[${context.getTrackingId()}] error=${e}`); } finally { this.logger.log(`[OUT] [${context.getTrackingId()}] ${this.cancel.name}`); } } /** * 指定した音声ファイルに紐づくタスクをsuspendする * @param audioFileId * @param externalId * @returns suspend */ async suspend( context: Context, audioFileId: number, externalId: string, ): Promise { try { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.suspend.name } | params: { audioFileId: ${audioFileId}, externalId: ${externalId} };`, ); const { id } = await this.usersRepository.findUserByExternalId( externalId, ); return await this.taskRepository.suspend( audioFileId, id, TASK_STATUS.IN_PROGRESS, ); } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); if (e instanceof Error) { switch (e.constructor) { case TasksNotFoundError: throw new HttpException( makeErrorResponse('E010603'), HttpStatus.NOT_FOUND, ); case StatusNotMatchError: case TypistUserNotMatchError: throw new HttpException( makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST, ); default: throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } } throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { this.logger.log( `[OUT] [${context.getTrackingId()}] ${this.suspend.name}`, ); } } private async getB2cUsers( context: Context, tasks: TaskEntity[], permissions: CheckoutPermission[], ): Promise { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.getB2cUsers.name } | params: { tasks: ${tasks}, permissions: ${permissions} };`, ); // 割り当て候補の外部IDを列挙 const assigneesExternalIds = permissions.flatMap((permission) => permission.user ? [permission.user.external_id] : [], ); // 割り当てられているタイピストの外部IDを列挙 const typistExternalIds = tasks.flatMap((task) => task.typist_user ? [task.typist_user.external_id] : [], ); //重複をなくす const distinctedExternalIds = [ ...new Set(assigneesExternalIds.concat(typistExternalIds)), ]; // B2Cからユーザー名を取得する return await this.adB2cService.getUsers(context, distinctedExternalIds); } /** * 文字起こし候補を変更する * @param audioFileId * @param assignees * @returns checkout permission */ async changeCheckoutPermission( context: Context, audioFileId: number, assignees: Assignee[], externalId: string, role: Roles[], ): Promise { try { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.changeCheckoutPermission.name } | params: { audioFileId: ${audioFileId}, assignees: ${assignees}, externalId: ${externalId}, role: ${role} };`, ); const { author_id, account_id } = await this.usersRepository.findUserByExternalId(externalId); // RoleがAuthorで、AuthorIDが存在しないことは想定外のため、エラーとする if (role.includes(USER_ROLES.AUTHOR) && !author_id) { throw new Error('AuthorID not found'); } await this.taskRepository.changeCheckoutPermission( audioFileId, author_id ?? undefined, account_id, role, assignees, ); // すべての割り当て候補ユーザーを取得する const assigneesGroupIds = assignees .filter((assignee) => assignee.typistGroupId) .flatMap((assignee) => assignee.typistGroupId ? [assignee.typistGroupId] : [], ); const assigneesUserIds = assignees .filter((assignee) => assignee.typistUserId) .flatMap((assignee) => assignee.typistUserId ? [assignee.typistUserId] : [], ); // 通知を送信する await this.sendNotify( context, assigneesUserIds, assigneesGroupIds, audioFileId, account_id, ); } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); if (e instanceof Error) { switch (e.constructor) { case TypistUserNotFoundError: case TypistUserGroupNotFoundError: throw new HttpException( makeErrorResponse('E010204'), HttpStatus.BAD_REQUEST, ); case TasksNotFoundError: throw new HttpException( makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST, ); default: throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } } throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { this.logger.log( `[OUT] [${context.getTrackingId()}] ${ this.changeCheckoutPermission.name }`, ); } } // 通知を送信するプライベートメソッド private async sendNotify( context: Context, typistUserIds: number[], typistGroupIds: number[], audioFileId: number, accountId: number, ): Promise { this.logger.log( `[IN] [${context.getTrackingId()}] ${this.sendNotify.name} | params: { ` + `typistUserIds: ${typistUserIds}, ` + `typistGroupIds: ${typistGroupIds}, ` + `audioFileId: ${audioFileId}, ` + `accountId: ${accountId} };`, ); const groupMembers = await this.userGroupsRepositoryService.getGroupMembersFromGroupIds( typistGroupIds, ); // 重複のない割り当て候補ユーザーID一覧を取得する const distinctUserIds = [ ...new Set([...typistUserIds, ...groupMembers.map((x) => x.user_id)]), ]; // 割り当てられたユーザーがいない場合は通知不要 if (distinctUserIds.length === 0) { this.logger.log(`[${context.getTrackingId()}] No user assigned.`); return; } // タグを生成 const tags = distinctUserIds.map((x) => `user_${x}`); this.logger.log(`[${context.getTrackingId()}] tags: ${tags}`); // 通知内容に含む音声ファイル情報を取得 const { file } = await this.taskRepository.getTaskAndAudioFile( audioFileId, accountId, [TASK_STATUS.UPLOADED], ); if (!file) { throw new Error('audio file not found'); } // タグ対象に通知送信 await this.notificationhubService.notify(context, tags, { authorId: file.author_id, filename: file.file_name.replace('.zip', ''), priority: file.priority === '00' ? 'Normal' : 'High', uploadedAt: file.uploaded_at.toISOString(), }); } }