diff --git a/src/domain/index.ts b/src/domain/index.ts index 87fcc82c..aaf86f8a 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -72,10 +72,12 @@ export function init(repositories: Repositories, appConfig: AppConfig): DomainSe repositories.userSessionRepository ); const editorToolsService = new EditorToolsService(repositories.editorToolsRepository); + const fileUploaderService = new FileUploaderService(repositories.objectStorageRepository, repositories.fileRepository); const sharedServices = { editorTools: editorToolsService, note: noteService, + fileUploader: fileUploaderService, /** * @todo find a way how to resolve circular dependency */ @@ -85,8 +87,6 @@ export function init(repositories: Repositories, appConfig: AppConfig): DomainSe const noteSettingsService = new NoteSettingsService(repositories.noteSettingsRepository, repositories.teamRepository, sharedServices); const aiService = new AIService(repositories.aiRepository); - const fileUploaderService = new FileUploaderService(repositories.objectStorageRepository, repositories.fileRepository); - return { fileUploaderService, noteService, diff --git a/src/domain/service/fileUploader.service.ts b/src/domain/service/fileUploader.service.ts index 066ba1cc..8f9496db 100644 --- a/src/domain/service/fileUploader.service.ts +++ b/src/domain/service/fileUploader.service.ts @@ -6,6 +6,7 @@ import type FileRepository from '@repository/file.repository.js'; import type ObjectRepository from '@repository/object.repository.js'; import { DomainError } from '@domain/entities/DomainError.js'; import mime from 'mime'; +import { isEmpty } from '@infrastructure/utils/empty.js'; /** * File data for upload @@ -143,6 +144,34 @@ export default class FileUploaderService { return fileData; } + /** + * Delete file + * @param key - file key + */ + public async deleteFile(key: UploadedFile['key']): Promise { + const fileData = await this.fileRepository.getByKey(key); + + if (isEmpty(fileData)) { + throw new DomainError('File not found'); + } + + /** + * Define file type and bucket + */ + const fileType = this.defineFileType(fileData.location); + const bucket = this.defineBucketByFileType(fileType); + + /** + * Delete file from object storage and database + */ + const isRemovedFromObjectStorage = await this.objectRepository.delete(key, bucket); + const isRemovedFromDatabase = await this.fileRepository.deleteByKey(key); + + if (isRemovedFromObjectStorage === false || isRemovedFromDatabase === false) { + throw new DomainError('Cannot delete file'); + } + } + /** * Define file type by location * @param location - file location diff --git a/src/domain/service/noteSettings.ts b/src/domain/service/noteSettings.ts index b43efc4b..a0d03c40 100644 --- a/src/domain/service/noteSettings.ts +++ b/src/domain/service/noteSettings.ts @@ -9,6 +9,7 @@ import type User from '@domain/entities/user.js'; import { createInvitationHash } from '@infrastructure/utils/invitationHash.js'; import { DomainError } from '@domain/entities/DomainError.js'; import type { SharedDomainMethods } from './shared/index.js'; +import { notEmpty } from '@infrastructure/utils/empty.js'; /** * Service responsible for Note Settings @@ -107,6 +108,13 @@ export default class NoteSettingsService { throw new DomainError(`Note settings not found`); } + /** + * In this case we need to remove previous cover + */ + if (notEmpty(data.cover) && notEmpty(noteSettings.cover)) { + await this.shared.fileUploader.deleteFile(noteSettings.cover); + } + return await this.noteSettingsRepository.patchNoteSettingsById(noteSettings.id, data); } diff --git a/src/domain/service/shared/fileUploader.ts b/src/domain/service/shared/fileUploader.ts new file mode 100644 index 00000000..6f7c425a --- /dev/null +++ b/src/domain/service/shared/fileUploader.ts @@ -0,0 +1,13 @@ +import type UploadedFile from '@domain/entities/file.js'; + +/** + * Which methods of Domain can be used by other domains + * Uses to decouple domains from each other + */ +export default interface FileUploaderServiceSharedMethods { + /** + * Delete file + * @param key - file key + */ + deleteFile: (key: UploadedFile['key']) => Promise; +} diff --git a/src/domain/service/shared/index.ts b/src/domain/service/shared/index.ts index fb34245a..d9e31431 100644 --- a/src/domain/service/shared/index.ts +++ b/src/domain/service/shared/index.ts @@ -1,8 +1,11 @@ import type EditorToolsServiceSharedMethods from './editorTools.js'; +import type FileUploaderServiceSharedMethods from './fileUploader.js'; import type NoteServiceSharedMethods from './note.js'; export type SharedDomainMethods = { editorTools: EditorToolsServiceSharedMethods; note: NoteServiceSharedMethods; + + fileUploader: FileUploaderServiceSharedMethods; }; diff --git a/src/presentation/http/router/noteSettings.test.ts b/src/presentation/http/router/noteSettings.test.ts index a7368eda..8a9bba9a 100644 --- a/src/presentation/http/router/noteSettings.test.ts +++ b/src/presentation/http/router/noteSettings.test.ts @@ -480,7 +480,6 @@ describe('NoteSettings API', () => { await global.db.insertNoteSetting({ noteId: note.id, isPublic: true, - cover: 'image.png', }); /** Create test team if user is in team */ diff --git a/src/repository/file.repository.ts b/src/repository/file.repository.ts index 21ece7e9..62a61f33 100644 --- a/src/repository/file.repository.ts +++ b/src/repository/file.repository.ts @@ -40,4 +40,13 @@ export default class FileRepository { public async getFileLocationByKey(type: T, key: UploadedFile['key']): Promise { return await this.storage.getFileLocationByKey(type, key); }; + + /** + * Delete file by key + * @param key - file unique key + * @returns true if file deleted + */ + public async deleteByKey(key: UploadedFile['key']): Promise { + return await this.storage.delete(key); + } } diff --git a/src/repository/object.repository.ts b/src/repository/object.repository.ts index 60e8d5c7..7f8ae4a2 100644 --- a/src/repository/object.repository.ts +++ b/src/repository/object.repository.ts @@ -36,4 +36,13 @@ export default class ObjectStorageRepository { public async insert(objectData: Buffer, key: string, bucket: string): Promise { return await this.storage.uploadFile(bucket, key, objectData); } + + /** + * Delete object + * @param key - object key + * @param bucket - bucket name + */ + public async delete(key: string, bucket: string): Promise { + return await this.storage.removeFile(bucket, key); + } } diff --git a/src/repository/storage/postgres/orm/sequelize/file.ts b/src/repository/storage/postgres/orm/sequelize/file.ts index 130c856d..1d6c78dd 100644 --- a/src/repository/storage/postgres/orm/sequelize/file.ts +++ b/src/repository/storage/postgres/orm/sequelize/file.ts @@ -161,4 +161,22 @@ export default class FileSequelizeStorage { return res.location as FileLocationByType[T]; } + + /** + * Delete file + * @param key - file key + * @returns true if file deleted + */ + public async delete(key: UploadedFile['key']): Promise { + const affectedRows = await this.model.destroy({ + where: { + key, + }, + }); + + /** + * If file not found return false + */ + return affectedRows > 0; + } } diff --git a/src/repository/storage/s3/index.ts b/src/repository/storage/s3/index.ts index e551cb44..e4a67482 100644 --- a/src/repository/storage/s3/index.ts +++ b/src/repository/storage/s3/index.ts @@ -1,5 +1,5 @@ import { getLogger } from '@infrastructure/logging/index.js'; -import { S3Client, GetObjectCommand, PutObjectCommand, CreateBucketCommand } from '@aws-sdk/client-s3'; +import { S3Client, GetObjectCommand, PutObjectCommand, CreateBucketCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; import { Buffer } from 'buffer'; import { Readable } from 'stream'; import { streamToBuffer } from '@infrastructure/utils/streamToBuffer.js'; @@ -88,6 +88,27 @@ export class S3Storage { } } + /** + * Remove file from bucket + * @param bucket - bucket name + * @param key - file key + * @returns true if object was deleted + */ + public async removeFile(bucket: string, key: string): Promise { + try { + await this.s3.send(new DeleteObjectCommand({ + Bucket: bucket, + Key: key, + })) + + return true; + } catch (error) { + s3StorageLogger.error(error) + + return false; + } + } + /** * Method to create bucket in object storage, return its location * @param name - bucket name diff --git a/src/tests/utils/database-helpers.ts b/src/tests/utils/database-helpers.ts index 5628d885..bc474ef9 100644 --- a/src/tests/utils/database-helpers.ts +++ b/src/tests/utils/database-helpers.ts @@ -208,7 +208,7 @@ export default class DatabaseHelpers { public async insertNoteSetting(noteSettings: NoteSettingsMockCreationAttributes): Promise { const customHostname = noteSettings.customHostname ?? null; const invitationHash = noteSettings.invitationHash ?? createInvitationHash(); - const cover = noteSettings.cover ?? null; + const cover = noteSettings.cover ?? ''; noteSettings.invitationHash = invitationHash;