feat: 업무(Task) 모듈 4단계 — CRUD·상태머신·진행률 집계·역할 기반 상세 UI

백엔드
- Task/ChecklistItem 엔티티(저장소 내 순번 seq, jsonb content, assignees ManyToMany, checklist)
- GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId] + POST .../status
- 상태 머신(todo→prog→review→done, review→changes, changes→{review,prog}) + 전이별 역할 검증
- 담당자=저장소 멤버만 지정, 승인 완료(→done) 시 체크리스트 일괄 완료(영속)
- 저장소 doneCount/totalCount·멤버 taskCount 실집계(RepoModule→TaskModule)
- Task.repo FK 컬럼명 snake_case 고정(@JoinColumn), task.service 단위 테스트

프론트
- types/task·useTask·task.store, format.ts 라벨 파생(상태/마감/dday)
- RepoDetailPage 업무 목록, TaskCreatePage 생성/수정 겸용(편집 라우트)
- TaskDetailPage 역할 기반 액션 분기(지시자 승인/수정요청, 담당자 시작/승인요청)
- 액션 카드·상태 뱃지 디자인 시안 정렬, 작성폼/목록 UI 정리

문서: api-contract·integration-progress 4단계 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 18:12:11 +09:00
parent c01f8e535c
commit 875eddf659
27 changed files with 2466 additions and 342 deletions
+2
View File
@@ -12,6 +12,7 @@ import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { RepoModule } from './modules/repo/repo.module';
import { TaskModule } from './modules/task/task.module';
@Module({
imports: [
@@ -63,6 +64,7 @@ import { RepoModule } from './modules/repo/repo.module';
UserModule,
AuthModule,
RepoModule,
TaskModule,
],
controllers: [AppController],
providers: [
@@ -2,11 +2,12 @@ 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 { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
// 저장소/멤버 리포지토리 + UserService 간이 모킹
// 저장소/멤버 리포지토리 + UserService/TaskService 간이 모킹
function makeService() {
const repoRepo = { findOne: jest.fn() };
const memberRepo = {
@@ -17,12 +18,17 @@ function makeService() {
remove: jest.fn(),
};
const userService = { findByEmail: jest.fn() };
// 담당 업무 수 집계는 빈 Map(0건)으로 모킹
const taskService = {
assigneeCounts: jest.fn().mockResolvedValue(new Map()),
};
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,
);
return { svc, repoRepo, memberRepo, userService };
return { svc, repoRepo, memberRepo, userService, taskService };
}
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' };
@@ -30,17 +36,22 @@ const ADMIN = { roleType: 'admin' };
describe('MemberService', () => {
describe('list', () => {
it('멤버가 아니고 비공개면 접근 거부', async () => {
it('조직 모델: 멤버가 아니어도 인증 사용자면 목록 열람 가능', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// 목록에 현재 사용자(other-user)가 없음
// 목록에 현재 요청자가 없어도(비멤버) 거부하지 않는다
memberRepo.find.mockResolvedValue([
{ user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null } },
{
user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null },
email: 'a@b.c',
roleType: 'member',
isOwner: false,
},
]);
await expect(svc.list('q3-launch', 'other-user')).rejects.toBeInstanceOf(
ForbiddenException,
);
const result = await svc.list('q3-launch');
expect(result).toHaveLength(1);
expect(result[0].user.id).toBe('someone-else');
});
});
+11 -14
View File
@@ -8,6 +8,7 @@ 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 { Repo } from './entities/repo.entity';
import { RepoMember, type MemberRoleType } from './entities/repo-member.entity';
import { InviteMemberDto } from './dto/invite-member.dto';
@@ -17,7 +18,6 @@ import { UpdateMemberDto } from './dto/update-member.dto';
export interface RepoMemberResponse {
user: PublicUser;
email: string;
subRole: string | null;
taskCount: number; // TODO(4단계): 실제 담당 업무 수 집계
roleType: MemberRoleType;
owner: boolean; // 소유자 여부(isOwner)
@@ -32,21 +32,21 @@ export class MemberService {
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly taskService: TaskService,
) {}
// 멤버 목록 — 멤버이거나 공개 저장소만 조회 가능
async list(slugName: string, userId: string): Promise<RepoMemberResponse[]> {
// 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책).
// 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다.
async list(slugName: string): Promise<RepoMemberResponse[]> {
const repo = await this.getRepoOrThrow(slugName);
const members = await this.memberRepo.find({
where: { repo: { id: repo.id } },
relations: ['user'],
order: { createdAt: 'ASC' },
});
const isMember = members.some((m) => m.user.id === userId);
if (!isMember && repo.visibility !== 'public') {
throw new ForbiddenException();
}
return members.map((m) => this.toResponse(m));
// 사용자별 담당 업무 수 집계(taskCount)
const counts = await this.taskService.assigneeCounts(repo.id);
return members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0));
}
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
@@ -83,7 +83,6 @@ export class MemberService {
user,
roleType: dto.roleType,
isOwner: false,
subRole: dto.subRole?.trim() || null,
});
const saved = await this.memberRepo.save(member);
saved.user = user; // 관계 보장(응답 매핑용)
@@ -109,7 +108,6 @@ export class MemberService {
}
if (dto.roleType !== undefined) member.roleType = dto.roleType;
if (dto.subRole !== undefined) member.subRole = dto.subRole.trim() || null;
await this.memberRepo.save(member);
return this.toResponse(member);
@@ -170,13 +168,12 @@ export class MemberService {
}
}
// 엔티티 → 응답 매핑
private toResponse(member: RepoMember): RepoMemberResponse {
// 엔티티 → 응답 매핑 (taskCount 미제공 시 0)
private toResponse(member: RepoMember, taskCount = 0): RepoMemberResponse {
return {
user: UserService.toPublic(member.user),
email: member.user.email,
subRole: member.subRole,
taskCount: 0,
taskCount,
roleType: member.roleType,
owner: member.isOwner,
};
+2
View File
@@ -2,6 +2,7 @@ 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 { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { RepoService } from './repo.service';
@@ -16,6 +17,7 @@ import { RepoSyncService } from './repo-sync.service';
TypeOrmModule.forFeature([Repo, RepoMember]),
UserModule,
GiteaModule,
TaskModule,
],
controllers: [RepoController, MemberController],
providers: [RepoService, MemberService, RepoSyncService],
+26 -9
View File
@@ -14,6 +14,7 @@ import {
GiteaService,
type GiteaRepoResult,
} from '../gitea/gitea.service';
import { TaskService } from '../task/task.service';
import { Repo, type RepoVisibility } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { CreateRepoDto } from './dto/create-repo.dto';
@@ -56,6 +57,7 @@ export class RepoService {
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly gitea: GiteaService,
private readonly taskService: TaskService,
) {}
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보.
@@ -81,13 +83,15 @@ export class RepoService {
relations: ['members', 'members.user'],
order: { updatedAt: 'DESC' },
});
return repos.map((repo) => this.toResponse(repo));
// 저장소별 업무 완료/전체 수를 한 번에 집계해 진행률에 반영
const counts = await this.taskService.countsByRepo(repos.map((r) => r.id));
return repos.map((repo) => this.toResponse(repo, counts.get(repo.id)));
}
// 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드).
async findOne(slugName: string): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(slugName);
return this.toResponse(repo);
return this.toResponse(repo, await this.taskCountsOf(repo.id));
}
// 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록
@@ -150,10 +154,10 @@ export class RepoService {
user,
roleType: 'admin',
isOwner: true,
subRole: '리드',
});
await this.memberRepo.save(ownerMember);
// 갓 생성한 저장소는 업무가 없으므로 집계는 0/0
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
} catch (err) {
// Relay 저장 실패 시 방금 만든 Gitea 저장소를 정리(고아 방지, 베스트 에포트)
@@ -211,7 +215,10 @@ export class RepoService {
});
}
return this.toResponse(await this.getEntityOrThrow(slugName));
return this.toResponse(
await this.getEntityOrThrow(slugName),
await this.taskCountsOf(repo.id),
);
}
// 저장소 삭제 — 소유자만 가능
@@ -317,8 +324,18 @@ export class RepoService {
}
}
// 엔티티 → 응답 매핑
private toResponse(repo: Repo): RepoResponse {
// 단일 저장소의 업무 완료/전체 수 집계
private async taskCountsOf(
repoId: string,
): Promise<{ done: number; total: number } | undefined> {
return (await this.taskService.countsByRepo([repoId])).get(repoId);
}
// 엔티티 → 응답 매핑 (counts 미제공 시 0/0)
private toResponse(
repo: Repo,
counts?: { done: number; total: number },
): RepoResponse {
const members = (repo.members ?? []).map((m) =>
UserService.toPublic(m.user),
);
@@ -332,9 +349,9 @@ export class RepoService {
branch: repo.branch,
members,
memberCount: members.length,
// TODO(4단계): 업무 모듈 도입 시 실제 완료/전체 수 집계
doneCount: 0,
totalCount: 0,
// 업무 모듈 집계(없으면 0/0)
doneCount: counts?.done ?? 0,
totalCount: counts?.total ?? 0,
cloneUrl: repo.cloneUrl,
htmlUrl: repo.htmlUrl,
giteaMissing: repo.giteaMissing,
@@ -0,0 +1,22 @@
import { IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { TaskStatus } from '../entities/task.entity';
// 업무 상태 변경 요청 — 상태 머신 전이 규칙은 서비스에서 검증
const TASK_STATUSES: TaskStatus[] = [
'todo',
'prog',
'review',
'done',
'changes',
];
export class ChangeStatusDto {
@ApiProperty({
description: '변경할 상태',
enum: TASK_STATUSES,
example: 'prog',
})
@IsIn(TASK_STATUSES, { message: '올바른 업무 상태가 아닙니다.' })
status!: TaskStatus;
}
@@ -0,0 +1,78 @@
import {
ArrayNotEmpty,
IsArray,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
// 체크리스트 입력 항목 (생성 시 함께 등록)
export class ChecklistInputDto {
@ApiProperty({
description: '체크리스트 항목 내용',
example: '가로형 배너 디자인',
})
@IsString()
@IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' })
@MaxLength(200)
text!: string;
}
// 업무 생성 요청
export class CreateTaskDto {
@ApiProperty({
description: '업무 제목',
example: '메인 배너 이미지 2종 디자인',
})
@IsString()
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
@MaxLength(200)
title!: string;
@ApiProperty({
description: '본문 문단 배열(선택)',
type: [String],
required: false,
example: ['9월 신제품 캠페인 메인 배너를 제작합니다.'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
content?: string[];
@ApiProperty({
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
type: [String],
example: ['a1b2c3d4-...'],
})
@IsArray()
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
@IsUUID('4', { each: true })
assigneeIds!: string[];
@ApiProperty({
description: '마감 기한(ISO8601, 선택)',
required: false,
example: '2026-06-18T00:00:00Z',
})
@IsOptional()
@IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' })
dueDate?: string;
@ApiProperty({
description: '체크리스트(선택)',
type: [ChecklistInputDto],
required: false,
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ChecklistInputDto)
checklist?: ChecklistInputDto[];
}
@@ -0,0 +1,51 @@
import {
ArrayNotEmpty,
IsArray,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 업무 수정 요청 — 제공된 필드만 갱신(체크리스트/댓글/파일은 5단계 별도 엔드포인트)
export class UpdateTaskDto {
@ApiProperty({ description: '업무 제목', required: false })
@IsOptional()
@IsString()
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
@MaxLength(200)
title?: string;
@ApiProperty({
description: '본문 문단 배열',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@IsString({ each: true })
content?: string[];
@ApiProperty({
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
@IsUUID('4', { each: true })
assigneeIds?: string[];
@ApiProperty({
description: '마감 기한(ISO8601). null 이면 마감 해제',
required: false,
nullable: true,
})
@IsOptional()
@IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' })
dueDate?: string | null;
}
@@ -0,0 +1,36 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { Task } from './task.entity';
// 업무 체크리스트 항목
@Entity('checklist_items')
export class ChecklistItem {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 업무 (업무 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피
@ManyToOne(() => Task, (task) => task.checklist, { onDelete: 'CASCADE' })
task!: Relation<Task>;
// 항목 내용
@Column()
text!: string;
// 완료 여부
@Column({ default: false })
done!: boolean;
// 표시 순서(생성 순)
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,81 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Repo } from '../../repo/entities/repo.entity';
import { ChecklistItem } from './checklist-item.entity';
// 업무 상태 (상태 머신: todo → prog → review → done/changes, changes → review)
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes';
// 업무 엔티티 — 저장소에 종속, 저장소 내 순번(seq)으로 식별(#9)
@Entity('tasks')
@Unique(['repo', 'seq'])
export class Task {
// 내부 식별자 (UUID)
@PrimaryGeneratedColumn('uuid')
id!: string;
// 저장소 내 표시 순번(#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>;
// 업무 제목
@Column()
title!: string;
// 본문 문단 배열(멘션은 @로 시작) — jsonb 로 보관
@Column({ type: 'jsonb', default: () => "'[]'" })
content!: string[];
// 진행 상태
@Column({ type: 'varchar', default: 'todo' })
status!: TaskStatus;
// 마감 기한(선택)
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
dueDate!: Date | null;
// 지시자(업무 생성자) — 사용자 삭제 시에도 업무는 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
issuer!: User | null;
// 담당자 목록 (저장소 멤버만 지정 가능 — 서비스에서 검증)
@ManyToMany(() => User)
@JoinTable({
name: 'task_assignees',
joinColumn: { name: 'task_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
})
assignees!: User[];
// 체크리스트 항목
@OneToMany(() => ChecklistItem, (item) => item.task, { cascade: true })
checklist!: Relation<ChecklistItem>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
+120
View File
@@ -0,0 +1,120 @@
import {
Body,
Controller,
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 {
TaskService,
type RepoTaskResponse,
type TaskDetailResponse,
} from './task.service';
import type { TaskStatus } from './entities/task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { ChangeStatusDto } from './dto/change-status.dto';
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번)
@ApiTags('Task')
@Controller('repos/:repoId/tasks')
@UseGuards(JwtAuthGuard)
export class TaskController {
constructor(private readonly taskService: TaskService) {}
@Get()
@ApiOperation({ summary: '업무 목록 (상태/담당자 필터)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(
@Param('repoId') repoId: string,
@Query('status') status?: TaskStatus,
@Query('assignee') assignee?: string,
): Promise<RepoTaskResponse[]> {
return this.taskService.list(repoId, { status, assignee });
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '업무 생성 (admin)' })
@ApiResponse({ status: 201, description: '생성 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
@ApiResponse({
status: 400,
description: '담당자가 저장소 멤버가 아님 (BIZ_001)',
})
create(
@Param('repoId') repoId: string,
@Body() dto: CreateTaskDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.create(repoId, user.id, dto);
}
@Get(':taskId')
@ApiOperation({ summary: '업무 상세' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '업무를 찾을 수 없음 (RES_001)' })
findOne(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
): Promise<TaskDetailResponse> {
return this.taskService.findOne(repoId, taskId);
}
@Patch(':taskId')
@ApiOperation({ summary: '업무 수정 (admin)' })
@ApiResponse({ status: 200, description: '수정 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
update(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: UpdateTaskDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.update(repoId, user.id, taskId, dto);
}
@Delete(':taskId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '업무 삭제 (admin)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
async remove(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.taskService.remove(repoId, user.id, taskId);
return null;
}
@Post(':taskId/status')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '업무 상태 변경 (상태 머신)' })
@ApiResponse({ status: 200, description: '변경 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
@ApiResponse({
status: 409,
description: '허용되지 않는 상태 전이 (BIZ_001)',
})
changeStatus(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: ChangeStatusDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.changeStatus(repoId, user.id, taskId, dto.status);
}
}
+17
View File
@@ -0,0 +1,17 @@
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 { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { TaskService } from './task.service';
import { TaskController } from './task.controller';
// 업무 모듈 (업무 CRUD + 상태 전이)
@Module({
imports: [TypeOrmModule.forFeature([Task, ChecklistItem, Repo, RepoMember])],
controllers: [TaskController],
providers: [TaskService],
exports: [TaskService],
})
export class TaskModule {}
@@ -0,0 +1,132 @@
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 { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
// 리포지토리 간이 모킹
function makeService() {
const taskRepo = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
};
const checklistRepo = { create: jest.fn(), save: jest.fn() };
const repoRepo = { findOne: jest.fn() };
const memberRepo = { findOne: jest.fn(), find: jest.fn() };
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>,
);
return { svc, taskRepo, checklistRepo, repoRepo, memberRepo };
}
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', name: '캠페인' };
describe('TaskService', () => {
describe('changeStatus — 상태 머신', () => {
it('허용되지 않는 전이(todo→done)는 BusinessException', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({ roleType: 'admin' });
taskRepo.findOne.mockResolvedValue({
seq: 1,
status: 'todo',
issuer: { id: 'admin' },
assignees: [],
checklist: [],
});
await expect(
svc.changeStatus('q3-launch', 'admin', 1, 'done'),
).rejects.toBeInstanceOf(BusinessException);
});
it('승인(review→done)은 지시자/admin 이 아니면 Forbidden', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// 요청자는 일반 멤버(담당자)
memberRepo.findOne.mockResolvedValue({ roleType: 'member' });
taskRepo.findOne.mockResolvedValue({
seq: 9,
status: 'review',
issuer: { id: 'someone-else' },
assignees: [{ id: 'assignee' }],
checklist: [],
});
await expect(
svc.changeStatus('q3-launch', 'assignee', 9, 'done'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('비멤버는 상태 변경 불가(Forbidden)', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue(null); // 멤버십 없음
await expect(
svc.changeStatus('q3-launch', 'outsider', 1, 'prog'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('승인(review→done) 시 체크리스트를 모두 완료 처리한다', async () => {
const { svc, taskRepo, checklistRepo, repoRepo, memberRepo } =
makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({ roleType: 'admin' }); // 지시자/admin
const task = {
seq: 9,
status: 'review',
title: '배너',
content: [],
issuer: { id: 'admin', email: 'a@b.c', name: 'A', role: null },
assignees: [],
checklist: [
{ id: 'c1', text: '가로형', done: true, sortOrder: 0 },
{ id: 'c2', text: '세로형', done: false, sortOrder: 1 },
{ id: 'c3', text: '모바일', done: false, sortOrder: 2 },
],
dueDate: null,
createdAt: new Date('2026-06-08T00:00:00Z'),
};
taskRepo.findOne.mockResolvedValue(task);
taskRepo.save.mockResolvedValue(task);
checklistRepo.save.mockResolvedValue(task.checklist);
const result = await svc.changeStatus('q3-launch', 'admin', 9, 'done');
expect(checklistRepo.save).toHaveBeenCalledTimes(1);
expect(task.checklist.every((c) => c.done)).toBe(true);
expect(result.checklist.every((c) => c.done)).toBe(true);
});
});
describe('create — 담당자 멤버 검증', () => {
it('담당자가 저장소 멤버가 아니면 BusinessException', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// assertAdmin 통과
memberRepo.findOne.mockResolvedValue({
roleType: 'admin',
user: { id: 'admin' },
});
// 요청한 담당자 2명 중 매칭 멤버십 1건만 → 비멤버 포함
memberRepo.find.mockResolvedValue([{ user: { id: 'u1' } }]);
await expect(
svc.create('q3-launch', 'admin', {
title: '배너 디자인',
assigneeIds: ['u1', 'u2'],
}),
).rejects.toBeInstanceOf(BusinessException);
});
});
});
+404
View File
@@ -0,0 +1,404 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, 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 { Task, type TaskStatus } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
// 목록 행 응답 — 라벨류는 프론트가 파생(dueLabel/dday/statusLabel 등)
export interface RepoTaskResponse {
id: number; // 저장소 내 순번(seq)
title: string;
status: TaskStatus;
dueDate: string | null;
checklist: [number, number]; // [완료, 전체]
commentCount: number; // 5단계 전까지 0
assignees: PublicUser[];
}
// 체크리스트 항목 응답
export interface ChecklistItemResponse {
id: string;
text: string;
done: boolean;
}
// 업무 상세 응답 — comments/activities/files/approval 은 5단계에서 채운다(현재 빈 값)
export interface TaskDetailResponse {
id: number;
repoId: string; // slugName
repoName: string;
title: string;
status: TaskStatus;
content: string[];
checklist: ChecklistItemResponse[];
assignees: PublicUser[];
issuer: PublicUser | null;
dueDate: string | null;
createdAt: string;
comments: never[];
activities: never[];
files: never[];
}
// 상태 머신 전이 규칙(허용 목적지)
const TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
todo: ['prog'],
prog: ['review'],
review: ['done', 'changes'],
changes: ['review', 'prog'],
done: [],
};
// 상태 한국어 라벨(에러 문구용)
const STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
prog: '진행 중',
review: '승인 대기',
done: '완료',
changes: '수정 요청',
};
// 업무 비즈니스 로직
@Injectable()
export class TaskService {
constructor(
@InjectRepository(Task)
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>,
) {}
// 업무 목록 — 상태/담당자 필터(선택). seq 내림차순(최신 우선)
async list(
slugName: string,
filters: { status?: TaskStatus; assignee?: string },
): Promise<RepoTaskResponse[]> {
const repo = await this.getRepoOrThrow(slugName);
const qb = this.taskRepo
.createQueryBuilder('task')
.leftJoinAndSelect('task.assignees', 'assignee')
.leftJoinAndSelect('task.checklist', 'checklist')
.where('task.repo = :repoId', { repoId: repo.id })
.orderBy('task.seq', 'DESC')
.addOrderBy('checklist.sort_order', 'ASC');
if (filters.status) {
qb.andWhere('task.status = :status', { status: filters.status });
}
// 담당자 필터: 해당 사용자가 담당인 업무만 (서브쿼리로 task id 추림)
if (filters.assignee) {
qb.andWhere(
'task.id IN ' +
qb
.subQuery()
.select('ta.task_id')
.from('task_assignees', 'ta')
.where('ta.user_id = :assignee')
.getQuery(),
{ assignee: filters.assignee },
);
}
const tasks = await qb.getMany();
return tasks.map((t) => this.toListResponse(t));
}
// 업무 단건 상세 — 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.toDetailResponse(task, repo);
}
// 업무 생성 — admin(지시자) 권한. 담당자는 저장소 멤버만 지정 가능
async create(
slugName: string,
actingUserId: string,
dto: CreateTaskDto,
): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
const issuer = await this.assertAdmin(repo.id, actingUserId);
const assignees = await this.resolveMemberAssignees(
repo.id,
dto.assigneeIds,
);
// 저장소 내 다음 순번 채번
const last = await this.taskRepo.findOne({
where: { repo: { id: repo.id } },
order: { seq: 'DESC' },
});
const seq = (last?.seq ?? 0) + 1;
const task = this.taskRepo.create({
seq,
repo,
title: dto.title,
content: dto.content ?? [],
status: 'todo',
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
issuer: issuer.user,
assignees,
checklist: (dto.checklist ?? []).map((c, idx) =>
this.checklistRepo.create({
text: c.text,
done: false,
sortOrder: idx,
}),
),
});
const saved = await this.taskRepo.save(task);
return this.toDetailResponse(
await this.getTaskOrThrow(repo.id, saved.seq),
repo,
);
}
// 업무 수정 — admin 권한. 제공된 필드만 갱신
async update(
slugName: string,
actingUserId: string,
seq: number,
dto: UpdateTaskDto,
): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertAdmin(repo.id, actingUserId);
const task = await this.getTaskOrThrow(repo.id, seq);
if (dto.title !== undefined) task.title = dto.title;
if (dto.content !== undefined) task.content = dto.content;
if (dto.dueDate !== undefined) {
task.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
}
if (dto.assigneeIds !== undefined) {
task.assignees = await this.resolveMemberAssignees(
repo.id,
dto.assigneeIds,
);
}
await this.taskRepo.save(task);
return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo);
}
// 업무 삭제 — admin 권한
async remove(
slugName: string,
actingUserId: string,
seq: number,
): Promise<void> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertAdmin(repo.id, actingUserId);
const task = await this.getTaskOrThrow(repo.id, seq);
await this.taskRepo.remove(task);
}
// 상태 변경 — 상태 머신 전이 규칙 + 역할 검증
async changeStatus(
slugName: string,
actingUserId: string,
seq: number,
next: TaskStatus,
): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
const membership = await this.memberRepo.findOne({
where: { repo: { id: repo.id }, user: { id: actingUserId } },
});
if (!membership) {
throw new ForbiddenException();
}
const task = await this.getTaskOrThrow(repo.id, seq);
const from = task.status;
if (from === next) {
return this.toDetailResponse(task, repo);
}
const allowed = TRANSITIONS[from] ?? [];
if (!allowed.includes(next)) {
throw new BusinessException(
'BIZ_001',
`'${STATUS_LABEL[from]}' 상태에서 '${STATUS_LABEL[next]}'(으)로 변경할 수 없습니다.`,
HttpStatus.CONFLICT,
);
}
const isAdmin = membership.roleType === 'admin';
const isIssuer = task.issuer?.id === actingUserId;
const isAssignee = task.assignees.some((a) => a.id === actingUserId);
// 승인/수정요청(review 출발)은 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
const permitted =
from === 'review'
? isAdmin || isIssuer
: isAdmin || isIssuer || isAssignee;
if (!permitted) {
throw new ForbiddenException();
}
task.status = next;
await this.taskRepo.save(task);
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리(부수효과로 영속)
if (next === 'done' && task.checklist?.length) {
for (const item of task.checklist) item.done = true;
await this.checklistRepo.save(task.checklist);
}
return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo);
}
// --- 집계(다른 모듈에서 사용) ---
// 저장소별 업무 완료/전체 수 — RepoService 진행률 집계용
async countsByRepo(
repoIds: string[],
): Promise<Map<string, { done: number; total: number }>> {
const result = new Map<string, { done: number; total: number }>();
if (repoIds.length === 0) return result;
const rows = await this.taskRepo
.createQueryBuilder('task')
.select('task.repo_id', 'repoId')
.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 }>();
for (const r of rows) {
result.set(r.repoId, {
done: Number(r.done),
total: Number(r.total),
});
}
return result;
}
// 저장소 내 사용자별 담당 업무 수 — MemberService taskCount 집계용
async assigneeCounts(repoId: 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 })
.groupBy('ta.user_id')
.getRawMany<{ userId: string; count: string }>();
const map = new Map<string, number>();
for (const r of rows) map.set(r.userId, Number(r.count));
return map;
}
// --- 내부 헬퍼 ---
// slugName 으로 저장소 로딩, 없으면 404
private async getRepoOrThrow(slugName: string): Promise<Repo> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
throw new NotFoundException();
}
return repo;
}
// repo 내 순번(seq)으로 업무(+관계) 로딩, 없으면 404
private async getTaskOrThrow(repoId: string, seq: number): Promise<Task> {
const task = await this.taskRepo.findOne({
where: { repo: { id: repoId }, seq },
relations: ['assignees', 'issuer', 'checklist'],
});
if (!task) {
throw new NotFoundException();
}
// 체크리스트 표시 순서 보정
task.checklist?.sort((a, b) => a.sortOrder - b.sortOrder);
return task;
}
// admin 권한 확인 — 멤버십(+user) 반환(지시자 지정용). 아니면 403
private async assertAdmin(
repoId: string,
userId: string,
): Promise<RepoMember> {
const membership = await this.memberRepo.findOne({
where: { repo: { id: repoId }, user: { id: userId } },
relations: ['user'],
});
if (!membership || membership.roleType !== 'admin') {
throw new ForbiddenException();
}
return membership;
}
// 담당자 ID 목록 → User[] 로 변환하며 "저장소 멤버" 인지 검증
private async resolveMemberAssignees(
repoId: string,
assigneeIds: string[],
): Promise<RepoMember['user'][]> {
const uniqueIds = [...new Set(assigneeIds)];
const memberships = await this.memberRepo.find({
where: { repo: { id: repoId }, user: { id: In(uniqueIds) } },
relations: ['user'],
});
if (memberships.length !== uniqueIds.length) {
throw new BusinessException(
'BIZ_001',
'담당자는 저장소 멤버만 지정할 수 있습니다.',
HttpStatus.BAD_REQUEST,
);
}
return memberships.map((m) => m.user);
}
// 엔티티 → 목록 행 응답
private toListResponse(task: Task): RepoTaskResponse {
const checklist = task.checklist ?? [];
const done = checklist.filter((c) => c.done).length;
return {
id: task.seq,
title: task.title,
status: task.status,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
checklist: [done, checklist.length],
commentCount: 0,
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
};
}
// 엔티티 → 상세 응답
private toDetailResponse(task: Task, repo: Repo): TaskDetailResponse {
const checklist = (task.checklist ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((c) => ({ id: c.id, text: c.text, done: c.done }));
return {
id: task.seq,
repoId: repo.slugName,
repoName: repo.name,
title: task.title,
status: task.status,
content: task.content ?? [],
checklist,
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
createdAt: task.createdAt.toISOString(),
comments: [],
activities: [],
files: [],
};
}
}