feat: 멤버 관리 연동 + 사용자 검색 API (3단계)

- 멤버 목록/초대/역할변경/제거 프론트 연동(RepoMembersPage), 초대 자동완성
- GET /users 사용자 검색 API + UserController
- RepoMembersPage 역할 변경 드롭다운 잘림(overflow) 수정
- InviteMember/UpdateMember DTO, RepoMember 엔티티 정비

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 18:11:46 +09:00
parent e2fe089321
commit c01f8e535c
13 changed files with 686 additions and 199 deletions
@@ -1,11 +1,4 @@
import {
IsEmail,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { IsEmail, IsIn, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { MemberRoleType } from '../entities/repo-member.entity';
@@ -26,14 +19,4 @@ export class InviteMemberDto {
})
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
roleType!: MemberRoleType;
@ApiProperty({
description: '저장소 내 직무(선택)',
example: '콘텐츠 디자인',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(20)
subRole?: string;
}
@@ -1,8 +1,8 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import { IsIn, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { MemberRoleType } from '../entities/repo-member.entity';
// 멤버 수정 DTO — 권한 역할/저장소 내 직무 (owner 는 변경 불가)
// 멤버 수정 DTO — 권한 역할 (owner 는 변경 불가)
export class UpdateMemberDto {
@ApiProperty({
description: '권한 역할',
@@ -12,10 +12,4 @@ export class UpdateMemberDto {
@IsOptional()
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
roleType?: MemberRoleType;
@ApiProperty({ description: '저장소 내 직무', required: false })
@IsOptional()
@IsString()
@MaxLength(20)
subRole?: string;
}
@@ -37,10 +37,6 @@ export class RepoMember {
@Column({ name: 'is_owner', default: false })
isOwner!: boolean;
// 저장소 내 직무 표기(예: '리드', '콘텐츠 디자인') — 선택
@Column({ name: 'sub_role', type: 'varchar', nullable: true })
subRole!: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -26,14 +26,11 @@ export class MemberController {
constructor(private readonly memberService: MemberService) {}
@Get()
@ApiOperation({ summary: '저장소 멤버 목록 (멤버 또는 공개)' })
@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);
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(@Param('repoId') repoId: string): Promise<RepoMemberResponse[]> {
return this.memberService.list(repoId);
}
@Post()
@@ -0,0 +1,24 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UserService, type PublicUser } from './user.service';
// 사용자 컨트롤러 — 멤버 초대 자동완성을 위한 검색/목록 제공 (인증 필요)
@ApiTags('User')
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({
summary: '사용자 검색/목록 (멤버 초대 자동완성)',
description: 'q 가 있으면 이메일/이름 부분일치, 없으면 전체 목록(이름순).',
})
@ApiQuery({ name: 'q', required: false, description: '검색어(이메일/이름)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
async list(@Query('q') q?: string): Promise<PublicUser[]> {
const users = await this.userService.search(q);
return users.map((u) => UserService.toPublic(u));
}
}
+2
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { User } from './entities/user.entity';
// 사용자 데이터 계층 모듈 — UserService 를 다른 모듈(Auth 등)에 제공
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
+16 -1
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ILike, Repository } from 'typeorm';
import { User } from './entities/user.entity';
// 외부 노출용 사용자 형태 (passwordHash 등 민감정보 제외)
@@ -29,6 +29,21 @@ export class UserService {
return this.userRepository.findOne({ where: { email } });
}
// 사용자 검색/목록 (멤버 초대 자동완성용) — 이메일/이름 부분일치(대소문자 무시).
// 검색어가 없으면 전체 목록(이름순, limit 개)을 반환한다.
search(query: string | undefined, limit = 50): Promise<User[]> {
const q = (query ?? '').trim();
if (!q) {
return this.userRepository.find({ order: { name: 'ASC' }, take: limit });
}
const like = ILike(`%${q}%`);
return this.userRepository.find({
where: [{ email: like }, { name: like }],
order: { name: 'ASC' },
take: limit,
});
}
// 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회
findByEmailWithPassword(email: string): Promise<User | null> {
return this.userRepository