diff --git a/backend/src/migrations/1783100000000-AddDriveFileSoftDeleteAudit.ts b/backend/src/migrations/1783100000000-AddDriveFileSoftDeleteAudit.ts new file mode 100644 index 0000000..036051a --- /dev/null +++ b/backend/src/migrations/1783100000000-AddDriveFileSoftDeleteAudit.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// 드라이브 파일 삭제 감사(소프트 삭제) 컬럼 추가. +// - deleted_at: 삭제 시각(null 이면 미삭제) +// - deleted_by_id: 삭제한 사용자(FK users, 사용자 삭제 시 SET NULL) +// 파일 삭제 시 실제 객체는 즉시 영구 삭제하되, 메타 행은 status='deleted' 묘비로 보존해 +// "누가·언제 지웠는지" 추적한다. status 는 기존 varchar 라 값만 추가(스키마 변경 없음). +// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다. +// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다. +export class AddDriveFileSoftDeleteAudit1783100000000 implements MigrationInterface { + name = 'AddDriveFileSoftDeleteAudit1783100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP`, + ); + await queryRunner.query( + `ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_by_id" uuid`, + ); + // FK 는 제약 이름 존재 검사로 중복 추가를 방지(IF NOT EXISTS 미지원 대비) + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_files_deleted_by' + ) THEN + ALTER TABLE "drive_files" + ADD CONSTRAINT "FK_drive_files_deleted_by" + FOREIGN KEY ("deleted_by_id") REFERENCES "users"("id") + ON DELETE SET NULL ON UPDATE NO ACTION; + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "drive_files" DROP CONSTRAINT IF EXISTS "FK_drive_files_deleted_by"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_by_id"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_at"`, + ); + } +} diff --git a/backend/src/modules/activity/entities/activity.entity.ts b/backend/src/modules/activity/entities/activity.entity.ts index 79653c3..ecb9fb1 100644 --- a/backend/src/modules/activity/entities/activity.entity.ts +++ b/backend/src/modules/activity/entities/activity.entity.ts @@ -30,7 +30,11 @@ export type ActivityType = | 'task.edited' | 'task.checkpoint_reported' | 'task.file_removed' - | 'task.removed'; + | 'task.removed' + // 드라이브(프로젝트 연동 폴더 한정 — 누가 올렸/지웠는지 추적) + | 'drive.file_uploaded' + | 'drive.file_deleted' + | 'drive.folder_deleted'; // 활동 이벤트 — 행위 타입 + 행위자 + 대상(payload)만 저장하고 // 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙). diff --git a/backend/src/modules/drive/drive.module.ts b/backend/src/modules/drive/drive.module.ts index 9401411..83e3c14 100644 --- a/backend/src/modules/drive/drive.module.ts +++ b/backend/src/modules/drive/drive.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserModule } from '../user/user.module'; +import { ActivityModule } from '../activity/activity.module'; import { DriveFolder } from './entities/drive-folder.entity'; import { DriveFile } from './entities/drive-file.entity'; import { ProjectFolderLink } from './entities/project-folder-link.entity'; @@ -20,6 +21,7 @@ import { DriveController } from './drive.controller'; ProjectMember, ]), UserModule, + ActivityModule, ], controllers: [DriveController], providers: [DriveService], diff --git a/backend/src/modules/drive/drive.service.ts b/backend/src/modules/drive/drive.service.ts index 7bf5ed9..3fc584e 100644 --- a/backend/src/modules/drive/drive.service.ts +++ b/backend/src/modules/drive/drive.service.ts @@ -13,6 +13,8 @@ import { StorageService } from '../../common/storage/storage.service'; import type { PresignedUpload } from '../../common/storage/storage.types'; import { UserService, type PublicUser } from '../user/user.service'; import { User } from '../user/entities/user.entity'; +import { ActivityService } from '../activity/activity.service'; +import type { ActivityType } from '../activity/entities/activity.entity'; import { ProjectMember } from '../project/entities/project-member.entity'; import { DriveFolder } from './entities/drive-folder.entity'; import { DriveFile } from './entities/drive-file.entity'; @@ -88,6 +90,7 @@ export class DriveService { private readonly memberRepo: Repository, private readonly storage: StorageService, private readonly userService: UserService, + private readonly activity: ActivityService, ) {} /* ===================== 조회 ===================== */ @@ -185,8 +188,20 @@ export class DriveService { .innerJoin('f.folder', 'fd') .where('fd.path LIKE :prefix', { prefix: `${folder.path}%` }) .getMany(); + // 활동 기록은 제거 전에 — 연동 프로젝트 해석에 폴더 경로(자기+조상)가 필요하다. + const projectIds = await this.resolveLinkedProjectIds( + this.parseFolderPath(folder.path), + ); await this.folderRepo.remove(folder); await this.deleteObjects(descendantFiles.map((f) => f.objectKey)); + for (const projectId of projectIds) { + await this.activity.record({ + projectId, + actorId: userId, + type: 'drive.folder_deleted', + payload: { folderName: folder.name, fileCount: descendantFiles.length }, + }); + } } /* ===================== 파일 업로드(Presigned) ===================== */ @@ -248,6 +263,10 @@ export class DriveService { if (file.owner?.id !== userId) { throw new ForbiddenException(); } + if (file.status === 'deleted') { + // 이미 삭제된 묘비 행 — 확정 불가 + throw new NotFoundException(); + } if (file.status === 'active') { // 멱등 — 이미 확정된 경우 현재 상태 반환 return this.toFileResponse(file, file.folder?.id ?? null); @@ -282,6 +301,8 @@ export class DriveService { file.size = head.size; file.status = 'active'; await this.fileRepo.save(file); + // 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에 업로드 기록 + await this.recordFileActivity(file, userId, 'drive.file_uploaded'); return this.toFileResponse(file, file.folder?.id ?? null); } @@ -299,11 +320,17 @@ export class DriveService { return this.toFileResponse(file, file.folder?.id ?? null); } - // 파일 삭제 — 쓰기 권한 필요. 객체 스토리지 파일도 정리. + // 파일 삭제 — 쓰기 권한 필요. 객체 스토리지 파일은 즉시 영구 삭제(복구 불가)하고, + // 메타 행은 누가·언제 지웠는지 추적할 수 있도록 묘비(tombstone)로 보존(소프트 삭제)한다. async deleteFile(userId: string, fileId: string): Promise { const file = await this.getFileForAccess(userId, fileId, 'write'); - await this.fileRepo.remove(file); + file.status = 'deleted'; + file.deletedAt = new Date(); + file.deletedBy = { id: userId } as User; + await this.fileRepo.save(file); await this.deleteObjects([file.objectKey]); + // 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에 삭제 기록 + await this.recordFileActivity(file, userId, 'drive.file_deleted'); } // 다운로드용 단기 presigned GET URL 발급 — 읽기 권한 필요. @@ -354,7 +381,8 @@ export class DriveService { where: { id: fileId }, relations: { owner: true, folder: true }, }); - if (!file) { + if (!file || file.status === 'deleted') { + // 소프트 삭제된 묘비 행은 존재하지 않는 것으로 취급(목록·다운로드·재삭제 차단) throw new NotFoundException(); } const access = await this.resolveFileAccess(userId, file); @@ -442,6 +470,37 @@ export class DriveService { /* ===================== 내부 유틸 ===================== */ + // 파일이 프로젝트 연동 폴더(또는 그 하위)에 속하면 해당 프로젝트(들) 활동 피드에 기록한다. + // 루트(폴더 없음) 개인 파일은 연동 프로젝트가 없어 기록하지 않는다(소유자 전용이라 추적 불요). + private async recordFileActivity( + file: DriveFile, + actorId: string, + type: ActivityType, + ): Promise { + const folderIds = file.folder ? this.parseFolderPath(file.folder.path) : []; + const projectIds = await this.resolveLinkedProjectIds(folderIds); + for (const projectId of projectIds) { + await this.activity.record({ + projectId, + actorId, + type, + payload: { fileName: file.name }, + }); + } + } + + // 폴더 id 집합(자기+조상)이 연동된 프로젝트 id 목록(중복 제거)을 반환. + private async resolveLinkedProjectIds( + folderIds: string[], + ): Promise { + if (folderIds.length === 0) return []; + const links = await this.linkRepo.find({ + where: { folder: { id: In(folderIds) } }, + relations: { project: true }, + }); + return [...new Set(links.map((l) => l.project.id))]; + } + private async getUserOrThrow(userId: string): Promise { const user = await this.userService.findById(userId); if (!user) { diff --git a/backend/src/modules/drive/entities/drive-file.entity.ts b/backend/src/modules/drive/entities/drive-file.entity.ts index 61fb7f9..8197d54 100644 --- a/backend/src/modules/drive/entities/drive-file.entity.ts +++ b/backend/src/modules/drive/entities/drive-file.entity.ts @@ -16,7 +16,8 @@ import type { AttachmentKind } from '../../task/entities/attachment.entity'; // 드라이브 파일 업로드 상태 // pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과 -export type DriveFileStatus = 'pending' | 'active'; +// deleted: 소프트 삭제(객체는 영구 제거됨, 메타 행은 감사용 묘비로 보존 — 누가·언제 삭제했는지) +export type DriveFileStatus = 'pending' | 'active' | 'deleted'; // AI 텍스트 추출 상태(프로젝트 연동 문서의 AI 검토용 본문 캐시) // pending: 미추출 / done: 추출 완료 / unsupported: 추출 불가 형식 / failed: 추출 시도 실패 @@ -89,4 +90,13 @@ export class DriveFile { @UpdateDateColumn({ name: 'updated_at' }) updatedAt!: Date; + + // 삭제 시각(소프트 삭제) — null 이면 미삭제. 객체는 즉시 영구 삭제되며 이 행만 묘비로 남는다. + @Column({ name: 'deleted_at', type: 'timestamp', nullable: true }) + deletedAt!: Date | null; + + // 삭제한 사용자 — 소유자가 아닌 연동 멤버가 지울 수 있으므로 별도 기록(사용자 삭제 시 SET NULL) + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'deleted_by_id' }) + deletedBy!: User | null; } diff --git a/frontend/src/stores/activity.store.ts b/frontend/src/stores/activity.store.ts index 4f98f81..c906da8 100644 --- a/frontend/src/stores/activity.store.ts +++ b/frontend/src/stores/activity.store.ts @@ -112,6 +112,30 @@ function toProjectActivityItem(api: ApiActivity): ProjectActivityItem { } case 'task.removed': return { ...base, tone: 'task', icon: 'pencil', html: `${actor}님이 ${title} 업무를 삭제했습니다` } + case 'drive.file_uploaded': + return { + ...base, + tone: 'setting', + icon: 'repo', + html: `${actor}님이 파일 ${escapeHtml(pstr(p, 'fileName'))}을(를) 업로드했습니다`, + } + case 'drive.file_deleted': + return { + ...base, + tone: 'setting', + icon: 'pencil', + html: `${actor}님이 파일 ${escapeHtml(pstr(p, 'fileName'))}을(를) 삭제했습니다`, + } + case 'drive.folder_deleted': { + const cnt = typeof p.fileCount === 'number' ? p.fileCount : 0 + const suffix = cnt > 0 ? ` (파일 ${cnt}개 포함)` : '' + return { + ...base, + tone: 'setting', + icon: 'pencil', + html: `${actor}님이 폴더 ${escapeHtml(pstr(p, 'folderName'))}을(를) 삭제했습니다${suffix}`, + } + } default: return { ...base, tone: 'task', icon: 'check', html: `${actor}님의 활동` } } diff --git a/frontend/src/types/activity.ts b/frontend/src/types/activity.ts index 0a92829..d3c5e96 100644 --- a/frontend/src/types/activity.ts +++ b/frontend/src/types/activity.ts @@ -19,6 +19,9 @@ export type ApiActivityType = | 'task.checkpoint_reported' | 'task.file_removed' | 'task.removed' + | 'drive.file_uploaded' + | 'drive.file_deleted' + | 'drive.folder_deleted' // 활동(API 원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립한다 export interface ApiActivity {