Add files deletion when destroying attachment, workspace or workspaceMember (#10222)

Solution
- update attachment soft delete logic by destroy (seen with Weiko &
Felix)
- add two jobs for file and workspace folder deletion
- add listener to attachment and workspaceMember destroy event -> add
file deletion job
- update logic in deleteWorkspace method -> add wokspace folder deletion
job

closes https://github.com/twentyhq/core-team-issues/issues/147

To go further
- delete old avatar when workspaceMember replaces its avatar
- same with workspace picture

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Etienne
2025-02-20 10:35:14 +01:00
committed by GitHub
parent 422e4e33c0
commit 316876fcb5
9 changed files with 212 additions and 6 deletions

View File

@ -5,7 +5,7 @@ import { PREVIEWABLE_EXTENSIONS } from '@/activities/files/components/DocumentVi
import { Attachment } from '@/activities/files/types/Attachment';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
FieldContext,
@ -95,12 +95,12 @@ export const AttachmentRow = ({
[attachment?.id],
);
const { deleteOneRecord: deleteOneAttachment } = useDeleteOneRecord({
const { destroyOneRecord: destroyOneAttachment } = useDestroyOneRecord({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const handleDelete = () => {
deleteOneAttachment(attachment.id);
destroyOneAttachment(attachment.id);
};
const { updateOneRecord: updateOneAttachment } = useUpdateOneRecord({

View File

@ -1,15 +1,27 @@
import { Module } from '@nestjs/common';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileDeletionJob } from 'src/engine/core-modules/file/jobs/file-deletion.job';
import { FileWorkspaceFolderDeletionJob } from 'src/engine/core-modules/file/jobs/file-workspace-folder-deletion.job';
import { FileAttachmentListener } from 'src/engine/core-modules/file/listeners/file-attachment.listener';
import { FileWorkspaceMemberListener } from 'src/engine/core-modules/file/listeners/file-workspace-member.listener';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { FileController } from './controllers/file.controller';
import { FileService } from './services/file.service';
@Module({
imports: [JwtModule],
providers: [FileService, EnvironmentService, FilePathGuard],
providers: [
FileService,
EnvironmentService,
FilePathGuard,
FileAttachmentListener,
FileWorkspaceMemberListener,
FileWorkspaceFolderDeletionJob,
FileDeletionJob,
],
exports: [FileService],
controllers: [FileController],
})

View File

@ -0,0 +1,42 @@
import { UnrecoverableError } from 'bullmq';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
export type FileDeletionJobData = {
workspaceId: string;
fullPath: string;
};
@Processor(MessageQueue.deleteCascadeQueue)
export class FileDeletionJob {
constructor(private readonly fileService: FileService) {}
@Process(FileDeletionJob.name)
async handle(data: FileDeletionJobData): Promise<void> {
const { workspaceId, fullPath } = data;
const folderPath = fullPath.split('/').slice(0, -1).join('/');
const filename = fullPath.split('/').pop();
if (!filename) {
throw new UnrecoverableError(
`[${FileDeletionJob.name}] Cannot parse filename from full path - ${fullPath}`,
);
}
try {
await this.fileService.deleteFile({
workspaceId,
filename,
folderPath,
});
} catch (error) {
throw new Error(
`[${FileDeletionJob.name}] Cannot delete file - ${fullPath}`,
);
}
}
}

View File

@ -0,0 +1,26 @@
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
export type FileWorkspaceFolderDeletionJobData = {
workspaceId: string;
};
@Processor(MessageQueue.deleteCascadeQueue)
export class FileWorkspaceFolderDeletionJob {
constructor(private readonly fileService: FileService) {}
@Process(FileWorkspaceFolderDeletionJob.name)
async handle(data: FileWorkspaceFolderDeletionJobData): Promise<void> {
const { workspaceId } = data;
try {
await this.fileService.deleteWorkspaceFolder(workspaceId);
} catch (error) {
throw new Error(
`[${FileWorkspaceFolderDeletionJob.name}] Cannot delete workspace folder - ${workspaceId}`,
);
}
}
}

View File

@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import {
FileDeletionJob,
FileDeletionJobData,
} from 'src/engine/core-modules/file/jobs/file-deletion.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
@Injectable()
export class FileAttachmentListener {
constructor(
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnDatabaseBatchEvent('attachment', DatabaseEventAction.DESTROYED)
async handleDestroyEvent(
payload: WorkspaceEventBatch<
ObjectRecordDestroyEvent<AttachmentWorkspaceEntity>
>,
) {
for (const event of payload.events) {
await this.messageQueueService.add<FileDeletionJobData>(
FileDeletionJob.name,
{
workspaceId: payload.workspaceId,
fullPath: event.properties.before.fullPath,
},
);
}
}
}

View File

@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import {
FileDeletionJob,
FileDeletionJobData,
} from 'src/engine/core-modules/file/jobs/file-deletion.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class FileWorkspaceMemberListener {
constructor(
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DESTROYED)
async handleDestroyEvent(
payload: WorkspaceEventBatch<
ObjectRecordDestroyEvent<WorkspaceMemberWorkspaceEntity>
>,
) {
for (const event of payload.events) {
const avatarUrl = event.properties.before.avatarUrl;
if (!avatarUrl) {
continue;
}
this.messageQueueService.add<FileDeletionJobData>(FileDeletionJob.name, {
workspaceId: payload.workspaceId,
fullPath: event.properties.before.avatarUrl,
});
}
}
}

View File

@ -48,4 +48,29 @@ export class FileService {
return signedPayload;
}
async deleteFile({
folderPath,
filename,
workspaceId,
}: {
folderPath: string;
filename: string;
workspaceId: string;
}) {
const workspaceFolderPath = `workspace-${workspaceId}/${folderPath}`;
return await this.fileStorageService.delete({
folderPath: workspaceFolderPath,
filename,
});
}
async deleteWorkspaceFolder(workspaceId: string) {
const workspaceFolderPath = `workspace-${workspaceId}`;
return await this.fileStorageService.delete({
folderPath: workspaceFolderPath,
});
}
}

View File

@ -9,6 +9,8 @@ import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@ -101,6 +103,10 @@ describe('WorkspaceService', () => {
provide: WorkspaceCacheStorageService,
useValue: {},
},
{
provide: getQueueToken(MessageQueue.deleteCascadeQueue),
useValue: {},
},
],
}).compile();

View File

@ -20,6 +20,13 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
FileWorkspaceFolderDeletionJob,
FileWorkspaceFolderDeletionJobData,
} from 'src/engine/core-modules/file/jobs/file-workspace-folder-deletion.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@ -63,6 +70,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly permissionsService: PermissionsService,
private readonly customDomainService: CustomDomainService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
) {
super(workspaceRepository);
}
@ -317,6 +326,11 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
await this.deleteMetadataSchemaCacheAndUserWorkspace(workspace);
await this.messageQueueService.add<FileWorkspaceFolderDeletionJobData>(
FileWorkspaceFolderDeletionJob.name,
{ workspaceId: id },
);
return await this.workspaceRepository.delete(id);
}