feat: 드라이브 업로드·삭제 행위자 추적 추가

연동 프로젝트 폴더의 파일 업로드/삭제와 폴더 삭제를 활동 피드에 기록하고,
파일 삭제를 소프트 삭제(status=deleted + deletedBy/deletedAt 묘비)로 전환해
누가·언제 지웠는지 추적할 수 있게 한다. 실제 객체는 즉시 영구 삭제(복구 제외).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:54:17 +09:00
parent 2cbd19e791
commit ec54ba0e8d
7 changed files with 154 additions and 5 deletions
@@ -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<void> {
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<void> {
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"`,
);
}
}
@@ -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)만 저장하고
// 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙).
@@ -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],
+62 -3
View File
@@ -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<ProjectMember>,
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<void> {
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<void> {
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<string[]> {
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<User> {
const user = await this.userService.findById(userId);
if (!user) {
@@ -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;
}
+24
View File
@@ -112,6 +112,30 @@ function toProjectActivityItem(api: ApiActivity): ProjectActivityItem {
}
case 'task.removed':
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 삭제했습니다` }
case 'drive.file_uploaded':
return {
...base,
tone: 'setting',
icon: 'repo',
html: `<b>${actor}</b>님이 파일 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 업로드했습니다`,
}
case 'drive.file_deleted':
return {
...base,
tone: 'setting',
icon: 'pencil',
html: `<b>${actor}</b>님이 파일 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 삭제했습니다`,
}
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: `<b>${actor}</b>님이 폴더 <b>${escapeHtml(pstr(p, 'folderName'))}</b>을(를) 삭제했습니다${suffix}`,
}
}
default:
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님의 활동` }
}
+3
View File
@@ -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 {