-
Notifications
You must be signed in to change notification settings - Fork 94
feat(newsletter): implement newsletter API integration with subscribe… #452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| 'use client'; | ||
| import { useSearchParams } from 'next/navigation'; | ||
| import Link from 'next/link'; | ||
| import { Suspense } from 'react'; | ||
|
|
||
| const msgs: Record<string, string> = { | ||
| expired: 'This confirmation link has expired.', | ||
| invalid: 'This confirmation link is invalid or already used.', | ||
| }; | ||
|
|
||
| function Content() { | ||
| const p = useSearchParams(); | ||
| return ( | ||
| <main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'> | ||
| <h1 className='text-3xl font-semibold'>Confirmation failed</h1> | ||
| <p className='text-[#D9D9D9]'> | ||
| {msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'} | ||
| </p> | ||
| <Link href='/' className='text-[#A7F950] underline'> | ||
| Back to home | ||
| </Link> | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| export default function Page() { | ||
| return ( | ||
| <Suspense> | ||
| <Content /> | ||
| </Suspense> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import Link from 'next/link'; | ||
| export default function NewsletterConfirmedPage() { | ||
| return ( | ||
| <main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'> | ||
| <h1 className='text-3xl font-semibold'>You're subscribed! 🎉</h1> | ||
| <p className='text-[#D9D9D9]'> | ||
| Your subscription has been confirmed. Welcome aboard! | ||
| </p> | ||
| <Link href='/' className='text-[#A7F950] underline'> | ||
| Back to home | ||
| </Link> | ||
| </main> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| 'use client'; | ||
| import { useSearchParams } from 'next/navigation'; | ||
| import Link from 'next/link'; | ||
| import { Suspense } from 'react'; | ||
|
|
||
| const msgs: Record<string, string> = { | ||
| invalid: 'This unsubscribe link is invalid or has already been used.', | ||
| }; | ||
|
|
||
| function Content() { | ||
| const p = useSearchParams(); | ||
| return ( | ||
| <main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'> | ||
| <h1 className='text-3xl font-semibold'>Unsubscribe failed</h1> | ||
| <p className='text-[#D9D9D9]'> | ||
| {msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'} | ||
| </p> | ||
| <Link href='/' className='text-[#A7F950] underline'> | ||
| Back to home | ||
| </Link> | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| export default function Page() { | ||
| return ( | ||
| <Suspense> | ||
| <Content /> | ||
| </Suspense> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import Link from 'next/link'; | ||
| export default function NewsletterUnsubscribedPage() { | ||
| return ( | ||
| <main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'> | ||
| <h1 className='text-3xl font-semibold'>You've been unsubscribed</h1> | ||
| <p className='text-[#D9D9D9]'> | ||
| You won't receive any more emails from us. | ||
| </p> | ||
| <Link href='/' className='text-[#A7F950] underline'> | ||
| Back to home | ||
| </Link> | ||
| </main> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
|
|
||
| const backendUrl = process.env.NEXT_PUBLIC_API_URL; | ||
| const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''; | ||
|
|
||
| export async function GET( | ||
| _req: NextRequest, | ||
| { params }: { params: Promise<{ token: string }> } | ||
| ) { | ||
| const { token } = await params; | ||
| const res = await fetch(`${backendUrl}/api/newsletter/confirm/${token}`, { | ||
| redirect: 'manual', | ||
| }); | ||
| if (res.status === 302) { | ||
| return NextResponse.redirect(`${appUrl}/newsletter/confirmed`); | ||
| } | ||
| const reason = res.status === 400 ? 'expired' : 'invalid'; | ||
| return NextResponse.redirect( | ||
| `${appUrl}/newsletter/confirm/error?reason=${reason}` | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
|
|
||
| function normalizeBackendUrl(raw: string | undefined): string | undefined { | ||
| if (!raw) return undefined; | ||
| return raw.replace(/\/$/, '').replace(/\/api$/i, ''); | ||
| } | ||
|
|
||
| const backendUrl = normalizeBackendUrl(process.env.NEXT_PUBLIC_API_URL); | ||
|
|
||
| export async function PATCH(req: NextRequest) { | ||
| const body = await req.json(); | ||
|
|
||
| if (!backendUrl) { | ||
| return NextResponse.json( | ||
| { message: 'Server configuration error: NEXT_PUBLIC_API_URL is not set' }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
|
|
||
| const res = await fetch(`${backendUrl}/api/newsletter/preferences`, { | ||
| method: 'PATCH', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
| return NextResponse.json(await res.json().catch(() => ({})), { | ||
| status: res.status, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
|
|
||
| const backendUrl = process.env.NEXT_PUBLIC_API_URL; | ||
| const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''; | ||
|
Comment on lines
+3
to
+4
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes—per the official Next.js docs, in App Router Route Handlers you should pass return NextResponse.redirect(new URL('/path', request.url))The 🏁 Script executed: cat -n app/api/newsletter/unsubscribe/[token]/route.tsRepository: boundlessfi/boundless Length of output: 821 Fail closed when env config is missing or the backend is down. If Suggested fix const backendUrl = process.env.NEXT_PUBLIC_API_URL;
-const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';
+const appUrl = process.env.NEXT_PUBLIC_APP_URL;
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
+ const origin = appUrl ?? _req.nextUrl.origin;
+ if (!backendUrl) {
+ return NextResponse.redirect(
+ new URL('/newsletter/unsubscribe/error', origin)
+ );
+ }
+
const { token } = await params;
- const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe/${token}`, {
- redirect: 'manual',
- });
+ let res: Response;
+ try {
+ res = await fetch(`${backendUrl}/api/newsletter/unsubscribe/${token}`, {
+ redirect: 'manual',
+ });
+ } catch {
+ return NextResponse.redirect(
+ new URL('/newsletter/unsubscribe/error', origin)
+ );
+ }
+
if (res.status === 302) {
- return NextResponse.redirect(`${appUrl}/newsletter/unsubscribed`);
+ return NextResponse.redirect(new URL('/newsletter/unsubscribed', origin));
}
return NextResponse.redirect(
- `${appUrl}/newsletter/unsubscribe/error?reason=invalid`
+ new URL('/newsletter/unsubscribe/error?reason=invalid', origin)
);
}Per Next.js documentation, 🤖 Prompt for AI Agents |
||
|
|
||
| export async function GET( | ||
| _req: NextRequest, | ||
| { params }: { params: Promise<{ token: string }> } | ||
| ) { | ||
| const { token } = await params; | ||
| const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe/${token}`, { | ||
| redirect: 'manual', | ||
| }); | ||
| if (res.status === 302) { | ||
| return NextResponse.redirect(`${appUrl}/newsletter/unsubscribed`); | ||
| } | ||
| return NextResponse.redirect( | ||
| `${appUrl}/newsletter/unsubscribe/error?reason=invalid` | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
|
|
||
| const backendUrl = process.env.NEXT_PUBLIC_API_URL; | ||
|
|
||
| export async function POST(req: NextRequest) { | ||
| const body = await req.json(); | ||
| const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
| return NextResponse.json(await res.json().catch(() => ({})), { | ||
| status: res.status, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add error handling for JSON parsing and network failures.
Two failure scenarios are not gracefully handled:
req.json()throws if the request body is malformed JSON, resulting in an unhandled exception.fetch()throws on network errors (e.g., backend unreachable), also resulting in an unhandled exception.Both cases produce opaque 500 errors without meaningful messages.
🛠️ Proposed fix with error handling
export async function PATCH(req: NextRequest) { - const body = await req.json(); + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { message: 'Invalid JSON body' }, + { status: 400 } + ); + } if (!backendUrl) { return NextResponse.json( { message: 'Server configuration error: NEXT_PUBLIC_API_URL is not set' }, { status: 500 } ); } - const res = await fetch(`${backendUrl}/api/newsletter/preferences`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - return NextResponse.json(await res.json().catch(() => ({})), { - status: res.status, - }); + try { + const res = await fetch(`${backendUrl}/api/newsletter/preferences`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return NextResponse.json(await res.json().catch(() => ({})), { + status: res.status, + }); + } catch { + return NextResponse.json( + { message: 'Failed to connect to backend service' }, + { status: 502 } + ); + } }🤖 Prompt for AI Agents