diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4bd7cdd..c3005e5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { HealthModule } from './modules/health/health.module'; import { UserModule } from './modules/user/user.module'; import { AuthModule } from './modules/auth/auth.module'; import { RepoModule } from './modules/repo/repo.module'; +import { TaskModule } from './modules/task/task.module'; @Module({ imports: [ @@ -63,6 +64,7 @@ import { RepoModule } from './modules/repo/repo.module'; UserModule, AuthModule, RepoModule, + TaskModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/modules/repo/member.service.spec.ts b/backend/src/modules/repo/member.service.spec.ts index e584aff..13906bd 100644 --- a/backend/src/modules/repo/member.service.spec.ts +++ b/backend/src/modules/repo/member.service.spec.ts @@ -2,11 +2,12 @@ import { ForbiddenException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { MemberService } from './member.service'; import { UserService } from '../user/user.service'; +import { TaskService } from '../task/task.service'; import { BusinessException } from '../../common/exceptions/business.exception'; import { Repo } from './entities/repo.entity'; import { RepoMember } from './entities/repo-member.entity'; -// 저장소/멤버 리포지토리 + UserService 간이 모킹 +// 저장소/멤버 리포지토리 + UserService/TaskService 간이 모킹 function makeService() { const repoRepo = { findOne: jest.fn() }; const memberRepo = { @@ -17,12 +18,17 @@ function makeService() { remove: jest.fn(), }; const userService = { findByEmail: jest.fn() }; + // 담당 업무 수 집계는 빈 Map(0건)으로 모킹 + const taskService = { + assigneeCounts: jest.fn().mockResolvedValue(new Map()), + }; const svc = new MemberService( repoRepo as unknown as Repository, memberRepo as unknown as Repository, userService as unknown as UserService, + taskService as unknown as TaskService, ); - return { svc, repoRepo, memberRepo, userService }; + return { svc, repoRepo, memberRepo, userService, taskService }; } const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' }; @@ -30,17 +36,22 @@ const ADMIN = { roleType: 'admin' }; describe('MemberService', () => { describe('list', () => { - it('멤버가 아니고 비공개면 접근 거부', async () => { + it('조직 모델: 멤버가 아니어도 인증 사용자면 목록 열람 가능', async () => { const { svc, repoRepo, memberRepo } = makeService(); repoRepo.findOne.mockResolvedValue(REPO); - // 목록에 현재 사용자(other-user)가 없음 + // 목록에 현재 요청자가 없어도(비멤버) 거부하지 않는다 memberRepo.find.mockResolvedValue([ - { user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null } }, + { + user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null }, + email: 'a@b.c', + roleType: 'member', + isOwner: false, + }, ]); - await expect(svc.list('q3-launch', 'other-user')).rejects.toBeInstanceOf( - ForbiddenException, - ); + const result = await svc.list('q3-launch'); + expect(result).toHaveLength(1); + expect(result[0].user.id).toBe('someone-else'); }); }); diff --git a/backend/src/modules/repo/member.service.ts b/backend/src/modules/repo/member.service.ts index f1bb68e..a5e14af 100644 --- a/backend/src/modules/repo/member.service.ts +++ b/backend/src/modules/repo/member.service.ts @@ -8,6 +8,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { UserService, type PublicUser } from '../user/user.service'; import { BusinessException } from '../../common/exceptions/business.exception'; +import { TaskService } from '../task/task.service'; import { Repo } from './entities/repo.entity'; import { RepoMember, type MemberRoleType } from './entities/repo-member.entity'; import { InviteMemberDto } from './dto/invite-member.dto'; @@ -17,7 +18,6 @@ import { UpdateMemberDto } from './dto/update-member.dto'; export interface RepoMemberResponse { user: PublicUser; email: string; - subRole: string | null; taskCount: number; // TODO(4단계): 실제 담당 업무 수 집계 roleType: MemberRoleType; owner: boolean; // 소유자 여부(isOwner) @@ -32,21 +32,21 @@ export class MemberService { @InjectRepository(RepoMember) private readonly memberRepo: Repository, private readonly userService: UserService, + private readonly taskService: TaskService, ) {} - // 멤버 목록 — 멤버이거나 공개 저장소만 조회 가능 - async list(slugName: string, userId: string): Promise { + // 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책). + // 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다. + async list(slugName: string): Promise { const repo = await this.getRepoOrThrow(slugName); const members = await this.memberRepo.find({ where: { repo: { id: repo.id } }, relations: ['user'], order: { createdAt: 'ASC' }, }); - const isMember = members.some((m) => m.user.id === userId); - if (!isMember && repo.visibility !== 'public') { - throw new ForbiddenException(); - } - return members.map((m) => this.toResponse(m)); + // 사용자별 담당 업무 수 집계(taskCount) + const counts = await this.taskService.assigneeCounts(repo.id); + return members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0)); } // 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대 @@ -83,7 +83,6 @@ export class MemberService { user, roleType: dto.roleType, isOwner: false, - subRole: dto.subRole?.trim() || null, }); const saved = await this.memberRepo.save(member); saved.user = user; // 관계 보장(응답 매핑용) @@ -109,7 +108,6 @@ export class MemberService { } if (dto.roleType !== undefined) member.roleType = dto.roleType; - if (dto.subRole !== undefined) member.subRole = dto.subRole.trim() || null; await this.memberRepo.save(member); return this.toResponse(member); @@ -170,13 +168,12 @@ export class MemberService { } } - // 엔티티 → 응답 매핑 - private toResponse(member: RepoMember): RepoMemberResponse { + // 엔티티 → 응답 매핑 (taskCount 미제공 시 0) + private toResponse(member: RepoMember, taskCount = 0): RepoMemberResponse { return { user: UserService.toPublic(member.user), email: member.user.email, - subRole: member.subRole, - taskCount: 0, + taskCount, roleType: member.roleType, owner: member.isOwner, }; diff --git a/backend/src/modules/repo/repo.module.ts b/backend/src/modules/repo/repo.module.ts index 83f09aa..659fab3 100644 --- a/backend/src/modules/repo/repo.module.ts +++ b/backend/src/modules/repo/repo.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserModule } from '../user/user.module'; import { GiteaModule } from '../gitea/gitea.module'; +import { TaskModule } from '../task/task.module'; import { Repo } from './entities/repo.entity'; import { RepoMember } from './entities/repo-member.entity'; import { RepoService } from './repo.service'; @@ -16,6 +17,7 @@ import { RepoSyncService } from './repo-sync.service'; TypeOrmModule.forFeature([Repo, RepoMember]), UserModule, GiteaModule, + TaskModule, ], controllers: [RepoController, MemberController], providers: [RepoService, MemberService, RepoSyncService], diff --git a/backend/src/modules/repo/repo.service.ts b/backend/src/modules/repo/repo.service.ts index 0c3d6ce..ed6f5ad 100644 --- a/backend/src/modules/repo/repo.service.ts +++ b/backend/src/modules/repo/repo.service.ts @@ -14,6 +14,7 @@ import { GiteaService, type GiteaRepoResult, } from '../gitea/gitea.service'; +import { TaskService } from '../task/task.service'; import { Repo, type RepoVisibility } from './entities/repo.entity'; import { RepoMember } from './entities/repo-member.entity'; import { CreateRepoDto } from './dto/create-repo.dto'; @@ -56,6 +57,7 @@ export class RepoService { private readonly memberRepo: Repository, private readonly userService: UserService, private readonly gitea: GiteaService, + private readonly taskService: TaskService, ) {} // 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보. @@ -81,13 +83,15 @@ export class RepoService { relations: ['members', 'members.user'], order: { updatedAt: 'DESC' }, }); - return repos.map((repo) => this.toResponse(repo)); + // 저장소별 업무 완료/전체 수를 한 번에 집계해 진행률에 반영 + const counts = await this.taskService.countsByRepo(repos.map((r) => r.id)); + return repos.map((repo) => this.toResponse(repo, counts.get(repo.id))); } // 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드). async findOne(slugName: string): Promise { const repo = await this.getEntityOrThrow(slugName); - return this.toResponse(repo); + return this.toResponse(repo, await this.taskCountsOf(repo.id)); } // 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록 @@ -150,10 +154,10 @@ export class RepoService { user, roleType: 'admin', isOwner: true, - subRole: '리드', }); await this.memberRepo.save(ownerMember); + // 갓 생성한 저장소는 업무가 없으므로 집계는 0/0 return this.toResponse(await this.getEntityOrThrow(saved.slugName)); } catch (err) { // Relay 저장 실패 시 방금 만든 Gitea 저장소를 정리(고아 방지, 베스트 에포트) @@ -211,7 +215,10 @@ export class RepoService { }); } - return this.toResponse(await this.getEntityOrThrow(slugName)); + return this.toResponse( + await this.getEntityOrThrow(slugName), + await this.taskCountsOf(repo.id), + ); } // 저장소 삭제 — 소유자만 가능 @@ -317,8 +324,18 @@ export class RepoService { } } - // 엔티티 → 응답 매핑 - private toResponse(repo: Repo): RepoResponse { + // 단일 저장소의 업무 완료/전체 수 집계 + private async taskCountsOf( + repoId: string, + ): Promise<{ done: number; total: number } | undefined> { + return (await this.taskService.countsByRepo([repoId])).get(repoId); + } + + // 엔티티 → 응답 매핑 (counts 미제공 시 0/0) + private toResponse( + repo: Repo, + counts?: { done: number; total: number }, + ): RepoResponse { const members = (repo.members ?? []).map((m) => UserService.toPublic(m.user), ); @@ -332,9 +349,9 @@ export class RepoService { branch: repo.branch, members, memberCount: members.length, - // TODO(4단계): 업무 모듈 도입 시 실제 완료/전체 수 집계 - doneCount: 0, - totalCount: 0, + // 업무 모듈 집계(없으면 0/0) + doneCount: counts?.done ?? 0, + totalCount: counts?.total ?? 0, cloneUrl: repo.cloneUrl, htmlUrl: repo.htmlUrl, giteaMissing: repo.giteaMissing, diff --git a/backend/src/modules/task/dto/change-status.dto.ts b/backend/src/modules/task/dto/change-status.dto.ts new file mode 100644 index 0000000..52fbd68 --- /dev/null +++ b/backend/src/modules/task/dto/change-status.dto.ts @@ -0,0 +1,22 @@ +import { IsIn } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import type { TaskStatus } from '../entities/task.entity'; + +// 업무 상태 변경 요청 — 상태 머신 전이 규칙은 서비스에서 검증 +const TASK_STATUSES: TaskStatus[] = [ + 'todo', + 'prog', + 'review', + 'done', + 'changes', +]; + +export class ChangeStatusDto { + @ApiProperty({ + description: '변경할 상태', + enum: TASK_STATUSES, + example: 'prog', + }) + @IsIn(TASK_STATUSES, { message: '올바른 업무 상태가 아닙니다.' }) + status!: TaskStatus; +} diff --git a/backend/src/modules/task/dto/create-task.dto.ts b/backend/src/modules/task/dto/create-task.dto.ts new file mode 100644 index 0000000..e5e3978 --- /dev/null +++ b/backend/src/modules/task/dto/create-task.dto.ts @@ -0,0 +1,78 @@ +import { + ArrayNotEmpty, + IsArray, + IsISO8601, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +// 체크리스트 입력 항목 (생성 시 함께 등록) +export class ChecklistInputDto { + @ApiProperty({ + description: '체크리스트 항목 내용', + example: '가로형 배너 디자인', + }) + @IsString() + @IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' }) + @MaxLength(200) + text!: string; +} + +// 업무 생성 요청 +export class CreateTaskDto { + @ApiProperty({ + description: '업무 제목', + example: '메인 배너 이미지 2종 디자인', + }) + @IsString() + @IsNotEmpty({ message: '업무 제목을 입력해 주세요.' }) + @MaxLength(200) + title!: string; + + @ApiProperty({ + description: '본문 문단 배열(선택)', + type: [String], + required: false, + example: ['9월 신제품 캠페인 메인 배너를 제작합니다.'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + content?: string[]; + + @ApiProperty({ + description: '담당자 사용자 ID 배열(저장소 멤버만 가능)', + type: [String], + example: ['a1b2c3d4-...'], + }) + @IsArray() + @ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' }) + @IsUUID('4', { each: true }) + assigneeIds!: string[]; + + @ApiProperty({ + description: '마감 기한(ISO8601, 선택)', + required: false, + example: '2026-06-18T00:00:00Z', + }) + @IsOptional() + @IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' }) + dueDate?: string; + + @ApiProperty({ + description: '체크리스트(선택)', + type: [ChecklistInputDto], + required: false, + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ChecklistInputDto) + checklist?: ChecklistInputDto[]; +} diff --git a/backend/src/modules/task/dto/update-task.dto.ts b/backend/src/modules/task/dto/update-task.dto.ts new file mode 100644 index 0000000..637f395 --- /dev/null +++ b/backend/src/modules/task/dto/update-task.dto.ts @@ -0,0 +1,51 @@ +import { + ArrayNotEmpty, + IsArray, + IsISO8601, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +// 업무 수정 요청 — 제공된 필드만 갱신(체크리스트/댓글/파일은 5단계 별도 엔드포인트) +export class UpdateTaskDto { + @ApiProperty({ description: '업무 제목', required: false }) + @IsOptional() + @IsString() + @IsNotEmpty({ message: '업무 제목을 입력해 주세요.' }) + @MaxLength(200) + title?: string; + + @ApiProperty({ + description: '본문 문단 배열', + type: [String], + required: false, + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + content?: string[]; + + @ApiProperty({ + description: '담당자 사용자 ID 배열(저장소 멤버만 가능)', + type: [String], + required: false, + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' }) + @IsUUID('4', { each: true }) + assigneeIds?: string[]; + + @ApiProperty({ + description: '마감 기한(ISO8601). null 이면 마감 해제', + required: false, + nullable: true, + }) + @IsOptional() + @IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' }) + dueDate?: string | null; +} diff --git a/backend/src/modules/task/entities/checklist-item.entity.ts b/backend/src/modules/task/entities/checklist-item.entity.ts new file mode 100644 index 0000000..cdfc2c1 --- /dev/null +++ b/backend/src/modules/task/entities/checklist-item.entity.ts @@ -0,0 +1,36 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + type Relation, +} from 'typeorm'; +import { Task } from './task.entity'; + +// 업무 체크리스트 항목 +@Entity('checklist_items') +export class ChecklistItem { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // 소속 업무 (업무 삭제 시 함께 삭제) + // Relation<> 래퍼: 엔티티 순환참조 회피 + @ManyToOne(() => Task, (task) => task.checklist, { onDelete: 'CASCADE' }) + task!: Relation; + + // 항목 내용 + @Column() + text!: string; + + // 완료 여부 + @Column({ default: false }) + done!: boolean; + + // 표시 순서(생성 순) + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder!: number; + + @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 new file mode 100644 index 0000000..d46b875 --- /dev/null +++ b/backend/src/modules/task/entities/task.entity.ts @@ -0,0 +1,81 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + type Relation, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; +import { Repo } from '../../repo/entities/repo.entity'; +import { ChecklistItem } from './checklist-item.entity'; + +// 업무 상태 (상태 머신: todo → prog → review → done/changes, changes → review) +export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes'; + +// 업무 엔티티 — 저장소에 종속, 저장소 내 순번(seq)으로 식별(#9) +@Entity('tasks') +@Unique(['repo', 'seq']) +export class Task { + // 내부 식별자 (UUID) + @PrimaryGeneratedColumn('uuid') + id!: string; + + // 저장소 내 표시 순번(#9) — 라우팅/표시 식별자. 저장소별로 1부터 증가 + @Index() + @Column({ type: 'int' }) + seq!: number; + + // 소속 저장소 (저장소 삭제 시 함께 삭제) + // Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함 + // FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.repo_id 와 일치) + @ManyToOne(() => Repo, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'repo_id' }) + repo!: Relation; + + // 업무 제목 + @Column() + title!: string; + + // 본문 문단 배열(멘션은 @로 시작) — jsonb 로 보관 + @Column({ type: 'jsonb', default: () => "'[]'" }) + content!: string[]; + + // 진행 상태 + @Column({ type: 'varchar', default: 'todo' }) + status!: TaskStatus; + + // 마감 기한(선택) + @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) + dueDate!: Date | null; + + // 지시자(업무 생성자) — 사용자 삭제 시에도 업무는 유지(SET NULL) + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + issuer!: User | null; + + // 담당자 목록 (저장소 멤버만 지정 가능 — 서비스에서 검증) + @ManyToMany(() => User) + @JoinTable({ + name: 'task_assignees', + joinColumn: { name: 'task_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' }, + }) + assignees!: User[]; + + // 체크리스트 항목 + @OneToMany(() => ChecklistItem, (item) => item.task, { cascade: true }) + checklist!: Relation[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/task/task.controller.ts b/backend/src/modules/task/task.controller.ts new file mode 100644 index 0000000..91c7219 --- /dev/null +++ b/backend/src/modules/task/task.controller.ts @@ -0,0 +1,120 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { PublicUser } from '../user/user.service'; +import { + TaskService, + type RepoTaskResponse, + type TaskDetailResponse, +} from './task.service'; +import type { TaskStatus } from './entities/task.entity'; +import { CreateTaskDto } from './dto/create-task.dto'; +import { UpdateTaskDto } from './dto/update-task.dto'; +import { ChangeStatusDto } from './dto/change-status.dto'; + +// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번) +@ApiTags('Task') +@Controller('repos/:repoId/tasks') +@UseGuards(JwtAuthGuard) +export class TaskController { + constructor(private readonly taskService: TaskService) {} + + @Get() + @ApiOperation({ summary: '업무 목록 (상태/담당자 필터)' }) + @ApiResponse({ status: 200, description: '목록 조회 성공' }) + @ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' }) + list( + @Param('repoId') repoId: string, + @Query('status') status?: TaskStatus, + @Query('assignee') assignee?: string, + ): Promise { + return this.taskService.list(repoId, { status, assignee }); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '업무 생성 (admin)' }) + @ApiResponse({ status: 201, description: '생성 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) + @ApiResponse({ + status: 400, + description: '담당자가 저장소 멤버가 아님 (BIZ_001)', + }) + create( + @Param('repoId') repoId: string, + @Body() dto: CreateTaskDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.taskService.create(repoId, user.id, dto); + } + + @Get(':taskId') + @ApiOperation({ summary: '업무 상세' }) + @ApiResponse({ status: 200, description: '조회 성공' }) + @ApiResponse({ status: 404, description: '업무를 찾을 수 없음 (RES_001)' }) + findOne( + @Param('repoId') repoId: string, + @Param('taskId', ParseIntPipe) taskId: number, + ): Promise { + return this.taskService.findOne(repoId, taskId); + } + + @Patch(':taskId') + @ApiOperation({ summary: '업무 수정 (admin)' }) + @ApiResponse({ status: 200, description: '수정 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) + update( + @Param('repoId') repoId: string, + @Param('taskId', ParseIntPipe) taskId: number, + @Body() dto: UpdateTaskDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.taskService.update(repoId, user.id, taskId, dto); + } + + @Delete(':taskId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '업무 삭제 (admin)' }) + @ApiResponse({ status: 200, description: '삭제 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) + async remove( + @Param('repoId') repoId: string, + @Param('taskId', ParseIntPipe) taskId: number, + @CurrentUser() user: PublicUser, + ): Promise { + await this.taskService.remove(repoId, user.id, taskId); + return null; + } + + @Post(':taskId/status') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '업무 상태 변경 (상태 머신)' }) + @ApiResponse({ status: 200, description: '변경 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) + @ApiResponse({ + status: 409, + description: '허용되지 않는 상태 전이 (BIZ_001)', + }) + changeStatus( + @Param('repoId') repoId: string, + @Param('taskId', ParseIntPipe) taskId: number, + @Body() dto: ChangeStatusDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.taskService.changeStatus(repoId, user.id, taskId, dto.status); + } +} diff --git a/backend/src/modules/task/task.module.ts b/backend/src/modules/task/task.module.ts new file mode 100644 index 0000000..4b740c8 --- /dev/null +++ b/backend/src/modules/task/task.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Repo } from '../repo/entities/repo.entity'; +import { RepoMember } from '../repo/entities/repo-member.entity'; +import { Task } from './entities/task.entity'; +import { ChecklistItem } from './entities/checklist-item.entity'; +import { TaskService } from './task.service'; +import { TaskController } from './task.controller'; + +// 업무 모듈 (업무 CRUD + 상태 전이) +@Module({ + imports: [TypeOrmModule.forFeature([Task, ChecklistItem, Repo, RepoMember])], + controllers: [TaskController], + providers: [TaskService], + exports: [TaskService], +}) +export class TaskModule {} diff --git a/backend/src/modules/task/task.service.spec.ts b/backend/src/modules/task/task.service.spec.ts new file mode 100644 index 0000000..b312c98 --- /dev/null +++ b/backend/src/modules/task/task.service.spec.ts @@ -0,0 +1,132 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { TaskService } from './task.service'; +import { BusinessException } from '../../common/exceptions/business.exception'; +import { Repo } from '../repo/entities/repo.entity'; +import { RepoMember } from '../repo/entities/repo-member.entity'; +import { Task } from './entities/task.entity'; +import { ChecklistItem } from './entities/checklist-item.entity'; + +// 리포지토리 간이 모킹 +function makeService() { + const taskRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }; + const checklistRepo = { create: jest.fn(), save: jest.fn() }; + const repoRepo = { findOne: jest.fn() }; + const memberRepo = { findOne: jest.fn(), find: jest.fn() }; + const svc = new TaskService( + taskRepo as unknown as Repository, + checklistRepo as unknown as Repository, + repoRepo as unknown as Repository, + memberRepo as unknown as Repository, + ); + return { svc, taskRepo, checklistRepo, repoRepo, memberRepo }; +} + +const REPO = { id: 'repo-uuid', slugName: 'q3-launch', name: '캠페인' }; + +describe('TaskService', () => { + describe('changeStatus — 상태 머신', () => { + it('허용되지 않는 전이(todo→done)는 BusinessException', async () => { + const { svc, taskRepo, repoRepo, memberRepo } = makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + memberRepo.findOne.mockResolvedValue({ roleType: 'admin' }); + taskRepo.findOne.mockResolvedValue({ + seq: 1, + status: 'todo', + issuer: { id: 'admin' }, + assignees: [], + checklist: [], + }); + + await expect( + svc.changeStatus('q3-launch', 'admin', 1, 'done'), + ).rejects.toBeInstanceOf(BusinessException); + }); + + it('승인(review→done)은 지시자/admin 이 아니면 Forbidden', async () => { + const { svc, taskRepo, repoRepo, memberRepo } = makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + // 요청자는 일반 멤버(담당자) + memberRepo.findOne.mockResolvedValue({ roleType: 'member' }); + taskRepo.findOne.mockResolvedValue({ + seq: 9, + status: 'review', + issuer: { id: 'someone-else' }, + assignees: [{ id: 'assignee' }], + checklist: [], + }); + + await expect( + svc.changeStatus('q3-launch', 'assignee', 9, 'done'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('비멤버는 상태 변경 불가(Forbidden)', async () => { + const { svc, repoRepo, memberRepo } = makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + memberRepo.findOne.mockResolvedValue(null); // 멤버십 없음 + + await expect( + svc.changeStatus('q3-launch', 'outsider', 1, 'prog'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('승인(review→done) 시 체크리스트를 모두 완료 처리한다', async () => { + const { svc, taskRepo, checklistRepo, repoRepo, memberRepo } = + makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + memberRepo.findOne.mockResolvedValue({ roleType: 'admin' }); // 지시자/admin + const task = { + seq: 9, + status: 'review', + title: '배너', + content: [], + issuer: { id: 'admin', email: 'a@b.c', name: 'A', role: null }, + assignees: [], + checklist: [ + { id: 'c1', text: '가로형', done: true, sortOrder: 0 }, + { id: 'c2', text: '세로형', done: false, sortOrder: 1 }, + { id: 'c3', text: '모바일', done: false, sortOrder: 2 }, + ], + dueDate: null, + createdAt: new Date('2026-06-08T00:00:00Z'), + }; + taskRepo.findOne.mockResolvedValue(task); + taskRepo.save.mockResolvedValue(task); + checklistRepo.save.mockResolvedValue(task.checklist); + + const result = await svc.changeStatus('q3-launch', 'admin', 9, 'done'); + + expect(checklistRepo.save).toHaveBeenCalledTimes(1); + expect(task.checklist.every((c) => c.done)).toBe(true); + expect(result.checklist.every((c) => c.done)).toBe(true); + }); + }); + + describe('create — 담당자 멤버 검증', () => { + it('담당자가 저장소 멤버가 아니면 BusinessException', async () => { + const { svc, repoRepo, memberRepo } = makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + // assertAdmin 통과 + memberRepo.findOne.mockResolvedValue({ + roleType: 'admin', + user: { id: 'admin' }, + }); + // 요청한 담당자 2명 중 매칭 멤버십 1건만 → 비멤버 포함 + memberRepo.find.mockResolvedValue([{ user: { id: 'u1' } }]); + + await expect( + svc.create('q3-launch', 'admin', { + title: '배너 디자인', + assigneeIds: ['u1', 'u2'], + }), + ).rejects.toBeInstanceOf(BusinessException); + }); + }); +}); diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts new file mode 100644 index 0000000..dda3ed7 --- /dev/null +++ b/backend/src/modules/task/task.service.ts @@ -0,0 +1,404 @@ +import { + ForbiddenException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { UserService, type PublicUser } from '../user/user.service'; +import { BusinessException } from '../../common/exceptions/business.exception'; +import { Repo } from '../repo/entities/repo.entity'; +import { RepoMember } from '../repo/entities/repo-member.entity'; +import { Task, type TaskStatus } from './entities/task.entity'; +import { ChecklistItem } from './entities/checklist-item.entity'; +import { CreateTaskDto } from './dto/create-task.dto'; +import { UpdateTaskDto } from './dto/update-task.dto'; + +// 목록 행 응답 — 라벨류는 프론트가 파생(dueLabel/dday/statusLabel 등) +export interface RepoTaskResponse { + id: number; // 저장소 내 순번(seq) + title: string; + status: TaskStatus; + dueDate: string | null; + checklist: [number, number]; // [완료, 전체] + commentCount: number; // 5단계 전까지 0 + assignees: PublicUser[]; +} + +// 체크리스트 항목 응답 +export interface ChecklistItemResponse { + id: string; + text: string; + done: boolean; +} + +// 업무 상세 응답 — comments/activities/files/approval 은 5단계에서 채운다(현재 빈 값) +export interface TaskDetailResponse { + id: number; + repoId: string; // slugName + repoName: string; + title: string; + status: TaskStatus; + content: string[]; + checklist: ChecklistItemResponse[]; + assignees: PublicUser[]; + issuer: PublicUser | null; + dueDate: string | null; + createdAt: string; + comments: never[]; + activities: never[]; + files: never[]; +} + +// 상태 머신 전이 규칙(허용 목적지) +const TRANSITIONS: Record = { + todo: ['prog'], + prog: ['review'], + review: ['done', 'changes'], + changes: ['review', 'prog'], + done: [], +}; + +// 상태 한국어 라벨(에러 문구용) +const STATUS_LABEL: Record = { + todo: '할 일', + prog: '진행 중', + review: '승인 대기', + done: '완료', + changes: '수정 요청', +}; + +// 업무 비즈니스 로직 +@Injectable() +export class TaskService { + constructor( + @InjectRepository(Task) + private readonly taskRepo: Repository, + @InjectRepository(ChecklistItem) + private readonly checklistRepo: Repository, + @InjectRepository(Repo) + private readonly repoRepo: Repository, + @InjectRepository(RepoMember) + private readonly memberRepo: Repository, + ) {} + + // 업무 목록 — 상태/담당자 필터(선택). seq 내림차순(최신 우선) + async list( + slugName: string, + filters: { status?: TaskStatus; assignee?: string }, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + + const qb = this.taskRepo + .createQueryBuilder('task') + .leftJoinAndSelect('task.assignees', 'assignee') + .leftJoinAndSelect('task.checklist', 'checklist') + .where('task.repo = :repoId', { repoId: repo.id }) + .orderBy('task.seq', 'DESC') + .addOrderBy('checklist.sort_order', 'ASC'); + + if (filters.status) { + qb.andWhere('task.status = :status', { status: filters.status }); + } + // 담당자 필터: 해당 사용자가 담당인 업무만 (서브쿼리로 task id 추림) + if (filters.assignee) { + qb.andWhere( + 'task.id IN ' + + qb + .subQuery() + .select('ta.task_id') + .from('task_assignees', 'ta') + .where('ta.user_id = :assignee') + .getQuery(), + { assignee: filters.assignee }, + ); + } + + const tasks = await qb.getMany(); + return tasks.map((t) => this.toListResponse(t)); + } + + // 업무 단건 상세 — repo 내 순번(seq)으로 조회 + async findOne(slugName: string, seq: number): Promise { + const repo = await this.getRepoOrThrow(slugName); + const task = await this.getTaskOrThrow(repo.id, seq); + return this.toDetailResponse(task, repo); + } + + // 업무 생성 — admin(지시자) 권한. 담당자는 저장소 멤버만 지정 가능 + async create( + slugName: string, + actingUserId: string, + dto: CreateTaskDto, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + const issuer = await this.assertAdmin(repo.id, actingUserId); + + const assignees = await this.resolveMemberAssignees( + repo.id, + dto.assigneeIds, + ); + + // 저장소 내 다음 순번 채번 + const last = await this.taskRepo.findOne({ + where: { repo: { id: repo.id } }, + order: { seq: 'DESC' }, + }); + const seq = (last?.seq ?? 0) + 1; + + const task = this.taskRepo.create({ + seq, + repo, + title: dto.title, + content: dto.content ?? [], + status: 'todo', + dueDate: dto.dueDate ? new Date(dto.dueDate) : null, + issuer: issuer.user, + assignees, + checklist: (dto.checklist ?? []).map((c, idx) => + this.checklistRepo.create({ + text: c.text, + done: false, + sortOrder: idx, + }), + ), + }); + const saved = await this.taskRepo.save(task); + return this.toDetailResponse( + await this.getTaskOrThrow(repo.id, saved.seq), + repo, + ); + } + + // 업무 수정 — admin 권한. 제공된 필드만 갱신 + async update( + slugName: string, + actingUserId: string, + seq: number, + dto: UpdateTaskDto, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + await this.assertAdmin(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + + if (dto.title !== undefined) task.title = dto.title; + if (dto.content !== undefined) task.content = dto.content; + if (dto.dueDate !== undefined) { + task.dueDate = dto.dueDate ? new Date(dto.dueDate) : null; + } + if (dto.assigneeIds !== undefined) { + task.assignees = await this.resolveMemberAssignees( + repo.id, + dto.assigneeIds, + ); + } + + await this.taskRepo.save(task); + return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo); + } + + // 업무 삭제 — admin 권한 + async remove( + slugName: string, + actingUserId: string, + seq: number, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + await this.assertAdmin(repo.id, actingUserId); + const task = await this.getTaskOrThrow(repo.id, seq); + await this.taskRepo.remove(task); + } + + // 상태 변경 — 상태 머신 전이 규칙 + 역할 검증 + async changeStatus( + slugName: string, + actingUserId: string, + seq: number, + next: TaskStatus, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + const membership = await this.memberRepo.findOne({ + where: { repo: { id: repo.id }, user: { id: actingUserId } }, + }); + if (!membership) { + throw new ForbiddenException(); + } + const task = await this.getTaskOrThrow(repo.id, seq); + + const from = task.status; + if (from === next) { + return this.toDetailResponse(task, repo); + } + const allowed = TRANSITIONS[from] ?? []; + if (!allowed.includes(next)) { + throw new BusinessException( + 'BIZ_001', + `'${STATUS_LABEL[from]}' 상태에서 '${STATUS_LABEL[next]}'(으)로 변경할 수 없습니다.`, + HttpStatus.CONFLICT, + ); + } + + const isAdmin = membership.roleType === 'admin'; + const isIssuer = task.issuer?.id === actingUserId; + const isAssignee = task.assignees.some((a) => a.id === actingUserId); + // 승인/수정요청(review 출발)은 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자) + const permitted = + from === 'review' + ? isAdmin || isIssuer + : isAdmin || isIssuer || isAssignee; + if (!permitted) { + throw new ForbiddenException(); + } + + task.status = next; + await this.taskRepo.save(task); + + // 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리(부수효과로 영속) + if (next === 'done' && task.checklist?.length) { + for (const item of task.checklist) item.done = true; + await this.checklistRepo.save(task.checklist); + } + + return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo); + } + + // --- 집계(다른 모듈에서 사용) --- + + // 저장소별 업무 완료/전체 수 — RepoService 진행률 집계용 + async countsByRepo( + repoIds: string[], + ): Promise> { + const result = new Map(); + if (repoIds.length === 0) return result; + const rows = await this.taskRepo + .createQueryBuilder('task') + .select('task.repo_id', 'repoId') + .addSelect('COUNT(*)', 'total') + .addSelect("COUNT(*) FILTER (WHERE task.status = 'done')", 'done') + .where('task.repo_id IN (:...repoIds)', { repoIds }) + .groupBy('task.repo_id') + .getRawMany<{ repoId: string; total: string; done: string }>(); + for (const r of rows) { + result.set(r.repoId, { + done: Number(r.done), + total: Number(r.total), + }); + } + return result; + } + + // 저장소 내 사용자별 담당 업무 수 — MemberService taskCount 집계용 + async assigneeCounts(repoId: string): Promise> { + const rows = await this.taskRepo + .createQueryBuilder('task') + .innerJoin('task_assignees', 'ta', 'ta.task_id = task.id') + .select('ta.user_id', 'userId') + .addSelect('COUNT(*)', 'count') + .where('task.repo_id = :repoId', { repoId }) + .groupBy('ta.user_id') + .getRawMany<{ userId: string; count: string }>(); + const map = new Map(); + for (const r of rows) map.set(r.userId, Number(r.count)); + return map; + } + + // --- 내부 헬퍼 --- + + // slugName 으로 저장소 로딩, 없으면 404 + private async getRepoOrThrow(slugName: string): Promise { + const repo = await this.repoRepo.findOne({ where: { slugName } }); + if (!repo) { + throw new NotFoundException(); + } + return repo; + } + + // repo 내 순번(seq)으로 업무(+관계) 로딩, 없으면 404 + private async getTaskOrThrow(repoId: string, seq: number): Promise { + const task = await this.taskRepo.findOne({ + where: { repo: { id: repoId }, seq }, + relations: ['assignees', 'issuer', 'checklist'], + }); + if (!task) { + throw new NotFoundException(); + } + // 체크리스트 표시 순서 보정 + task.checklist?.sort((a, b) => a.sortOrder - b.sortOrder); + return task; + } + + // admin 권한 확인 — 멤버십(+user) 반환(지시자 지정용). 아니면 403 + private async assertAdmin( + repoId: string, + userId: string, + ): Promise { + const membership = await this.memberRepo.findOne({ + where: { repo: { id: repoId }, user: { id: userId } }, + relations: ['user'], + }); + if (!membership || membership.roleType !== 'admin') { + throw new ForbiddenException(); + } + return membership; + } + + // 담당자 ID 목록 → User[] 로 변환하며 "저장소 멤버" 인지 검증 + private async resolveMemberAssignees( + repoId: string, + assigneeIds: string[], + ): Promise { + const uniqueIds = [...new Set(assigneeIds)]; + const memberships = await this.memberRepo.find({ + where: { repo: { id: repoId }, user: { id: In(uniqueIds) } }, + relations: ['user'], + }); + if (memberships.length !== uniqueIds.length) { + throw new BusinessException( + 'BIZ_001', + '담당자는 저장소 멤버만 지정할 수 있습니다.', + HttpStatus.BAD_REQUEST, + ); + } + return memberships.map((m) => m.user); + } + + // 엔티티 → 목록 행 응답 + private toListResponse(task: Task): RepoTaskResponse { + const checklist = task.checklist ?? []; + const done = checklist.filter((c) => c.done).length; + return { + id: task.seq, + title: task.title, + status: task.status, + dueDate: task.dueDate ? task.dueDate.toISOString() : null, + checklist: [done, checklist.length], + commentCount: 0, + assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)), + }; + } + + // 엔티티 → 상세 응답 + private toDetailResponse(task: Task, repo: Repo): TaskDetailResponse { + const checklist = (task.checklist ?? []) + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((c) => ({ id: c.id, text: c.text, done: c.done })); + return { + id: task.seq, + repoId: repo.slugName, + repoName: repo.name, + title: task.title, + status: task.status, + content: task.content ?? [], + checklist, + assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)), + issuer: task.issuer ? UserService.toPublic(task.issuer) : null, + dueDate: task.dueDate ? task.dueDate.toISOString() : null, + createdAt: task.createdAt.toISOString(), + comments: [], + activities: [], + files: [], + }; + } +} diff --git a/docs/api-contract.md b/docs/api-contract.md index ce70393..1dbd71f 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -149,44 +149,73 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 | 메서드 | 경로 | 요청 | 응답 | 권한 | |---|---|---|---|---| -| GET | `.../members` | — | `RepoMember[]` | member+ | -| POST | `.../members` | `{ email, roleType, subRole? }` | `RepoMember` | admin | -| PATCH | `.../members/:userId` | `{ roleType?, subRole? }` | `RepoMember` | admin | +| GET | `.../members` | — | `RepoMember[]` | 인증(조직 모델상 누구나 열람) | +| POST | `.../members` | `{ email, roleType }` | `RepoMember` | admin | +| PATCH | `.../members/:userId` | `{ roleType? }` | `RepoMember` | admin | | DELETE | `.../members/:userId` | — | `null` | admin (owner 제거 불가) | -`RepoMember`: `{ user: User, email, subRole, taskCount, roleType('admin'|'member'), owner }` -(`isYou` 는 프론트가 현재 사용자와 비교해 산출) +`RepoMember`: `{ user: User, email, taskCount, roleType('admin'|'member'), owner }` +(`isYou` 는 프론트가 현재 사용자와 비교해 산출. `taskCount` 는 4단계에서 사용자별 담당 업무 수로 실집계) +> **가시성 정책**: 멤버십은 **쓰기/권한과 업무 담당 자격**에만 쓰이고, 저장소·멤버·업무의 **조회는 인증 사용자면 누구나 가능**(단일 조직 모델). 비멤버를 가리는 가시성 가드는 도입하지 않음. > 매핑 페이지: RepoMembersPage(초대 모달, 역할 변경, 제거). +#### 사용자 검색 (`/api/users`) — 멤버 초대 자동완성 + +| 메서드 | 경로 | 요청 | 응답 | 권한 | +|---|---|---|---|---| +| GET | `/users` | `?q=<검색어>` | `User[]`(PublicUser) | 인증 | + +- `q` 있으면 **이메일/이름 부분일치(대소문자 무시)**, 없으면 전체 목록(이름순, 최대 50). 응답은 `PublicUser`(`{ id, email, name, role }`). +- 초대 모달의 이메일 입력에서 호출: 포커스 시 전체, 입력 시 부분일치 목록을 띄우고 선택하면 이메일이 채워진다. **이미 저장소 멤버인 사용자는 프론트가 목록에서 제외**(현재 멤버 목록과 id 비교). + --- -## 4. 업무 (`/api/repos/:repoId/tasks`) — **4단계** +## 4. 업무 (`/api/repos/:repoId/tasks`) — **4단계 ✅ 구현 완료** -| 메서드 | 경로 | 요청 | 응답 | -|---|---|---|---| -| GET | `.../tasks` | `?status&assignee` | `RepoTask[]` | -| POST | `.../tasks` | `CreateTaskDto` | `TaskDetail` | -| GET | `.../tasks/:taskId` | — | `TaskDetail` | -| PATCH | `.../tasks/:taskId` | `UpdateTaskDto` | `TaskDetail` | -| DELETE | `.../tasks/:taskId` | — | `null` | -| POST | `.../tasks/:taskId/status` | `{ status }` | `TaskDetail` | +| 메서드 | 경로 | 요청 | 응답 | 권한 | +|---|---|---|---|---| +| GET | `.../tasks` | `?status&assignee` | `RepoTask[]` | 인증 | +| POST | `.../tasks` | `CreateTaskDto` | `TaskDetail` | admin | +| GET | `.../tasks/:taskId` | — | `TaskDetail` | 인증 | +| PATCH | `.../tasks/:taskId` | `UpdateTaskDto` | `TaskDetail` | admin | +| DELETE | `.../tasks/:taskId` | — | `null` | admin | +| POST | `.../tasks/:taskId/status` | `{ status }` | `TaskDetail` | 멤버(전이별 역할 검증) | + +- `:taskId` 는 **저장소 내 순번(seq)** — 정수(`ParseIntPipe`). 전역 PK 는 별도 uuid. +- 목록 정렬: seq 내림차순(최신 우선). `?status`/`?assignee`(userId) 필터. 상태(`TaskStatus`): `todo | prog | review | done | changes`. 전이 규칙(상태 머신): ``` todo ─시작→ prog ─승인요청→ review ─승인→ done ▲ └─수정요청→ changes ─재요청→ review - └──────────────────────────────────┘ + └──────────(changes→prog)───────────┘ ``` -- `POST .../status` 는 일반 상태 변경. -- **승인 워크플로우**(5단계와 함께): 담당자 "승인 요청"(prog→review), 지시자 "승인"(review→done) / "수정 요청"(review→changes). +전이 허용 맵: `todo→[prog]`, `prog→[review]`, `review→[done,changes]`, `changes→[review,prog]`, `done→[]`. +허용 외 전이는 `BIZ_001`(409). -`CreateTaskDto`: `{ title, content?: string[], assigneeIds: uuid[], dueDate?: ISO, checklist?: {text}[] }` -`RepoTask`(목록 행): `{ id, title, status, dueDate, checklist: [done,total], commentCount, assignees: User[] }` -`TaskDetail`: mock 의 `TaskDetail` 인터페이스와 동일 — 단 라벨류는 원시값으로: -`{ id, repoId, repoName, title, status, content[], checklist[], comments[], activities[], assignees[], issuer, dueDate, files[], approval?, createdAt }` -> 매핑 페이지: RepoDetailPage(목록), TaskCreatePage, TaskDetailPage(지시자), TaskAssigneePage(담당자, state 분기). +**역할 검증**(`POST .../status`): +- 승인/수정요청(**review 출발**: review→done, review→changes) = **지시자(issuer) 또는 admin**. +- 그 외 작업 전이(시작 todo→prog, 승인요청 prog→review, 재요청 changes→review/prog) = **담당자(assignee) 또는 지시자/admin**. +- 비멤버는 전이 불가(403). `from === next` 면 no-op(현재 상태 반환). + +**부수효과**: +- **담당자 지정은 저장소 멤버만 가능** — `assigneeIds` 에 비멤버 포함 시 `BIZ_001`(400, "담당자는 저장소 멤버만 지정할 수 있습니다."). +- **승인 완료(→done) 시 체크리스트 항목 done 을 모두 true 로 영속 처리**(승인=완료의 부수효과). +- 생성 시 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[] }` +`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단계(현재 미포함). + +> **프론트 매핑/동작**: +> - 목록 진입은 항상 `…/tasks/:taskId`(**TaskDetailPage**) — 이 화면이 **현재 사용자 역할로 액션을 분기**한다(지시자: 승인/수정요청, 담당자: 업무 시작/승인 요청/재요청·승인 대기 중). `isIssuer`/`isAssignee` 로 판별. +> - **수정**: 생성 폼(TaskCreatePage)을 편집 모드로 재사용 — 프론트 라우트 `…/tasks/:taskId/edit`(API 는 동일 PATCH). 기존 값 미리 채움, 체크리스트는 읽기 전용. +> - 라벨류(dueLabel/dday/statusLabel)는 `format.ts`(`taskStatusLabel`/`taskDueInfo`)에서 원시값으로 파생. +> - `TaskAssigneePage`(`/view`)는 mock '내 업무' 전용 잔존(7단계 정리). 댓글/AI 패널/첨부 업로드는 로컬 데모(5단계 실연동). --- @@ -258,10 +287,11 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done ``` src/modules/ - auth/ (1) — User 엔티티, JWT 전략, Guard, 쿠키 - repo/ (2) — Repo 엔티티 - member/ (3) — RepoMember 조인 엔티티 (repo 모듈에 흡수 가능) - task/ (4,5) — Task / ChecklistItem / Comment / Reply / Attachment + auth/ (1) ✅ — User 엔티티, JWT 전략, Guard, 쿠키 + repo/ (2) ✅ — Repo 엔티티 (+ member 흡수, Gitea 동기화) + member/ (3) ✅ — RepoMember 조인 엔티티 (repo 모듈에 흡수됨) + task/ (4) ✅ — Task / ChecklistItem (Comment / Reply / Attachment 는 5단계) activity/ (6) — Activity 이벤트 (다른 모듈에서 emit) my-tasks/ (7) — 집계 전용 (task 모듈에 흡수 가능) ``` +> 실제 배치: `member` 는 `repo` 모듈에 흡수(`MemberService`/`MemberController` + `RepoMember` 재사용). `task` 모듈은 4단계 완료(엔티티·CRUD·상태머신·집계), 5단계에서 Comment/Reply/Attachment 추가 예정. `RepoModule` 이 진행률·taskCount 집계를 위해 `TaskModule` 을 import(엔티티만 참조해 순환 없음). diff --git a/docs/integration-progress.md b/docs/integration-progress.md index 54930ca..5b12b41 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~3단계 완료(+ Gitea 양방향 연동·템플릿 복제·생성폼 보강), 4단계(업무) 대기** -> 남은 프론트 연동: RepoMembersPage(3단계), cloneUrl/htmlUrl 표시·giteaMissing 배지(2.5단계). 그 외 4~8단계 미착수. +> 최종 갱신: 2026-06-17 · 현재 위치: **0~4단계 완료(+ Gitea 양방향 연동·템플릿 복제·생성폼 보강·멤버 프론트 연동·멤버 초대 자동완성·import 멤버 데드락 해소·cloneUrl/htmlUrl·giteaMissing 배지·업무 CRUD/상태머신/진행률 집계), 5단계(업무 상세 부속) 대기** +> 남은 프론트 연동: 업무 상세의 댓글/체크리스트 토글/첨부/승인 노트(5단계). 그 외 6~8단계 미착수. --- @@ -17,8 +17,8 @@ ### 단계 순서 ``` -0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드) -→ 4. 업무 → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리 +0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드+프론트) +→ 4. 업무 ✅(백엔드+프론트) → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리 ``` --- @@ -55,9 +55,9 @@ **백엔드** `backend/src/modules/repo/` - `Repo` 엔티티: `slugName`(영문, 라우팅 id, unique), `owner`('marketing-team' 고정 상수), `name`(한글 표시제목), `description`, `visibility`, `branch`. -- `RepoMember` 엔티티: repo↔user 조인, `roleType`(admin/member), `isOwner`, `subRole`, `@Unique([repo, user])`. +- `RepoMember` 엔티티: repo↔user 조인, `roleType`(admin/member), `isOwner`, `@Unique([repo, user])`. - `RepoService`: 내 멤버십 저장소 목록 / 단건(멤버·공개 접근 체크) / 생성(생성자를 owner·admin 등록) / 수정(admin) / 삭제(owner). -- `GET/POST/PATCH/DELETE /repos[/:repoId]` — 전부 `JwtAuthGuard`. 응답 `RepoResponse`(원시값; `doneCount/totalCount`=0, 4단계에서 집계). +- `GET/POST/PATCH/DELETE /repos[/:repoId]` — 전부 `JwtAuthGuard`. 응답 `RepoResponse`(원시값; `doneCount/totalCount` 는 당시 0 하드코딩 → **4단계에서 업무 status 집계로 교체됨**). **프론트** - `shared/utils/format.ts`: `avatarColor`(id 해시)·`initialOf`·`relativeTimeKo`·`progressOf` 추가. @@ -76,7 +76,7 @@ - **삭제**: Gitea repo 삭제 동기화. 베스트 에포트(실패해도 Relay 삭제 유지, 에러 로그). - 인프라 변경 없음(외부 Gitea). 토큰은 JWT 처럼 `backend/.env.{env}` 의 백엔드 고유 시크릿. 커밋용 `backend/.env.{env}.example` 에 키 문서화. -> **TODO(프론트)**: RepoSettingsPage/RepoDetailPage 에 `cloneUrl`/`htmlUrl` 표시(클론 주소 복사, Gitea 열기 버튼). 백엔드 응답에는 이미 포함. +> **프론트 ✅**: 공용 컴포넌트 `components/GiteaRepoBar.vue`(클론 주소 표시·복사 + 'Gitea에서 열기', 미연동 시 렌더 안 함)를 **RepoSettingsPage**(일반 카드)·**RepoDetailPage**(헤더 아래)에 적용. `Repo` 뷰 타입/`repo.store` 매핑에 `cloneUrl`/`htmlUrl`/`giteaMissing` 추가. ### 2.6단계 — Gitea → Relay 역방향 동기화 ✅ (백엔드) @@ -87,22 +87,54 @@ - **추가형**: Gitea ID→slug 매칭. 없으면 import(멤버 0명), 있으면 연동정보만 backfill. 표시명/설명/공개범위는 import 후 Relay 가 SSOT. - **누락 표기**: Gitea 에서 사라진 연동 origin 저장소는 삭제하지 않고 `giteaMissing:true` 로만 표기(재출현 시 복구). `Repo.giteaMissing` 컬럼 추가, `RepoResponse`/프론트 `ApiRepo` 에 노출. - 미연동 시 건너뜀 + 중복 실행 가드. + - **import 저장소 멤버 데드락 해소(2026-06-17)**: import 저장소는 멤버 0명이라 admin 이 없어 아무도 멤버를 초대할 수 없는 막힘이 있었음. 동기화 끝에 **`REPO_IMPORT_ADMIN_EMAIL`(env) 의 가입 사용자를 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정**(신규 import + 기존 멤버 0명 모두 대상, Relay 생성분은 생성자가 이미 멤버라 제외). env 미설정/미가입이면 건너뜀(가입 후 다음 동기화에서 지정). 키 문서화: `backend/.env.{env}.example`. - **목록 모델 변경**: `GET /repos` 가 '내 멤버 저장소' → **'조직 저장소 전체'**(`findAll`). 단건(`findOne`)도 조직 모델상 인증 사용자 열람 허용(쓰기 권한은 기존 admin/owner 유지). 멤버십은 역할/권한 전용. -> **TODO(프론트)**: RepoListPage 에 `giteaMissing` '원격 없음' 배지 표시. import 된 멤버 0명 저장소의 빈 아바타/0% 진행률은 정상. +> **프론트 ✅**: RepoListPage 에 `giteaMissing` '원격 없음' 배지(저장소 행 제목 옆) 표시. 헤더 'Gitea 연동됨' 배지는 실제 연동 여부(`htmlUrl` 보유)에 따라 노출되도록 보정. import 된 멤버 0명 저장소의 빈 아바타/0% 진행률은 정상. -### 3단계 — 멤버 관리 ✅ (백엔드) +### 3단계 — 멤버 관리 ✅ (백엔드 + 프론트) **백엔드** `backend/src/modules/repo/` - `MemberService` + `MemberController`(repo 모듈에 흡수, `RepoMember` 엔티티 재사용). 엔드포인트: `GET/POST/PATCH/DELETE /repos/:repoId/members[/:userId]`. - - **목록**: 멤버이거나 공개 저장소만 조회(생성순 정렬). 응답 `RepoMemberResponse { user, email, subRole, taskCount, roleType, owner }`. + - **목록**: 조직 모델상 **인증 사용자면 누구나 열람**(생성순 정렬, `RepoService.findOne` 과 동일 정책). 응답 `RepoMemberResponse { user, email, taskCount, roleType, owner }`. (※ 2.6단계 조직 모델 전환 후에도 list 가 옛 '멤버 or 공개' 체크를 유지해 import/타인 생성 저장소 멤버 탭이 403 나던 버그 수정 — 2026-06-17) - **초대**: admin 권한. **이미 가입된 사용자만 이메일로 초대**(별도 초대 메일/계정 생성 없음). 미가입 이메일→`BIZ_001`(404), 이미 멤버→`BIZ_001`(409). - - **역할/직무 변경·제거**: admin 권한. **소유자(owner)는 역할 변경·제거 불가**(`BIZ_001` 403). -- `InviteMemberDto`(email/roleType/subRole?), `UpdateMemberDto`(roleType?/subRole?). 단위 테스트 `member.service.spec.ts`(소유자 보호·중복·권한 가드). + - **역할 변경·제거**: admin 권한. **소유자(owner)는 역할 변경·제거 불가**(`BIZ_001` 403). +- `InviteMemberDto`(email/roleType), `UpdateMemberDto`(roleType?). 단위 테스트 `member.service.spec.ts`(소유자 보호·중복·권한 가드). - `taskCount` 는 4단계 업무 모듈 도입 전까지 0. - **Gitea 미동기화**: 인증 모델이 조직 토큰 단일 사용이라 멤버↔Gitea collaborator 동기화는 하지 않음(사용자별 Gitea 계정 매핑은 8단계 후보). -> **TODO(프론트)**: `useMember`/`member.store`, **RepoMembersPage**(초대 모달·역할 변경·제거) 연동. +**사용자 검색 API** `backend/src/modules/user/` ✅ (멤버 초대 자동완성용) +- `GET /users?q=<검색어>`(`JwtAuthGuard`) — `q` 있으면 이메일/이름 부분일치(`ILike`, 대소문자 무시), 없으면 전체(이름순, 최대 50). 응답 `PublicUser[]`. +- `UserController` 신규 + `UserService.search()` 추가, `UserModule` 에 컨트롤러 등록. (1단계에서 삭제했던 샘플 user.controller 와 무관한 신규 검색 전용) + +**프론트** `frontend/src/` ✅ +- `types/member.ts`(`ApiRepoMember`/`InviteMemberPayload`/`UpdateMemberPayload`), `composables/useMember.ts`(list/invite/update/remove), `stores/member.store.ts`(API→화면용 `RepoMember` 매핑: 이니셜·색상 파생, `isYou`=현재 사용자 비교). +- **RepoMembersPage** 목업 제거 → 실제 연동: 멤버 목록(로딩/빈 상태), 헤더·탭 카운트 실데이터, **초대 모달**(이메일+역할, 가입 사용자만, **사용자 자동완성** — 포커스 시 전체·입력 시 이메일/이름 부분일치, 기존 멤버 제외), **역할 변경 드롭다운**(멤버↔관리자), **멤버 제거**(confirm). 초대/역할변경/제거 컨트롤은 **현재 사용자가 admin 일 때만 노출**(`isAdmin`), 소유자(owner) 행은 변경·제거 불가. +- 미가입/중복 초대 등은 인터셉터가 서버 `BIZ_001` 문구를 그대로 노출. `taskCount` 는 4단계까지 0. + +### 4단계 — 업무(Task) CRUD + 상태 전이 ✅ (백엔드 + 프론트) + +**백엔드** `backend/src/modules/task/` +- `Task` 엔티티: 저장소 종속(`@ManyToOne Repo`, `Relation<>`), `seq`(저장소 내 순번 #N, `@Unique([repo, seq])`), `title`, `content`(jsonb 문단 배열), `status`, `dueDate`, `issuer`(User, SET NULL), `assignees`(`@ManyToMany` → `task_assignees`), `checklist`(`@OneToMany ChecklistItem`, cascade). `ChecklistItem`: `text`/`done`/`sortOrder`. +- `TaskService`: 목록(상태/담당자 필터, seq 내림차순) / 상세 / 생성 / 수정 / 삭제 / `changeStatus`. + - **담당자 = 저장소 멤버만**: `resolveMemberAssignees` 가 `assigneeIds` 를 멤버십과 대조, 비멤버 포함 시 `BIZ_001`(400). + - **순번 채번**: 저장소 내 `max(seq)+1`. + - **상태 머신**: `todo→prog→review→done`, `review→changes`, `changes→{review,prog}`. 허용 외 전이는 `BIZ_001`(409). 역할: 승인/수정요청(review 출발)은 지시자/admin, 그 외 작업 전이는 담당자/지시자/admin. 비멤버는 전이 불가(403). + - **승인 완료(→done) 부수효과**: 해당 업무의 체크리스트 항목 `done` 을 모두 true 로 **영속 처리**(승인=완료. `done` 은 출구 전이 없어 안전). 디자인 시안의 '승인 완료=체크리스트 100%' 동작 일치. +- `TaskController`: `GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId]` + `POST .../:taskId/status`(`taskId`=seq, `ParseIntPipe`). 생성/수정/삭제는 admin. +- **저장소 진행률 실집계**: `RepoService` 가 `TaskService.countsByRepo` 로 `doneCount/totalCount` 를 채운다(기존 0 하드코딩 제거). **멤버 `taskCount`**: `MemberService` 가 `TaskService.assigneeCounts` 로 사용자별 담당 수 집계(`RepoModule`→`TaskModule` import). 순환 모듈 없음(TaskModule 은 엔티티만 참조). +- 단위 테스트 `task.service.spec.ts`(상태머신 위반·승인 권한·비멤버 차단·담당자 멤버 검증·**승인 시 체크리스트 일괄 완료**). 백엔드 18 tests pass. +- `comments`/`activities`/`files`/`approval` 은 **5단계 영역** — 응답에 빈 배열로 내려간다. + +**프론트** `frontend/src/` +- `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단계** — 수정 폼에선 표시·읽기전용(제목/내용/담당자/마감만 저장). +- 댓글/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% 완료’) 제거(업무 수·바는 유지). +- **디자인 시안 정렬(2026-06-17)**: 담당자 액션 카드를 시안의 `.ap-card` 색상 체계로 정렬 — 승인요청(prog)=accent(`request`)·수정요청(changes)=빨강·승인완료(done)=초록·승인대기(review)=앰버. ‘업무 시작’도 `request` 톤(인디고)으로 통일. 사이드 상태 뱃지(`.status-badge`)에 `todo/changes/done` 변형 추가(기존엔 review 변형만 있어 나머지가 파란색으로 떨어지던 버그 수정). 카드 본문의 행위자·시각·승인노트는 5·6단계 데이터라 지시자 이름만 넣어 근사. ### 저장소 생성 폼 보강 ✅ (소유자/설명/템플릿 — 백엔드+프론트) @@ -123,13 +155,9 @@ ## 3. 남은 단계 -### 4단계 — 업무(Task) CRUD + 상태 전이 -- 백엔드: `Task`(+`ChecklistItem`) 엔티티, `GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId]`, `POST .../status`. 상태 머신 todo→prog→review→done/changes. -- 저장소 `doneCount/totalCount` 실제 집계로 교체(현재 0 하드코딩). -- 프론트: **RepoDetailPage 업무 목록**, **TaskCreatePage**, **TaskDetailPage**(지시자), **TaskAssigneePage**(담당자) 연동. - ### 5단계 — 업무 상세 부속 -- 체크리스트 토글, 댓글/답글, **파일 첨부**, 승인 워크플로우(승인 요청/승인/수정 요청). +- 체크리스트 **개별 항목 토글/추가/삭제 영속**(현재 항목은 읽기 전용, 단 *승인 완료 시 일괄 완료*는 4단계에서 구현), 댓글/답글, **파일 첨부**, 승인 노트(승인 요청/수정 요청 시 메시지). +- 승인 워크플로우 전용 엔드포인트(`approval/request|approve|changes`)는 상태 머신(4단계 `POST .../status`)에 노트·첨부를 더하는 형태로 확장. - **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO). ### 6단계 — 활동 피드 @@ -139,6 +167,7 @@ ### 7단계 — 내 업무 집계 - `GET /tasks/assigned`·`/tasks/issued`(저장소 횡단 집계). 프론트: **MyTasksPage**(담당/지시 탭). +- **정리 대상**: 현재 mock `MyTasksPage` 가 유일하게 `…/tasks/:taskId/view`(`TaskAssigneePage`)로 링크 중. 4단계에서 상세(`TaskDetailPage`)가 역할 기반으로 통합됐으므로, 이 단계에서 MyTasks 를 실데이터로 연동하며 `/view` 링크를 `…/tasks/:taskId` 로 바꾸고 `TaskAssigneePage`/`task-assignee-view` 라우트를 제거한다. ### 8단계 — 마무리(선택) - 소셜 로그인(Google/Kakao), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일. @@ -147,15 +176,16 @@ ## 4. 전환기 한계 (현재 시점) -- **RepoMembersPage** 는 백엔드 API(3단계)는 준비됐으나 **프론트 연동 대기** → 연동 전까지 mock 사용. **RepoActivityPage** 는 아직 mock `findRepo` 사용(6단계에서 해소). -- **RepoDetail 업무 목록**도 mock(4단계). 업무 모듈이 없어 모든 저장소 **진행률은 `0% / 시작 전`**. -- `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus` 등) + 미연동 화면 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전. +- **RepoActivityPage** 는 아직 mock `findRepo` 사용(6단계에서 해소). (RepoMembersPage 는 3단계, RepoDetail 업무 목록은 4단계에서 프론트 연동 완료) +- **업무 상세의 댓글·체크리스트 토글·첨부·승인 노트**는 5단계 영역 — 현재 댓글/에이전트는 로컬 데모, 첨부/승인노트 UI 는 자리만 표시. 진행률은 4단계 집계로 실데이터. +- `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus`/`RepoTask`/`TaskDetail` 등) + 미연동 화면(활동/내업무) 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전. --- ## 5. 알아둘 사항 / 결정 기록 - **엔티티 순환참조**: 서로 참조하는 관계(예: `Repo`↔`RepoMember`)의 **단일 클래스 관계 속성에는 TypeORM `Relation<>` 래퍼**를 사용한다. 안 그러면 SWC 환경에서 `Cannot access 'X' before initialization` 크래시. → 4·5단계(Task↔Repo, Comment↔Task 등)에도 동일 적용. +- **raw 집계 쿼리용 FK 컬럼명**: `createQueryBuilder().select('task.repo_id', …)` 처럼 **raw SQL 로 FK 컬럼을 직접 참조**하면, `@ManyToOne` 의 기본 FK 컬럼명이 camelCase(`repoId`)라 `task.repo_id`(snake) 와 불일치해 `42703 column does not exist` 가 난다. → 관계에 **`@JoinColumn({ name: 'repo_id' })` 를 명시**해 컬럼명을 snake_case 로 고정(프로젝트 컬럼 네이밍과도 일치). `Task.repo` 에 적용. (`task.repo = :id` 같은 관계 쿼리는 자동 매핑되어 무관) - **bcrypt → bcryptjs**: Docker 베이스가 `node:20-alpine`(빌드 도구 없음)이라 네이티브 모듈 `bcrypt` 대신 순수 JS `bcryptjs` 사용. - **dev 도커 익명 볼륨**: `/app/node_modules` 가 익명 볼륨이라 **의존성 변경 시 `up -d --build -V`(익명 볼륨 갱신)** 필요. `--build` 만으로는 기존 node_modules 볼륨이 재사용되어 새 패키지가 반영되지 않음. - **owner 고정**: 조직 모델이 없어 `owner='marketing-team'` 상수. **Gitea 연동 시 owner=`GITEA_ORG`** 로 대체(미연동 시 상수 폴백). 조직 도입 시 교체. @@ -163,6 +193,11 @@ - **Gitea 연동 방식**: HTTP 클라이언트는 새 의존성(@nestjs/axios) 없이 Node 20 내장 `fetch` 사용(베이스 이미지 alpine 호환). 인증은 조직 토큰 1개(사용자별 Gitea 계정 매핑은 8단계 후보). `slug`↔Gitea repo name 1:1, 한글 표시제목은 Gitea description 에 합쳐 저장(Gitea 에 표시명 필드 없음). - **연동 비활성 폴백**: `GITEA_*` 미설정 시 `GiteaService.enabled=false` → 저장소 CRUD 가 Gitea 없이 동작. 로컬 개발/테스트에서 Gitea 없이도 기존 흐름 유지. - **저장소 생성 폼 보강**: ① **프로젝트 설명 필수화**(`CreateRepoDto.description` required, 프론트 검증 추가). ② **템플릿 복제** — `useTemplate=true` 면 Gitea generate API 로 `GITEA_TEMPLATE_OWNER/REPO`(기본 `TtiPo/project-base`, *template* 설정 필요) 저장소를 복제 생성(브랜치는 템플릿을 따름). 프론트는 `GET /repos/meta`(owner+template) 로 owner 고정표시·템플릿 옵션을 받아 하드코딩 제거. +- **import 저장소 멤버 데드락 / `REPO_IMPORT_ADMIN_EMAIL`**: import 저장소는 멤버 0명 → admin 부재 → 초대 불가의 데드락. env(`REPO_IMPORT_ADMIN_EMAIL`)로 지정한 가입 사용자를 동기화 시 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정해 해소. (선택지 중 "env 지정 이메일" 채택 — 동기화는 시스템 작업이라 실행 주체가 없기 때문) +- **`subRole`(저장소 내 직무) 제거**: 표시/권한 어디에도 관여하지 않는 장식 라벨이라 백엔드 엔티티·DTO·응답 + 프론트 타입·표시·초대입력에서 **완전 제거**(2026-06-17). owner 에 박던 `'리드'` 기본값도 제거. dev `synchronize` 가 `repo_members.sub_role` 컬럼을 드롭함. (권한은 `roleType`(admin/member)만 사용. 전사 직무 `User.role` 은 별개로 유지) +- **env 변경 시 컨테이너 재생성 필요**: `docker-compose` 의 `env_file`(루트·`backend/.env.*`)은 **컨테이너 시작 시 1회만** 주입된다. `nest start --watch` 의 핫리로드는 **코드만** 다시 읽고 `process.env` 는 갱신하지 않으므로, env 값을 바꾸면 `up -d`(설정 변경 감지 시 재생성) 또는 `up -d --force-recreate <서비스>` 로 컨테이너를 다시 만들어야 한다. `docker compose restart` 는 env_file 을 다시 읽지 않음. (코드 변경만이면 watch 가 자동 반영) +- **env 파일 구조 정규화**: 각 실제 `.env.{env}` 와 그 `.env.{env}.example` 은 **주석·키 순서·빈 줄까지 동일**하게 맞추고 차이는 값(실제 secret ↔ placeholder)만 두도록 정리. frontend 는 고유 키가 없어 헤더 주석만 동일하게 둠. +- **전역 `relay.css` 모달 규칙 상속 주의**: 공통 `.modal-body`(text-align:center)·`.mbtn`(flex:1)·`.modal-backdrop` 가 전역이라, 다른 레이아웃의 커스텀 모달(예: `.invite-modal`)은 **스코프 클래스로 `text-align`/`flex` 를 명시적으로 리셋**해야 한다. (초대 모달 본문 좌측정렬·푸터 버튼 폭 깨짐의 원인이었음) --- @@ -178,7 +213,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d - 백엔드: `npm run build` / `npm run lint` (현재 0 error) - 프론트: `npm run type-check` / `npm run lint` / `npm run build-only` (현재 0 error) -- dev `synchronize=true` 가 `users`·`repos`·`repo_members` 테이블 자동 생성(Gitea 연동 컬럼 포함). DB가 비어 있으므로 **회원가입 후 로그인**. +- dev `synchronize=true` 가 `users`·`repos`·`repo_members`·`tasks`·`checklist_items`·`task_assignees` 테이블 자동 생성(Gitea 연동 컬럼 포함)·스키마 변경 자동 반영(예: `sub_role` 컬럼 드롭, `tasks.repo_id` FK 컬럼명). DB가 비어 있으므로 **회원가입 후 로그인**. 엔티티 신규 추가(4단계) 시 백엔드 컨테이너 재기동으로 스키마 반영. - 포트: 프론트 5273, 백엔드 3100(`/api`), Swagger `http://localhost:3100/api-docs`. ### Gitea 연동 설정/검증 @@ -189,3 +224,4 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d 2. 부팅 로그에서 `Gitea 연동 활성화 — (org: )` 확인(미설정 시 비활성 경고). 3. Relay 에서 저장소 생성 → Gitea `` 조직에 동일 slug repo 가 생기는지 확인. 응답 `cloneUrl`/`htmlUrl` 채워짐. 4. 수정(설명/공개범위)·삭제 시 Gitea 측 반영 확인. +5. (선택) `REPO_IMPORT_ADMIN_EMAIL` 에 **가입된** 사용자 이메일을 넣으면, 동기화 시 멤버 0명 import 저장소에 그 사용자가 owner-admin 으로 지정된다. **env 변경이므로 컨테이너 재생성 필요**(`up -d --force-recreate backend`). 부팅 로그의 `import 저장소 owner-admin 지정: ` 로 확인. diff --git a/frontend/src/components/RepoHeader.vue b/frontend/src/components/RepoHeader.vue index a476647..f916e1c 100644 --- a/frontend/src/components/RepoHeader.vue +++ b/frontend/src/components/RepoHeader.vue @@ -1,7 +1,6 @@