← Projects

CaseComp.ca

A platform for running university case competitions end to end, from registration to live judging.

2,000+
users
10+
universities
240ms
slow query, down from 1.8s

Founding engineer, team project

React, TypeScript, Supabase, PostgreSQL, Stripe, Resend, Vercel, Cloudflare, OpenTelemetry

casecomp.ca ↗

What it is

CaseComp.ca runs university case competitions end to end. Organizers create a competition and set a price. Teams register and pay, then submit their cases. Judges score live while organizers watch the results come in, and then the results get published.

It has 2,000+ users across 10+ universities. We built it as a team.

CaseComp.ca landing page with a search for case competitions and three featured competitions
The landing page.
CaseComp.ca competitions page with filters for education level, country and status next to a list of competitions
Browsing competitions, with filters on the left.

Why we built it

University case competitions were being run on forms and spreadsheets: registration in one place, payments in another, judges' scores in a shared sheet. We put all of it, including live scoring, in one app.

Architecture

The frontend is React and TypeScript on Vercel. Supabase gives us Postgres, auth and realtime channels. The two things that need the Stripe secret key, creating a checkout session and handling Stripe's webhooks, run as Node.js functions on Vercel, because that code has to stay on the server.

CaseComp.ca architecture
CaseComp.ca architectureThe React web app reads and writes through the Supabase client, with row-level security in Postgres. To pay, a Vercel function creates a Stripe Checkout session and the team pays on Stripe's hosted page. Stripe calls a webhook handler on Vercel that verifies the signature, skips events it has already processed, marks the registration paid in Postgres and sends a receipt through Resend. Score changes stream back to the organizer over Supabase Realtime. Cloudflare serves static assets, and requests are traced with OpenTelemetry.Vercel functions (Node.js)SupabaseThird partiesWeb appReact · TypeScriptOpenTelemetrytracesCreate checkoutStripe sessionWebhook handlerverify, then dedupeduplicate_payments=0Supabase AuthPostgresrow-level security on every tablejudging_query=1.8s→240msRealtime channelslive scoresCloudflare CDNstatic assetsStripe Checkouthosted payment pagecard_data_stored=noneResendemailsign inHTTP · Supabase client1HTTP2redirect to pay3webhook45receipt email6live scores7traces
  • client
  • service
  • database or storage
  • queue or stream
  • external
  • request
  • async: event, message, webhook
  • telemetry
  1. The web app reads and writes data through the Supabase client over HTTP. Postgres applies row-level security to every query.
  2. When a team registers, a Vercel function creates a Stripe Checkout session.
  3. The team pays on Stripe's hosted page, so card data never touches CaseComp.
  4. Stripe calls our webhook handler.
  5. The handler verifies the signature, skips events it has already processed, and marks the registration paid.
  6. It sends a receipt through Resend.
  7. Score changes stream to the organizer's dashboard over Realtime channels.

How the frontend talks to the backend: the Supabase TypeScript client turns a call like supabase.from('competitions').select(...) into an HTTP request. Postgres runs the query with row-level security applied and sends back JSON. There's no hand-written authorization layer; row-level security policies decide who can see what.

The data model is small and normalized, with foreign keys and unique constraints doing a lot of the work. Every change ships as a versioned migration, so there's always a way back.

CaseComp.ca data model
CaseComp.ca data modelUsers organize competitions. Competitions have many registrations, submissions and judge assignments. Teams have many registrations and submissions. Each registration has one payment. Scores belong to one judge assignment and one submission, with one row per criterion.1N1N1N1N1N111N1N1Nusersid pkemailnamecompetitionsid pkorganizer_id fktitlepricejudgesid pkuser_id fkcompetition_id fkscoresjudge_id fksubmission_id fkcriterionvalueteamsid pknameregistrationsid pkteam_id fkcompetition_id fkpaidpaymentsid pkregistration_id fkstripe_txn_idsubmissionsid pkteam_id fkcompetition_id fksubmitted_at
The core tables, simplified.

The interesting part: live judging

Live judging was the hardest feature. Several judges score at the same time while organizers watch the totals move, and nobody can be allowed to overwrite anyone else.

Each score is written to Postgres first, because the database is the source of truth. Supabase Realtime then broadcasts the change, and the organizer's dashboard updates without a refresh.

The concurrency problem mostly disappears with the right shape of data. A score row is one judge, one submission and one criterion, so two judges never write the same row. Row-level security makes sure a judge can only write scores for submissions they're assigned to.

Live judging
Live judgingTwo judges write their own scores to Postgres. Each row is one judge, one submission and one criterion, so they never write the same row. Postgres is the source of truth: the change goes to Supabase Realtime, which pushes the new score to the organizer's dashboard without a refresh. If a judge tries to write another judge's row, row-level security blocks it.Judge 1Judge 2PostgresRealtimeOrganizerwrite my score1write my score2one row per judge × submission × criterionchange3new score4updates, no refreshwrite Judge 1's rowblocked by row-level security
  • client
  • database or storage
  • queue or stream
  • request
  • async: event, message, webhook
  • failure path
  1. Judge 1 writes their score for a submission and criterion.
  2. Judge 2 writes theirs. It's a different row, so there's nothing to conflict with.
  3. Postgres emits the change to Supabase Realtime.
  4. Realtime pushes the new score to the organizer, whose dashboard updates live.

When it fails

  • A judge who tries to write another judge's row is blocked by row-level security.

The same row-level security rules are the whole security model. Teams, judges and organizers only ever see their own data, so calling the API directly can't leak another team's submission or scores.

RoleCompetitionsRegistrationsSubmissionsScores
OrganizerCreate and edit their ownRead, for their competitionsRead, for their competitionsRead, for their competitions
Team memberRead published onesTheir own team'sTheir own team'sTheir own, once published
JudgeRead assigned onesNoneRead, if assignedWrite their own rows only
Who can see and change what. Enforced in Postgres, not in the app.

Payments and duplicate webhooks

Teams pay on Stripe's hosted checkout page, so card data never touches our servers. When the payment goes through, Stripe tells us with a webhook.

Stripe can deliver the same webhook more than once. If the handler isn't careful, that's a double registration or a double charge. So the handler verifies Stripe's signature, then checks the event ID against the events it has already processed. A repeat gets a 200 and changes nothing.

The event ID is stored under a unique constraint, in the same transaction that marks the registration paid, so recording the event and applying it succeed or fail together. Two deliveries arriving at the same moment can't both get through, and replaying any event is always safe.

Registration and payment
Registration and paymentThe team registers and the checkout function creates a Stripe session. The team pays on Stripe's hosted page. Stripe sends a completed event to the webhook handler, which verifies the signature, checks whether the event ID was already processed, records the payment, marks the registration paid and sends a confirmation email. When Stripe delivers the same event again, the handler sees the ID, returns 200 and changes nothing.BrowserCheckoutStripeWebhookPostgresResendregister1create session2hosted payment pagepay (card stays at Stripe)3session completed4verify signature5event ID seen?nosave payment, mark paid6confirmation email7same event againevent ID seen?yesduplicate: return 200, change nothing
  • client
  • service
  • database or storage
  • external
  • request
  • async: event, message, webhook
  1. The team registers for a competition.
  2. A Vercel function creates a Stripe Checkout session.
  3. The team pays on Stripe's hosted page.
  4. Stripe sends a checkout.session.completed webhook.
  5. The handler verifies the signature.
  6. A new event ID gets recorded in payments, and the registration is marked paid.
  7. A confirmation email goes out through Resend.

When it fails

  • A webhook with an invalid signature is rejected.
  • A duplicate delivery is recognized by its event ID and ignored.
  • If processing fails, Stripe retries the delivery later.

Performance

The judging-round query was slow. EXPLAIN ANALYZE showed a sequential scan, so I added an index on the columns it filters by, and it went from 1.8 seconds to 240 milliseconds.

List endpoints are paginated and their filter columns are indexed, so response times held as the data grew. Cloudflare absorbs the traffic spikes right before a competition opens.

OpenTelemetry traces the API and database calls, so if a page gets slow again, the trace shows which query or call is responsible.

Key decisions

DecisionInstead ofWhy
Row-level security in PostgresFilters in app code onlyOne forgotten WHERE clause can't leak another team's data
Event-ID dedupe in the same transactionCheck, then insertNo race between two deliveries of one event
Stripe's hosted checkoutTaking card details ourselvesCard data never touches our servers
Indexes from EXPLAIN ANALYZEAdding a cacheFix the cause first; nothing to invalidate
OpenTelemetry tracingAd hoc loggingSlow pages show up with a cause attached
The main choices, and what they replaced.

Results

users
2,000+
universities
10+
judging query
1.8s→240ms
duplicate payments
0
card data stored
none