Merged PR 948: PH1エンハンス先行リリース対応

## 概要
[ユーザー ストーリー 4489: 【PH1エンハンス】Dictation Finishedになったファイルのステータスを変更したい](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/OMDSDictation-2nd/_workitems/edit/4489)
[ユーザー ストーリー 4491: 【PH1エンハンス】通知にユーザーIDを付加する](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/OMDSDictation-2nd/_workitems/edit/4491)
This commit is contained in:
x.itou.t 2024-11-11 05:23:38 +00:00
parent ad397f6fe7
commit a07cfe51aa
21 changed files with 1301 additions and 93 deletions

View File

@ -0,0 +1,363 @@
# Pipeline側でKeyVaultやDocker、AppService等に対する操作権限を持ったServiceConnectionを作成しておくこと
# また、環境変数 STATIC_DICTATION_DEPLOYMENT_TOKEN の値として静的WebAppsのデプロイトークンを設定しておくこと
trigger:
branches:
include:
- release-ph1-enhance
tags:
include:
- stage-*
jobs:
- job: initialize
displayName: Initialize
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
persistCredentials: true
- script: |
git fetch origin release-ph1-enhance:release-ph1-enhance
if git merge-base --is-ancestor $(Build.SourceVersion) release-ph1-enhance; then
echo "This commit is in the release-ph1-enhance branch."
else
echo "This commit is not in the release-ph1-enhance branch."
exit 1
fi
displayName: 'タグが付けられたCommitがrelease-ph1-enhanceブランチに存在するか確認'
- job: backend_test
dependsOn: initialize
condition: succeeded('initialize')
displayName: UnitTest
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Bash@3
displayName: Bash Script (Test)
inputs:
targetType: inline
workingDirectory: dictation_server/.devcontainer
script: |
sudo curl -L "https://github.com/docker/compose/releases/download/v2.20.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose --version
docker-compose -f pipeline-docker-compose.yml build
docker-compose -f pipeline-docker-compose.yml up -d
docker-compose exec -T dictation_server sudo npm ci
docker-compose exec -T dictation_server sudo npm run migrate:up:test
docker-compose exec -T dictation_server sudo npm run test
- job: backend_build
dependsOn: backend_test
condition: succeeded('backend_test')
displayName: Build And Push Backend Image
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Npm@1
displayName: npm ci
inputs:
command: ci
workingDir: dictation_server
verbose: false
- task: Docker@0
displayName: build
inputs:
azureSubscriptionEndpoint: 'omds-service-connection-stg'
azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}'
dockerFile: DockerfileServerDictation.dockerfile
imageName: odmscloud/staging/dictation:$(Build.SourceVersion)
buildArguments: |
BUILD_VERSION=$(Build.SourceVersion)
- task: Docker@0
displayName: push
inputs:
azureSubscriptionEndpoint: 'omds-service-connection-stg'
azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}'
action: Push an image
imageName: odmscloud/staging/dictation:$(Build.SourceVersion)
- job: frontend_build_staging
dependsOn: backend_build
condition: succeeded('backend_build')
displayName: Build Frontend Files(staging)
variables:
storageAccountName: saomdspipeline
environment: staging
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Npm@1
displayName: npm ci
inputs:
command: ci
workingDir: dictation_client
verbose: false
- task: Bash@3
displayName: Bash Script
inputs:
targetType: inline
script: cd dictation_client && npm run build:stg
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: dictation_client/build
includeRootFolder: false
archiveType: 'zip'
archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip'
replaceExistingArchive: true
- task: AzureCLI@2
inputs:
azureSubscription: 'omds-service-connection-stg'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az storage blob upload \
--auth-mode login \
--account-name $(storageAccountName) \
--container-name $(environment) \
--name $(Build.SourceVersion).zip \
--type block \
--overwrite \
--file $(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip
- job: frontend_build_production
dependsOn: frontend_build_staging
condition: succeeded('frontend_build_staging')
displayName: Build Frontend Files(production)
variables:
storageAccountName: saomdspipeline
environment: production
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Npm@1
displayName: npm ci
inputs:
command: ci
workingDir: dictation_client
verbose: false
- task: Bash@3
displayName: Bash Script
inputs:
targetType: inline
script: cd dictation_client && npm run build:prod
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: dictation_client/build
includeRootFolder: false
archiveType: 'zip'
archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip'
replaceExistingArchive: true
- task: AzureCLI@2
inputs:
azureSubscription: 'omds-service-connection-stg'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az storage blob upload \
--auth-mode login \
--account-name $(storageAccountName) \
--container-name $(environment) \
--name $(Build.SourceVersion).zip \
--type block \
--overwrite \
--file $(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip
- job: function_test
dependsOn: frontend_build_production
condition: succeeded('frontend_build_production')
displayName: UnitTest
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Bash@3
displayName: Bash Script (Test)
inputs:
targetType: inline
workingDirectory: dictation_function/.devcontainer
script: |
sudo curl -L "https://github.com/docker/compose/releases/download/v2.20.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose --version
docker-compose -f pipeline-docker-compose.yml build
docker-compose -f pipeline-docker-compose.yml up -d
docker-compose exec -T dictation_function sudo npm ci
docker-compose exec -T dictation_function sudo npm run test
- job: function_build
dependsOn: function_test
condition: succeeded('function_test')
displayName: Build And Push Function Image
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: Npm@1
displayName: npm ci
inputs:
command: ci
workingDir: dictation_function
verbose: false
- task: Docker@0
displayName: build
inputs:
azureSubscriptionEndpoint: 'omds-service-connection-stg'
azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}'
dockerFile: DockerfileFunctionDictation.dockerfile
imageName: odmscloud/staging/dictation_function:$(Build.SourceVersion)
buildArguments: |
BUILD_VERSION=$(Build.SourceVersion)
- task: Docker@0
displayName: push
inputs:
azureSubscriptionEndpoint: 'omds-service-connection-stg'
azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}'
action: Push an image
imageName: odmscloud/staging/dictation_function:$(Build.SourceVersion)
- job: backend_deploy
dependsOn: function_build
condition: succeeded('function_build')
displayName: Backend Deploy
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureWebAppContainer@1
inputs:
azureSubscription: 'omds-service-connection-stg'
appName: 'app-odms-dictation-stg'
deployToSlotOrASE: true
resourceGroupName: 'stg-application-rg'
slotName: 'staging'
containers: 'crodmsregistrymaintenance.azurecr.io/odmscloud/staging/dictation:$(Build.SourceVersion)'
- job: frontend_deploy
dependsOn: backend_deploy
condition: succeeded('backend_deploy')
displayName: Deploy Frontend Files
variables:
storageAccountName: saomdspipeline
environment: staging
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureCLI@2
inputs:
azureSubscription: 'omds-service-connection-stg'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az storage blob download \
--auth-mode login \
--account-name $(storageAccountName) \
--container-name $(environment) \
--name $(Build.SourceVersion).zip \
--file $(Build.SourcesDirectory)/$(Build.SourceVersion).zip
- task: Bash@3
displayName: Bash Script
inputs:
targetType: inline
script: unzip $(Build.SourcesDirectory)/$(Build.SourceVersion).zip -d $(Build.SourcesDirectory)/$(Build.SourceVersion)
- task: AzureStaticWebApp@0
displayName: 'Static Web App: '
inputs:
workingDirectory: '$(Build.SourcesDirectory)'
app_location: '/$(Build.SourceVersion)'
config_file_location: /dictation_client
skip_app_build: true
skip_api_build: true
is_static_export: false
verbose: false
azure_static_web_apps_api_token: $(STATIC_DICTATION_DEPLOYMENT_TOKEN)
- job: function_deploy
dependsOn: frontend_deploy
condition: succeeded('frontend_deploy')
displayName: Function Deploy
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureFunctionAppContainer@1
inputs:
azureSubscription: 'omds-service-connection-stg'
appName: 'func-odms-dictation-stg'
imageName: 'crodmsregistrymaintenance.azurecr.io/odmscloud/staging/dictation_function:$(Build.SourceVersion)'
- job: smoke_test
dependsOn: function_deploy
condition: succeeded('function_deploy')
displayName: 'smoke test'
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
# スモークテスト用にjobを確保
- job: swap_slot
dependsOn: smoke_test
condition: succeeded('smoke_test')
displayName: 'Swap Staging and Production'
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureAppServiceManage@0
displayName: 'Azure App Service Manage: app-odms-dictation-stg'
inputs:
azureSubscription: 'omds-service-connection-stg'
action: 'Swap Slots'
WebAppName: 'app-odms-dictation-stg'
ResourceGroupName: 'stg-application-rg'
SourceSlot: 'staging'
SwapWithProduction: true
- job: migration
dependsOn: swap_slot
condition: succeeded('swap_slot')
displayName: DB migration
pool:
name: odms-deploy-pipeline
steps:
- checkout: self
clean: true
fetchDepth: 1
- task: AzureKeyVault@2
displayName: 'Azure Key Vault: kv-odms-secret-stg'
inputs:
ConnectedServiceName: 'omds-service-connection-stg'
KeyVaultName: kv-odms-secret-stg
- task: CmdLine@2
displayName: migration
inputs:
script: >2
# DB接続情報書き換え
sed -i -e "s/DB_NAME/$(db-name-ph1-enhance)/g" ./dictation_server/db/dbconfig.yml
sed -i -e "s/DB_PASS/$(admin-db-pass)/g" ./dictation_server/db/dbconfig.yml
sed -i -e "s/DB_USERNAME/$(admin-db-user)/g" ./dictation_server/db/dbconfig.yml
sed -i -e "s/DB_PORT/$(db-port)/g" ./dictation_server/db/dbconfig.yml
sed -i -e "s/DB_HOST/$(db-host)/g" ./dictation_server/db/dbconfig.yml
sql-migrate --version
cat ./dictation_server/db/dbconfig.yml
# migration実行
sql-migrate up -config=./dictation_server/db/dbconfig.yml -env=ci

View File

@ -7058,6 +7058,44 @@ export const TasksApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Pendingにします
* @summary
* @param {number} audioFileId ODMS Cloud上の音声ファイルID
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
reopen: async (audioFileId: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'audioFileId' is not null or undefined
assertParamExists('reopen', 'audioFileId', audioFileId)
const localVarPath = `/tasks/{audioFileId}/reopen`
.replace(`{${"audioFileId"}}`, encodeURIComponent(String(audioFileId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -7224,6 +7262,19 @@ export const TasksApiFp = function(configuration?: Configuration) {
const operationBasePath = operationServerMap['TasksApi.getTasks']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
/**
* Pendingにします
* @summary
* @param {number} audioFileId ODMS Cloud上の音声ファイルID
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async reopen(audioFileId: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.reopen(audioFileId, options);
const index = configuration?.serverIndex ?? 0;
const operationBasePath = operationServerMap['TasksApi.reopen']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
/**
* Pendingにします
* @summary
@ -7332,6 +7383,16 @@ export const TasksApiFactory = function (configuration?: Configuration, basePath
getTasks(limit?: number, offset?: number, status?: string, direction?: string, paramName?: string, options?: any): AxiosPromise<TasksResponse> {
return localVarFp.getTasks(limit, offset, status, direction, paramName, options).then((request) => request(axios, basePath));
},
/**
* Pendingにします
* @summary
* @param {number} audioFileId ODMS Cloud上の音声ファイルID
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
reopen(audioFileId: number, options?: any): AxiosPromise<object> {
return localVarFp.reopen(audioFileId, options).then((request) => request(axios, basePath));
},
/**
* Pendingにします
* @summary
@ -7453,6 +7514,18 @@ export class TasksApi extends BaseAPI {
return TasksApiFp(this.configuration).getTasks(limit, offset, status, direction, paramName, options).then((request) => request(this.axios, this.basePath));
}
/**
* Pendingにします
* @summary
* @param {number} audioFileId ODMS Cloud上の音声ファイルID
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof TasksApi
*/
public reopen(audioFileId: number, options?: AxiosRequestConfig) {
return TasksApiFp(this.configuration).reopen(audioFileId, options).then((request) => request(this.axios, this.basePath));
}
/**
* Pendingにします
* @summary

View File

@ -450,6 +450,78 @@ export const cancelAsync = createAsyncThunk<
}
});
export const reopenAsync = createAsyncThunk<
{
/** empty */
},
{
direction: DirectionType;
paramName: SortableColumnType;
audioFileId: number;
isTypist: boolean;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/reopenAsync", async (args, thunkApi) => {
const { audioFileId, direction, paramName, isTypist } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const tasksApi = new TasksApi(config);
const usersApi = new UsersApi(config);
try {
// ユーザーがタイピストである場合に、ソート条件を保存する
if (isTypist) {
await usersApi.updateSortCriteria(
{ direction, paramName },
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
}
await tasksApi.reopen(audioFileId, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
// ステータスが[Finished]以外、またはタスクが存在しない場合、またはtypistで自分のタスクでない場合
if (error.code === "E010601" || error.code === "E010603") {
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("dictationPage.message.reopenFailedError"),
})
);
return thunkApi.rejectWithValue({ error });
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const listBackupPopupTasksAsync = createAsyncThunk<
TasksResponse,
{

View File

@ -32,6 +32,7 @@ import {
selectIsLoading,
playbackAsync,
cancelAsync,
reopenAsync,
PRIORITY,
deleteTaskAsync,
isSortableColumnType,
@ -491,6 +492,58 @@ const DictationPage: React.FC = (): JSX.Element => {
]
);
const onReopen = useCallback(
async (audioFileId: number) => {
if (
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
const { meta } = await dispatch(
reopenAsync({
audioFileId,
direction: sortDirection,
paramName: sortableParamName,
isTypist,
})
);
if (meta.requestStatus === "fulfilled") {
const filter = getFilter(
filterUploaded,
filterInProgress,
filterPending,
filterFinished,
filterBackup
);
dispatch(
listTasksAsync({
limit: LIMIT_TASK_NUM,
offset: 0,
filter,
direction: sortDirection,
paramName: sortableParamName,
})
);
dispatch(listTypistsAsync());
dispatch(listTypistGroupsAsync());
}
},
[
dispatch,
filterBackup,
filterFinished,
filterInProgress,
filterPending,
filterUploaded,
isTypist,
sortDirection,
sortableParamName,
t,
]
);
const onCloseBackupPopup = useCallback(() => {
setIsBackupPopupOpen(false);
}, []);
@ -1263,6 +1316,27 @@ const DictationPage: React.FC = (): JSX.Element => {
)}
</a>
</li>
<li>
{/* タスクのステータスがFinishedかつ、ログインユーザーがAdminかTypistの場合、Change status to Pendingボタンを活性化する */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
className={
x.status === STATUS.FINISHED &&
(isAdmin || isTypist)
? ""
: styles.isDisable
}
onClick={() => {
onReopen(x.audioFileId);
}}
>
{t(
getTranslationID(
"dictationPage.label.reopenDictation"
)
)}
</a>
</li>
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a

View File

@ -257,6 +257,7 @@
"taskNotEditable": "Der Transkriptionist kann nicht geändert werden, da die Transkription bereits ausgeführt wird oder die Datei nicht vorhanden ist. Bitte aktualisieren Sie den Bildschirm und prüfen Sie den aktuellen Status.",
"backupFailedError": "Der Prozess „Dateisicherung“ ist fehlgeschlagen. Bitte versuchen Sie es später noch einmal. Wenn der Fehler weiterhin besteht, wenden Sie sich an Ihren Systemadministrator.",
"cancelFailedError": "Die Diktate konnten nicht gelöscht werden. Bitte aktualisieren Sie Ihren Bildschirm und versuchen Sie es erneut.",
"reopenFailedError": "Der Status kann nicht in „Ausstehend“ geändert werden. Bitte aktualisieren Sie Ihren Bildschirm und versuchen Sie es erneut.",
"deleteFailedError": "Die Aufgabe konnte nicht gelöscht werden. Bitte aktualisieren Sie den Bildschirm und überprüfen Sie ihn erneut.",
"licenseNotAssignedError": "Die Transkription ist nicht möglich, da keine gültige Lizenz zugewiesen ist. Bitten Sie Ihren Administrator, eine gültige Lizenz zuzuweisen.",
"licenseExpiredError": "Die Transkription ist nicht möglich, da Ihre Lizenz abgelaufen ist. Bitte bitten Sie Ihren Administrator, Ihnen eine gültige Lizenz zuzuweisen.",
@ -310,7 +311,8 @@
"applications": "Desktopanwendung",
"cancelDictation": "Transkription abbrechen",
"rawFileName": "Ursprünglicher Dateiname",
"fileNameSave": "Führen Sie eine Dateiumbenennung durch"
"fileNameSave": "Führen Sie eine Dateiumbenennung durch",
"reopenDictation": "Status auf „Ausstehend“ ändern"
}
},
"cardLicenseIssuePopupPage": {

View File

@ -257,6 +257,7 @@
"taskNotEditable": "The transcriptionist cannot be changed because the transcription is already in progress or the file does not exist. Please refresh the screen and check the latest status.",
"backupFailedError": "The \"File Backup\" process has failed. Please try again later. If the error continues, contact your system administrator.",
"cancelFailedError": "Failed to delete the dictations. Please refresh your screen and try again.",
"reopenFailedError": "The status could not be changed to Pending. Please refresh the screen to see the current status. Only files with Finished status can be operated.",
"deleteFailedError": "Failed to delete the task. Please refresh the screen and check again.",
"licenseNotAssignedError": "Transcription is not possible because a valid license is not assigned. Please ask your administrator to assign a valid license.",
"licenseExpiredError": "Transcription is not possible because your license is expired. Please ask your administrator to assign a valid license.",
@ -310,7 +311,8 @@
"applications": "Desktop Application",
"cancelDictation": "Cancel Transcription",
"rawFileName": "Original File Name",
"fileNameSave": "Execute file rename"
"fileNameSave": "Execute file rename",
"reopenDictation": "Change status to Pending"
}
},
"cardLicenseIssuePopupPage": {

View File

@ -257,6 +257,7 @@
"taskNotEditable": "No se puede cambiar el transcriptor porque la transcripción ya está en curso o el archivo no existe. Actualice la pantalla y verifique el estado más reciente.",
"backupFailedError": "El proceso de \"Copia de seguridad de archivos\" ha fallado. Por favor, inténtelo de nuevo más tarde. Si el error continúa, comuníquese con el administrador del sistema.",
"cancelFailedError": "No se pudieron eliminar los dictados. Actualice su pantalla e inténtelo nuevamente.",
"reopenFailedError": "No se pudo cambiar el estado a Pendiente. Actualice la pantalla para ver el estado actual. Solo se pueden utilizar los archivos con estado Finalizado.",
"deleteFailedError": "No se pudo eliminar la tarea. Actualice la pantalla y verifique nuevamente.",
"licenseNotAssignedError": "La transcripción no es posible porque no se ha asignado una licencia válida. Solicite a su administrador que le asigne una licencia válida.",
"licenseExpiredError": "La transcripción no es posible porque su licencia ha caducado. Solicite a su administrador que le asigne una licencia válida.",
@ -310,7 +311,8 @@
"applications": "Aplicación de escritorio",
"cancelDictation": "Cancelar transcripción",
"rawFileName": "Nombre de archivo original",
"fileNameSave": "Ejecutar cambio de nombre de archivo"
"fileNameSave": "Ejecutar cambio de nombre de archivo",
"reopenDictation": "Cambiar el estado a Pendiente"
}
},
"cardLicenseIssuePopupPage": {

View File

@ -257,6 +257,7 @@
"taskNotEditable": "Le transcripteur ne peut pas être changé car la transcription est déjà en cours ou le fichier n'existe pas. Veuillez actualiser l'écran et vérifier le dernier statut.",
"backupFailedError": "Le processus de « Sauvegarde de fichier » a échoué. Veuillez réessayer plus tard. Si l'erreur persiste, contactez votre administrateur système.",
"cancelFailedError": "Échec de la suppression des dictées. Veuillez actualiser votre écran et réessayer.",
"reopenFailedError": "Le statut n'a pas pu être modifié en Suspendu. Veuillez actualiser l'écran pour voir le statut actuel. Seuls les fichiers dont le statut est Terminé peuvent être traités.",
"deleteFailedError": "Échec de la suppression de la tâche. Veuillez actualiser l'écran et vérifier à nouveau.",
"licenseNotAssignedError": "La transcription n'est pas possible car aucune licence valide n'a été attribuée. Veuillez demander à votre administrateur d'attribuer une licence valide.",
"licenseExpiredError": "La transcription n'est pas possible car votre licence est expirée. Veuillez demander à votre administrateur de vous attribuer une licence valide.",
@ -310,7 +311,8 @@
"applications": "Application de bureau",
"cancelDictation": "Annuler la transcription",
"rawFileName": "Nom du fichier d'origine",
"fileNameSave": "Exécuter le changement de nom du fichier"
"fileNameSave": "Exécuter le changement de nom du fichier",
"reopenDictation": "Changer le statut en Suspendu"
}
},
"cardLicenseIssuePopupPage": {

View File

@ -2,5 +2,6 @@ DB_HOST=omds-mysql
DB_PORT=3306
DB_NAME=omds
DB_NAME_CCB=omds_ccb
DB_NAME_PH1ENHANCE=omds_ph1enhance
DB_USERNAME=omdsdbuser
DB_PASSWORD=omdsdbpass

View File

@ -6,6 +6,10 @@ ccb:
dialect: mysql
dir: /app/dictation_server/db/migrations
datasource: ${DB_USERNAME}:${DB_PASSWORD}@tcp(${DB_HOST}:${DB_PORT})/${DB_NAME_CCB}?charset=utf8mb4&collation=utf8mb4_0900_ai_ci&parseTime=true
ph1_enhance:
dialect: mysql
dir: /app/dictation_server/db/migrations
datasource: ${DB_USERNAME}:${DB_PASSWORD}@tcp(${DB_HOST}:${DB_PORT})/${DB_NAME_PH1ENHANCE}?charset=utf8mb4&collation=utf8mb4_0900_ai_ci&parseTime=true
ci:
dialect: mysql
dir: ./dictation_server/db/migrations

View File

@ -3524,6 +3524,68 @@
"security": [{ "bearer": [] }]
}
},
"/tasks/{audioFileId}/reopen": {
"post": {
"operationId": "reopen",
"summary": "",
"description": "完了した文字起こしタスクを再開しますステータスをPendingにします",
"parameters": [
{
"name": "audioFileId",
"required": true,
"in": "path",
"description": "ODMS Cloud上の音声ファイルID",
"schema": { "type": "number" }
}
],
"responses": {
"200": {
"description": "成功時のレスポンス",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChangeStatusResponse"
}
}
}
},
"400": {
"description": "不正なパラメータ",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"401": {
"description": "認証エラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"404": {
"description": "指定したIDの音声ファイルが存在しない場合",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"500": {
"description": "想定外のサーバーエラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
},
"tags": ["tasks"],
"security": [{ "bearer": [] }]
}
},
"/licenses/orders": {
"post": {
"operationId": "createOrders",

View File

@ -1,4 +1,5 @@
export type NotificationBody = {
id: string;
filename: string;
authorId: string;
priority: string;

View File

@ -28,6 +28,10 @@ export class EnvValidator {
@IsString()
DB_NAME_CCB: string;
@IsNotEmpty()
@IsString()
DB_NAME_PH1ENHANCE: string;
@IsNotEmpty()
@IsString()
DB_USERNAME: string;

View File

@ -203,13 +203,6 @@ 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;
/**
*
*/

View File

@ -367,10 +367,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -472,10 +473,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -737,10 +739,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -1408,10 +1411,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -1526,10 +1530,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -1654,10 +1659,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -1782,10 +1788,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -1900,10 +1907,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -2050,10 +2058,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -2199,10 +2208,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},
@ -2349,10 +2359,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(notificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'file',
id: '2',
priority: 'High',
uploadedAt: '2023-05-26T11:22:33.444',
},

View File

@ -273,12 +273,17 @@ export class FilesService {
this.logger.log(`[${context.getTrackingId()}] tags: ${tags}`);
// タグ対象に通知送信
await this.notificationhubService.notify(context, tags, {
authorId: authorId,
filename: fileName.replace('.zip', ''),
priority: priority === '00' ? 'Normal' : 'High',
uploadedAt: uploadedDate,
});
await Promise.all(
tags.map((tag) => {
return this.notificationhubService.notify(context, tag, {
id: tag.split('user_')[1],
authorId: authorId,
filename: fileName.replace('.zip', ''),
priority: priority === '00' ? 'Normal' : 'High',
uploadedAt: uploadedDate,
});
}),
);
// 追加したタスクのJOBナンバーを返却
return { jobNumber: task.job_number };

View File

@ -831,4 +831,91 @@ export class TasksController {
await this.taskService.deleteTask(context, userId, audioFileId);
return {};
}
@Post(':audioFileId/reopen')
@ApiResponse({
status: HttpStatus.OK,
type: ChangeStatusResponse,
description: '成功時のレスポンス',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '不正なパラメータ',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: '指定したIDの音声ファイルが存在しない場合',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: '認証エラー',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: '想定外のサーバーエラー',
type: ErrorResponse,
})
@ApiOperation({
operationId: 'reopen',
description:
'終了した文字起こしタスクを再開しますステータスをPendingにします',
})
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({
roles: [ADMIN_ROLES.ADMIN, USER_ROLES.TYPIST],
}),
)
@ApiBearerAuth()
async reopen(
@Req() req: Request,
@Param() params: ChangeStatusRequest,
): Promise<ChangeStatusResponse> {
const { audioFileId } = params;
// AuthGuardでチェック済みなのでここでのアクセストークンチェックはしない
const accessToken = retrieveAuthorizationToken(req);
if (!accessToken) {
throw new HttpException(
makeErrorResponse('E000107'),
HttpStatus.UNAUTHORIZED,
);
}
const ip = retrieveIp(req);
if (!ip) {
throw new HttpException(
makeErrorResponse('E000401'),
HttpStatus.UNAUTHORIZED,
);
}
const requestId = retrieveRequestId(req);
if (!requestId) {
throw new HttpException(
makeErrorResponse('E000501'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const decodedAccessToken = jwt.decode(accessToken, { json: true });
if (!decodedAccessToken) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
const { userId, role } = decodedAccessToken as AccessToken;
// RoleGuardでroleの文字列に想定外の文字列や重複がないことは担保されているためここでは型変換のみ行う
const roles = role.split(' ') as Roles[];
const context = makeContext(userId, requestId);
this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`);
await this.taskService.reopen(context, audioFileId, userId, roles);
return {};
}
}

View File

@ -1132,10 +1132,11 @@ describe('changeCheckoutPermission', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId_2}`],
`user_${typistUserId_2}`,
{
authorId: 'MY_AUTHOR_ID',
filename: 'x',
id: '2',
priority: 'High',
uploadedAt: resultTask?.file?.uploaded_at.toISOString(),
},
@ -1216,10 +1217,11 @@ describe('changeCheckoutPermission', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId_2}`],
`user_${typistUserId_2}`,
{
authorId: 'MY_AUTHOR_ID',
filename: 'x',
id: '2',
priority: 'High',
uploadedAt: resultTask?.file?.uploaded_at.toISOString(),
},
@ -3529,10 +3531,11 @@ describe('cancel', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${typistUserId}`],
`user_${typistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'x',
id: '1',
priority: 'High',
uploadedAt: resultTask?.file?.uploaded_at.toISOString(),
},
@ -3639,10 +3642,11 @@ describe('cancel', () => {
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId', 'requestId'),
[`user_${autoRoutingTypistUserId}`],
`user_${autoRoutingTypistUserId}`,
{
authorId: 'AUTHOR_ID',
filename: 'x',
id: '2',
priority: 'High',
uploadedAt: resultTask?.file?.uploaded_at.toISOString(),
},
@ -5308,3 +5312,323 @@ describe('deleteTask', () => {
}
});
});
describe('reopen', () => {
let source: DataSource | null = null;
beforeAll(async () => {
if (source == null) {
source = await (async () => {
const s = new DataSource({
type: 'mysql',
host: 'test_mysql_db',
port: 3306,
username: 'user',
password: 'password',
database: 'odms',
entities: [__dirname + '/../../**/*.entity{.ts,.js}'],
synchronize: false, // trueにすると自動的にmigrationが行われるため注意
logger: new TestLogger('none'),
logging: true,
});
return await s.initialize();
})();
}
});
beforeEach(async () => {
if (source) {
await truncateAllTable(source);
}
});
afterAll(async () => {
await source?.destroy();
source = null;
});
it('API実行者のRoleがTypistの場合、自身が担当する完了済みの文字起こしタスクを再開できる', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
const { id: authorUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'MY_AUTHOR_ID',
});
const { taskId } = await createTask(
source,
accountId,
authorUserId,
'MY_AUTHOR_ID',
'',
'01',
'00000001',
'Finished',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
await service.reopen(
makeContext('trackingId', 'requestId'),
1,
'typist-user-external-id',
['typist', 'standard'],
);
const resultTask = await getTask(source, taskId);
expect(resultTask?.status).toEqual('Pending');
expect(resultTask?.finished_at).toEqual(null);
});
it('API実行者のRoleがAdminの場合、完了済みの文字起こしタスクを再開できる', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
const { id: authorUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'MY_AUTHOR_ID',
});
const { taskId } = await createTask(
source,
accountId,
authorUserId,
'MY_AUTHOR_ID',
'',
'01',
'00000001',
'Finished',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
await service.reopen(
makeContext('trackingId', 'requestId'),
1,
'typist-user-external-id',
['admin', 'standard'],
);
const resultTask = await getTask(source, taskId);
expect(resultTask?.status).toEqual('Pending');
expect(resultTask?.finished_at).toEqual(null);
});
it('タスクのステータスが[Finished]でない時、タスクを再開できない', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
const { id: authorUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'MY_AUTHOR_ID',
});
const { taskId } = await createTask(
source,
accountId,
authorUserId,
'MY_AUTHOR_ID',
'',
'01',
'00000001',
'Uploaded',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
await expect(
service.reopen(
makeContext('trackingId', 'requestId'),
1,
'typist-user-external-id',
['admin', 'author'],
),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST),
);
});
it('API実行者のRoleがTypistの場合、他人の終了済み文字起こしタスクを再開できない', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
const { id: anotherTypistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'another-typist-user-external-id',
role: 'typist',
});
const { id: authorUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'MY_AUTHOR_ID',
});
const { taskId } = await createTask(
source,
accountId,
authorUserId,
'MY_AUTHOR_ID',
'',
'01',
'00000001',
'Finished',
anotherTypistUserId,
);
await createCheckoutPermissions(source, taskId, anotherTypistUserId);
const service = module.get<TasksService>(TasksService);
await expect(
service.reopen(
makeContext('trackingId', 'requestId'),
1,
'typist-user-external-id',
['typist', 'standard'],
),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST),
);
});
it('API実行者のRoleがAuthorの場合、他人が終了済み文字起こしタスクを再開できない', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
const { id: anotherTypistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'another-typist-user-external-id',
role: 'typist',
});
const { id: authorUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'MY_AUTHOR_ID',
});
const { taskId } = await createTask(
source,
accountId,
authorUserId,
'MY_AUTHOR_ID',
'',
'01',
'00000001',
'Finished',
anotherTypistUserId,
);
await createCheckoutPermissions(source, taskId, anotherTypistUserId);
const service = module.get<TasksService>(TasksService);
await expect(
service.reopen(
makeContext('trackingId', 'requestId'),
1,
'author-user-external-id',
['author', 'standard'],
),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST),
);
});
it('タスクがない時、タスクを再開できない', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'MY_AUTHOR_ID',
});
const service = module.get<TasksService>(TasksService);
await expect(
service.reopen(
makeContext('trackingId', 'requestId'),
1,
'typist-user-external-id',
['typist', 'standard'],
),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010603'), HttpStatus.NOT_FOUND),
);
});
});

View File

@ -712,6 +712,80 @@ export class TasksService {
}
}
/**
* (Pendingに変更する)
* @param audioFileId
* @param externalId
* @param role
* @returns reopen
*/
async reopen(
context: Context,
audioFileId: number,
externalId: string,
role: Roles[],
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.reopen.name
} | params: { audioFileId: ${audioFileId}, externalId: ${externalId}, role: ${role} };`,
);
let user: User;
try {
// ユーザー取得
user = await this.usersRepository.findUserByExternalId(
context,
externalId,
);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
this.logger.log(`[OUT] [${context.getTrackingId()}] ${this.reopen.name}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// roleにAdminが含まれていれば、文字起こし担当でなくても再開できるため、ユーザーIDは指定しない
await this.taskRepository.reopen(
context,
audioFileId,
[TASK_STATUS.FINISHED],
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,
);
} finally {
this.logger.log(`[OUT] [${context.getTrackingId()}] ${this.reopen.name}`);
}
}
/**
* backupする
* @param context
@ -1037,12 +1111,18 @@ export class TasksService {
}
// タグ対象に通知送信
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(),
});
await Promise.all(
tags.map((tag) => {
return this.notificationhubService.notify(context, tag, {
id: tag.split('user_')[1],
authorId: file.author_id,
filename: file.file_name.replace('.zip', ''),
priority: file.priority === '00' ? 'Normal' : 'High',
uploadedAt: file.uploaded_at.toISOString(),
});
}),
);
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.sendNotify.name}`,
);

View File

@ -9,7 +9,6 @@ import {
createAppleInstallation,
createWindowsRawNotification,
} from '@azure/notification-hubs';
import { TAG_MAX_COUNT } from '../../constants';
import { PNS } from '../../constants';
import { Context } from '../../common/log';
import { NotificationBody } from '../../common/notify/types/types';
@ -83,65 +82,57 @@ export class NotificationhubService {
/**
*
* @param context
* @param tags
* @param tag
* @param bodyContent
* @returns notify
*/
async notify(
context: Context,
tags: string[],
tag: string,
bodyContent: NotificationBody,
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.notify.name
} | params: { tags: ${tags}, bodyContent: ${JSON.stringify(
bodyContent,
)} }`,
} | params: { tag: ${tag}, bodyContent: ${JSON.stringify(bodyContent)} }`,
);
try {
// OR条件によるtag指定は20個までなので分割して送信する
const chunkTags = splitArrayInChunks(tags, TAG_MAX_COUNT);
const tagExpression = createTagExpression([tag]);
for (let index = 0; index < chunkTags.length; index++) {
const currentTags = chunkTags[index];
const tagExpression = createTagExpression(currentTags);
// Windows
try {
const body = {
wns: {
alert: '',
},
newDictation: bodyContent,
};
const notification = createWindowsRawNotification({
body: JSON.stringify(body),
});
const result = await this.client.sendNotification(notification, {
tagExpression,
});
this.logger.log(`[${context.getTrackingId()}] ${result}`);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
}
// Apple
try {
const body = createAppleNotificationBody({
aps: {
alert: '',
},
newDictation: bodyContent,
});
const notification = createAppleNotification({ body });
const result = await this.client.sendNotification(notification, {
tagExpression,
});
this.logger.log(`[${context.getTrackingId()}] ${result}`);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
}
// Windows
try {
const body = {
wns: {
alert: '',
},
newDictation: bodyContent,
};
const notification = createWindowsRawNotification({
body: JSON.stringify(body),
});
const result = await this.client.sendNotification(notification, {
tagExpression,
});
this.logger.log(`[${context.getTrackingId()}] ${result}`);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
}
// Apple
try {
const body = createAppleNotificationBody({
aps: {
alert: '',
},
newDictation: bodyContent,
});
const notification = createAppleNotification({ body });
const result = await this.client.sendNotification(notification, {
tagExpression,
});
this.logger.log(`[${context.getTrackingId()}] ${result}`);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
}
} catch (e) {
throw e;
@ -150,11 +141,3 @@ export class NotificationhubService {
}
}
}
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;
};

View File

@ -506,6 +506,69 @@ export class TasksRepositoryService {
});
}
/**
* IDで指定した完了済みのタスクを再開する(Pendingに変更する)
* @param audio_file_id ID
* @param permittedSourceStatus
* @param account_id ID
* @param user_id ID(API実行者がAdminのときは使用しない)
* @returns reopen
*/
async reopen(
context: Context,
audio_file_id: number,
permittedSourceStatus: TaskStatus[],
account_id: number,
user_id?: number | undefined,
): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
const taskRepo = entityManager.getRepository(Task);
const task = await taskRepo.findOne({
where: {
audio_file_id: audio_file_id,
},
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
if (!task) {
throw new TasksNotFoundError(
`task not found. audio_file_id:${audio_file_id}`,
);
}
if (!isTaskStatus(task.status)) {
throw new Error('invalid task status.');
}
// ステータスチェック
if (!permittedSourceStatus.includes(task.status)) {
throw new StatusNotMatchError(
`Unexpected task status. audio_file_id:${audio_file_id}, status:${task.status}`,
);
}
if (task.account_id !== account_id) {
throw new AccountNotMatchError(
`task account_id not match. audio_file_id:${audio_file_id}, account_id(Task):${task.account_id}, account_id:${account_id}`,
);
}
if (user_id && task.typist_user_id !== user_id) {
throw new TypistUserNotMatchError(
`TypistUser not match. audio_file_id:${audio_file_id}, typist_user_id:${task.typist_user_id}, user_id:${user_id}`,
);
}
// 対象タスクのステータスをPendingに,文字起こし終了日時をnullに更新
await updateEntity(
taskRepo,
{ audio_file_id: audio_file_id },
{
status: TASK_STATUS.PENDING,
finished_at: null,
},
this.isCommentOut,
context,
);
});
}
/**
* IDで指定したタスクをbackupする
* @param accountId ID
@ -921,7 +984,7 @@ export class TasksRepositoryService {
throw new Error(`JobNumber not exists. account_id:${account_id}`);
}
let newJobNumber: string = '';
let newJobNumber = '';
if (currentJobNumberData.job_number === MAX_JOB_NUMBER) {
// 末尾なら00000001に戻る
newJobNumber = '00000001';