11/* eslint-disable no-console */
22import {
33 BadRequestException ,
4+ ConflictException ,
45 HttpStatus ,
56 Injectable ,
67 InternalServerErrorException ,
@@ -12,7 +13,7 @@ import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
1213import { PrismaService } from '@src/shared/prisma/prisma.service' ;
1314import { DateUtil } from '@src/shared/utils/date.util' ;
1415import * as crypto from 'crypto' ;
15- import { addHours } from 'date-fns' ;
16+ import { addHours , addSeconds } from 'date-fns' ;
1617import nodemailer from 'nodemailer' ;
1718import {
1819 GenerateTokenResponseDto ,
@@ -103,14 +104,45 @@ export class InvitationsService {
103104 if ( dto . role === UserRole . SUPERADMIN ) {
104105 throw new UnauthorizedException ( '최고관리자는 초대할 수 없습니다.' ) ;
105106 }
107+ // 이미 초대한 이메일인지 확인 (초대 상태가 PENDING이고, 아직 가입되지 않은 경우)
108+ // 회원가입 이후 삭제되지 않고, 상태(status)가 'ACCEPTED'로 변경 됨.
109+ const existing = await this . prisma . invitation . findFirst ( {
110+ where : {
111+ email : dto . email ,
112+ status : 'PENDING' ,
113+ } ,
114+ } ) ;
115+
116+ if ( existing ) {
117+ throw new ConflictException ( '이미 초대된 이메일입니다. 기존 초대를 확인해주세요.' ) ;
118+ }
119+
120+ // 이미 가입된 유저인지 확인
121+ const existingUser = await this . prisma . user . findUnique ( {
122+ where : { email : dto . email } ,
123+ } ) ;
124+
125+ if ( existingUser ) {
126+ throw new ConflictException ( '이미 가입된 이메일입니다.' ) ;
127+ }
106128
107129 // 초대 토큰 생성
108130 const { token } = this . generateToken ( ) ;
109131 // 초대 토큰 만료 시간 설정(토큰 생성 24시간 후)
110132 const expiresAt = addHours ( new Date ( ) , 24 ) ;
111133
134+ // 테스트용 (5초 후 만료)
135+ // const expiresAt = addSeconds(new Date(), 5);
136+ // console.log(
137+ // '\x1b[33m[초대 생성] 만료 시간:',
138+ // expiresAt.toISOString(),
139+ // '| 로컬 시간:',
140+ // expiresAt.toLocaleString(),
141+ // '\x1b[0m',
142+ // );
143+
112144 // 초대 생성 후 이메일 발송 처리되도록 트랜잭션 처리
113- // const invitation = await this.prisma .$transaction(async tx => {
145+ // const invitation = await this.pris```ma .$transaction(async tx => {
114146 // // 테이블에 초대 정보 생성(id, email, name, token, role, expiresAt, companyId, inviterId)
115147
116148 // const createdInvitation = await tx.invitation.create({
@@ -148,6 +180,17 @@ export class InvitationsService {
148180 } ;
149181 } catch ( error ) {
150182 console . error ( `초대 생성 에러: ${ error } ` ) ;
183+
184+ // ConflictException, BadRequestException 등은 그대로 다시 던지기
185+ if (
186+ error instanceof ConflictException ||
187+ error instanceof BadRequestException ||
188+ error instanceof UnauthorizedException
189+ ) {
190+ throw error ;
191+ }
192+
193+ // 나머지는 내부 서버 에러로 처리
151194 throw new InternalServerErrorException ( `초대 생성에 실패했습니다.` ) ;
152195 }
153196 }
@@ -191,40 +234,50 @@ export class InvitationsService {
191234 }
192235
193236 //TODO: 초대 조회하기
194- public async getInvitation ( token : Token ) : Promise < Invitation | Error > {
237+ public async getInvitation ( token : Token ) : Promise < Invitation > {
195238 try {
196- const invitation = await this . verifyToken ( token ) ;
197- // 초대 정보가 없으면 에러 발생
198- if ( invitation instanceof Error ) {
199- throw new InternalServerErrorException ( `초대 조회에 실패했습니다.` ) ;
200- }
201- console . log ( `\x1b[32m초대 조회: ${ JSON . stringify ( invitation ) } \x1b[0m` ) ;
202- return invitation ;
239+ console . log ( '[Service] getInvitation 진입:' , token ) ;
240+
241+ return await this . verifyToken ( token ) ;
203242 } catch ( error ) {
204243 console . error ( `초대 조회 에러: ${ error } ` ) ;
205- throw new InternalServerErrorException ( `초대 조회에 실패했습니다.` ) ;
244+
245+ // ✅ 받은 에러를 그대로 던져서 HTTP status 유지
246+ if ( error instanceof UnauthorizedException || error instanceof NotFoundException ) {
247+ throw error ;
248+ }
249+
250+ // 그 외의 예외는 500 에러로 던지기
251+ throw new InternalServerErrorException ( '초대 조회에 실패했습니다.' ) ;
206252 }
207253 }
208254
209255 //TODO: 초대 토큰 만료 시간 검증
210256 public isTokenExpired ( invitation : Invitation ) : boolean {
257+ console . log ( '[Service] isTokenExpired:' , invitation . expiresAt , '| 현재시간:' , new Date ( ) ) ;
211258 const now = new Date ( ) ;
212259 const isExpired = now > invitation . expiresAt ;
213260 console . log ( `토큰 만료여부: ${ isExpired ? '만료' : '유효' } ` ) ;
261+ console . log ( '[Service] isExpired 판별 결과:' , isExpired ) ;
214262 return isExpired ;
215263 }
216264
217265 //TODO: 초대 토큰 검증(만료 시간 확인 포함)
218- public async verifyToken ( token : Token ) : Promise < Invitation | Error > {
266+ public async verifyToken ( token : Token ) : Promise < Invitation > {
219267 try {
268+ console . log ( '[Service] verifyToken 진입:' , token ) ;
269+
220270 const invitation = await this . getInvitationByToken ( token ) ;
221271 if ( ! invitation ) {
222272 throw new BadRequestException ( '초대 토큰이 없습니다.' ) ;
223273 }
274+
224275 const isExpired = this . isTokenExpired ( invitation ) ;
225276 if ( isExpired ) {
277+ console . log ( '[Service] 만료됨 → UnauthorizedException 던짐' ) ;
226278 throw new UnauthorizedException ( '초대 토큰이 만료되었습니다.' ) ;
227279 }
280+
228281 return invitation ;
229282 } catch ( error ) {
230283 console . error ( `토큰 검증 에러: ${ error } ` ) ;
@@ -246,6 +299,10 @@ export class InvitationsService {
246299 //TODO: 초대 메일 템플릿
247300 public mailTemplate ( name : string , token : Token ) : string {
248301 const url = process . env . FRONTEND_HOST ;
302+
303+ // const fullLink = `${url}/auth/signup?token=${token}`;
304+ // console.log('\x1b[36m[초대 링크]', fullLink, '\x1b[0m');
305+
249306 return `
250307 <h1>초대 메일</h1>
251308 <p>안녕하세요. ${ name } 님 초대합니다.</p>
0 commit comments