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:
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user