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:
2026-06-20 18:41:58 +09:00
parent 9bcbc1564f
commit 2f66cdda8a
13 changed files with 877 additions and 0 deletions
+2
View File
@@ -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(),
};
}
}
+64
View File
@@ -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 백엔드.