From efe7b2958176f8bf4d606d0bfc3ecba407751a6b Mon Sep 17 00:00:00 2001 From: TtiPo Date: Tue, 16 Jun 2026 23:41:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=EA=B4=80=EB=A6=AC=20API=20=EC=B6=94=EA=B0=80=20(3?= =?UTF-8?q?=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저장소별 멤버 초대/역할변경/제거 엔드포인트를 추가한다. - MemberService/MemberController: GET/POST/PATCH/DELETE /repos/:repoId/members - 권한: 목록=멤버+, 변경=admin, 소유자(owner) 역할변경·제거 불가 - 초대는 가입된 사용자만(이메일 조회), 미가입/중복 시 BIZ_001 - InviteMemberDto/UpdateMemberDto, member.service.spec(가드 단위 테스트) - 멤버는 Gitea 미동기화(조직 토큰 단일 모델), api-contract/진행현황 갱신 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/modules/repo/dto/invite-member.dto.ts | 39 ++++ .../src/modules/repo/dto/update-member.dto.ts | 21 ++ backend/src/modules/repo/member.controller.ts | 83 ++++++++ .../src/modules/repo/member.service.spec.ts | 118 +++++++++++ backend/src/modules/repo/member.service.ts | 184 ++++++++++++++++++ backend/src/modules/repo/repo.module.ts | 10 +- docs/integration-progress.md | 26 ++- 7 files changed, 468 insertions(+), 13 deletions(-) create mode 100644 backend/src/modules/repo/dto/invite-member.dto.ts create mode 100644 backend/src/modules/repo/dto/update-member.dto.ts create mode 100644 backend/src/modules/repo/member.controller.ts create mode 100644 backend/src/modules/repo/member.service.spec.ts create mode 100644 backend/src/modules/repo/member.service.ts diff --git a/backend/src/modules/repo/dto/invite-member.dto.ts b/backend/src/modules/repo/dto/invite-member.dto.ts new file mode 100644 index 0000000..f4081fe --- /dev/null +++ b/backend/src/modules/repo/dto/invite-member.dto.ts @@ -0,0 +1,39 @@ +import { + IsEmail, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import type { MemberRoleType } from '../entities/repo-member.entity'; + +// 멤버 초대 DTO — 가입된 사용자를 이메일로 초대(별도 초대 메일/계정 생성 없음) +export class InviteMemberDto { + @ApiProperty({ + description: '초대할 사용자 이메일(이미 가입된 사용자)', + example: 'sykim@acme.co', + }) + @IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' }) + @IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' }) + email!: string; + + @ApiProperty({ + description: '권한 역할', + enum: ['admin', 'member'], + example: 'member', + }) + @IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' }) + roleType!: MemberRoleType; + + @ApiProperty({ + description: '저장소 내 직무(선택)', + example: '콘텐츠 디자인', + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(20) + subRole?: string; +} diff --git a/backend/src/modules/repo/dto/update-member.dto.ts b/backend/src/modules/repo/dto/update-member.dto.ts new file mode 100644 index 0000000..6d8e935 --- /dev/null +++ b/backend/src/modules/repo/dto/update-member.dto.ts @@ -0,0 +1,21 @@ +import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import type { MemberRoleType } from '../entities/repo-member.entity'; + +// 멤버 수정 DTO — 권한 역할/저장소 내 직무 (owner 는 변경 불가) +export class UpdateMemberDto { + @ApiProperty({ + description: '권한 역할', + enum: ['admin', 'member'], + required: false, + }) + @IsOptional() + @IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' }) + roleType?: MemberRoleType; + + @ApiProperty({ description: '저장소 내 직무', required: false }) + @IsOptional() + @IsString() + @MaxLength(20) + subRole?: string; +} diff --git a/backend/src/modules/repo/member.controller.ts b/backend/src/modules/repo/member.controller.ts new file mode 100644 index 0000000..646b088 --- /dev/null +++ b/backend/src/modules/repo/member.controller.ts @@ -0,0 +1,83 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + 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 { MemberService, type RepoMemberResponse } from './member.service'; +import { InviteMemberDto } from './dto/invite-member.dto'; +import { UpdateMemberDto } from './dto/update-member.dto'; + +// 저장소 멤버 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName) +@ApiTags('Member') +@Controller('repos/:repoId/members') +@UseGuards(JwtAuthGuard) +export class MemberController { + constructor(private readonly memberService: MemberService) {} + + @Get() + @ApiOperation({ summary: '저장소 멤버 목록 (멤버 또는 공개)' }) + @ApiResponse({ status: 200, description: '목록 조회 성공' }) + @ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' }) + list( + @Param('repoId') repoId: string, + @CurrentUser() user: PublicUser, + ): Promise { + return this.memberService.list(repoId, user.id); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '멤버 초대 (admin)' }) + @ApiResponse({ status: 201, description: '초대 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) + @ApiResponse({ + status: 404, + description: '가입된 사용자를 찾을 수 없음 (BIZ_001)', + }) + @ApiResponse({ status: 409, description: '이미 멤버 (BIZ_001)' }) + invite( + @Param('repoId') repoId: string, + @Body() dto: InviteMemberDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.memberService.invite(repoId, user.id, dto); + } + + @Patch(':userId') + @ApiOperation({ summary: '멤버 역할/직무 변경 (admin, owner 제외)' }) + @ApiResponse({ status: 200, description: '변경 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 / 소유자 변경 불가' }) + update( + @Param('repoId') repoId: string, + @Param('userId') userId: string, + @Body() dto: UpdateMemberDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.memberService.update(repoId, user.id, userId, dto); + } + + @Delete(':userId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '멤버 제거 (admin, owner 제외)' }) + @ApiResponse({ status: 200, description: '제거 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 / 소유자 제거 불가' }) + async remove( + @Param('repoId') repoId: string, + @Param('userId') userId: string, + @CurrentUser() user: PublicUser, + ): Promise { + await this.memberService.remove(repoId, user.id, userId); + return null; + } +} diff --git a/backend/src/modules/repo/member.service.spec.ts b/backend/src/modules/repo/member.service.spec.ts new file mode 100644 index 0000000..e584aff --- /dev/null +++ b/backend/src/modules/repo/member.service.spec.ts @@ -0,0 +1,118 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { MemberService } from './member.service'; +import { UserService } from '../user/user.service'; +import { BusinessException } from '../../common/exceptions/business.exception'; +import { Repo } from './entities/repo.entity'; +import { RepoMember } from './entities/repo-member.entity'; + +// 저장소/멤버 리포지토리 + UserService 간이 모킹 +function makeService() { + const repoRepo = { findOne: jest.fn() }; + const memberRepo = { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + const userService = { findByEmail: jest.fn() }; + const svc = new MemberService( + repoRepo as unknown as Repository, + memberRepo as unknown as Repository, + userService as unknown as UserService, + ); + return { svc, repoRepo, memberRepo, userService }; +} + +const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' }; +const ADMIN = { roleType: 'admin' }; + +describe('MemberService', () => { + describe('list', () => { + 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 } }, + ]); + + await expect(svc.list('q3-launch', 'other-user')).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + }); + + describe('invite', () => { + it('가입되지 않은 이메일이면 BusinessException', async () => { + const { svc, repoRepo, memberRepo, userService } = makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + memberRepo.findOne.mockResolvedValueOnce(ADMIN); // assertAdmin 통과 + userService.findByEmail.mockResolvedValue(null); + + await expect( + svc.invite('q3-launch', 'admin-user', { + email: 'ghost@acme.co', + roleType: 'member', + }), + ).rejects.toBeInstanceOf(BusinessException); + }); + + it('이미 멤버면 BusinessException', async () => { + const { svc, repoRepo, memberRepo, userService } = makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + memberRepo.findOne + .mockResolvedValueOnce(ADMIN) // assertAdmin + .mockResolvedValueOnce({ id: 'existing-member' }); // 기존 멤버십 존재 + userService.findByEmail.mockResolvedValue({ id: 'u1', email: 'u@b.c' }); + + await expect( + svc.invite('q3-launch', 'admin-user', { + email: 'u@b.c', + roleType: 'member', + }), + ).rejects.toBeInstanceOf(BusinessException); + }); + + it('admin 이 아니면 ForbiddenException', async () => { + const { svc, repoRepo, memberRepo } = makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + memberRepo.findOne.mockResolvedValueOnce({ roleType: 'member' }); // 일반 멤버 + + await expect( + svc.invite('q3-launch', 'member-user', { + email: 'u@b.c', + roleType: 'member', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); + + describe('update / remove — 소유자 보호', () => { + it('소유자의 역할은 변경할 수 없다', async () => { + const { svc, repoRepo, memberRepo } = makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + memberRepo.findOne + .mockResolvedValueOnce(ADMIN) // assertAdmin + .mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자 + + await expect( + svc.update('q3-launch', 'admin-user', 'owner', { roleType: 'member' }), + ).rejects.toBeInstanceOf(BusinessException); + }); + + it('소유자는 제거할 수 없다', async () => { + const { svc, repoRepo, memberRepo } = makeService(); + repoRepo.findOne.mockResolvedValue(REPO); + memberRepo.findOne + .mockResolvedValueOnce(ADMIN) // assertAdmin + .mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자 + + await expect( + svc.remove('q3-launch', 'admin-user', 'owner'), + ).rejects.toBeInstanceOf(BusinessException); + expect(memberRepo.remove).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/modules/repo/member.service.ts b/backend/src/modules/repo/member.service.ts new file mode 100644 index 0000000..f1bb68e --- /dev/null +++ b/backend/src/modules/repo/member.service.ts @@ -0,0 +1,184 @@ +import { + ForbiddenException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; +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 { Repo } from './entities/repo.entity'; +import { RepoMember, type MemberRoleType } from './entities/repo-member.entity'; +import { InviteMemberDto } from './dto/invite-member.dto'; +import { UpdateMemberDto } from './dto/update-member.dto'; + +// 멤버 응답 형태 — isYou 는 프론트가 현재 사용자와 비교해 산출 +export interface RepoMemberResponse { + user: PublicUser; + email: string; + subRole: string | null; + taskCount: number; // TODO(4단계): 실제 담당 업무 수 집계 + roleType: MemberRoleType; + owner: boolean; // 소유자 여부(isOwner) +} + +// 저장소 멤버 관리 비즈니스 로직 +@Injectable() +export class MemberService { + constructor( + @InjectRepository(Repo) + private readonly repoRepo: Repository, + @InjectRepository(RepoMember) + private readonly memberRepo: Repository, + private readonly userService: UserService, + ) {} + + // 멤버 목록 — 멤버이거나 공개 저장소만 조회 가능 + async list(slugName: string, userId: 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)); + } + + // 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대 + async invite( + slugName: string, + actingUserId: string, + dto: InviteMemberDto, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + await this.assertAdmin(repo.id, actingUserId); + + const user = await this.userService.findByEmail(dto.email); + if (!user) { + throw new BusinessException( + 'BIZ_001', + '해당 이메일의 가입된 사용자를 찾을 수 없습니다. 먼저 가입이 필요합니다.', + HttpStatus.NOT_FOUND, + ); + } + + const existing = await this.memberRepo.findOne({ + where: { repo: { id: repo.id }, user: { id: user.id } }, + }); + if (existing) { + throw new BusinessException( + 'BIZ_001', + '이미 저장소에 속한 멤버입니다.', + HttpStatus.CONFLICT, + ); + } + + const member = this.memberRepo.create({ + repo, + user, + roleType: dto.roleType, + isOwner: false, + subRole: dto.subRole?.trim() || null, + }); + const saved = await this.memberRepo.save(member); + saved.user = user; // 관계 보장(응답 매핑용) + return this.toResponse(saved); + } + + // 멤버 역할/직무 변경 — admin 권한. 소유자(owner)는 변경 불가 + async update( + slugName: string, + actingUserId: string, + targetUserId: string, + dto: UpdateMemberDto, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + await this.assertAdmin(repo.id, actingUserId); + const member = await this.getMemberOrThrow(repo.id, targetUserId); + if (member.isOwner) { + throw new BusinessException( + 'BIZ_001', + '소유자의 권한은 변경할 수 없습니다.', + HttpStatus.FORBIDDEN, + ); + } + + 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); + } + + // 멤버 제거 — admin 권한. 소유자(owner)는 제거 불가 + async remove( + slugName: string, + actingUserId: string, + targetUserId: string, + ): Promise { + const repo = await this.getRepoOrThrow(slugName); + await this.assertAdmin(repo.id, actingUserId); + const member = await this.getMemberOrThrow(repo.id, targetUserId); + if (member.isOwner) { + throw new BusinessException( + 'BIZ_001', + '소유자는 저장소에서 제거할 수 없습니다.', + HttpStatus.FORBIDDEN, + ); + } + await this.memberRepo.remove(member); + } + + // --- 내부 헬퍼 --- + + // slugName 으로 저장소 로딩, 없으면 404 + private async getRepoOrThrow(slugName: string): Promise { + const repo = await this.repoRepo.findOne({ where: { slugName } }); + if (!repo) { + throw new NotFoundException(); + } + return repo; + } + + // 저장소-사용자 멤버십 로딩(+user), 없으면 404 + private async getMemberOrThrow( + repoId: string, + userId: string, + ): Promise { + const member = await this.memberRepo.findOne({ + where: { repo: { id: repoId }, user: { id: userId } }, + relations: ['user'], + }); + if (!member) { + throw new NotFoundException(); + } + return member; + } + + // admin 권한 확인 (아니면 403) + private async assertAdmin(repoId: string, userId: string): Promise { + const membership = await this.memberRepo.findOne({ + where: { repo: { id: repoId }, user: { id: userId } }, + }); + if (!membership || membership.roleType !== 'admin') { + throw new ForbiddenException(); + } + } + + // 엔티티 → 응답 매핑 + private toResponse(member: RepoMember): RepoMemberResponse { + return { + user: UserService.toPublic(member.user), + email: member.user.email, + subRole: member.subRole, + taskCount: 0, + 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 c008cf0..812a2ce 100644 --- a/backend/src/modules/repo/repo.module.ts +++ b/backend/src/modules/repo/repo.module.ts @@ -6,16 +6,18 @@ import { Repo } from './entities/repo.entity'; import { RepoMember } from './entities/repo-member.entity'; import { RepoService } from './repo.service'; import { RepoController } from './repo.controller'; +import { MemberService } from './member.service'; +import { MemberController } from './member.controller'; -// 저장소 모듈 +// 저장소 모듈 (저장소 + 멤버 관리) @Module({ imports: [ TypeOrmModule.forFeature([Repo, RepoMember]), UserModule, GiteaModule, ], - controllers: [RepoController], - providers: [RepoService], - exports: [RepoService], + controllers: [RepoController, MemberController], + providers: [RepoService, MemberService], + exports: [RepoService, MemberService], }) export class RepoModule {} diff --git a/docs/integration-progress.md b/docs/integration-progress.md index 8974222..a7579cc 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -2,7 +2,7 @@ > 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록. > 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조. -> 최종 갱신: 2026-06-16 · 현재 위치: **2단계(저장소) 완료 + Gitea 연동, 3단계(멤버) 대기** +> 최종 갱신: 2026-06-16 · 현재 위치: **3단계(멤버) 백엔드 완료, 4단계(업무) 대기** --- @@ -16,8 +16,8 @@ ### 단계 순서 ``` -0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ -→ 3. 멤버 → 4. 업무 → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리 +0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드) +→ 4. 업무 → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리 ``` --- @@ -77,15 +77,23 @@ > **TODO(프론트)**: RepoSettingsPage/RepoDetailPage 에 `cloneUrl`/`htmlUrl` 표시(클론 주소 복사, Gitea 열기 버튼). 백엔드 응답에는 이미 포함. +### 3단계 — 멤버 관리 ✅ (백엔드) + +**백엔드** `backend/src/modules/repo/` +- `MemberService` + `MemberController`(repo 모듈에 흡수, `RepoMember` 엔티티 재사용). 엔드포인트: `GET/POST/PATCH/DELETE /repos/:repoId/members[/:userId]`. + - **목록**: 멤버이거나 공개 저장소만 조회(생성순 정렬). 응답 `RepoMemberResponse { user, email, subRole, taskCount, roleType, owner }`. + - **초대**: admin 권한. **이미 가입된 사용자만 이메일로 초대**(별도 초대 메일/계정 생성 없음). 미가입 이메일→`BIZ_001`(404), 이미 멤버→`BIZ_001`(409). + - **역할/직무 변경·제거**: admin 권한. **소유자(owner)는 역할 변경·제거 불가**(`BIZ_001` 403). +- `InviteMemberDto`(email/roleType/subRole?), `UpdateMemberDto`(roleType?/subRole?). 단위 테스트 `member.service.spec.ts`(소유자 보호·중복·권한 가드). +- `taskCount` 는 4단계 업무 모듈 도입 전까지 0. +- **Gitea 미동기화**: 인증 모델이 조직 토큰 단일 사용이라 멤버↔Gitea collaborator 동기화는 하지 않음(사용자별 Gitea 계정 매핑은 8단계 후보). + +> **TODO(프론트)**: `useMember`/`member.store`, **RepoMembersPage**(초대 모달·역할 변경·제거) 연동. + --- ## 3. 남은 단계 -### 3단계 — 멤버 관리 -- 백엔드: `GET/POST/PATCH/DELETE /repos/:repoId/members`(목록·초대·역할변경·제거), admin 권한 가드, owner 제거 불가. -- 프론트: `useMember`/`member.store`, **RepoMembersPage**(초대 모달·역할 변경·제거) 연동. -- `RepoMember` 엔티티는 2단계에서 이미 존재 → 데이터 계층은 일부 재사용. - ### 4단계 — 업무(Task) CRUD + 상태 전이 - 백엔드: `Task`(+`ChecklistItem`) 엔티티, `GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId]`, `POST .../status`. 상태 머신 todo→prog→review→done/changes. - 저장소 `doneCount/totalCount` 실제 집계로 교체(현재 0 하드코딩). @@ -110,7 +118,7 @@ ## 4. 전환기 한계 (현재 시점) -- **RepoMembersPage·RepoActivityPage** 는 아직 mock `findRepo` 사용 → 실제 생성한 저장소의 **멤버/활동 탭은 "찾을 수 없음"** 표시(각각 3·6단계에서 해소). +- **RepoMembersPage** 는 백엔드 API(3단계)는 준비됐으나 **프론트 연동 대기** → 연동 전까지 mock 사용. **RepoActivityPage** 는 아직 mock `findRepo` 사용(6단계에서 해소). - **RepoDetail 업무 목록**도 mock(4단계). 업무 모듈이 없어 모든 저장소 **진행률은 `0% / 시작 전`**. - `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus` 등) + 미연동 화면 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전.