feat: 저장소 멤버 관리 API 추가 (3단계)
저장소별 멤버 초대/역할변경/제거 엔드포인트를 추가한다. - 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<RepoMemberResponse[]> {
|
||||
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<RepoMemberResponse> {
|
||||
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<RepoMemberResponse> {
|
||||
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<null> {
|
||||
await this.memberService.remove(repoId, user.id, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<Repo>,
|
||||
memberRepo as unknown as Repository<RepoMember>,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Repo>,
|
||||
@InjectRepository(RepoMember)
|
||||
private readonly memberRepo: Repository<RepoMember>,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
// 멤버 목록 — 멤버이거나 공개 저장소만 조회 가능
|
||||
async list(slugName: string, userId: string): Promise<RepoMemberResponse[]> {
|
||||
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<RepoMemberResponse> {
|
||||
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<RepoMemberResponse> {
|
||||
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<void> {
|
||||
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<Repo> {
|
||||
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<RepoMember> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user