Feature/email subscriptions - #7
Conversation
Implements event subscription API, 24h reminder scheduling, and email notifications. Adds database schema for subscriptions, utility functions for event parsing and email validation, and integrates Brevo for transactional emails. Updates environment variables and dependencies to support PostgreSQL, scheduling, and email delivery.
Introduces a new /api/jobs endpoint for triggering scheduled reminders with secret-based authorization, documented in README. Subscription logic is improved to only send confirmation emails for new subscriptions and to handle event errors more gracefully. The scheduler and subscription queries are tightened to avoid duplicate reminders. Adds initial Vitest-based test suite and configuration.
Introduces a unified rate limiting system with in-memory and Redis-backed options, configurable per endpoint. Adds rate limit enforcement to subscription, unsubscription, and cron job API routes. Updates documentation and environment configuration for Redis support. Includes tests for both in-memory and Redis rate limiting logic.
Introduces a SubscribeDialog component and integrates event reminder subscription into the events page, allowing users to subscribe for email reminders. Adds a new API route for cron-based reminder processing, updates EventCard to support subscription, and adjusts tsconfig target. Also includes an example .env file for configuration.
Introduces unsubscribe functionality for event reminder emails. Adds API route and frontend page for unsubscribing, updates email sending logic to include unsubscribe links, and documents the base URL in .env.example.
✅ Deploy Preview for mcss-website-2026 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
| 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"; | ||
|
|
There was a problem hiding this comment.
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
| 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)) } }); |
There was a problem hiding this comment.
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 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)) } }); |
There was a problem hiding this comment.
Same here:
Attackers could circumvent IP-based rate limits if behind proxies. Make sure getClientIp accurately resolves IPs respecting trusted proxy headers
| @@ -0,0 +1,72 @@ | |||
| import { getRedis } from '@/lib/redis'; | |||
There was a problem hiding this comment.
we don't have money for redis unfortunately... Also don't think caching would be super beneficial here considering these are 24hour jobs and we aren't too concerned about how long they would take. Would it be possible to simplify the logic by removing the caching?
| CREATE TABLE IF NOT EXISTS subscriptions ( | ||
| id BIGSERIAL PRIMARY KEY, | ||
| email TEXT NOT NULL, | ||
| event_id TEXT NOT NULL, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | ||
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | ||
| reminder_scheduled_for TIMESTAMPTZ, | ||
| reminder_sent_at TIMESTAMPTZ, | ||
| last_notified_at TIMESTAMPTZ | ||
| ); | ||
|
|
||
| -- Ensure a single subscription per email+event | ||
| CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_email_event | ||
| ON subscriptions (email, event_id); | ||
|
|
||
| -- Speed up due-reminder queries | ||
| CREATE INDEX IF NOT EXISTS idx_subscriptions_reminder_scheduled_for | ||
| ON subscriptions (reminder_scheduled_for); |
There was a problem hiding this comment.
So we would need to create a database for these changes. Would it be possible to look into creating an email newsletter subscription?: https://www.brevo.com/landing/newsletter
This might serve our use case well
There was a problem hiding this comment.
I implemented it without a database, subscriptions are stored using Brevo's Contacts API instead. When a user subscribes, their info is saved as contact attributes in Brevo. A cron job checks daily and sends reminder emails 24h before events.
|
Looks good overall, I like the consideration that went into configuring things with external services, I think this is really useful to keep in mind for our application. Do first look into Brevo email newsletters: https://www.brevo.com/landing/newsletter I think this might just be the key to accomplishing our use case. Secondly, just make sure existing behaviour isn't broken from your changes with the merge conflicts. |
… a DB to handle subscription logic
Email Subscriptions for Events
Summary
Implements email subscription functionality for events. Users can subscribe to upcoming events and receive email reminders 24 hours before the event starts.
Features
Technical Details
/api/cron/remindersendpoint — set up external cron to call every 15 minNew Files
components/SubscribeDialog.tsxapp/api/cron/reminders/route.tsapp/api/unsubscribe/route.tspages/unsubscribe.tsx.env.exampleModified Files
components/EventCard.tsxpages/events.tsxlib/email/send.tslib/utils.tscleanEventhelper functionapp/api/events/route.tscleanEventfrom utilsEnvironment Variables Required
Cron Setup
The reminder system requires an external cron job to call /api/cron/reminders every 15 minutes.
Request format:
Options for setting up cron:
Testing