first commit

This commit is contained in:
2026-06-16 17:12:08 +09:00
commit e1bc5a1dfc
257 changed files with 49823 additions and 0 deletions
+142
View File
@@ -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;
}
}
+22
View File
@@ -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 {}
+83
View File
@@ -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;
},
);
+30
View File
@@ -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;
}
}
+16
View File
@@ -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 {}
+191
View File
@@ -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;
}
+12
View File
@@ -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 {}
+66
View File
@@ -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,
};
}
}