diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index fd92f21..d4917b2 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -176,6 +176,21 @@ export async function POST(req: Request) { }); } + // 새 글이 공개 글인 경우 구독자들에게 이메일 발송 + if (!post.isPrivate) { + const { sendNewPostNotifications } = await import( + '@/app/lib/email/notifications' + ); + sendNewPostNotifications({ + title: newPost.title, + subTitle: newPost.subTitle, + slug: newPost.slug, + thumbnailImage: newPost.thumbnailImage, + }).catch((error) => { + console.error('Failed to send post notifications:', error); + }); + } + return Response.json( { success: true, diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts new file mode 100644 index 0000000..589300d --- /dev/null +++ b/app/api/subscribe/route.ts @@ -0,0 +1,178 @@ +import { randomUUID } from 'crypto'; +import { NextRequest } from 'next/server'; +import dbConnect from '@/app/lib/dbConnect'; +import { sendVerificationEmail } from '@/app/lib/email/resend'; +import { checkRateLimit, getClientIP } from '@/app/lib/rateLimit'; +import Subscriber from '@/app/models/Subscriber'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export async function POST(req: NextRequest) { + try { + const clientIP = getClientIP(req); + const rateLimit = checkRateLimit(clientIP); + + if (!rateLimit.allowed) { + return Response.json( + { + success: false, + error: '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.', + }, + { status: 429 } + ); + } + + const { email, nickname } = await req.json(); + + if (!email || !nickname) { + return Response.json( + { + success: false, + error: '이메일과 닉네임은 필수 항목입니다.', + }, + { status: 400 } + ); + } + + if (!EMAIL_REGEX.test(email)) { + return Response.json( + { + success: false, + error: '유효한 이메일 주소를 입력해주세요.', + }, + { status: 400 } + ); + } + + if (nickname.trim().length < 2) { + return Response.json( + { + success: false, + error: '닉네임은 최소 2자 이상이어야 합니다.', + }, + { status: 400 } + ); + } + + await dbConnect(); + + const existingSubscriber = await Subscriber.findOne({ email }); + + if (existingSubscriber) { + if (existingSubscriber.isActive && existingSubscriber.isVerified) { + return Response.json( + { + success: false, + error: '이미 구독 중인 이메일입니다.', + }, + { status: 409 } + ); + } + + if (!existingSubscriber.isVerified) { + const verificationAge = Date.now() - existingSubscriber.createdAt; + const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; + + if (verificationAge < TWENTY_FOUR_HOURS) { + return Response.json( + { + success: false, + error: + '이미 인증 이메일이 발송되었습니다. 이메일을 확인해주세요.', + }, + { status: 409 } + ); + } + + const newVerificationToken = randomUUID(); + existingSubscriber.verificationToken = newVerificationToken; + existingSubscriber.nickname = nickname.trim(); + await existingSubscriber.save(); + + const emailResult = await sendVerificationEmail( + email, + nickname.trim(), + newVerificationToken + ); + + if (!emailResult.success) { + return Response.json( + { + success: false, + error: + '인증 이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.', + }, + { status: 500 } + ); + } + + return Response.json( + { + success: true, + message: '인증 이메일이 재발송되었습니다. 이메일을 확인해주세요.', + }, + { status: 200 } + ); + } + + existingSubscriber.isActive = true; + await existingSubscriber.save(); + + return Response.json( + { + success: true, + message: '구독이 재활성화되었습니다.', + }, + { status: 200 } + ); + } + + const verificationToken = randomUUID(); + const unsubscribeToken = randomUUID(); + + const newSubscriber = await Subscriber.create({ + email: email.toLowerCase().trim(), + nickname: nickname.trim(), + verificationToken, + unsubscribeToken, + isActive: false, + isVerified: false, + }); + + const emailResult = await sendVerificationEmail( + email, + nickname.trim(), + verificationToken + ); + + if (!emailResult.success) { + await Subscriber.deleteOne({ _id: newSubscriber._id }); + + return Response.json( + { + success: false, + error: '인증 이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.', + }, + { status: 500 } + ); + } + + return Response.json( + { + success: true, + message: '인증 이메일이 발송되었습니다. 이메일을 확인해주세요.', + }, + { status: 201 } + ); + } catch (error) { + console.error('Subscribe API error:', error); + return Response.json( + { + success: false, + error: '구독 처리 중 오류가 발생했습니다.', + detail: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/api/subscribe/unsubscribe/route.ts b/app/api/subscribe/unsubscribe/route.ts new file mode 100644 index 0000000..4cc6e5f --- /dev/null +++ b/app/api/subscribe/unsubscribe/route.ts @@ -0,0 +1,42 @@ +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; +import dbConnect from '@/app/lib/dbConnect'; +import { sendUnsubscribeConfirmation } from '@/app/lib/email/resend'; +import Subscriber from '@/app/models/Subscriber'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const token = searchParams.get('token'); + + if (!token) { + redirect('/subscribe/error?message=invalid_token'); + } + + await dbConnect(); + + const subscriber = await Subscriber.findOne({ unsubscribeToken: token }); + + if (!subscriber) { + redirect('/subscribe/error?message=subscriber_not_found'); + } + + if (!subscriber.isActive) { + redirect('/subscribe/unsubscribed?message=already_unsubscribed'); + } + + subscriber.isActive = false; + await subscriber.save(); + + sendUnsubscribeConfirmation(subscriber.email, subscriber.nickname).catch( + (error) => { + console.error('Failed to send unsubscribe confirmation:', error); + } + ); + + redirect('/subscribe/unsubscribed'); + } catch (error) { + console.error('Unsubscribe API error:', error); + redirect('/subscribe/error?message=server_error'); + } +} diff --git a/app/api/subscribe/verify/route.ts b/app/api/subscribe/verify/route.ts new file mode 100644 index 0000000..b4469ef --- /dev/null +++ b/app/api/subscribe/verify/route.ts @@ -0,0 +1,43 @@ +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; +import dbConnect from '@/app/lib/dbConnect'; +import Subscriber from '@/app/models/Subscriber'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const token = searchParams.get('token'); + + if (!token) { + redirect('/subscribe/error?message=invalid_token'); + } + + await dbConnect(); + + const subscriber = await Subscriber.findOne({ verificationToken: token }); + + if (!subscriber) { + redirect('/subscribe/error?message=token_not_found'); + } + + const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; + const tokenAge = Date.now() - subscriber.createdAt; + + if (tokenAge > TWENTY_FOUR_HOURS) { + redirect('/subscribe/error?message=token_expired'); + } + + if (subscriber.isVerified && subscriber.isActive) { + redirect('/subscribe/verified?message=already_verified'); + } + + subscriber.isVerified = true; + subscriber.isActive = true; + await subscriber.save(); + + redirect('/subscribe/verified'); + } catch (error) { + console.error('Verify API error:', error); + redirect('/subscribe/error?message=server_error'); + } +} diff --git a/app/entities/common/Footer.tsx b/app/entities/common/Footer.tsx index 55230a8..3afed5b 100644 --- a/app/entities/common/Footer.tsx +++ b/app/entities/common/Footer.tsx @@ -1,9 +1,63 @@ 'use client'; +import axios from 'axios'; import Link from 'next/link'; +import { useState, FormEvent } from 'react'; import useToast from '@/app/hooks/useToast'; const Footer = () => { const toast = useToast(); + const [nickname, setNickname] = useState(''); + const [email, setEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!nickname.trim() || !email.trim()) { + toast.error('닉네임과 이메일을 모두 입력해주세요.'); + return; + } + + if (nickname.trim().length < 2) { + toast.error('닉네임은 최소 2자 이상이어야 합니다.'); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + toast.error('유효한 이메일 주소를 입력해주세요.'); + return; + } + + setIsLoading(true); + + try { + const response = await axios.post('/api/subscribe', { + email: email.trim(), + nickname: nickname.trim(), + }); + + if (response.data.success) { + toast.success( + response.data.message || '인증 이메일이 발송되었습니다.' + ); + setIsSubmitted(true); + setNickname(''); + setEmail(''); + } + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + toast.error( + error.response.data.error || '구독 신청에 실패했습니다.' + ); + } else { + toast.error('구독 신청 중 오류가 발생했습니다.'); + } + } finally { + setIsLoading(false); + } + }; return (