first commit
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { CookieOptions, Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
|
||||
// 쿠키 만료 시간(ms)
|
||||
const ACCESS_MAX_AGE = 15 * 60 * 1000; // 15분
|
||||
const REFRESH_MAX_AGE = 14 * 24 * 60 * 60 * 1000; // 14일
|
||||
// refresh 쿠키 전송 경로 — /auth/* 요청에만 첨부 (글로벌 prefix 'api' 포함)
|
||||
const REFRESH_COOKIE_PATH = '/api/auth';
|
||||
|
||||
// 인증 컨트롤러 — 라우팅/쿠키 발급만 담당, 비즈니스 로직은 AuthService 위임
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
// HttpOnly·Secure 쿠키 공통 옵션
|
||||
private cookieBase(): CookieOptions {
|
||||
const isProd = this.config.get<string>('NODE_ENV') === 'production';
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProd, // 운영: HTTPS 전용
|
||||
sameSite: isProd ? 'none' : 'lax', // 운영: 크로스 도메인 허용
|
||||
};
|
||||
}
|
||||
|
||||
// access/refresh 토큰을 쿠키로 설정
|
||||
private setAuthCookies(
|
||||
res: Response,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
): void {
|
||||
res.cookie('access_token', accessToken, {
|
||||
...this.cookieBase(),
|
||||
path: '/',
|
||||
maxAge: ACCESS_MAX_AGE,
|
||||
});
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
...this.cookieBase(),
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
maxAge: REFRESH_MAX_AGE,
|
||||
});
|
||||
}
|
||||
|
||||
// 인증 쿠키 제거 (로그아웃)
|
||||
private clearAuthCookies(res: Response): void {
|
||||
res.clearCookie('access_token', { ...this.cookieBase(), path: '/' });
|
||||
res.clearCookie('refresh_token', {
|
||||
...this.cookieBase(),
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('signup')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '회원가입 (가입 후 자동 로그인)' })
|
||||
@ApiResponse({ status: 201, description: '가입 성공, 인증 쿠키 발급' })
|
||||
@ApiResponse({ status: 409, description: '이미 가입된 이메일 (BIZ_001)' })
|
||||
async signup(
|
||||
@Body() dto: SignupDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<PublicUser> {
|
||||
const user = await this.authService.signup(dto);
|
||||
const tokens = await this.authService.issueTokens(user);
|
||||
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '로그인' })
|
||||
@ApiResponse({ status: 200, description: '로그인 성공, 인증 쿠키 발급' })
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: '이메일/비밀번호 불일치 (BIZ_001)',
|
||||
})
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<PublicUser> {
|
||||
const user = await this.authService.validateUser(dto);
|
||||
const tokens = await this.authService.issueTokens(user);
|
||||
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '로그아웃 (인증 쿠키 제거)' })
|
||||
@ApiResponse({ status: 200, description: '로그아웃 성공' })
|
||||
logout(@Res({ passthrough: true }) res: Response): null {
|
||||
this.clearAuthCookies(res);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtRefreshGuard)
|
||||
@ApiOperation({ summary: 'access 토큰 재발급 (refresh 쿠키 필요)' })
|
||||
@ApiResponse({ status: 200, description: '토큰 재발급 성공' })
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: 'refresh 토큰 만료/누락 (AUTH_001)',
|
||||
})
|
||||
async refresh(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<null> {
|
||||
const tokens = await this.authService.issueTokens(user);
|
||||
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: '현재 로그인 사용자 조회 (부트스트랩/가드용)' })
|
||||
@ApiResponse({ status: 200, description: '사용자 조회 성공' })
|
||||
@ApiResponse({ status: 401, description: '미인증 (AUTH_001)' })
|
||||
me(@CurrentUser() user: PublicUser): PublicUser {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
||||
|
||||
// 인증 모듈 — JWT(access/refresh) + Passport 전략
|
||||
@Module({
|
||||
imports: [
|
||||
UserModule,
|
||||
PassportModule,
|
||||
// 토큰 서명 옵션은 AuthService 에서 호출 단위로 지정하므로 기본 등록만 한다
|
||||
JwtModule.register({}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { JwtService, type JwtSignOptions } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
// bcrypt 해시 라운드
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
// 발급된 토큰 쌍
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// 인증 비즈니스 로직 — 가입, 로그인 검증, 토큰 발급
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
// 회원가입 — 이메일 중복 검사 후 비밀번호 해시하여 저장
|
||||
async signup(dto: SignupDto): Promise<PublicUser> {
|
||||
const exists = await this.userService.findByEmail(dto.email);
|
||||
if (exists) {
|
||||
// 409 + 비즈니스 코드/문구 명시 → 필터가 그대로 노출
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 가입된 이메일입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
||||
const user = await this.userService.create({
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
name: dto.name,
|
||||
role: dto.role ?? null,
|
||||
});
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
|
||||
// 로그인 검증 — 이메일/비밀번호 일치 확인
|
||||
async validateUser(dto: LoginDto): Promise<PublicUser> {
|
||||
const user = await this.userService.findByEmailWithPassword(dto.email);
|
||||
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
|
||||
// 401 + 비즈니스 코드/문구 명시 → 자격 불일치를 명확히 안내
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
|
||||
// access/refresh 토큰 발급
|
||||
async issueTokens(user: PublicUser): Promise<TokenPair> {
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: user.id, email: user.email },
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: (this.config.get<string>('JWT_ACCESS_TTL') ??
|
||||
'15m') as JwtSignOptions['expiresIn'],
|
||||
},
|
||||
);
|
||||
const refreshToken = await this.jwtService.signAsync(
|
||||
{ sub: user.id },
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn: (this.config.get<string>('JWT_REFRESH_TTL') ??
|
||||
'14d') as JwtSignOptions['expiresIn'],
|
||||
},
|
||||
);
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { PublicUser } from '../../user/user.service';
|
||||
|
||||
// 현재 인증된 사용자 추출 데코레이터 — JwtAuthGuard 통과 후 req.user 를 반환
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): PublicUser => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user: PublicUser }>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 로그인 DTO — 이메일/비밀번호 + 로그인 유지 여부
|
||||
export class LoginDto {
|
||||
@ApiProperty({ description: '사용자 이메일', example: 'sykim@acme.co' })
|
||||
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ description: '비밀번호', example: 'password123' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '비밀번호는 필수 입력 항목입니다.' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '로그인 상태 유지 여부',
|
||||
example: true,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
keepLogin?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 회원가입 DTO — 이메일/비밀번호/이름
|
||||
export class SignupDto {
|
||||
@ApiProperty({ description: '사용자 이메일', example: 'sykim@acme.co' })
|
||||
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
|
||||
@IsString()
|
||||
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ description: '사용자 이름', example: '김서연' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '이름은 필수 입력 항목입니다.' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '전사 직무(선택)',
|
||||
example: '콘텐츠 기획',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
role?: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
// access 토큰 기반 인증 가드 — 보호 라우트에 적용 (미인증 시 401 → AUTH_001)
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
// refresh 토큰 기반 가드 — /auth/refresh 전용
|
||||
@Injectable()
|
||||
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request } from 'express';
|
||||
import { UserService, type PublicUser } from '../../user/user.service';
|
||||
import type { JwtPayload } from '../types/jwt-payload';
|
||||
|
||||
// refresh 토큰 검증 전략 — refresh_token 쿠키에서 토큰 추출
|
||||
@Injectable()
|
||||
export class JwtRefreshStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
'jwt-refresh',
|
||||
) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: (req: Request): string | null =>
|
||||
(req?.cookies as Record<string, string> | undefined)?.refresh_token ??
|
||||
null,
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<PublicUser> {
|
||||
const user = await this.userService.findById(payload.sub);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request } from 'express';
|
||||
import { UserService, type PublicUser } from '../../user/user.service';
|
||||
import type { JwtPayload } from '../types/jwt-payload';
|
||||
|
||||
// access 토큰 검증 전략 — access_token 쿠키에서 토큰 추출
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
super({
|
||||
// 쿠키(access_token)에서 JWT 추출
|
||||
jwtFromRequest: (req: Request): string | null =>
|
||||
(req?.cookies as Record<string, string> | undefined)?.access_token ??
|
||||
null,
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
// 토큰 유효 시 실제 사용자 존재를 재확인하고 공개 형태로 반환 → req.user
|
||||
async validate(payload: JwtPayload): Promise<PublicUser> {
|
||||
const user = await this.userService.findById(payload.sub);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// JWT 토큰 페이로드 — access/refresh 공통
|
||||
export interface JwtPayload {
|
||||
// 사용자 id (subject)
|
||||
sub: string;
|
||||
// 이메일 (access 토큰에만 포함)
|
||||
email?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckService,
|
||||
TypeOrmHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
|
||||
// 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템에서 사용
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly health: HealthCheckService,
|
||||
private readonly db: TypeOrmHealthIndicator,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
@ApiOperation({ summary: '애플리케이션 상태 확인' })
|
||||
check() {
|
||||
return this.health.check([() => this.db.pingCheck('database')]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { RepoVisibility } from '../entities/repo.entity';
|
||||
|
||||
// 저장소 생성 DTO
|
||||
export class CreateRepoDto {
|
||||
@ApiProperty({
|
||||
description: '저장소 slug(영문 소문자·숫자·하이픈·밑줄)',
|
||||
example: 'q3-launch-campaign',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '저장소 이름은 필수 입력 항목입니다.' })
|
||||
@Matches(/^[a-z0-9-_]+$/, {
|
||||
message:
|
||||
'저장소 이름은 영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.',
|
||||
})
|
||||
@MaxLength(60)
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '표시 제목(한글)',
|
||||
example: '3분기 신제품 런칭 캠페인',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '표시 제목은 필수 입력 항목입니다.' })
|
||||
@MaxLength(40)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '공개 범위',
|
||||
enum: ['private', 'public'],
|
||||
example: 'private',
|
||||
})
|
||||
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
|
||||
visibility!: RepoVisibility;
|
||||
|
||||
@ApiProperty({ description: '프로젝트 설명(선택)', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '메인 브랜치', example: 'main', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branch?: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { RepoVisibility } from '../entities/repo.entity';
|
||||
|
||||
// 저장소 수정 DTO — 표시 제목/설명/공개범위/브랜치 (slug·owner 는 변경 불가)
|
||||
export class UpdateRepoDto {
|
||||
@ApiProperty({ description: '표시 제목(한글)', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '프로젝트 설명', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '공개 범위',
|
||||
enum: ['private', 'public'],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
|
||||
visibility?: RepoVisibility;
|
||||
|
||||
@ApiProperty({ description: '메인 브랜치', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branch?: string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Repo } from './repo.entity';
|
||||
|
||||
// 저장소 내 권한 역할
|
||||
export type MemberRoleType = 'admin' | 'member';
|
||||
|
||||
// 저장소 멤버 (Repo ↔ User 조인 + 역할 정보)
|
||||
@Entity('repo_members')
|
||||
@Unique(['repo', 'user'])
|
||||
export class RepoMember {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 저장소 (저장소 삭제 시 함께 삭제)
|
||||
// Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함
|
||||
@ManyToOne(() => Repo, (repo) => repo.members, { onDelete: 'CASCADE' })
|
||||
repo!: Relation<Repo>;
|
||||
|
||||
// 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
|
||||
// 권한 역할 (admin: 저장소 관리, member: 일반)
|
||||
@Column({ name: 'role_type', type: 'varchar', default: 'member' })
|
||||
roleType!: MemberRoleType;
|
||||
|
||||
// 소유자 여부(저장소 생성자) — 역할 변경/제거 불가 대상
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { RepoMember } from './repo-member.entity';
|
||||
|
||||
// 저장소 공개 범위
|
||||
export type RepoVisibility = 'private' | 'public';
|
||||
|
||||
// 저장소 엔티티
|
||||
@Entity('repos')
|
||||
export class Repo {
|
||||
// 내부 식별자 (UUID)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 라우팅/URL 식별자(영문 slug 세그먼트, 예: 'q3-launch-campaign') — 전역 유일
|
||||
@Column({ name: 'slug_name', unique: true })
|
||||
slugName!: string;
|
||||
|
||||
// 소유 조직 slug (예: 'marketing-team') — 현재는 단일 조직 고정
|
||||
@Column()
|
||||
owner!: string;
|
||||
|
||||
// 표시 제목(한글, 예: '3분기 신제품 런칭 캠페인')
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 프로젝트 설명(선택)
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
// 공개 범위
|
||||
@Column({ type: 'varchar', default: 'private' })
|
||||
visibility!: RepoVisibility;
|
||||
|
||||
// 메인 브랜치
|
||||
@Column({ default: 'main' })
|
||||
branch!: string;
|
||||
|
||||
// 멤버 목록
|
||||
@OneToMany(() => RepoMember, (member) => member.repo)
|
||||
members!: Relation<RepoMember>[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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 { RepoService, type RepoResponse } from './repo.service';
|
||||
import { CreateRepoDto } from './dto/create-repo.dto';
|
||||
import { UpdateRepoDto } from './dto/update-repo.dto';
|
||||
|
||||
// 저장소 컨트롤러 — 모든 엔드포인트 인증 필요
|
||||
@ApiTags('Repo')
|
||||
@Controller('repos')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class RepoController {
|
||||
constructor(private readonly repoService: RepoService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '내가 멤버인 저장소 목록' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
findAll(@CurrentUser() user: PublicUser): Promise<RepoResponse[]> {
|
||||
return this.repoService.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '저장소 생성' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
@ApiResponse({
|
||||
status: 409,
|
||||
description: '이미 사용 중인 저장소 이름 (BIZ_001)',
|
||||
})
|
||||
create(
|
||||
@Body() dto: CreateRepoDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoResponse> {
|
||||
return this.repoService.create(dto, user.id);
|
||||
}
|
||||
|
||||
@Get(':repoId')
|
||||
@ApiOperation({ summary: '저장소 단건 조회' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
@ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' })
|
||||
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
|
||||
findOne(
|
||||
@Param('repoId') repoId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoResponse> {
|
||||
return this.repoService.findOneForUser(repoId, user.id);
|
||||
}
|
||||
|
||||
@Patch(':repoId')
|
||||
@ApiOperation({ summary: '저장소 수정 (admin)' })
|
||||
@ApiResponse({ status: 200, description: '수정 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
update(
|
||||
@Param('repoId') repoId: string,
|
||||
@Body() dto: UpdateRepoDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoResponse> {
|
||||
return this.repoService.update(repoId, user.id, dto);
|
||||
}
|
||||
|
||||
@Delete(':repoId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '저장소 삭제 (소유자)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
async remove(
|
||||
@Param('repoId') repoId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.repoService.remove(repoId, user.id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { RepoService } from './repo.service';
|
||||
import { RepoController } from './repo.controller';
|
||||
|
||||
// 저장소 모듈
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Repo, RepoMember]), UserModule],
|
||||
controllers: [RepoController],
|
||||
providers: [RepoService],
|
||||
exports: [RepoService],
|
||||
})
|
||||
export class RepoModule {}
|
||||
@@ -0,0 +1,191 @@
|
||||
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, type RepoVisibility } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { CreateRepoDto } from './dto/create-repo.dto';
|
||||
import { UpdateRepoDto } from './dto/update-repo.dto';
|
||||
|
||||
// 현재는 단일 조직 고정 (조직 모델 도입 시 교체)
|
||||
const DEFAULT_OWNER = 'marketing-team';
|
||||
|
||||
// 저장소 응답 형태 — 파생 라벨/진행률은 프론트에서 계산하므로 원시값 위주로 내린다
|
||||
export interface RepoResponse {
|
||||
id: string; // slugName (라우팅 식별자)
|
||||
name: string;
|
||||
owner: string;
|
||||
slug: string; // owner/slugName
|
||||
desc: string | null;
|
||||
visibility: RepoVisibility;
|
||||
branch: string;
|
||||
members: PublicUser[];
|
||||
memberCount: number;
|
||||
doneCount: number;
|
||||
totalCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 저장소 비즈니스 로직
|
||||
@Injectable()
|
||||
export class RepoService {
|
||||
constructor(
|
||||
@InjectRepository(Repo)
|
||||
private readonly repoRepo: Repository<Repo>,
|
||||
@InjectRepository(RepoMember)
|
||||
private readonly memberRepo: Repository<RepoMember>,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
// 내가 멤버인 저장소 목록
|
||||
async findAllForUser(userId: string): Promise<RepoResponse[]> {
|
||||
const memberships = await this.memberRepo.find({
|
||||
where: { user: { id: userId } },
|
||||
relations: ['repo'],
|
||||
});
|
||||
const repoIds = memberships.map((m) => m.repo.id);
|
||||
if (repoIds.length === 0) return [];
|
||||
|
||||
const repos = await this.repoRepo.find({
|
||||
where: { id: In(repoIds) },
|
||||
relations: ['members', 'members.user'],
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
return repos.map((repo) => this.toResponse(repo));
|
||||
}
|
||||
|
||||
// 저장소 단건 — 접근 권한 확인(멤버이거나 공개)
|
||||
async findOneForUser(
|
||||
slugName: string,
|
||||
userId: string,
|
||||
): Promise<RepoResponse> {
|
||||
const repo = await this.getEntityOrThrow(slugName);
|
||||
const isMember = repo.members.some((m) => m.user.id === userId);
|
||||
if (!isMember && repo.visibility !== 'public') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return this.toResponse(repo);
|
||||
}
|
||||
|
||||
// 저장소 생성 — 생성자를 소유자(admin)로 등록
|
||||
async create(dto: CreateRepoDto, userId: string): Promise<RepoResponse> {
|
||||
const existing = await this.repoRepo.findOne({
|
||||
where: { slugName: dto.slug },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 사용 중인 저장소 이름입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
const user = await this.userService.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const repo = this.repoRepo.create({
|
||||
slugName: dto.slug,
|
||||
owner: DEFAULT_OWNER,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
visibility: dto.visibility,
|
||||
branch: dto.branch?.trim() || 'main',
|
||||
});
|
||||
const saved = await this.repoRepo.save(repo);
|
||||
|
||||
const owner = this.memberRepo.create({
|
||||
repo: saved,
|
||||
user,
|
||||
roleType: 'admin',
|
||||
isOwner: true,
|
||||
subRole: '리드',
|
||||
});
|
||||
await this.memberRepo.save(owner);
|
||||
|
||||
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
|
||||
}
|
||||
|
||||
// 저장소 수정 — admin 권한 필요
|
||||
async update(
|
||||
slugName: string,
|
||||
userId: string,
|
||||
dto: UpdateRepoDto,
|
||||
): Promise<RepoResponse> {
|
||||
const repo = await this.getEntityOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, userId);
|
||||
|
||||
if (dto.name !== undefined) repo.name = dto.name;
|
||||
if (dto.description !== undefined) repo.description = dto.description;
|
||||
if (dto.visibility !== undefined) repo.visibility = dto.visibility;
|
||||
if (dto.branch !== undefined && dto.branch.trim())
|
||||
repo.branch = dto.branch.trim();
|
||||
|
||||
await this.repoRepo.save(repo);
|
||||
return this.toResponse(await this.getEntityOrThrow(slugName));
|
||||
}
|
||||
|
||||
// 저장소 삭제 — 소유자만 가능
|
||||
async remove(slugName: string, userId: string): Promise<void> {
|
||||
const repo = await this.getEntityOrThrow(slugName);
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repo.id }, user: { id: userId } },
|
||||
});
|
||||
if (!membership?.isOwner) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.repoRepo.remove(repo);
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// slugName 으로 저장소(+멤버) 로딩, 없으면 404
|
||||
private async getEntityOrThrow(slugName: string): Promise<Repo> {
|
||||
const repo = await this.repoRepo.findOne({
|
||||
where: { slugName },
|
||||
relations: ['members', 'members.user'],
|
||||
});
|
||||
if (!repo) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
// 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(repo: Repo): RepoResponse {
|
||||
const members = (repo.members ?? []).map((m) =>
|
||||
UserService.toPublic(m.user),
|
||||
);
|
||||
return {
|
||||
id: repo.slugName,
|
||||
name: repo.name,
|
||||
owner: repo.owner,
|
||||
slug: `${repo.owner}/${repo.slugName}`,
|
||||
desc: repo.description,
|
||||
visibility: repo.visibility,
|
||||
branch: repo.branch,
|
||||
members,
|
||||
memberCount: members.length,
|
||||
// TODO(4단계): 업무 모듈 도입 시 실제 완료/전체 수 집계
|
||||
doneCount: 0,
|
||||
totalCount: 0,
|
||||
updatedAt: repo.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
// 사용자 엔티티 — 인증/계정의 단일 진실 공급원
|
||||
@Entity('users')
|
||||
export class User {
|
||||
// 외부 노출 식별자 (UUID)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 로그인 이메일 (유일)
|
||||
@Column({ unique: true })
|
||||
email!: string;
|
||||
|
||||
// 비밀번호 해시 — 평문 저장 금지. 조회 시 기본 제외(select: false)
|
||||
@Column({ name: 'password_hash', select: false })
|
||||
passwordHash!: string;
|
||||
|
||||
// 표시 이름
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 전사 직무(예: '콘텐츠 기획') — 선택값
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
role!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserService } from './user.service';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
// 사용자 데이터 계층 모듈 — UserService 를 다른 모듈(Auth 등)에 제공
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
// 외부 노출용 사용자 형태 (passwordHash 등 민감정보 제외)
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
}
|
||||
|
||||
// 사용자 데이터 접근 계층 — 인증 모듈이 주입하여 사용
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
// id 단건 조회 (없으면 null)
|
||||
findById(id: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
// 이메일로 조회 (가입 중복 체크용)
|
||||
findByEmail(email: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
// 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회
|
||||
findByEmailWithPassword(email: string): Promise<User | null> {
|
||||
return this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.passwordHash')
|
||||
.where('user.email = :email', { email })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
// 신규 사용자 생성 (비밀번호는 해시된 상태로 전달받음)
|
||||
create(payload: {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
name: string;
|
||||
role?: string | null;
|
||||
}): Promise<User> {
|
||||
const user = this.userRepository.create({
|
||||
email: payload.email,
|
||||
passwordHash: payload.passwordHash,
|
||||
name: payload.name,
|
||||
role: payload.role ?? null,
|
||||
});
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
|
||||
static toPublic(user: User): PublicUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user