Back to Projects
Next.jsReactTypeScriptTailwindshadcnSupabasePostgreSQLStripeOpenAIGolangSESVercelSecurity

Africa Patient Safety Summit

The full platform behind a pan-African patient safety summit convened by Medicines for Africa with the African Medicines Agency. A thirteen page public site, a multi step delegate registration flow priced entirely on the server, Stripe checkout with idempotent webhooks, and an admin back office for RSVPs, payments, exports and AI drafted bulk email.

Africa Patient Safety Summit

Project Overview

Medicines for Africa needed a home for the Africa Patient Safety Summit, two days in Kigali in September 2026, co-convened with the African Medicines Agency, roughly 250 delegates flying in from ministries of health, national regulators, hospitals, pharma and patient organisations across the continent. That audience does not forgive a Google Form and a Mailchimp list. It had to look and behave like a continental institution from the first paint.

So this is not a brochure site with a contact form bolted on. It is about 14,000 lines of TypeScript and SQL across 145 files: thirteen public pages, a five section admin dashboard, eleven database migrations and a payment pipeline that actually has to survive Stripe retrying the same event three times at 2am.

Every word of copy lives in typed content modules under lib/content, one per surface: programme, themes, speakers, committee, travel, partnerships, pricing. Conference organisers change their minds roughly once a week, and the whole point of that layout is that a date shift or a new committee member is a one line edit in a data file, never a trip into a component. The programme model in particular is opinionated. Sessions carry a layer, a framing question, the insight, the shift they drive and the system weaknesses they fix, because the Summit is structured around what they call the patient safety reality gap: perceived safety, reported safety, and what patients actually live through.

Registration is where the interesting work is. Three delegate categories (general, public sector and academic, corporate and industry) crossed with four pricing windows (early bird, standard, late, onsite) gives twelve live prices, and exactly one window is active on any given day. That window is resolved against the calendar date in Africa/Kigali, not the server's timezone, because a delegate paying at 11pm in Lagos should not get yesterday's early bird rate. The client never gets a vote on price: the form collects people, the server action rebuilds the entire basket from the active window, validates every category key, then writes the RSVP and its attendees. There is a Zod schema on both sides, a honeypot field that quietly swallows bots, a duplicate lookup that warns you if that email already has a place, and a terms dialog you have to clear before checkout will even open.

The money path is the part I am quietly proud of. Checkout lines are built from the server side basket, the RSVP id rides along as both client_reference_id and metadata, and the webhook is the only thing allowed to mark a registration paid. Idempotency is keyed on the Stripe payment intent, and the payments table enforces uniqueness with a partial index, which Postgres flatly refuses to use as an ON CONFLICT arbiter, so a plain upsert is off the table. The handler looks the payment up, updates in place if Stripe is re-delivering, inserts if it is new, and lets errors propagate on purpose so the endpoint returns 500 and Stripe retries instead of the platform silently losing a $650 registration. Expired sessions walk the RSVP back to cancelled, but only if it is still pending, so a paid row can never be clobbered by a late expiry event.

Email goes out through Email Lambda, the Golang SES service I open sourced after Mailgun's support annoyed me enough to build my own. Nice when your side projects start paying rent. Confirmations fan out to every attendee plus the registrant, admins get their own notification, and every single send is written to an email_logs audit table with its status, error and rendered body. All of it is scheduled with Next's after(), so Stripe gets its 200 immediately and the mail queue drains behind the response.

The back office runs on Supabase Auth with row level security that is default deny across every table. The public never reads registrant data, full stop. Admin reads go through SECURITY DEFINER predicates so the policies can check the admin roster without recursing through the roster's own policy, and trusted writes go through the service role client, which sidesteps RLS entirely. Auth is deliberately two layered: proxy.ts (Next 16 renamed middleware, which I discovered the fun way) keeps the session fresh and does a cheap optimistic bounce off /admin, then the dashboard layout, a server component, does the authoritative active and role check. Superusers manage the admin roster, everyone else just works the registrations.

Admins get the things event teams actually ask for at 11pm the night before doors open: filter and search RSVPs, drill into an individual registration, create one by hand for the minister who emailed a PDF instead of using the form, mark complimentary places, record an offline bank transfer against an RSVP, and stream RSVPs or payments out as CSV. Bulk email has a recipient picker driven by real registration state, a confirmation dialog that makes you look at the number before you hit send, and a drafting drawer wired to gpt-4o-mini under a strict JSON contract, constrained to a small tag whitelist so nobody can paste a wall of Word markup into a delegate's inbox.

My favourite bit is the least glamorous. Paid registration eventually had to pause while the programme firmed up, so the whole site pivots to interest capture on one environment variable. Flip NEXT_PUBLIC_REGISTRATION_MODE and the buttons, page copy and the server side checkout guard all move together, with expressions of interest upserting on email into their own table. No branch, no rewrite, no panic. Two months of work, 90 commits, 28 pull requests, and the thing that saved the day was a boolean.

Key Features

  • Thirteen page public site with the entire programme, themes, speakers and committee driven by typed content modules
  • Multi step delegate registration with a registrant step, unlimited guests, and a review step before payment
  • Twelve live prices from three delegate categories across four time windows, resolved in Africa/Kigali
  • Server authoritative pricing that rebuilds the basket from scratch and never trusts the client
  • Stripe Checkout with an idempotent webhook keyed on the payment intent, and retry-friendly failures
  • Expired checkout sessions that release a pending place without ever touching a paid one
  • Duplicate registration detection, bot honeypot, and a mandatory terms gate before checkout opens
  • Confirmation, pending reservation and admin notification emails through a self hosted Golang SES service
  • Full email audit log capturing subject, rendered body, status and error for every send
  • Admin dashboard with revenue and registration stats, filterable RSVPs and individual RSVP drilldowns
  • Manual RSVP creation, complimentary places, and offline payment recording for bank transfers
  • CSV export of RSVPs and payments straight out of streaming API routes
  • Bulk email with a state driven recipient picker, a send confirmation dialog, and gpt-4o-mini drafting
  • Superuser and standard admin roles with account activation toggles
  • Registration kill switch: one environment variable flips the whole site between paid checkout and interest capture

Challenges & Solutions

Keeping pricing authoritative on the server across twelve category and window combinations without a single client side price

Date boundaries that respect the Summit's own timezone instead of whichever region the deployment happens to land in

Stripe idempotency against a partial unique index, which Postgres will not accept as an ON CONFLICT arbiter

Failing loudly in the webhook so Stripe retries, rather than swallowing an error and losing a paid registration

Default deny RLS that serves both a public site and an admin console, without the policies recursing through themselves

Pausing paid registration on a live site with a single environment variable and no code branch

Next 16 moved the goalposts, middleware is proxy.ts now, so the docs in node_modules got read properly instead of guessed at

Key Learnings

Partial unique indexes and ON CONFLICT do not mix, and the lookup-then-write pattern that replaces them
Next's after() is the cleanest way to fan out email without making a payment provider wait on your SMTP
SECURITY DEFINER helper functions are the trick for RLS policies that need to read the table they protect
An optimistic auth gate plus an authoritative server component check beats trying to do both in one place
Treating conference copy as typed data instead of JSX turns weekly content churn into a one line diff
Feature flagging a whole revenue flow behind one env var is worth the twenty minutes it costs up front

Project Info

Timeline

2 months

Team

Solo build for Medicines for Africa

Status

Live

Tech Stack

Frontend

Next.jsReactTypeScriptTailwindshadcn

Data / Auth

SupabasePostgreSQLRow Level Security

Payments

StripeStripe Webhooks

Email

Email Lambda (Golang)AWS SES

AI

OpenAI

Validation

ZodReact Hook Form

Deployment

VercelGitHub Actions