diff --git a/backend/src/modules/activity/activity.controller.ts b/backend/src/modules/activity/activity.controller.ts index ed193d2..dace81c 100644 --- a/backend/src/modules/activity/activity.controller.ts +++ b/backend/src/modules/activity/activity.controller.ts @@ -1,20 +1,27 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { ActivityService, type ActivityResponse } from './activity.service'; // 활동 피드 컨트롤러 — 읽기 전용. 조직 모델상 인증 사용자면 열람 가능 @ApiTags('Activity') -@Controller('repos/:repoId/activity') +@Controller('projects/:projectId/activity') @UseGuards(JwtAuthGuard) export class ActivityController { constructor(private readonly activityService: ActivityService) {} @Get() - @ApiOperation({ summary: '저장소 활동 피드 (최신순)' }) + @ApiOperation({ summary: '프로젝트 활동 피드 (최신순)' }) @ApiResponse({ status: 200, description: '조회 성공' }) - @ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' }) - list(@Param('repoId') repoId: string): Promise { - return this.activityService.listForRepo(repoId); + list( + @Param('projectId', ParseUUIDPipe) projectId: string, + ): Promise { + return this.activityService.listForProject(projectId); } } diff --git a/backend/src/modules/activity/activity.module.ts b/backend/src/modules/activity/activity.module.ts index 106893a..87374e0 100644 --- a/backend/src/modules/activity/activity.module.ts +++ b/backend/src/modules/activity/activity.module.ts @@ -1,14 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Activity } from './entities/activity.entity'; -import { Repo } from '../repo/entities/repo.entity'; import { ActivityService } from './activity.service'; import { ActivityController } from './activity.controller'; -// 활동 피드 모듈 — 다른 도메인 모듈(repo/task)이 import 해 ActivityService 로 이벤트를 적재한다. -// (Activity 는 taskSeq 만 보관해 Task 엔티티를 참조하지 않으므로 순환 의존 없음) +// 활동 피드 모듈 — 다른 도메인 모듈(project/task)이 import 해 ActivityService 로 이벤트를 적재한다. +// (Activity 는 projectId + taskSeq 만 보관해 Task 엔티티를 참조하지 않으므로 순환 의존 없음) @Module({ - imports: [TypeOrmModule.forFeature([Activity, Repo])], + imports: [TypeOrmModule.forFeature([Activity])], controllers: [ActivityController], providers: [ActivityService], exports: [ActivityService], diff --git a/backend/src/modules/activity/activity.service.ts b/backend/src/modules/activity/activity.service.ts index bb47698..c9b720c 100644 --- a/backend/src/modules/activity/activity.service.ts +++ b/backend/src/modules/activity/activity.service.ts @@ -1,8 +1,8 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { UserService, type PublicUser } from '../user/user.service'; -import { Repo } from '../repo/entities/repo.entity'; +import { Project } from '../project/entities/project.entity'; import { Activity, type ActivityType } from './entities/activity.entity'; // 활동 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립 @@ -17,7 +17,7 @@ export interface ActivityResponse { // 활동 적재 파라미터 export interface RecordActivityParams { - repoId: string; // Repo uuid + projectId: string; // Project uuid actorId: string | null; type: ActivityType; payload?: Record; @@ -25,7 +25,7 @@ export interface RecordActivityParams { } // 활동 피드 비즈니스 로직 — 다른 도메인 서비스가 record() 로 이벤트를 적재하고, -// 저장소/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음). +// 프로젝트/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음). @Injectable() export class ActivityService { private readonly logger = new Logger(ActivityService.name); @@ -33,15 +33,13 @@ export class ActivityService { constructor( @InjectRepository(Activity) private readonly activityRepo: Repository, - @InjectRepository(Repo) - private readonly repoRepo: Repository, ) {} // 활동 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수 async record(params: RecordActivityParams): Promise { try { const activity = this.activityRepo.create({ - repo: { id: params.repoId } as Repo, + project: { id: params.projectId } as Project, actor: params.actorId ? { id: params.actorId } : null, type: params.type, payload: params.payload ?? {}, @@ -56,14 +54,10 @@ export class ActivityService { } } - // 저장소 활동 피드 — slugName 으로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트. - async listForRepo(slugName: string): Promise { - const repo = await this.repoRepo.findOne({ where: { slugName } }); - if (!repo) { - throw new NotFoundException(); - } + // 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트. + async listForProject(projectId: string): Promise { const rows = await this.activityRepo.find({ - where: { repo: { id: repo.id } }, + where: { project: { id: projectId } }, relations: ['actor'], order: { createdAt: 'DESC' }, take: 200, @@ -71,34 +65,19 @@ export class ActivityService { return rows.map((a) => this.toResponse(a)); } - // 업무 활동 — repoId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용. + // 업무 활동 — projectId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용. async listForTask( - repoId: string, + projectId: string, taskSeq: number, ): Promise { const rows = await this.activityRepo.find({ - where: { repo: { id: repoId }, taskSeq }, + where: { project: { id: projectId }, taskSeq }, relations: ['actor'], order: { createdAt: 'DESC' }, }); return rows.map((a) => this.toResponse(a)); } - // 저장소 단위(업무 무관) 활동만 — 필요 시 사용. taskSeq IS NULL - async listRepoOnly(slugName: string): Promise { - const repo = await this.repoRepo.findOne({ where: { slugName } }); - if (!repo) { - throw new NotFoundException(); - } - const rows = await this.activityRepo.find({ - where: { repo: { id: repo.id }, taskSeq: IsNull() }, - relations: ['actor'], - order: { createdAt: 'DESC' }, - take: 200, - }); - return rows.map((a) => this.toResponse(a)); - } - // 엔티티 → 응답 매핑 private toResponse(a: Activity): ActivityResponse { return { diff --git a/backend/src/modules/activity/entities/activity.entity.ts b/backend/src/modules/activity/entities/activity.entity.ts index ee22423..3538156 100644 --- a/backend/src/modules/activity/entities/activity.entity.ts +++ b/backend/src/modules/activity/entities/activity.entity.ts @@ -9,14 +9,17 @@ import { type Relation, } from 'typeorm'; import { User } from '../../user/entities/user.entity'; -import { Repo } from '../../repo/entities/repo.entity'; +import { Project } from '../../project/entities/project.entity'; -// 활동 이벤트 타입 — 2~5단계 행위에서 자동 적재(쓰기 API 없음, 읽기 전용) +// 활동 이벤트 타입 — 행위에서 자동 적재(쓰기 API 없음, 읽기 전용) export type ActivityType = - // 저장소(설정) - | 'repo.created' - | 'repo.renamed' - | 'repo.visibility_changed' + // 프로젝트(설정) + | 'project.created' + | 'project.renamed' + | 'project.visibility_changed' + // 저장소 연동 + | 'repo.linked' + | 'repo.unlinked' // 멤버 | 'member.invited' | 'member.role_changed' @@ -39,12 +42,12 @@ export class Activity { @PrimaryGeneratedColumn('uuid') id!: string; - // 소속 저장소 (저장소 삭제 시 함께 삭제) + // 소속 프로젝트 (프로젝트 삭제 시 함께 삭제) // Relation<> 래퍼: 엔티티 순환참조 회피 @Index() - @ManyToOne(() => Repo, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'repo_id' }) - repo!: Relation; + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project!: Relation; // 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 저장소 활동이면 null // (Task FK 대신 seq 를 저장해 업무 삭제와 무관하게 활동을 유지) diff --git a/backend/src/modules/ai/ai-suggest.controller.ts b/backend/src/modules/ai/ai-suggest.controller.ts index 4f6325b..96ee902 100644 --- a/backend/src/modules/ai/ai-suggest.controller.ts +++ b/backend/src/modules/ai/ai-suggest.controller.ts @@ -24,15 +24,15 @@ export class AiSuggestController { @Post('suggest-checklist') @HttpCode(HttpStatus.OK) @Throttle({ default: { limit: 20, ttl: 60_000 } }) - @ApiOperation({ summary: '할 일(체크리스트) 추천 — 저장소·업무 기반' }) + @ApiOperation({ summary: '할 일(체크리스트) 추천 — 프로젝트·업무 기반' }) @ApiResponse({ status: 200, description: '카테고리별 추천 항목' }) - @ApiResponse({ status: 404, description: '저장소 없음 (RES_001)' }) + @ApiResponse({ status: 404, description: '프로젝트 없음 (RES_001)' }) suggestChecklist( @CurrentUser() user: PublicUser, @Body() dto: SuggestChecklistDto, ): Promise<{ groups: ChecklistSuggestionGroup[] }> { return this.aiService.suggestChecklist(user.id, { - repoId: dto.repoId, + projectId: dto.projectId, title: dto.title, content: dto.content, }); diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index d198cc6..28e03c4 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TaskModule } from '../task/task.module'; -import { RepoModule } from '../repo/repo.module'; +import { ProjectModule } from '../project/project.module'; import { AiController } from './ai.controller'; import { AiSuggestController } from './ai-suggest.controller'; import { AiService } from './ai.service'; @@ -9,12 +9,12 @@ import { AiConversation } from './entities/ai-conversation.entity'; import { AiMessage } from './entities/ai-message.entity'; // AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속. -// Task/RepoModule 을 가져와 사용자 업무·저장소 컨텍스트를 시스템 프롬프트에 주입한다. +// Task/ProjectModule 을 가져와 사용자 업무·저장소·프로젝트 컨텍스트를 시스템 프롬프트에 주입한다. @Module({ imports: [ TypeOrmModule.forFeature([AiConversation, AiMessage]), TaskModule, - RepoModule, + ProjectModule, ], controllers: [AiController, AiSuggestController], providers: [AiService], diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index 60133fa..7052f40 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -10,7 +10,7 @@ import { } from '../../common/pagination/pagination'; import { TaskService } from '../task/task.service'; import type { TaskStatus } from '../task/entities/task.entity'; -import { RepoService } from '../repo/repo.service'; +import { ProjectService } from '../project/project.service'; import { AiConversation } from './entities/ai-conversation.entity'; import { AiMessage } from './entities/ai-message.entity'; @@ -102,7 +102,7 @@ export class AiService { constructor( config: ConfigService, private readonly taskService: TaskService, - private readonly repoService: RepoService, + private readonly projectService: ProjectService, @InjectRepository(AiConversation) private readonly convRepo: Repository, @InjectRepository(AiMessage) @@ -184,18 +184,18 @@ export class AiService { return this.appendAndReply(conv, content, userId); } - // 할 일(체크리스트) 추천 — 저장소 설명 + 업무 제목/내용을 바탕으로 카테고리별 항목 제안 + // 할 일(체크리스트) 추천 — 프로젝트 설명 + 업무 제목/내용을 바탕으로 카테고리별 항목 제안 async suggestChecklist( userId: string, - input: { repoId: string; title: string; content?: string[] }, + input: { projectId: string; title: string; content?: string[] }, ): Promise<{ groups: ChecklistSuggestionGroup[] }> { this.ensureEnabled(); - // 저장소 정보(이름/설명) — 조직 모델상 인증 사용자면 열람 가능. 없으면 404. - const repo = await this.repoService.findOne(input.repoId, userId); + // 프로젝트 정보(이름/설명) — 인증 사용자면 열람 가능. 없으면 404. + const project = await this.projectService.findOne(input.projectId, userId); const content = (input.content ?? []).join('\n').trim() || '(없음)'; const userMsg = [ - `[저장소] 표시 제목: ${repo.name}`, - `설명: ${repo.desc ?? '(없음)'}`, + `[프로젝트] 표시 제목: ${project.name}`, + `설명: ${project.description ?? '(없음)'}`, '', `[업무] 제목: ${input.title}`, '내용:', @@ -367,7 +367,7 @@ export class AiService { CONTEXT_TASK_LIMIT, ); const issued = await this.taskService.listIssued(userId, 1, 1); - const repos = await this.repoService.findAll(1, CONTEXT_REPO_LIMIT); + const projects = await this.projectService.findAll(1, CONTEXT_REPO_LIMIT); const lines = [`오늘 날짜: ${today} (KST)`, '']; @@ -380,7 +380,7 @@ export class AiService { const due = t.dueDate ? t.dueDate.slice(0, 10) : '없음'; const [done, totalC] = t.checklist; lines.push( - `- [${t.repoName}] #${t.id} ${t.title} — 상태: ${STATUS_LABEL[t.status]}, 마감: ${due}, 체크리스트: ${done}/${totalC}`, + `- [${t.projectName}] #${t.id} ${t.title} — 상태: ${STATUS_LABEL[t.status]}, 마감: ${due}, 체크리스트: ${done}/${totalC}`, ); } if (assigned.total > assigned.items.length) { @@ -389,20 +389,19 @@ export class AiService { } lines.push('', `내가 지시한 업무: ${issued.total}건`); - // 저장소 목록(조직 전체 — 인증 사용자는 모두 열람 가능) - lines.push('', `저장소 목록 (${repos.total}개):`); - if (repos.items.length === 0) { + // 프로젝트 목록(인증 사용자는 전체 열람 가능) + lines.push('', `프로젝트 목록 (${projects.total}개):`); + if (projects.items.length === 0) { lines.push('- (없음)'); } else { - for (const r of repos.items) { - const vis = r.visibility === 'private' ? '비공개' : '공개'; - // 표시 제목(name) 위주로 안내 — slug(영문 내부 이름)는 사용자 노출용이 아니라 제외 + for (const p of projects.items) { + const vis = p.visibility === 'private' ? '비공개' : '공개'; lines.push( - `- ${r.name} — ${vis}, 업무 ${r.doneCount}/${r.totalCount}건`, + `- ${p.name} — ${vis}, 업무 ${p.doneCount}/${p.totalCount}건`, ); } - if (repos.total > repos.items.length) { - lines.push(`- … 외 ${repos.total - repos.items.length}개`); + if (projects.total > projects.items.length) { + lines.push(`- … 외 ${projects.total - projects.items.length}개`); } } return lines.join('\n'); diff --git a/backend/src/modules/ai/dto/suggest-checklist.dto.ts b/backend/src/modules/ai/dto/suggest-checklist.dto.ts index f0a2e8a..c15686f 100644 --- a/backend/src/modules/ai/dto/suggest-checklist.dto.ts +++ b/backend/src/modules/ai/dto/suggest-checklist.dto.ts @@ -8,12 +8,12 @@ import { } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -// 할 일(체크리스트) 추천 요청 — 저장소·업무 정보를 바탕으로 AI 가 항목 제안 +// 할 일(체크리스트) 추천 요청 — 프로젝트·업무 정보를 바탕으로 AI 가 항목 제안 export class SuggestChecklistDto { - @ApiProperty({ description: '저장소 식별자(slugName)' }) + @ApiProperty({ description: '프로젝트 식별자(slugName)' }) @IsString() - @IsNotEmpty({ message: '저장소가 필요합니다.' }) - repoId!: string; + @IsNotEmpty({ message: '프로젝트가 필요합니다.' }) + projectId!: string; @ApiProperty({ description: '업무 제목' }) @IsString() diff --git a/backend/src/modules/repo/dto/invite-member.dto.ts b/backend/src/modules/repo/dto/invite-member.dto.ts deleted file mode 100644 index 7771133..0000000 --- a/backend/src/modules/repo/dto/invite-member.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsEmail, IsIn, IsNotEmpty } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; -import type { MemberRoleType } from '../entities/repo-member.entity'; - -// 멤버 초대 DTO — 가입된 사용자를 이메일로 초대(별도 초대 메일/계정 생성 없음) -export class InviteMemberDto { - @ApiProperty({ - description: '초대할 사용자 이메일(이미 가입된 사용자)', - example: 'sykim@acme.co', - }) - @IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' }) - @IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' }) - email!: string; - - @ApiProperty({ - description: '권한 역할', - enum: ['admin', 'member'], - example: 'member', - }) - @IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' }) - roleType!: MemberRoleType; -} diff --git a/backend/src/modules/repo/dto/update-member.dto.ts b/backend/src/modules/repo/dto/update-member.dto.ts deleted file mode 100644 index 8f7a931..0000000 --- a/backend/src/modules/repo/dto/update-member.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsIn, IsOptional } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; -import type { MemberRoleType } from '../entities/repo-member.entity'; - -// 멤버 수정 DTO — 권한 역할 (owner 는 변경 불가) -export class UpdateMemberDto { - @ApiProperty({ - description: '권한 역할', - enum: ['admin', 'member'], - required: false, - }) - @IsOptional() - @IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' }) - roleType?: MemberRoleType; -} diff --git a/backend/src/modules/repo/entities/repo-member.entity.ts b/backend/src/modules/repo/entities/repo-member.entity.ts deleted file mode 100644 index b00f378..0000000 --- a/backend/src/modules/repo/entities/repo-member.entity.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - ManyToOne, - PrimaryGeneratedColumn, - type Relation, - Unique, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { Repo } from './repo.entity'; - -// 저장소 내 권한 역할 -export type MemberRoleType = 'admin' | 'member'; - -// 저장소 멤버 (Repo ↔ User 조인 + 역할 정보) -@Entity('repo_members') -@Unique(['repo', 'user']) -export class RepoMember { - @PrimaryGeneratedColumn('uuid') - id!: string; - - // 소속 저장소 (저장소 삭제 시 함께 삭제) - // Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함 - @ManyToOne(() => Repo, (repo) => repo.members, { onDelete: 'CASCADE' }) - repo!: Relation; - - // 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩 - @ManyToOne(() => User, { onDelete: 'CASCADE' }) - user!: User; - - // 권한 역할 (admin: 저장소 관리, member: 일반) - @Column({ name: 'role_type', type: 'varchar', default: 'member' }) - roleType!: MemberRoleType; - - // 소유자 여부(저장소 생성자) — 역할 변경/제거 불가 대상 - @Column({ name: 'is_owner', default: false }) - isOwner!: boolean; - - @CreateDateColumn({ name: 'created_at' }) - createdAt!: Date; -} diff --git a/backend/src/modules/repo/entities/repo.entity.ts b/backend/src/modules/repo/entities/repo.entity.ts index b4726cb..35989cd 100644 --- a/backend/src/modules/repo/entities/repo.entity.ts +++ b/backend/src/modules/repo/entities/repo.entity.ts @@ -2,24 +2,32 @@ import { Column, CreateDateColumn, Entity, - OneToMany, + Index, + JoinColumn, + ManyToOne, PrimaryGeneratedColumn, type Relation, UpdateDateColumn, } from 'typeorm'; -import { RepoMember } from './repo-member.entity'; +import { Project } from '../../project/entities/project.entity'; // 저장소 공개 범위 export type RepoVisibility = 'private' | 'public'; -// 저장소 엔티티 +// 저장소 엔티티 — 프로젝트에 연동되는 Gitea 저장소(코드 연동 전용). 멤버십/권한은 소유 프로젝트가 결정. @Entity('repos') export class Repo { // 내부 식별자 (UUID) @PrimaryGeneratedColumn('uuid') id!: string; - // 라우팅/URL 식별자(영문 slug 세그먼트, 예: 'q3-launch-campaign') — 전역 유일 + // 소속 프로젝트 (프로젝트 삭제 시 함께 삭제) + @Index() + @ManyToOne(() => Project, { onDelete: 'CASCADE', nullable: false }) + @JoinColumn({ name: 'project_id' }) + project!: Relation; + + // 라우팅/URL 식별자(영문 slug 세그먼트, 예: 'q3-launch-campaign') — Gitea 조직 내 유일 → 전역 유일 @Column({ name: 'slug_name', unique: true }) slugName!: string; @@ -66,10 +74,6 @@ export class Repo { @Column({ name: 'gitea_missing', default: false }) giteaMissing!: boolean; - // 멤버 목록 - @OneToMany(() => RepoMember, (member) => member.repo) - members!: Relation[]; - @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/backend/src/modules/repo/member.controller.ts b/backend/src/modules/repo/member.controller.ts deleted file mode 100644 index 5bed24a..0000000 --- a/backend/src/modules/repo/member.controller.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - Body, - Controller, - DefaultValuePipe, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseIntPipe, - 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 { MemberService, type RepoMemberResponse } from './member.service'; -import type { PaginatedResult } from '../../common/pagination/pagination'; -import { InviteMemberDto } from './dto/invite-member.dto'; -import { UpdateMemberDto } from './dto/update-member.dto'; - -// 저장소 멤버 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName) -@ApiTags('Member') -@Controller('repos/:repoId/members') -@UseGuards(JwtAuthGuard) -export class MemberController { - constructor(private readonly memberService: MemberService) {} - - @Get() - @ApiOperation({ summary: '저장소 멤버 목록 (페이지네이션)' }) - @ApiResponse({ status: 200, description: '목록 조회 성공' }) - @ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' }) - list( - @Param('repoId') repoId: string, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, - ): Promise> { - return this.memberService.list(repoId, page, size); - } - - @Post() - @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: '멤버 초대 (admin)' }) - @ApiResponse({ status: 201, description: '초대 성공' }) - @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) - @ApiResponse({ - status: 404, - description: '가입된 사용자를 찾을 수 없음 (BIZ_001)', - }) - @ApiResponse({ status: 409, description: '이미 멤버 (BIZ_001)' }) - invite( - @Param('repoId') repoId: string, - @Body() dto: InviteMemberDto, - @CurrentUser() user: PublicUser, - ): Promise { - return this.memberService.invite(repoId, user.id, dto); - } - - @Patch(':userId') - @ApiOperation({ summary: '멤버 역할/직무 변경 (admin, owner 제외)' }) - @ApiResponse({ status: 200, description: '변경 성공' }) - @ApiResponse({ status: 403, description: '권한 없음 / 소유자 변경 불가' }) - update( - @Param('repoId') repoId: string, - @Param('userId') userId: string, - @Body() dto: UpdateMemberDto, - @CurrentUser() user: PublicUser, - ): Promise { - return this.memberService.update(repoId, user.id, userId, dto); - } - - @Delete(':userId') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '멤버 제거 (admin, owner 제외)' }) - @ApiResponse({ status: 200, description: '제거 성공' }) - @ApiResponse({ status: 403, description: '권한 없음 / 소유자 제거 불가' }) - async remove( - @Param('repoId') repoId: string, - @Param('userId') userId: string, - @CurrentUser() user: PublicUser, - ): Promise { - await this.memberService.remove(repoId, user.id, userId); - return null; - } -} diff --git a/backend/src/modules/repo/member.service.spec.ts b/backend/src/modules/repo/member.service.spec.ts deleted file mode 100644 index f2345a1..0000000 --- a/backend/src/modules/repo/member.service.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { ForbiddenException } from '@nestjs/common'; -import { Repository } from 'typeorm'; -import { MemberService } from './member.service'; -import { UserService } from '../user/user.service'; -import { TaskService } from '../task/task.service'; -import { ActivityService } from '../activity/activity.service'; -import { NotificationService } from '../notification/notification.service'; -import { RepoCacheService } from './repo-cache.service'; -import { BusinessException } from '../../common/exceptions/business.exception'; -import { Repo } from './entities/repo.entity'; -import { RepoMember } from './entities/repo-member.entity'; - -// 저장소/멤버 리포지토리 + UserService/TaskService 간이 모킹 -function makeService() { - const repoRepo = { findOne: jest.fn() }; - const memberRepo = { - findOne: jest.fn(), - find: jest.fn(), - findAndCount: jest.fn(), - create: jest.fn(), - save: jest.fn(), - remove: jest.fn(), - }; - const userService = { - findByEmail: jest.fn(), - findById: jest.fn().mockResolvedValue(null), - }; - // 담당 업무 수 집계는 빈 Map(0건)으로 모킹 - const taskService = { - assigneeCounts: jest.fn().mockResolvedValue(new Map()), - }; - // 활동 적재는 no-op 모킹 - const activity = { record: jest.fn().mockResolvedValue(undefined) }; - // 알림 적재는 no-op 모킹 - const notification = { notify: jest.fn().mockResolvedValue(undefined) }; - // 목록 캐시 무효화는 no-op 모킹 - const repoCache = { invalidateList: jest.fn().mockResolvedValue(undefined) }; - const svc = new MemberService( - repoRepo as unknown as Repository, - memberRepo as unknown as Repository, - userService as unknown as UserService, - taskService as unknown as TaskService, - activity as unknown as ActivityService, - notification as unknown as NotificationService, - repoCache as unknown as RepoCacheService, - ); - return { - svc, - repoRepo, - memberRepo, - userService, - taskService, - activity, - notification, - repoCache, - }; -} - -const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' }; -const ADMIN = { roleType: 'admin' }; - -describe('MemberService', () => { - describe('list', () => { - it('조직 모델: 멤버가 아니어도 인증 사용자면 목록 열람 가능', async () => { - const { svc, repoRepo, memberRepo } = makeService(); - repoRepo.findOne.mockResolvedValue(REPO); - // 목록에 현재 요청자가 없어도(비멤버) 거부하지 않는다 - memberRepo.findAndCount.mockResolvedValue([ - [ - { - user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null }, - email: 'a@b.c', - roleType: 'member', - isOwner: false, - }, - ], - 1, - ]); - - const result = await svc.list('q3-launch'); - expect(result.total).toBe(1); - expect(result.items).toHaveLength(1); - expect(result.items[0].user.id).toBe('someone-else'); - }); - }); - - describe('invite', () => { - it('가입되지 않은 이메일이면 BusinessException', async () => { - const { svc, repoRepo, memberRepo, userService } = makeService(); - repoRepo.findOne.mockResolvedValue(REPO); - memberRepo.findOne.mockResolvedValueOnce(ADMIN); // assertAdmin 통과 - userService.findByEmail.mockResolvedValue(null); - - await expect( - svc.invite('q3-launch', 'admin-user', { - email: 'ghost@acme.co', - roleType: 'member', - }), - ).rejects.toBeInstanceOf(BusinessException); - }); - - it('이미 멤버면 BusinessException', async () => { - const { svc, repoRepo, memberRepo, userService } = makeService(); - repoRepo.findOne.mockResolvedValue(REPO); - memberRepo.findOne - .mockResolvedValueOnce(ADMIN) // assertAdmin - .mockResolvedValueOnce({ id: 'existing-member' }); // 기존 멤버십 존재 - userService.findByEmail.mockResolvedValue({ id: 'u1', email: 'u@b.c' }); - - await expect( - svc.invite('q3-launch', 'admin-user', { - email: 'u@b.c', - roleType: 'member', - }), - ).rejects.toBeInstanceOf(BusinessException); - }); - - it('admin 이 아니면 ForbiddenException', async () => { - const { svc, repoRepo, memberRepo } = makeService(); - repoRepo.findOne.mockResolvedValue(REPO); - memberRepo.findOne.mockResolvedValueOnce({ roleType: 'member' }); // 일반 멤버 - - await expect( - svc.invite('q3-launch', 'member-user', { - email: 'u@b.c', - roleType: 'member', - }), - ).rejects.toBeInstanceOf(ForbiddenException); - }); - }); - - describe('update / remove — 소유자 보호', () => { - it('소유자의 역할은 변경할 수 없다', async () => { - const { svc, repoRepo, memberRepo } = makeService(); - repoRepo.findOne.mockResolvedValue(REPO); - memberRepo.findOne - .mockResolvedValueOnce(ADMIN) // assertAdmin - .mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자 - - await expect( - svc.update('q3-launch', 'admin-user', 'owner', { roleType: 'member' }), - ).rejects.toBeInstanceOf(BusinessException); - }); - - it('소유자는 제거할 수 없다', async () => { - const { svc, repoRepo, memberRepo } = makeService(); - repoRepo.findOne.mockResolvedValue(REPO); - memberRepo.findOne - .mockResolvedValueOnce(ADMIN) // assertAdmin - .mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자 - - await expect( - svc.remove('q3-launch', 'admin-user', 'owner'), - ).rejects.toBeInstanceOf(BusinessException); - expect(memberRepo.remove).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/backend/src/modules/repo/member.service.ts b/backend/src/modules/repo/member.service.ts deleted file mode 100644 index 050b93a..0000000 --- a/backend/src/modules/repo/member.service.ts +++ /dev/null @@ -1,282 +0,0 @@ -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 { TaskService } from '../task/task.service'; -import { ActivityService } from '../activity/activity.service'; -import { NotificationService } from '../notification/notification.service'; -import { RepoCacheService } from './repo-cache.service'; -import { - paginated, - resolvePage, - type PaginatedResult, -} from '../../common/pagination/pagination'; -import { Repo } from './entities/repo.entity'; -import { RepoMember, type MemberRoleType } from './entities/repo-member.entity'; -import { InviteMemberDto } from './dto/invite-member.dto'; -import { UpdateMemberDto } from './dto/update-member.dto'; - -// 멤버 응답 형태 — isYou 는 프론트가 현재 사용자와 비교해 산출 -export interface RepoMemberResponse { - user: PublicUser; - email: string; - taskCount: number; // TODO(4단계): 실제 담당 업무 수 집계 - roleType: MemberRoleType; - owner: boolean; // 소유자 여부(isOwner) -} - -// 저장소 멤버 관리 비즈니스 로직 -@Injectable() -export class MemberService { - constructor( - @InjectRepository(Repo) - private readonly repoRepo: Repository, - @InjectRepository(RepoMember) - private readonly memberRepo: Repository, - private readonly userService: UserService, - private readonly taskService: TaskService, - private readonly activity: ActivityService, - private readonly notification: NotificationService, - private readonly repoCache: RepoCacheService, - ) {} - - // 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책). 페이지네이션. - // 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다. - async list( - slugName: string, - page?: number, - size?: number, - ): Promise> { - const repo = await this.getRepoOrThrow(slugName); - const pg = resolvePage(page, size); - const [members, total] = await this.memberRepo.findAndCount({ - where: { repo: { id: repo.id } }, - relations: ['user'], - order: { createdAt: 'ASC' }, - skip: pg.skip, - take: pg.take, - }); - // 사용자별 담당 업무 수 집계(taskCount) - const counts = await this.taskService.assigneeCounts(repo.id); - return paginated( - members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0)), - total, - pg, - ); - } - - // 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대 - async invite( - slugName: string, - actingUserId: string, - dto: InviteMemberDto, - ): Promise { - const repo = await this.getRepoOrThrow(slugName); - await this.assertAdmin(repo.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: { repo: { id: repo.id }, user: { id: user.id } }, - }); - if (existing) { - throw new BusinessException( - 'BIZ_001', - '이미 저장소에 속한 멤버입니다.', - HttpStatus.CONFLICT, - ); - } - - const member = this.memberRepo.create({ - repo, - user, - roleType: dto.roleType, - isOwner: false, - }); - const saved = await this.memberRepo.save(member); - saved.user = user; // 관계 보장(응답 매핑용) - - // 활동 적재 — 멤버 초대 - await this.activity.record({ - repoId: repo.id, - actorId: actingUserId, - type: 'member.invited', - payload: { targetName: user.name, roleType: dto.roleType }, - }); - - // 알림 — 초대된 사용자에게 '저장소 초대' - await this.notification.notify({ - recipientIds: [user.id], - actorId: actingUserId, - type: 'member.invited', - payload: { - repoId: repo.slugName, - repoName: repo.name, - roleType: dto.roleType, - actorName: await this.actorName(actingUserId), - }, - }); - - // 저장소 목록 캐시 무효화 — 멤버 추가로 목록 카드의 멤버 수/아바타가 바뀜 - await this.repoCache.invalidateList(); - - return this.toResponse(saved); - } - - // 멤버 역할/직무 변경 — admin 권한. 소유자(owner)는 변경 불가 - async update( - slugName: string, - actingUserId: string, - targetUserId: string, - dto: UpdateMemberDto, - ): Promise { - const repo = await this.getRepoOrThrow(slugName); - await this.assertAdmin(repo.id, actingUserId); - const member = await this.getMemberOrThrow(repo.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); - - // 활동 적재 — 멤버 역할 변경 - await this.activity.record({ - repoId: repo.id, - actorId: actingUserId, - type: 'member.role_changed', - payload: { targetName: member.user.name, roleType: member.roleType }, - }); - - // 알림 — 역할이 변경된 당사자에게(본인이 본인 역할을 바꾼 경우는 자동 제외) - await this.notification.notify({ - recipientIds: [member.user.id], - actorId: actingUserId, - type: 'member.role_changed', - payload: { - repoId: repo.slugName, - repoName: repo.name, - roleType: member.roleType, - actorName: await this.actorName(actingUserId), - }, - }); - - // (역할 변경은 저장소 목록 응답의 멤버 수/아바타를 바꾸지 않으므로 캐시 무효화 불필요) - return this.toResponse(member); - } - - // 멤버 제거 — admin 권한. 소유자(owner)는 제거 불가 - async remove( - slugName: string, - actingUserId: string, - targetUserId: string, - ): Promise { - const repo = await this.getRepoOrThrow(slugName); - await this.assertAdmin(repo.id, actingUserId); - const member = await this.getMemberOrThrow(repo.id, targetUserId); - if (member.isOwner) { - throw new BusinessException( - 'BIZ_001', - '소유자는 저장소에서 제거할 수 없습니다.', - HttpStatus.FORBIDDEN, - ); - } - const targetName = member.user.name; // 제거 전 이름 캡처 - const removedUserId = member.user.id; // 제거 전 id 캡처(알림 수신자) - await this.memberRepo.remove(member); - - // 활동 적재 — 멤버 제거 - await this.activity.record({ - repoId: repo.id, - actorId: actingUserId, - type: 'member.removed', - payload: { targetName }, - }); - - // 알림 — 제거된 당사자에게(조직 모델상 저장소 열람은 여전히 가능) - await this.notification.notify({ - recipientIds: [removedUserId], - actorId: actingUserId, - type: 'member.removed', - payload: { - repoId: repo.slugName, - repoName: repo.name, - actorName: await this.actorName(actingUserId), - }, - }); - - // 저장소 목록 캐시 무효화 — 멤버 제거로 목록 카드의 멤버 수/아바타가 바뀜 - await this.repoCache.invalidateList(); - } - - // --- 내부 헬퍼 --- - - // 행위자 표시 이름(알림 payload 용) — 없으면 빈 문자열 - private async actorName(userId: string): Promise { - const actor = await this.userService.findById(userId); - return actor?.name ?? ''; - } - - // slugName 으로 저장소 로딩, 없으면 404 - private async getRepoOrThrow(slugName: string): Promise { - const repo = await this.repoRepo.findOne({ where: { slugName } }); - if (!repo) { - throw new NotFoundException(); - } - return repo; - } - - // 저장소-사용자 멤버십 로딩(+user), 없으면 404 - private async getMemberOrThrow( - repoId: string, - userId: string, - ): Promise { - const member = await this.memberRepo.findOne({ - where: { repo: { id: repoId }, user: { id: userId } }, - relations: ['user'], - }); - if (!member) { - throw new NotFoundException(); - } - return member; - } - - // admin 권한 확인 (아니면 403) - private async assertAdmin(repoId: string, userId: string): Promise { - const membership = await this.memberRepo.findOne({ - where: { repo: { id: repoId }, user: { id: userId } }, - }); - if (!membership || membership.roleType !== 'admin') { - throw new ForbiddenException(); - } - } - - // 엔티티 → 응답 매핑 (taskCount 미제공 시 0) - private toResponse(member: RepoMember, taskCount = 0): RepoMemberResponse { - return { - user: UserService.toPublic(member.user), - email: member.user.email, - taskCount, - roleType: member.roleType, - owner: member.isOwner, - }; - } -} diff --git a/backend/src/modules/repo/repo-cache.service.ts b/backend/src/modules/repo/repo-cache.service.ts deleted file mode 100644 index fc28ba1..0000000 --- a/backend/src/modules/repo/repo-cache.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import type { Cache } from 'cache-manager'; -import type { PaginatedResult } from '../../common/pagination/pagination'; -import type { RepoResponse } from './repo.service'; - -// 저장소 목록 캐시 — 버전 네임스페이스 방식으로 O(1) 무효화. -// 키에 버전을 포함하고, 변경(생성/수정/삭제/동기화) 시 버전을 올려 기존 키를 통째로 폐기한다. -// (keyv 는 와일드카드 삭제를 지원하지 않으므로 버전 증가로 일괄 무효화한다) -// 모든 캐시 접근은 실패 시 무시하고 DB 조회로 폴백 — 캐시 장애가 요청을 깨지 않는다. -@Injectable() -export class RepoCacheService { - private static readonly VER_KEY = 'repos:list:ver'; - private static readonly LIST_TTL = 60_000; // 60초(업무 카운트 staleness 백스톱) - - constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {} - - // 현재 목록 캐시 버전(없으면 1로 초기화) - private async version(): Promise { - try { - const v = await this.cache.get(RepoCacheService.VER_KEY); - if (typeof v === 'number') return v; - await this.cache.set(RepoCacheService.VER_KEY, 1); - } catch { - // 캐시 장애 — 버전 1로 취급(매번 미스 → DB 조회) - } - return 1; - } - - // 버전 증가 → 이전 버전의 모든 목록 캐시 키를 무효화(TTL 로 자연 소멸) - async invalidateList(): Promise { - try { - const v = await this.version(); - await this.cache.set(RepoCacheService.VER_KEY, v + 1); - } catch { - // 무시(다음 조회는 구버전 키를 보지만 TTL 60초 내 소멸) - } - } - - // 목록 캐시 get-or-compute — 히트 시 캐시, 미스 시 compute() 결과를 적재 후 반환 - async listOrCompute( - page: number, - size: number, - compute: () => Promise>, - ): Promise> { - const ver = await this.version(); - const key = `repos:list:v${ver}:p${page}:s${size}`; - try { - const hit = await this.cache.get>(key); - if (hit) return hit; - } catch { - // 캐시 장애 — 아래 DB 조회로 폴백 - } - const value = await compute(); - try { - await this.cache.set(key, value, RepoCacheService.LIST_TTL); - } catch { - // 적재 실패 무시 - } - return value; - } -} diff --git a/backend/src/modules/repo/repo-sync.service.ts b/backend/src/modules/repo/repo-sync.service.ts index 4256062..f77f23a 100644 --- a/backend/src/modules/repo/repo-sync.service.ts +++ b/backend/src/modules/repo/repo-sync.service.ts @@ -1,14 +1,10 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { UserService } from '../user/user.service'; -import type { User } from '../user/entities/user.entity'; import { GiteaService, type GiteaRepoResult } from '../gitea/gitea.service'; -import { RepoCacheService } from './repo-cache.service'; +import { ProjectService } from '../project/project.service'; import { Repo } from './entities/repo.entity'; -import { RepoMember } from './entities/repo-member.entity'; // Gitea description 에서 분리한 표시명/설명 interface ParsedDescription { @@ -17,33 +13,22 @@ interface ParsedDescription { } // Gitea 조직 저장소를 Relay 로 가져오는 동기화 서비스. -// - 서버 기동 직후 1회 + 매시간 정각에 조직의 저장소 목록을 받아 Relay 와 맞춘다. -// - 추가형(additive): Gitea 에만 있는 저장소는 Relay 로 import, 양쪽에 있으면 연동정보 backfill. +// - 서버 기동 직후 1회 + 매 10분마다 조직의 저장소 목록을 받아 Relay 와 맞춘다. +// - 추가형(additive): Gitea 에만 있는 저장소는 "미분류" 프로젝트로 import, 양쪽에 있으면 연동정보 backfill. // - Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 giteaMissing 플래그로 표기만 한다. -// - 멤버십은 Relay 자체 관리(Gitea collaborator 미동기화) — import 저장소는 멤버 0명으로 들어온다. +// - 멤버십은 프로젝트 단위 — import 저장소는 "미분류" 프로젝트에 연결되고, 관리자가 적절한 프로젝트로 이동한다. @Injectable() export class RepoSyncService implements OnApplicationBootstrap { private readonly logger = new Logger(RepoSyncService.name); // 동기화 중복 실행 방지(부트스트랩과 스케줄이 겹치는 경우) private running = false; - // import(멤버 0명) 저장소에 기본 owner-admin 으로 지정할 사용자 이메일. - // 동기화는 시스템 작업이라 실행 주체가 없으므로 env 로 지정한다. 비어 있으면 멤버 0명으로 둔다. - private readonly importAdminEmail: string; constructor( @InjectRepository(Repo) private readonly repoRepo: Repository, - @InjectRepository(RepoMember) - private readonly memberRepo: Repository, - private readonly userService: UserService, private readonly gitea: GiteaService, - private readonly repoCache: RepoCacheService, - config: ConfigService, - ) { - this.importAdminEmail = ( - config.get('REPO_IMPORT_ADMIN_EMAIL') ?? '' - ).trim(); - } + private readonly projectService: ProjectService, + ) {} // 서버 기동 직후 1회 동기화 — 부팅을 막지 않도록 비차단(백그라운드)으로 실행 onApplicationBootstrap(): void { @@ -70,6 +55,8 @@ export class RepoSyncService implements OnApplicationBootstrap { try { const remote = await this.gitea.listOrgRepos(); const existing = await this.repoRepo.find(); + // import 대상 기본 프로젝트("미분류") + const unassigned = await this.projectService.ensureUnassignedProject(); // 매칭용 인덱스: Gitea ID 우선, 없으면 slugName 으로 매칭 const byGiteaId = new Map(); @@ -101,9 +88,10 @@ export class RepoSyncService implements OnApplicationBootstrap { relinked++; } } else { - // Gitea 에만 있는 저장소 — Relay 로 가져오기(멤버 없음) + // Gitea 에만 있는 저장소 — "미분류" 프로젝트로 가져오기 const { name, desc } = this.parseDescription(gr.description, gr.name); const repo = this.repoRepo.create({ + project: unassigned, slugName: gr.name, owner: this.gitea.org, name, @@ -135,17 +123,8 @@ export class RepoSyncService implements OnApplicationBootstrap { } } - // import(멤버 0명) 저장소에 기본 owner-admin 지정 — 멤버 할당 데드락 방지. - // (env REPO_IMPORT_ADMIN_EMAIL 미설정/미가입 시 건너뜀) - const adminAssigned = await this.ensureImportAdmin(); - - // 동기화로 저장소 목록이 바뀌었으면 캐시 무효화(새 import/누락표시/admin 즉시 반영) - if (imported + relinked + missing + adminAssigned > 0) { - await this.repoCache.invalidateList(); - } - this.logger.log( - `Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing} · admin지정 ${adminAssigned}`, + `Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing}`, ); } catch (e) { this.logger.error( @@ -157,47 +136,6 @@ export class RepoSyncService implements OnApplicationBootstrap { } } - // import(멤버 0명) Gitea 연동 저장소에 기본 owner-admin 을 지정한다. - // env REPO_IMPORT_ADMIN_EMAIL 의 가입 사용자를 owner-admin 으로 등록(신규 import + 기존 멤버 0명 모두 대상). - // 반환: 이번 실행에서 admin 을 새로 붙인 저장소 수. - private async ensureImportAdmin(): Promise { - if (!this.importAdminEmail) return 0; - - const admin = await this.userService.findByEmail(this.importAdminEmail); - if (!admin) { - this.logger.warn( - `REPO_IMPORT_ADMIN_EMAIL(${this.importAdminEmail}) 에 해당하는 가입 사용자가 없어 import 저장소 admin 지정을 건너뜁니다. (가입 후 다음 동기화에서 지정됨)`, - ); - return 0; - } - - // Gitea 연동(import) 저장소 중 멤버가 0명인 것만 대상. - // Relay 생성 저장소는 생성자(owner)가 항상 멤버이므로 제외된다. - const linked = await this.repoRepo.find({ relations: ['members'] }); - let assigned = 0; - for (const repo of linked) { - if (repo.giteaRepoId === null) continue; - if ((repo.members?.length ?? 0) > 0) continue; - await this.assignOwnerAdmin(repo, admin); - assigned++; - this.logger.log( - `import 저장소 owner-admin 지정: ${repo.slugName} ← ${admin.email}`, - ); - } - return assigned; - } - - // 저장소에 사용자를 owner-admin 멤버로 등록(Relay 생성 시 owner 등록과 동일 규약) - private async assignOwnerAdmin(repo: Repo, user: User): Promise { - const member = this.memberRepo.create({ - repo, - user, - roleType: 'admin', - isOwner: true, - }); - await this.memberRepo.save(member); - } - // Gitea 를 기준으로 Relay 저장소 메타를 맞춘다 — Gitea 의 네이티브 필드와 깔끔히 1:1 대응되는 // 이름(slug)/공개범위/기본 브랜치 + 연동정보만 동기화한다. 변경이 있었으면 true. // ※ 표시명/설명은 Gitea 단일 description 에 "표시제목 — 설명" 으로 합쳐 저장하므로, diff --git a/backend/src/modules/repo/repo.controller.ts b/backend/src/modules/repo/repo.controller.ts index fc088d8..7d75426 100644 --- a/backend/src/modules/repo/repo.controller.ts +++ b/backend/src/modules/repo/repo.controller.ts @@ -8,6 +8,7 @@ import { HttpStatus, Param, ParseIntPipe, + ParseUUIDPipe, Patch, Post, Query, @@ -22,39 +23,41 @@ import type { PaginatedResult } from '../../common/pagination/pagination'; import { CreateRepoDto } from './dto/create-repo.dto'; import { UpdateRepoDto } from './dto/update-repo.dto'; -// 저장소 컨트롤러 — 모든 엔드포인트 인증 필요 +// 저장소 컨트롤러 — 프로젝트에 연동된 저장소. 모든 엔드포인트 인증 필요. @ApiTags('Repo') -@Controller('repos') +@Controller('projects/:projectId/repos') @UseGuards(JwtAuthGuard) export class RepoController { constructor(private readonly repoService: RepoService) {} @Get() - @ApiOperation({ summary: '조직 저장소 전체 목록 (페이지네이션)' }) + @ApiOperation({ summary: '프로젝트 연동 저장소 목록 (페이지네이션)' }) @ApiResponse({ status: 200, description: '목록 조회 성공' }) findAll( + @Param('projectId', ParseUUIDPipe) projectId: string, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, ): Promise> { - return this.repoService.findAll(page, size); + return this.repoService.findAll(projectId, page, size); } @Post() @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: '저장소 생성' }) + @ApiOperation({ summary: '저장소 생성·연동 (admin)' }) @ApiResponse({ status: 201, description: '생성 성공' }) @ApiResponse({ status: 409, description: '이미 사용 중인 저장소 이름 (BIZ_001)', }) create( + @Param('projectId', ParseUUIDPipe) projectId: string, @Body() dto: CreateRepoDto, @CurrentUser() user: PublicUser, ): Promise { - return this.repoService.create(dto, user.id); + return this.repoService.create(projectId, dto, user.id); } - // ':repoId' 보다 먼저 선언해야 'meta' 가 파라미터로 매칭되지 않음 + // ':repoId' 보다 먼저 — 생성 폼 메타(소유자/템플릿) @Get('meta') @ApiOperation({ summary: '저장소 생성 폼 메타(소유자/템플릿) 조회' }) @ApiResponse({ status: 200, description: '조회 성공' }) @@ -65,7 +68,7 @@ export class RepoController { return this.repoService.getCreateMeta(); } - // ':repoId' 보다 먼저 선언 — 생성 폼의 이름(slug) 실시간 가용성 체크 + // ':repoId' 보다 먼저 — 생성 폼의 이름(slug) 실시간 가용성 체크 @Get('name-available') @ApiOperation({ summary: '저장소 이름(slug) 사용 가능 여부' }) @ApiResponse({ status: 200, description: '확인 성공' }) @@ -78,10 +81,11 @@ export class RepoController { @ApiResponse({ status: 200, description: '조회 성공' }) @ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' }) findOne( + @Param('projectId', ParseUUIDPipe) projectId: string, @Param('repoId') repoId: string, @CurrentUser() user: PublicUser, ): Promise { - return this.repoService.findOne(repoId, user.id); + return this.repoService.findOne(projectId, repoId, user.id); } @Patch(':repoId') @@ -89,23 +93,25 @@ export class RepoController { @ApiResponse({ status: 200, description: '수정 성공' }) @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) update( + @Param('projectId', ParseUUIDPipe) projectId: string, @Param('repoId') repoId: string, @Body() dto: UpdateRepoDto, @CurrentUser() user: PublicUser, ): Promise { - return this.repoService.update(repoId, user.id, dto); + return this.repoService.update(projectId, repoId, user.id, dto); } @Delete(':repoId') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '저장소 삭제 (소유자)' }) + @ApiOperation({ summary: '저장소 삭제·연동 해제 (admin)' }) @ApiResponse({ status: 200, description: '삭제 성공' }) @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) async remove( + @Param('projectId', ParseUUIDPipe) projectId: string, @Param('repoId') repoId: string, @CurrentUser() user: PublicUser, ): Promise { - await this.repoService.remove(repoId, user.id); + await this.repoService.remove(projectId, repoId, user.id); return null; } } diff --git a/backend/src/modules/repo/repo.module.ts b/backend/src/modules/repo/repo.module.ts index 2b306f3..2493fd0 100644 --- a/backend/src/modules/repo/repo.module.ts +++ b/backend/src/modules/repo/repo.module.ts @@ -1,31 +1,24 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { UserModule } from '../user/user.module'; import { GiteaModule } from '../gitea/gitea.module'; -import { TaskModule } from '../task/task.module'; import { ActivityModule } from '../activity/activity.module'; -import { NotificationModule } from '../notification/notification.module'; +import { ProjectModule } from '../project/project.module'; import { Repo } from './entities/repo.entity'; -import { RepoMember } from './entities/repo-member.entity'; import { RepoService } from './repo.service'; import { RepoController } from './repo.controller'; -import { MemberService } from './member.service'; -import { MemberController } from './member.controller'; import { RepoSyncService } from './repo-sync.service'; -import { RepoCacheService } from './repo-cache.service'; -// 저장소 모듈 (저장소 + 멤버 관리 + Gitea 동기화) +// 저장소 모듈 — 프로젝트에 연동되는 Gitea 저장소 + Gitea 양방향 동기화. +// 권한은 ProjectService(프로젝트 멤버십)가 결정한다. @Module({ imports: [ - TypeOrmModule.forFeature([Repo, RepoMember]), - UserModule, + TypeOrmModule.forFeature([Repo]), GiteaModule, - TaskModule, ActivityModule, - NotificationModule, + ProjectModule, ], - controllers: [RepoController, MemberController], - providers: [RepoService, MemberService, RepoSyncService, RepoCacheService], - exports: [RepoService, MemberService], + controllers: [RepoController], + providers: [RepoService, RepoSyncService], + exports: [RepoService], }) export class RepoModule {} diff --git a/backend/src/modules/repo/repo.service.ts b/backend/src/modules/repo/repo.service.ts index 5edae9d..9f37560 100644 --- a/backend/src/modules/repo/repo.service.ts +++ b/backend/src/modules/repo/repo.service.ts @@ -1,5 +1,4 @@ import { - ForbiddenException, HttpStatus, Injectable, Logger, @@ -7,14 +6,13 @@ import { } 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 { GiteaApiError, GiteaService, type GiteaRepoResult, } from '../gitea/gitea.service'; -import { TaskService } from '../task/task.service'; +import { ProjectService } from '../project/project.service'; import { ActivityService } from '../activity/activity.service'; import { paginated, @@ -22,40 +20,35 @@ import { type PaginatedResult, } from '../../common/pagination/pagination'; import { Repo, type RepoVisibility } from './entities/repo.entity'; -import { RepoMember } from './entities/repo-member.entity'; -import { RepoCacheService } from './repo-cache.service'; import { CreateRepoDto } from './dto/create-repo.dto'; import { UpdateRepoDto } from './dto/update-repo.dto'; -// Gitea 연동 비활성 시 사용할 기본 소유자 (조직 모델 도입 시 교체) +// Gitea 연동 비활성 시 사용할 기본 소유자 const DEFAULT_OWNER = 'marketing-team'; -// Gitea 저장소 이름 충돌 시 반환되는 HTTP 상태 (enum 비교 회피용 숫자 상수) +// Gitea 저장소 이름 충돌 시 반환되는 HTTP 상태 const HTTP_CONFLICT = 409; -// 저장소 응답 형태 — 파생 라벨/진행률은 프론트에서 계산하므로 원시값 위주로 내린다 +// 저장소 응답 형태 — 프로젝트에 연동된 Gitea 저장소(코드 연동 전용). 멤버/업무 카운트는 프로젝트가 보유. export interface RepoResponse { id: string; // slugName (라우팅 식별자) + projectId: string; // 소속 프로젝트 name: string; owner: string; slug: string; // owner/slugName desc: string | null; visibility: RepoVisibility; branch: string; - members: PublicUser[]; - memberCount: number; - doneCount: number; - totalCount: number; - cloneUrl: string | null; // Gitea HTTPS clone URL (미연동 시 null) - htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null) - giteaMissing: boolean; // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신) + cloneUrl: string | null; + htmlUrl: string | null; + giteaMissing: boolean; updatedAt: string; - // 현재 사용자의 이 저장소 권한 — 단건(findOne)에서만 채워진다(목록은 캐시·사용자 무관이라 미포함). - canManage?: boolean; // admin (설정 변경/탭 노출) - isOwner?: boolean; // 소유자 (저장소 삭제) + // 단건 조회에서만 — 현재 사용자의 소속 프로젝트 권한 + canManage?: boolean; // 프로젝트 admin + isOwner?: boolean; // 프로젝트 소유자 } -// 저장소 비즈니스 로직 +// 저장소 비즈니스 로직 — 저장소는 프로젝트에 연동되며 권한은 프로젝트가 결정한다. @Injectable() export class RepoService { private readonly logger = new Logger(RepoService.name); @@ -63,23 +56,17 @@ export class RepoService { constructor( @InjectRepository(Repo) private readonly repoRepo: Repository, - @InjectRepository(RepoMember) - private readonly memberRepo: Repository, - private readonly userService: UserService, private readonly gitea: GiteaService, - private readonly taskService: TaskService, + private readonly projectService: ProjectService, private readonly activity: ActivityService, - private readonly repoCache: RepoCacheService, ) {} - // 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보. - // 저장소 생성 전 프론트가 미리 조회하여 표시한다. + // 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보 getCreateMeta(): { owner: string; template: { available: boolean; slug: string }; } { return { - // 연동 시 owner = GITEA_ORG, 아니면 기본 상수 owner: this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER, template: { available: this.gitea.templateEnabled, @@ -88,36 +75,29 @@ export class RepoService { }; } - // 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분. 오프셋 페이지네이션. - // 멤버십은 저장소 내 역할/권한에만 쓰이고 목록 노출에는 영향을 주지 않는다(단일 조직 모델). + // 프로젝트의 연동 저장소 목록 — 인증 사용자면 열람. 페이지네이션. async findAll( + projectId: string, page?: number, size?: number, ): Promise> { + await this.projectService.getProjectOrThrow(projectId); const pg = resolvePage(page, size); - // 페이지 단위로 캐시(버전 네임스페이스). 구조 변경 시 버전 증가로 일괄 무효화하고, - // 업무 카운트 staleness 는 60초 TTL 로 자연 보정한다. - return this.repoCache.listOrCompute(pg.page, pg.size, async () => { - const [repos, total] = await this.repoRepo.findAndCount({ - relations: ['members', 'members.user'], - order: { updatedAt: 'DESC' }, - skip: pg.skip, - take: pg.take, - }); - // 저장소별 업무 완료/전체 수를 한 번에 집계해 진행률에 반영 - const counts = await this.taskService.countsByRepo( - repos.map((r) => r.id), - ); - return paginated( - repos.map((repo) => this.toResponse(repo, counts.get(repo.id))), - total, - pg, - ); + const [repos, total] = await this.repoRepo.findAndCount({ + where: { project: { id: projectId } }, + relations: ['project'], + order: { updatedAt: 'DESC' }, + skip: pg.skip, + take: pg.take, }); + return paginated( + repos.map((r) => this.toResponse(r)), + total, + pg, + ); } - // 저장소 이름(slug) 사용 가능 여부 — 생성 폼의 실시간 체크용(Relay 중복만 검사). - // 빈 값/형식은 프론트가 거르고, Gitea 이름 충돌은 생성 시 최종 확인한다. + // 저장소 이름(slug) 사용 가능 여부 — Gitea 조직 내 유일이므로 전역 중복만 검사 async isNameAvailable(slug: string): Promise<{ available: boolean }> { const trimmed = (slug ?? '').trim(); if (!trimmed) return { available: false }; @@ -127,21 +107,26 @@ export class RepoService { return { available: !existing }; } - // 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드). - // 응답에 현재 사용자의 권한(canManage/isOwner)을 함께 내려, 프론트가 설정 탭 노출·진입을 결정한다. - async findOne(slugName: string, userId: string): Promise { - const repo = await this.getEntityOrThrow(slugName); - const membership = await this.memberRepo.findOne({ - where: { repo: { id: repo.id }, user: { id: userId } }, - }); - return this.toResponse(repo, await this.taskCountsOf(repo.id), { - canManage: membership?.roleType === 'admin', - isOwner: membership?.isOwner ?? false, - }); + // 저장소 단건 — 프로젝트 내 저장소. 현재 사용자의 프로젝트 권한(canManage/isOwner) 포함. + async findOne( + projectId: string, + slugName: string, + userId: string, + ): Promise { + const repo = await this.getEntityOrThrow(projectId, slugName); + const perms = await this.projectPerms(projectId, userId); + return this.toResponse(repo, perms); } - // 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록 - async create(dto: CreateRepoDto, userId: string): Promise { + // 저장소 생성 — 프로젝트 admin 권한. Gitea 조직에 실제 repo 생성 후 프로젝트에 연동. + async create( + projectId: string, + dto: CreateRepoDto, + userId: string, + ): Promise { + const project = await this.projectService.getProjectOrThrow(projectId); + await this.projectService.assertProjectAdmin(projectId, userId); + const existing = await this.repoRepo.findOne({ where: { slugName: dto.slug }, }); @@ -152,20 +137,12 @@ export class RepoService { HttpStatus.CONFLICT, ); } - const user = await this.userService.findById(userId); - if (!user) { - throw new NotFoundException(); - } const description = dto.description; const visibility = dto.visibility; const requestedBranch = dto.branch?.trim() || 'main'; - // 연동 시 소유자는 Gitea 조직명 — slug `owner/slugName` 이 Gitea full_name 과 일치 const owner = this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER; - // 1) Gitea 조직에 실제 저장소 생성 (연동 활성 시) - // - useTemplate: 템플릿 저장소를 복제해 생성(브랜치는 템플릿을 따름) - // - 아니면 빈 저장소(auto_init, 요청 브랜치) let giteaRepo: GiteaRepoResult | null = null; if (this.gitea.enabled) { giteaRepo = await this.createGiteaRepo(dto.slug, { @@ -176,48 +153,35 @@ export class RepoService { useTemplate: dto.useTemplate ?? false, }); } - // 템플릿 복제 시 실제 기본 브랜치는 Gitea 응답값을 우선 사용 const branch = giteaRepo?.defaultBranch || requestedBranch; - // 2) Relay 메타데이터 저장 (Gitea 연동 정보 포함) try { - const repo = this.repoRepo.create({ - slugName: dto.slug, - owner, - name: dto.name, - description, - visibility, - branch, - giteaRepoId: giteaRepo?.id ?? null, - giteaFullName: giteaRepo?.fullName ?? null, - cloneUrl: giteaRepo?.cloneUrl ?? null, - htmlUrl: giteaRepo?.htmlUrl ?? null, - }); - const saved = await this.repoRepo.save(repo); + const saved = await this.repoRepo.save( + this.repoRepo.create({ + project, + slugName: dto.slug, + owner, + name: dto.name, + description, + visibility, + branch, + giteaRepoId: giteaRepo?.id ?? null, + giteaFullName: giteaRepo?.fullName ?? null, + cloneUrl: giteaRepo?.cloneUrl ?? null, + htmlUrl: giteaRepo?.htmlUrl ?? null, + }), + ); - const ownerMember = this.memberRepo.create({ - repo: saved, - user, - roleType: 'admin', - isOwner: true, - }); - await this.memberRepo.save(ownerMember); - - // 활동 적재 — 저장소 생성 await this.activity.record({ - repoId: saved.id, + projectId, actorId: userId, - type: 'repo.created', + type: 'repo.linked', payload: { repoName: saved.name }, }); - // 목록 캐시 무효화(새 저장소 즉시 반영) - await this.repoCache.invalidateList(); - - // 갓 생성한 저장소는 업무가 없으므로 집계는 0/0 - return this.toResponse(await this.getEntityOrThrow(saved.slugName)); + return this.toResponse(saved); } catch (err) { - // Relay 저장 실패 시 방금 만든 Gitea 저장소를 정리(고아 방지, 베스트 에포트) + // Relay 저장 실패 시 방금 만든 Gitea 저장소 정리(고아 방지) if (giteaRepo) { await this.gitea.deleteRepo(dto.slug).catch((e: unknown) => { this.logger.error( @@ -230,17 +194,16 @@ export class RepoService { } } - // 저장소 수정 — admin 권한 필요 + // 저장소 수정 — 프로젝트 admin 권한 async update( + projectId: string, slugName: string, userId: string, dto: UpdateRepoDto, ): Promise { - const repo = await this.getEntityOrThrow(slugName); - await this.assertAdmin(repo.id, userId); + const repo = await this.getEntityOrThrow(projectId, slugName); + await this.projectService.assertProjectAdmin(projectId, userId); - // 변경 감지 — 폼이 모든 필드를 항상 보내므로, 실제로 바뀐 값만 동기화·활동·캐시에 반영한다 - // (이전엔 필드 존재 여부로 판단해 저장할 때마다 허위 'renamed'/'visibility_changed' 활동이 쌓였음) const prevName = repo.name; const prevDescription = repo.description ?? ''; const prevVisibility = repo.visibility; @@ -259,8 +222,7 @@ export class RepoService { await this.repoRepo.save(repo); - // Gitea 메타데이터 동기화 (베스트 에포트) — 표시명/설명/공개범위/브랜치가 실제 바뀐 경우에만. - // (브랜치도 Gitea→Relay 로 역동기화되므로, Relay 변경분을 Gitea 에 밀어 양쪽을 일치시킨다) + // Gitea 메타데이터 동기화 (실제로 바뀐 경우에만) const giteaDescChanged = nameChanged || descriptionChanged; if ( this.gitea.enabled && @@ -285,67 +247,33 @@ export class RepoService { }); } - // 활동 적재 — 실제로 바뀐 측면만(표시 제목 변경 / 공개범위 변경) - if (nameChanged) { - await this.activity.record({ - repoId: repo.id, - actorId: userId, - type: 'repo.renamed', - payload: { newName: repo.name }, - }); - } - if (visibilityChanged) { - await this.activity.record({ - repoId: repo.id, - actorId: userId, - type: 'repo.visibility_changed', - payload: { visibility: repo.visibility }, - }); - } - - // 목록 캐시 무효화 — 목록 카드에 노출되는 값(표시명/설명/공개범위/브랜치)이 바뀐 경우에만 - if ( - nameChanged || - descriptionChanged || - visibilityChanged || - branchChanged - ) { - await this.repoCache.invalidateList(); - } - - // 응답에도 현재 사용자 권한을 포함(프론트가 저장 후에도 canManage/isOwner 를 유지하도록) - const membership = await this.memberRepo.findOne({ - where: { repo: { id: repo.id }, user: { id: userId } }, - }); - return this.toResponse( - await this.getEntityOrThrow(slugName), - await this.taskCountsOf(repo.id), - { - canManage: membership?.roleType === 'admin', - isOwner: membership?.isOwner ?? false, - }, - ); + const perms = await this.projectPerms(projectId, userId); + return this.toResponse(repo, perms); } - // 저장소 삭제 — 소유자만 가능 - async remove(slugName: string, userId: string): Promise { - const repo = await this.getEntityOrThrow(slugName); - const membership = await this.memberRepo.findOne({ - where: { repo: { id: repo.id }, user: { id: userId } }, - }); - if (!membership?.isOwner) { - throw new ForbiddenException(); - } + // 저장소 삭제(연동 해제) — 프로젝트 admin 권한 + async remove( + projectId: string, + slugName: string, + userId: string, + ): Promise { + const repo = await this.getEntityOrThrow(projectId, slugName); + await this.projectService.assertProjectAdmin(projectId, userId); + const repoName = repo.name; await this.repoRepo.remove(repo); - // 목록 캐시 무효화(삭제 즉시 반영) - await this.repoCache.invalidateList(); + await this.activity.record({ + projectId, + actorId: userId, + type: 'repo.unlinked', + payload: { repoName }, + }); - // Gitea 저장소도 삭제 (베스트 에포트 — 실패 시 로그만 남기고 Relay 삭제는 유지) + // Gitea 저장소도 삭제 (베스트 에포트) if (this.gitea.enabled && repo.giteaRepoId !== null) { - await this.gitea.deleteRepo(repo.slugName).catch((e: unknown) => { + await this.gitea.deleteRepo(slugName).catch((e: unknown) => { this.logger.error( - `Gitea 저장소 삭제 동기화 실패: ${repo.slugName}`, + `Gitea 저장소 삭제 동기화 실패: ${slugName}`, e instanceof Error ? e.stack : String(e), ); }); @@ -354,8 +282,19 @@ export class RepoService { // --- 내부 헬퍼 --- - // Gitea 조직에 저장소 생성 — 실패 시 사용자 노출 가능한 BusinessException 으로 변환. - // useTemplate 이면 템플릿 저장소를 복제(generate), 아니면 빈 저장소(auto_init). + // 현재 사용자의 프로젝트 권한(canManage/isOwner) — 단건 응답용 + private async projectPerms( + projectId: string, + userId: string, + ): Promise<{ canManage: boolean; isOwner: boolean }> { + const project = await this.projectService.findOne(projectId, userId); + return { + canManage: project.canManage ?? false, + isOwner: project.isOwner ?? false, + }; + } + + // Gitea 조직에 저장소 생성 — 실패 시 BusinessException 으로 변환 private async createGiteaRepo( slug: string, meta: { @@ -383,7 +322,6 @@ export class RepoService { defaultBranch: meta.branch, }); } catch (err) { - // Gitea 측 이름 충돌 → Relay 와 동일한 중복 문구로 안내 if (err instanceof GiteaApiError && err.status === HTTP_CONFLICT) { throw new BusinessException( 'BIZ_001', @@ -403,7 +341,7 @@ export class RepoService { } } - // Gitea 저장소 설명 문자열 구성 — Gitea 에 별도 표시명 필드가 없어 '표시제목 — 설명' 으로 합친다 + // Gitea 저장소 설명 문자열 — '표시제목 — 설명' 으로 합친다 private buildGiteaDescription( name: string, description: string | null, @@ -411,11 +349,14 @@ export class RepoService { return description ? `${name} — ${description}` : name; } - // slugName 으로 저장소(+멤버) 로딩, 없으면 404 - private async getEntityOrThrow(slugName: string): Promise { + // 프로젝트 내 slugName 으로 저장소 로딩, 없으면 404 + private async getEntityOrThrow( + projectId: string, + slugName: string, + ): Promise { const repo = await this.repoRepo.findOne({ - where: { slugName }, - relations: ['members', 'members.user'], + where: { slugName, project: { id: projectId } }, + relations: ['project'], }); if (!repo) { throw new NotFoundException(); @@ -423,45 +364,20 @@ export class RepoService { return repo; } - // admin 권한 확인 (아니면 403) - private async assertAdmin(repoId: string, userId: string): Promise { - const membership = await this.memberRepo.findOne({ - where: { repo: { id: repoId }, user: { id: userId } }, - }); - if (!membership || membership.roleType !== 'admin') { - throw new ForbiddenException(); - } - } - - // 단일 저장소의 업무 완료/전체 수 집계 - private async taskCountsOf( - repoId: string, - ): Promise<{ done: number; total: number } | undefined> { - return (await this.taskService.countsByRepo([repoId])).get(repoId); - } - - // 엔티티 → 응답 매핑 (counts 미제공 시 0/0). perms 는 단건 조회에서만 채운다. + // 엔티티 → 응답 매핑. perms 는 단건 조회에서만 채운다. private toResponse( repo: Repo, - counts?: { done: number; total: number }, perms?: { canManage: boolean; isOwner: boolean }, ): RepoResponse { - const members = (repo.members ?? []).map((m) => - UserService.toPublic(m.user), - ); return { id: repo.slugName, + projectId: repo.project?.id ?? '', name: repo.name, owner: repo.owner, slug: `${repo.owner}/${repo.slugName}`, desc: repo.description, visibility: repo.visibility, branch: repo.branch, - members, - memberCount: members.length, - // 업무 모듈 집계(없으면 0/0) - doneCount: counts?.done ?? 0, - totalCount: counts?.total ?? 0, cloneUrl: repo.cloneUrl, htmlUrl: repo.htmlUrl, giteaMissing: repo.giteaMissing, diff --git a/backend/src/modules/task/entities/task.entity.ts b/backend/src/modules/task/entities/task.entity.ts index d421fec..9777e05 100644 --- a/backend/src/modules/task/entities/task.entity.ts +++ b/backend/src/modules/task/entities/task.entity.ts @@ -14,31 +14,31 @@ import { UpdateDateColumn, } from 'typeorm'; import { User } from '../../user/entities/user.entity'; -import { Repo } from '../../repo/entities/repo.entity'; +import { Project } from '../../project/entities/project.entity'; import { ChecklistItem } from './checklist-item.entity'; // 업무 상태 (상태 머신: todo → prog → review → done/changes, changes → review) export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes'; -// 업무 엔티티 — 저장소에 종속, 저장소 내 순번(seq)으로 식별(#9) +// 업무 엔티티 — 프로젝트에 종속, 프로젝트 내 순번(seq)으로 식별(#9) @Entity('tasks') -@Unique(['repo', 'seq']) +@Unique(['project', 'seq']) export class Task { // 내부 식별자 (UUID) @PrimaryGeneratedColumn('uuid') id!: string; - // 저장소 내 표시 순번(#9) — 라우팅/표시 식별자. 저장소별로 1부터 증가 + // 프로젝트 내 표시 순번(#9) — 라우팅/표시 식별자. 프로젝트별로 1부터 증가 @Index() @Column({ type: 'int' }) seq!: number; - // 소속 저장소 (저장소 삭제 시 함께 삭제) - // Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함 - // FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.repo_id 와 일치) - @ManyToOne(() => Repo, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'repo_id' }) - repo!: Relation; + // 소속 프로젝트 (프로젝트 삭제 시 함께 삭제) + // Relation<> 래퍼: 엔티티 순환참조 회피. + // FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.project_id 와 일치) + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project!: Relation; // 업무 제목 @Column() @@ -60,7 +60,7 @@ export class Task { @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) issuer!: User | null; - // 담당자 목록 (저장소 멤버만 지정 가능 — 서비스에서 검증) + // 담당자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증) @ManyToMany(() => User) @JoinTable({ name: 'task_assignees', diff --git a/backend/src/modules/task/task.controller.ts b/backend/src/modules/task/task.controller.ts index e5981b2..6f47875 100644 --- a/backend/src/modules/task/task.controller.ts +++ b/backend/src/modules/task/task.controller.ts @@ -49,9 +49,9 @@ import { } from './upload.config'; import { BusinessException } from '../../common/exceptions/business.exception'; -// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번) +// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (projectId = slugName, taskId = repo 내 순번) @ApiTags('Task') -@Controller('repos/:repoId/tasks') +@Controller('projects/:projectId/tasks') @UseGuards(JwtAuthGuard) export class TaskController { constructor(private readonly taskService: TaskService) {} @@ -63,14 +63,19 @@ export class TaskController { @ApiResponse({ status: 200, description: '목록 조회 성공' }) @ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' }) list( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, @Query('segment') segment?: string, @Query('q') q?: string, @Query('assignee') assignee?: string, ): Promise { - return this.taskService.list(repoId, { segment, q, assignee }, page, size); + return this.taskService.list( + projectId, + { segment, q, assignee }, + page, + size, + ); } @Post() @@ -83,11 +88,11 @@ export class TaskController { description: '담당자가 저장소 멤버가 아님 (BIZ_001)', }) create( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Body() dto: CreateTaskDto, @CurrentUser() user: PublicUser, ): Promise { - return this.taskService.create(repoId, user.id, dto); + return this.taskService.create(projectId, user.id, dto); } @Get(':taskId') @@ -95,10 +100,10 @@ export class TaskController { @ApiResponse({ status: 200, description: '조회 성공' }) @ApiResponse({ status: 404, description: '업무를 찾을 수 없음 (RES_001)' }) findOne( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, ): Promise { - return this.taskService.findOne(repoId, taskId); + return this.taskService.findOne(projectId, taskId); } @Patch(':taskId') @@ -106,12 +111,12 @@ export class TaskController { @ApiResponse({ status: 200, description: '수정 성공' }) @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) update( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @Body() dto: UpdateTaskDto, @CurrentUser() user: PublicUser, ): Promise { - return this.taskService.update(repoId, user.id, taskId, dto); + return this.taskService.update(projectId, user.id, taskId, dto); } @Delete(':taskId') @@ -120,11 +125,11 @@ export class TaskController { @ApiResponse({ status: 200, description: '삭제 성공' }) @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) async remove( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @CurrentUser() user: PublicUser, ): Promise { - await this.taskService.remove(repoId, user.id, taskId); + await this.taskService.remove(projectId, user.id, taskId); return null; } @@ -138,12 +143,17 @@ export class TaskController { description: '허용되지 않는 상태 전이 (BIZ_001)', }) changeStatus( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @Body() dto: ChangeStatusDto, @CurrentUser() user: PublicUser, ): Promise { - return this.taskService.changeStatus(repoId, user.id, taskId, dto.status); + return this.taskService.changeStatus( + projectId, + user.id, + taskId, + dto.status, + ); } /* ===================== 댓글 / 답글 (5단계) ===================== */ @@ -153,13 +163,13 @@ export class TaskController { @ApiOperation({ summary: '댓글 작성 (멤버)' }) @ApiResponse({ status: 201, description: '작성 성공' }) addComment( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @Body() dto: CreateCommentDto, @CurrentUser() user: PublicUser, ): Promise { return this.taskService.addComment( - repoId, + projectId, user.id, taskId, dto.text, @@ -172,14 +182,14 @@ export class TaskController { @ApiOperation({ summary: '답글 작성 (멤버)' }) @ApiResponse({ status: 201, description: '작성 성공' }) addReply( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @Param('commentId', ParseUUIDPipe) commentId: string, @Body() dto: CreateReplyDto, @CurrentUser() user: PublicUser, ): Promise { return this.taskService.addReply( - repoId, + projectId, user.id, taskId, commentId, @@ -202,7 +212,7 @@ export class TaskController { @ApiOperation({ summary: '파일 업로드 (멤버)' }) @ApiResponse({ status: 201, description: '업로드 성공' }) addFile( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @UploadedFile() file: UploadedDiskFile, @CurrentUser() user: PublicUser, @@ -215,7 +225,7 @@ export class TaskController { HttpStatus.BAD_REQUEST, ); } - return this.taskService.addFile(repoId, user.id, taskId, file); + return this.taskService.addFile(projectId, user.id, taskId, file); } @Delete(':taskId/files/:fileId') @@ -223,12 +233,12 @@ export class TaskController { @ApiOperation({ summary: '파일 삭제 (멤버)' }) @ApiResponse({ status: 200, description: '삭제 성공' }) async removeFile( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @Param('fileId', ParseUUIDPipe) fileId: string, @CurrentUser() user: PublicUser, ): Promise { - await this.taskService.removeFile(repoId, user.id, taskId, fileId); + await this.taskService.removeFile(projectId, user.id, taskId, fileId); return null; } @@ -236,7 +246,7 @@ export class TaskController { @ApiOperation({ summary: '파일 다운로드 (멤버)' }) @ApiResponse({ status: 200, description: '파일 스트림' }) async downloadFile( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @Param('fileId', ParseUUIDPipe) fileId: string, @CurrentUser() user: PublicUser, @@ -244,7 +254,7 @@ export class TaskController { ): Promise { const { attachment, absolutePath } = await this.taskService.getFileForDownload( - repoId, + projectId, user.id, taskId, fileId, @@ -268,12 +278,17 @@ export class TaskController { @ApiResponse({ status: 200, description: '요청 성공' }) @ApiResponse({ status: 409, description: '허용되지 않는 전이 (BIZ_001)' }) requestApproval( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @Body() dto: ApprovalRequestDto, @CurrentUser() user: PublicUser, ): Promise { - return this.taskService.requestApproval(repoId, user.id, taskId, dto.note); + return this.taskService.requestApproval( + projectId, + user.id, + taskId, + dto.note, + ); } @Post(':taskId/approval/approve') @@ -282,11 +297,11 @@ export class TaskController { @ApiResponse({ status: 200, description: '승인 성공' }) @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) approve( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @CurrentUser() user: PublicUser, ): Promise { - return this.taskService.approve(repoId, user.id, taskId); + return this.taskService.approve(projectId, user.id, taskId); } @Post(':taskId/approval/changes') @@ -295,11 +310,16 @@ export class TaskController { @ApiResponse({ status: 200, description: '요청 성공' }) @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) requestChanges( - @Param('repoId') repoId: string, + @Param('projectId') projectId: string, @Param('taskId', ParseIntPipe) taskId: number, @Body() dto: ApprovalChangesDto, @CurrentUser() user: PublicUser, ): Promise { - return this.taskService.requestChanges(repoId, user.id, taskId, dto.note); + return this.taskService.requestChanges( + projectId, + user.id, + taskId, + dto.note, + ); } } diff --git a/backend/src/modules/task/task.module.ts b/backend/src/modules/task/task.module.ts index f520370..b42c101 100644 --- a/backend/src/modules/task/task.module.ts +++ b/backend/src/modules/task/task.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Repo } from '../repo/entities/repo.entity'; -import { RepoMember } from '../repo/entities/repo-member.entity'; +import { Project } from '../project/entities/project.entity'; +import { ProjectMember } from '../project/entities/project-member.entity'; import { Task } from './entities/task.entity'; import { ChecklistItem } from './entities/checklist-item.entity'; import { Comment } from './entities/comment.entity'; @@ -12,7 +12,7 @@ import { TaskService } from './task.service'; import { TaskController } from './task.controller'; import { MyTaskController } from './my-task.controller'; -// 업무 모듈 (업무 CRUD + 상태 전이 + 체크리스트/댓글/첨부/승인) +// 업무 모듈 (업무 CRUD + 상태 전이 + 체크리스트/댓글/첨부/승인) — 프로젝트 종속 @Module({ imports: [ TypeOrmModule.forFeature([ @@ -20,8 +20,8 @@ import { MyTaskController } from './my-task.controller'; ChecklistItem, Comment, Attachment, - Repo, - RepoMember, + Project, + ProjectMember, ]), ActivityModule, NotificationModule, diff --git a/backend/src/modules/task/task.service.spec.ts b/backend/src/modules/task/task.service.spec.ts index 4ab3a03..9a0384e 100644 --- a/backend/src/modules/task/task.service.spec.ts +++ b/backend/src/modules/task/task.service.spec.ts @@ -2,8 +2,8 @@ import { ForbiddenException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { TaskService } from './task.service'; import { BusinessException } from '../../common/exceptions/business.exception'; -import { Repo } from '../repo/entities/repo.entity'; -import { RepoMember } from '../repo/entities/repo-member.entity'; +import { Project } from '../project/entities/project.entity'; +import { ProjectMember } from '../project/entities/project-member.entity'; import { Task } from './entities/task.entity'; import { ChecklistItem } from './entities/checklist-item.entity'; import { Comment } from './entities/comment.entity'; @@ -36,8 +36,8 @@ function makeService() { const svc = new TaskService( taskRepo as unknown as Repository, checklistRepo as unknown as Repository, - repoRepo as unknown as Repository, - memberRepo as unknown as Repository, + repoRepo as unknown as Repository, + memberRepo as unknown as Repository, commentRepo as unknown as Repository, attachmentRepo as unknown as Repository, activity as unknown as ActivityService, diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index 89771e9..4f597ef 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -8,8 +8,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Repository } from 'typeorm'; import { UserService, type PublicUser } from '../user/user.service'; import { BusinessException } from '../../common/exceptions/business.exception'; -import { Repo } from '../repo/entities/repo.entity'; -import { RepoMember } from '../repo/entities/repo-member.entity'; +import { Project } from '../project/entities/project.entity'; +import { ProjectMember } from '../project/entities/project-member.entity'; import { Task, type TaskStatus } from './entities/task.entity'; import { ChecklistItem } from './entities/checklist-item.entity'; import { Comment } from './entities/comment.entity'; @@ -99,8 +99,8 @@ export interface CommentResponse { // 내 업무 행(저장소 횡단) — 라벨/그룹핑은 프론트가 파생 export interface MyTaskRowResponse { id: number; // 저장소 내 순번(seq) - repoId: string; // slugName (라우팅용) - repoName: string; + projectId: string; // projectId (라우팅용) + projectName: string; title: string; status: TaskStatus; dueDate: string | null; @@ -119,8 +119,8 @@ export interface ApprovalInfo { // 업무 상세 응답 — activities 는 6단계 영역(현재 빈 배열) export interface TaskDetailResponse { id: number; - repoId: string; // slugName - repoName: string; + projectId: string; // projectId + projectName: string; title: string; status: TaskStatus; content: string[]; @@ -162,10 +162,10 @@ export class TaskService { private readonly taskRepo: Repository, @InjectRepository(ChecklistItem) private readonly checklistRepo: Repository, - @InjectRepository(Repo) - private readonly repoRepo: Repository, - @InjectRepository(RepoMember) - private readonly memberRepo: Repository, + @InjectRepository(Project) + private readonly projectRepo: Repository, + @InjectRepository(ProjectMember) + private readonly memberRepo: Repository, @InjectRepository(Comment) private readonly commentRepo: Repository, @InjectRepository(Attachment) @@ -174,15 +174,15 @@ export class TaskService { private readonly notification: NotificationService, ) {} - // 알림 payload 공통 조립 — 프론트가 문구/링크(repoId/taskSeq) 구성에 사용 + // 알림 payload 공통 조립 — 프론트가 문구/링크(projectId/taskSeq) 구성에 사용 private taskNotifyPayload( - repo: Repo, + project: Project, task: Task, actorName: string, ): Record { return { - repoId: repo.slugName, - repoName: repo.name, + projectId: project.id, + projectName: project.name, taskSeq: task.seq, taskTitle: task.title, actorName, @@ -191,19 +191,19 @@ export class TaskService { // 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트 async list( - slugName: string, + projectId: string, filters: { segment?: string; q?: string; assignee?: string }, page?: number, size?: number, ): Promise { - const repo = await this.getRepoOrThrow(slugName); + const project = await this.getProjectOrThrow(projectId); const pg = resolvePage(page, size); const qb = this.taskRepo .createQueryBuilder('task') .leftJoinAndSelect('task.assignees', 'assignee') .leftJoinAndSelect('task.checklist', 'checklist') - .where('task.repo = :repoId', { repoId: repo.id }); + .where('task.project = :projectId', { projectId: project.id }); // 세그먼트 → 상태 조건 (진행 중 = prog+review, 그 외 단일 상태. 'all'·'changes 포함'은 무필터) const segment = filters.segment ?? 'all'; @@ -258,18 +258,18 @@ export class TaskService { ), ); - const counts = await this.segmentCounts(repo.id, q); + const counts = await this.segmentCounts(project.id, q); return { ...paginated(items, total, pg), counts }; } // 세그먼트별 카운트(검색어 반영) — RepoDetailPage 세그먼트 배지용 private async segmentCounts( - repoId: string, + projectId: string, q?: string, ): Promise { const cqb = this.taskRepo .createQueryBuilder('task') - .where('task.repo = :repoId', { repoId }); + .where('task.project = :projectId', { projectId }); if (q) { cqb.andWhere('task.title ILIKE :q', { q: `%${q}%` }); } @@ -291,37 +291,37 @@ export class TaskService { return counts; } - // 업무 단건 상세 — repo 내 순번(seq)으로 조회 - async findOne(slugName: string, seq: number): Promise { - const repo = await this.getRepoOrThrow(slugName); - const task = await this.getTaskOrThrow(repo.id, seq); - return this.buildDetail(task, repo); + // 업무 단건 상세 — project 내 순번(seq)으로 조회 + async findOne(projectId: string, seq: number): Promise { + const project = await this.getProjectOrThrow(projectId); + const task = await this.getTaskOrThrow(project.id, seq); + return this.buildDetail(task, project); } // 업무 생성 — admin(지시자) 권한. 담당자는 저장소 멤버만 지정 가능 async create( - slugName: string, + projectId: string, actingUserId: string, dto: CreateTaskDto, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const issuer = await this.assertAdmin(repo.id, actingUserId); + const project = await this.getProjectOrThrow(projectId); + const issuer = await this.assertAdmin(project.id, actingUserId); const assignees = await this.resolveMemberAssignees( - repo.id, + project.id, dto.assigneeIds, ); // 저장소 내 다음 순번 채번 const last = await this.taskRepo.findOne({ - where: { repo: { id: repo.id } }, + where: { project: { id: project.id } }, order: { seq: 'DESC' }, }); const seq = (last?.seq ?? 0) + 1; const task = this.taskRepo.create({ seq, - repo, + project, title: dto.title, content: dto.content ?? [], status: 'todo', @@ -340,7 +340,7 @@ export class TaskService { // 활동 적재 — 업무 생성 await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId: actingUserId, type: 'task.created', payload: { taskTitle: saved.title }, @@ -352,25 +352,25 @@ export class TaskService { recipientIds: assignees.map((a) => a.id), actorId: actingUserId, type: 'task.assigned', - payload: this.taskNotifyPayload(repo, saved, issuer.user.name), + payload: this.taskNotifyPayload(project, saved, issuer.user.name), }); return this.buildDetail( - await this.getTaskOrThrow(repo.id, saved.seq), - repo, + await this.getTaskOrThrow(project.id, saved.seq), + project, ); } // 업무 수정 — admin 권한. 제공된 필드만 갱신 async update( - slugName: string, + projectId: string, actingUserId: string, seq: number, dto: UpdateTaskDto, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const acting = await this.assertAdmin(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const acting = await this.assertAdmin(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); // 변경 감지용 이전 상태 캡처 const prevTitle = task.title; @@ -385,7 +385,7 @@ export class TaskService { } if (dto.assigneeIds !== undefined) { task.assignees = await this.resolveMemberAssignees( - repo.id, + project.id, dto.assigneeIds, ); } @@ -399,7 +399,7 @@ export class TaskService { } // 변경 항목별 활동 적재(담당자/마감/일반 수정) - await this.recordUpdateActivities(repo, actingUserId, task, { + await this.recordUpdateActivities(project, actingUserId, task, { dto, prevTitle, prevContent, @@ -418,7 +418,7 @@ export class TaskService { recipientIds: added, actorId: actingUserId, type: 'task.assigned', - payload: this.taskNotifyPayload(repo, task, acting.user.name), + payload: this.taskNotifyPayload(project, task, acting.user.name), }); } if (removed.length) { @@ -426,7 +426,7 @@ export class TaskService { recipientIds: removed, actorId: actingUserId, type: 'task.unassigned', - payload: this.taskNotifyPayload(repo, task, acting.user.name), + payload: this.taskNotifyPayload(project, task, acting.user.name), }); } } @@ -440,19 +440,22 @@ export class TaskService { actorId: actingUserId, type: 'task.due_changed', payload: { - ...this.taskNotifyPayload(repo, task, acting.user.name), + ...this.taskNotifyPayload(project, task, acting.user.name), dueDate: newDueIso, }, }); } } - return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + return this.buildDetail( + await this.getTaskOrThrow(project.id, seq), + project, + ); } // 업무 수정 시 실제로 바뀐 측면만 골라 활동 적재(담당자/마감/일반) private async recordUpdateActivities( - repo: Repo, + project: Project, actorId: string, task: Task, prev: { @@ -471,7 +474,7 @@ export class TaskService { const newIds = (task.assignees ?? []).map((a) => a.id).sort(); if (JSON.stringify(newIds) !== JSON.stringify(prev.prevAssigneeIds)) { await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId, type: 'task.assignees_changed', payload: { @@ -488,7 +491,7 @@ export class TaskService { const newDueIso = task.dueDate ? task.dueDate.toISOString() : null; if (newDueIso !== prev.prevDueIso) { await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId, type: 'task.due_changed', payload: { taskTitle: task.title, dueDate: newDueIso }, @@ -505,7 +508,7 @@ export class TaskService { JSON.stringify(dto.content) !== JSON.stringify(prev.prevContent); if (titleChanged || contentChanged || prev.checklistChanged) { await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId, type: 'task.edited', payload: { taskTitle: task.title }, @@ -560,13 +563,13 @@ export class TaskService { // 업무 삭제 — admin 권한 async remove( - slugName: string, + projectId: string, actingUserId: string, seq: number, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const acting = await this.assertAdmin(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const acting = await this.assertAdmin(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); // 디스크 첨부 파일 정리(베스트 에포트) — DB 행은 FK CASCADE 로 함께 삭제됨 const files = await this.attachmentRepo.find({ where: { task: { id: task.id } }, @@ -579,12 +582,16 @@ export class TaskService { ...(task.issuer ? [task.issuer.id] : []), ...(task.assignees ?? []).map((a) => a.id), ]; - const removalPayload = this.taskNotifyPayload(repo, task, acting.user.name); + const removalPayload = this.taskNotifyPayload( + project, + task, + acting.user.name, + ); await this.taskRepo.remove(task); // 활동 적재 — 업무 삭제(활동은 taskSeq 로만 연결돼 업무 삭제 후에도 유지) await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId: actingUserId, type: 'task.removed', payload: { taskTitle }, @@ -602,33 +609,36 @@ export class TaskService { // 상태 변경 — 상태 머신 전이 규칙 + 역할 검증 async changeStatus( - slugName: string, + projectId: string, actingUserId: string, seq: number, next: TaskStatus, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const membership = await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const membership = await this.assertMember(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); - await this.transition(repo, task, membership, actingUserId, next); + await this.transition(project, task, membership, actingUserId, next); - return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + return this.buildDetail( + await this.getTaskOrThrow(project.id, seq), + project, + ); } // --- 5단계: 댓글 / 답글 --- // 댓글 작성 — 저장소 멤버. fileId 로 기존 첨부 연결 가능 async addComment( - slugName: string, + projectId: string, actingUserId: string, seq: number, text: string, fileId?: string, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const membership = await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const membership = await this.assertMember(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); const attachment = fileId ? await this.getAttachmentOrThrow(task.id, fileId) @@ -645,7 +655,7 @@ export class TaskService { // 활동 적재 — 댓글 작성 await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId: actingUserId, type: 'task.comment_added', payload: { taskTitle: task.title }, @@ -660,7 +670,7 @@ export class TaskService { ], actorId: actingUserId, type: 'task.commented', - payload: this.taskNotifyPayload(repo, task, membership.user.name), + payload: this.taskNotifyPayload(project, task, membership.user.name), }); return this.toCommentResponse( @@ -668,22 +678,22 @@ export class TaskService { where: { id: saved.id }, relations: ['author', 'attachment', 'replies', 'replies.author'], }), - repo, + project, task.seq, ); } // 답글 작성 — 저장소 멤버. 최상위 댓글에만 답글 가능(1단계 깊이) async addReply( - slugName: string, + projectId: string, actingUserId: string, seq: number, commentId: string, text: string, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const membership = await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const membership = await this.assertMember(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); const parent = await this.commentRepo.findOne({ where: { id: commentId, task: { id: task.id }, parent: IsNull() }, @@ -703,7 +713,7 @@ export class TaskService { // 활동 적재 — 답글도 댓글과 동일 타입으로 기록 await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId: actingUserId, type: 'task.comment_added', payload: { taskTitle: task.title }, @@ -720,7 +730,7 @@ export class TaskService { actorId: actingUserId, type: 'task.commented', payload: { - ...this.taskNotifyPayload(repo, task, membership.user.name), + ...this.taskNotifyPayload(project, task, membership.user.name), isReply: true, }, }); @@ -737,14 +747,14 @@ export class TaskService { // 파일 업로드 — 저장소 멤버. 디스크 저장은 multer 가 처리, 메타데이터만 영속 async addFile( - slugName: string, + projectId: string, actingUserId: string, seq: number, file: UploadedDiskFile, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const membership = await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const membership = await this.assertMember(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); const name = decodeOriginalName(file.originalname); const attachment = this.attachmentRepo.create({ @@ -760,7 +770,7 @@ export class TaskService { // 활동 적재 — 파일 첨부 await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId: actingUserId, type: 'task.file_attached', payload: { fileName: name, taskTitle: task.title }, @@ -769,21 +779,21 @@ export class TaskService { return this.toAttachmentResponse( { ...saved, uploader: membership.user }, - repo, + project, task.seq, ); } // 파일 삭제 — 저장소 멤버. 디스크 파일도 정리 async removeFile( - slugName: string, + projectId: string, actingUserId: string, seq: number, fileId: string, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const membership = await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const membership = await this.assertMember(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); const attachment = await this.getAttachmentOrThrow(task.id, fileId); // 업로더 본인 또는 관리자만 삭제 가능 — 멤버라도 타인의 첨부는 지울 수 없음 if ( @@ -798,7 +808,7 @@ export class TaskService { // 활동 적재 — 첨부 삭제 await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId: actingUserId, type: 'task.file_removed', payload: { fileName, taskTitle: task.title }, @@ -808,14 +818,14 @@ export class TaskService { // 다운로드용 첨부 메타 + 디스크 절대경로 — 컨트롤러가 스트리밍 async getFileForDownload( - slugName: string, + projectId: string, actingUserId: string, seq: number, fileId: string, ): Promise<{ attachment: Attachment; absolutePath: string }> { - const repo = await this.getRepoOrThrow(slugName); - await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + await this.assertMember(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); const attachment = await this.getAttachmentOrThrow(task.id, fileId); return { attachment, @@ -827,55 +837,64 @@ export class TaskService { // 승인 요청(담당자 → 지시자): prog/changes → review. 요청 메시지 보관 async requestApproval( - slugName: string, + projectId: string, actingUserId: string, seq: number, note?: string, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const membership = await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const membership = await this.assertMember(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); task.approvalNote = note?.trim() ? note.trim() : null; task.approvalRequestedBy = membership.user; task.approvalRequestedAt = new Date(); task.changesNote = null; - await this.transition(repo, task, membership, actingUserId, 'review'); - return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + await this.transition(project, task, membership, actingUserId, 'review'); + return this.buildDetail( + await this.getTaskOrThrow(project.id, seq), + project, + ); } // 승인(지시자/admin): review → done async approve( - slugName: string, + projectId: string, actingUserId: string, seq: number, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const membership = await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const membership = await this.assertMember(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); - await this.transition(repo, task, membership, actingUserId, 'done'); - return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + await this.transition(project, task, membership, actingUserId, 'done'); + return this.buildDetail( + await this.getTaskOrThrow(project.id, seq), + project, + ); } // 수정 요청(지시자/admin): review → changes. 보완 메시지 보관 async requestChanges( - slugName: string, + projectId: string, actingUserId: string, seq: number, note: string, ): Promise { - const repo = await this.getRepoOrThrow(slugName); - const membership = await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); + const project = await this.getProjectOrThrow(projectId); + const membership = await this.assertMember(project.id, actingUserId); + const task = await this.getTaskOrThrow(project.id, seq); task.changesNote = note; task.approvalNote = null; task.approvalRequestedAt = null; - await this.transition(repo, task, membership, actingUserId, 'changes'); - return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + await this.transition(project, task, membership, actingUserId, 'changes'); + return this.buildDetail( + await this.getTaskOrThrow(project.id, seq), + project, + ); } // --- 7단계: 내 업무(저장소 횡단 집계) --- @@ -889,7 +908,7 @@ export class TaskService { const pg = resolvePage(page, size); const [tasks, total] = await this.taskRepo .createQueryBuilder('task') - .leftJoinAndSelect('task.repo', 'repo') + .leftJoinAndSelect('task.project', 'project') .leftJoinAndSelect('task.assignees', 'assignee') .leftJoinAndSelect('task.issuer', 'issuer') .leftJoinAndSelect('task.checklist', 'checklist') @@ -925,7 +944,7 @@ export class TaskService { const pg = resolvePage(page, size); const [tasks, total] = await this.taskRepo .createQueryBuilder('task') - .leftJoinAndSelect('task.repo', 'repo') + .leftJoinAndSelect('task.project', 'project') .leftJoinAndSelect('task.assignees', 'assignee') .leftJoinAndSelect('task.issuer', 'issuer') .leftJoinAndSelect('task.checklist', 'checklist') @@ -948,8 +967,8 @@ export class TaskService { const done = checklist.filter((c) => c.done).length; return { id: task.seq, - repoId: task.repo.slugName, - repoName: task.repo.name, + projectId: task.project.id, + projectName: task.project.name, title: task.title, status: task.status, dueDate: task.dueDate ? task.dueDate.toISOString() : null, @@ -962,21 +981,21 @@ export class TaskService { // --- 집계(다른 모듈에서 사용) --- // 저장소별 업무 완료/전체 수 — RepoService 진행률 집계용 - async countsByRepo( - repoIds: string[], + async countsByProject( + projectIds: string[], ): Promise> { const result = new Map(); - if (repoIds.length === 0) return result; + if (projectIds.length === 0) return result; const rows = await this.taskRepo .createQueryBuilder('task') - .select('task.repo_id', 'repoId') + .select('task.project_id', 'projectId') .addSelect('COUNT(*)', 'total') .addSelect("COUNT(*) FILTER (WHERE task.status = 'done')", 'done') - .where('task.repo_id IN (:...repoIds)', { repoIds }) - .groupBy('task.repo_id') - .getRawMany<{ repoId: string; total: string; done: string }>(); + .where('task.project_id IN (:...projectIds)', { projectIds }) + .groupBy('task.project_id') + .getRawMany<{ projectId: string; total: string; done: string }>(); for (const r of rows) { - result.set(r.repoId, { + result.set(r.projectId, { done: Number(r.done), total: Number(r.total), }); @@ -985,13 +1004,13 @@ export class TaskService { } // 저장소 내 사용자별 담당 업무 수 — MemberService taskCount 집계용 - async assigneeCounts(repoId: string): Promise> { + async assigneeCounts(projectId: string): Promise> { const rows = await this.taskRepo .createQueryBuilder('task') .innerJoin('task_assignees', 'ta', 'ta.task_id = task.id') .select('ta.user_id', 'userId') .addSelect('COUNT(*)', 'count') - .where('task.repo_id = :repoId', { repoId }) + .where('task.project_id = :projectId', { projectId }) .groupBy('ta.user_id') .getRawMany<{ userId: string; count: string }>(); const map = new Map(); @@ -1003,9 +1022,9 @@ export class TaskService { // 상태 전이 1건 적용 — 전이 규칙 + 역할 검증 후 저장(부수효과 포함) private async transition( - repo: Repo, + project: Project, task: Task, - membership: RepoMember, + membership: ProjectMember, actingUserId: string, next: TaskStatus, ): Promise { @@ -1045,7 +1064,7 @@ export class TaskService { // 활동 적재 — 상태 전이(시작/승인요청/승인/수정요청 등은 프론트가 from→to 로 문구 파생) await this.activity.record({ - repoId: repo.id, + projectId: project.id, actorId: actingUserId, type: 'task.status_changed', payload: { statusFrom: from, statusTo: next, taskTitle: task.title }, @@ -1059,21 +1078,21 @@ export class TaskService { recipientIds: task.issuer ? [task.issuer.id] : [], actorId: actingUserId, type: 'task.approval_requested', - payload: this.taskNotifyPayload(repo, task, actorName), + payload: this.taskNotifyPayload(project, task, actorName), }); } else if (next === 'done') { await this.notification.notify({ recipientIds: task.assignees.map((a) => a.id), actorId: actingUserId, type: 'task.approved', - payload: this.taskNotifyPayload(repo, task, actorName), + payload: this.taskNotifyPayload(project, task, actorName), }); } else if (next === 'changes') { await this.notification.notify({ recipientIds: task.assignees.map((a) => a.id), actorId: actingUserId, type: 'task.changes_requested', - payload: this.taskNotifyPayload(repo, task, actorName), + payload: this.taskNotifyPayload(project, task, actorName), }); } else if (next === 'prog') { // 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게 @@ -1081,24 +1100,26 @@ export class TaskService { recipientIds: task.issuer ? [task.issuer.id] : [], actorId: actingUserId, type: 'task.started', - payload: this.taskNotifyPayload(repo, task, actorName), + payload: this.taskNotifyPayload(project, task, actorName), }); } } - // slugName 으로 저장소 로딩, 없으면 404 - private async getRepoOrThrow(slugName: string): Promise { - const repo = await this.repoRepo.findOne({ where: { slugName } }); - if (!repo) { + // projectId 으로 저장소 로딩, 없으면 404 + private async getProjectOrThrow(projectId: string): Promise { + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + }); + if (!project) { throw new NotFoundException(); } - return repo; + return project; } - // repo 내 순번(seq)으로 업무(+관계) 로딩, 없으면 404 - private async getTaskOrThrow(repoId: string, seq: number): Promise { + // project 내 순번(seq)으로 업무(+관계) 로딩, 없으면 404 + private async getTaskOrThrow(projectId: string, seq: number): Promise { const task = await this.taskRepo.findOne({ - where: { repo: { id: repoId }, seq }, + where: { project: { id: projectId }, seq }, relations: ['assignees', 'issuer', 'checklist', 'approvalRequestedBy'], }); if (!task) { @@ -1126,11 +1147,11 @@ export class TaskService { // admin 권한 확인 — 멤버십(+user) 반환(지시자 지정용). 아니면 403 private async assertAdmin( - repoId: string, + projectId: string, userId: string, - ): Promise { + ): Promise { const membership = await this.memberRepo.findOne({ - where: { repo: { id: repoId }, user: { id: userId } }, + where: { project: { id: projectId }, user: { id: userId } }, relations: ['user'], }); if (!membership || membership.roleType !== 'admin') { @@ -1141,11 +1162,11 @@ export class TaskService { // 저장소 멤버 확인 — 멤버십(+user) 반환. 아니면 403 private async assertMember( - repoId: string, + projectId: string, userId: string, - ): Promise { + ): Promise { const membership = await this.memberRepo.findOne({ - where: { repo: { id: repoId }, user: { id: userId } }, + where: { project: { id: projectId }, user: { id: userId } }, relations: ['user'], }); if (!membership) { @@ -1156,12 +1177,12 @@ export class TaskService { // 담당자 ID 목록 → User[] 로 변환하며 "저장소 멤버" 인지 검증 private async resolveMemberAssignees( - repoId: string, + projectId: string, assigneeIds: string[], - ): Promise { + ): Promise { const uniqueIds = [...new Set(assigneeIds)]; const memberships = await this.memberRepo.find({ - where: { repo: { id: repoId }, user: { id: In(uniqueIds) } }, + where: { project: { id: projectId }, user: { id: In(uniqueIds) } }, relations: ['user'], }); if (memberships.length !== uniqueIds.length) { @@ -1240,12 +1261,12 @@ export class TaskService { // 업무 상세 + 부속(댓글/첨부/승인) 조립 private async buildDetail( task: Task, - repo: Repo, + project: Project, ): Promise { const [comments, files, activities] = await Promise.all([ - this.loadComments(task.id, repo, task.seq), - this.loadFiles(task.id, repo, task.seq), - this.activity.listForTask(repo.id, task.seq), + this.loadComments(task.id, project, task.seq), + this.loadFiles(task.id, project, task.seq), + this.activity.listForTask(project.id, task.seq), ]); const checklist = (task.checklist ?? []) @@ -1267,8 +1288,8 @@ export class TaskService { return { id: task.seq, - repoId: repo.slugName, - repoName: repo.name, + projectId: project.id, + projectName: project.name, title: task.title, status: task.status, content: task.content ?? [], @@ -1288,7 +1309,7 @@ export class TaskService { // 최상위 댓글(+답글) 로딩 후 응답 매핑 private async loadComments( taskId: string, - repo: Repo, + project: Project, seq: number, ): Promise { const comments = await this.commentRepo.find({ @@ -1296,13 +1317,13 @@ export class TaskService { relations: ['author', 'attachment', 'replies', 'replies.author'], order: { createdAt: 'ASC' }, }); - return comments.map((c) => this.toCommentResponse(c, repo, seq)); + return comments.map((c) => this.toCommentResponse(c, project, seq)); } // 업무 첨부 로딩 후 응답 매핑 private async loadFiles( taskId: string, - repo: Repo, + project: Project, seq: number, ): Promise { const files = await this.attachmentRepo.find({ @@ -1310,13 +1331,13 @@ export class TaskService { relations: ['uploader'], order: { createdAt: 'ASC' }, }); - return files.map((f) => this.toAttachmentResponse(f, repo, seq)); + return files.map((f) => this.toAttachmentResponse(f, project, seq)); } // 첨부 엔티티 → 응답(다운로드 URL 포함) private toAttachmentResponse( att: Attachment, - repo: Repo, + project: Project, seq: number, ): AttachmentResponse { return { @@ -1324,7 +1345,7 @@ export class TaskService { name: att.name, size: att.size, kind: att.kind, - url: `/api/repos/${repo.slugName}/tasks/${seq}/files/${att.id}/download`, + url: `/api/projects/${project.id}/tasks/${seq}/files/${att.id}/download`, uploader: att.uploader ? UserService.toPublic(att.uploader) : null, createdAt: att.createdAt.toISOString(), }; @@ -1333,7 +1354,7 @@ export class TaskService { // 댓글 엔티티 → 응답(답글 시간순 정렬) private toCommentResponse( comment: Comment, - repo: Repo, + project: Project, seq: number, ): CommentResponse { const replies = (comment.replies ?? []) @@ -1350,7 +1371,7 @@ export class TaskService { author: comment.author ? UserService.toPublic(comment.author) : null, text: comment.text, file: comment.attachment - ? this.toAttachmentResponse(comment.attachment, repo, seq) + ? this.toAttachmentResponse(comment.attachment, project, seq) : null, createdAt: comment.createdAt.toISOString(), replies, diff --git a/docs/project-refactor-plan.md b/docs/project-refactor-plan.md index fe1eb88..675d5c9 100644 --- a/docs/project-refactor-plan.md +++ b/docs/project-refactor-plan.md @@ -61,4 +61,8 @@ Project (이름/설명/공개범위) ← 작업의 기본 단위( - [ ] 문서(api-contract.md)·메모리 갱신, 잔여 UI 정리, 런타임 점검. ## 5. 진행 로그 -- (작성됨) Phase 0: 계획 수립. 다음 = Phase 1 백엔드. +- Phase 0: 계획 수립 ✅ +- Phase 1-a: Project/ProjectMember 모듈 기반(엔티티·서비스·컨트롤러·"미분류" 부팅) ✅ (커밋 2f66cdd) +- Phase 1-b: 백엔드 이전 ✅ — Activity(project FK·listForProject)·Repo(project FK, RepoMember 폐지)·Task(project 종속, seq 프로젝트 내 순번, 권한=ProjectMember) 전환. RepoService 프로젝트 종속(`/projects/:projectId/repos`, 멤버·업무카운트·캐시 제거). Task/Activity 컨트롤러 `/projects/...`. RepoSync→"미분류". AiService 프로젝트 컨텍스트. member.service/controller·repo-member·repo-cache 삭제. 검증 build/lint 0·14 tests. +- **Phase 1 잔여(소소, 후속)**: ProjectResponse 카운트(repoCount/doneCount/totalCount 현재 0 stub) 집계 배선, ProjectMemberService 활동·알림 배선(TODO), TaskService.assigneeCounts→ProjectMemberService taskCount 연결. +- 다음 = Phase 2 프론트.