2024-11-06 16:10:05 +02:00

284 lines
11 KiB
TypeScript

import { ApplicationRef, Injectable, OnDestroy } from '@angular/core';
import { FileUploadModel } from '../model/file-upload.model';
import { HttpErrorResponse, HttpEventType, HttpStatusCode } from '@angular/common/http';
import { interval, Subject, Subscription } from 'rxjs';
import { ConfigService } from '@services/config.service';
import { TranslateService } from '@ngx-translate/core';
import { IFileUploadResult, OverwriteFileOption, OverwriteFileOptions } from '@red/domain';
import { isAcceptedFileType, isCsv, isDocument, isZip } from '@utils/file-drop-utils';
import { ErrorMessageService, GenericService, Toaster } from '@iqser/common-ui';
import { FilesMapService } from '@services/files/files-map.service';
import { switchMap, tap, throttleTime } from 'rxjs/operators';
import { FilesService } from '@services/files/files.service';
import { UploadDownloadDialogService } from './upload-download-dialog.service';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { HeadersConfiguration } from '@iqser/common-ui/lib/utils';
import { LicenseService } from '@services/license.service';
import { LicenseFeatures } from '../../admin/screens/license/utils/constants';
import { UserPreferenceService } from '@users/user-preference.service';
export interface ActiveUpload {
subscription: Subscription;
fileUploadModel: FileUploadModel;
}
@Injectable({ providedIn: 'root' })
export class FileUploadService extends GenericService<IFileUploadResult> implements OnDestroy {
static readonly MAX_PARALLEL_UPLOADS = 5;
#pendingUploads: FileUploadModel[] = [];
#activeUploads: ActiveUpload[] = [];
readonly #fetchFiles$ = new Subject<string>();
readonly #subscriptions = new Subscription();
protected readonly _defaultModelPath = 'upload';
files: FileUploadModel[] = [];
groupedFiles: { [key: string]: FileUploadModel[] } = {};
activeUploads = 0;
constructor(
private readonly _filesService: FilesService,
private readonly _filesMapService: FilesMapService,
private readonly _applicationRef: ApplicationRef,
private readonly _translateService: TranslateService,
private readonly _configService: ConfigService,
private readonly _dialogService: UploadDownloadDialogService,
private readonly _errorMessageService: ErrorMessageService,
private readonly _licenseService: LicenseService,
private readonly _toaster: Toaster,
private readonly _userPreferenceService: UserPreferenceService,
) {
super();
const fileFetch$ = this.#fetchFiles$.pipe(
throttleTime(250),
switchMap(dossierId => this._filesService.loadAll(dossierId)),
);
this.#subscriptions.add(fileFetch$.subscribe());
const interval$ = interval(2500).pipe(tap(() => this._handleUploads()));
this.#subscriptions.add(interval$.subscribe());
}
get activeDossierKeys() {
return Object.keys(this.groupedFiles).filter(dossierId => this.groupedFiles[dossierId].length > 0);
}
ngOnDestroy() {
this.#subscriptions.unsubscribe();
}
scheduleUpload(item: FileUploadModel) {
if (!item.sizeError && !item.typeError) {
item.progress = 0;
item.completed = false;
item.error = null;
this.#pendingUploads.push(item);
}
}
async uploadFiles(files: FileUploadModel[], dossierId: string): Promise<number> {
const maxSizeMB = this._configService.values.MAX_FILE_SIZE_MB;
const maxSizeBytes = maxSizeMB * 1024 * 1024;
const dossierFiles = this._filesMapService.get(dossierId);
const supportMsOfficeFormats = this._licenseService.getFeature(LicenseFeatures.SUPPORT_MS_OFFICE_FORMATS)?.value as boolean;
let option: OverwriteFileOption | 'undefined' = this._userPreferenceService.getOverwriteFileOption();
for (let idx = 0; idx < files.length; ++idx) {
const file = files[idx];
let currentOption = option;
if (isZip(file)) {
if (dossierFiles.length > 0) {
const res = await this._dialogService.openOverwriteFileDialog(null);
if (res.cancel) {
return;
}
if (res.option === OverwriteFileOptions.PARTIAL_OVERWRITE) {
file.keepManualRedactions = true;
}
if (res.option === OverwriteFileOptions.SKIP) {
files = [];
}
}
} else if (dossierFiles.find(pf => pf.filename === file.file.name)) {
if (option === 'undefined') {
const res = await this._dialogService.openOverwriteFileDialog(file.file.name);
if (res.cancel) {
return;
}
if (res.rememberChoice) {
await this._userPreferenceService.saveOverwriteFileOption(res.option);
}
currentOption = res.option;
option = res.applyToAllFiles ? currentOption : undefined;
}
if (currentOption === OverwriteFileOptions.PARTIAL_OVERWRITE) {
file.keepManualRedactions = true;
}
if (currentOption === OverwriteFileOptions.SKIP) {
files.splice(idx, 1);
--idx;
continue;
}
}
if (!isAcceptedFileType(file, supportMsOfficeFormats)) {
file.completed = true;
file.error = {
message: this._translateService.instant('upload-status.error.file-type'),
};
file.typeError = true;
} else if (file.size > maxSizeBytes) {
file.completed = true;
file.error = {
message: this._translateService.instant('upload-status.error.file-size', {
size: maxSizeMB,
}),
};
file.sizeError = true;
}
}
this.files.push(...files);
files.forEach(newFile => {
this._addFileToGroup(newFile);
this.scheduleUpload(newFile);
});
return files.length;
}
filterFiles() {
for (const file of this.files) {
if (file.completed && !file.error) {
this.removeFile(file);
}
}
}
stopAllUploads() {
this.files = [];
this.groupedFiles = {};
}
removeFile(item: FileUploadModel) {
this._removeUpload(item);
const index = this.files.indexOf(item);
if (index > -1) {
this._removeFileFromGroup(item);
this.#fetchFiles$.next(item.dossierId);
this.files.splice(index, 1);
}
}
uploadFileForm(dossierId: string, keepManualRedactions = false, file?: Blob) {
const formParams = new FormData();
formParams.append('keepManualRedactions', keepManualRedactions.toString());
if (file !== undefined) {
formParams.append('file', file);
}
const headers = HeadersConfiguration.getHeaders({ contentType: false }).append('ngsw-bypass', 'true');
return this._http.post<IFileUploadResult>(`/${this._serviceName}/${this._defaultModelPath}/${dossierId}`, formParams, {
headers,
observe: 'events',
reportProgress: true,
});
}
private _addFileToGroup(file: FileUploadModel) {
if (!this.groupedFiles[file.dossierId]) {
this.groupedFiles[file.dossierId] = [];
}
this.groupedFiles[file.dossierId].push(file);
}
private _removeFileFromGroup(file: FileUploadModel) {
const index = this.groupedFiles[file.dossierId].indexOf(file);
this.groupedFiles[file.dossierId].splice(index, 1);
}
private _handleUploads() {
if (this.#activeUploads.length < FileUploadService.MAX_PARALLEL_UPLOADS && this.#pendingUploads.length > 0) {
let cnt = FileUploadService.MAX_PARALLEL_UPLOADS - this.#activeUploads.length;
while (cnt > 0 && this.#pendingUploads.length > 0) {
// Only schedule CSVs when no other file types in queue.
// CSVs are sorted at the end of `_pendingUploads`.
if (isCsv(this.#pendingUploads[0]) && this.#activeUploads.filter(upload => !isCsv(upload.fileUploadModel)).length > 0) {
return;
}
cnt--;
const poppedFileUploadModel = this.#pendingUploads.shift();
const subscription = this._createSubscription(poppedFileUploadModel);
this.#activeUploads.push({
subscription: subscription,
fileUploadModel: poppedFileUploadModel,
});
}
}
}
private _createSubscription(uploadFile: FileUploadModel) {
this.activeUploads++;
const obs = this.uploadFileForm(uploadFile.dossierId, uploadFile.keepManualRedactions, uploadFile.file);
return obs.subscribe({
next: event => {
if (event.type === HttpEventType.UploadProgress) {
uploadFile.progress = Math.round((event.loaded / (event.total || event.loaded)) * 100);
this._applicationRef.tick();
}
if (event.type === HttpEventType.Response) {
if (event.status < 300) {
uploadFile.progress = 100;
uploadFile.completed = true;
if (isCsv(uploadFile) || isZip(uploadFile)) {
this._toaster.success(_('file-upload.type.csv'));
}
} else {
uploadFile.completed = true;
uploadFile.error = {
message: this._translateService.instant('upload-status.error.generic'),
};
}
this._removeUpload(uploadFile);
}
},
error: (err: unknown) => {
uploadFile.completed = true;
uploadFile.error = {
// Extract error message
message: this._errorMessageService.getMessage(err as HttpErrorResponse, 'upload-status.error.generic'),
};
this._removeUpload(uploadFile);
if (
uploadFile.retryCount < 5 &&
![HttpStatusCode.BadRequest, HttpStatusCode.Conflict].includes((err as HttpErrorResponse).status)
) {
uploadFile.retryCount += 1;
this.scheduleUpload(uploadFile);
}
},
});
}
private _removeUpload(fileUploadModel: FileUploadModel) {
const index = this.#activeUploads.findIndex(au => au.fileUploadModel === fileUploadModel);
if (index > -1) {
const subscription = this.#activeUploads[index].subscription;
if (subscription) {
try {
subscription.unsubscribe();
} catch (e) {
console.error(e);
}
}
this.#activeUploads.splice(index, 1);
this.activeUploads--;
}
}
}