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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user