feat: 드라이브 업로드·삭제 행위자 추적 추가
연동 프로젝트 폴더의 파일 업로드/삭제와 폴더 삭제를 활동 피드에 기록하고, 파일 삭제를 소프트 삭제(status=deleted + deletedBy/deletedAt 묘비)로 전환해 누가·언제 지웠는지 추적할 수 있게 한다. 실제 객체는 즉시 영구 삭제(복구 제외). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user