Skip to content

Commit 4b3a55f

Browse files
authored
[25.10.04 / TASK-250] Feature - 주간 뉴스레터 수신거부 API 추가 (#46)
* feature: 뉴스레터 수신 거부 API 추가 * linting * feature: 수정된 User 모델 적용 (newsletter_subscribed 컬럼 추가) * feature: 뉴스레터 구독 해제시 슬랙 알림 * refactor: 이메일 검증 강화
1 parent 166319a commit 4b3a55f

File tree

7 files changed

+142
-3
lines changed

7 files changed

+142
-3
lines changed

src/controllers/__test__/user.controller.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,4 +405,55 @@ describe('UserController', () => {
405405
expect(mockResponse.redirect).not.toHaveBeenCalled();
406406
});
407407
});
408+
409+
describe('unsubscribeNewsletter', () => {
410+
beforeEach(() => {
411+
mockRequest.query = {};
412+
mockResponse.redirect = jest.fn().mockReturnThis();
413+
});
414+
415+
it('이메일이 없으면 메인 페이지로 리다이렉트해야 한다', async () => {
416+
mockRequest.query = {};
417+
418+
await userController.unsubscribeNewsletter(
419+
mockRequest as Request,
420+
mockResponse as Response,
421+
nextFunction
422+
);
423+
424+
expect(mockUserService.unsubscribeNewsletter).not.toHaveBeenCalled();
425+
expect(mockResponse.redirect).toHaveBeenCalledWith('/main');
426+
expect(nextFunction).not.toHaveBeenCalled();
427+
});
428+
429+
it('잘못된 이메일 형식이면 메인 페이지로 리다이렉트해야 한다', async () => {
430+
mockRequest.query = { email: 'invalid-email' };
431+
432+
await userController.unsubscribeNewsletter(
433+
mockRequest as Request,
434+
mockResponse as Response,
435+
nextFunction
436+
);
437+
438+
expect(mockUserService.unsubscribeNewsletter).not.toHaveBeenCalled();
439+
expect(mockResponse.redirect).toHaveBeenCalledWith('/main');
440+
expect(nextFunction).not.toHaveBeenCalled();
441+
});
442+
443+
it('구독 해제 완료시 메인 페이지로 리다이렉트해야 한다', async () => {
444+
const email = '[email protected]';
445+
mockRequest.query = { email };
446+
mockUserService.unsubscribeNewsletter.mockResolvedValue(undefined);
447+
448+
await userController.unsubscribeNewsletter(
449+
mockRequest as Request,
450+
mockResponse as Response,
451+
nextFunction
452+
);
453+
454+
expect(mockUserService.unsubscribeNewsletter).toHaveBeenCalledWith(email);
455+
expect(mockResponse.redirect).toHaveBeenCalledWith('/main');
456+
expect(nextFunction).not.toHaveBeenCalled();
457+
});
458+
});
408459
});

src/controllers/user.controller.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import logger from '@/configs/logger.config';
33
import { EmptyResponseDto, LoginResponseDto } from '@/types';
44
import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type';
55
import { UserService } from '@/services/user.service';
6-
import { QRTokenExpiredError, QRTokenInvalidError } from '@/exception/token.exception';
76
import { fetchVelogApi } from '@/modules/velog/velog.api';
7+
import { QRTokenExpiredError, QRTokenInvalidError } from '@/exception';
88

99
type Token10 = string & { __lengthBrand: 10 };
1010

@@ -169,4 +169,22 @@ export class UserController {
169169
next(error);
170170
}
171171
};
172+
173+
unsubscribeNewsletter: RequestHandler = async (req: Request, res: Response<EmptyResponseDto>, next: NextFunction) => {
174+
try {
175+
const email = req.query.email as string;
176+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
177+
178+
if (!email || !emailRegex.test(email)) {
179+
logger.error(`올바르지 않은 이메일: [email: ${req.query.email}]`);
180+
} else {
181+
await this.userService.unsubscribeNewsletter(email);
182+
}
183+
184+
res.redirect('/main');
185+
} catch (error) {
186+
logger.error(`뉴스레터 구독 해제 실패: [email: ${req.query.email}]`, error);
187+
next(error);
188+
}
189+
};
172190
}

src/repositories/user.repository.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { DBError } from '@/exception';
77
export class UserRepository {
88
constructor(private readonly pool: Pool) {}
99

10-
async findByUserId(id: number): Promise<User> {
10+
async findByUserId(id: number): Promise<User | null> {
1111
try {
1212
const user = await this.pool.query('SELECT * FROM "users_user" WHERE id = $1', [id]);
1313
return user.rows[0] || null;
@@ -17,7 +17,7 @@ export class UserRepository {
1717
}
1818
}
1919

20-
async findByUserVelogUUID(uuid: string): Promise<User> {
20+
async findByUserVelogUUID(uuid: string): Promise<User | null> {
2121
try {
2222
const user = await this.pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [uuid]);
2323
return user.rows[0] || null;
@@ -27,6 +27,16 @@ export class UserRepository {
2727
}
2828
}
2929

30+
async findByUserEmail(email: string): Promise<User | null> {
31+
try {
32+
const user = await this.pool.query('SELECT * FROM "users_user" WHERE email = $1', [email]);
33+
return user.rows[0] || null;
34+
} catch (error) {
35+
logger.error('Email로 유저를 조회 중 오류 : ', error);
36+
throw new DBError('유저 조회 중 문제가 발생했습니다.');
37+
}
38+
}
39+
3040
async findSampleUser(): Promise<User> {
3141
try {
3242
const query = `
@@ -152,4 +162,14 @@ export class UserRepository {
152162
throw new DBError('QR 코드 사용 처리 중 문제가 발생했습니다.');
153163
}
154164
}
165+
166+
async unsubscribeNewsletter(id: number): Promise<void> {
167+
try {
168+
const query = `UPDATE "users_user" SET newsletter_subscribed = false WHERE id = $1`;
169+
await this.pool.query(query, [id]);
170+
} catch (error) {
171+
logger.error('User Repo unsubscribeNewsletter Error : ', error);
172+
throw new DBError('뉴스레터 구독 해제 중 문제가 발생했습니다.');
173+
}
174+
}
155175
}

src/routes/user.router.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,24 @@ router.post('/qr-login', authMiddleware.verify, userController.createToken);
151151
*/
152152
router.get('/qr-login', userController.getToken);
153153

154+
/**
155+
* @swagger
156+
* /newsletter-unsubscribe:
157+
* get:
158+
* tags:
159+
* - User
160+
* summary: 뉴스레터 구독 해제 (메일에서 바로 접근)
161+
* parameters:
162+
* - in: query
163+
* name: email
164+
* required: true
165+
* schema:
166+
* type: string
167+
* description: 구독을 해제할 이메일
168+
* responses:
169+
* 302:
170+
* description: 뉴스레터 구독 해제 성공 후 메인 페이지로 리디렉션
171+
*/
172+
router.get('/newsletter-unsubscribe', userController.unsubscribeNewsletter);
173+
154174
export default router;

src/services/user.service.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,31 @@ export class UserService {
170170
);
171171
return { decryptedAccessToken, decryptedRefreshToken };
172172
}
173+
174+
async unsubscribeNewsletter(email: string) {
175+
try {
176+
const user = await this.userRepo.findByUserEmail(email);
177+
178+
if (!user) {
179+
logger.error(`유저를 찾을 수 없습니다. [email: ${email}]`);
180+
return; // 일반적인 실패시 조용히 리디렉션
181+
}
182+
183+
if (!user.newsletter_subscribed) {
184+
logger.error(`이미 구독이 해제된 이메일입니다. [email: ${email}]`);
185+
return; // 일반적인 실패시 조용히 리디렉션
186+
}
187+
188+
await this.userRepo.unsubscribeNewsletter(user.id);
189+
190+
try {
191+
await sendSlackMessage(`뉴스레터 구독 취소: ${email} (id: ${user.id})`);
192+
} catch (error) {
193+
logger.error('Slack 알림 전송 실패:', error);
194+
}
195+
} catch (error) {
196+
logger.error('User Service unsubscribeNewsletter Error : ', error);
197+
throw error;
198+
}
199+
}
173200
}

src/types/models/User.type.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface User {
1111
// 250607 추가
1212
username: string | null;
1313
thumbnail: string | null;
14+
// 251004 추가
15+
newsletter_subscribed: boolean;
1416
}
1517

1618

src/utils/fixtures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const mockUser: User = {
3737
is_active: true,
3838
created_at: new Date('2024-01-01T00:00:00Z'),
3939
updated_at: new Date('2024-01-01T00:00:00Z'),
40+
newsletter_subscribed: true,
4041
};
4142

4243
/**

0 commit comments

Comments
 (0)