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
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.


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.
- client
- service
- database or storage
- queue or stream
- external
- request
- async: event, message, webhook
- telemetry
- The web app reads and writes data through the Supabase client over HTTP. Postgres applies row-level security to every query.
- When a team registers, a Vercel function creates a Stripe Checkout session.
- The team pays on Stripe's hosted page, so card data never touches CaseComp.
- Stripe calls our webhook handler.
- The handler verifies the signature, skips events it has already processed, and marks the registration paid.
- It sends a receipt through Resend.
- 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.
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.
- client
- database or storage
- queue or stream
- request
- async: event, message, webhook
- failure path
- Judge 1 writes their score for a submission and criterion.
- Judge 2 writes theirs. It's a different row, so there's nothing to conflict with.
- Postgres emits the change to Supabase Realtime.
- 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.
| Role | Competitions | Registrations | Submissions | Scores |
|---|---|---|---|---|
| Organizer | Create and edit their own | Read, for their competitions | Read, for their competitions | Read, for their competitions |
| Team member | Read published ones | Their own team's | Their own team's | Their own, once published |
| Judge | Read assigned ones | None | Read, if assigned | Write their own rows only |
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.
- client
- service
- database or storage
- external
- request
- async: event, message, webhook
- The team registers for a competition.
- A Vercel function creates a Stripe Checkout session.
- The team pays on Stripe's hosted page.
- Stripe sends a checkout.session.completed webhook.
- The handler verifies the signature.
- A new event ID gets recorded in payments, and the registration is marked paid.
- 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
| Decision | Instead of | Why |
|---|---|---|
| Row-level security in Postgres | Filters in app code only | One forgotten WHERE clause can't leak another team's data |
| Event-ID dedupe in the same transaction | Check, then insert | No race between two deliveries of one event |
| Stripe's hosted checkout | Taking card details ourselves | Card data never touches our servers |
| Indexes from EXPLAIN ANALYZE | Adding a cache | Fix the cause first; nothing to invalidate |
| OpenTelemetry tracing | Ad hoc logging | Slow pages show up with a cause attached |
Results
- users
- 2,000+
- universities
- 10+
- judging query
- 1.8s→240ms
- duplicate payments
- 0
- card data stored
- none