Skip to content

Commit 0447065

Browse files
authored
Merge pull request #322 from snack-25/feature/management-memberOnlyList
[back] Feature/management member only list
2 parents ddd317b + d82c4ad commit 0447065

5 files changed

Lines changed: 184 additions & 25 deletions

File tree

src/auth/auth.service.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
BadRequestException,
33
ConflictException,
4+
HttpException,
45
Injectable,
56
InternalServerErrorException,
67
Logger,
@@ -45,11 +46,18 @@ export class AuthService {
4546
// 사용자 ID로 사용자 정보 조회
4647
public async getUserById(
4748
userId: string,
48-
): Promise<Pick<User, 'id' | 'name' | 'email' | 'role' | 'refreshToken'> | null> {
49+
): Promise<Pick<User, 'id' | 'name' | 'email' | 'role' | 'refreshToken' | 'companyId'> | null> {
4950
try {
5051
const user = await this.prisma.user.findUnique({
5152
where: { id: userId },
52-
select: { id: true, name: true, email: true, role: true, refreshToken: true },
53+
select: {
54+
id: true,
55+
name: true,
56+
email: true,
57+
role: true,
58+
refreshToken: true,
59+
companyId: true, // ✅ 필수!
60+
},
5361
});
5462
return user;
5563
} catch (error) {
@@ -70,11 +78,34 @@ export class AuthService {
7078

7179
if (!invitation) return null;
7280

81+
// ✅ 초대 만료 체크 추가
82+
if (invitation.expiresAt < new Date()) {
83+
console.warn('[초대 만료]', {
84+
email: invitation.email,
85+
token: invitation.token,
86+
expiresAt: invitation.expiresAt.toISOString(),
87+
now: new Date().toISOString(),
88+
});
89+
throw new UnauthorizedException('초대 토큰이 만료되었습니다.');
90+
}
91+
92+
if (invitation.status === 'ACCEPTED') {
93+
console.warn('[초대 재사용 시도]', {
94+
email: invitation.email,
95+
token: invitation.token,
96+
status: invitation.status,
97+
});
98+
throw new UnauthorizedException('이미 사용된 초대 링크입니다.');
99+
}
100+
73101
return {
74102
...invitation,
75103
companyName: invitation.company?.name || '', // ✅ 회사명 함께 전달
76104
};
77105
} catch (err) {
106+
if (err instanceof HttpException) {
107+
throw err;
108+
}
78109
throw new BadRequestException('초대 코드가 유효하지 않습니다: ' + err);
79110
}
80111
}
@@ -89,6 +120,8 @@ export class AuthService {
89120
email: true,
90121
name: true,
91122
role: true,
123+
status: true,
124+
expiresAt: true,
92125
company: {
93126
select: {
94127
id: true,
@@ -98,11 +131,25 @@ export class AuthService {
98131
},
99132
});
100133

101-
// 2. 초대 코드가 유효하지 않으면 예외 처리
134+
// 초대 코드가 유효하지 않으면 예외 처리
102135
if (!invitation) {
103136
throw new BadRequestException('초대 코드가 유효하지 않습니다');
104137
}
105138

139+
if (invitation.status === 'ACCEPTED') {
140+
throw new UnauthorizedException('이미 사용된 초대 링크입니다.');
141+
}
142+
143+
// 초대 토큰 만료 여부 확인
144+
const now = new Date();
145+
if (invitation.expiresAt < now) {
146+
throw new BadRequestException('초대 토큰이 만료되었습니다.');
147+
}
148+
149+
// if (dto.email !== invitation.email) {
150+
// throw new BadRequestException('초대된 이메일과 일치하지 않습니다.');
151+
// }
152+
106153
const existingUser = await this.prisma.user.findUnique({
107154
where: { email: invitation.email },
108155
});
@@ -431,19 +478,39 @@ export class AuthService {
431478

432479
// accessToken 생성 (payload에 userId 포함)
433480
private async generateAccessToken(userId: string): Promise<string> {
481+
// Step 1: 유저 정보 조회 (companyId 포함)
482+
const user = await this.prisma.user.findUnique({
483+
where: { id: userId },
484+
select: {
485+
id: true,
486+
email: true,
487+
role: true,
488+
companyId: true, // ✅ 핵심: companyId 추가
489+
},
490+
});
491+
492+
if (!user) {
493+
throw new NotFoundException('사용자를 찾을 수 없습니다');
494+
}
495+
496+
// Step 2: 토큰 payload 구성
434497
const payload: TokenRequestDto = {
435-
sub: userId,
436-
type: 'access', // 토큰 타입은 액세스 토큰
498+
sub: user.id, // JWT 표준 subject (userId)
499+
email: user.email,
500+
role: user.role,
501+
companyId: user.companyId, // ✅ 핵심: payload에 companyId 포함
502+
type: 'access',
437503
};
438504

439505
// 만료 시간을 명시적으로 설정 (현재 시간 + JWT_EXPIRES_IN)
440506
const expiresIn = this.configService.getOrThrow<string>('JWT_EXPIRES_IN');
441507

442508
this.logger.debug(`토큰 생성: 만료 시간 ${expiresIn}`);
443509

510+
// Step 3: accessToken 발급
444511
return this.jwtService.signAsync(payload, {
445512
secret: this.configService.getOrThrow<string>('JWT_SECRET'),
446-
expiresIn: expiresIn,
513+
expiresIn: this.configService.getOrThrow<string>('JWT_EXPIRES_IN'),
447514
});
448515
}
449516

src/auth/dto/auth.dto.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ export class SigninResponseDto {
4848

4949
// 토큰 생성 요청 DTO
5050
export class TokenRequestDto {
51-
public sub: string;
52-
public type: 'access' | 'refresh';
51+
public sub: string; // 사용자 ID (JWT 표준 필드)
52+
public email?: string; // 이메일 (선택)
53+
public role?: UserRole; // 역할 (예: USER, ADMIN)
54+
public companyId?: string; // ✅ 회사 ID
55+
public type: 'access' | 'refresh'; // access 또는 refresh 토큰 구분
5356
}
5457

5558
// 토큰 생성 응답 DTO

src/auth/jwt.strategy.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
55

66
// JWT payload의 타입을 정의
77
interface JwtPayload {
8-
sub: string; // 사용자 ID (주로 'sub'에 저장)
9-
email: string; // 사용자 이메일
10-
iat: number; // 토큰 발행 시간 (Unix timestamp)
8+
sub: string;
9+
email: string;
10+
role: string;
11+
companyId: string;
12+
type: 'access' | 'refresh';
13+
iat: number;
1114
}
1215

1316
// `cookies` 속성을 명시적으로 정의
@@ -32,8 +35,18 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-access') {
3235
}
3336

3437
// validate 메서드에서 반환 타입 명시
35-
public validate(payload: JwtPayload): { email: string; expires: number } {
38+
public validate(payload: JwtPayload): { email: string; expires: number; companyId: string } {
3639
// payload에서 email과 iat(발행 시간)을 반환
37-
return { email: payload.email, expires: payload.iat };
40+
const result = {
41+
email: payload.email,
42+
expires: payload.iat,
43+
companyId: payload.companyId,
44+
};
45+
46+
// ✅ 로그 찍기
47+
// console.log('🔑 [JwtStrategy] 토큰 payload 정보:', payload);
48+
// console.log('📦 [JwtStrategy] validate() 리턴값:', result);
49+
50+
return result;
3851
}
3952
}

src/invitations/invitations.service.ts

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable no-console */
22
import {
33
BadRequestException,
4+
ConflictException,
45
HttpStatus,
56
Injectable,
67
InternalServerErrorException,
@@ -12,7 +13,7 @@ import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
1213
import { PrismaService } from '@src/shared/prisma/prisma.service';
1314
import { DateUtil } from '@src/shared/utils/date.util';
1415
import * as crypto from 'crypto';
15-
import { addHours } from 'date-fns';
16+
import { addHours, addSeconds } from 'date-fns';
1617
import nodemailer from 'nodemailer';
1718
import {
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>

src/users/users.service.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export class UsersService {
4343
}),
4444
};
4545

46+
//console.log('🔥 유저 리스트 조회 조건:', where);
47+
4648
const [totalCount, users] = await this.prisma.$transaction([
4749
this.prisma.user.count({ where }),
4850
this.prisma.user.findMany({
@@ -60,6 +62,12 @@ export class UsersService {
6062
}),
6163
]);
6264

65+
// console.log('✅ 필터링된 사용자 수:', totalCount);
66+
// console.log(
67+
// '👤 사용자 목록:',
68+
// users.map(u => u.email),
69+
// );
70+
6371
return {
6472
totalCount,
6573
users,
@@ -105,6 +113,17 @@ export class UsersService {
105113
await this.prisma.user.delete({
106114
where: { id: userId },
107115
});
116+
117+
// 관련 초대 상태 변경 (조건: 동일한 이메일 + PENDING 상태)
118+
await this.prisma.invitation.updateMany({
119+
where: {
120+
email: user.email,
121+
status: 'PENDING', // 아직 가입 안한 상태. 사용 안 된 초대만 만료 처리 한다.
122+
},
123+
data: {
124+
status: 'EXPIRED',
125+
},
126+
});
108127
} catch (error) {
109128
// ✅ Prisma 클라이언트 오류 또는 기타 에러 처리
110129
console.error('❌ 사용자 삭제 중 오류 발생:', error);

0 commit comments

Comments
 (0)