refactor: 백엔드를 프로젝트 우선 구조로 이전 (업무·멤버·활동·저장소)

저장소=프로젝트 1:1 구조를 프로젝트 우선 + 프로젝트 1:N 저장소로 이전(2단계).

- Activity: repo FK → project FK, listForProject, /projects/:projectId/activity
- Repo: project FK 추가, RepoMember 폐지(멤버십은 프로젝트), /projects/:projectId/repos 로 이동
- Task: project 종속(seq=프로젝트 내 순번 @Unique[project,seq]), 권한=ProjectMember, /projects/:projectId/tasks
- RepoService: 프로젝트 종속(멤버·업무카운트·캐시 제거), 권한은 ProjectService 위임
- RepoSyncService: Gitea import → "미분류" 시스템 프로젝트로 연결, import-admin 로직 제거
- AiService: 컨텍스트·추천을 프로젝트 기준으로
- 삭제: repo-member 엔티티/멤버 서비스·컨트롤러/repo-cache
- 알림 payload repoId/repoName → projectId/projectName

검증: build/lint 0, 14 tests. 잔여(후속): ProjectResponse 카운트 집계·멤버 활동/알림 배선.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 19:03:09 +09:00
parent 2f66cdda8a
commit 6ba148314c
26 changed files with 487 additions and 1267 deletions
@@ -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<ActivityResponse[]> {
return this.activityService.listForRepo(repoId);
list(
@Param('projectId', ParseUUIDPipe) projectId: string,
): Promise<ActivityResponse[]> {
return this.activityService.listForProject(projectId);
}
}
@@ -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],
@@ -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<string, unknown>;
@@ -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<Activity>,
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
) {}
// 활동 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
async record(params: RecordActivityParams): Promise<void> {
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<ActivityResponse[]> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
throw new NotFoundException();
}
// 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
async listForProject(projectId: string): Promise<ActivityResponse[]> {
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<ActivityResponse[]> {
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<ActivityResponse[]> {
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 {
@@ -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<Repo>;
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 저장소 활동이면 null
// (Task FK 대신 seq 를 저장해 업무 삭제와 무관하게 활동을 유지)
@@ -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,
});
+3 -3
View File
@@ -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],
+18 -19
View File
@@ -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<AiConversation>,
@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');
@@ -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()
@@ -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;
}
@@ -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;
}
@@ -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<Repo>;
// 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩
@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;
}
@@ -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<Project>;
// 라우팅/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<RepoMember>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -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<PaginatedResult<RepoMemberResponse>> {
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<RepoMemberResponse> {
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<RepoMemberResponse> {
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<null> {
await this.memberService.remove(repoId, user.id, userId);
return 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<Repo>,
memberRepo as unknown as Repository<RepoMember>,
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();
});
});
});
-282
View File
@@ -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<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
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<PaginatedResult<RepoMemberResponse>> {
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<RepoMemberResponse> {
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<RepoMemberResponse> {
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<void> {
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<string> {
const actor = await this.userService.findById(userId);
return actor?.name ?? '';
}
// slugName 으로 저장소 로딩, 없으면 404
private async getRepoOrThrow(slugName: string): Promise<Repo> {
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<RepoMember> {
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<void> {
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,
};
}
}
@@ -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<number> {
try {
const v = await this.cache.get<number>(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<void> {
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<PaginatedResult<RepoResponse>>,
): Promise<PaginatedResult<RepoResponse>> {
const ver = await this.version();
const key = `repos:list:v${ver}:p${page}:s${size}`;
try {
const hit = await this.cache.get<PaginatedResult<RepoResponse>>(key);
if (hit) return hit;
} catch {
// 캐시 장애 — 아래 DB 조회로 폴백
}
const value = await compute();
try {
await this.cache.set(key, value, RepoCacheService.LIST_TTL);
} catch {
// 적재 실패 무시
}
return value;
}
}
+11 -73
View File
@@ -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<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly gitea: GiteaService,
private readonly repoCache: RepoCacheService,
config: ConfigService,
) {
this.importAdminEmail = (
config.get<string>('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<number, Repo>();
@@ -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<number> {
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<void> {
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 에 "표시제목 — 설명" 으로 합쳐 저장하므로,
+18 -12
View File
@@ -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<PaginatedResult<RepoResponse>> {
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<RepoResponse> {
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<RepoResponse> {
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<RepoResponse> {
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<null> {
await this.repoService.remove(repoId, user.id);
await this.repoService.remove(projectId, repoId, user.id);
return null;
}
}
+8 -15
View File
@@ -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 {}
+114 -198
View File
@@ -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<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
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<PaginatedResult<RepoResponse>> {
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<RepoResponse> {
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<RepoResponse> {
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<RepoResponse> {
// 저장소 생성 — 프로젝트 admin 권한. Gitea 조직에 실제 repo 생성 후 프로젝트에 연동.
async create(
projectId: string,
dto: CreateRepoDto,
userId: string,
): Promise<RepoResponse> {
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<RepoResponse> {
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<void> {
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<void> {
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<Repo> {
// 프로젝트 내 slugName 으로 저장소 로딩, 없으면 404
private async getEntityOrThrow(
projectId: string,
slugName: string,
): Promise<Repo> {
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<void> {
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,
@@ -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<Repo>;
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피.
// FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.project_id 와 일치)
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 업무 제목
@Column()
@@ -60,7 +60,7 @@ export class Task {
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
issuer!: User | null;
// 담당자 목록 (저장소 멤버만 지정 가능 — 서비스에서 검증)
// 담당자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증)
@ManyToMany(() => User)
@JoinTable({
name: 'task_assignees',
+50 -30
View File
@@ -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<RepoTaskListResult> {
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<TaskDetailResponse> {
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<TaskDetailResponse> {
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<TaskDetailResponse> {
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<null> {
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<TaskDetailResponse> {
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<CommentResponse> {
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<CommentReplyResponse> {
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<null> {
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<void> {
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<TaskDetailResponse> {
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<TaskDetailResponse> {
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<TaskDetailResponse> {
return this.taskService.requestChanges(repoId, user.id, taskId, dto.note);
return this.taskService.requestChanges(
projectId,
user.id,
taskId,
dto.note,
);
}
}
+5 -5
View File
@@ -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,
@@ -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<Task>,
checklistRepo as unknown as Repository<ChecklistItem>,
repoRepo as unknown as Repository<Repo>,
memberRepo as unknown as Repository<RepoMember>,
repoRepo as unknown as Repository<Project>,
memberRepo as unknown as Repository<ProjectMember>,
commentRepo as unknown as Repository<Comment>,
attachmentRepo as unknown as Repository<Attachment>,
activity as unknown as ActivityService,
+180 -159
View File
@@ -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<Task>,
@InjectRepository(ChecklistItem)
private readonly checklistRepo: Repository<ChecklistItem>,
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>,
@InjectRepository(ProjectMember)
private readonly memberRepo: Repository<ProjectMember>,
@InjectRepository(Comment)
private readonly commentRepo: Repository<Comment>,
@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<string, unknown> {
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<RepoTaskListResult> {
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<TaskSegmentCounts> {
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<TaskDetailResponse> {
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<TaskDetailResponse> {
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<TaskDetailResponse> {
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<TaskDetailResponse> {
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<void> {
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<TaskDetailResponse> {
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<CommentResponse> {
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<CommentReplyResponse> {
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<AttachmentResponse> {
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<void> {
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<TaskDetailResponse> {
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<TaskDetailResponse> {
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<TaskDetailResponse> {
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<Map<string, { done: number; total: number }>> {
const result = new Map<string, { done: number; total: number }>();
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<Map<string, number>> {
async assigneeCounts(projectId: string): Promise<Map<string, number>> {
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<string, number>();
@@ -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<void> {
@@ -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<Repo> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
// projectId 으로 저장소 로딩, 없으면 404
private async getProjectOrThrow(projectId: string): Promise<Project> {
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<Task> {
// project 내 순번(seq)으로 업무(+관계) 로딩, 없으면 404
private async getTaskOrThrow(projectId: string, seq: number): Promise<Task> {
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<RepoMember> {
): Promise<ProjectMember> {
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<RepoMember> {
): Promise<ProjectMember> {
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<RepoMember['user'][]> {
): Promise<ProjectMember['user'][]> {
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<TaskDetailResponse> {
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<CommentResponse[]> {
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<AttachmentResponse[]> {
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,
+5 -1
View File
@@ -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 프론트.