feat: 프로젝트 문서 첨부(생성/편집/상세)

프로젝트에 문서 파일을 첨부할 수 있도록 추가(업무 첨부 인프라·업로드 설정 재사용).

백엔드:
- ProjectAttachment 엔티티 + 마이그레이션(project_attachments)
- 업로드(관리자)/삭제(관리자)/다운로드(멤버) 엔드포인트, 멀터 디스크 저장
- 프로젝트 상세 응답에 attachments 포함

프론트:
- 생성 화면: 파일 staged 후 생성 직후 업로드
- 편집(설정) 화면: 기존 문서 목록 + 추가/삭제(즉시), 폼 미저장 보존
- 프로젝트 상세: 문서 목록(다운로드 전용)
- useProject.uploadFile/removeFile, ApiProjectAttachment 타입

권한: 업로드/삭제=관리자, 조회/다운로드=멤버. 형식/용량은 업무 첨부와 동일(25MB).
런타임 검증: 업로드·목록·다운로드·삭제 + 비관리자 403 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:32:35 +09:00
parent 916dd49887
commit 8fc9287747
12 changed files with 749 additions and 8 deletions
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 프로젝트 문서 첨부 — project_attachments 테이블 추가.
// (업무 첨부 attachments 와 동일 구조, project_id FK)
export class AddProjectAttachments1782500000000 implements MigrationInterface {
name = 'AddProjectAttachments1782500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_project_attachments_project" ON "project_attachments" ("project_id")`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_uploader"`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_project"`,
);
await queryRunner.query(`DROP TABLE "project_attachments"`);
}
}
@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Project } from './project.entity';
import type { AttachmentKind } from '../../task/entities/attachment.entity';
// 프로젝트 문서 첨부 — 실제 바이너리는 서버 업로드 폴더에 저장하고 메타데이터만 보관.
// (업무 첨부 Attachment 와 동일 패턴 / 동일 업로드 설정 재사용)
@Entity('project_attachments')
export class ProjectAttachment {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
@Index()
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 업로더 (사용자 삭제 시에도 첨부는 유지 — SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'uploader_id' })
uploader!: User | null;
// 원본 파일명(다운로드 시 표시)
@Column()
name!: string;
// 디스크 저장명(업로드 폴더 내 고유 파일명)
@Column({ name: 'stored_name' })
storedName!: string;
// 파일 크기(바이트)
@Column({ type: 'int' })
size!: number;
// MIME 타입
@Column()
mime!: string;
// 아이콘 종류(MIME/확장자에서 파생)
@Column({ type: 'varchar' })
kind!: AttachmentKind;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -12,14 +12,35 @@ import {
Patch,
Post,
Query,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiConsumes,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { BusinessException } from '../../common/exceptions/business.exception';
import type { PublicUser } from '../user/user.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { ProjectService, type ProjectResponse } from './project.service';
import {
ProjectService,
type ProjectResponse,
type ProjectAttachmentResponse,
} from './project.service';
import {
UPLOAD_DIR,
MAX_FILE_SIZE,
fileMimeFilter,
type UploadedDiskFile,
} from '../task/upload.config';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
@@ -91,4 +112,66 @@ export class ProjectController {
await this.projectService.remove(projectId, user.id);
return null;
}
/* ===================== 문서 첨부 ===================== */
@Post(':projectId/files')
@HttpCode(HttpStatus.CREATED)
@UseInterceptors(
FileInterceptor('file', {
dest: UPLOAD_DIR,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: fileMimeFilter,
}),
)
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: '프로젝트 문서 업로드 (admin)' })
@ApiResponse({ status: 201, description: '업로드 성공' })
addFile(
@Param('projectId', ParseUUIDPipe) projectId: string,
@UploadedFile() file: UploadedDiskFile,
@CurrentUser() user: PublicUser,
): Promise<ProjectAttachmentResponse> {
if (!file) {
throw new BusinessException(
'BIZ_001',
'업로드할 수 없는 파일 형식이거나 파일이 없습니다.',
HttpStatus.BAD_REQUEST,
);
}
return this.projectService.addFile(projectId, user.id, file);
}
@Delete(':projectId/files/:fileId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '프로젝트 문서 삭제 (admin)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async removeFile(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('fileId', ParseUUIDPipe) fileId: string,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.projectService.removeFile(projectId, user.id, fileId);
return null;
}
@Get(':projectId/files/:fileId/download')
@ApiOperation({ summary: '프로젝트 문서 다운로드 (멤버)' })
@ApiResponse({ status: 200, description: '파일 스트림' })
async downloadFile(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('fileId', ParseUUIDPipe) fileId: string,
@CurrentUser() user: PublicUser,
@Res() res: Response,
): Promise<void> {
const { attachment, absolutePath } =
await this.projectService.getFileForDownload(projectId, user.id, fileId);
res.setHeader('Content-Type', attachment.mime);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
);
res.sendFile(absolutePath);
}
}
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.entity';
import { ProjectAttachment } from './entities/project-attachment.entity';
import { Task } from '../task/entities/task.entity';
import { ActivityModule } from '../activity/activity.module';
import { NotificationModule } from '../notification/notification.module';
@@ -15,7 +16,7 @@ import { ProjectMemberController } from './project-member.controller';
// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다.
@Module({
imports: [
TypeOrmModule.forFeature([Project, ProjectMember, Task]),
TypeOrmModule.forFeature([Project, ProjectMember, ProjectAttachment, Task]),
UserModule,
ActivityModule,
NotificationModule,
+128 -1
View File
@@ -5,6 +5,8 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository, type FindOptionsOrder } from 'typeorm';
import { unlink } from 'fs/promises';
import { join } from 'path';
import { UserService, type PublicUser } from '../user/user.service';
import {
paginated,
@@ -13,10 +15,29 @@ import {
} from '../../common/pagination/pagination';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.entity';
import { ProjectAttachment } from './entities/project-attachment.entity';
import { Task } from '../task/entities/task.entity';
import type { AttachmentKind } from '../task/entities/attachment.entity';
import {
UPLOAD_DIR,
deriveKind,
decodeOriginalName,
type UploadedDiskFile,
} from '../task/upload.config';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
// 프로젝트 첨부 응답
export interface ProjectAttachmentResponse {
id: string;
name: string;
size: number;
kind: AttachmentKind;
url: string;
uploader: PublicUser | null;
createdAt: string;
}
// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산
export interface ProjectResponse {
id: string;
@@ -27,9 +48,10 @@ export interface ProjectResponse {
doneCount: number; // 완료 업무 수
totalCount: number; // 전체 업무 수
updatedAt: string;
// 단건(findOne)에서만 — 현재 사용자 권한
// 단건(findOne)에서만 — 현재 사용자 권한 + 문서 첨부
canManage?: boolean; // admin
isOwner?: boolean; // 소유자
attachments?: ProjectAttachmentResponse[];
}
// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용)
@@ -42,6 +64,8 @@ export class ProjectService {
private readonly memberRepo: Repository<ProjectMember>,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
@InjectRepository(ProjectAttachment)
private readonly attachmentRepo: Repository<ProjectAttachment>,
private readonly userService: UserService,
) {}
@@ -94,6 +118,7 @@ export class ProjectService {
...base,
canManage: membership?.roleType === 'admin',
isOwner: membership?.isOwner ?? false,
attachments: await this.listFiles(project.id),
};
}
@@ -181,6 +206,108 @@ export class ProjectService {
}
}
// --- 문서 첨부 ---
// 첨부 목록(최신순) — 프로젝트 상세에 포함
async listFiles(projectId: string): Promise<ProjectAttachmentResponse[]> {
const rows = await this.attachmentRepo.find({
where: { project: { id: projectId } },
relations: ['uploader'],
order: { createdAt: 'DESC' },
});
return rows.map((a) => this.toAttachmentResponse(a, projectId));
}
// 첨부 업로드 — 관리자만. 디스크 저장은 multer 가 처리, 메타데이터만 영속
async addFile(
projectId: string,
userId: string,
file: UploadedDiskFile,
): Promise<ProjectAttachmentResponse> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectAdmin(project.id, userId);
const uploader = await this.userService.findById(userId);
const name = decodeOriginalName(file.originalname);
const saved = await this.attachmentRepo.save(
this.attachmentRepo.create({
project: { id: project.id } as Project,
uploader: uploader ?? null,
name,
storedName: file.filename,
size: file.size,
mime: file.mimetype,
kind: deriveKind(file.mimetype, name),
}),
);
return this.toAttachmentResponse(
{ ...saved, uploader: uploader ?? null },
project.id,
);
}
// 첨부 삭제 — 관리자만. 디스크 파일도 정리
async removeFile(
projectId: string,
userId: string,
fileId: string,
): Promise<void> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectAdmin(project.id, userId);
const attachment = await this.attachmentRepo.findOne({
where: { id: fileId, project: { id: project.id } },
});
if (!attachment) {
throw new NotFoundException();
}
await this.attachmentRepo.remove(attachment);
await this.deleteFileFromDisk(attachment.storedName);
}
// 다운로드용 첨부 메타 + 디스크 절대경로 — 멤버면 조회 가능. 컨트롤러가 스트리밍
async getFileForDownload(
projectId: string,
userId: string,
fileId: string,
): Promise<{ attachment: ProjectAttachment; absolutePath: string }> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectMember(project.id, userId);
const attachment = await this.attachmentRepo.findOne({
where: { id: fileId, project: { id: project.id } },
});
if (!attachment) {
throw new NotFoundException();
}
return {
attachment,
absolutePath: join(UPLOAD_DIR, attachment.storedName),
};
}
// 디스크 파일 삭제(베스트 에포트 — 없어도 무시)
private async deleteFileFromDisk(storedName: string): Promise<void> {
try {
await unlink(join(UPLOAD_DIR, storedName));
} catch {
// 이미 없거나 권한 문제 — 본 흐름을 막지 않는다
}
}
private toAttachmentResponse(
att: ProjectAttachment,
projectId: string,
): ProjectAttachmentResponse {
return {
id: att.id,
name: att.name,
size: att.size,
kind: att.kind,
url: `/api/projects/${projectId}/files/${att.id}/download`,
uploader: att.uploader ? UserService.toPublic(att.uploader) : null,
createdAt: att.createdAt.toISOString(),
};
}
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
private async taskCountsByProject(
ids: string[],