-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/email subscriptions #7
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
base: main
Are you sure you want to change the base?
Changes from 6 commits
7008804
95f5b94
34ca921
7f4d297
12ecd59
799a2d3
c0b94da
12e6465
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,21 @@ | ||
| # Brevo (Sendinblue) API key for sending emails | ||
| BREVO_API_KEY= | ||
|
|
||
| # PostgreSQL connection string | ||
| DATABASE_URL=postgres://user:password@localhost:5432/dbname | ||
|
|
||
| # Google Sheets CSV URL (publish your sheet to web as CSV) | ||
| NEXT_PUBLIC_EVENTS_SHEET_URL= | ||
|
|
||
| # Base URL for email links | ||
| NEXT_PUBLIC_BASE_URL=https://yoursite.com | ||
|
|
||
| # Email sender configuration | ||
| MAIL_FROM=noreply@example.com | ||
| MAIL_FROM_NAME=Your Club Name | ||
|
|
||
| # Secret for authenticating cron job requests | ||
| CRON_SECRET= | ||
|
|
||
| # Optional: Redis URL for rate limiting (falls back to in-memory if not set) | ||
| REDIS_URL= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { NextResponse } from 'next/server'; | ||
| import { processEventReminders } from '@/lib/scheduler'; | ||
|
|
||
| // This endpoint should be called by a cron job (e.g., Vercel Cron, GitHub Actions) | ||
| // Recommended: Run every 15 minutes to catch the 24h window | ||
|
|
||
| export async function GET(request: Request) { | ||
| const authHeader = request.headers.get('authorization'); // verify cron for security | ||
| const cronSecret = process.env.CRON_SECRET; | ||
|
|
||
| if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); | ||
| } | ||
|
|
||
| try { | ||
| const result = await processEventReminders(); | ||
| return NextResponse.json({ | ||
| success: true, | ||
| processedEvents: result.processedEvents, | ||
| processedSubs: result.processedSubs, | ||
| errors: result.errors.length > 0 ? result.errors : undefined, | ||
| }, { status: 200 }); | ||
| } catch (err) { | ||
| return NextResponse.json({ error: String(err) }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| return GET(request); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { processEventReminders } from "@/lib/scheduler"; | ||
| import { getBucket, allow } from "@/lib/rateLimit"; | ||
|
|
||
| function isAuthorized(req: Request) { | ||
| const secret = process.env.CRON_SECRET; | ||
| if (!secret) return false; | ||
| const auth = req.headers.get("authorization"); | ||
| const header = req.headers.get("x-cron-secret"); | ||
| if (auth && auth.toLowerCase().startsWith("bearer ")) { | ||
| const token = auth.slice(7); | ||
| if (token === secret) return true; | ||
| } | ||
| if (header && header === secret) return true; | ||
| return false; | ||
| } | ||
|
|
||
| async function handle() { | ||
| try { | ||
| // Limit cron invocations to avoid abuse | ||
| const res = await allow('cron:jobs', 'global', 60 * 1000, 4); | ||
| if (!res.allowed) { | ||
| return NextResponse.json({ error: "Too many requests" }, { status: 429, headers: { 'retry-after': String(Math.ceil(res.retryAfterMs / 1000)) } }); | ||
| } | ||
| const summary = await processEventReminders(); | ||
| return NextResponse.json(summary, { | ||
| status: 200, | ||
| headers: { | ||
| "cache-control": "no-store", | ||
| }, | ||
| }); | ||
| } catch (err: any) { | ||
| return NextResponse.json({ error: String(err) }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| export async function GET(req: Request) { | ||
| if (!isAuthorized(req)) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
| return handle(); | ||
| } | ||
|
|
||
| export async function POST(req: Request) { | ||
| if (!isAuthorized(req)) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
| return handle(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| import { NextResponse } from 'next/server'; | ||
| import { fetchEventsFromSheet } from '@/lib/parseCsv'; | ||
| import { isValidEmail } from '@/lib/utils'; | ||
| import { subscribe, unsubscribe } from '@/lib/subscriptions'; | ||
| import { getBucket, getClientIp, allow } from '@/lib/rateLimit'; | ||
| import { sendImmediateConfirmation } from '@/lib/email/send'; | ||
|
|
||
| function normalizeTitle(value: string | undefined | null) { | ||
| return (value || '').trim(); | ||
| } | ||
|
|
||
| function parseIsoDate(value: string | undefined | null) { | ||
| if (!value) return null; | ||
| const d = new Date(value); | ||
| return isNaN(d.getTime()) ? null : d.toISOString(); | ||
| } | ||
|
|
||
| function getStartIso(row: Record<string, any>) { | ||
| return ( | ||
| parseIsoDate(row.date) || | ||
| parseIsoDate(row.start_date) || | ||
| parseIsoDate(row['start date']) || | ||
| null | ||
| ); | ||
| } | ||
|
|
||
| function makeEventId(title: string, startIso: string | null) { | ||
| const base = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); | ||
| return startIso ? `${base}-${startIso}` : base; | ||
| } | ||
|
|
||
| async function getEvent(eventTitle: string) { | ||
| const raw = await fetchEventsFromSheet(); | ||
| const want = normalizeTitle(eventTitle); | ||
| // Look for matching title or name | ||
| const row = (raw || []).find((r: any) => normalizeTitle((r as any).title) === want || normalizeTitle((r as any).name) === want); | ||
| if (!row) { | ||
| throw new Error('Event not found'); | ||
| } | ||
| const startIso = getStartIso(row); | ||
| if (!startIso) { | ||
| throw new Error('Event start time is invalid'); | ||
| } | ||
| const r: any = row as any; | ||
| const title = normalizeTitle(r.title || r.name || 'Untitled'); | ||
| const id = r.id || makeEventId(title, startIso); | ||
| const link = r.link || null; | ||
| const location = r.location || r.venue || null; | ||
| return { id, title, date: startIso, link, location } as { id: string; title: string; date: string; link: string | null; location: string | null }; | ||
| } | ||
|
|
||
| // API Route Handler | ||
| export async function POST(request: Request) { | ||
| try { | ||
| const ip = getClientIp(request); | ||
| const ipRes = await allow('subs:ip', ip, 10 * 60 * 1000, 20); | ||
| if (!ipRes.allowed) { | ||
| return NextResponse.json({ error: 'Too many requests' }, { status: 429, headers: { 'retry-after': String(Math.ceil(ipRes.retryAfterMs / 1000)) } }); | ||
|
Contributor
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. With regards to the IP rate limiting: Attackers could circumvent IP-based rate limits if behind proxies. Make sure getClientIp accurately resolves IPs respecting trusted proxy headers |
||
| } | ||
|
|
||
| const { email, eventTitle } = await request.json(); | ||
| if (!isValidEmail(String(email))) { | ||
| return NextResponse.json({ error: 'Invalid email address' }, { status: 400 }); | ||
| } | ||
|
|
||
| const emailKey = String(email).toLowerCase(); | ||
| const emailRes = await allow('subs:email', emailKey, 60 * 60 * 1000, 5); | ||
| if (!emailRes.allowed) { | ||
| return NextResponse.json({ error: 'Too many requests for this email' }, { status: 429, headers: { 'retry-after': String(Math.ceil(emailRes.retryAfterMs / 1000)) } }); | ||
| } | ||
| let event: { id: string; title: string; date: string; link: string | null; location: string | null }; | ||
| try { | ||
| event = await getEvent(String(eventTitle)); | ||
| } catch (e: any) { | ||
| const msg = String(e?.message || e || ""); | ||
| if (msg.includes("Event not found")) { | ||
| return NextResponse.json({ error: 'Event not found' }, { status: 404 }); | ||
| } | ||
| if (msg.includes("Event start time is invalid")) { | ||
| return NextResponse.json({ error: 'Event start time invalid' }, { status: 400 }); | ||
| } | ||
| throw e; | ||
| } | ||
|
|
||
| const nowMs = Date.now(); | ||
| const startMs = new Date(event.date).getTime(); | ||
| if (isNaN(startMs)) { | ||
| return NextResponse.json({ error: 'Event start time invalid' }, { status: 400 }); | ||
| } | ||
| if (nowMs >= startMs) { | ||
| return NextResponse.json({ error: 'Event already started or passed' }, { status: 400 }); | ||
| } | ||
| const reminderMs = startMs - 24 * 60 * 60 * 1000; | ||
| const reminderDate = nowMs >= reminderMs ? null : new Date(reminderMs); | ||
|
|
||
| const inserted = await subscribe(String(email), event.id, reminderDate); | ||
| if (inserted) { | ||
| // Send confirmation email only for newly created subscription | ||
| await sendImmediateConfirmation( | ||
| String(email), | ||
| { id: event.id, title: event.title, startTime: event.date } | ||
| ); | ||
| } | ||
|
|
||
| return NextResponse.json({ message: 'Subscription successful' }, { status: 200 }); | ||
|
|
||
| } catch (err) { | ||
| return NextResponse.json({ error: String(err) }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| export async function DELETE(request: Request) { | ||
| try { | ||
| const ip = getClientIp(request); | ||
| const ipRes = await allow('unsub:ip', ip, 10 * 60 * 1000, 30); | ||
| if (!ipRes.allowed) { | ||
| return NextResponse.json({ error: 'Too many requests' }, { status: 429, headers: { 'retry-after': String(Math.ceil(ipRes.retryAfterMs / 1000)) } }); | ||
|
Contributor
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. Same here: Attackers could circumvent IP-based rate limits if behind proxies. Make sure getClientIp accurately resolves IPs respecting trusted proxy headers |
||
| } | ||
|
|
||
| const { email, eventTitle } = await request.json(); | ||
| if (!isValidEmail(String(email))) { | ||
| return NextResponse.json({ error: 'Invalid email address' }, { status: 400 }); | ||
| } | ||
|
|
||
| const emailKey = String(email).toLowerCase(); | ||
| const emailRes = await allow('unsub:email', emailKey, 60 * 60 * 1000, 10); | ||
| if (!emailRes.allowed) { | ||
| return NextResponse.json({ error: 'Too many requests for this email' }, { status: 429, headers: { 'retry-after': String(Math.ceil(emailRes.retryAfterMs / 1000)) } }); | ||
| } | ||
| let event; | ||
| try { | ||
| event = await getEvent(String(eventTitle)); | ||
| } catch (e: any) { | ||
| const msg = String(e?.message || e || ""); | ||
| if (msg.includes('Event not found')) { | ||
| return NextResponse.json({ error: 'Event not found' }, { status: 404 }); | ||
| } | ||
| if (msg.includes('Event start time is invalid')) { | ||
| return NextResponse.json({ error: 'Event start time invalid' }, { status: 400 }); | ||
| } | ||
| throw e; | ||
| } | ||
| const result = await unsubscribe(String(email), event.id); | ||
| if (result.deleted) { | ||
| return NextResponse.json({ message: 'Unsubscribed' }, { status: 200 }); | ||
| } | ||
| // Use 200 for a graceful no-op to avoid 204 body constraints | ||
| return NextResponse.json({ message: 'No subscription found' }, { status: 200 }); | ||
| } catch (err) { | ||
| return NextResponse.json({ error: String(err) }, { status: 500 }); | ||
| } | ||
| } | ||
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.
Some adjustments were made to some of the files hit by these changes. Would you be able to review the merge conflict here? I don't want it to break existing functionality
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.
Done