Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/controllers/__test__/user.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
20 changes: 19 additions & 1 deletion src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -169,4 +169,22 @@ export class UserController {
next(error);
}
};

unsubscribeNewsletter: RequestHandler = async (req: Request, res: Response<EmptyResponseDto>, 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);
}
};
}
24 changes: 22 additions & 2 deletions src/repositories/user.repository.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

타입 강화를 더 돈독히 해주셨군요. (애초에 이게 TS 린팅에 안걸리는게 더 신기하네..)

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { DBError } from '@/exception';
export class UserRepository {
constructor(private readonly pool: Pool) {}

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

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

async findByUserEmail(email: string): Promise<User | null> {
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<User> {
try {
const query = `
Expand Down Expand Up @@ -152,4 +162,14 @@ export class UserRepository {
throw new DBError('QR 코드 사용 처리 중 문제가 발생했습니다.');
}
}

async unsubscribeNewsletter(id: number): Promise<void> {
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('뉴스레터 구독 해제 중 문제가 발생했습니다.');
}
}
}
20 changes: 20 additions & 0 deletions src/routes/user.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
27 changes: 27 additions & 0 deletions src/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
2 changes: 2 additions & 0 deletions src/types/models/User.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface User {
// 250607 추가
username: string | null;
thumbnail: string | null;
// 251004 추가
newsletter_subscribed: boolean;
}


Expand Down
1 change: 1 addition & 0 deletions src/utils/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

/**
Expand Down