At a glance
- What it is: the website and operating platform for The Benin Chorale & Philharmonic, a choral and orchestral music society in Benin City, Nigeria. It handles ticketing, donations, auditions, membership, attendance, publishing, and all of the society's email.
- My role: I'm a member of the society. Nobody asked me to build it. I saw a problem I could solve and built the platform as a volunteer, then kept maintaining it. I was later appointed the society's Director of ICT, Training & Research.
- Where it is now: live, with 1,000+ tickets sold, 5,000+ registrations, 100+ members, and an admin team of 3 to 5 people using it to run the society.
- The constraint: it runs entirely on free tiers. The only thing the society pays for is the domain.
The problem
Before the platform, the society ran on a patchwork: WhatsApp for announcements, Google Forms for registrations, paper registers for rehearsal attendance, and bank transfers for tickets, which someone then had to match up by hand.
It worked, but only because a few people spent a lot of time holding it together. Nothing was searchable, payments were hard to reconcile, and the society was nearly invisible online.
The other side of the problem was money. A volunteer-run music society has no budget for software, so every tool had to be free, or it wouldn't happen.
From a brochure site to a platform
I didn't set out to build all of this at once.
Version 1 (early 2025) was a simple website built with Vite and React: who the society is, its performances, its leadership. It gave the society a real presence online.
Version 2 (from September 2025) was a rewrite in Next.js, for two reasons:
- Search. Server-rendered pages rank far better than a single-page app that needs extra tooling to be understood by search engines.
- Secrets. Selling tickets means verifying payments, and that has to happen on a server with secret keys the browser never sees. Next.js gave me a server without running a separate backend.
From there, the platform grew one real need at a time: ticketing, then internal events and auditions, then a member portal, then attendance, then communications.
What the platform does
- Public site: home, about, board and leadership pages, performances, events, articles and poetry, and a members directory, with structured data and a sitemap generated from the database.
- Events and ticketing: free and paid events, ticket categories, coupons, and members-only events, with Paystack payments verified on the server. Organisers get PDF and CSV attendee lists.
- Auditions: registration with photo uploads and time slots, plus exportable rosters.
- Donations: Paystack donations, including anonymous ones, with an admin dashboard.
- Membership: sign-up, admin approval, membership tiers, promotions, roles, and closing accounts. Leadership pages are generated from members' roles, so there's one source of truth.
- Publishing: members write articles and poetry in a rich-text editor. Posts go through review before they're published, and readers can like and comment.
- Attendance: executives take the rehearsal register, members see their own history, and admins get monthly and yearly reports.
- Internal processes: the society's internal assessment process runs on the platform too, with slot booking, scoring, and automatic reminders.
- Admin CMS: admins edit page content through structured forms, without touching code.
- Communications: a contact inbox with two-way email threads, newsletters, and a log of every email the platform sends.
- Grants: a scanner that collects arts-funding opportunities from several sources and emails them to the admins.
Sending email when the app couldn't
The problem
At first, the app called the email provider (Resend) directly after events such as a member's approval. On slow or unstable connections, those calls timed out and the email silently failed. Even retrying three times with increasing delays didn't fix it.
The fix: send from the database
I moved email out of the app and into Postgres. Now the app only writes a row, for example "this member was approved". A database trigger builds the email and sends it through pg_net, Supabase's built-in HTTP extension. The provider's key lives in Supabase Vault, not in the app's settings.
The upside is that an email is attempted whenever the data changes, no matter what caused the change: the app, an admin, or another part of the system.
Knowing whether it actually sent
pg_net sends requests in the background, and its responses are kept for only about six hours. So I built an email log. Every email now goes through one database function that records it as queued. A scheduled job then checks the responses every five minutes and marks each email as sent, failed, or unknown. That function never throws an error, so a failed email can never undo the action that triggered it.
Admins can see every email the platform has sent, and whether it arrived, in one place.
A newsletter on 100 free emails a day
The email provider's free tier allows 100 emails a day, shared with every other email the platform sends. A newsletter to every subscriber can't go out in one burst without blocking approvals and ticket confirmations.
So "send" doesn't send. It queues:
- Sending a newsletter creates one delivery row per subscriber. It's safe to run twice without anyone getting a duplicate.
- A scheduled job runs every 15 minutes and sends only as many as the day's remaining budget allows, after counting everything else already sent that day. Transactional email always has room.
- Deliveries are claimed with
FOR UPDATE SKIP LOCKED, so two overlapping runs can never send the same email twice. - Failed deliveries are retried up to three times, and anyone who unsubscribes mid-campaign is skipped.
The campaign that never finished
Some newsletters stayed on "sending" forever. There were three causes: status updates depended on the 15-minute schedule; some responses expired before they were checked; and when a subscriber was deleted, the final roll-up found no rows and never closed the campaign.
The fix was a settling step that runs before and after every queue pass and whenever an admin opens the page. Deliveries still unresolved after an hour count as sent, and the roll-up no longer depends on those rows existing.
Two-way email, without a paid inbound service
Admins reply to contact messages from the platform, and the society wanted the visitor's answer to land back in the same thread.
- Each reply goes out with a unique reply address:
reply+<message-id>@the society's domain. - A Cloudflare Email Worker catches mail sent to those addresses and forwards it to the platform.
- The platform checks a shared secret and that the sender matches the original conversation, so guessing an ID isn't enough to inject a message. It strips quoted history from Gmail, Outlook, and Apple Mail, and saves the reply to the thread. A repeated delivery is simply ignored.
- If anything fails, the Worker forwards the email to the society's Gmail instead, so a reply is never lost.
Booking slots without double-booking
The internal assessment process lets members book a slot on a given day. When several people book at once, two of them must never get the same slot.
Booking runs in a single Postgres function that takes a lock for that date, picks the lowest free slot, and records it. Unique indexes act as a safety net: one member per slot, and one active booking per person. The function checks every rule itself: the right day, not in the past, before the deadline, and that the booking belongs to the member.
Auditing my own work
I regularly audit the projects I'm actively working on for security issues, outdated dependencies, and anything that needs cleaning up. On BCS, one audit found problems from the early days, when I was moving fast:
- The first admin login used a password stored in a public environment variable. Next.js puts those into the browser's code, so the password was effectively public. I replaced it with Supabase Auth and role checks on every admin route.
- Ticket verification trusted the amount sent by the browser. A buyer could have paid less than the ticket price. Now the server looks up the real price, applies the coupon rules itself, compares the result with what Paystack actually charged, and rejects a payment reference that's already been used.
- Database access policies were too open. Anyone with the public API key could have read ticket buyers' details or inserted fake tickets, every coupon code was readable, and authors could publish their own articles without review. I locked all of that down, including hiding individual sensitive columns.
- File storage accepted uploads from anyone. All uploads now go through one server route that checks who's uploading, enforces size limits, and verifies each file's type from its actual bytes.
None of these were exploited. I found and fixed them before anyone else did, and that's why I keep auditing.
Fixing search visibility
An SEO audit turned up problems that were quietly hurting the site:
- One layout rendered its own
<html>and<body>inside the main one, so every page had a nested document. - Every page declared the homepage as its canonical version, which told search engines to treat all of them as duplicates of the homepage.
- An old leftover route exposed members-only events.
I fixed the layout, gave each page its own canonical URL, removed the old route, marked internal events as noindex, and generated the sitemap from the database.
How I work
I use AI tools throughout: to plan work, to review changes, and to verify them. I own the architecture, the decisions, the debugging, and what ships, and I properly review any code I didn't write myself before it's merged.
Results
- 1,000+ tickets sold and 5,000+ registrations processed through the platform.
- 100+ members and an admin team of 3 to 5 people run the society on it.
- WhatsApp coordination, Google Forms, paper registers, and manual payment matching are replaced by one system.
- It runs entirely on free tiers. The only cost is the domain.
What I'd do differently
- Security rules first. I tightened the database access policies after the fact. Designing them from day one would have avoided the audit fixes entirely.
- Tests from the start. The platform relies on careful manual testing on preview deployments. Automated tests would make changes safer.
- A migration tool. Database changes are hand-written SQL scripts, run in order by hand. A proper migration tool would track what's been applied.
- One path for every email. Newer emails go through the logged path, but some older triggers still send directly and don't appear in the log. I'm moving them over.
