Skip to content

Commit 1ff6fb5

Browse files
authored
Merge pull request #343 from boostcampwm-2024/feat/user-signup
✨ feat: 사용자 회원가입 API 구현
2 parents 0d04b63 + 7cafeb4 commit 1ff6fb5

File tree

11 files changed

+239
-14
lines changed

11 files changed

+239
-14
lines changed

server/package-lock.json

Lines changed: 4 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
"rxjs": "^7.8.1",
5454
"sanitize-html": "^2.14.0",
5555
"typeorm": "^0.3.20",
56-
"uuid": "^11.0.3",
56+
"uuid": "^11.1.0",
5757
"winston": "^3.16.0",
5858
"winston-daily-rotate-file": "^5.0.0"
5959
},

server/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { StatisticModule } from './statistic/module/statistic.module';
1212
import { TestModule } from './common/test/test.module';
1313
import { UserModule } from './user/module/user.module';
1414
import { ActivityModule } from './activity/module/activity.module';
15+
import { EmailModule } from './common/email/email.module';
1516

1617
@Module({
1718
imports: [
@@ -40,6 +41,7 @@ import { ActivityModule } from './activity/module/activity.module';
4041
ActivityModule,
4142
TestModule,
4243
StatisticModule,
44+
EmailModule,
4345
],
4446
controllers: [],
4547
providers: [],

server/src/common/email/email.service.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ import * as nodemailer from 'nodemailer';
33
import { ConfigService } from '@nestjs/config';
44
import { WinstonLoggerService } from '../logger/logger.service';
55
import SMTPTransport from 'nodemailer/lib/smtp-transport';
6-
import { createMailContent } from './mail_content';
6+
import {
7+
createRssRegistrationContent,
8+
createVerificationMailContent,
9+
PRODUCT_DOMAIN,
10+
} from './mail_content';
711
import { Rss } from '../../rss/entity/rss.entity';
12+
import { User } from '../../user/entity/user.entity';
813

914
@Injectable()
1015
export class EmailService {
@@ -52,9 +57,34 @@ export class EmailService {
5257
approveFlag,
5358
description,
5459
);
60+
61+
await this.sendMail(mailOptions);
62+
}
63+
64+
async sendUserCertificationMail(user: User, uuid: string): Promise<void> {
65+
const mailOptions = this.createCertificationMail(user, uuid);
66+
5567
await this.sendMail(mailOptions);
5668
}
5769

70+
private createCertificationMail(
71+
user: User,
72+
uuid: string,
73+
): nodemailer.SendMailOptions {
74+
const redirectUrl = `${PRODUCT_DOMAIN}/api/user/cert?token=${uuid}`;
75+
76+
return {
77+
from: `Denamu<${this.emailUser}>`,
78+
to: user.email,
79+
subject: `[🎋 Denamu] 회원가입 인증 메일`,
80+
html: createVerificationMailContent(
81+
user.userName,
82+
redirectUrl,
83+
this.emailUser,
84+
),
85+
};
86+
}
87+
5888
private createRssRegistrationMail(
5989
rss: Rss,
6090
approveFlag: boolean,
@@ -65,7 +95,12 @@ export class EmailService {
6595
from: `Denamu<${this.emailUser}>`,
6696
to: `${rss.userName}<${rss.email}>`,
6797
subject: `[🎋 Denamu] RSS 등록이 ${result} 되었습니다.`,
68-
html: createMailContent(rss, approveFlag, this.emailUser, description),
98+
html: createRssRegistrationContent(
99+
rss,
100+
approveFlag,
101+
this.emailUser,
102+
description,
103+
),
69104
};
70105
}
71106
}

server/src/common/email/mail_content.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Rss } from '../../rss/entity/rss.entity';
2+
export const PRODUCT_DOMAIN = 'https://denamu.site';
23

3-
export function createMailContent(
4+
export function createRssRegistrationContent(
45
rss: Rss,
56
approveFlag: boolean,
67
serviceAddress: string,
@@ -23,7 +24,7 @@ export function createMailContent(
2324
<p><strong>블로거 이름:</strong> ${rss.userName}</p>
2425
<p><strong>RSS 주소:</strong> ${rss.rssUrl}</p>
2526
</div>
26-
${approveFlag ? acceptContent(rss) : rejectContent(rss, description)}
27+
${approveFlag ? acceptContent() : rejectContent(description)}
2728
<center>
2829
<a href="https://denamu.site" style="display: inline-block; padding: 12px 24px; background-color: #007bff; color: #ffffff; text-decoration: none; border-radius: 4px; margin: 20px 0;">${approveFlag ? '서비스 바로가기' : '다시 신청하러 가기'}</a>
2930
</center>
@@ -38,17 +39,54 @@ export function createMailContent(
3839
`;
3940
}
4041

41-
function acceptContent(rss: Rss) {
42+
function acceptContent() {
4243
return `
4344
<p>안녕하세요! 귀하의 블로그가 저희 서비스에 성공적으로 등록되었음을 알려드립니다.</p>
4445
<p>이제 귀하의 새로운 글이 업데이트될 때마다 저희 플랫폼에서 확인하실 수 있습니다.</p>
4546
`;
4647
}
4748

48-
function rejectContent(rss: Rss, description: string) {
49+
function rejectContent(description: string) {
4950
return `
5051
<p><strong>거부 사유:</strong></p>
5152
<div style="background-color: #f8f9fa; border-radius: 8px; padding: 15px 20px; margin: 15px 0; color: #666; line-height: 1.6;">${description}</div>
5253
<p>위 사유를 해결하신 후 다시 신청해 주시기 바랍니다.</p>
5354
`;
5455
}
56+
57+
export function createVerificationMailContent(
58+
userName: string,
59+
verificationLink: string,
60+
serviceAddress: string,
61+
) {
62+
return `
63+
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', '맑은 고딕', sans-serif; margin: 0; padding: 1px; background-color: #f4f4f4;">
64+
<div style="max-width: 600px; margin: 20px auto; background-color: #ffffff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
65+
<div style="text-align: center; padding: 20px 0; border-bottom: 2px solid #f0f0f0;">
66+
<img src="https://denamu.site/files/Denamu_Logo_KOR.png" alt="Denamu Logo" width="244" height="120">
67+
</div>
68+
<div style="padding: 20px 0;">
69+
<div style="color: #007bff; font-size: 24px; font-weight: bold; margin-bottom: 20px; text-align: center;">회원가입 인증을 완료해주세요</div>
70+
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin: 15px 0;">
71+
<p><strong>안녕하세요, ${userName}님!</strong></p>
72+
<p>Denamu 서비스에 가입해 주셔서 감사합니다.</p>
73+
<p>아래 버튼을 클릭하여 회원가입 인증을 완료해 주세요.</p>
74+
</div>
75+
<center>
76+
<a href="${verificationLink}" style="display: inline-block; padding: 12px 24px; background-color: #007bff; color: #ffffff; text-decoration: none; border-radius: 4px; margin: 20px 0; font-weight: bold;">이메일 인증하기</a>
77+
</center>
78+
<div style="font-size: 14px; color: #6c757d; margin-top: 20px; text-align: center;">
79+
<p>버튼이 작동하지 않는 경우, 아래 링크를 복사하여 브라우저에 붙여넣기 해주세요:</p>
80+
<p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 4px;">${verificationLink}</p>
81+
<p>이 링크는 10분 동안 유효합니다.</p>
82+
</div>
83+
</div>
84+
</div>
85+
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center; border-top: 2px solid #f0f0f0; color: #6c757d; font-size: 14px; height: 100px;">
86+
<p>본 메일은 발신전용입니다.</p>
87+
<p>문의사항이 있으시다면 ${serviceAddress}로 연락주세요.</p>
88+
</div>
89+
</div>
90+
</div>
91+
`;
92+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ApiTags } from '@nestjs/swagger';
2+
import {
3+
Body,
4+
Controller,
5+
Get,
6+
HttpCode,
7+
HttpStatus,
8+
Post,
9+
Query,
10+
} from '@nestjs/common';
11+
import { ApiResponse } from '../../common/response/common.response';
12+
import { UserService } from '../service/user.service';
13+
import { SignupDto } from '../dto/request/signup.dto';
14+
15+
@ApiTags('User')
16+
@Controller('user')
17+
export class UserController {
18+
constructor(private readonly userService: UserService) {}
19+
20+
@Get('/email-check')
21+
@HttpCode(HttpStatus.OK)
22+
async checkEmailDuplication(@Query('email') email: string) {
23+
return ApiResponse.responseWithData(
24+
'이메일 중복 조회 요청이 성공적으로 처리되었습니다.',
25+
{
26+
exists: await this.userService.checkEmailDuplication(email),
27+
},
28+
);
29+
}
30+
31+
@Post('/signup')
32+
@HttpCode(HttpStatus.CREATED)
33+
async signupUser(@Body() signupDto: SignupDto) {
34+
await this.userService.signupUser(signupDto);
35+
return ApiResponse.responseWithNoContent(
36+
'회원가입이 요청이 성공적으로 처리되었습니다.',
37+
);
38+
}
39+
40+
@Post('/certificate')
41+
@HttpCode(HttpStatus.OK)
42+
async certificateUser(@Body() uuid: string) {
43+
await this.userService.certificateUser(uuid);
44+
return ApiResponse.responseWithNoContent(
45+
'이메일 인증이 성공적으로 처리되었습니다.',
46+
);
47+
}
48+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { IsEmail, IsNotEmpty } from 'class-validator';
2+
import { User } from '../../entity/user.entity';
3+
4+
export class SignupDto {
5+
@IsEmail(
6+
{},
7+
{
8+
message: '이메일 주소 형식에 맞춰서 작성해주세요.',
9+
},
10+
)
11+
@IsNotEmpty({
12+
message: '이메일이 없습니다.',
13+
})
14+
email: string;
15+
16+
@IsNotEmpty({
17+
message: '비밀번호가 없습니다.',
18+
})
19+
password: string;
20+
21+
@IsNotEmpty({
22+
message: '사용자 이름이 없습니다.',
23+
})
24+
userName: string;
25+
26+
toEntity() {
27+
const user = new User();
28+
user.email = this.email;
29+
user.password = this.password;
30+
user.userName = this.userName;
31+
32+
return user;
33+
}
34+
}

server/src/user/entity/user.entity.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export class User extends BaseEntity {
4242

4343
@Column({
4444
name: 'introduction',
45+
nullable: true,
4546
})
4647
introduction: string;
4748

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { Module } from '@nestjs/common';
22
import { AdminRepository } from '../../admin/repository/admin.repository';
3+
import { UserRepository } from '../repository/user.repository';
4+
import { UserService } from '../service/user.service';
5+
import { UserController } from '../controller/user.controller';
36

47
@Module({
58
imports: [],
6-
controllers: [],
7-
providers: [AdminRepository],
9+
controllers: [UserController],
10+
providers: [UserService, AdminRepository, UserRepository],
811
})
912
export class UserModule {}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { UserRepository } from '../repository/user.repository';
2+
import {
3+
ConflictException,
4+
Injectable,
5+
NotFoundException,
6+
} from '@nestjs/common';
7+
import { SignupDto } from '../dto/request/signup.dto';
8+
import { v4 as uuidv4 } from 'uuid';
9+
import { RedisService } from '../../common/redis/redis.service';
10+
import { USER_CONSTANTS } from '../user.constants';
11+
import { EmailService } from '../../common/email/email.service';
12+
13+
@Injectable()
14+
export class UserService {
15+
constructor(
16+
private readonly userRepository: UserRepository,
17+
private readonly redisService: RedisService,
18+
private readonly emailService: EmailService,
19+
) {}
20+
21+
async checkEmailDuplication(email: string): Promise<boolean> {
22+
const user = await this.userRepository.findOne({
23+
where: { email },
24+
});
25+
26+
return !!user;
27+
}
28+
29+
async signupUser(signupDto: SignupDto): Promise<void> {
30+
const user = await this.userRepository.findOne({
31+
where: { email: signupDto.email },
32+
});
33+
34+
if (user) {
35+
throw new ConflictException('이미 존재하는 이메일입니다.');
36+
}
37+
38+
const newUser = signupDto.toEntity();
39+
40+
const uuid = uuidv4();
41+
await this.redisService.set(
42+
`${USER_CONSTANTS.USER_AUTH_KEY}_${newUser.email}_${uuid}`,
43+
JSON.stringify(newUser),
44+
'EX',
45+
600,
46+
);
47+
48+
this.emailService.sendUserCertificationMail(newUser, uuid);
49+
}
50+
51+
async certificateUser(uuid: string): Promise<void> {
52+
const user = await this.redisService.get(
53+
`${USER_CONSTANTS.USER_AUTH_KEY}_${uuid}`,
54+
);
55+
56+
if (!user) {
57+
throw new NotFoundException('인증에 실패했습니다.');
58+
}
59+
60+
await this.userRepository.save(JSON.parse(user));
61+
}
62+
}

0 commit comments

Comments
 (0)