SkillUp
Registering, tracking and certifying 17 tracks of people, on a free tier.
- 17
- Tracks
- 319
- Tests
- 11
- Migrations
- 38 days
- Shipped in
The problem
A free three-day training programme with seventeen tracks, twenty seats each, and a registration desk staffed by volunteers. People sign up for themselves; pastors and parents sign up groups of twenty at once; some walk in on the day with no email address at all. Every one of them needs a seat, a reminder, a record of attendance and a certificate at the end.
And it all had to run on free tiers, which turns out to be the constraint that shaped the most interesting decisions.
What I built
Registration in two modes, one for an individual and one for a submitter entering up to twenty people. Tracks fill to capacity and then overflow to a waitlist with a position. Every registrant gets a reference number issued by a Postgres trigger from a per-track sequence, confirmation email on the way in, reminders on a schedule, a check-in at the desk, and a certificate at the end.
Behind it: eleven migrations, six tables with row-level security, sixteen email templates, an admin area with fifteen pages, and 319 tests run on both pre-commit and pre-push.
Decisions I'd defend
Static content lives in source control, state lives in the database. Tracks, schedule, FAQs and facilitators are TypeScript files, so a copy edit is a pull request rather than a migration. The payoff is concrete: the WhatsApp invite link for each track is in that file specifically so a reviewer can verify it, because a wrong invite link is a real incident at an event and a reviewable diff is the cheapest control there is.
The certificate pipeline is built around a number: a hundred emails a day, which is the free plan ceiling. Certificates are queued by track, split across consecutive days under a per-day cap, and sent by a daily job that selects only rows scheduled in the past with no sent timestamp. That query is the idempotency guarantee. Each row carries its own status, error and attempt count, so a failure retries tomorrow without resending everything.
Lagos time is a first-class concern rather than a formatting detail. The server renders in UTC, so a naive date would show the wrong day around midnight, and the check-in boundary is a calendar day. Every display and every day boundary is pinned to Lagos, and the registration window is derived from environment timestamps so the event can be retimed without a deploy.
The reference number embeds the track code, so when someone switches track the number has to be reissued. Rather than patching around it, the trigger was extended to null and regenerate on a track change, on the reasoning that sequences are monotonic and a regenerated number can never collide with an abandoned one.
What went wrong
I shipped an hourly certificate cron for tighter batching and discovered in production that the hosting plan only allows daily crons. The fix was to redesign around a daily job as backstop plus a manual "send the due batch now" button, which is a better design anyway because it puts a human in the loop on the day it matters.
A check constraint blocked a real workflow mid-programme: admins registering walk-ins at the desk were writing a value the original constraint did not permit, and a second migration hours later made phone optional too, because at a desk you sometimes have neither email nor phone. Two schema changes driven by what actually happened at the table.
I also built email confirmation codes for destructive admin actions, then deleted the feature before launch in favour of plain confirmation modals. The migration that created its table stayed in place and a later one dropped the table, rather than editing history, because the first migration had already run in production.
What I would do differently
Row-level security is enabled on every table, but every public write runs through a server action using the service role, so in practice the schema validation is the real gate and the policies are defence in depth against someone talking to the database directly. That is a reasonable architecture, but I would not describe the policies as the authorization layer, because they are not.
The group-registration path has no duplicate check. A submitted registrant whose email already exists hits the unique constraint, gets logged, and is skipped silently, so they vanish from the summary email with nothing surfaced to the submitter. Capacity is also checked and then written without a transaction, so concurrent submissions can overshoot a cap.
Observability is structured logging and nothing else, and some of those log lines carry registrant emails for a youth programme. That is the thing I would fix first.
Where it is now
Live, and it carried the real event: tracks filled to capacity, and six commits went out over the two days the programme was running.