Skip to content

Commit 1415c27

Browse files
authored
Merge pull request #468 from boostcampwm-2024/feat/delete-user
✨ feat: 회원 탈퇴 기능 구현
2 parents 900300f + 7f666be commit 1415c27

File tree

10 files changed

+392
-0
lines changed

10 files changed

+392
-0
lines changed

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createRssRegistrationContent,
99
createRssRemoveCertificateContent,
1010
createVerificationMailContent,
11+
createDeleteAccountContent,
1112
PRODUCT_DOMAIN,
1213
} from './mailContent';
1314
import { Rss } from '../../rss/entity/rss.entity';
@@ -162,4 +163,28 @@ export class EmailService {
162163
),
163164
};
164165
}
166+
167+
async sendDeleteAccountMail(user: User, token: string): Promise<void> {
168+
const mailOptions = this.createDeleteAccountMail(user, token);
169+
170+
await this.sendMail(mailOptions);
171+
}
172+
173+
private createDeleteAccountMail(
174+
user: User,
175+
token: string,
176+
): nodemailer.SendMailOptions {
177+
const redirectUrl = `${PRODUCT_DOMAIN}/user/delete-account?token=${token}`;
178+
179+
return {
180+
from: `Denamu<${this.emailUser}>`,
181+
to: user.email,
182+
subject: `[🎋 Denamu] 회원탈퇴 확인 메일`,
183+
html: createDeleteAccountContent(
184+
user.userName,
185+
redirectUrl,
186+
this.emailUser,
187+
),
188+
};
189+
}
165190
}

server/src/common/email/mailContent.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,42 @@ export function createPasswordResetMailContent(
165165
</div>
166166
`;
167167
}
168+
169+
export function createDeleteAccountContent(
170+
userName: string,
171+
verificationLink: string,
172+
serviceAddress: string,
173+
) {
174+
return `
175+
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', '맑은 고딕', sans-serif; margin: 0; padding: 1px; background-color: #f4f4f4;">
176+
<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);">
177+
<div style="text-align: center; padding: 20px 0; border-bottom: 2px solid #f0f0f0;">
178+
<img src="https://denamu.site/files/Denamu_Logo_KOR.png" alt="Denamu Logo" width="244" height="120">
179+
</div>
180+
<div style="padding: 20px 0;">
181+
<div style="color: #dc3545; font-size: 24px; font-weight: bold; margin-bottom: 20px; text-align: center;">회원탈퇴 요청을 확인해주세요</div>
182+
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin: 15px 0;">
183+
<p><strong>안녕하세요, ${userName}님!</strong></p>
184+
<p>Denamu 서비스 회원탈퇴 요청이 접수되었습니다.</p>
185+
<p>정말 탈퇴하시려면 아래 버튼을 클릭하여 회원탈퇴를 완료해 주세요.</p>
186+
<p style="color: #dc3545; font-weight: bold; margin-top: 15px;">⚠️ 탈퇴 시 모든 개인정보와 활동 내역을 복구할 수 없습니다.</p>
187+
</div>
188+
<center>
189+
<a href="${verificationLink}" style="display: inline-block; padding: 12px 24px; background-color: #dc3545; color: #ffffff; text-decoration: none; border-radius: 4px; margin: 20px 0; font-weight: bold;">회원탈퇴 확인</a>
190+
</center>
191+
<div style="font-size: 14px; color: #6c757d; margin-top: 20px; text-align: center;">
192+
<p>버튼이 작동하지 않는 경우, 아래 링크를 복사하여 브라우저에 붙여넣기 해주세요:</p>
193+
<p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 4px;">${verificationLink}</p>
194+
<p>이 링크는 10분 동안 유효합니다.</p>
195+
<p style="margin-top: 15px;">본인이 요청하지 않은 경우, 이 메일을 무시하시기 바랍니다.</p>
196+
</div>
197+
</div>
198+
</div>
199+
<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;">
200+
<p>본 메일은 발신전용입니다.</p>
201+
<p>문의사항이 있으시다면 ${serviceAddress}로 연락주세요.</p>
202+
</div>
203+
</div>
204+
</div>
205+
`;
206+
}

server/src/common/redis/redis.constant.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ export const REDIS_KEYS = {
1111
RSS_REMOVE_KEY: 'rss:remove',
1212
CHAT_HISTORY_KEY: 'chat:history',
1313
FULL_FEED_CRAWL_QUEUE: `feed:full-crawl:queue`,
14+
USER_DELETE_ACCOUNT_KEY: 'user:delete-account',
1415
USER_RESET_PASSWORD_KEY: 'user:password_reset',
1516
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { applyDecorators } from '@nestjs/common';
2+
import {
3+
ApiBadRequestResponse,
4+
ApiNotFoundResponse,
5+
ApiOkResponse,
6+
ApiOperation,
7+
} from '@nestjs/swagger';
8+
9+
export function ApiConfirmDeleteAccount() {
10+
return applyDecorators(
11+
ApiOperation({
12+
summary: '회원탈퇴 확정 API',
13+
}),
14+
ApiOkResponse({
15+
description: 'Ok',
16+
schema: {
17+
properties: {
18+
message: {
19+
type: 'string',
20+
},
21+
},
22+
},
23+
example: {
24+
message: '회원탈퇴가 완료되었습니다.',
25+
},
26+
}),
27+
ApiBadRequestResponse({
28+
description: 'Bad Request',
29+
example: {
30+
message: '오류 메세지',
31+
},
32+
}),
33+
ApiNotFoundResponse({
34+
description: 'Not Found',
35+
example: {
36+
message: '유효하지 않거나 만료된 토큰입니다.',
37+
},
38+
}),
39+
);
40+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { applyDecorators } from '@nestjs/common';
2+
import {
3+
ApiBadRequestResponse,
4+
ApiNotFoundResponse,
5+
ApiOkResponse,
6+
ApiOperation,
7+
ApiUnauthorizedResponse,
8+
} from '@nestjs/swagger';
9+
10+
export function ApiRequestDeleteAccount() {
11+
return applyDecorators(
12+
ApiOperation({
13+
summary: '회원탈퇴 신청 API',
14+
description:
15+
'인증된 사용자의 회원탈퇴를 신청합니다. 이메일로 확인 링크가 발송됩니다.',
16+
}),
17+
ApiOkResponse({
18+
description: 'Ok',
19+
schema: {
20+
properties: {
21+
message: {
22+
type: 'string',
23+
},
24+
},
25+
},
26+
example: {
27+
message: '회원탈퇴 신청이 성공적으로 처리되었습니다.',
28+
},
29+
}),
30+
ApiBadRequestResponse({
31+
description: 'Bad Request',
32+
example: {
33+
message: '오류 메세지',
34+
},
35+
}),
36+
ApiUnauthorizedResponse({
37+
description: 'Unauthorized',
38+
example: {
39+
message: '인증이 필요합니다.',
40+
},
41+
}),
42+
ApiNotFoundResponse({
43+
description: 'Not Found',
44+
example: {
45+
message: '존재하지 않는 사용자입니다.',
46+
},
47+
}),
48+
);
49+
}

server/src/user/controller/user.controller.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import { ApiRefreshToken } from '../api-docs/refreshToken.api-docs';
2828
import { ApiLogoutUser } from '../api-docs/logoutUser.api-docs';
2929
import { UpdateUserRequestDto } from '../dto/request/updateUser.dto';
3030
import { ApiUpdateUser } from '../api-docs/updateUser.api-docs';
31+
import { ConfirmDeleteAccountDto } from '../dto/request/confirmDeleteAccount.dto';
32+
import { ApiRequestDeleteAccount } from '../api-docs/requestDeleteAccount.api-docs';
33+
import { ApiConfirmDeleteAccount } from '../api-docs/confirmDeleteAccount.api-docs';
3134
import { ResetPasswordRequestDto } from '../dto/request/resetPassword.dto';
3235
import { ForgotPasswordRequestDto } from '../dto/request/forgotPassword.dto';
3336
import { ApiForgotPassword } from '../api-docs/forgotPassword.api-docs';
@@ -118,6 +121,25 @@ export class UserController {
118121
);
119122
}
120123

124+
@ApiRequestDeleteAccount()
125+
@Post('/delete-account/request')
126+
@HttpCode(HttpStatus.OK)
127+
@UseGuards(JwtGuard)
128+
async requestDeleteAccount(@Req() req) {
129+
await this.userService.requestDeleteAccount(req.user.id);
130+
return ApiResponse.responseWithNoContent(
131+
'회원탈퇴 신청이 성공적으로 처리되었습니다. 이메일을 확인해주세요.',
132+
);
133+
}
134+
135+
@ApiConfirmDeleteAccount()
136+
@Post('/delete-account/confirm')
137+
@HttpCode(HttpStatus.OK)
138+
async confirmDeleteAccount(@Body() confirmDto: ConfirmDeleteAccountDto) {
139+
await this.userService.confirmDeleteAccount(confirmDto.token);
140+
return ApiResponse.responseWithNoContent('회원탈퇴가 완료되었습니다.');
141+
}
142+
121143
@ApiForgotPassword()
122144
@Post('/password-reset')
123145
@HttpCode(HttpStatus.OK)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString } from 'class-validator';
3+
4+
export class ConfirmDeleteAccountDto {
5+
@ApiProperty({
6+
example: 'd2ba0d98-95ce-4905-87fc-384965ffe7c9',
7+
description: '회원탈퇴 인증 토큰을 입력해주세요.',
8+
})
9+
@IsNotEmpty({
10+
message: '인증 토큰을 입력해주세요.',
11+
})
12+
@IsString({
13+
message: '문자열로 입력해주세요.',
14+
})
15+
token: string;
16+
17+
constructor(partial: Partial<ConfirmDeleteAccountDto>) {
18+
Object.assign(this, partial);
19+
}
20+
}

server/src/user/service/user.service.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,42 @@ export class UserService {
234234
);
235235
await this.userRepository.save(user);
236236
}
237+
238+
async requestDeleteAccount(userId: number): Promise<void> {
239+
const user = await this.getUser(userId);
240+
241+
const token = uuidv4();
242+
await this.redisService.set(
243+
`${REDIS_KEYS.USER_DELETE_ACCOUNT_KEY}:${token}`,
244+
user.id.toString(),
245+
'EX',
246+
600,
247+
);
248+
249+
this.emailService.sendDeleteAccountMail(user, token);
250+
}
251+
252+
async confirmDeleteAccount(token: string): Promise<void> {
253+
const userIdString = await this.redisService.get(
254+
`${REDIS_KEYS.USER_DELETE_ACCOUNT_KEY}:${token}`,
255+
);
256+
257+
if (!userIdString) {
258+
throw new NotFoundException('유효하지 않거나 만료된 토큰입니다.');
259+
}
260+
261+
const userId = parseInt(userIdString, 10);
262+
263+
const user = await this.getUser(userId);
264+
265+
if (user.profileImage) {
266+
await this.fileService.deleteByPath(user.profileImage);
267+
}
268+
269+
await this.userRepository.remove(user);
270+
271+
await this.redisService.del(
272+
`${REDIS_KEYS.USER_DELETE_ACCOUNT_KEY}:${token}`,
273+
);
274+
}
237275
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { validate } from 'class-validator';
2+
import { ConfirmDeleteAccountDto } from '../../../src/user/dto/request/confirmDeleteAccount.dto';
3+
4+
describe('ConfirmDeleteAccountDto Test', () => {
5+
let dto: ConfirmDeleteAccountDto;
6+
7+
beforeEach(() => {
8+
dto = new ConfirmDeleteAccountDto({
9+
token: 'd2ba0d98-95ce-4905-87fc-384965ffe7c9',
10+
});
11+
});
12+
13+
it('토큰이 유효할 경우 유효성 검사에 성공한다.', async () => {
14+
// when
15+
const errors = await validate(dto);
16+
17+
// then
18+
expect(errors).toHaveLength(0);
19+
});
20+
21+
describe('token', () => {
22+
it('토큰이 빈 문자열일 경우 유효성 검사에 실패한다.', async () => {
23+
// given
24+
dto.token = '';
25+
26+
// when
27+
const errors = await validate(dto);
28+
29+
// then
30+
expect(errors).toHaveLength(1);
31+
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
32+
});
33+
34+
it('토큰이 문자열이 아니고 정수일 경우 유효성 검사에 실패한다.', async () => {
35+
// given
36+
dto.token = 1 as any;
37+
38+
// when
39+
const errors = await validate(dto);
40+
41+
// then
42+
expect(errors).toHaveLength(1);
43+
expect(errors[0].constraints).toHaveProperty('isString');
44+
});
45+
});
46+
});

0 commit comments

Comments
 (0)