Skip to content
Merged
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
15 changes: 15 additions & 0 deletions app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
178 changes: 178 additions & 0 deletions app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
42 changes: 42 additions & 0 deletions app/api/subscribe/unsubscribe/route.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
43 changes: 43 additions & 0 deletions app/api/subscribe/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
Loading
Loading