From a056d7f55a9cc09aaf612dd952721cd151f6f7c0 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 18 Jun 2026 18:13:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=85=EB=AC=B4=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EB=B6=80=EC=86=8D=C2=B7=ED=99=9C=EB=8F=99=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=C2=B7=EB=82=B4=20=EC=97=85=EB=AC=B4=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20+=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20(5~8=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 5단계(업무 상세 부속): 체크리스트(상세 읽기전용/수정폼 추가·삭제)·댓글/답글·파일 첨부(서버 로컬 업로드 폴더)·승인 워크플로우(요청/승인/수정요청 노트) - 6단계(활동 피드): 저장소/멤버/업무 행위를 이벤트로 자동 적재, 저장소 활동 피드·업무 활동 탭 실연동(프론트가 type+payload로 문구 조립) - 7단계(내 업무): 담당/지시 저장소 횡단 집계, MyTasksPage 실연동, TaskAssigneePage(/view) 제거 - 8단계(진행): 목록 페이지네이션 통일 — 백엔드 전체(공통 유틸 + 5개 목록, 저장소 업무는 세그먼트/검색/카운트) + 프론트 더보기(저장소 목록·내 업무 완료, 멤버·저장소 업무 대기) - UI 보강: 업무 목록 댓글/첨부 분리표기, 첨부 배지 색상·라벨 통일, 완료 업무 D-Day 배지, 활동 이벤트 점검·보강(업무 수정/삭제·답글·첨부 삭제) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/.gitignore | 3 + backend/Dockerfile | 4 + backend/src/app.module.ts | 2 + backend/src/common/pagination/pagination.ts | 42 + .../modules/activity/activity.controller.ts | 20 + .../src/modules/activity/activity.module.ts | 16 + .../src/modules/activity/activity.service.ts | 113 ++ .../activity/entities/activity.entity.ts | 70 + backend/src/modules/repo/member.controller.ts | 14 +- .../src/modules/repo/member.service.spec.ts | 29 +- backend/src/modules/repo/member.service.ts | 53 +- backend/src/modules/repo/repo.controller.ts | 13 +- backend/src/modules/repo/repo.module.ts | 2 + backend/src/modules/repo/repo.service.ts | 51 +- backend/src/modules/task/dto/approval.dto.ts | 27 + .../modules/task/dto/create-comment.dto.ts | 34 + .../src/modules/task/dto/update-task.dto.ts | 36 +- .../task/entities/attachment.entity.ts | 55 + .../modules/task/entities/comment.entity.ts | 55 + .../src/modules/task/entities/task.entity.ts | 23 + .../src/modules/task/my-task.controller.ts | 44 + backend/src/modules/task/task.controller.ts | 186 ++- backend/src/modules/task/task.module.ts | 20 +- backend/src/modules/task/task.service.spec.ts | 96 +- backend/src/modules/task/task.service.ts | 930 ++++++++++- backend/src/modules/task/upload.config.ts | 43 + docs/api-contract.md | 56 +- docs/integration-progress.md | 93 +- frontend/src/assets/styles/relay.css | 25 + frontend/src/composables/useActivity.ts | 14 + frontend/src/composables/useMyTask.ts | 24 + frontend/src/composables/useRepo.ts | 7 +- frontend/src/composables/useTask.ts | 98 +- frontend/src/mock/relay.mock.ts | 16 + frontend/src/pages/relay/MyTasksPage.vue | 123 +- frontend/src/pages/relay/RepoActivityPage.vue | 85 +- frontend/src/pages/relay/RepoDetailPage.vue | 18 +- frontend/src/pages/relay/RepoListPage.vue | 15 +- frontend/src/pages/relay/TaskAssigneePage.vue | 1418 ----------------- frontend/src/pages/relay/TaskCreatePage.vue | 25 +- frontend/src/pages/relay/TaskDetailPage.vue | 365 ++++- frontend/src/router/index.ts | 9 +- frontend/src/shared/utils/format.ts | 80 +- frontend/src/stores/activity.store.ts | 151 ++ frontend/src/stores/my-task.store.ts | 196 +++ frontend/src/stores/repo.store.ts | 35 +- frontend/src/stores/task.store.ts | 209 ++- frontend/src/types/activity.ts | 31 + frontend/src/types/my-task.ts | 17 + frontend/src/types/pagination.ts | 11 + frontend/src/types/task.ts | 67 +- 51 files changed, 3437 insertions(+), 1732 deletions(-) create mode 100644 backend/src/common/pagination/pagination.ts create mode 100644 backend/src/modules/activity/activity.controller.ts create mode 100644 backend/src/modules/activity/activity.module.ts create mode 100644 backend/src/modules/activity/activity.service.ts create mode 100644 backend/src/modules/activity/entities/activity.entity.ts create mode 100644 backend/src/modules/task/dto/approval.dto.ts create mode 100644 backend/src/modules/task/dto/create-comment.dto.ts create mode 100644 backend/src/modules/task/entities/attachment.entity.ts create mode 100644 backend/src/modules/task/entities/comment.entity.ts create mode 100644 backend/src/modules/task/my-task.controller.ts create mode 100644 backend/src/modules/task/upload.config.ts create mode 100644 frontend/src/composables/useActivity.ts create mode 100644 frontend/src/composables/useMyTask.ts delete mode 100644 frontend/src/pages/relay/TaskAssigneePage.vue create mode 100644 frontend/src/stores/activity.store.ts create mode 100644 frontend/src/stores/my-task.store.ts create mode 100644 frontend/src/types/activity.ts create mode 100644 frontend/src/types/my-task.ts create mode 100644 frontend/src/types/pagination.ts diff --git a/backend/.gitignore b/backend/.gitignore index 1f75059..05086a2 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -48,6 +48,9 @@ lerna-debug.log* .temp .tmp +# 업무 첨부 업로드 폴더(런타임 생성, 커밋 금지) +/uploads + # Runtime data pids *.pid diff --git a/backend/Dockerfile b/backend/Dockerfile index 61ac3f6..d903a34 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,6 +25,10 @@ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist +# 업무 첨부 업로드 폴더 — 비-root(node) 사용자가 쓸 수 있도록 미리 생성/소유권 부여 +# (운영에서 파일 영속이 필요하면 이 경로에 볼륨을 마운트한다: UPLOAD_DIR=/app/uploads) +RUN mkdir -p /app/uploads && chown -R node:node /app/uploads + EXPOSE 3000 # 비-root 실행 (보안 규칙) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index c3005e5..1800407 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,7 @@ 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'; +import { ActivityModule } from './modules/activity/activity.module'; @Module({ imports: [ @@ -65,6 +66,7 @@ import { TaskModule } from './modules/task/task.module'; AuthModule, RepoModule, TaskModule, + ActivityModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/pagination/pagination.ts b/backend/src/common/pagination/pagination.ts new file mode 100644 index 0000000..a953dce --- /dev/null +++ b/backend/src/common/pagination/pagination.ts @@ -0,0 +1,42 @@ +// 목록 페이지네이션 공통 — 오프셋 기반(?page&size), 표준 응답 { items, total, page, size } + +// 페이지 응답 형태 +export interface PaginatedResult { + items: T[]; + total: number; + page: number; + size: number; +} + +// 정규화된 페이지 파라미터(+ DB skip/take) +export interface PageParams { + page: number; + size: number; + skip: number; + take: number; +} + +const DEFAULT_SIZE = 20; +const MAX_SIZE = 100; + +// page/size 정규화(범위 보정) + skip/take 계산 +export function resolvePage(page?: number, size?: number): PageParams { + const p = + Number.isFinite(page) && (page as number) > 0 + ? Math.floor(page as number) + : 1; + const s = + Number.isFinite(size) && (size as number) > 0 + ? Math.min(Math.floor(size as number), MAX_SIZE) + : DEFAULT_SIZE; + return { page: p, size: s, skip: (p - 1) * s, take: s }; +} + +// items + total → 표준 페이지 응답 +export function paginated( + items: T[], + total: number, + params: PageParams, +): PaginatedResult { + return { items, total, page: params.page, size: params.size }; +} diff --git a/backend/src/modules/activity/activity.controller.ts b/backend/src/modules/activity/activity.controller.ts new file mode 100644 index 0000000..ed193d2 --- /dev/null +++ b/backend/src/modules/activity/activity.controller.ts @@ -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 { + return this.activityService.listForRepo(repoId); + } +} diff --git a/backend/src/modules/activity/activity.module.ts b/backend/src/modules/activity/activity.module.ts new file mode 100644 index 0000000..106893a --- /dev/null +++ b/backend/src/modules/activity/activity.module.ts @@ -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 {} diff --git a/backend/src/modules/activity/activity.service.ts b/backend/src/modules/activity/activity.service.ts new file mode 100644 index 0000000..bb47698 --- /dev/null +++ b/backend/src/modules/activity/activity.service.ts @@ -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; + createdAt: string; +} + +// 활동 적재 파라미터 +export interface RecordActivityParams { + repoId: string; // Repo uuid + actorId: string | null; + type: ActivityType; + payload?: Record; + taskSeq?: number | null; +} + +// 활동 피드 비즈니스 로직 — 다른 도메인 서비스가 record() 로 이벤트를 적재하고, +// 저장소/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음). +@Injectable() +export class ActivityService { + private readonly logger = new Logger(ActivityService.name); + + constructor( + @InjectRepository(Activity) + private readonly activityRepo: Repository, + @InjectRepository(Repo) + private readonly repoRepo: Repository, + ) {} + + // 활동 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수 + async record(params: RecordActivityParams): Promise { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/backend/src/modules/activity/entities/activity.entity.ts b/backend/src/modules/activity/entities/activity.entity.ts new file mode 100644 index 0000000..ee22423 --- /dev/null +++ b/backend/src/modules/activity/entities/activity.entity.ts @@ -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; + + // 업무 활동이면 업무 순번(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; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/repo/member.controller.ts b/backend/src/modules/repo/member.controller.ts index f0f00f7..5bed24a 100644 --- a/backend/src/modules/repo/member.controller.ts +++ b/backend/src/modules/repo/member.controller.ts @@ -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 { - 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> { + return this.memberService.list(repoId, page, size); } @Post() diff --git a/backend/src/modules/repo/member.service.spec.ts b/backend/src/modules/repo/member.service.spec.ts index 13906bd..8c91f42 100644 --- a/backend/src/modules/repo/member.service.spec.ts +++ b/backend/src/modules/repo/member.service.spec.ts @@ -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, memberRepo as unknown as Repository, 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'); }); }); diff --git a/backend/src/modules/repo/member.service.ts b/backend/src/modules/repo/member.service.ts index a5e14af..be5aa56 100644 --- a/backend/src/modules/repo/member.service.ts +++ b/backend/src/modules/repo/member.service.ts @@ -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, private readonly userService: UserService, private readonly taskService: TaskService, + private readonly activity: ActivityService, ) {} - // 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책). + // 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책). 페이지네이션. // 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다. - async list(slugName: string): Promise { + async list( + slugName: string, + page?: number, + size?: number, + ): Promise> { 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 }, + }); } // --- 내부 헬퍼 --- diff --git a/backend/src/modules/repo/repo.controller.ts b/backend/src/modules/repo/repo.controller.ts index b4f8042..820454d 100644 --- a/backend/src/modules/repo/repo.controller.ts +++ b/backend/src/modules/repo/repo.controller.ts @@ -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 { - return this.repoService.findAll(); + findAll( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + ): Promise> { + return this.repoService.findAll(page, size); } @Post() diff --git a/backend/src/modules/repo/repo.module.ts b/backend/src/modules/repo/repo.module.ts index 659fab3..53afb01 100644 --- a/backend/src/modules/repo/repo.module.ts +++ b/backend/src/modules/repo/repo.module.ts @@ -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], diff --git a/backend/src/modules/repo/repo.service.ts b/backend/src/modules/repo/repo.service.ts index ed6f5ad..0121caa 100644 --- a/backend/src/modules/repo/repo.service.ts +++ b/backend/src/modules/repo/repo.service.ts @@ -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 { - const repos = await this.repoRepo.find({ + async findAll( + page?: number, + size?: number, + ): Promise> { + 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), diff --git a/backend/src/modules/task/dto/approval.dto.ts b/backend/src/modules/task/dto/approval.dto.ts new file mode 100644 index 0000000..57439d3 --- /dev/null +++ b/backend/src/modules/task/dto/approval.dto.ts @@ -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; +} diff --git a/backend/src/modules/task/dto/create-comment.dto.ts b/backend/src/modules/task/dto/create-comment.dto.ts new file mode 100644 index 0000000..3f4b2f1 --- /dev/null +++ b/backend/src/modules/task/dto/create-comment.dto.ts @@ -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; +} diff --git a/backend/src/modules/task/dto/update-task.dto.ts b/backend/src/modules/task/dto/update-task.dto.ts index 637f395..f25beb6 100644 --- a/backend/src/modules/task/dto/update-task.dto.ts +++ b/backend/src/modules/task/dto/update-task.dto.ts @@ -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[]; } diff --git a/backend/src/modules/task/entities/attachment.entity.ts b/backend/src/modules/task/entities/attachment.entity.ts new file mode 100644 index 0000000..f4d7784 --- /dev/null +++ b/backend/src/modules/task/entities/attachment.entity.ts @@ -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; + + // 업로더 (사용자 삭제 시에도 첨부는 유지 — 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; +} diff --git a/backend/src/modules/task/entities/comment.entity.ts b/backend/src/modules/task/entities/comment.entity.ts new file mode 100644 index 0000000..9545a3e --- /dev/null +++ b/backend/src/modules/task/entities/comment.entity.ts @@ -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; + + // 작성자 (사용자 삭제 시에도 댓글은 유지 — 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 | null; + + // 이 댓글에 달린 답글들 + @OneToMany(() => Comment, (c) => c.parent) + replies!: Relation[]; + + // 첨부(선택) — 첨부 삭제 시 연결만 해제(SET NULL) + @ManyToOne(() => Attachment, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'attachment_id' }) + attachment!: Relation | null; + + // 댓글 본문 + @Column({ type: 'text' }) + text!: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/task/entities/task.entity.ts b/backend/src/modules/task/entities/task.entity.ts index d46b875..d421fec 100644 --- a/backend/src/modules/task/entities/task.entity.ts +++ b/backend/src/modules/task/entities/task.entity.ts @@ -73,6 +73,29 @@ export class Task { @OneToMany(() => ChecklistItem, (item) => item.task, { cascade: true }) checklist!: Relation[]; + // --- 승인 워크플로우(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; diff --git a/backend/src/modules/task/my-task.controller.ts b/backend/src/modules/task/my-task.controller.ts new file mode 100644 index 0000000..7cb24ea --- /dev/null +++ b/backend/src/modules/task/my-task.controller.ts @@ -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> { + 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> { + return this.taskService.listIssued(user.id, page, size); + } +} diff --git a/backend/src/modules/task/task.controller.ts b/backend/src/modules/task/task.controller.ts index 91c7219..8d3fd2f 100644 --- a/backend/src/modules/task/task.controller.ts +++ b/backend/src/modules/task/task.controller.ts @@ -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 { - return this.taskService.list(repoId, { status, assignee }); + ): Promise { + return this.taskService.list(repoId, { segment, q, assignee }, page, size); } @Post() @@ -117,4 +143,150 @@ export class TaskController { ): Promise { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.taskService.requestChanges(repoId, user.id, taskId, dto.note); + } } diff --git a/backend/src/modules/task/task.module.ts b/backend/src/modules/task/task.module.ts index 4b740c8..91ed1bc 100644 --- a/backend/src/modules/task/task.module.ts +++ b/backend/src/modules/task/task.module.ts @@ -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], }) diff --git a/backend/src/modules/task/task.service.spec.ts b/backend/src/modules/task/task.service.spec.ts index b312c98..7fb2f69 100644 --- a/backend/src/modules/task/task.service.spec.ts +++ b/backend/src/modules/task/task.service.spec.ts @@ -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, checklistRepo as unknown as Repository, repoRepo as unknown as Repository, memberRepo as unknown as Repository, + commentRepo as unknown as Repository, + attachmentRepo as unknown as Repository, + 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(); diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index dda3ed7..73a93da 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -5,15 +5,34 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { In, IsNull, Repository } from 'typeorm'; import { UserService, type PublicUser } from '../user/user.service'; import { BusinessException } from '../../common/exceptions/business.exception'; import { Repo } from '../repo/entities/repo.entity'; import { RepoMember } from '../repo/entities/repo-member.entity'; import { Task, type TaskStatus } from './entities/task.entity'; import { ChecklistItem } from './entities/checklist-item.entity'; +import { Comment } from './entities/comment.entity'; +import { Attachment, type AttachmentKind } from './entities/attachment.entity'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; +import { + ActivityService, + type ActivityResponse, +} from '../activity/activity.service'; +import { + paginated, + resolvePage, + type PaginatedResult, +} from '../../common/pagination/pagination'; +import { + UPLOAD_DIR, + decodeOriginalName, + deriveKind, + type UploadedDiskFile, +} from './upload.config'; +import { join } from 'path'; +import { unlink } from 'fs/promises'; // 목록 행 응답 — 라벨류는 프론트가 파생(dueLabel/dday/statusLabel 등) export interface RepoTaskResponse { @@ -22,7 +41,8 @@ export interface RepoTaskResponse { status: TaskStatus; dueDate: string | null; checklist: [number, number]; // [완료, 전체] - commentCount: number; // 5단계 전까지 0 + commentCount: number; + fileCount: number; // 첨부 파일 수 assignees: PublicUser[]; } @@ -33,7 +53,69 @@ export interface ChecklistItemResponse { done: boolean; } -// 업무 상세 응답 — comments/activities/files/approval 은 5단계에서 채운다(현재 빈 값) +// 저장소 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영 +export interface TaskSegmentCounts { + all: number; + prog: number; // 진행 중(prog+review) + todo: number; + done: number; +} + +// 저장소 업무 목록 응답 — 페이지 + 세그먼트 카운트 +export type RepoTaskListResult = PaginatedResult & { + counts: TaskSegmentCounts; +}; + +// 첨부파일 응답 +export interface AttachmentResponse { + id: string; + name: string; + size: number; // 바이트(표시 변환은 프론트) + kind: AttachmentKind; + url: string; // 다운로드 경로 + uploader: PublicUser | null; + createdAt: string; +} + +// 답글 응답 +export interface CommentReplyResponse { + id: string; + author: PublicUser | null; + text: string; + createdAt: string; +} + +// 댓글 응답(답글 포함) +export interface CommentResponse { + id: string; + author: PublicUser | null; + text: string; + file: AttachmentResponse | null; + createdAt: string; + replies: CommentReplyResponse[]; +} + +// 내 업무 행(저장소 횡단) — 라벨/그룹핑은 프론트가 파생 +export interface MyTaskRowResponse { + id: number; // 저장소 내 순번(seq) + repoId: string; // slugName (라우팅용) + repoName: string; + title: string; + status: TaskStatus; + dueDate: string | null; + checklist: [number, number]; // [완료, 전체] + assignees: PublicUser[]; + issuer: PublicUser | null; +} + +// 승인 요청 정보(승인 대기 상태에서 노출) +export interface ApprovalInfo { + requester: PublicUser | null; + requestedAt: string | null; + note: string | null; +} + +// 업무 상세 응답 — activities 는 6단계 영역(현재 빈 배열) export interface TaskDetailResponse { id: number; repoId: string; // slugName @@ -46,9 +128,11 @@ export interface TaskDetailResponse { issuer: PublicUser | null; dueDate: string | null; createdAt: string; - comments: never[]; - activities: never[]; - files: never[]; + comments: CommentResponse[]; + activities: ActivityResponse[]; + files: AttachmentResponse[]; + approval: ApprovalInfo | null; + changesNote: string | null; } // 상태 머신 전이 규칙(허용 목적지) @@ -81,25 +165,42 @@ export class TaskService { private readonly repoRepo: Repository, @InjectRepository(RepoMember) private readonly memberRepo: Repository, + @InjectRepository(Comment) + private readonly commentRepo: Repository, + @InjectRepository(Attachment) + private readonly attachmentRepo: Repository, + private readonly activity: ActivityService, ) {} - // 업무 목록 — 상태/담당자 필터(선택). seq 내림차순(최신 우선) + // 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트 async list( slugName: string, - filters: { status?: TaskStatus; assignee?: string }, - ): Promise { + filters: { segment?: string; q?: string; assignee?: string }, + page?: number, + size?: number, + ): Promise { const repo = await this.getRepoOrThrow(slugName); + const pg = resolvePage(page, size); const qb = this.taskRepo .createQueryBuilder('task') .leftJoinAndSelect('task.assignees', 'assignee') .leftJoinAndSelect('task.checklist', 'checklist') - .where('task.repo = :repoId', { repoId: repo.id }) - .orderBy('task.seq', 'DESC') - .addOrderBy('checklist.sort_order', 'ASC'); + .where('task.repo = :repoId', { repoId: repo.id }); - if (filters.status) { - qb.andWhere('task.status = :status', { status: filters.status }); + // 세그먼트 → 상태 조건 (진행 중 = prog+review, 그 외 단일 상태. 'all'·'changes 포함'은 무필터) + const segment = filters.segment ?? 'all'; + if (segment === 'prog') { + qb.andWhere("task.status IN ('prog', 'review')"); + } else if (segment === 'todo') { + qb.andWhere("task.status = 'todo'"); + } else if (segment === 'done') { + qb.andWhere("task.status = 'done'"); + } + + const q = filters.q?.trim(); + if (q) { + qb.andWhere('task.title ILIKE :q', { q: `%${q}%` }); } // 담당자 필터: 해당 사용자가 담당인 업무만 (서브쿼리로 task id 추림) if (filters.assignee) { @@ -115,15 +216,64 @@ export class TaskService { ); } - const tasks = await qb.getMany(); - return tasks.map((t) => this.toListResponse(t)); + qb.orderBy('task.seq', 'DESC').addOrderBy('checklist.sort_order', 'ASC'); + + const [tasks, total] = await qb + .skip(pg.skip) + .take(pg.take) + .getManyAndCount(); + + const taskIds = tasks.map((t) => t.id); + const [commentCounts, fileCounts] = await Promise.all([ + this.commentCountsByTask(taskIds), + this.fileCountsByTask(taskIds), + ]); + const items = tasks.map((t) => + this.toListResponse( + t, + commentCounts.get(t.id) ?? 0, + fileCounts.get(t.id) ?? 0, + ), + ); + + const counts = await this.segmentCounts(repo.id, q); + return { ...paginated(items, total, pg), counts }; + } + + // 세그먼트별 카운트(검색어 반영) — RepoDetailPage 세그먼트 배지용 + private async segmentCounts( + repoId: string, + q?: string, + ): Promise { + const cqb = this.taskRepo + .createQueryBuilder('task') + .where('task.repo = :repoId', { repoId }); + if (q) { + cqb.andWhere('task.title ILIKE :q', { q: `%${q}%` }); + } + const rows = await cqb + .select('task.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('task.status') + .getRawMany<{ status: TaskStatus; count: string }>(); + + const counts: TaskSegmentCounts = { all: 0, prog: 0, todo: 0, done: 0 }; + for (const r of rows) { + const c = Number(r.count); + counts.all += c; + if (r.status === 'prog' || r.status === 'review') counts.prog += c; + else if (r.status === 'todo') counts.todo += c; + else if (r.status === 'done') counts.done += c; + // 'changes'(수정 요청)는 전체에만 집계(기존 RepoDetailPage 세그먼트 동작과 동일) + } + return counts; } // 업무 단건 상세 — repo 내 순번(seq)으로 조회 async findOne(slugName: string, seq: number): Promise { const repo = await this.getRepoOrThrow(slugName); const task = await this.getTaskOrThrow(repo.id, seq); - return this.toDetailResponse(task, repo); + return this.buildDetail(task, repo); } // 업무 생성 — admin(지시자) 권한. 담당자는 저장소 멤버만 지정 가능 @@ -165,7 +315,17 @@ export class TaskService { ), }); const saved = await this.taskRepo.save(task); - return this.toDetailResponse( + + // 활동 적재 — 업무 생성 + await this.activity.record({ + repoId: repo.id, + actorId: actingUserId, + type: 'task.created', + payload: { taskTitle: saved.title }, + taskSeq: saved.seq, + }); + + return this.buildDetail( await this.getTaskOrThrow(repo.id, saved.seq), repo, ); @@ -182,6 +342,12 @@ export class TaskService { await this.assertAdmin(repo.id, actingUserId); const task = await this.getTaskOrThrow(repo.id, seq); + // 변경 감지용 이전 상태 캡처 + const prevTitle = task.title; + const prevContent = task.content ?? []; + const prevDueIso = task.dueDate ? task.dueDate.toISOString() : null; + const prevAssigneeIds = (task.assignees ?? []).map((a) => a.id).sort(); + if (dto.title !== undefined) task.title = dto.title; if (dto.content !== undefined) task.content = dto.content; if (dto.dueDate !== undefined) { @@ -195,7 +361,132 @@ export class TaskService { } await this.taskRepo.save(task); - return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo); + + // 체크리스트 항목 동기화(추가/삭제/내용·순서 갱신) — 제공된 경우에만 + let checklistChanged = false; + if (dto.checklist !== undefined) { + checklistChanged = await this.syncChecklist(task, dto.checklist); + } + + // 변경 항목별 활동 적재(담당자/마감/일반 수정) + await this.recordUpdateActivities(repo, actingUserId, task, { + dto, + prevTitle, + prevContent, + prevDueIso, + prevAssigneeIds, + checklistChanged, + }); + + return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + } + + // 업무 수정 시 실제로 바뀐 측면만 골라 활동 적재(담당자/마감/일반) + private async recordUpdateActivities( + repo: Repo, + actorId: string, + task: Task, + prev: { + dto: UpdateTaskDto; + prevTitle: string; + prevContent: string[]; + prevDueIso: string | null; + prevAssigneeIds: string[]; + checklistChanged: boolean; + }, + ): Promise { + const { dto } = prev; + + // 담당자 변경 + if (dto.assigneeIds !== undefined) { + const newIds = (task.assignees ?? []).map((a) => a.id).sort(); + if (JSON.stringify(newIds) !== JSON.stringify(prev.prevAssigneeIds)) { + await this.activity.record({ + repoId: repo.id, + actorId, + type: 'task.assignees_changed', + payload: { + taskTitle: task.title, + assigneeNames: (task.assignees ?? []).map((a) => a.name), + }, + taskSeq: task.seq, + }); + } + } + + // 마감 변경 + if (dto.dueDate !== undefined) { + const newDueIso = task.dueDate ? task.dueDate.toISOString() : null; + if (newDueIso !== prev.prevDueIso) { + await this.activity.record({ + repoId: repo.id, + actorId, + type: 'task.due_changed', + payload: { taskTitle: task.title, dueDate: newDueIso }, + taskSeq: task.seq, + }); + } + } + + // 일반 수정(제목/내용/체크리스트) + const titleChanged = + dto.title !== undefined && dto.title !== prev.prevTitle; + const contentChanged = + dto.content !== undefined && + JSON.stringify(dto.content) !== JSON.stringify(prev.prevContent); + if (titleChanged || contentChanged || prev.checklistChanged) { + await this.activity.record({ + repoId: repo.id, + actorId, + type: 'task.edited', + payload: { taskTitle: task.title }, + taskSeq: task.seq, + }); + } + } + + // 체크리스트 전체 동기화 — id 기준 머지(기존 유지/완료 보존, 신규 추가, 빠진 항목 삭제) + // 반환: 의미 있는 변경(추가/삭제/내용변경)이 있었는지 — 활동 적재 판단용(순서만 변경은 제외) + private async syncChecklist( + task: Task, + items: { id?: string; text: string }[], + ): Promise { + const existing = task.checklist ?? []; + const byId = new Map(existing.map((c) => [c.id, c])); + const keepIds = new Set(); + const toSave: ChecklistItem[] = []; + let changed = false; + + items.forEach((it, idx) => { + const found = it.id ? byId.get(it.id) : undefined; + if (found) { + // 기존 항목 — 내용/순서만 갱신(완료 상태 done 은 보존) + if (found.text !== it.text) changed = true; // 내용 변경 감지(갱신 전 비교) + found.text = it.text; + found.sortOrder = idx; + keepIds.add(found.id); + toSave.push(found); + } else { + // 신규 항목 — 완료는 항상 false 로 생성 + changed = true; + toSave.push( + this.checklistRepo.create({ + text: it.text, + done: false, + sortOrder: idx, + task, + }), + ); + } + }); + + const toRemove = existing.filter((c) => !keepIds.has(c.id)); + if (toRemove.length) { + changed = true; + await this.checklistRepo.remove(toRemove); + } + if (toSave.length) await this.checklistRepo.save(toSave); + return changed; } // 업무 삭제 — admin 권한 @@ -207,7 +498,23 @@ export class TaskService { const repo = await this.getRepoOrThrow(slugName); await this.assertAdmin(repo.id, actingUserId); const task = await this.getTaskOrThrow(repo.id, seq); + // 디스크 첨부 파일 정리(베스트 에포트) — DB 행은 FK CASCADE 로 함께 삭제됨 + const files = await this.attachmentRepo.find({ + where: { task: { id: task.id } }, + }); + await Promise.all(files.map((f) => this.deleteFileFromDisk(f.storedName))); + const taskTitle = task.title; // 삭제 전 캡처 + const taskSeq = task.seq; await this.taskRepo.remove(task); + + // 활동 적재 — 업무 삭제(활동은 taskSeq 로만 연결돼 업무 삭제 후에도 유지) + await this.activity.record({ + repoId: repo.id, + actorId: actingUserId, + type: 'task.removed', + payload: { taskTitle }, + taskSeq, + }); } // 상태 변경 — 상태 머신 전이 규칙 + 역할 검증 @@ -218,49 +525,321 @@ export class TaskService { next: TaskStatus, ): Promise { 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 membership = await this.assertMember(repo.id, actingUserId); 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, - ); + await this.transition(repo, task, membership, actingUserId, next); + + return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + } + + // --- 5단계: 댓글 / 답글 --- + + // 댓글 작성 — 저장소 멤버. fileId 로 기존 첨부 연결 가능 + async addComment( + slugName: string, + actingUserId: string, + seq: number, + text: string, + fileId?: string, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + const membership = await this.assertMember(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + + const attachment = fileId + ? await this.getAttachmentOrThrow(task.id, fileId) + : null; + + const comment = this.commentRepo.create({ + task, + author: membership.user, + text, + attachment, + parent: null, + }); + const saved = await this.commentRepo.save(comment); + + // 활동 적재 — 댓글 작성 + await this.activity.record({ + repoId: repo.id, + actorId: actingUserId, + type: 'task.comment_added', + payload: { taskTitle: task.title }, + taskSeq: task.seq, + }); + + return this.toCommentResponse( + await this.commentRepo.findOneOrFail({ + where: { id: saved.id }, + relations: ['author', 'attachment', 'replies', 'replies.author'], + }), + repo, + task.seq, + ); + } + + // 답글 작성 — 저장소 멤버. 최상위 댓글에만 답글 가능(1단계 깊이) + async addReply( + slugName: string, + actingUserId: string, + seq: number, + commentId: string, + text: string, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + const membership = await this.assertMember(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + + const parent = await this.commentRepo.findOne({ + where: { id: commentId, task: { id: task.id }, parent: IsNull() }, + }); + if (!parent) { + throw new NotFoundException(); } - 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(); - } + const reply = this.commentRepo.create({ + task, + author: membership.user, + text, + parent, + }); + const saved = await this.commentRepo.save(reply); - task.status = next; - await this.taskRepo.save(task); + // 활동 적재 — 답글도 댓글과 동일 타입으로 기록 + await this.activity.record({ + repoId: repo.id, + actorId: actingUserId, + type: 'task.comment_added', + payload: { taskTitle: task.title }, + taskSeq: task.seq, + }); - // 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리(부수효과로 영속) - if (next === 'done' && task.checklist?.length) { - for (const item of task.checklist) item.done = true; - await this.checklistRepo.save(task.checklist); - } + return { + id: saved.id, + author: membership.user ? UserService.toPublic(membership.user) : null, + text: saved.text, + createdAt: saved.createdAt.toISOString(), + }; + } - return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo); + // --- 5단계: 파일 첨부 --- + + // 파일 업로드 — 저장소 멤버. 디스크 저장은 multer 가 처리, 메타데이터만 영속 + async addFile( + slugName: string, + actingUserId: string, + seq: number, + file: UploadedDiskFile, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + const membership = await this.assertMember(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + + const name = decodeOriginalName(file.originalname); + const attachment = this.attachmentRepo.create({ + task, + uploader: membership.user, + name, + storedName: file.filename, + size: file.size, + mime: file.mimetype, + kind: deriveKind(file.mimetype, name), + }); + const saved = await this.attachmentRepo.save(attachment); + + // 활동 적재 — 파일 첨부 + await this.activity.record({ + repoId: repo.id, + actorId: actingUserId, + type: 'task.file_attached', + payload: { fileName: name, taskTitle: task.title }, + taskSeq: task.seq, + }); + + return this.toAttachmentResponse( + { ...saved, uploader: membership.user }, + repo, + task.seq, + ); + } + + // 파일 삭제 — 저장소 멤버. 디스크 파일도 정리 + async removeFile( + slugName: string, + actingUserId: string, + seq: number, + fileId: string, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + await this.assertMember(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + const attachment = await this.getAttachmentOrThrow(task.id, fileId); + const fileName = attachment.name; // 삭제 전 캡처 + await this.attachmentRepo.remove(attachment); + await this.deleteFileFromDisk(attachment.storedName); + + // 활동 적재 — 첨부 삭제 + await this.activity.record({ + repoId: repo.id, + actorId: actingUserId, + type: 'task.file_removed', + payload: { fileName, taskTitle: task.title }, + taskSeq: task.seq, + }); + } + + // 다운로드용 첨부 메타 + 디스크 절대경로 — 컨트롤러가 스트리밍 + async getFileForDownload( + slugName: string, + actingUserId: string, + seq: number, + fileId: string, + ): Promise<{ attachment: Attachment; absolutePath: string }> { + const repo = await this.getRepoOrThrow(slugName); + await this.assertMember(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + const attachment = await this.getAttachmentOrThrow(task.id, fileId); + return { + attachment, + absolutePath: join(UPLOAD_DIR, attachment.storedName), + }; + } + + // --- 5단계: 승인 워크플로우 --- + + // 승인 요청(담당자 → 지시자): prog/changes → review. 요청 메시지 보관 + async requestApproval( + slugName: string, + actingUserId: string, + seq: number, + note?: string, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + const membership = await this.assertMember(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + + task.approvalNote = note?.trim() ? note.trim() : null; + task.approvalRequestedBy = membership.user; + task.approvalRequestedAt = new Date(); + task.changesNote = null; + + await this.transition(repo, task, membership, actingUserId, 'review'); + return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + } + + // 승인(지시자/admin): review → done + async approve( + slugName: string, + actingUserId: string, + seq: number, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + const membership = await this.assertMember(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + + await this.transition(repo, task, membership, actingUserId, 'done'); + return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + } + + // 수정 요청(지시자/admin): review → changes. 보완 메시지 보관 + async requestChanges( + slugName: string, + actingUserId: string, + seq: number, + note: string, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + const membership = await this.assertMember(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + + task.changesNote = note; + task.approvalNote = null; + task.approvalRequestedAt = null; + + await this.transition(repo, task, membership, actingUserId, 'changes'); + return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); + } + + // --- 7단계: 내 업무(저장소 횡단 집계) --- + + // 내가 담당인 업무 — 마감 임박순(마감 없음은 뒤로), 오프셋 페이지네이션 + async listAssigned( + userId: string, + page?: number, + size?: number, + ): Promise> { + const pg = resolvePage(page, size); + const [tasks, total] = await this.taskRepo + .createQueryBuilder('task') + .leftJoinAndSelect('task.repo', 'repo') + .leftJoinAndSelect('task.assignees', 'assignee') + .leftJoinAndSelect('task.issuer', 'issuer') + .leftJoinAndSelect('task.checklist', 'checklist') + .where( + 'task.id IN ' + + this.taskRepo + .createQueryBuilder() + .subQuery() + .select('ta.task_id') + .from('task_assignees', 'ta') + .where('ta.user_id = :userId') + .getQuery(), + ) + .setParameter('userId', userId) + .orderBy('task.due_date', 'ASC', 'NULLS LAST') + .addOrderBy('task.seq', 'DESC') + .skip(pg.skip) + .take(pg.take) + .getManyAndCount(); + return paginated( + tasks.map((t) => this.toMyTaskRow(t)), + total, + pg, + ); + } + + // 내가 지시(생성)한 업무 — 마감 임박순, 오프셋 페이지네이션 + async listIssued( + userId: string, + page?: number, + size?: number, + ): Promise> { + const pg = resolvePage(page, size); + const [tasks, total] = await this.taskRepo + .createQueryBuilder('task') + .leftJoinAndSelect('task.repo', 'repo') + .leftJoinAndSelect('task.assignees', 'assignee') + .leftJoinAndSelect('task.issuer', 'issuer') + .leftJoinAndSelect('task.checklist', 'checklist') + .where('task.issuer = :userId', { userId }) + .orderBy('task.due_date', 'ASC', 'NULLS LAST') + .addOrderBy('task.seq', 'DESC') + .skip(pg.skip) + .take(pg.take) + .getManyAndCount(); + return paginated( + tasks.map((t) => this.toMyTaskRow(t)), + total, + pg, + ); + } + + // 엔티티 → 내 업무 행 응답 + private toMyTaskRow(task: Task): MyTaskRowResponse { + const checklist = task.checklist ?? []; + const done = checklist.filter((c) => c.done).length; + return { + id: task.seq, + repoId: task.repo.slugName, + repoName: task.repo.name, + title: task.title, + status: task.status, + dueDate: task.dueDate ? task.dueDate.toISOString() : null, + checklist: [done, checklist.length], + assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)), + issuer: task.issuer ? UserService.toPublic(task.issuer) : null, + }; } // --- 집계(다른 모듈에서 사용) --- @@ -305,6 +884,58 @@ export class TaskService { // --- 내부 헬퍼 --- + // 상태 전이 1건 적용 — 전이 규칙 + 역할 검증 후 저장(부수효과 포함) + private async transition( + repo: Repo, + task: Task, + membership: RepoMember, + actingUserId: string, + next: TaskStatus, + ): Promise { + const from = task.status; + if (from === next) { + return; + } + 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); + } + + // 활동 적재 — 상태 전이(시작/승인요청/승인/수정요청 등은 프론트가 from→to 로 문구 파생) + await this.activity.record({ + repoId: repo.id, + actorId: actingUserId, + type: 'task.status_changed', + payload: { statusFrom: from, statusTo: next, taskTitle: task.title }, + taskSeq: task.seq, + }); + } + // slugName 으로 저장소 로딩, 없으면 404 private async getRepoOrThrow(slugName: string): Promise { const repo = await this.repoRepo.findOne({ where: { slugName } }); @@ -318,7 +949,7 @@ export class TaskService { private async getTaskOrThrow(repoId: string, seq: number): Promise { const task = await this.taskRepo.findOne({ where: { repo: { id: repoId }, seq }, - relations: ['assignees', 'issuer', 'checklist'], + relations: ['assignees', 'issuer', 'checklist', 'approvalRequestedBy'], }); if (!task) { throw new NotFoundException(); @@ -328,6 +959,20 @@ export class TaskService { return task; } + // 업무에 속한 첨부 로딩, 없으면 404 + private async getAttachmentOrThrow( + taskId: string, + fileId: string, + ): Promise { + const attachment = await this.attachmentRepo.findOne({ + where: { id: fileId, task: { id: taskId } }, + }); + if (!attachment) { + throw new NotFoundException(); + } + return attachment; + } + // admin 권한 확인 — 멤버십(+user) 반환(지시자 지정용). 아니면 403 private async assertAdmin( repoId: string, @@ -343,6 +988,21 @@ export class TaskService { return membership; } + // 저장소 멤버 확인 — 멤버십(+user) 반환. 아니면 403 + private async assertMember( + repoId: string, + userId: string, + ): Promise { + const membership = await this.memberRepo.findOne({ + where: { repo: { id: repoId }, user: { id: userId } }, + relations: ['user'], + }); + if (!membership) { + throw new ForbiddenException(); + } + return membership; + } + // 담당자 ID 목록 → User[] 로 변환하며 "저장소 멤버" 인지 검증 private async resolveMemberAssignees( repoId: string, @@ -363,8 +1023,55 @@ export class TaskService { return memberships.map((m) => m.user); } + // 업무별 댓글 수(답글 포함) 집계 — 목록 commentCount 용 + private async commentCountsByTask( + taskIds: string[], + ): Promise> { + const map = new Map(); + if (taskIds.length === 0) return map; + const rows = await this.commentRepo + .createQueryBuilder('comment') + .select('comment.task_id', 'taskId') + .addSelect('COUNT(*)', 'count') + .where('comment.task_id IN (:...taskIds)', { taskIds }) + .groupBy('comment.task_id') + .getRawMany<{ taskId: string; count: string }>(); + for (const r of rows) map.set(r.taskId, Number(r.count)); + return map; + } + + // 업무별 첨부 파일 수 집계 — 목록 fileCount 용 + private async fileCountsByTask( + taskIds: string[], + ): Promise> { + const map = new Map(); + if (taskIds.length === 0) return map; + const rows = await this.attachmentRepo + .createQueryBuilder('att') + .select('att.task_id', 'taskId') + .addSelect('COUNT(*)', 'count') + .where('att.task_id IN (:...taskIds)', { taskIds }) + .groupBy('att.task_id') + .getRawMany<{ taskId: string; count: string }>(); + for (const r of rows) map.set(r.taskId, Number(r.count)); + return map; + } + + // 디스크 파일 삭제(베스트 에포트 — 이미 없으면 무시) + private async deleteFileFromDisk(storedName: string): Promise { + try { + await unlink(join(UPLOAD_DIR, storedName)); + } catch { + // 파일이 이미 없거나 권한 문제 — 무시(로그성 정리) + } + } + // 엔티티 → 목록 행 응답 - private toListResponse(task: Task): RepoTaskResponse { + private toListResponse( + task: Task, + commentCount: number, + fileCount: number, + ): RepoTaskResponse { const checklist = task.checklist ?? []; const done = checklist.filter((c) => c.done).length; return { @@ -373,17 +1080,40 @@ export class TaskService { status: task.status, dueDate: task.dueDate ? task.dueDate.toISOString() : null, checklist: [done, checklist.length], - commentCount: 0, + commentCount, + fileCount, assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)), }; } - // 엔티티 → 상세 응답 - private toDetailResponse(task: Task, repo: Repo): TaskDetailResponse { + // 업무 상세 + 부속(댓글/첨부/승인) 조립 + private async buildDetail( + task: Task, + repo: Repo, + ): Promise { + const [comments, files, activities] = await Promise.all([ + this.loadComments(task.id, repo, task.seq), + this.loadFiles(task.id, repo, task.seq), + this.activity.listForTask(repo.id, task.seq), + ]); + const checklist = (task.checklist ?? []) .slice() .sort((a, b) => a.sortOrder - b.sortOrder) .map((c) => ({ id: c.id, text: c.text, done: c.done })); + + // 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출 + const approval: ApprovalInfo | null = + task.status === 'review' && task.approvalRequestedAt + ? { + requester: task.approvalRequestedBy + ? UserService.toPublic(task.approvalRequestedBy) + : null, + requestedAt: task.approvalRequestedAt.toISOString(), + note: task.approvalNote, + } + : null; + return { id: task.seq, repoId: repo.slugName, @@ -396,9 +1126,83 @@ export class TaskService { issuer: task.issuer ? UserService.toPublic(task.issuer) : null, dueDate: task.dueDate ? task.dueDate.toISOString() : null, createdAt: task.createdAt.toISOString(), - comments: [], - activities: [], - files: [], + comments, + activities, + files, + approval, + changesNote: task.status === 'changes' ? task.changesNote : null, + }; + } + + // 최상위 댓글(+답글) 로딩 후 응답 매핑 + private async loadComments( + taskId: string, + repo: Repo, + seq: number, + ): Promise { + const comments = await this.commentRepo.find({ + where: { task: { id: taskId }, parent: IsNull() }, + relations: ['author', 'attachment', 'replies', 'replies.author'], + order: { createdAt: 'ASC' }, + }); + return comments.map((c) => this.toCommentResponse(c, repo, seq)); + } + + // 업무 첨부 로딩 후 응답 매핑 + private async loadFiles( + taskId: string, + repo: Repo, + seq: number, + ): Promise { + const files = await this.attachmentRepo.find({ + where: { task: { id: taskId } }, + relations: ['uploader'], + order: { createdAt: 'ASC' }, + }); + return files.map((f) => this.toAttachmentResponse(f, repo, seq)); + } + + // 첨부 엔티티 → 응답(다운로드 URL 포함) + private toAttachmentResponse( + att: Attachment, + repo: Repo, + seq: number, + ): AttachmentResponse { + return { + id: att.id, + name: att.name, + size: att.size, + kind: att.kind, + url: `/api/repos/${repo.slugName}/tasks/${seq}/files/${att.id}/download`, + uploader: att.uploader ? UserService.toPublic(att.uploader) : null, + createdAt: att.createdAt.toISOString(), + }; + } + + // 댓글 엔티티 → 응답(답글 시간순 정렬) + private toCommentResponse( + comment: Comment, + repo: Repo, + seq: number, + ): CommentResponse { + const replies = (comment.replies ?? []) + .slice() + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map((r) => ({ + id: r.id, + author: r.author ? UserService.toPublic(r.author) : null, + text: r.text, + createdAt: r.createdAt.toISOString(), + })); + return { + id: comment.id, + author: comment.author ? UserService.toPublic(comment.author) : null, + text: comment.text, + file: comment.attachment + ? this.toAttachmentResponse(comment.attachment, repo, seq) + : null, + createdAt: comment.createdAt.toISOString(), + replies, }; } } diff --git a/backend/src/modules/task/upload.config.ts b/backend/src/modules/task/upload.config.ts new file mode 100644 index 0000000..439f876 --- /dev/null +++ b/backend/src/modules/task/upload.config.ts @@ -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'); +} diff --git a/docs/api-contract.md b/docs/api-contract.md index 1dbd71f..5c877a6 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -42,10 +42,14 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프론트는 `BIZ_001` 코드일 때 서버 `message` 를 그대로 노출한다. 예: 로그인 실패 → `BusinessException('BIZ_001', '이메일 또는 비밀번호가 올바르지 않습니다.', 401)`. -### 페이지네이션 +### 페이지네이션 — **8단계에서 통일(진행 중)** -목록 API 는 오프셋 기반: 쿼리 `?page=1&size=20`, 응답 `{ items: [...], total, page, size }`. -(1단계에서는 단순 배열 허용, 7단계에서 페이지네이션으로 통일) +목록 API 는 오프셋 기반: 쿼리 `?page=1&size=20`(기본 size 20, 최대 100), 응답 `{ items: [...], total, page, size }`. +프론트는 **더보기 버튼**으로 점진 로드(시안에 페이지 UI 없음). + +- **백엔드 적용 완료**: `GET /repos`, `/repos/:id/members`, `/tasks/assigned`, `/tasks/issued`, `/repos/:id/tasks`(세그먼트/검색 + `counts` 동봉). 공통 유틸 `common/pagination`. +- **프론트 적용 완료**: 저장소 목록, 내 업무. **남음**: 멤버, 저장소 업무 목록(RepoDetailPage). +- 응답이 단순 배열 → `{ items, total, page, size }` 로 바뀌었으므로 프론트 composable/store 가 함께 갱신돼야 한다. --- @@ -206,8 +210,8 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done - 생성 시 status 는 항상 `todo`. 저장소 `doneCount/totalCount` 는 업무 status 집계(`done` 수/전체)로 실시간 반영, 멤버 `taskCount` 는 사용자별 담당 업무 수로 집계. `CreateTaskDto`: `{ title, content?: string[], assigneeIds: uuid[](≥1), dueDate?: ISO, checklist?: {text}[] }` -`UpdateTaskDto`: `{ title?, content?: string[], assigneeIds?: uuid[], dueDate?: ISO|null }` — 제공된 필드만 갱신(체크리스트 항목 편집은 5단계). -`RepoTask`(목록 행): `{ id(seq), title, status, dueDate: ISO|null, checklist: [done,total], commentCount, assignees: User[] }` +`UpdateTaskDto`: `{ title?, content?: string[], assigneeIds?: uuid[], dueDate?: ISO|null, checklist?: {id?, text}[] }` — 제공된 필드만 갱신. `checklist` 제공 시 **전체 동기화**(id 있으면 기존 유지·완료 보존, 없으면 신규, 빠진 기존 항목은 삭제). +`RepoTask`(목록 행): `{ id(seq), title, status, dueDate: ISO|null, checklist: [done,total], commentCount, fileCount, assignees: User[] }` — 목록 행은 댓글 수(말풍선)·첨부 수(클립)를 **분리 표기**(각 0이면 숨김). `TaskDetail`: `{ id(seq), repoId, repoName, title, status, content[], checklist: {id,text,done}[], assignees[], issuer: User|null, dueDate: ISO|null, createdAt, comments[], activities[], files[] }` > **comments/activities/files 는 5·6단계 영역** — 4단계 응답에서는 빈 배열로 내려간다. `approval` 객체도 5단계(현재 미포함). @@ -219,14 +223,15 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done --- -## 5. 업무 상세 부속 — **5단계** +## 5. 업무 상세 부속 — **5단계 ✅ 구현 완료** -### 체크리스트 -| 메서드 | 경로 | 요청 | 응답 | -|---|---|---|---| -| POST | `.../tasks/:taskId/checklist` | `{ text }` | `ChecklistItem` | -| PATCH | `.../checklist/:itemId` | `{ text?, done? }` | `ChecklistItem` | -| DELETE | `.../checklist/:itemId` | — | `null` | +> 모든 엔드포인트 `JwtAuthGuard` + 저장소 멤버. `:taskId`=seq(정수), `:itemId`/`:fileId`/`:commentId`=uuid. +> 파일 저장은 **서버 로컬 업로드 폴더**(`UPLOAD_DIR`, 기본 `cwd/uploads`) 채택. 승인 워크플로우는 4단계 상태머신(`POST .../status`)을 노트와 함께 확장한 형태. + +### 체크리스트 — **상세 화면 읽기 전용 / 정의는 생성·수정 폼에서** +- **완료 토글은 사용자가 하지 않는다** — 상세 화면에서는 표시만 하고, 완료는 승인 워크플로우에서 일괄 처리된다("승인 완료(→done) 시 체크리스트 항목 done 일괄 true", 4단계 부수효과). +- **항목 추가/삭제(정의)는 업무 생성·수정 폼에서** 한다: 생성=`CreateTaskDto.checklist`, 수정=`UpdateTaskDto.checklist`(id 기준 전체 동기화). +- (5단계 초기 설계의 `POST/PATCH/DELETE .../checklist` 항목별 토글 API 는 제거됨 — 항목 편집은 업무 수정 PATCH 로 통합.) ### 댓글 / 답글 | 메서드 | 경로 | 요청 | 응답 | @@ -240,7 +245,7 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done | POST | `.../tasks/:taskId/files` | `multipart/form-data` | `Attachment` | | DELETE | `.../files/:fileId` | — | `null` | -> **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO). `Attachment`: `{ id, name, size, kind('pdf'|'zip'|'doc'|'img'), url }`. +> **결정됨**: 파일 저장소 = **서버 로컬 업로드 폴더**(S3/MinIO 후순위). `Attachment`: `{ id, name, size(바이트), kind('pdf'|'zip'|'doc'|'img'), url(다운로드 경로), uploader: User|null, createdAt }`. 업로드 필드명 `file`. 다운로드는 `GET .../files/:fileId/download`(인증 쿠키로 접근, 인라인 스트리밍). 크기 표기는 프론트 `formatBytes`. ### 승인 워크플로우 | 메서드 | 경로 | 요청 | 응답 | @@ -251,29 +256,34 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done --- -## 6. 활동 피드 — **6단계** +## 6. 활동 피드 — **6단계 ✅ 구현 완료** 활동은 **2~5단계 행위에서 이벤트로 자동 적재**(쓰기 API 없음, 읽기만). | 메서드 | 경로 | 응답 | |---|---|---| -| GET | `/repos/:repoId/activity` | `RepoActivityDay[]`(날짜 그룹) | -| (업무 활동) | `TaskDetail.activities` 에 포함 | `Activity[]` | +| GET | `/repos/:repoId/activity` | `ApiActivity[]`(최신순, 최대 200) — 날짜 그룹핑은 프론트 | +| (업무 활동) | `TaskDetail.activities` 에 포함 | `ApiActivity[]`(해당 업무 seq) | -`RepoActivityItem`: `{ tone, icon, actor: User, payload, createdAt }` -> 서버는 이벤트 타입 + 행위자 + 대상만 저장하고, **표시 HTML 문구는 프론트에서 조립**(현재 mock 은 html 통문자열 — 연동 시 구조화 권장). 매핑: RepoActivityPage, TaskDetail 활동 탭. +`ApiActivity`(원시): `{ id, type, actor: User|null, taskSeq: number|null, payload: object, createdAt }` +- `type`: 저장소 `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`(타입별): `taskTitle`, `statusFrom`/`statusTo`, `targetName`, `roleType`, `newName`, `visibility`, `fileName`, `assigneeNames`, `dueDate` 등. +> 업무 수정(`update`)은 **실제로 바뀐 측면만** 골라 적재한다(담당자 변경=`assignees_changed`, 마감 변경=`due_changed`, 제목·내용·체크리스트 변경=`edited`). 답글은 댓글과 동일 `task.comment_added`. 저장소 삭제는 활동도 함께 사라져 미적재, 저장소 설명/브랜치 변경·체크리스트 개별 완료는 미적재(승인 완료로 일괄 표현). +> **서버는 이벤트 타입 + 행위자 + 대상(payload)만 저장**하고, 표시 문구/아이콘/톤·날짜 그룹은 **프론트가 type+payload 로 조립**(구조화 채택). 사용자 입력은 `escapeHtml` 후 신뢰 태그만 삽입(v-html XSS 방지). 적재는 베스트 에포트(실패해도 본 기능 안 막음), 6단계 도입 이후 발생분만 기록(소급 없음). 매핑: RepoActivityPage(저장소 활동), TaskDetail 활동 탭(업무 활동). --- -## 7. 내 업무 (`/api/tasks`) — **7단계** +## 7. 내 업무 (`/api/tasks`) — **7단계 ✅ 구현 완료** | 메서드 | 경로 | 요청 | 응답 | |---|---|---|---| -| GET | `/tasks/assigned` | — | `MyTaskGroup[]`(상태 그룹) — 내가 담당 | -| GET | `/tasks/issued` | — | `MyTaskGroup[]` — 내가 지시 | +| GET | `/tasks/assigned` | — | `MyTaskRow[]` — 내가 담당(저장소 횡단) | +| GET | `/tasks/issued` | — | `MyTaskRow[]` — 내가 지시(저장소 횡단) | -저장소 횡단 집계. `MyTaskGroup`/`MyTaskRow` 는 mock 인터페이스와 동일하되 라벨류 제외, 원시값(`dueDate`, `status`) 기반. -> 매핑 페이지: MyTasksPage(담당/지시 탭, 상태 그룹). `countActive` 는 프론트 유지. +- 백엔드는 **플랫 행 배열**(`MyTaskRowResponse[]`)을 마감 임박순(마감 없음 뒤로)으로 반환하고, **상태 그룹핑·순서·라벨은 프론트가 파생**(활동 피드와 동일 패턴). +- `MyTaskRowResponse`: `{ id(seq), repoId(slug), repoName, title, status, dueDate, checklist:[done,total], assignees: User[], issuer: User|null }`. +- 프론트 파생: 담당 뷰=지시자 표시·그룹순서 `[changes,prog,todo,review,done]`, 지시 뷰=담당자 미니아바타·승인대기 강조(`내 승인 필요`)·그룹순서 `[review,prog,changes,todo,done]`. `dueLabel`/`dday`/`ddayLevel`/`overdue` 는 `taskDueInfo` 로 파생. +> 매핑 페이지: MyTasksPage(담당/지시 탭, 상태 그룹). 행 클릭은 항상 `…/tasks/:taskId`(TaskDetailPage, 역할 기반 분기). **`TaskAssigneePage`(`/view`) 라우트·파일 제거 완료.** --- diff --git a/docs/integration-progress.md b/docs/integration-progress.md index 5b12b41..2c71d9b 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -2,8 +2,8 @@ > 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록. > 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조. -> 최종 갱신: 2026-06-17 · 현재 위치: **0~4단계 완료(+ Gitea 양방향 연동·템플릿 복제·생성폼 보강·멤버 프론트 연동·멤버 초대 자동완성·import 멤버 데드락 해소·cloneUrl/htmlUrl·giteaMissing 배지·업무 CRUD/상태머신/진행률 집계), 5단계(업무 상세 부속) 대기** -> 남은 프론트 연동: 업무 상세의 댓글/체크리스트 토글/첨부/승인 노트(5단계). 그 외 6~8단계 미착수. +> 최종 갱신: 2026-06-18 · 현재 위치: **0~7단계 완료(모든 화면 실데이터 연동). 8단계(마무리·선택) 진행 중 — 페이지네이션 통일 작업 중.** +> 8단계 진행: **페이지네이션** 백엔드 전체 완료 + 프론트 2/4 도메인(저장소 목록·내 업무) 완료, 멤버·저장소 업무 도메인 대기. 그 외 8단계 항목(소셜 로그인·이메일 인증·알림·AI 패널·Redis 캐싱)은 미착수. --- @@ -18,7 +18,7 @@ ``` 0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드+프론트) -→ 4. 업무 ✅(백엔드+프론트) → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리 +→ 4. 업무 ✅(백엔드+프론트) → 5. 업무 상세 부속 ✅(백엔드+프론트) → 6. 활동 피드 ✅(백엔드+프론트) → 7. 내 업무 → 8. 마무리 ``` --- @@ -130,7 +130,7 @@ - `shared/utils/format.ts`: `taskStatusLabel`·`taskDueInfo`(dueDate+완료여부→ dateLabel/dday/listLabel/overdue)·`monthDayKo` 추가. - `types/task.ts`, `composables/useTask.ts`(list/get/create/update/remove/changeStatus), `stores/task.store.ts`(API→mock `RepoTask`/`TaskDetail` 뷰 매핑: 마감/dday/상태 라벨 파생, 지시자 null 폴백). - 페이지 연동: **RepoDetailPage**(업무 목록 실데이터+로딩/빈상태, 툴바 ‘새 업무’ 버튼), **TaskCreatePage**(제목/내용 textarea/체크리스트/마감일/담당자=멤버 선택 드롭다운 → POST → 생성 상세로 이동), **TaskDetailPage**(상세 fetch, 삭제=DELETE, 승인=review→done·수정요청=review→changes), **TaskAssigneePage**(상세 fetch, 실제 status 로 카드 분기: 시작 todo→prog·승인요청 prog→review·재요청 changes→review, 삭제). -- **업무 수정 UI**: `TaskCreatePage` 를 생성/수정 겸용으로 확장. 라우트 `/repos/:repoId/tasks/:taskId/edit`(`task-edit`) 추가 → `taskId` 있으면 편집 모드(기존 값 미리 채움 + `useTask().get` 로 원시값 로드 + 제출 시 PATCH `taskStore.update`). 헤더/버튼 라벨 전환(업무 수정/저장). 상세·담당자 페이지의 ‘수정’ 버튼을 `…/edit` 로 연결(기존엔 빈 생성 폼으로 오연결). **체크리스트 항목 편집은 5단계** — 수정 폼에선 표시·읽기전용(제목/내용/담당자/마감만 저장). +- **업무 수정 UI**: `TaskCreatePage` 를 생성/수정 겸용으로 확장. 라우트 `/repos/:repoId/tasks/:taskId/edit`(`task-edit`) 추가 → `taskId` 있으면 편집 모드(기존 값 미리 채움 + `useTask().get` 로 원시값 로드 + 제출 시 PATCH `taskStore.update`). 헤더/버튼 라벨 전환(업무 수정/저장). 상세·담당자 페이지의 ‘수정’ 버튼을 `…/edit` 로 연결(기존엔 빈 생성 폼으로 오연결). (※ 수정 폼의 체크리스트 항목 추가/삭제는 2026-06-18 에 지원 — `UpdateTaskDto.checklist` 동기화. 제목/내용/담당자/마감과 함께 저장.) - 댓글/AI 에이전트 패널/첨부 업로드는 **로컬 데모 유지(5단계 실연동)**. 담당자 페이지의 `?state=` 쿼리 의존 제거 → `task.status` 기반 분기. - **역할 기반 액션 통합(2026-06-17)**: 목록은 항상 `TaskDetailPage`(`…/tasks/:taskId`)로 진입하고, 이 페이지가 **현재 사용자 역할로 액션을 분기**한다. `isIssuer`(task.issuer===me) / `isAssignee`(me ∈ assignees) 계산 → 지시자는 승인 대기(review)에서 승인/수정요청, 담당자는 `업무 시작`(todo→prog)·`승인 요청`(prog→review)·`다시 승인 요청`(changes→review)·승인 대기 중(review) 표시. 담당자가 "업무 시작"에 도달할 경로가 없던 문제 해소(기존 시작 버튼은 `/view`(TaskAssigneePage)에만 있었고 거기로 가는 링크가 mock MyTasksPage뿐이었음). `TaskAssigneePage`/`/view` 는 mock MyTasks 전용으로 잔존(7단계 정리). **한계**: 지시자가 아닌 admin은 화면상 승인 버튼 미노출(편집/삭제는 가능, 백엔드는 admin 승인 허용). - **UI 정리(2026-06-17)**: 공용 `RepoHeader` 기본 ‘새 업무’ 버튼 제거(`#actions` 슬롯 제공 시에만 렌더) → 상세 헤더 중복 버튼 해소. 작성 폼 ‘임시저장’ 제거, 체크박스 토글 비활성(어느 화면도 사용자 체크 불가), 담당자 칩을 입력칸 아래로 분리, 지시자 직무 줄 제거. 저장소 목록의 진행률 라벨(‘시작 전/67% 완료’) 제거(업무 수·바는 유지). @@ -151,34 +151,87 @@ - `CreateRepoDto.useTemplate=true` 면 빈 저장소 대신 복제 생성. 프론트 생성폼 템플릿 드롭다운=실제 옵션(없음 / `TtiPo/project-base`). - 라이브 검증: 생성된 repo 의 파일 트리가 `project-base` 와 동일하게 복제됨 확인. +### 5단계 — 업무 상세 부속 ✅ (백엔드 + 프론트) + +**백엔드** `backend/src/modules/task/` +- 신규 엔티티: `Comment`(답글은 `parent` 자기참조로 1단계 깊이, `attachment` 선택 연결, author/parent `Relation<>`), `Attachment`(원본명·`storedName`·size·mime·kind, uploader SET NULL). `Task` 에 승인 워크플로우 필드 추가(`approvalNote`/`approvalRequestedBy`/`approvalRequestedAt`/`changesNote`). +- **체크리스트**: **상세 화면 읽기 전용**(완료 토글은 사용자가 하지 않음 — 승인 완료 시 일괄 처리, 4단계 부수효과). **항목 추가/삭제(정의)는 업무 생성·수정 폼에서** 한다: 생성=`CreateTaskDto.checklist`, 수정=`UpdateTaskDto.checklist`(id 기준 전체 동기화 — 기존 유지·완료 보존, 신규 추가, 빠진 항목 삭제). — *2026-06-18: 5단계 초기의 항목별 토글 API 제거 → 수정 PATCH 로 통합* +- **댓글/답글**: `POST .../comments`(`text`/`fileId?`), `POST .../comments/:commentId/replies` — 멤버. 답글은 최상위 댓글에만. +- **파일 첨부**: `POST .../files`(multipart, `FileInterceptor` + `dest`), `DELETE .../files/:fileId`, `GET .../files/:fileId/download`(`@Res` 직접 스트리밍, `Content-Disposition` UTF-8) — 멤버. 비ASCII 파일명 latin1→UTF-8 복원, MIME/확장자로 `kind` 파생. +- **승인 워크플로우**: `POST .../approval/request`(prog/changes→review, note 선택), `/approve`(review→done), `/changes`(review→changes, note 필수). 4단계 상태머신 전이 로직을 `transition()` 으로 추출해 `POST .../status` 와 공유(역할 검증 동일). 승인 정보는 review 상태일 때만 `approval{requester,requestedAt,note}` 로 노출, changes 상태는 `changesNote`. +- `TaskDetailResponse` 에 `comments`/`files`/`approval`/`changesNote` 실데이터 채움. 목록 `commentCount` 실집계. `activities` 는 6단계라 빈 배열 유지. +- **파일 저장 방식(결정됨)**: **서버 로컬 업로드 폴더**(`upload.config.ts` `UPLOAD_DIR`, 기본 `cwd/uploads`). `@types/multer` 의존 없이 `UploadedDiskFile` 인터페이스 자체 정의. 운영 Dockerfile 이 `/app/uploads` 를 `node` 소유로 생성(비-root 쓰기 가능). dev 는 `./backend:/app` 바인드라 `backend/uploads`(gitignore)로 영속, 운영 영속이 필요하면 해당 경로에 볼륨 마운트(추후). S3/MinIO 는 후순위. +- 테스트 `task.service.spec.ts` 보강(승인 요청/수정요청 역할, 체크리스트 비멤버 차단). 백엔드 **21 tests pass**, build/lint 0 err. + +**프론트** `frontend/src/` +- `shared/utils/format.ts`: `formatBytes`(바이트→"18.4 MB"). `types/task.ts`: `ApiAttachment`/`ApiComment`/`ApiCommentReply`/`ApiApproval` + 부속 페이로드. `composables/useTask.ts`: 체크리스트/댓글/답글/파일(FormData)/승인 메서드. `stores/task.store.ts`: 댓글·첨부·승인 뷰 매핑(작성자 시간·역할배지·바이트 변환), 부속 변경 후 상세 재조회로 일관 갱신. +- mock 뷰 타입(`ChecklistItem`/`Attachment`/`Comment`/`Reply`/`TaskDetail`)에 옵셔널 `id`/`url`/`changesNote` 추가(연동용, 기존 화면 호환). +- **TaskDetailPage**: 체크리스트는 읽기 전용(표시만), 댓글/답글 실연동(로컬 데모 제거), 첨부 카드 항상 표시+업로드/다운로드 링크/삭제, 승인 요청 메모(prog/changes 카드 textarea)·수정 요청 메모(모달 textarea) 전송, 지시자 승인 카드에 요청 메모 노출·담당자 수정요청 카드에 보완 메모 노출. type-check/lint/build 0 err. + +> **한계**: 댓글에 파일 첨부(`fileId`)는 백엔드만 지원(상세 화면 UI 미노출). 업무 삭제 시 디스크 파일은 베스트에포트 정리(DB 행은 FK CASCADE). AI 에이전트 패널은 여전히 로컬 데모(8단계). + +### 6단계 — 활동 피드 ✅ (백엔드 + 프론트) + +**백엔드** `backend/src/modules/activity/` +- `Activity` 엔티티: `repo`(FK, CASCADE)·`taskSeq`(int nullable, 업무 활동 필터용 — Task FK 대신 seq 저장)·`actor`(User SET NULL)·`type`·`payload`(jsonb)·`createdAt`. 표시 문구/아이콘/톤은 저장하지 않고 **프론트가 type+payload 로 조립**(구조화 채택). +- `ActivityService`: `record()`(베스트 에포트 적재 — 실패해도 본 흐름 안 막음), `listForRepo(slug)`(최신순 200건), `listForTask(repoId, seq)`. `ActivityController`: `GET /repos/:repoId/activity`(인증, 조직 모델상 누구나 열람). +- **이벤트 자동 적재**(다른 서비스가 `ActivityService.record` 호출): RepoService(생성/이름변경/공개범위), MemberService(초대/역할변경/제거), TaskService(생성·상태전이[`transition` 단일 지점]·댓글/답글·파일 첨부/삭제·업무 삭제·**업무 수정[변경 측면별: 담당자/마감/일반]**). 상태전이는 `task.status_changed` 단일 타입 + `payload{statusFrom,statusTo}` 로 적재하고 프론트가 from→to 로 문구 파생(시작/승인요청/승인/수정요청). 업무 수정(`update`)은 이전 상태와 비교해 **실제 바뀐 측면만** 적재(`task.assignees_changed`/`task.due_changed`/`task.edited`). `ActivityModule` 을 RepoModule·TaskModule 이 import(Activity 는 taskSeq 만 보관해 Task 엔티티 미참조 → 순환 없음). +- `TaskDetailResponse.activities` 를 `ActivityService.listForTask` 로 채움(기존 빈 배열 제거). + +**프론트** `frontend/src/` +- `format.ts`: `dayGroupKo`(오늘/어제/이번 주/날짜), `escapeHtml`(XSS 방지). `types/activity.ts`(`ApiActivity`), `composables/useActivity.ts`, `stores/activity.store.ts`(type+payload→톤/아이콘/문구 조립 + 날짜 그룹핑). **사용자 입력(이름/제목/파일명/표시명)은 escapeHtml 후 신뢰 태그만 조립**해 v-html 렌더. +- **RepoActivityPage**: mock(`REPO_ACTIVITY`/`findRepo`) 제거 → 실연동(저장소 헤더=repo.store, 활동=activity.store, tone 필터, 로딩/빈 상태). +- **TaskDetailPage 활동 탭**: `task.store` 의 `toTaskActivityView` 로 API 활동을 시안 Activity 뷰(tone/icon/html/statusFrom·To/file)로 매핑 → 실데이터 표시. +- 검증: 프론트 type-check/lint/build 0 err, 백엔드 build/lint 0 err·20 tests pass. + +> **한계**: 활동은 **6단계 도입 이후 발생분만** 기록(소급 적재 없음). 활동 문구 내 업무 링크는 표시(`.tgt`)만 하고 클릭 라우팅은 생략. 체크리스트 개별 완료는 별도 이벤트 없이 승인 완료(`status_changed`→done)로 일괄 표현. 저장소 설명/브랜치 변경은 미적재. +> *(2026-06-18 점검·보강: 업무 수정[담당자/마감/일반]·답글·업무 삭제·첨부 삭제 이벤트 추가.)* + +--- + +### 7단계 — 내 업무 집계 ✅ (백엔드 + 프론트) +- 백엔드 `MyTaskController` `GET /tasks/assigned`·`/tasks/issued`(저장소 횡단, `TaskService.listAssigned/listIssued`, 마감 임박순). 플랫 행 배열 반환, 그룹핑·라벨은 프론트 파생. +- 프론트 `format.ts` `taskDueInfo`에 `ddayLevel` 추가, `types/my-task.ts`/`composables/useMyTask.ts`/`stores/my-task.store.ts`(담당/지시 뷰별 그룹 순서·라벨·미니아바타·승인대기 강조). **MyTasksPage** mock 제거→실연동(로딩/빈상태). +- **`TaskAssigneePage`(`/view`)·`task-assignee-view` 라우트·파일 제거 완료**(상세가 역할 기반 통합). 행 클릭은 항상 `…/tasks/:taskId`. +- 라이브 검증: `/tasks/assigned`·`/tasks/issued` 200·정상. + --- ## 3. 남은 단계 -### 5단계 — 업무 상세 부속 -- 체크리스트 **개별 항목 토글/추가/삭제 영속**(현재 항목은 읽기 전용, 단 *승인 완료 시 일괄 완료*는 4단계에서 구현), 댓글/답글, **파일 첨부**, 승인 노트(승인 요청/수정 요청 시 메시지). -- 승인 워크플로우 전용 엔드포인트(`approval/request|approve|changes`)는 상태 머신(4단계 `POST .../status`)에 노트·첨부를 더하는 형태로 확장. -- **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO). +### 8단계 — 마무리(선택) · **진행 중** -### 6단계 — 활동 피드 -- 2~5단계 행위를 이벤트로 적재(쓰기 API 없음, 읽기만). `GET /repos/:repoId/activity`. -- 프론트: **RepoActivityPage**, TaskDetail 활동 탭. -- **결정 필요**: 활동 표현 — 현재 mock은 HTML 통문자열 → 구조화(타입+행위자+대상) 권장. +#### 8-1. 페이지네이션 통일 — 백엔드 ✅ / 프론트 2/4 도메인 -### 7단계 — 내 업무 집계 -- `GET /tasks/assigned`·`/tasks/issued`(저장소 횡단 집계). 프론트: **MyTasksPage**(담당/지시 탭). -- **정리 대상**: 현재 mock `MyTasksPage` 가 유일하게 `…/tasks/:taskId/view`(`TaskAssigneePage`)로 링크 중. 4단계에서 상세(`TaskDetailPage`)가 역할 기반으로 통합됐으므로, 이 단계에서 MyTasks 를 실데이터로 연동하며 `/view` 링크를 `…/tasks/:taskId` 로 바꾸고 `TaskAssigneePage`/`task-assignee-view` 라우트를 제거한다. +**결정(2026-06-18)**: 전체 목록 통일 + 프론트는 **더보기 버튼** 방식(시안에 페이지 UI 없음). -### 8단계 — 마무리(선택) -- 소셜 로그인(Google/Kakao), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일. +**백엔드 ✅ (완료)** +- 공통 유틸 `backend/src/common/pagination/pagination.ts`: `resolvePage(page,size)`(범위 보정 + skip/take), `paginated(items,total,pg)`, `PaginatedResult{items,total,page,size}`. 기본 size 20, 최대 100. +- 컨트롤러는 `@Query('page', DefaultValuePipe(1), ParseIntPipe)` / `size` 로 받아 서비스에 전달. +- 적용: `GET /repos`(RepoService.findAll), `GET /repos/:id/members`(MemberService.list), `GET /tasks/assigned`·`/tasks/issued`(TaskService.listAssigned/listIssued). 응답이 배열→`{items,total,page,size}` 로 변경. +- `GET /repos/:id/tasks`(TaskService.list)는 **세그먼트(all/prog/todo/done)·검색(q)·페이지네이션 + 세그먼트 카운트** 동봉(`RepoTaskListResult = PaginatedResult & { counts }`). 세그먼트 prog=prog+review, changes는 전체에만 집계(기존 RepoDetailPage 동작 보존). 기존 `?status` 쿼리는 `?segment` 로 대체. +- 검증: 백엔드 build/lint 0 err, 20 tests pass(member spec 의 list 응답 `{items,total}` 으로 갱신). + +**프론트 — 2/4 도메인 ✅** +- 공통 타입 `types/pagination.ts`(`Paginated`, `DEFAULT_PAGE_SIZE=20`), 전역 `.load-more` 버튼 스타일(relay.css). +- ✅ **저장소 목록**: `useRepo.list(page,size)`→`Paginated`, `repo.store`(total/page/hasMore/loadingMore + `loadMore` append), RepoListPage 하단 더보기 + total 카운트. 검색/공개여부 필터는 클라 유지(저장소 소규모). +- ✅ **내 업무**: `useMyTask.assigned/issued(page,size)`, `my-task.store`(원시 행 누적 + 그룹핑은 computed 로 파생 → 더보기 시 같은 그룹에 합류, 담당/지시 각각 total/page/hasMore + `loadMoreAssigned`/`loadMoreIssued`), MyTasksPage 뷰별 더보기. +- 검증: 프론트 type-check/lint/build 0 err. + +**프론트 — 남은 2/4 도메인 ⏳** +- ⏳ **멤버**(RepoMembersPage): 더보기 페이지네이션. 단 **TaskCreatePage 가 담당자 후보로 전체 멤버를 사용**하므로, 거기엔 전체 로드(모든 페이지 순회 `fetchAll`)를 별도 보장해야 함. +- ⏳ **저장소 업무**(RepoDetailPage): 현재 세그먼트/검색이 **클라이언트 필터** → 백엔드(`segment`/`q`)로 이전 + 세그먼트 카운트(`counts`) 연동 + 더보기. `useTask.list`·`task.store.fetchList` 응답 형태 변경 반영. (가장 복잡) +- ⚠️ **전환기 주의**: 백엔드 응답이 이미 `{items,...}` 로 바뀌어, 프론트 연동 전까지 **멤버·저장소 상세 화면은 일시적으로 동작이 어긋날 수 있음**(다음 작업에서 해소). + +#### 8-2. 그 외(미착수) +- 소셜 로그인(Google/Kakao OAuth), 이메일 인증, 알림, AI 에이전트 패널 실연동, Redis 캐싱. --- ## 4. 전환기 한계 (현재 시점) -- **RepoActivityPage** 는 아직 mock `findRepo` 사용(6단계에서 해소). (RepoMembersPage 는 3단계, RepoDetail 업무 목록은 4단계에서 프론트 연동 완료) -- **업무 상세의 댓글·체크리스트 토글·첨부·승인 노트**는 5단계 영역 — 현재 댓글/에이전트는 로컬 데모, 첨부/승인노트 UI 는 자리만 표시. 진행률은 4단계 집계로 실데이터. -- `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus`/`RepoTask`/`TaskDetail` 등) + 미연동 화면(활동/내업무) 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전. +- **모든 화면이 실데이터로 연동 완료.** 남은 미연동 요소는 **AI 에이전트 패널(로컬 데모, 8단계)** 뿐. +- `src/mock/relay.mock.ts` 는 이제 **타입 정의 공급원으로만** 사용(여러 store/page 가 `Repo`/`User`/`TaskStatus`/`RepoTask`/`TaskDetail`/`Activity`/`MyTaskGroup` 등 뷰 타입을 import). 정적 데이터(`REPO_ACTIVITY`/`MY_TASKS_*`/`REPOS`/`USERS` 등)는 사용처가 없어졌다 — 추후 타입만 `src/types/` 로 이전하고 데이터는 제거 가능(현재는 유지). --- diff --git a/frontend/src/assets/styles/relay.css b/frontend/src/assets/styles/relay.css index 29ef34b..1cba258 100644 --- a/frontend/src/assets/styles/relay.css +++ b/frontend/src/assets/styles/relay.css @@ -596,3 +596,28 @@ body { .modal-textarea::placeholder { color: var(--text-3); } + +/* ============================================================ + * 더보기 버튼 (목록 페이지네이션 공통) + * ============================================================ */ +.load-more { + display: block; + margin: 1.25rem auto 0; + padding: 0.5rem 1.375rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: #fff; + font-family: inherit; + font-size: 0.844rem; + font-weight: 600; + color: var(--text-2); + cursor: pointer; +} +.load-more:hover { + background: #f7f8fa; + color: var(--text); +} +.load-more:disabled { + opacity: 0.6; + cursor: default; +} diff --git a/frontend/src/composables/useActivity.ts b/frontend/src/composables/useActivity.ts new file mode 100644 index 0000000..77b3f81 --- /dev/null +++ b/frontend/src/composables/useActivity.ts @@ -0,0 +1,14 @@ +import { useApi } from './useApi' +import type { ApiActivity } from '@/types/activity' + +// 활동 피드 API Composable — 인터셉터가 success/data 를 언래핑 +export function useActivity() { + const api = useApi() + + // 저장소 활동 피드(최신순) + async function list(repoId: string): Promise { + return (await api.get(`/repos/${repoId}/activity`)) as unknown as ApiActivity[] + } + + return { list } +} diff --git a/frontend/src/composables/useMyTask.ts b/frontend/src/composables/useMyTask.ts new file mode 100644 index 0000000..3ff9033 --- /dev/null +++ b/frontend/src/composables/useMyTask.ts @@ -0,0 +1,24 @@ +import { useApi } from './useApi' +import type { ApiMyTaskRow } from '@/types/my-task' +import type { Paginated } from '@/types/pagination' + +// 내 업무 API Composable — 저장소 횡단 집계(담당/지시), 오프셋 페이지네이션 +export function useMyTask() { + const api = useApi() + + // 내가 담당인 업무 + async function assigned(page = 1, size = 20): Promise> { + return (await api.get('/tasks/assigned', { + params: { page, size }, + })) as unknown as Paginated + } + + // 내가 지시한 업무 + async function issued(page = 1, size = 20): Promise> { + return (await api.get('/tasks/issued', { + params: { page, size }, + })) as unknown as Paginated + } + + return { assigned, issued } +} diff --git a/frontend/src/composables/useRepo.ts b/frontend/src/composables/useRepo.ts index c350fea..ef76073 100644 --- a/frontend/src/composables/useRepo.ts +++ b/frontend/src/composables/useRepo.ts @@ -5,13 +5,16 @@ import type { RepoCreateMeta, UpdateRepoPayload, } from '@/types/repo' +import type { Paginated } from '@/types/pagination' // 저장소 도메인 API Composable — 인터셉터가 success/data 를 언래핑 export function useRepo() { const api = useApi() - async function list(): Promise { - return (await api.get('/repos')) as unknown as ApiRepo[] + async function list(page = 1, size = 20): Promise> { + return (await api.get('/repos', { + params: { page, size }, + })) as unknown as Paginated } // 저장소 생성 폼 메타(소유자/템플릿) — 생성 전 미리 조회 diff --git a/frontend/src/composables/useTask.ts b/frontend/src/composables/useTask.ts index b8defa1..f3e5fdc 100644 --- a/frontend/src/composables/useTask.ts +++ b/frontend/src/composables/useTask.ts @@ -1,5 +1,8 @@ import { useApi } from './useApi' import type { + ApiAttachment, + ApiComment, + ApiCommentReply, ApiRepoTask, ApiTaskDetail, CreateTaskPayload, @@ -58,5 +61,98 @@ export function useTask() { })) as unknown as ApiTaskDetail } - return { list, get, create, update, remove, changeStatus } + /* ===================== 댓글 / 답글 (5단계) ===================== */ + + async function addComment( + repoId: string, + taskId: number, + text: string, + fileId?: string, + ): Promise { + return (await api.post(`/repos/${repoId}/tasks/${taskId}/comments`, { + text, + ...(fileId ? { fileId } : {}), + })) as unknown as ApiComment + } + + async function addReply( + repoId: string, + taskId: number, + commentId: string, + text: string, + ): Promise { + return (await api.post( + `/repos/${repoId}/tasks/${taskId}/comments/${commentId}/replies`, + { text }, + )) as unknown as ApiCommentReply + } + + /* ===================== 파일 첨부 (5단계) ===================== */ + + async function uploadFile( + repoId: string, + taskId: number, + file: File, + ): Promise { + const form = new FormData() + form.append('file', file) + return (await api.post(`/repos/${repoId}/tasks/${taskId}/files`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + })) as unknown as ApiAttachment + } + + async function removeFile( + repoId: string, + taskId: number, + fileId: string, + ): Promise { + await api.delete(`/repos/${repoId}/tasks/${taskId}/files/${fileId}`) + } + + /* ===================== 승인 워크플로우 (5단계) ===================== */ + + async function requestApproval( + repoId: string, + taskId: number, + note?: string, + ): Promise { + return (await api.post(`/repos/${repoId}/tasks/${taskId}/approval/request`, { + ...(note ? { note } : {}), + })) as unknown as ApiTaskDetail + } + + async function approve( + repoId: string, + taskId: number, + ): Promise { + return (await api.post( + `/repos/${repoId}/tasks/${taskId}/approval/approve`, + )) as unknown as ApiTaskDetail + } + + async function requestChanges( + repoId: string, + taskId: number, + note: string, + ): Promise { + return (await api.post(`/repos/${repoId}/tasks/${taskId}/approval/changes`, { + note, + })) as unknown as ApiTaskDetail + } + + return { + list, + get, + create, + update, + remove, + changeStatus, + addComment, + addReply, + uploadFile, + removeFile, + requestApproval, + approve, + requestChanges, + } } diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index 92d3430..e7bc977 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -23,14 +23,20 @@ export interface User { /** 첨부파일 */ export interface Attachment { + /** 첨부 ID(API 연동 시) */ + id?: string name: string size: string /** 파일 아이콘 종류 */ kind: 'pdf' | 'zip' | 'doc' | 'img' + /** 다운로드 URL(API 연동 시) */ + url?: string } /** 체크리스트 항목 */ export interface ChecklistItem { + /** 항목 ID(API 연동 시) */ + id?: string text: string done: boolean } @@ -76,11 +82,15 @@ export interface RepoTask { checklist?: [number, number] /** 코멘트 수 */ comments?: number + /** 첨부 파일 수 */ + files?: number assignees: User[] } /** 댓글의 답글 */ export interface Reply { + /** 답글 ID(API 연동 시) */ + id?: string author: User time: string text: string @@ -88,6 +98,8 @@ export interface Reply { /** 댓글 */ export interface Comment { + /** 댓글 ID(API 연동 시) */ + id?: string author: User /** 작성자 역할 배지(예: 지시자) */ roleBadge?: string @@ -135,6 +147,8 @@ export interface TaskDetail { requestedAgo: string note: string } + /** 수정 요청 메시지(수정 요청 상태일 때) */ + changesNote?: string issuedLabel: string } @@ -463,6 +477,8 @@ export interface MyTaskRow { title: string status: TaskStatus repoName: string + /** 라우팅용 저장소 식별자(slug) */ + repoId?: string /** 담당자 미니 아바타(지시 뷰) */ assignees?: User[] /** 보조 텍스트(예: "지시 · 한승우", "정민호", "박지훈님이 2시간 전 승인 요청") */ diff --git a/frontend/src/pages/relay/MyTasksPage.vue b/frontend/src/pages/relay/MyTasksPage.vue index 3e21744..d69f4fb 100644 --- a/frontend/src/pages/relay/MyTasksPage.vue +++ b/frontend/src/pages/relay/MyTasksPage.vue @@ -1,34 +1,47 @@ @@ -355,31 +377,6 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo padding-bottom: 0.125rem; white-space: nowrap; } -.new-btn { - margin-left: auto; - height: 2.25rem; - padding: 0 0.9375rem; - border-radius: var(--radius); - font-size: 0.844rem; - font-weight: 600; - border: 1px solid var(--accent); - background: var(--accent); - color: #fff; - box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4); - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 0.375rem; - text-decoration: none; - white-space: nowrap; -} -.new-btn:hover { - background: var(--accent-hover); -} -.new-btn svg { - width: 0.9375rem; - height: 0.9375rem; -} /* 담당/지시 토글 */ .viewtabs { @@ -396,6 +393,7 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo margin-right: 1.125rem; font-size: 0.875rem; font-weight: 600; + line-height: 1; color: var(--text-2); background: none; border: none; @@ -405,6 +403,9 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo white-space: nowrap; font-family: inherit; } +.viewtab .tb-label { + line-height: 1; +} .viewtab:hover { color: var(--text); } @@ -489,6 +490,18 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo font-weight: 700; } +/* 로딩/빈 상태 */ +.state-msg { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 0.625rem; + box-shadow: var(--shadow-sm); + padding: 2.5rem 1.25rem; + text-align: center; + font-size: 0.844rem; + color: var(--text-3); +} + /* 상태 그룹 */ .group { margin-bottom: 1.375rem; diff --git a/frontend/src/pages/relay/RepoActivityPage.vue b/frontend/src/pages/relay/RepoActivityPage.vue index 15ffb01..ca620dc 100644 --- a/frontend/src/pages/relay/RepoActivityPage.vue +++ b/frontend/src/pages/relay/RepoActivityPage.vue @@ -1,29 +1,55 @@ - -