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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Brevo (Sendinblue) API key for sending emails
BREVO_API_KEY=

# 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=
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,33 @@ npm i

# Step 4: Start the development server with auto-reloading and an instant preview.
npm run dev
```
```

## Cron Reminders

- Endpoint: `GET/POST /api/jobs`
- Auth: set `CRON_SECRET` in env and send either `Authorization: Bearer <CRON_SECRET>` or header `x-cron-secret: <CRON_SECRET>`.

Example curl:

```sh
curl -X POST "http://localhost:3000/api/jobs" \
-H "Authorization: Bearer $CRON_SECRET"
```

### Platform setup (examples)

- Vercel: add a Cron Job to call `/api/jobs` every 15 minutes and set `CRON_SECRET` in Project Env Vars.
- GitHub Actions: schedule a workflow that hits the endpoint with the secret header.
- Any external cron: ping the endpoint with the same header.

This triggers the event-centric scheduler that sends 24h-before reminders and marks them as sent.

## Rate Limiting

- In-memory by default; configurable per-endpoint.
- To enable Redis-backed limits across instances, set `REDIS_URL` (or platform-provided URL) and redeploy.
- Current limits:
- Subscribe: 20 req/10 min per IP; 5 req/hour per email
- Unsubscribe: 30 req/10 min per IP; 10 req/hour per email
- Cron: 4 calls/minute (global)
30 changes: 30 additions & 0 deletions app/api/cron/reminders/route.ts
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);
}
49 changes: 49 additions & 0 deletions app/api/jobs/route.ts
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 = 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();
}
164 changes: 164 additions & 0 deletions app/api/subscriptions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { NextResponse } from 'next/server';
import { fetchEventsFromSheet } from '@/lib/parseCsv';
import { isValidEmail } from '@/lib/utils';
import { subscribeToEvent, unsubscribeFromEvent } from '@/lib/brevo';
import { 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 = 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)) } });
}

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 = 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 reminderIso = nowMs >= reminderMs ? null : new Date(reminderMs).toISOString();

// Subscribe via Brevo Contacts API
const result = await subscribeToEvent(
String(email),
event.id,
event.title,
event.date,
reminderIso
);

if (result.alreadySubscribed) {
return NextResponse.json({ message: 'Already subscribed' }, { status: 200 });
}

// Send confirmation email for new subscription
await sendImmediateConfirmation(
String(email),
{ id: event.id, title: event.title, startTime: event.date }
);

return NextResponse.json({ message: 'Subscription successful' }, { status: 200 });

} catch (err) {
console.error('Subscribe error:', err);
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

export async function DELETE(request: Request) {
try {
const ip = getClientIp(request);
const ipRes = 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)) } });
}

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 = 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;
}
// Unsubscribe via Brevo Contacts API
const result = await unsubscribeFromEvent(String(email), event.id);
if (result.deleted) {
return NextResponse.json({ message: 'Unsubscribed' }, { status: 200 });
}
return NextResponse.json({ message: 'No subscription found' }, { status: 200 });
} catch (err) {
console.error('Unsubscribe error:', err);
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
79 changes: 79 additions & 0 deletions app/api/unsubscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { parseUnsubscribeToken } from "@/lib/email/send";
import { unsubscribeFromEvent } from "@/lib/brevo";
import { fetchEventsFromSheet } from "@/lib/parseCsv";

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 getEventId(eventTitle: string): Promise<string | null> {
const raw = await fetchEventsFromSheet();
const want = normalizeTitle(eventTitle);
const row = (raw || []).find(
(r: any) => normalizeTitle(r.title) === want || normalizeTitle(r.name) === want
);
if (!row) return null;

const startIso = getStartIso(row);
const r = row as any;
const title = normalizeTitle(r.title || r.name || "Untitled");
return r.id || makeEventId(title, startIso);
}

export async function POST(request: Request) {
try {
const { token } = await request.json();

if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Invalid token" }, { status: 400 });
}

const parsed = parseUnsubscribeToken(token);
if (!parsed) {
return NextResponse.json({ error: "Invalid or expired token" }, { status: 400 });
}

const { email, eventTitle } = parsed;

// Get the event ID from the title
const eventId = await getEventId(eventTitle);
if (!eventId) {
// Event might have been removed, but we should still try to unsubscribe by title
return NextResponse.json({ error: "Event not found" }, { status: 404 });
}

// Unsubscribe via Brevo Contacts API
const result = await unsubscribeFromEvent(email, eventId);

if (result.deleted) {
return NextResponse.json({ message: "Unsubscribed successfully" }, { status: 200 });
}

return NextResponse.json({ message: "No subscription found" }, { status: 200 });

} catch (err) {
console.error("Unsubscribe error:", err);
return NextResponse.json({ error: "Something went wrong" }, { status: 500 });
}
}
Loading