feat: 프로젝트(Project) 모듈 기반 추가 — 프로젝트 우선 구조 1단계
저장소=프로젝트 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Project>;
|
||||
|
||||
// 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩
|
||||
@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;
|
||||
}
|
||||
@@ -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<ProjectMember>[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -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<PaginatedResult<ProjectMemberResponse>> {
|
||||
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<ProjectMemberResponse> {
|
||||
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<ProjectMemberResponse> {
|
||||
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<null> {
|
||||
await this.memberService.remove(projectId, user.id, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<Project>,
|
||||
@InjectRepository(ProjectMember)
|
||||
private readonly memberRepo: Repository<ProjectMember>,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
// 멤버 목록 — 인증 사용자면 열람. 쓰기는 admin 가드.
|
||||
async list(
|
||||
projectId: string,
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<ProjectMemberResponse>> {
|
||||
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<ProjectMemberResponse> {
|
||||
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<ProjectMemberResponse> {
|
||||
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<void> {
|
||||
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<Project> {
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { id: projectId },
|
||||
});
|
||||
if (!project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
private async getMemberOrThrow(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<ProjectMember> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<PaginatedResult<ProjectResponse>> {
|
||||
return this.projectService.findAll(page, size);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '프로젝트 생성' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
create(
|
||||
@Body() dto: CreateProjectDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ProjectResponse> {
|
||||
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<ProjectResponse> {
|
||||
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<ProjectResponse> {
|
||||
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<null> {
|
||||
await this.projectService.remove(projectId, user.id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<Project>,
|
||||
@InjectRepository(ProjectMember)
|
||||
private readonly memberRepo: Repository<ProjectMember>,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
// 부팅 시 "미분류" 시스템 프로젝트 보장(Gitea import 기본 버킷)
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureUnassignedProject();
|
||||
}
|
||||
|
||||
// 프로젝트 목록 — 단일 조직 모델상 인증 사용자면 전체 열람. 페이지네이션.
|
||||
async findAll(
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<ProjectResponse>> {
|
||||
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<ProjectResponse> {
|
||||
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<ProjectResponse> {
|
||||
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<ProjectResponse> {
|
||||
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<void> {
|
||||
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<Project> {
|
||||
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<Project> {
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { id: projectId },
|
||||
});
|
||||
if (!project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
// 프로젝트 멤버 확인 — 아니면 403
|
||||
async assertProjectMember(projectId: string, userId: string): Promise<void> {
|
||||
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<void> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 백엔드.
|
||||
Reference in New Issue
Block a user