From 2f66cdda8ac3827d3c279aee2e79bdf0a12c150f Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 20 Jun 2026 18:41:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8(Pr?= =?UTF-8?q?oject)=20=EB=AA=A8=EB=93=88=20=EA=B8=B0=EB=B0=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=EA=B5=AC=EC=A1=B0=201=EB=8B=A8=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저장소=프로젝트 1:1 구조를 프로젝트 우선 + 프로젝트 1:N 저장소로 전환하는 리팩터의 기반. 기존 코드는 건드리지 않고 추가만 함(기존 저장소 기반 동작 유지). - Project / ProjectMember 엔티티 (isSystem '미분류' 버킷, admin/member/owner) - ProjectService: CRUD + 권한 헬퍼(assertProjectMember/Admin) + 부팅 시 '미분류' 시스템 프로젝트 보장 - ProjectMemberService: 초대/역할/제거 (활동·알림 배선은 Activity 이관 단계 TODO) - /projects, /projects/:id/members 라우트, app.module 등록 - docs/project-refactor-plan.md (리팩터 SSOT) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/app.module.ts | 2 + .../modules/project/dto/create-project.dto.ts | 36 +++ .../modules/project/dto/invite-member.dto.ts | 22 ++ .../modules/project/dto/update-member.dto.ts | 15 ++ .../modules/project/dto/update-project.dto.ts | 27 +++ .../project/entities/project-member.entity.ts | 43 ++++ .../project/entities/project.entity.ts | 47 ++++ .../project/project-member.controller.ts | 87 +++++++ .../modules/project/project-member.service.ts | 195 +++++++++++++++ .../src/modules/project/project.controller.ts | 92 +++++++ backend/src/modules/project/project.module.ts | 19 ++ .../src/modules/project/project.service.ts | 228 ++++++++++++++++++ docs/project-refactor-plan.md | 64 +++++ 13 files changed, 877 insertions(+) create mode 100644 backend/src/modules/project/dto/create-project.dto.ts create mode 100644 backend/src/modules/project/dto/invite-member.dto.ts create mode 100644 backend/src/modules/project/dto/update-member.dto.ts create mode 100644 backend/src/modules/project/dto/update-project.dto.ts create mode 100644 backend/src/modules/project/entities/project-member.entity.ts create mode 100644 backend/src/modules/project/entities/project.entity.ts create mode 100644 backend/src/modules/project/project-member.controller.ts create mode 100644 backend/src/modules/project/project-member.service.ts create mode 100644 backend/src/modules/project/project.controller.ts create mode 100644 backend/src/modules/project/project.module.ts create mode 100644 backend/src/modules/project/project.service.ts create mode 100644 docs/project-refactor-plan.md diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8a6590c..9504d9e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { TaskModule } from './modules/task/task.module'; import { ActivityModule } from './modules/activity/activity.module'; import { NotificationModule } from './modules/notification/notification.module'; import { AiModule } from './modules/ai/ai.module'; +import { ProjectModule } from './modules/project/project.module'; @Module({ imports: [ @@ -97,6 +98,7 @@ import { AiModule } from './modules/ai/ai.module'; ActivityModule, NotificationModule, AiModule, + ProjectModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/modules/project/dto/create-project.dto.ts b/backend/src/modules/project/dto/create-project.dto.ts new file mode 100644 index 0000000..334f3a5 --- /dev/null +++ b/backend/src/modules/project/dto/create-project.dto.ts @@ -0,0 +1,36 @@ +import { + IsIn, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import type { ProjectVisibility } from '../entities/project.entity'; + +// 프로젝트 생성 DTO +export class CreateProjectDto { + @ApiProperty({ + description: '표시 제목', + example: '3분기 신제품 런칭 캠페인', + }) + @IsString() + @IsNotEmpty({ message: '프로젝트 이름을 입력해 주세요.' }) + @MaxLength(60) + name!: string; + + @ApiProperty({ description: '프로젝트 설명', required: false }) + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; + + @ApiProperty({ + description: '공개 범위', + enum: ['private', 'public'], + required: false, + }) + @IsOptional() + @IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' }) + visibility?: ProjectVisibility; +} diff --git a/backend/src/modules/project/dto/invite-member.dto.ts b/backend/src/modules/project/dto/invite-member.dto.ts new file mode 100644 index 0000000..d28ae72 --- /dev/null +++ b/backend/src/modules/project/dto/invite-member.dto.ts @@ -0,0 +1,22 @@ +import { IsEmail, IsIn, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import type { ProjectRoleType } from '../entities/project-member.entity'; + +// 프로젝트 멤버 초대 DTO — 가입된 사용자를 이메일로 초대 +export class InviteProjectMemberDto { + @ApiProperty({ + description: '초대할 사용자 이메일(이미 가입된 사용자)', + example: 'sykim@acme.co', + }) + @IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' }) + @IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' }) + email!: string; + + @ApiProperty({ + description: '권한 역할', + enum: ['admin', 'member'], + example: 'member', + }) + @IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' }) + roleType!: ProjectRoleType; +} diff --git a/backend/src/modules/project/dto/update-member.dto.ts b/backend/src/modules/project/dto/update-member.dto.ts new file mode 100644 index 0000000..7088c67 --- /dev/null +++ b/backend/src/modules/project/dto/update-member.dto.ts @@ -0,0 +1,15 @@ +import { IsIn, IsOptional } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import type { ProjectRoleType } from '../entities/project-member.entity'; + +// 프로젝트 멤버 수정 DTO — 권한 역할 (owner 는 변경 불가) +export class UpdateProjectMemberDto { + @ApiProperty({ + description: '권한 역할', + enum: ['admin', 'member'], + required: false, + }) + @IsOptional() + @IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' }) + roleType?: ProjectRoleType; +} diff --git a/backend/src/modules/project/dto/update-project.dto.ts b/backend/src/modules/project/dto/update-project.dto.ts new file mode 100644 index 0000000..77622dc --- /dev/null +++ b/backend/src/modules/project/dto/update-project.dto.ts @@ -0,0 +1,27 @@ +import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import type { ProjectVisibility } from '../entities/project.entity'; + +// 프로젝트 수정 DTO — 제공된 필드만 갱신 +export class UpdateProjectDto { + @ApiProperty({ description: '표시 제목', required: false }) + @IsOptional() + @IsString() + @MaxLength(60) + name?: string; + + @ApiProperty({ description: '프로젝트 설명', required: false }) + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; + + @ApiProperty({ + description: '공개 범위', + enum: ['private', 'public'], + required: false, + }) + @IsOptional() + @IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' }) + visibility?: ProjectVisibility; +} diff --git a/backend/src/modules/project/entities/project-member.entity.ts b/backend/src/modules/project/entities/project-member.entity.ts new file mode 100644 index 0000000..7eb8283 --- /dev/null +++ b/backend/src/modules/project/entities/project-member.entity.ts @@ -0,0 +1,43 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + type Relation, + Unique, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; +import { Project } from './project.entity'; + +// 프로젝트 내 권한 역할 +export type ProjectRoleType = 'admin' | 'member'; + +// 프로젝트 멤버 (Project ↔ User 조인 + 역할). 권한·담당자 풀의 기준 단위. +@Entity('project_members') +@Unique(['project', 'user']) +export class ProjectMember { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // 소속 프로젝트 (프로젝트 삭제 시 함께 삭제) + @ManyToOne(() => Project, (project) => project.members, { + onDelete: 'CASCADE', + }) + project!: Relation; + + // 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩 + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user!: User; + + // 권한 역할 (admin: 프로젝트 관리, member: 일반) + @Column({ name: 'role_type', type: 'varchar', default: 'member' }) + roleType!: ProjectRoleType; + + // 소유자 여부(프로젝트 생성자) — 역할 변경/제거 불가, 프로젝트 삭제 권한 + @Column({ name: 'is_owner', default: false }) + isOwner!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/project/entities/project.entity.ts b/backend/src/modules/project/entities/project.entity.ts new file mode 100644 index 0000000..df4709f --- /dev/null +++ b/backend/src/modules/project/entities/project.entity.ts @@ -0,0 +1,47 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + type Relation, + UpdateDateColumn, +} from 'typeorm'; +import { ProjectMember } from './project-member.entity'; + +// 프로젝트 공개 범위 +export type ProjectVisibility = 'private' | 'public'; + +// 프로젝트 엔티티 — 작업의 기본 단위(우선). 저장소(Repo)는 프로젝트에 0~N개 연동된다. +@Entity('projects') +export class Project { + // 내부 식별자 (UUID) — 라우팅에도 사용 + @PrimaryGeneratedColumn('uuid') + id!: string; + + // 표시 제목(한글, 예: '3분기 신제품 런칭 캠페인') + @Column() + name!: string; + + // 설명(선택) + @Column({ type: 'varchar', nullable: true }) + description!: string | null; + + // 공개 범위 + @Column({ type: 'varchar', default: 'private' }) + visibility!: ProjectVisibility; + + // 시스템 예약 프로젝트 여부 — "미분류"(Gitea import 기본 버킷)는 true. 삭제/이름변경 보호. + @Column({ name: 'is_system', default: false }) + isSystem!: boolean; + + // 멤버 목록 + @OneToMany(() => ProjectMember, (member) => member.project) + members!: Relation[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/project/project-member.controller.ts b/backend/src/modules/project/project-member.controller.ts new file mode 100644 index 0000000..1fd7f6c --- /dev/null +++ b/backend/src/modules/project/project-member.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + DefaultValuePipe, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { PublicUser } from '../user/user.service'; +import type { PaginatedResult } from '../../common/pagination/pagination'; +import { + ProjectMemberService, + type ProjectMemberResponse, +} from './project-member.service'; +import { InviteProjectMemberDto } from './dto/invite-member.dto'; +import { UpdateProjectMemberDto } from './dto/update-member.dto'; + +// 프로젝트 멤버 컨트롤러 — 모든 엔드포인트 인증 필요 +@ApiTags('Project') +@Controller('projects/:projectId/members') +@UseGuards(JwtAuthGuard) +export class ProjectMemberController { + constructor(private readonly memberService: ProjectMemberService) {} + + @Get() + @ApiOperation({ summary: '프로젝트 멤버 목록 (페이지네이션)' }) + @ApiResponse({ status: 200, description: '목록 조회 성공' }) + list( + @Param('projectId', ParseUUIDPipe) projectId: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + ): Promise> { + return this.memberService.list(projectId, page, size); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '멤버 초대 (admin)' }) + @ApiResponse({ status: 201, description: '초대 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) + @ApiResponse({ status: 409, description: '이미 멤버 (BIZ_001)' }) + invite( + @Param('projectId', ParseUUIDPipe) projectId: string, + @Body() dto: InviteProjectMemberDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.memberService.invite(projectId, user.id, dto); + } + + @Patch(':userId') + @ApiOperation({ summary: '멤버 역할 변경 (admin, owner 제외)' }) + @ApiResponse({ status: 200, description: '변경 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 / 소유자 변경 불가' }) + update( + @Param('projectId', ParseUUIDPipe) projectId: string, + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: UpdateProjectMemberDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.memberService.update(projectId, user.id, userId, dto); + } + + @Delete(':userId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '멤버 제거 (admin, owner 제외)' }) + @ApiResponse({ status: 200, description: '제거 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 / 소유자 제거 불가' }) + async remove( + @Param('projectId', ParseUUIDPipe) projectId: string, + @Param('userId', ParseUUIDPipe) userId: string, + @CurrentUser() user: PublicUser, + ): Promise { + await this.memberService.remove(projectId, user.id, userId); + return null; + } +} diff --git a/backend/src/modules/project/project-member.service.ts b/backend/src/modules/project/project-member.service.ts new file mode 100644 index 0000000..3b4d921 --- /dev/null +++ b/backend/src/modules/project/project-member.service.ts @@ -0,0 +1,195 @@ +import { + ForbiddenException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserService, type PublicUser } from '../user/user.service'; +import { BusinessException } from '../../common/exceptions/business.exception'; +import { + paginated, + resolvePage, + type PaginatedResult, +} from '../../common/pagination/pagination'; +import { Project } from './entities/project.entity'; +import { + ProjectMember, + type ProjectRoleType, +} from './entities/project-member.entity'; +import { InviteProjectMemberDto } from './dto/invite-member.dto'; +import { UpdateProjectMemberDto } from './dto/update-member.dto'; + +// 프로젝트 멤버 응답 — isYou 는 프론트가 현재 사용자와 비교해 산출 +export interface ProjectMemberResponse { + user: PublicUser; + email: string; + taskCount: number; // TODO(Task 이관): 실제 담당 업무 수 집계 + roleType: ProjectRoleType; + owner: boolean; +} + +// 프로젝트 멤버 관리 비즈니스 로직 +// TODO(Activity/Notification 이관): 초대/역할변경/제거 시 활동 적재·알림 발송 배선 +@Injectable() +export class ProjectMemberService { + constructor( + @InjectRepository(Project) + private readonly projectRepo: Repository, + @InjectRepository(ProjectMember) + private readonly memberRepo: Repository, + private readonly userService: UserService, + ) {} + + // 멤버 목록 — 인증 사용자면 열람. 쓰기는 admin 가드. + async list( + projectId: string, + page?: number, + size?: number, + ): Promise> { + const project = await this.getProjectOrThrow(projectId); + const pg = resolvePage(page, size); + const [members, total] = await this.memberRepo.findAndCount({ + where: { project: { id: project.id } }, + relations: ['user'], + order: { createdAt: 'ASC' }, + skip: pg.skip, + take: pg.take, + }); + return paginated( + members.map((m) => this.toResponse(m)), + total, + pg, + ); + } + + // 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대 + async invite( + projectId: string, + actingUserId: string, + dto: InviteProjectMemberDto, + ): Promise { + const project = await this.getProjectOrThrow(projectId); + await this.assertAdmin(project.id, actingUserId); + + const user = await this.userService.findByEmail(dto.email); + if (!user) { + throw new BusinessException( + 'BIZ_001', + '해당 이메일의 가입된 사용자를 찾을 수 없습니다. 먼저 가입이 필요합니다.', + HttpStatus.NOT_FOUND, + ); + } + + const existing = await this.memberRepo.findOne({ + where: { project: { id: project.id }, user: { id: user.id } }, + }); + if (existing) { + throw new BusinessException( + 'BIZ_001', + '이미 프로젝트에 속한 멤버입니다.', + HttpStatus.CONFLICT, + ); + } + + const member = this.memberRepo.create({ + project, + user, + roleType: dto.roleType, + isOwner: false, + }); + const saved = await this.memberRepo.save(member); + saved.user = user; + return this.toResponse(saved); + } + + // 멤버 역할 변경 — admin 권한. 소유자는 변경 불가 + async update( + projectId: string, + actingUserId: string, + targetUserId: string, + dto: UpdateProjectMemberDto, + ): Promise { + const project = await this.getProjectOrThrow(projectId); + await this.assertAdmin(project.id, actingUserId); + const member = await this.getMemberOrThrow(project.id, targetUserId); + if (member.isOwner) { + throw new BusinessException( + 'BIZ_001', + '소유자의 권한은 변경할 수 없습니다.', + HttpStatus.FORBIDDEN, + ); + } + if (dto.roleType !== undefined) member.roleType = dto.roleType; + await this.memberRepo.save(member); + return this.toResponse(member); + } + + // 멤버 제거 — admin 권한. 소유자는 제거 불가 + async remove( + projectId: string, + actingUserId: string, + targetUserId: string, + ): Promise { + const project = await this.getProjectOrThrow(projectId); + await this.assertAdmin(project.id, actingUserId); + const member = await this.getMemberOrThrow(project.id, targetUserId); + if (member.isOwner) { + throw new BusinessException( + 'BIZ_001', + '소유자는 프로젝트에서 제거할 수 없습니다.', + HttpStatus.FORBIDDEN, + ); + } + await this.memberRepo.remove(member); + } + + // --- 내부 헬퍼 --- + + private async getProjectOrThrow(projectId: string): Promise { + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + }); + if (!project) { + throw new NotFoundException(); + } + return project; + } + + private async getMemberOrThrow( + projectId: string, + userId: string, + ): Promise { + const member = await this.memberRepo.findOne({ + where: { project: { id: projectId }, user: { id: userId } }, + relations: ['user'], + }); + if (!member) { + throw new NotFoundException(); + } + return member; + } + + private async assertAdmin(projectId: string, userId: string): Promise { + const membership = await this.memberRepo.findOne({ + where: { project: { id: projectId }, user: { id: userId } }, + }); + if (!membership || membership.roleType !== 'admin') { + throw new ForbiddenException(); + } + } + + private toResponse( + member: ProjectMember, + taskCount = 0, + ): ProjectMemberResponse { + return { + user: UserService.toPublic(member.user), + email: member.user.email, + taskCount, + roleType: member.roleType, + owner: member.isOwner, + }; + } +} diff --git a/backend/src/modules/project/project.controller.ts b/backend/src/modules/project/project.controller.ts new file mode 100644 index 0000000..25f3cbc --- /dev/null +++ b/backend/src/modules/project/project.controller.ts @@ -0,0 +1,92 @@ +import { + Body, + Controller, + DefaultValuePipe, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { PublicUser } from '../user/user.service'; +import type { PaginatedResult } from '../../common/pagination/pagination'; +import { ProjectService, type ProjectResponse } from './project.service'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; + +// 프로젝트 컨트롤러 — 모든 엔드포인트 인증 필요 +@ApiTags('Project') +@Controller('projects') +@UseGuards(JwtAuthGuard) +export class ProjectController { + constructor(private readonly projectService: ProjectService) {} + + @Get() + @ApiOperation({ summary: '프로젝트 전체 목록 (페이지네이션)' }) + @ApiResponse({ status: 200, description: '목록 조회 성공' }) + findAll( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + ): Promise> { + return this.projectService.findAll(page, size); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '프로젝트 생성' }) + @ApiResponse({ status: 201, description: '생성 성공' }) + create( + @Body() dto: CreateProjectDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.projectService.create(dto, user.id); + } + + @Get(':projectId') + @ApiOperation({ summary: '프로젝트 단건 조회' }) + @ApiResponse({ status: 200, description: '조회 성공' }) + @ApiResponse({ + status: 404, + description: '프로젝트를 찾을 수 없음 (RES_001)', + }) + findOne( + @Param('projectId', ParseUUIDPipe) projectId: string, + @CurrentUser() user: PublicUser, + ): Promise { + return this.projectService.findOne(projectId, user.id); + } + + @Patch(':projectId') + @ApiOperation({ summary: '프로젝트 수정 (admin)' }) + @ApiResponse({ status: 200, description: '수정 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) + update( + @Param('projectId', ParseUUIDPipe) projectId: string, + @Body() dto: UpdateProjectDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.projectService.update(projectId, user.id, dto); + } + + @Delete(':projectId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '프로젝트 삭제 (소유자, 시스템 프로젝트 제외)' }) + @ApiResponse({ status: 200, description: '삭제 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) + async remove( + @Param('projectId', ParseUUIDPipe) projectId: string, + @CurrentUser() user: PublicUser, + ): Promise { + await this.projectService.remove(projectId, user.id); + return null; + } +} diff --git a/backend/src/modules/project/project.module.ts b/backend/src/modules/project/project.module.ts new file mode 100644 index 0000000..01d1f3f --- /dev/null +++ b/backend/src/modules/project/project.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +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 { ProjectService } from './project.service'; +import { ProjectMemberService } from './project-member.service'; +import { ProjectController } from './project.controller'; +import { ProjectMemberController } from './project-member.controller'; + +// 프로젝트 모듈 — 작업의 기본 단위. 저장소(Repo)·업무(Task)·활동(Activity)이 프로젝트에 연동된다. +// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다. +@Module({ + imports: [TypeOrmModule.forFeature([Project, ProjectMember]), UserModule], + controllers: [ProjectController, ProjectMemberController], + providers: [ProjectService, ProjectMemberService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/backend/src/modules/project/project.service.ts b/backend/src/modules/project/project.service.ts new file mode 100644 index 0000000..e61e71f --- /dev/null +++ b/backend/src/modules/project/project.service.ts @@ -0,0 +1,228 @@ +import { + ForbiddenException, + Injectable, + Logger, + NotFoundException, + type OnApplicationBootstrap, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserService, type PublicUser } from '../user/user.service'; +import { + paginated, + resolvePage, + type PaginatedResult, +} from '../../common/pagination/pagination'; +import { Project, type ProjectVisibility } from './entities/project.entity'; +import { ProjectMember } from './entities/project-member.entity'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; + +// "미분류" 시스템 프로젝트 이름 — Gitea import 기본 버킷 +export const UNASSIGNED_PROJECT_NAME = '미분류'; + +// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산 +export interface ProjectResponse { + id: string; + name: string; + description: string | null; + visibility: ProjectVisibility; + isSystem: boolean; + members: PublicUser[]; + memberCount: number; + repoCount: number; // 연동 저장소 수 + doneCount: number; // 완료 업무 수 + totalCount: number; // 전체 업무 수 + updatedAt: string; + // 단건(findOne)에서만 — 현재 사용자 권한 + canManage?: boolean; // admin + isOwner?: boolean; // 소유자 +} + +// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용) +@Injectable() +export class ProjectService implements OnApplicationBootstrap { + private readonly logger = new Logger(ProjectService.name); + + constructor( + @InjectRepository(Project) + private readonly projectRepo: Repository, + @InjectRepository(ProjectMember) + private readonly memberRepo: Repository, + private readonly userService: UserService, + ) {} + + // 부팅 시 "미분류" 시스템 프로젝트 보장(Gitea import 기본 버킷) + async onApplicationBootstrap(): Promise { + await this.ensureUnassignedProject(); + } + + // 프로젝트 목록 — 단일 조직 모델상 인증 사용자면 전체 열람. 페이지네이션. + async findAll( + page?: number, + size?: number, + ): Promise> { + const pg = resolvePage(page, size); + const [projects, total] = await this.projectRepo.findAndCount({ + relations: ['members', 'members.user'], + order: { isSystem: 'DESC', updatedAt: 'DESC' }, + skip: pg.skip, + take: pg.take, + }); + return paginated( + projects.map((p) => this.toResponse(p)), + total, + pg, + ); + } + + // 프로젝트 단건 — 인증 사용자면 열람 가능. 현재 사용자 권한(canManage/isOwner) 포함. + async findOne(projectId: string, userId: string): Promise { + const project = await this.getProjectOrThrow(projectId); + const membership = await this.memberRepo.findOne({ + where: { project: { id: project.id }, user: { id: userId } }, + }); + const base = this.toResponse(project); + return { + ...base, + canManage: membership?.roleType === 'admin', + isOwner: membership?.isOwner ?? false, + }; + } + + // 프로젝트 생성 — 생성자를 owner-admin 멤버로 등록 + async create( + dto: CreateProjectDto, + userId: string, + ): Promise { + const project = await this.projectRepo.save( + this.projectRepo.create({ + name: dto.name, + description: dto.description ?? null, + visibility: dto.visibility ?? 'private', + isSystem: false, + }), + ); + const creator = await this.userService.findById(userId); + if (creator) { + await this.memberRepo.save( + this.memberRepo.create({ + project, + user: creator, + roleType: 'admin', + isOwner: true, + }), + ); + } + return this.findOne(project.id, userId); + } + + // 프로젝트 수정 — admin 권한. 시스템 프로젝트는 이름 변경 불가. + async update( + projectId: string, + userId: string, + dto: UpdateProjectDto, + ): Promise { + const project = await this.getProjectOrThrow(projectId); + await this.assertProjectAdmin(project.id, userId); + if ( + project.isSystem && + dto.name !== undefined && + dto.name !== project.name + ) { + throw new ForbiddenException(); + } + if (dto.name !== undefined) project.name = dto.name; + if (dto.description !== undefined) project.description = dto.description; + if (dto.visibility !== undefined) project.visibility = dto.visibility; + await this.projectRepo.save(project); + return this.findOne(project.id, userId); + } + + // 프로젝트 삭제 — 소유자 권한. 시스템 프로젝트는 삭제 불가. + async remove(projectId: string, userId: string): Promise { + const project = await this.getProjectOrThrow(projectId); + const membership = await this.memberRepo.findOne({ + where: { project: { id: project.id }, user: { id: userId } }, + }); + if (!membership?.isOwner) { + throw new ForbiddenException(); + } + if (project.isSystem) { + throw new ForbiddenException(); + } + await this.projectRepo.remove(project); + } + + // --- 다른 모듈에서 쓰는 헬퍼 --- + + // "미분류" 시스템 프로젝트 보장 후 id 반환 (Gitea import 연결용) + async ensureUnassignedProject(): Promise { + const existing = await this.projectRepo.findOne({ + where: { isSystem: true }, + }); + if (existing) return existing; + const created = await this.projectRepo.save( + this.projectRepo.create({ + name: UNASSIGNED_PROJECT_NAME, + description: 'Gitea 에서 가져온, 아직 프로젝트에 배정되지 않은 저장소', + visibility: 'private', + isSystem: true, + }), + ); + this.logger.log('"미분류" 시스템 프로젝트 생성'); + return created; + } + + // projectId 로 프로젝트 로딩, 없으면 404 + async getProjectOrThrow(projectId: string): Promise { + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + }); + if (!project) { + throw new NotFoundException(); + } + return project; + } + + // 프로젝트 멤버 확인 — 아니면 403 + async assertProjectMember(projectId: string, userId: string): Promise { + const membership = await this.memberRepo.findOne({ + where: { project: { id: projectId }, user: { id: userId } }, + }); + if (!membership) { + throw new ForbiddenException(); + } + } + + // 프로젝트 admin 확인 — 아니면 403 + async assertProjectAdmin(projectId: string, userId: string): Promise { + const membership = await this.memberRepo.findOne({ + where: { project: { id: projectId }, user: { id: userId } }, + }); + if (!membership || membership.roleType !== 'admin') { + throw new ForbiddenException(); + } + } + + // 엔티티 → 응답 매핑. + // TODO(Task 이관): repoCount/doneCount/totalCount 를 RepoService/TaskService 로 집계(필요 시 async 전환). + private toResponse(project: Project): ProjectResponse { + const members = project.members ?? []; + return { + id: project.id, + name: project.name, + description: project.description, + visibility: project.visibility, + isSystem: project.isSystem, + members: members + .filter((m) => m.user) + .map((m) => UserService.toPublic(m.user)), + memberCount: members.length, + repoCount: 0, + doneCount: 0, + totalCount: 0, + updatedAt: project.updatedAt.toISOString(), + }; + } +} diff --git a/docs/project-refactor-plan.md b/docs/project-refactor-plan.md new file mode 100644 index 0000000..fe1eb88 --- /dev/null +++ b/docs/project-refactor-plan.md @@ -0,0 +1,64 @@ +# 프로젝트 우선 구조 리팩터 계획 (Project-First) + +> 작성 2026-06-20. 현재 **저장소(Repo)=프로젝트 1:1** 구조를 **프로젝트 우선 + 프로젝트 1:N 저장소** 로 전환한다. +> 이 문서가 리팩터의 단일 진실 공급원(SSOT). 단계별로 갱신한다. + +## 1. 목표 구조 + +``` +Project (이름/설명/공개범위) ← 작업의 기본 단위(우선) + ├─ ProjectMember (admin/member, isOwner) ← 멤버십을 프로젝트로 이동(RepoMember 폐지) + ├─ Task (project, seq[프로젝트 내 순번], …) ← 업무는 프로젝트 소속 + │ └─ checklist / comment / attachment (기존 그대로, Task 종속) + ├─ Activity (project, taskSeq?, …) ← 활동도 프로젝트 단위 + └─ Repo[] (project FK, gitea 필드…) ← 0~N개 저장소 연동(선택, 코드 연동 전용) +``` + +- **일반 프로젝트** = 연동 저장소 0개. 업무·멤버·활동 정상 사용. +- **저장소 연동 프로젝트** = 저장소 1~N개 링크. 추가로 Gitea/코드참조. + +## 2. 확정된 설계 결정 (사용자 합의 2026-06-20) + +1. **업무·멤버·활동 = 프로젝트 단위.** `Task→Repo` 종속을 `Task→Project` 로 이동. `RepoMember` 폐지 → `ProjectMember`. +2. **Gitea 자동 가져오기(역방향 동기화)** → import 저장소는 **"미분류" 기본 프로젝트**에 연결. 관리자가 나중에 적절한 프로젝트로 이동. +3. **dev DB 초기화**(volume 재생성). 마이그레이션 스크립트 불필요. + +## 3. 추가 설계 결정 (구현자 판단 — 변경 가능) + +- **라우팅**: 업무는 프로젝트 직속, 저장소는 형제 부속. + - `/projects` · `/projects/:projectId` (상세=업무목록) · `/projects/:projectId/{members,activity,settings}` + - `/projects/:projectId/tasks/:seq` (업무 상세) · `/projects/:projectId/tasks/new` + - `/projects/:projectId/repos` (연동 저장소 목록·링크) · `/projects/:projectId/repos/:repoSlug` (저장소 상세/설정) + - `/tasks` (내 업무, 프로젝트 횡단 — 경로 유지) +- **저장소 식별자**: `slugName` 은 Gitea 조직 내 유일이므로 **전역 유일 유지**(프로젝트 내 유일로 바꾸지 않음). Repo 에 `project_id` FK만 추가. +- **Task.seq**: `@Unique(['repo','seq'])` → `@Unique(['project','seq'])` (프로젝트 내 순번 #N). +- **권한**: `assertMember/assertAdmin(repo.id, …)` → `assertProjectMember/assertProjectAdmin(project.id, …)`. 저장소 단위 세분 권한은 두지 않음(프로젝트 멤버면 연동 저장소 전체 접근). 향후 옵션. +- **저장소 자체엔 멤버 없음.** 저장소는 순수 Gitea 연동 메타. 멤버십/권한은 소유 프로젝트가 결정. +- **"미분류" 기본 프로젝트**: 시스템 예약 프로젝트 1개(부팅 시 보장 생성). Gitea import 대상. + +## 4. 단계별 실행 계획 + +### Phase 1 — 백엔드 데이터 모델 + Project 모듈 (핵심) +- [ ] `Project`·`ProjectMember` 엔티티 + `ProjectModule`/`ProjectService`/`ProjectController`(CRUD + 멤버 초대/역할/제거 + 권한 가드) +- [ ] "미분류" 기본 프로젝트 부팅 보장(OnApplicationBootstrap) +- [ ] `Repo` 에 `project` FK 추가. `RepoMember` 폐지. Repo 생성/수정/삭제가 프로젝트 권한으로 게이트. +- [ ] `Task` 를 `project` 종속으로 변경(`@Unique(['project','seq'])`), 권한 체인 프로젝트로 교체, 담당자=프로젝트 멤버. +- [ ] `Activity` 를 `project` 종속으로. (taskSeq 유지) +- [ ] `RepoSyncService`: import → "미분류" 프로젝트 연결. +- [ ] `AiService.suggestChecklist`: 프로젝트/저장소 컨텍스트 정합. +- [ ] 컨트롤러 라우트 재배치(`/projects/...`). 기존 `/repos/...` 제거 또는 이전. +- [ ] 검증: build/lint/test. + +### Phase 2 — 프론트 프로젝트 중심 재구성 +- [ ] `project.store`·`useProject`·`types/project.ts` +- [ ] 신규 페이지: ProjectListPage(진입점)·ProjectDetailPage(업무목록)·ProjectSettingsPage·ProjectMembersPage·ProjectActivityPage +- [ ] 저장소: 프로젝트 내 "연동 저장소" 섹션 + RepoLinkPage(기존 RepoCreate 재구성) +- [ ] task/member/activity 스토어를 projectId 키로 재설계 +- [ ] 라우팅 전면 교체, AppShell 네비(프로젝트/내 업무) +- [ ] 검증: type-check/lint/build. + +### Phase 3 — 마무리 +- [ ] 문서(api-contract.md)·메모리 갱신, 잔여 UI 정리, 런타임 점검. + +## 5. 진행 로그 +- (작성됨) Phase 0: 계획 수립. 다음 = Phase 1 백엔드.