From a7a7ff97c0df3b275e361fac6f9ca98dd51c75ab Mon Sep 17 00:00:00 2001 From: ooheunda Date: Sat, 4 Oct 2025 01:26:36 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feature:=20=EB=89=B4=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=8B=A0=20=EA=B1=B0=EB=B6=80=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/user.controller.test.ts | 40 +++++++++++++++++++ src/controllers/user.controller.ts | 16 ++++++++ src/repositories/user.repository.ts | 24 ++++++++++- src/routes/user.router.ts | 21 ++++++++++ src/services/user.service.ts | 15 +++++++ 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/controllers/__test__/user.controller.test.ts b/src/controllers/__test__/user.controller.test.ts index 693d7cb..51ce08b 100644 --- a/src/controllers/__test__/user.controller.test.ts +++ b/src/controllers/__test__/user.controller.test.ts @@ -405,4 +405,44 @@ describe('UserController', () => { expect(mockResponse.redirect).not.toHaveBeenCalled(); }); }); + + describe('unsubscribeNewsletter', () => { + beforeEach(() => { + mockRequest.query = {}; + mockResponse.redirect = jest.fn().mockReturnThis(); + }); + + it('이메일이 없으면 BadRequestError를 던져야 한다', async () => { + mockRequest.query = {}; + + await userController.unsubscribeNewsletter( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: '이메일이 필요합니다.', + }) + ); + expect(mockResponse.redirect).not.toHaveBeenCalled(); + }); + + it('구독 해제 완료시 메인 페이지로 리다이렉트해야 한다', async () => { + const email = 'test@example.com'; + mockRequest.query = { email }; + mockUserService.unsubscribeNewsletter.mockResolvedValue(undefined); + + await userController.unsubscribeNewsletter( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockUserService.unsubscribeNewsletter).toHaveBeenCalledWith(email); + expect(mockResponse.redirect).toHaveBeenCalledWith('/main'); + expect(nextFunction).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 8ce0ab3..3ab8f61 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -5,6 +5,7 @@ import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type'; import { UserService } from '@/services/user.service'; import { QRTokenExpiredError, QRTokenInvalidError } from '@/exception/token.exception'; import { fetchVelogApi } from '@/modules/velog/velog.api'; +import { BadRequestError } from '@/exception'; type Token10 = string & { __lengthBrand: 10 }; @@ -169,4 +170,19 @@ export class UserController { next(error); } }; + + unsubscribeNewsletter: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { + try { + const email = req.query.email as string; + if (!email) { + throw new BadRequestError('이메일이 필요합니다.'); + } + + await this.userService.unsubscribeNewsletter(email); + res.redirect('/main'); + } catch (error) { + logger.error(`뉴스레터 구독 해제 실패: [email: ${req.query.email}]`, error); + next(error); + } + }; } diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index ebeaee2..23ab47d 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -7,7 +7,7 @@ import { DBError } from '@/exception'; export class UserRepository { constructor(private readonly pool: Pool) {} - async findByUserId(id: number): Promise { + async findByUserId(id: number): Promise { try { const user = await this.pool.query('SELECT * FROM "users_user" WHERE id = $1', [id]); return user.rows[0] || null; @@ -17,7 +17,7 @@ export class UserRepository { } } - async findByUserVelogUUID(uuid: string): Promise { + async findByUserVelogUUID(uuid: string): Promise { try { const user = await this.pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [uuid]); return user.rows[0] || null; @@ -27,6 +27,16 @@ export class UserRepository { } } + async findByUserEmail(email: string): Promise { + try { + const user = await this.pool.query('SELECT * FROM "users_user" WHERE email = $1', [email]); + return user.rows[0] || null; + } catch (error) { + logger.error('Email로 유저를 조회 중 오류 : ', error); + throw new DBError('유저 조회 중 문제가 발생했습니다.'); + } + } + async findSampleUser(): Promise { try { const query = ` @@ -152,4 +162,14 @@ export class UserRepository { throw new DBError('QR 코드 사용 처리 중 문제가 발생했습니다.'); } } + + async unsubscribeNewsletter(id: number): Promise { + try { + const query = `UPDATE "users_user" SET newsletter_subscribed = false WHERE id = $1`; + await this.pool.query(query, [id]); + } catch (error) { + logger.error('User Repo unsubscribeNewsletter Error : ', error); + throw new DBError('뉴스레터 구독 해제 중 문제가 발생했습니다.'); + } + } } diff --git a/src/routes/user.router.ts b/src/routes/user.router.ts index 5559fea..6cdc879 100644 --- a/src/routes/user.router.ts +++ b/src/routes/user.router.ts @@ -151,4 +151,25 @@ router.post('/qr-login', authMiddleware.verify, userController.createToken); */ router.get('/qr-login', userController.getToken); +/** + * @swagger + * /newsletter-unsubscribe: + * get: + * tags: + * - User + * summary: 뉴스레터 구독 해제 (메일에서 바로 접근) + * parameters: + * - in: query + * name: email + * required: true + * schema: + * type: string + * description: 구독을 해제할 이메일 + * responses: + * 302: + * description: 뉴스레터 구독 해제 성공 후 메인 페이지로 리디렉션 + */ +router.get('/newsletter-unsubscribe', userController.unsubscribeNewsletter); + + export default router; diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 66acf84..fdff6f5 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -170,4 +170,19 @@ export class UserService { ); return { decryptedAccessToken, decryptedRefreshToken }; } + + async unsubscribeNewsletter(email: string) { + try { + const user = await this.userRepo.findByUserEmail(email); + if (!user) { + logger.error(`유저를 찾을 수 없습니다. [email: ${email}]`); + return; // 일반적인 실패시 리디렉션 + } + + await this.userRepo.unsubscribeNewsletter(user.id); + } catch (error) { + logger.error('User Service unsubscribeNewsletter Error : ', error); + throw error; + } + } } From 7613ef92ec579d02c01e322db17eb166aad23a78 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Sat, 4 Oct 2025 01:30:03 +0900 Subject: [PATCH 2/5] linting --- src/controllers/user.controller.ts | 5 ++--- src/routes/user.router.ts | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 3ab8f61..98f95be 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -3,9 +3,8 @@ import logger from '@/configs/logger.config'; import { EmptyResponseDto, LoginResponseDto } from '@/types'; import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type'; import { UserService } from '@/services/user.service'; -import { QRTokenExpiredError, QRTokenInvalidError } from '@/exception/token.exception'; import { fetchVelogApi } from '@/modules/velog/velog.api'; -import { BadRequestError } from '@/exception'; +import { QRTokenExpiredError, QRTokenInvalidError, BadRequestError } from '@/exception'; type Token10 = string & { __lengthBrand: 10 }; @@ -172,7 +171,7 @@ export class UserController { }; unsubscribeNewsletter: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { - try { + try { const email = req.query.email as string; if (!email) { throw new BadRequestError('이메일이 필요합니다.'); diff --git a/src/routes/user.router.ts b/src/routes/user.router.ts index 6cdc879..a433fdb 100644 --- a/src/routes/user.router.ts +++ b/src/routes/user.router.ts @@ -155,7 +155,7 @@ router.get('/qr-login', userController.getToken); * @swagger * /newsletter-unsubscribe: * get: - * tags: + * tags: * - User * summary: 뉴스레터 구독 해제 (메일에서 바로 접근) * parameters: @@ -171,5 +171,4 @@ router.get('/qr-login', userController.getToken); */ router.get('/newsletter-unsubscribe', userController.unsubscribeNewsletter); - export default router; From e6545292379781bbb13756f5b20fa5f56172db18 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Sat, 4 Oct 2025 01:36:27 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feature:=20=EC=88=98=EC=A0=95=EB=90=9C=20Us?= =?UTF-8?q?er=20=EB=AA=A8=EB=8D=B8=20=EC=A0=81=EC=9A=A9=20(newsletter=5Fsu?= =?UTF-8?q?bscribed=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/models/User.type.ts | 2 ++ src/utils/fixtures.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/types/models/User.type.ts b/src/types/models/User.type.ts index 7a3640c..ec69efc 100644 --- a/src/types/models/User.type.ts +++ b/src/types/models/User.type.ts @@ -11,6 +11,8 @@ export interface User { // 250607 추가 username: string | null; thumbnail: string | null; + // 251004 추가 + newsletter_subscribed: boolean; } diff --git a/src/utils/fixtures.ts b/src/utils/fixtures.ts index 6a03cfb..ec66f4a 100644 --- a/src/utils/fixtures.ts +++ b/src/utils/fixtures.ts @@ -37,6 +37,7 @@ export const mockUser: User = { is_active: true, created_at: new Date('2024-01-01T00:00:00Z'), updated_at: new Date('2024-01-01T00:00:00Z'), + newsletter_subscribed: true, }; /** From 41e3ac0279724ea5834da1770168c47578dad764 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Sat, 4 Oct 2025 10:38:08 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feature:=20=EB=89=B4=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=ED=84=B0=20=EA=B5=AC=EB=8F=85=20=ED=95=B4=EC=A0=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=8A=AC=EB=9E=99=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/user.service.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/services/user.service.ts b/src/services/user.service.ts index fdff6f5..bdeb5c6 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -174,12 +174,24 @@ export class UserService { async unsubscribeNewsletter(email: string) { try { const user = await this.userRepo.findByUserEmail(email); + if (!user) { logger.error(`유저를 찾을 수 없습니다. [email: ${email}]`); - return; // 일반적인 실패시 리디렉션 + return; // 일반적인 실패시 조용히 리디렉션 + } + + if (!user.newsletter_subscribed) { + logger.error(`이미 구독이 해제된 이메일입니다. [email: ${email}]`); + return; // 일반적인 실패시 조용히 리디렉션 } await this.userRepo.unsubscribeNewsletter(user.id); + + try { + await sendSlackMessage(`뉴스레터 구독 취소: ${email} (id: ${user.id})`); + } catch (error) { + logger.error('Slack 알림 전송 실패:', error); + } } catch (error) { logger.error('User Service unsubscribeNewsletter Error : ', error); throw error; From de08d82259d108c0c0b0b03ca9e58c1f4435fe98 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Mon, 6 Oct 2025 01:05:50 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/user.controller.test.ts | 23 ++++++++++++++----- src/controllers/user.controller.ts | 11 +++++---- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/controllers/__test__/user.controller.test.ts b/src/controllers/__test__/user.controller.test.ts index 51ce08b..6b254a7 100644 --- a/src/controllers/__test__/user.controller.test.ts +++ b/src/controllers/__test__/user.controller.test.ts @@ -412,7 +412,7 @@ describe('UserController', () => { mockResponse.redirect = jest.fn().mockReturnThis(); }); - it('이메일이 없으면 BadRequestError를 던져야 한다', async () => { + it('이메일이 없으면 메인 페이지로 리다이렉트해야 한다', async () => { mockRequest.query = {}; await userController.unsubscribeNewsletter( @@ -421,12 +421,23 @@ describe('UserController', () => { nextFunction ); - expect(nextFunction).toHaveBeenCalledWith( - expect.objectContaining({ - message: '이메일이 필요합니다.', - }) + expect(mockUserService.unsubscribeNewsletter).not.toHaveBeenCalled(); + expect(mockResponse.redirect).toHaveBeenCalledWith('/main'); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('잘못된 이메일 형식이면 메인 페이지로 리다이렉트해야 한다', async () => { + mockRequest.query = { email: 'invalid-email' }; + + await userController.unsubscribeNewsletter( + mockRequest as Request, + mockResponse as Response, + nextFunction ); - expect(mockResponse.redirect).not.toHaveBeenCalled(); + + expect(mockUserService.unsubscribeNewsletter).not.toHaveBeenCalled(); + expect(mockResponse.redirect).toHaveBeenCalledWith('/main'); + expect(nextFunction).not.toHaveBeenCalled(); }); it('구독 해제 완료시 메인 페이지로 리다이렉트해야 한다', async () => { diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 98f95be..7fdd5cd 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -4,7 +4,7 @@ import { EmptyResponseDto, LoginResponseDto } from '@/types'; import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type'; import { UserService } from '@/services/user.service'; import { fetchVelogApi } from '@/modules/velog/velog.api'; -import { QRTokenExpiredError, QRTokenInvalidError, BadRequestError } from '@/exception'; +import { QRTokenExpiredError, QRTokenInvalidError } from '@/exception'; type Token10 = string & { __lengthBrand: 10 }; @@ -173,11 +173,14 @@ export class UserController { unsubscribeNewsletter: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { try { const email = req.query.email as string; - if (!email) { - throw new BadRequestError('이메일이 필요합니다.'); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!email || !emailRegex.test(email)) { + logger.error(`올바르지 않은 이메일: [email: ${req.query.email}]`); + } else { + await this.userService.unsubscribeNewsletter(email); } - await this.userService.unsubscribeNewsletter(email); res.redirect('/main'); } catch (error) { logger.error(`뉴스레터 구독 해제 실패: [email: ${req.query.email}]`, error);