diff --git a/src/controllers/__test__/user.controller.test.ts b/src/controllers/__test__/user.controller.test.ts index 693d7cb..6b254a7 100644 --- a/src/controllers/__test__/user.controller.test.ts +++ b/src/controllers/__test__/user.controller.test.ts @@ -405,4 +405,55 @@ describe('UserController', () => { expect(mockResponse.redirect).not.toHaveBeenCalled(); }); }); + + describe('unsubscribeNewsletter', () => { + beforeEach(() => { + mockRequest.query = {}; + mockResponse.redirect = jest.fn().mockReturnThis(); + }); + + it('이메일이 없으면 메인 페이지로 리다이렉트해야 한다', async () => { + mockRequest.query = {}; + + await userController.unsubscribeNewsletter( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + 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(mockUserService.unsubscribeNewsletter).not.toHaveBeenCalled(); + expect(mockResponse.redirect).toHaveBeenCalledWith('/main'); + expect(nextFunction).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..7fdd5cd 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -3,8 +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 { QRTokenExpiredError, QRTokenInvalidError } from '@/exception'; type Token10 = string & { __lengthBrand: 10 }; @@ -169,4 +169,22 @@ export class UserController { next(error); } }; + + unsubscribeNewsletter: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { + try { + const email = req.query.email as string; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!email || !emailRegex.test(email)) { + logger.error(`올바르지 않은 이메일: [email: ${req.query.email}]`); + } else { + 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..a433fdb 100644 --- a/src/routes/user.router.ts +++ b/src/routes/user.router.ts @@ -151,4 +151,24 @@ 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..bdeb5c6 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -170,4 +170,31 @@ 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; // 일반적인 실패시 조용히 리디렉션 + } + + 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; + } + } } 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, }; /**