diff --git a/backend/src/migrations/1783200000000-AddDriveSharedAndAudit.ts b/backend/src/migrations/1783200000000-AddDriveSharedAndAudit.ts new file mode 100644 index 0000000..eb39266 --- /dev/null +++ b/backend/src/migrations/1783200000000-AddDriveSharedAndAudit.ts @@ -0,0 +1,105 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// 전사 공유 드라이브 전환 + 전역 감사 로그. +// - drive_activities: 누가 무엇을(추가/이름변경/삭제) 했는지 전역 감사 로그(프로젝트 무관). +// - drive_files / drive_folders 의 owner_id FK: CASCADE → SET NULL 로 변경 +// (공유 드라이브에서 업로더/생성자가 탈퇴해도 공유 파일·폴더는 보존). +// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다. +// 멱등·견고: IF NOT EXISTS / owner_id 의 기존 FK 를 동적으로 모두 제거 후 재생성(제약 이름 상이 대비). +export class AddDriveSharedAndAudit1783200000000 implements MigrationInterface { + name = 'AddDriveSharedAndAudit1783200000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ===== 감사 로그 테이블 ===== + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "drive_activities" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "type" character varying NOT NULL, + "payload" jsonb NOT NULL DEFAULT '{}', + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "actor_id" uuid, + CONSTRAINT "PK_drive_activities" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_drive_activities_actor" ON "drive_activities" ("actor_id")`, + ); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_activities_actor' + ) THEN + ALTER TABLE "drive_activities" + ADD CONSTRAINT "FK_drive_activities_actor" + FOREIGN KEY ("actor_id") REFERENCES "users"("id") + ON DELETE SET NULL ON UPDATE NO ACTION; + END IF; + END $$; + `); + + // ===== owner_id FK 를 SET NULL 로 교체(drive_files, drive_folders) ===== + for (const table of ['drive_files', 'drive_folders']) { + // 혹시 NOT NULL 이면 해제(공유 파일은 업로더 없이도 존재 가능) + await queryRunner.query( + `ALTER TABLE "${table}" ALTER COLUMN "owner_id" DROP NOT NULL`, + ); + // owner_id 컬럼에 걸린 기존 외래키를 이름과 무관하게 모두 제거 → SET NULL 로 재생성 + await queryRunner.query(` + DO $$ + DECLARE r record; + BEGIN + FOR r IN + SELECT con.conname + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_attribute att + ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey) + WHERE rel.relname = '${table}' + AND con.contype = 'f' + AND att.attname = 'owner_id' + LOOP + EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname); + END LOOP; + ALTER TABLE "${table}" + ADD CONSTRAINT "FK_${table}_owner" + FOREIGN KEY ("owner_id") REFERENCES "users"("id") + ON DELETE SET NULL ON UPDATE NO ACTION; + END $$; + `); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // owner_id FK 를 CASCADE 로 되돌린다(이름 무관 제거 후 재생성) + for (const table of ['drive_files', 'drive_folders']) { + await queryRunner.query(` + DO $$ + DECLARE r record; + BEGIN + FOR r IN + SELECT con.conname + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_attribute att + ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey) + WHERE rel.relname = '${table}' + AND con.contype = 'f' + AND att.attname = 'owner_id' + LOOP + EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname); + END LOOP; + ALTER TABLE "${table}" + ADD CONSTRAINT "FK_${table}_owner" + FOREIGN KEY ("owner_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE NO ACTION; + END $$; + `); + } + + await queryRunner.query( + `ALTER TABLE "drive_activities" DROP CONSTRAINT IF EXISTS "FK_drive_activities_actor"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS "drive_activities"`); + } +} diff --git a/backend/src/modules/drive/drive-audit.service.ts b/backend/src/modules/drive/drive-audit.service.ts new file mode 100644 index 0000000..bdb874c --- /dev/null +++ b/backend/src/modules/drive/drive-audit.service.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + paginated, + resolvePage, + type PaginatedResult, +} from '../../common/pagination/pagination'; +import { UserService, type PublicUser } from '../user/user.service'; +import { + DriveActivity, + type DriveActivityType, +} from './entities/drive-activity.entity'; + +// 감사 로그 응답(원시) — 표시 문구/아이콘은 프론트가 type+payload 로 조립 +export interface DriveActivityResponse { + id: string; + type: DriveActivityType; + actor: PublicUser | null; + payload: Record; + createdAt: string; +} + +// 감사 로그 적재 파라미터 +export interface RecordDriveActivityParams { + actorId: string | null; + type: DriveActivityType; + payload?: Record; +} + +// 드라이브 감사 로그 비즈니스 로직 — DriveService 가 record() 로 행위를 적재하고, +// 드라이브 화면이 list() 로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음). +@Injectable() +export class DriveAuditService { + private readonly logger = new Logger(DriveAuditService.name); + + constructor( + @InjectRepository(DriveActivity) + private readonly activityRepo: Repository, + ) {} + + // 감사 로그 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수 + async record(params: RecordDriveActivityParams): Promise { + try { + const activity = this.activityRepo.create({ + actor: params.actorId ? { id: params.actorId } : null, + type: params.type, + payload: params.payload ?? {}, + }); + await this.activityRepo.save(activity); + } catch (err) { + this.logger.error( + `드라이브 감사 로그 적재 실패: ${params.type}`, + err instanceof Error ? err.stack : String(err), + ); + } + } + + // 전역 감사 로그 — 최신순, 오프셋 페이지네이션. 날짜 그룹핑은 프론트. + async list( + page?: number, + size?: number, + ): Promise> { + const pg = resolvePage(page, size); + const [rows, total] = await this.activityRepo.findAndCount({ + relations: ['actor'], + order: { createdAt: 'DESC' }, + skip: pg.skip, + take: pg.take, + }); + return paginated( + rows.map((a) => this.toResponse(a)), + total, + pg, + ); + } + + // 엔티티 → 응답 매핑 + private toResponse(a: DriveActivity): DriveActivityResponse { + return { + id: a.id, + type: a.type, + actor: a.actor ? UserService.toPublic(a.actor) : null, + payload: a.payload ?? {}, + createdAt: a.createdAt.toISOString(), + }; + } +} diff --git a/backend/src/modules/drive/drive.controller.ts b/backend/src/modules/drive/drive.controller.ts index 08c9b80..4024b7d 100644 --- a/backend/src/modules/drive/drive.controller.ts +++ b/backend/src/modules/drive/drive.controller.ts @@ -1,11 +1,13 @@ import { Body, Controller, + DefaultValuePipe, Delete, Get, HttpCode, HttpStatus, Param, + ParseIntPipe, ParseUUIDPipe, Patch, Post, @@ -13,6 +15,7 @@ import { UseGuards, } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import type { PaginatedResult } from '../../common/pagination/pagination'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { PublicUser } from '../user/user.service'; @@ -23,21 +26,27 @@ import { type DriveListResponse, type PresignUploadResponse, } from './drive.service'; +import { + DriveAuditService, + type DriveActivityResponse, +} from './drive-audit.service'; import { CreateFolderDto } from './dto/create-folder.dto'; import { PresignUploadDto } from './dto/presign-upload.dto'; import { RenameDto } from './dto/rename.dto'; -// 드라이브 컨트롤러 — 프로젝트와 독립된 개인 드라이브. 모든 엔드포인트 인증 필요. +// 드라이브 컨트롤러 — 전사 공유 드라이브(로그인 사용자 전원 공유). 모든 엔드포인트 인증 필요. @ApiTags('Drive') @Controller('drive') @UseGuards(JwtAuthGuard) export class DriveController { - constructor(private readonly driveService: DriveService) {} + constructor( + private readonly driveService: DriveService, + private readonly auditService: DriveAuditService, + ) {} @Get() @ApiOperation({ summary: '드라이브 목록(폴더 미지정 시 루트)' }) @ApiResponse({ status: 200, description: '목록 조회 성공' }) - @ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' }) list( @CurrentUser() user: PublicUser, @Query('folderId') folderId?: string, @@ -45,6 +54,16 @@ export class DriveController { return this.driveService.list(user.id, folderId); } + @Get('activity') + @ApiOperation({ summary: '드라이브 감사 로그(누가 무엇을 했는지, 최신순)' }) + @ApiResponse({ status: 200, description: '조회 성공' }) + activity( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + ): Promise> { + return this.auditService.list(page, size); + } + /* ===================== 폴더 ===================== */ @Post('folders') diff --git a/backend/src/modules/drive/drive.module.ts b/backend/src/modules/drive/drive.module.ts index 83e3c14..933c758 100644 --- a/backend/src/modules/drive/drive.module.ts +++ b/backend/src/modules/drive/drive.module.ts @@ -5,26 +5,27 @@ 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'; -import { ProjectMember } from '../project/entities/project-member.entity'; +import { DriveActivity } from './entities/drive-activity.entity'; import { DriveService } from './drive.service'; +import { DriveAuditService } from './drive-audit.service'; import { DriveController } from './drive.controller'; -// 드라이브 모듈 — 프로젝트와 독립된 파일 저장소. +// 드라이브 모듈 — 전사 공유 파일 저장소(로그인 사용자 전원 공유). // 저장소(StorageService)는 전역 StorageModule 에서 주입된다. -// ProjectMember 는 폴더-프로젝트 연동 시 멤버십(권한) 판정을 위해 forFeature 로 등록한다. +// ProjectFolderLink 는 프로젝트 연동(AI 검토 등) 부가 기능을 위해 유지한다. @Module({ imports: [ TypeOrmModule.forFeature([ DriveFolder, DriveFile, ProjectFolderLink, - ProjectMember, + DriveActivity, ]), UserModule, ActivityModule, ], controllers: [DriveController], - providers: [DriveService], + providers: [DriveService, DriveAuditService], exports: [DriveService], }) export class DriveModule {} diff --git a/backend/src/modules/drive/drive.service.ts b/backend/src/modules/drive/drive.service.ts index 3fc584e..8c5f44a 100644 --- a/backend/src/modules/drive/drive.service.ts +++ b/backend/src/modules/drive/drive.service.ts @@ -15,7 +15,7 @@ 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 { DriveAuditService } from './drive-audit.service'; import { DriveFolder } from './entities/drive-folder.entity'; import { DriveFile } from './entities/drive-file.entity'; import { ProjectFolderLink } from './entities/project-folder-link.entity'; @@ -30,14 +30,6 @@ import { import { CreateFolderDto } from './dto/create-folder.dto'; import { PresignUploadDto } from './dto/presign-upload.dto'; -// 폴더/파일에 대한 접근 수준 — 높을수록 권한이 강하다. -export type DriveAccess = 'owner' | 'write' | 'read'; -const ACCESS_RANK: Record = { - read: 1, - write: 2, - owner: 3, -}; - // --- 응답 형태 --- export interface DriveFolderResponse { id: string; @@ -86,27 +78,27 @@ export class DriveService { private readonly fileRepo: Repository, @InjectRepository(ProjectFolderLink) private readonly linkRepo: Repository, - @InjectRepository(ProjectMember) - private readonly memberRepo: Repository, private readonly storage: StorageService, private readonly userService: UserService, private readonly activity: ActivityService, + private readonly audit: DriveAuditService, ) {} /* ===================== 조회 ===================== */ - // 드라이브 목록 — folderId 없으면 내 루트, 있으면 해당 폴더(읽기 권한 필요). - async list(userId: string, folderId?: string): Promise { + // 드라이브 목록 — folderId 없으면 공유 루트, 있으면 해당 폴더. + // 전사 공유 드라이브이므로 로그인 사용자 모두가 동일한 트리를 본다(소유자 필터 없음). + async list(_userId: string, folderId?: string): Promise { if (!folderId) { - // 루트 — 내 소유의 최상위 폴더/파일만 + // 루트 — 공유 최상위 폴더/파일 전체 const [folders, files] = await Promise.all([ this.folderRepo.find({ - where: { owner: { id: userId }, parent: IsNull() }, + where: { parent: IsNull() }, relations: { owner: true }, order: { name: 'ASC' }, }), this.fileRepo.find({ - where: { owner: { id: userId }, folder: IsNull(), status: 'active' }, + where: { folder: IsNull(), status: 'active' }, relations: { owner: true }, order: { name: 'ASC' }, }), @@ -119,7 +111,7 @@ export class DriveService { }; } - const folder = await this.getFolderForAccess(userId, folderId, 'read'); + const folder = await this.loadFolder(folderId); const [folders, files, breadcrumb] = await Promise.all([ this.folderRepo.find({ where: { parent: { id: folder.id } }, @@ -143,14 +135,14 @@ export class DriveService { /* ===================== 폴더 CRUD ===================== */ - // 폴더 생성 — 루트면 내 드라이브, 하위면 상위 폴더에 쓰기 권한 필요. + // 폴더 생성 — 루트 또는 상위 폴더 하위에 생성(로그인 사용자 전원 가능). async createFolder( userId: string, dto: CreateFolderDto, ): Promise { let parent: DriveFolder | null = null; if (dto.parentId) { - parent = await this.getFolderForAccess(userId, dto.parentId, 'write'); + parent = await this.loadFolder(dto.parentId); } const owner = await this.getUserOrThrow(userId); // 1차 저장으로 id 확보 → materialized path 구성 후 재저장 @@ -164,24 +156,35 @@ export class DriveService { ); created.path = `${parent ? parent.path : ''}${created.id}/`; await this.folderRepo.save(created); + await this.audit.record({ + actorId: userId, + type: 'folder_created', + payload: { name: created.name }, + }); return this.toFolderResponse({ ...created, owner }, parent?.id ?? null); } - // 폴더 이름 변경 — 쓰기 권한 필요. + // 폴더 이름 변경 — 로그인 사용자 전원 가능. async renameFolder( userId: string, folderId: string, name: string, ): Promise { - const folder = await this.getFolderForAccess(userId, folderId, 'write'); + const folder = await this.loadFolder(folderId); + const fromName = folder.name; folder.name = name; await this.folderRepo.save(folder); + await this.audit.record({ + actorId: userId, + type: 'folder_renamed', + payload: { fromName, toName: name }, + }); return this.toFolderResponse(folder, folder.parent?.id ?? null); } - // 폴더 삭제 — 쓰기 권한 필요. 하위 트리의 객체 스토리지 파일까지 정리. + // 폴더 삭제 — 로그인 사용자 전원 가능. 하위 트리의 객체 스토리지 파일까지 정리. async deleteFolder(userId: string, folderId: string): Promise { - const folder = await this.getFolderForAccess(userId, folderId, 'write'); + const folder = await this.loadFolder(folderId); // 자기 + 하위 폴더에 속한 모든 파일의 객체 키 수집(행은 FK CASCADE 로 삭제됨) const descendantFiles = await this.fileRepo .createQueryBuilder('f') @@ -194,6 +197,11 @@ export class DriveService { ); await this.folderRepo.remove(folder); await this.deleteObjects(descendantFiles.map((f) => f.objectKey)); + await this.audit.record({ + actorId: userId, + type: 'folder_deleted', + payload: { name: folder.name, fileCount: descendantFiles.length }, + }); for (const projectId of projectIds) { await this.activity.record({ projectId, @@ -220,7 +228,7 @@ export class DriveService { } let folder: DriveFolder | null = null; if (dto.folderId) { - folder = await this.getFolderForAccess(userId, dto.folderId, 'write'); + folder = await this.loadFolder(dto.folderId); } const owner = await this.getUserOrThrow(userId); const objectKey = buildObjectKey(userId, dto.filename); @@ -260,6 +268,7 @@ export class DriveService { if (!file) { throw new NotFoundException(); } + // 발급자 본인만 확정 가능(업로드 2단계 핸드셰이크 무결성 — 접근 권한과 무관) if (file.owner?.id !== userId) { throw new ForbiddenException(); } @@ -301,44 +310,60 @@ export class DriveService { file.size = head.size; file.status = 'active'; await this.fileRepo.save(file); - // 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에 업로드 기록 + await this.audit.record({ + actorId: userId, + type: 'file_uploaded', + payload: { name: file.name }, + }); + // 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 업로드 기록 await this.recordFileActivity(file, userId, 'drive.file_uploaded'); return this.toFileResponse(file, file.folder?.id ?? null); } /* ===================== 파일 CRUD ===================== */ - // 파일 이름 변경 — 쓰기 권한 필요. + // 파일 이름 변경 — 로그인 사용자 전원 가능. async renameFile( userId: string, fileId: string, name: string, ): Promise { - const file = await this.getFileForAccess(userId, fileId, 'write'); + const file = await this.loadFile(fileId); + const fromName = file.name; file.name = name; await this.fileRepo.save(file); + await this.audit.record({ + actorId: userId, + type: 'file_renamed', + payload: { fromName, toName: name }, + }); return this.toFileResponse(file, file.folder?.id ?? null); } - // 파일 삭제 — 쓰기 권한 필요. 객체 스토리지 파일은 즉시 영구 삭제(복구 불가)하고, + // 파일 삭제 — 로그인 사용자 전원 가능. 객체 스토리지 파일은 즉시 영구 삭제(복구 불가)하고, // 메타 행은 누가·언제 지웠는지 추적할 수 있도록 묘비(tombstone)로 보존(소프트 삭제)한다. async deleteFile(userId: string, fileId: string): Promise { - const file = await this.getFileForAccess(userId, fileId, 'write'); + const file = await this.loadFile(fileId); 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.audit.record({ + actorId: userId, + type: 'file_deleted', + payload: { name: file.name }, + }); + // 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 삭제 기록 await this.recordFileActivity(file, userId, 'drive.file_deleted'); } - // 다운로드용 단기 presigned GET URL 발급 — 읽기 권한 필요. + // 다운로드용 단기 presigned GET URL 발급 — 로그인 사용자 전원 가능. async getDownloadUrl( - userId: string, + _userId: string, fileId: string, ): Promise<{ url: string }> { - const file = await this.getFileForAccess(userId, fileId, 'read'); + const file = await this.loadFile(fileId); if (file.status !== 'active') { // 아직 업로드 확정 전 — 다운로드 불가 throw new NotFoundException(); @@ -351,14 +376,10 @@ export class DriveService { return { url }; } - /* ===================== 권한 해석 ===================== */ + /* ===================== 로딩 ===================== */ - // 폴더를 로딩하고 요구 접근 수준을 만족하는지 검사(불충분 시 403, 미존재 404). - private async getFolderForAccess( - userId: string, - folderId: string, - required: DriveAccess, - ): Promise { + // 폴더 로딩(미존재 404). 전사 공유라 권한 검사는 없다. + private async loadFolder(folderId: string): Promise { const folder = await this.folderRepo.findOne({ where: { id: folderId }, relations: { owner: true, parent: true }, @@ -366,87 +387,21 @@ export class DriveService { if (!folder) { throw new NotFoundException(); } - const access = await this.resolveFolderAccess(userId, folder); - this.assertAccess(access, required); return folder; } - // 파일을 로딩하고 요구 접근 수준을 만족하는지 검사. - private async getFileForAccess( - userId: string, - fileId: string, - required: DriveAccess, - ): Promise { + // 파일 로딩(미존재/소프트 삭제 묘비는 404 — 목록·다운로드·재삭제 차단). + private async loadFile(fileId: string): Promise { const file = await this.fileRepo.findOne({ where: { id: fileId }, relations: { owner: true, folder: true }, }); if (!file || file.status === 'deleted') { - // 소프트 삭제된 묘비 행은 존재하지 않는 것으로 취급(목록·다운로드·재삭제 차단) throw new NotFoundException(); } - const access = await this.resolveFileAccess(userId, file); - this.assertAccess(access, required); return file; } - // 폴더 접근 수준 산출 — 소유자 / 연동(쓰기·읽기) / 없음. - private async resolveFolderAccess( - userId: string, - folder: DriveFolder, - ): Promise { - if (folder.owner?.id === userId) return 'owner'; - // 자기 + 조상 폴더 중 하나라도 내가 멤버인 프로젝트에 연동되어 있으면 그 권한을 적용 - return this.resolveLinkedAccess(this.parseFolderPath(folder.path), userId); - } - - // 파일 접근 수준 산출 — 소유자 / 소속 폴더의 연동 권한 / 없음(루트 파일은 소유자 전용). - private async resolveFileAccess( - userId: string, - file: DriveFile, - ): Promise { - if (file.owner?.id === userId) return 'owner'; - if (!file.folder) return null; - return this.resolveLinkedAccess( - this.parseFolderPath(file.folder.path), - userId, - ); - } - - // 폴더 id 집합(자기+조상)에 대해, 내가 멤버인 프로젝트로의 연동 권한 중 최상위를 반환. - private async resolveLinkedAccess( - folderIds: string[], - userId: string, - ): Promise { - if (folderIds.length === 0) return null; - const links = await this.linkRepo.find({ - where: { folder: { id: In(folderIds) } }, - relations: { project: true }, - }); - if (links.length === 0) return null; - const projectIds = [...new Set(links.map((l) => l.project.id))]; - const memberships = await this.memberRepo.find({ - where: { project: { id: In(projectIds) }, user: { id: userId } }, - relations: { project: true }, - }); - const memberProjectIds = new Set(memberships.map((m) => m.project.id)); - const accessible = links.filter((l) => memberProjectIds.has(l.project.id)); - if (accessible.length === 0) return null; - return accessible.some((l) => l.permission === 'read-write') - ? 'write' - : 'read'; - } - - // 접근 수준이 요구치 미만이면 403 - private assertAccess( - access: DriveAccess | null, - required: DriveAccess, - ): void { - if (!access || ACCESS_RANK[access] < ACCESS_RANK[required]) { - throw new ForbiddenException(); - } - } - // "rootId/childId/selfId/" → ["rootId","childId","selfId"] (자기 포함 + 조상) private parseFolderPath(path: string): string[] { return path.split('/').filter(Boolean); @@ -471,7 +426,7 @@ export class DriveService { /* ===================== 내부 유틸 ===================== */ // 파일이 프로젝트 연동 폴더(또는 그 하위)에 속하면 해당 프로젝트(들) 활동 피드에 기록한다. - // 루트(폴더 없음) 개인 파일은 연동 프로젝트가 없어 기록하지 않는다(소유자 전용이라 추적 불요). + // (전역 감사 로그와는 별개로, 프로젝트 화면의 활동 피드 표시를 위한 부가 기록) private async recordFileActivity( file: DriveFile, actorId: string, diff --git a/backend/src/modules/drive/entities/drive-activity.entity.ts b/backend/src/modules/drive/entities/drive-activity.entity.ts new file mode 100644 index 0000000..c92f032 --- /dev/null +++ b/backend/src/modules/drive/entities/drive-activity.entity.ts @@ -0,0 +1,45 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; + +// 드라이브 감사 로그 이벤트 타입 — 추가/이름변경/삭제 행위에서 자동 적재(읽기 전용) +export type DriveActivityType = + | 'file_uploaded' + | 'file_renamed' + | 'file_deleted' + | 'folder_created' + | 'folder_renamed' + | 'folder_deleted'; + +// 드라이브 감사 로그 — 전사 공유 드라이브에서 "누가 무엇을 했는지" 전역 추적. +// 프로젝트 활동(activities)은 project NOT NULL 이라 전사 로그로 못 쓰므로 별도 테이블로 둔다. +// 표시 문구는 프론트가 type+payload 로 조립한다(파생값 규칙). +@Entity('drive_activities') +export class DriveActivity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // 행위자 (사용자 삭제 시에도 기록은 유지 — SET NULL) + @Index() + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'actor_id' }) + actor!: User | null; + + // 이벤트 타입 + @Column({ type: 'varchar' }) + type!: DriveActivityType; + + // 대상 정보(자유형) — 예: name, fromName/toName(이름변경), folderName(상위 폴더 컨텍스트), fileCount(폴더 삭제) + @Column({ type: 'jsonb', default: () => "'{}'" }) + payload!: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/drive/entities/drive-file.entity.ts b/backend/src/modules/drive/entities/drive-file.entity.ts index 8197d54..00ad5ca 100644 --- a/backend/src/modules/drive/entities/drive-file.entity.ts +++ b/backend/src/modules/drive/entities/drive-file.entity.ts @@ -30,11 +30,11 @@ export class DriveFile { @PrimaryGeneratedColumn('uuid') id!: string; - // 소유자 (사용자 삭제 시 파일 메타도 함께 삭제 — 객체 정리는 서비스가 담당) + // 업로더 (전사 공유 드라이브 — 업로더가 탈퇴해도 공유 파일은 보존하므로 SET NULL) @Index() - @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'owner_id' }) - owner!: User; + owner!: User | null; // 소속 폴더 (null = 드라이브 루트). 폴더 삭제 시 파일 메타도 함께 삭제(CASCADE). @Index() diff --git a/backend/src/modules/drive/entities/drive-folder.entity.ts b/backend/src/modules/drive/entities/drive-folder.entity.ts index 927a5a6..bcb8b56 100644 --- a/backend/src/modules/drive/entities/drive-folder.entity.ts +++ b/backend/src/modules/drive/entities/drive-folder.entity.ts @@ -11,18 +11,18 @@ import { } from 'typeorm'; import { User } from '../../user/entities/user.entity'; -// 드라이브 폴더 — 사용자 소유의 폴더 트리(adjacency list). -// 프로젝트와 독립적이며, ProjectFolderLink 로 프로젝트에 연동(공유)할 수 있다. +// 드라이브 폴더 — 전사 공유 폴더 트리(adjacency list). 로그인 사용자 모두가 공유한다. +// ProjectFolderLink 로 프로젝트에 연동(AI 검토 등)할 수 있으나 접근은 전원 공유다. @Entity('drive_folders') export class DriveFolder { @PrimaryGeneratedColumn('uuid') id!: string; - // 소유자 (사용자 삭제 시 폴더도 함께 삭제) + // 생성자(업로더) — 전사 공유라 생성자가 탈퇴해도 폴더는 보존하므로 SET NULL @Index() - @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'owner_id' }) - owner!: User; + owner!: User | null; // 상위 폴더 (null = 드라이브 루트). 상위 삭제 시 하위도 함께 삭제(CASCADE). // Relation<> 래퍼: 자기참조 순환 회피 diff --git a/backend/src/modules/project/project.service.ts b/backend/src/modules/project/project.service.ts index 15d157a..c516468 100644 --- a/backend/src/modules/project/project.service.ts +++ b/backend/src/modules/project/project.service.ts @@ -242,13 +242,16 @@ export class ProjectService { const out: ProjectDriveLinkResponse[] = []; for (const l of links) { out.push( - this.toDriveLinkResponse(l, await this.countFilesInFolderTree(l.folder)), + this.toDriveLinkResponse( + l, + await this.countFilesInFolderTree(l.folder), + ), ); } return out; } - // 드라이브 폴더를 프로젝트에 연동(admin, 본인 소유 폴더만) + // 드라이브 폴더를 프로젝트에 연동(admin). 전사 공유 드라이브이므로 모든 폴더를 연동할 수 있다. async linkDriveFolder( projectId: string, userId: string, @@ -263,14 +266,6 @@ export class ProjectService { if (!folder) { throw new NotFoundException(); } - // 타인 폴더 공유 방지 — 본인이 소유한 폴더만 연동 가능 - if (folder.owner?.id !== userId) { - throw new BusinessException( - 'AUTH_003', - '본인이 소유한 폴더만 연동할 수 있습니다.', - HttpStatus.FORBIDDEN, - ); - } const existing = await this.linkRepo.findOne({ where: { project: { id: project.id }, folder: { id: folder.id } }, }); diff --git a/frontend/src/composables/useDrive.ts b/frontend/src/composables/useDrive.ts index 542ccda..9e05547 100644 --- a/frontend/src/composables/useDrive.ts +++ b/frontend/src/composables/useDrive.ts @@ -1,5 +1,6 @@ import { useApi } from './useApi' import type { + DriveActivityPage, DriveFile, DriveFolder, DriveList, @@ -58,6 +59,16 @@ export function useDrive() { await api.delete(`/drive/files/${id}`) } + // 감사 로그(최신순, 페이지네이션) + async function listActivity( + page = 1, + size = 20, + ): Promise { + return (await api.get('/drive/activity', { + params: { page, size }, + })) as unknown as DriveActivityPage + } + return { list, createFolder, @@ -68,5 +79,6 @@ export function useDrive() { getDownloadUrl, renameFile, deleteFile, + listActivity, } } diff --git a/frontend/src/pages/relay/DrivePage.vue b/frontend/src/pages/relay/DrivePage.vue index 43ffad9..79323e4 100644 --- a/frontend/src/pages/relay/DrivePage.vue +++ b/frontend/src/pages/relay/DrivePage.vue @@ -6,6 +6,8 @@ import { watch } from 'vue' import { storeToRefs } from 'pinia' import AppShell from '@/layouts/AppShell.vue' import EmptyState from '@/components/common/EmptyState.vue' +import BaseModal from '@/components/common/BaseModal.vue' +import LoadMore from '@/components/common/LoadMore.vue' import DriveNameModal from '@/components/drive/DriveNameModal.vue' import { useDriveStore } from '@/stores/drive.store' import { useDialog } from '@/composables/useDialog' @@ -16,14 +18,25 @@ import { isAllowedDriveMime, resolveMimeType, } from '@/shared/utils/drive' -import type { DriveFile, DriveFolder } from '@/types/drive' +import type { DriveActivity, DriveFile, DriveFolder } from '@/types/drive' const route = useRoute() const router = useRouter() const store = useDriveStore() const dialog = useDialog() -const { current, breadcrumb, folders, files, loading, uploads, itemCount, isEmpty } = - storeToRefs(store) +const { + current, + breadcrumb, + folders, + files, + loading, + uploads, + itemCount, + isEmpty, + activities, + activityLoading, + hasMoreActivity, +} = storeToRefs(store) // 로컬 상태 — 검색(현재 폴더 내), 모달, 드래그, 파일 입력 const keyword = ref('') @@ -168,6 +181,63 @@ watch( }, { immediate: true }, ) + +// --- 기록(감사 로그) 패널 --- +const showActivity = ref(false) +function openActivity(): void { + showActivity.value = true + void store.fetchActivity() // 열 때마다 최신으로 갱신 +} + +// payload(자유형) 안전 추출 +function ps(payload: Record, key: string): string { + const v = payload[key] + return typeof v === 'string' ? v : '' +} +// 행위자 표시명(탈퇴 시 대체 문구) +function actorName(a: DriveActivity): string { + return a.actor?.name ?? '(탈퇴한 사용자)' +} +// 행위 문구 조립 — 대상 이름을 강조하기 위해 앞/대상/뒤 3토막으로 반환 +function actionParts(a: DriveActivity): { pre: string; target: string; post: string } { + const p = a.payload + switch (a.type) { + case 'file_uploaded': + return { pre: '파일 ', target: ps(p, 'name'), post: '을(를) 업로드했습니다' } + case 'file_deleted': + return { pre: '파일 ', target: ps(p, 'name'), post: '을(를) 삭제했습니다' } + case 'file_renamed': + return { + pre: '파일 이름을 ', + target: `${ps(p, 'fromName')} → ${ps(p, 'toName')}`, + post: '(으)로 변경했습니다', + } + case 'folder_created': + return { pre: '폴더 ', target: ps(p, 'name'), post: '을(를) 만들었습니다' } + case 'folder_deleted': { + const cnt = typeof p.fileCount === 'number' ? p.fileCount : 0 + return { + pre: '폴더 ', + target: ps(p, 'name'), + post: `을(를) 삭제했습니다 (파일 ${cnt}개 포함)`, + } + } + case 'folder_renamed': + return { + pre: '폴더 이름을 ', + target: `${ps(p, 'fromName')} → ${ps(p, 'toName')}`, + post: '(으)로 변경했습니다', + } + default: + return { pre: '', target: '', post: '' } + } +} +// 타입별 아이콘 톤(업로드/생성=accent, 삭제=danger, 이름변경=muted) +function actionTone(type: DriveActivity['type']): 'up' | 'del' | 'edit' { + if (type === 'file_deleted' || type === 'folder_deleted') return 'del' + if (type === 'file_renamed' || type === 'folder_renamed') return 'edit' + return 'up' +} @@ -749,6 +883,53 @@ watch( } } +/* 기록(감사 로그) 패널 */ +.dr-log { + display: flex; + flex-direction: column; + gap: 0.875rem; + list-style: none; + margin: 0; + padding: 0; +} +.dr-log-row { + display: flex; + align-items: flex-start; + gap: 0.625rem; +} +.dr-log-dot { + flex: 0 0 auto; + width: 0.5rem; + height: 0.5rem; + margin-top: 0.4375rem; + border-radius: 999px; + background: var(--text-3); +} +.dr-log-dot.is-up { + background: var(--accent); +} +.dr-log-dot.is-del { + background: var(--red); +} +.dr-log-dot.is-edit { + background: #e0823d; +} +.dr-log-text { + font-size: 0.875rem; + color: var(--text-2); + line-height: 1.5; +} +.dr-log-text b { + color: var(--text); + font-weight: 600; +} +.dr-log-time { + display: block; + font-size: 0.75rem; + color: var(--text-3); + margin-top: 0.125rem; +} + /* 드래그 오버레이 */ .dr-drop { position: absolute; diff --git a/frontend/src/stores/drive.store.ts b/frontend/src/stores/drive.store.ts index 98ef61d..91a6d19 100644 --- a/frontend/src/stores/drive.store.ts +++ b/frontend/src/stores/drive.store.ts @@ -1,9 +1,17 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { useDrive } from '@/composables/useDrive' -import type { DriveCrumb, DriveFile, DriveFolder } from '@/types/drive' +import type { + DriveActivity, + DriveCrumb, + DriveFile, + DriveFolder, +} from '@/types/drive' import type { PresignedUpload } from '@/types/drive' +// 감사 로그 페이지 크기 +const ACTIVITY_PAGE_SIZE = 20 + // 진행 중 업로드 1건(UI 표시용) export interface UploadItem { id: number // 로컬 임시 id @@ -58,6 +66,15 @@ export const useDriveStore = defineStore('drive', () => { const loading = ref(false) const uploads = ref([]) + // 감사 로그(기록 패널) + const activities = ref([]) + const activityLoading = ref(false) + const activityPage = ref(0) // 마지막으로 불러온 페이지(0=미로드) + const activityTotal = ref(0) + const hasMoreActivity = computed( + () => activities.value.length < activityTotal.value, + ) + const isEmpty = computed( () => folders.value.length === 0 && files.value.length === 0, ) @@ -154,6 +171,36 @@ export const useDriveStore = defineStore('drive', () => { uploads.value = uploads.value.filter((u) => u.id !== id) } + // --- 감사 로그(기록 패널) --- + // 첫 페이지 로드(패널을 열 때마다 최신 상태로 갱신) + async function fetchActivity(): Promise { + activityLoading.value = true + try { + const res = await drive.listActivity(1, ACTIVITY_PAGE_SIZE) + activities.value = res.items + activityTotal.value = res.total + activityPage.value = res.page + } finally { + activityLoading.value = false + } + } + // 다음 페이지 누적 + async function loadMoreActivity(): Promise { + if (activityLoading.value || !hasMoreActivity.value) return + activityLoading.value = true + try { + const res = await drive.listActivity( + activityPage.value + 1, + ACTIVITY_PAGE_SIZE, + ) + activities.value = [...activities.value, ...res.items] + activityTotal.value = res.total + activityPage.value = res.page + } finally { + activityLoading.value = false + } + } + return { folderId, current, @@ -162,6 +209,9 @@ export const useDriveStore = defineStore('drive', () => { files, loading, uploads, + activities, + activityLoading, + hasMoreActivity, isEmpty, itemCount, fetchList, @@ -173,5 +223,7 @@ export const useDriveStore = defineStore('drive', () => { download, uploadOne, dismissUpload, + fetchActivity, + loadMoreActivity, } }) diff --git a/frontend/src/types/drive.ts b/frontend/src/types/drive.ts index 6d07286..2c41a08 100644 --- a/frontend/src/types/drive.ts +++ b/frontend/src/types/drive.ts @@ -73,3 +73,30 @@ export interface PresignUploadPayload { size: number folderId?: string } + +// --- 감사 로그(전사 공유 드라이브 — 누가 무엇을 했는지) --- +// (SSOT: backend/src/modules/drive/drive-audit.service.ts 의 DriveActivityResponse) +export type DriveActivityType = + | 'file_uploaded' + | 'file_renamed' + | 'file_deleted' + | 'folder_created' + | 'folder_renamed' + | 'folder_deleted' + +// 감사 로그 1건(표시 문구는 프론트가 type+payload 로 조립) +export interface DriveActivity { + id: string + type: DriveActivityType + actor: DriveOwner | null + payload: Record + createdAt: string +} + +// 페이지네이션 응답(backend common/pagination 미러) +export interface DriveActivityPage { + items: DriveActivity[] + total: number + page: number + size: number +}