feat: 업무 상세 부속·활동 피드·내 업무 집계 + 페이지네이션 (5~8단계)

- 5단계(업무 상세 부속): 체크리스트(상세 읽기전용/수정폼 추가·삭제)·댓글/답글·파일 첨부(서버 로컬 업로드 폴더)·승인 워크플로우(요청/승인/수정요청 노트)
- 6단계(활동 피드): 저장소/멤버/업무 행위를 이벤트로 자동 적재, 저장소 활동 피드·업무 활동 탭 실연동(프론트가 type+payload로 문구 조립)
- 7단계(내 업무): 담당/지시 저장소 횡단 집계, MyTasksPage 실연동, TaskAssigneePage(/view) 제거
- 8단계(진행): 목록 페이지네이션 통일 — 백엔드 전체(공통 유틸 + 5개 목록, 저장소 업무는 세그먼트/검색/카운트) + 프론트 더보기(저장소 목록·내 업무 완료, 멤버·저장소 업무 대기)
- UI 보강: 업무 목록 댓글/첨부 분리표기, 첨부 배지 색상·라벨 통일, 완료 업무 D-Day 배지, 활동 이벤트 점검·보강(업무 수정/삭제·답글·첨부 삭제)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 18:13:17 +09:00
parent 875eddf659
commit a056d7f55a
51 changed files with 3437 additions and 1732 deletions
@@ -0,0 +1,20 @@
import { Controller, Get, Param, 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')
@UseGuards(JwtAuthGuard)
export class ActivityController {
constructor(private readonly activityService: ActivityService) {}
@Get()
@ApiOperation({ summary: '저장소 활동 피드 (최신순)' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(@Param('repoId') repoId: string): Promise<ActivityResponse[]> {
return this.activityService.listForRepo(repoId);
}
}
@@ -0,0 +1,16 @@
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 엔티티를 참조하지 않으므로 순환 의존 없음)
@Module({
imports: [TypeOrmModule.forFeature([Activity, Repo])],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
})
export class ActivityModule {}
@@ -0,0 +1,113 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { Repo } from '../repo/entities/repo.entity';
import { Activity, type ActivityType } from './entities/activity.entity';
// 활동 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
export interface ActivityResponse {
id: string;
type: ActivityType;
actor: PublicUser | null;
taskSeq: number | null;
payload: Record<string, unknown>;
createdAt: string;
}
// 활동 적재 파라미터
export interface RecordActivityParams {
repoId: string; // Repo uuid
actorId: string | null;
type: ActivityType;
payload?: Record<string, unknown>;
taskSeq?: number | null;
}
// 활동 피드 비즈니스 로직 — 다른 도메인 서비스가 record() 로 이벤트를 적재하고,
// 저장소/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
@Injectable()
export class ActivityService {
private readonly logger = new Logger(ActivityService.name);
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,
actor: params.actorId ? { id: params.actorId } : null,
type: params.type,
payload: params.payload ?? {},
taskSeq: params.taskSeq ?? null,
});
await this.activityRepo.save(activity);
} catch (err) {
this.logger.error(
`활동 적재 실패: ${params.type}`,
err instanceof Error ? err.stack : String(err),
);
}
}
// 저장소 활동 피드 — slugName 으로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
async listForRepo(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 } },
relations: ['actor'],
order: { createdAt: 'DESC' },
take: 200,
});
return rows.map((a) => this.toResponse(a));
}
// 업무 활동 — repoId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
async listForTask(
repoId: string,
taskSeq: number,
): Promise<ActivityResponse[]> {
const rows = await this.activityRepo.find({
where: { repo: { id: repoId }, 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 {
id: a.id,
type: a.type,
actor: a.actor ? UserService.toPublic(a.actor) : null,
taskSeq: a.taskSeq,
payload: a.payload ?? {},
createdAt: a.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,70 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Repo } from '../../repo/entities/repo.entity';
// 활동 이벤트 타입 — 2~5단계 행위에서 자동 적재(쓰기 API 없음, 읽기 전용)
export type ActivityType =
// 저장소(설정)
| 'repo.created'
| 'repo.renamed'
| 'repo.visibility_changed'
// 멤버
| 'member.invited'
| 'member.role_changed'
| 'member.removed'
// 업무
| 'task.created'
| 'task.status_changed'
| 'task.comment_added'
| 'task.file_attached'
| 'task.assignees_changed'
| 'task.due_changed'
| 'task.edited'
| 'task.file_removed'
| 'task.removed';
// 활동 이벤트 — 행위 타입 + 행위자 + 대상(payload)만 저장하고
// 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙).
@Entity('activities')
export class Activity {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 저장소 (저장소 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피
@Index()
@ManyToOne(() => Repo, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'repo_id' })
repo!: Relation<Repo>;
// 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 저장소 활동이면 null
// (Task FK 대신 seq 를 저장해 업무 삭제와 무관하게 활동을 유지)
@Index()
@Column({ name: 'task_seq', type: 'int', nullable: true })
taskSeq!: number | null;
// 행위자 (사용자 삭제 시에도 활동은 유지 — SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'actor_id' })
actor!: User | null;
// 이벤트 타입
@Column({ type: 'varchar' })
type!: ActivityType;
// 대상 정보(자유형) — 예: taskSeq/taskTitle, statusFrom/statusTo, targetName, fileName, visibility, newName, roleType
@Column({ type: 'jsonb', default: () => "'{}'" })
payload!: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
+11 -3
View File
@@ -1,13 +1,16 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@@ -15,6 +18,7 @@ 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';
@@ -26,11 +30,15 @@ export class MemberController {
constructor(private readonly memberService: MemberService) {}
@Get()
@ApiOperation({ summary: '저장소 멤버 목록 (인증 사용자 열람 가능)' })
@ApiOperation({ summary: '저장소 멤버 목록 (페이지네이션)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(@Param('repoId') repoId: string): Promise<RepoMemberResponse[]> {
return this.memberService.list(repoId);
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()
+19 -10
View File
@@ -3,6 +3,7 @@ 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 { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
@@ -13,6 +14,7 @@ function makeService() {
const memberRepo = {
findOne: jest.fn(),
find: jest.fn(),
findAndCount: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
@@ -22,13 +24,16 @@ function makeService() {
const taskService = {
assigneeCounts: jest.fn().mockResolvedValue(new Map()),
};
// 활동 적재는 no-op 모킹
const activity = { record: 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,
);
return { svc, repoRepo, memberRepo, userService, taskService };
return { svc, repoRepo, memberRepo, userService, taskService, activity };
}
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' };
@@ -40,18 +45,22 @@ describe('MemberService', () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// 목록에 현재 요청자가 없어도(비멤버) 거부하지 않는다
memberRepo.find.mockResolvedValue([
{
user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null },
email: 'a@b.c',
roleType: 'member',
isOwner: false,
},
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).toHaveLength(1);
expect(result[0].user.id).toBe('someone-else');
expect(result.total).toBe(1);
expect(result.items).toHaveLength(1);
expect(result.items[0].user.id).toBe('someone-else');
});
});
+49 -4
View File
@@ -9,6 +9,12 @@ 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 {
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';
@@ -33,20 +39,32 @@ export class MemberService {
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly taskService: TaskService,
private readonly activity: ActivityService,
) {}
// 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책).
// 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책). 페이지네이션.
// 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다.
async list(slugName: string): Promise<RepoMemberResponse[]> {
async list(
slugName: string,
page?: number,
size?: number,
): Promise<PaginatedResult<RepoMemberResponse>> {
const repo = await this.getRepoOrThrow(slugName);
const members = await this.memberRepo.find({
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 members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0));
return paginated(
members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0)),
total,
pg,
);
}
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
@@ -86,6 +104,15 @@ export class MemberService {
});
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 },
});
return this.toResponse(saved);
}
@@ -110,6 +137,15 @@ export class MemberService {
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 },
});
return this.toResponse(member);
}
@@ -129,7 +165,16 @@ export class MemberService {
HttpStatus.FORBIDDEN,
);
}
const targetName = member.user.name; // 제거 전 이름 캡처
await this.memberRepo.remove(member);
// 활동 적재 — 멤버 제거
await this.activity.record({
repoId: repo.id,
actorId: actingUserId,
type: 'member.removed',
payload: { targetName },
});
}
// --- 내부 헬퍼 ---
+10 -3
View File
@@ -1,13 +1,16 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@@ -15,6 +18,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { RepoService, type RepoResponse } from './repo.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { CreateRepoDto } from './dto/create-repo.dto';
import { UpdateRepoDto } from './dto/update-repo.dto';
@@ -26,10 +30,13 @@ export class RepoController {
constructor(private readonly repoService: RepoService) {}
@Get()
@ApiOperation({ summary: '조직 저장소 전체 목록' })
@ApiOperation({ summary: '조직 저장소 전체 목록 (페이지네이션)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
findAll(): Promise<RepoResponse[]> {
return this.repoService.findAll();
findAll(
@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);
}
@Post()
+2
View File
@@ -3,6 +3,7 @@ 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 { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { RepoService } from './repo.service';
@@ -18,6 +19,7 @@ import { RepoSyncService } from './repo-sync.service';
UserModule,
GiteaModule,
TaskModule,
ActivityModule,
],
controllers: [RepoController, MemberController],
providers: [RepoService, MemberService, RepoSyncService],
+47 -4
View File
@@ -15,6 +15,12 @@ import {
type GiteaRepoResult,
} from '../gitea/gitea.service';
import { TaskService } from '../task/task.service';
import { ActivityService } from '../activity/activity.service';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { Repo, type RepoVisibility } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { CreateRepoDto } from './dto/create-repo.dto';
@@ -58,6 +64,7 @@ export class RepoService {
private readonly userService: UserService,
private readonly gitea: GiteaService,
private readonly taskService: TaskService,
private readonly activity: ActivityService,
) {}
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보.
@@ -76,16 +83,26 @@ export class RepoService {
};
}
// 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분.
// 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분. 오프셋 페이지네이션.
// 멤버십은 저장소 내 역할/권한에만 쓰이고 목록 노출에는 영향을 주지 않는다(단일 조직 모델).
async findAll(): Promise<RepoResponse[]> {
const repos = await this.repoRepo.find({
async findAll(
page?: number,
size?: number,
): Promise<PaginatedResult<RepoResponse>> {
const pg = resolvePage(page, size);
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 repos.map((repo) => this.toResponse(repo, counts.get(repo.id)));
return paginated(
repos.map((repo) => this.toResponse(repo, counts.get(repo.id))),
total,
pg,
);
}
// 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드).
@@ -157,6 +174,14 @@ export class RepoService {
});
await this.memberRepo.save(ownerMember);
// 활동 적재 — 저장소 생성
await this.activity.record({
repoId: saved.id,
actorId: userId,
type: 'repo.created',
payload: { repoName: saved.name },
});
// 갓 생성한 저장소는 업무가 없으므로 집계는 0/0
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
} catch (err) {
@@ -215,6 +240,24 @@ export class RepoService {
});
}
// 활동 적재 — 표시 제목 변경 / 공개범위 변경(설정 그룹)
if (dto.name !== undefined) {
await this.activity.record({
repoId: repo.id,
actorId: userId,
type: 'repo.renamed',
payload: { newName: repo.name },
});
}
if (visChanged) {
await this.activity.record({
repoId: repo.id,
actorId: userId,
type: 'repo.visibility_changed',
payload: { visibility: repo.visibility },
});
}
return this.toResponse(
await this.getEntityOrThrow(slugName),
await this.taskCountsOf(repo.id),
@@ -0,0 +1,27 @@
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 승인 요청(담당자 → 지시자) — 메시지 선택
export class ApprovalRequestDto {
@ApiProperty({
description: '승인 요청 메시지(선택)',
required: false,
example: '가로형/세로형 1차 제작을 완료했습니다. 검토 부탁드립니다.',
})
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}
// 수정 요청(지시자 → 담당자) — 보완 내용 필수
export class ApprovalChangesDto {
@ApiProperty({
description: '수정 요청 메시지(보완이 필요한 내용)',
example: '세로형 카피 가독성이 낮으니 자간과 대비를 조정해 주세요.',
})
@IsString()
@IsNotEmpty({ message: '수정 요청 내용을 입력해 주세요.' })
@MaxLength(2000)
note!: string;
}
@@ -0,0 +1,34 @@
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 댓글 작성 요청 (fileId 로 기존 첨부를 함께 연결 가능)
export class CreateCommentDto {
@ApiProperty({
description: '댓글 내용',
example: '가로형 시안 검토 부탁드립니다.',
})
@IsString()
@IsNotEmpty({ message: '댓글 내용을 입력해 주세요.' })
@MaxLength(2000)
text!: string;
@ApiProperty({ description: '연결할 첨부파일 ID(선택)', required: false })
@IsOptional()
@IsUUID('4')
fileId?: string;
}
// 답글 작성 요청
export class CreateReplyDto {
@ApiProperty({ description: '답글 내용', example: '네, 확인했습니다.' })
@IsString()
@IsNotEmpty({ message: '답글 내용을 입력해 주세요.' })
@MaxLength(2000)
text!: string;
}
@@ -7,10 +7,32 @@ import {
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
// 업무 수정 요청 — 제공된 필드만 갱신(체크리스트/댓글/파일은 5단계 별도 엔드포인트)
// 수정 시 체크리스트 항목 — id 있으면 기존 항목 갱신(완료 상태 보존), 없으면 신규 추가
export class UpdateChecklistItemInput {
@ApiProperty({
description: '기존 항목 ID(신규 추가 시 생략)',
required: false,
})
@IsOptional()
@IsUUID('4')
id?: string;
@ApiProperty({
description: '체크리스트 항목 내용',
example: '세로형 배너 디자인',
})
@IsString()
@IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' })
@MaxLength(200)
text!: string;
}
// 업무 수정 요청 — 제공된 필드만 갱신
export class UpdateTaskDto {
@ApiProperty({ description: '업무 제목', required: false })
@IsOptional()
@@ -48,4 +70,16 @@ export class UpdateTaskDto {
@IsOptional()
@IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' })
dueDate?: string | null;
@ApiProperty({
description:
'체크리스트 전체(추가/삭제/순서 동기화). id 있는 항목은 유지(완료 보존), 없으면 신규, 빠진 기존 항목은 삭제',
type: [UpdateChecklistItemInput],
required: false,
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UpdateChecklistItemInput)
checklist?: UpdateChecklistItemInput[];
}
@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Task } from './task.entity';
// 첨부파일 아이콘 종류(프론트 표시용)
export type AttachmentKind = 'pdf' | 'zip' | 'doc' | 'img';
// 업무 첨부파일 — 실제 바이너리는 서버 업로드 폴더에 저장하고 메타데이터만 보관
@Entity('attachments')
export class Attachment {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 업무 (업무 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'task_id' })
task!: Relation<Task>;
// 업로더 (사용자 삭제 시에도 첨부는 유지 — SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'uploader_id' })
uploader!: User | null;
// 원본 파일명(다운로드 시 표시)
@Column()
name!: string;
// 디스크 저장명(업로드 폴더 내 고유 파일명)
@Column({ name: 'stored_name' })
storedName!: string;
// 파일 크기(바이트) — 표시용 변환은 프론트 formatBytes 가 담당
@Column({ type: 'int' })
size!: number;
// MIME 타입
@Column()
mime!: string;
// 아이콘 종류(MIME/확장자에서 파생해 저장)
@Column({ type: 'varchar' })
kind!: AttachmentKind;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Task } from './task.entity';
import { Attachment } from './attachment.entity';
// 업무 댓글 — 답글은 parent 를 가리키는 같은 엔티티로 표현(1단계 깊이)
@Entity('comments')
export class Comment {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 업무 (업무 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'task_id' })
task!: Relation<Task>;
// 작성자 (사용자 삭제 시에도 댓글은 유지 — SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'author_id' })
author!: User | null;
// 부모 댓글 — 답글이면 설정, 최상위 댓글이면 null
@ManyToOne(() => Comment, (c) => c.replies, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'parent_id' })
parent!: Relation<Comment> | null;
// 이 댓글에 달린 답글들
@OneToMany(() => Comment, (c) => c.parent)
replies!: Relation<Comment>[];
// 첨부(선택) — 첨부 삭제 시 연결만 해제(SET NULL)
@ManyToOne(() => Attachment, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'attachment_id' })
attachment!: Relation<Attachment> | null;
// 댓글 본문
@Column({ type: 'text' })
text!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -73,6 +73,29 @@ export class Task {
@OneToMany(() => ChecklistItem, (item) => item.task, { cascade: true })
checklist!: Relation<ChecklistItem>[];
// --- 승인 워크플로우(5단계) ---
// 최근 승인 요청 메시지(담당자 → 지시자). 승인 대기(review) 상태에서 노출
@Column({ name: 'approval_note', type: 'text', nullable: true })
approvalNote!: string | null;
// 승인을 요청한 사용자 (사용자 삭제 시 SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'approval_requested_by' })
approvalRequestedBy!: User | null;
// 승인 요청 시각
@Column({
name: 'approval_requested_at',
type: 'timestamptz',
nullable: true,
})
approvalRequestedAt!: Date | null;
// 최근 수정 요청 메시지(지시자 → 담당자). 수정 요청(changes) 상태에서 노출
@Column({ name: 'changes_note', type: 'text', nullable: true })
changesNote!: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -0,0 +1,44 @@
import {
Controller,
DefaultValuePipe,
Get,
ParseIntPipe,
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 MyTaskRowResponse } from './task.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
// 내 업무(저장소 횡단) — 담당/지시 집계. 그룹핑·라벨은 프론트가 파생
@ApiTags('MyTask')
@Controller('tasks')
@UseGuards(JwtAuthGuard)
export class MyTaskController {
constructor(private readonly taskService: TaskService) {}
@Get('assigned')
@ApiOperation({ summary: '내가 담당인 업무 (저장소 횡단, 페이지네이션)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listAssigned(
@CurrentUser() user: PublicUser,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<MyTaskRowResponse>> {
return this.taskService.listAssigned(user.id, page, size);
}
@Get('issued')
@ApiOperation({ summary: '내가 지시한 업무 (저장소 횡단, 페이지네이션)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listIssued(
@CurrentUser() user: PublicUser,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<MyTaskRowResponse>> {
return this.taskService.listIssued(user.id, page, size);
}
}
+179 -7
View File
@@ -1,30 +1,51 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiConsumes,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
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 AttachmentResponse,
type CommentReplyResponse,
type CommentResponse,
type RepoTaskListResult,
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';
import { CreateCommentDto, CreateReplyDto } from './dto/create-comment.dto';
import { ApprovalChangesDto, ApprovalRequestDto } from './dto/approval.dto';
import {
MAX_FILE_SIZE,
UPLOAD_DIR,
type UploadedDiskFile,
} from './upload.config';
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번)
@ApiTags('Task')
@@ -34,15 +55,20 @@ export class TaskController {
constructor(private readonly taskService: TaskService) {}
@Get()
@ApiOperation({ summary: '업무 목록 (상태/담당자 필터)' })
@ApiOperation({
summary: '업무 목록 (세그먼트/검색 + 페이지네이션 + 세그먼트 카운트)',
})
@ApiResponse({ status: 200, description: '목록 조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(
@Param('repoId') repoId: string,
@Query('status') status?: TaskStatus,
@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<RepoTaskResponse[]> {
return this.taskService.list(repoId, { status, assignee });
): Promise<RepoTaskListResult> {
return this.taskService.list(repoId, { segment, q, assignee }, page, size);
}
@Post()
@@ -117,4 +143,150 @@ export class TaskController {
): Promise<TaskDetailResponse> {
return this.taskService.changeStatus(repoId, user.id, taskId, dto.status);
}
/* ===================== 댓글 / 답글 (5단계) ===================== */
@Post(':taskId/comments')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '댓글 작성 (멤버)' })
@ApiResponse({ status: 201, description: '작성 성공' })
addComment(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: CreateCommentDto,
@CurrentUser() user: PublicUser,
): Promise<CommentResponse> {
return this.taskService.addComment(
repoId,
user.id,
taskId,
dto.text,
dto.fileId,
);
}
@Post(':taskId/comments/:commentId/replies')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '답글 작성 (멤버)' })
@ApiResponse({ status: 201, description: '작성 성공' })
addReply(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Param('commentId', ParseUUIDPipe) commentId: string,
@Body() dto: CreateReplyDto,
@CurrentUser() user: PublicUser,
): Promise<CommentReplyResponse> {
return this.taskService.addReply(
repoId,
user.id,
taskId,
commentId,
dto.text,
);
}
/* ===================== 파일 첨부 (5단계) ===================== */
@Post(':taskId/files')
@HttpCode(HttpStatus.CREATED)
@UseInterceptors(
FileInterceptor('file', {
dest: UPLOAD_DIR,
limits: { fileSize: MAX_FILE_SIZE },
}),
)
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: '파일 업로드 (멤버)' })
@ApiResponse({ status: 201, description: '업로드 성공' })
addFile(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@UploadedFile() file: UploadedDiskFile,
@CurrentUser() user: PublicUser,
): Promise<AttachmentResponse> {
return this.taskService.addFile(repoId, user.id, taskId, file);
}
@Delete(':taskId/files/:fileId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '파일 삭제 (멤버)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async removeFile(
@Param('repoId') repoId: 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);
return null;
}
@Get(':taskId/files/:fileId/download')
@ApiOperation({ summary: '파일 다운로드 (멤버)' })
@ApiResponse({ status: 200, description: '파일 스트림' })
async downloadFile(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Param('fileId', ParseUUIDPipe) fileId: string,
@CurrentUser() user: PublicUser,
@Res() res: Response,
): Promise<void> {
const { attachment, absolutePath } =
await this.taskService.getFileForDownload(
repoId,
user.id,
taskId,
fileId,
);
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res)
res.setHeader('Content-Type', attachment.mime);
res.setHeader(
'Content-Disposition',
`inline; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
);
res.sendFile(absolutePath);
}
/* ===================== 승인 워크플로우 (5단계) ===================== */
@Post(':taskId/approval/request')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '승인 요청 (담당자 → 지시자)' })
@ApiResponse({ status: 200, description: '요청 성공' })
@ApiResponse({ status: 409, description: '허용되지 않는 전이 (BIZ_001)' })
requestApproval(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: ApprovalRequestDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.requestApproval(repoId, user.id, taskId, dto.note);
}
@Post(':taskId/approval/approve')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '승인 (지시자/admin)' })
@ApiResponse({ status: 200, description: '승인 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
approve(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.approve(repoId, user.id, taskId);
}
@Post(':taskId/approval/changes')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '수정 요청 (지시자/admin)' })
@ApiResponse({ status: 200, description: '요청 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
requestChanges(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: ApprovalChangesDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.requestChanges(repoId, user.id, taskId, dto.note);
}
}
+17 -3
View File
@@ -4,13 +4,27 @@ 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 { Comment } from './entities/comment.entity';
import { Attachment } from './entities/attachment.entity';
import { ActivityModule } from '../activity/activity.module';
import { TaskService } from './task.service';
import { TaskController } from './task.controller';
import { MyTaskController } from './my-task.controller';
// 업무 모듈 (업무 CRUD + 상태 전이)
// 업무 모듈 (업무 CRUD + 상태 전이 + 체크리스트/댓글/첨부/승인)
@Module({
imports: [TypeOrmModule.forFeature([Task, ChecklistItem, Repo, RepoMember])],
controllers: [TaskController],
imports: [
TypeOrmModule.forFeature([
Task,
ChecklistItem,
Comment,
Attachment,
Repo,
RepoMember,
]),
ActivityModule,
],
controllers: [TaskController, MyTaskController],
providers: [TaskService],
exports: [TaskService],
})
+95 -1
View File
@@ -6,6 +6,9 @@ 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 { Comment } from './entities/comment.entity';
import { Attachment } from './entities/attachment.entity';
import { ActivityService } from '../activity/activity.service';
// 리포지토리 간이 모킹
function makeService() {
@@ -19,13 +22,33 @@ function makeService() {
const checklistRepo = { create: jest.fn(), save: jest.fn() };
const repoRepo = { findOne: jest.fn() };
const memberRepo = { findOne: jest.fn(), find: jest.fn() };
// 상세 조립(buildDetail)에서 댓글/첨부는 빈 목록으로 모킹
const commentRepo = { find: jest.fn().mockResolvedValue([]) };
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
// 활동 서비스 — 적재는 no-op, 업무 활동 조회는 빈 목록
const activity = {
record: jest.fn().mockResolvedValue(undefined),
listForTask: jest.fn().mockResolvedValue([]),
};
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>,
commentRepo as unknown as Repository<Comment>,
attachmentRepo as unknown as Repository<Attachment>,
activity as unknown as ActivityService,
);
return { svc, taskRepo, checklistRepo, repoRepo, memberRepo };
return {
svc,
taskRepo,
checklistRepo,
repoRepo,
memberRepo,
commentRepo,
attachmentRepo,
activity,
};
}
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', name: '캠페인' };
@@ -109,6 +132,77 @@ describe('TaskService', () => {
});
});
describe('승인 워크플로우 (5단계)', () => {
it('수정 요청(review→changes)은 지시자/admin 이 아니면 Forbidden', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({
roleType: 'member',
user: { id: 'assignee' },
});
taskRepo.findOne.mockResolvedValue({
seq: 9,
status: 'review',
issuer: { id: 'someone-else' },
assignees: [{ id: 'assignee' }],
checklist: [],
});
await expect(
svc.requestChanges('q3-launch', 'assignee', 9, '보완 바랍니다'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('승인 요청(prog→review)은 담당자가 가능', async () => {
const {
svc,
taskRepo,
repoRepo,
memberRepo,
commentRepo,
attachmentRepo,
} = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({
roleType: 'member',
user: { id: 'assignee', email: 'a@b.c', name: 'A', role: null },
});
const task = {
seq: 9,
status: 'prog',
title: '배너',
content: [],
issuer: { id: 'boss' },
assignees: [{ id: 'assignee' }],
checklist: [],
dueDate: null,
createdAt: new Date('2026-06-08T00:00:00Z'),
approvalRequestedBy: {
id: 'assignee',
email: 'a@b.c',
name: 'A',
role: null,
},
approvalRequestedAt: new Date('2026-06-10T00:00:00Z'),
approvalNote: '검토 부탁드립니다',
};
taskRepo.findOne.mockResolvedValue(task);
taskRepo.save.mockResolvedValue(task);
commentRepo.find.mockResolvedValue([]);
attachmentRepo.find.mockResolvedValue([]);
const result = await svc.requestApproval(
'q3-launch',
'assignee',
9,
'검토 부탁드립니다',
);
expect(task.status).toBe('review');
expect(result.approval?.note).toBe('검토 부탁드립니다');
});
});
describe('create — 담당자 멤버 검증', () => {
it('담당자가 저장소 멤버가 아니면 BusinessException', async () => {
const { svc, repoRepo, memberRepo } = makeService();
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
import { mkdirSync } from 'fs';
import { join } from 'path';
import type { AttachmentKind } from './entities/attachment.entity';
// 업무 첨부파일 업로드 폴더 — 서버 로컬 디스크에 보관(추후 S3/MinIO 로 교체 가능)
// 도커 환경에서는 UPLOAD_DIR 로 볼륨 경로를 주입하고, 미설정 시 작업 디렉터리 하위 uploads 사용
export const UPLOAD_DIR =
process.env.UPLOAD_DIR || join(process.cwd(), 'uploads');
// 모듈 로드 시 업로드 폴더 존재 보장(multer dest 는 폴더를 자동 생성하지 않음)
mkdirSync(UPLOAD_DIR, { recursive: true });
// 첨부 1건당 최대 크기(25MB)
export const MAX_FILE_SIZE = 25 * 1024 * 1024;
// multer(diskStorage) 가 채워주는 업로드 파일 메타 — @types/multer 의존 없이 필요한 필드만 정의
export interface UploadedDiskFile {
originalname: string; // 원본 파일명(비ASCII 는 latin1 인코딩으로 들어옴)
filename: string; // 업로드 폴더 내 저장명
size: number; // 바이트
mimetype: string;
path: string; // 절대 경로
}
// MIME/확장자로 아이콘 종류 파생
export function deriveKind(mime: string, name: string): AttachmentKind {
const lower = name.toLowerCase();
if (mime.startsWith('image/')) return 'img';
if (mime === 'application/pdf' || lower.endsWith('.pdf')) return 'pdf';
if (
mime.includes('zip') ||
mime.includes('compressed') ||
lower.endsWith('.zip')
) {
return 'zip';
}
return 'doc';
}
// multer 가 비ASCII 파일명을 latin1 로 디코딩하므로 UTF-8 로 복원
export function decodeOriginalName(name: string): string {
return Buffer.from(name, 'latin1').toString('utf8');
}