Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
21 changes: 21 additions & 0 deletions .env.example
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=
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);
}
36 changes: 1 addition & 35 deletions app/api/events/route.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,6 @@
import { NextResponse } from "next/server";
import { fetchEventsFromSheet } from "@/lib/parseCsv";

function parseDate(value: string) {
if (!value) return null;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d.toISOString();
}

function cleanEvent(raw: Record<string, any>) {
const mapKey = (k: string) => {
const lower = k.toLowerCase().trim();
if (lower === "title" || lower === "name") return "title";
if (lower === "description" || lower === "desc") return "description";
if (lower === "date") return "date";
if (lower === "location" || lower === "venue") return "location";
if (lower === "isupcoming" || lower === "upcoming") return "isUpcoming";
if (lower === "link") return "link";
return lower;
};

const out: any = {};
for (const k of Object.keys(raw)) {
const nk = mapKey(k);
out[nk] = raw[k];
}

out.isUpcoming = Boolean(out.isupcoming || out.isUpcoming || out["isUpcoming"]);
if (typeof out.isUpcoming === "string") {
out.isUpcoming = out.isUpcoming.toLowerCase() === "true";
}

out.date = parseDate(out.date || out.start_date || out["start date"] || "");
out.id = out.id || `${(out.title || "event").toString().toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${out.date || ""}`;

return out;
}
import { cleanEvent } from "@/lib/utils";

Comment on lines 2 to 3

Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

export async function GET() {
try {
Expand Down
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 = 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();
}
152 changes: 152 additions & 0 deletions app/api/subscriptions/route.ts
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)) } });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)) } });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 });
}
}
Loading