Corsia
Exam prep for taxi and chauffeur drivers.
Source available on request

The problem
Candidates study from about 3,000 official questions scattered across PDFs, plus a blank-map test. There was no way to practise in the real exam format, which changes between sessions, or to see which topics they had actually mastered.
- Status
- MVP ahead of schedule, launching in December
- Stack
- Next.js 16, React 19, TypeScript, Tailwind 4, Supabase, Stripe, Resend, Python, OpenStreetMap
- Facts
- 3,137 official questions imported
- 13 migrations, about 24 tables
- Content pipeline from PDF to CSV and SVG maps
Decisions worth explaining
Answers are graded on the server
Questions reach the browser without the correct answer and with shuffled options. A database function grades each answer, records the attempt and caps the timing value. The only exception is offline packs, limited to 200 questions a day per registered device.
Exam rules are data, not code
Number of questions, minutes, pass thresholds and map letters are stored as JSON on each exam session, because the Region already changed the format once in 2026. A new format is a row, not a deploy.
Stripe webhooks are verified and idempotent
Every event has its signature checked, only paid sessions grant access, and the metadata is validated. The purchase function ignores a checkout session it has already seen, through a unique column, so Stripe’s retries can never grant access twice.
A piece of the code
The browser never knows the right answer until it has committed to one.
begin
if auth.uid() is null then
raise exception 'not signed in' using errcode = '42501';
end if;
select * into q from public.questions where id = p_question;
if not found or not private.trainable(q) then
raise exception 'question not available' using errcode = '22023';
end if;
insert into public.attempts (user_id, question_id, chosen_index, correct, ms, mode)
values (auth.uid(), q.id, p_chosen, p_chosen is not distinct from q.correct_index,
least(greatest(p_ms, 0), 3600000), p_mode);
return query select p_chosen is not distinct from q.correct_index, q.correct_index, q.explanation, q.source;
end;