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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 에 "표시제목 — 설명" 으로 합쳐 저장하므로,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user