diff --git a/backend/src/modules/repo/dto/invite-member.dto.ts b/backend/src/modules/repo/dto/invite-member.dto.ts index f4081fe..7771133 100644 --- a/backend/src/modules/repo/dto/invite-member.dto.ts +++ b/backend/src/modules/repo/dto/invite-member.dto.ts @@ -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; } diff --git a/backend/src/modules/repo/dto/update-member.dto.ts b/backend/src/modules/repo/dto/update-member.dto.ts index 6d8e935..8f7a931 100644 --- a/backend/src/modules/repo/dto/update-member.dto.ts +++ b/backend/src/modules/repo/dto/update-member.dto.ts @@ -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; } diff --git a/backend/src/modules/repo/entities/repo-member.entity.ts b/backend/src/modules/repo/entities/repo-member.entity.ts index f22539f..b00f378 100644 --- a/backend/src/modules/repo/entities/repo-member.entity.ts +++ b/backend/src/modules/repo/entities/repo-member.entity.ts @@ -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; } diff --git a/backend/src/modules/repo/member.controller.ts b/backend/src/modules/repo/member.controller.ts index 646b088..f0f00f7 100644 --- a/backend/src/modules/repo/member.controller.ts +++ b/backend/src/modules/repo/member.controller.ts @@ -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 { - return this.memberService.list(repoId, user.id); + @ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' }) + list(@Param('repoId') repoId: string): Promise { + return this.memberService.list(repoId); } @Post() diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts new file mode 100644 index 0000000..22c6d91 --- /dev/null +++ b/backend/src/modules/user/user.controller.ts @@ -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 { + const users = await this.userService.search(q); + return users.map((u) => UserService.toPublic(u)); + } +} diff --git a/backend/src/modules/user/user.module.ts b/backend/src/modules/user/user.module.ts index d320dda..43436db 100644 --- a/backend/src/modules/user/user.module.ts +++ b/backend/src/modules/user/user.module.ts @@ -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], }) diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 76c3b3b..2a7b3a1 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -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 { + 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 { return this.userRepository diff --git a/frontend/src/composables/useMember.ts b/frontend/src/composables/useMember.ts new file mode 100644 index 0000000..1ad7f75 --- /dev/null +++ b/frontend/src/composables/useMember.ts @@ -0,0 +1,47 @@ +import { useApi } from './useApi' +import type { + ApiRepoMember, + InviteMemberPayload, + UpdateMemberPayload, +} from '@/types/member' + +// 저장소 멤버 도메인 API Composable — 인터셉터가 success/data 를 언래핑 +// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/members +export function useMember() { + const api = useApi() + + // 멤버 목록 (멤버 또는 공개 저장소) + async function list(repoId: string): Promise { + return (await api.get(`/repos/${repoId}/members`)) as unknown as ApiRepoMember[] + } + + // 멤버 초대 (admin) — 가입된 사용자만 이메일로 초대 + async function invite( + repoId: string, + payload: InviteMemberPayload, + ): Promise { + return (await api.post( + `/repos/${repoId}/members`, + payload, + )) as unknown as ApiRepoMember + } + + // 멤버 역할/직무 변경 (admin, owner 제외) + async function update( + repoId: string, + userId: string, + payload: UpdateMemberPayload, + ): Promise { + return (await api.patch( + `/repos/${repoId}/members/${userId}`, + payload, + )) as unknown as ApiRepoMember + } + + // 멤버 제거 (admin, owner 제외) + async function remove(repoId: string, userId: string): Promise { + await api.delete(`/repos/${repoId}/members/${userId}`) + } + + return { list, invite, update, remove } +} diff --git a/frontend/src/composables/useUser.ts b/frontend/src/composables/useUser.ts new file mode 100644 index 0000000..147a5d0 --- /dev/null +++ b/frontend/src/composables/useUser.ts @@ -0,0 +1,16 @@ +import { useApi } from './useApi' +import type { ApiMember } from '@/types/repo' + +// 사용자 도메인 API Composable — 멤버 초대 자동완성용 검색/목록 +// 응답은 PublicUser(= ApiMember: { id, email, name, role }) 배열 +export function useUser() { + const api = useApi() + + // 사용자 검색 — q 가 있으면 이메일/이름 부분일치, 없으면 전체 목록 + async function search(q?: string): Promise { + const params = q && q.trim() ? { q: q.trim() } : {} + return (await api.get('/users', { params })) as unknown as ApiMember[] + } + + return { search } +} diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index 68c4514..92d3430 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -56,6 +56,12 @@ export interface Repo { progressLevel: 'done' | 'mid' | 'low' /** 진행률 텍스트(예: "67% 완료", "시작 전") */ progressLabel: string + /** Gitea HTTPS clone URL (미연동 시 null/undefined) */ + cloneUrl?: string | null + /** Gitea 웹 UI URL (미연동 시 null/undefined) */ + htmlUrl?: string | null + /** Gitea 조직에서 사라진 저장소 표시(역방향 동기화로 갱신) */ + giteaMissing?: boolean } /** 저장소 상세의 업무 목록 행 */ @@ -744,8 +750,6 @@ export function countActive(groups: MyTaskGroup[]): number { export interface RepoMember { user: User email: string - /** 저장소 내 직무 표기(예: 리드, 콘텐츠 디자인) */ - subRole: string /** 담당 업무 수 */ taskCount: number /** 권한 역할 */ @@ -758,11 +762,11 @@ export interface RepoMember { export const REPO_MEMBERS: Record = { 'q3-launch-campaign': [ - { user: USERS.jung, email: 'tkjung@acme.co', subRole: '리드', taskCount: 1, roleType: 'admin', isYou: true, owner: true }, - { user: USERS.kim, email: 'sykim@acme.co', subRole: '콘텐츠 디자인', taskCount: 2, roleType: 'member' }, - { user: USERS.park, email: 'jhpark@acme.co', subRole: '퍼포먼스 마케팅', taskCount: 2, roleType: 'member' }, - { user: USERS.lee, email: 'dylee@acme.co', subRole: '카피라이터', taskCount: 2, roleType: 'member' }, - { user: USERS.choi, email: 'yjchoi@acme.co', subRole: 'UX 디자인', taskCount: 1, roleType: 'member' }, + { user: USERS.jung, email: 'tkjung@acme.co', taskCount: 1, roleType: 'admin', isYou: true, owner: true }, + { user: USERS.kim, email: 'sykim@acme.co', taskCount: 2, roleType: 'member' }, + { user: USERS.park, email: 'jhpark@acme.co', taskCount: 2, roleType: 'member' }, + { user: USERS.lee, email: 'dylee@acme.co', taskCount: 2, roleType: 'member' }, + { user: USERS.choi, email: 'yjchoi@acme.co', taskCount: 1, roleType: 'member' }, ], } diff --git a/frontend/src/pages/relay/RepoMembersPage.vue b/frontend/src/pages/relay/RepoMembersPage.vue index b11abc8..d6b8ff3 100644 --- a/frontend/src/pages/relay/RepoMembersPage.vue +++ b/frontend/src/pages/relay/RepoMembersPage.vue @@ -1,18 +1,59 @@