Skip to content
All work

Corsia

Exam prep for taxi and chauffeur drivers.

Open the live demo

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.

supabase/migrations/…_accounts_training.sql
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;
Next projectSconti Nova