Skip to content

feat(my): persist the tracker to the signed-in user's account, and record wins (#226) - #234

Draft
Jose-Gael-Cruz-Lopez wants to merge 8 commits into
mainfrom
feat/issue-226-supabase-tracker-wins
Draft

feat(my): persist the tracker to the signed-in user's account, and record wins (#226)#234
Jose-Gael-Cruz-Lopez wants to merge 8 commits into
mainfrom
feat/issue-226-supabase-tracker-wins

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Collaborator

Closes #226. Branch work by @allykeightley; opening the PR so CI runs on it.

TL;DR

The tracker and /my have been localStorage-only since they shipped. A user
who clears their cache, or opens the site in a different browser, loses their
entire pipeline — and with #226 adding wins, they'd lose those too. This moves
per-user state onto Supabase, keyed to the Clerk session, and adds the trophy
badges #226 asked for.

localStorage does not go away. It stays as the signed-out path, and as the
fallback on any deployment where Clerk or Supabase isn't configured.


Where the tracker lives now

On mount the provider asks /api/tracker whether this session has somewhere
better than the browser to put things. Three answers, all of them fine:

Response Meaning
200 { synced: false } Clerk or Supabase not configured — stay local
401 Signed out — stay local
200 { synced: true } Adopt the account's rows as the truth

It asks the route rather than reading Clerk hooks, because the provider also
mounts on deployments with no <ClerkProvider> above it, where those hooks throw.

Handover on first sign-in. A browser's existing tracker is POSTed once,
guarded by a hackhq-tracker-imported-v1 flag. The import is additive
(ignoreDuplicates: true), so an account that already tracks a hackathon keeps
the stage it has on the server — signing in on a second device can't roll your
pipeline back to whatever that browser happened to remember. Without the flag, a
later visit would resurrect rows the user had since deleted from their account.

Writes are optimistic and revert on failure. Showing a save that isn't there
is worse than a flicker, because the next visit would silently replace it with
the server's version.

Schema

public.user_hackathons — one row per (user, hackathon): the stage it sits in
and whether the user won it. user_id is the Clerk sub, matching the
submitted_by convention already on public.hackathons.

Deliberately no foreign key to public.hackathons. That table is a mirror
that the hourly sync_supabase.yml cron can leave up to an hour behind
.github/scripts/listings.json — which is what the app actually renders from.
An FK would reject saving any listing added since the last sync. The id is
validated in the app layer against the live listing set instead.

This is the part worth being explicit about, since the repo is the source of
truth for listings: it still is. listings.json owns hackathon content;
user_hackathons owns per-user state, which the repo structurally cannot hold.
They don't overlap.

Wins (#226)

Wins live in a second map rather than as a fifth stage, so the pipeline and the
passport keep working off the stage list unchanged. The badge appears everywhere
the hackathon does — deck row, detail dialog, tracker card — from one shared
component, so the gold, the size and the wording can't drift apart. It carries
both an aria-label and a title naming the hackathon, not just "won": a screen
reader working down the deck would otherwise hear the same word repeated with
nothing to attach it to.

Claiming a win also moves the hackathon to Going — a trophy on something still in
Interested wouldn't mean anything. On the passport a win takes the stamp over,
reading CHAMPION in the cover's foil gold instead of HACKED.

Auth model

lib/tracker-store.ts authenticates with the service role key, which
bypasses RLS. Ownership is enforced there instead, by the .eq("user_id", userId)
on every read, update and delete and by writing user_id explicitly on every
insert. The userId always comes from the Clerk session resolved in the route
handler — never from a request body.

The RLS policies in the migration aren't redundant: they mean anon and
authenticated can't touch the table at all, so nothing reachable with a
publishable key can read one user's tracker.

Moving enforcement back into Postgres — via Clerk's native Supabase integration,
so the browser's own session token satisfies those policies — needs a trust
relationship configured in both dashboards. Tracked as a follow-up rather than a
prerequisite for shipping.

Config

Two new server-only variables (documented in web/.env.example). Both are
optional; without them the tracker stays browser-local exactly as today.

SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=

isTrackerSyncConfigured() is deliberately Clerk-inclusive — with Supabase
configured but sign-in off there's no user id to scope a row by — and
validateEnv() warns on both half-configured cases.

These need adding to the production environment as part of #223. Uses
@supabase/supabase-js over HTTP, so it's Workers-compatible (unlike the
postgres/drizzle TCP path, which is db:* scripts only).

Open before merge

  • Migration ledger drift. public.user_hackathons is already live on the
    project and its shape matches this SQL exactly — 6 columns, 4 owner-only
    policies, correct grants — but the version isn't in
    supabase_migrations.schema_migrations, so it was applied outside
    apply_migration. That breaks the invariant supabase/migrations/README.md
    is built on. The SQL is idempotent (create table if not exists,
    drop policy if exists throughout), so re-sending it through
    apply_migration records the version without touching the schema; then
    rename the file to the version it reports back. The file's header NOTE
    ("not yet applied") needs correcting either way.
  • Focused review of lib/tracker-store.ts — service role means a missing
    .eq("user_id", …) would be a cross-user read with no database backstop.
    Four functions, all short.
  • Prod secrets into Configure and Complete Production Deployment #223.

Testing

20 new cases: lib/tracker.test.ts (13) covers the shared validation and
map/entry conversion, lib/env.test.ts (7) covers the configuration matrix.
lib/passport-stamps.test.ts extended for the win stamps. First CI run on this
branch — web-ci.yml only fires on pull_request and pushes to main, so the
lint/test/build/tsc signal starts here.

akeight and others added 8 commits July 25, 2026 08:46
The tracker has only ever lived in localStorage, so a user's pipeline was tied
to one browser and a win could not be recorded anywhere durable. #226 needs
somewhere to record wins, and both the pipeline and the win belong to the same
(user, hackathon) pair, so this models them as one owner-scoped row.

Reads and writes are the owner's only, enforced by RLS against the Clerk `sub`
in the JWT. `user_id` defaults from that claim rather than being accepted from
the client, so a caller cannot create a row it does not own.

No foreign key to hackathons on purpose: that table is an hourly mirror, while
the app renders from listings.json directly, so an FK would reject saves for
listings newer than the last sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
Gives the tracker a server to talk to. Four operations, all scoped to the Clerk
session's user id — never to a user id taken from a request body — plus a POST
that hands a browser-local tracker over additively, so signing in on a second
device cannot roll a pipeline back to whatever that browser remembered.

The stage list moves from components/hq/store.tsx to lib/tracker.ts so the route
validates against the same vocabulary the UI renders, rather than a second copy
that could drift. store.tsx re-exports it, so no call site changes.

Sync is optional like Clerk and Mapbox already are: without both Supabase
variables the route answers 200 with `synced: false`, which the client will read
as its cue to stay on localStorage. Only a signed-out caller on a configured
deployment gets a 401, because there that is a real failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
An 85-byte stub created by an `npm install` that ran from the repo root instead
of web/. There is no package.json there, so it described nothing and only
invited npm to treat the root as a project.

Co-authored-by: Cursor <cursoragent@cursor.com>
The provider now asks /api/tracker on mount whether this session has an account
to save to, and adopts its rows when it does. It asks the route rather than
reading Clerk hooks because it also mounts where no <ClerkProvider> is above it,
and those hooks throw there.

A browser's existing tracker is handed over once, on the first synced visit,
guarded by a localStorage flag — the import is additive, so without the flag a
later visit would resurrect rows the user had deleted from their account.

Wins live in a second map rather than as a fifth stage, so the pipeline and the
passport keep working off the stage list unchanged. Claiming a win also moves the
hackathon to Going: a trophy on something still sitting in Interested would not
mean anything.

Writes are optimistic and revert when the request fails. Showing a save that
isn't there is worse than a flicker, because the next visit would silently
replace it with the server's version.

Co-authored-by: Cursor <cursoragent@cursor.com>
Puts the win on screen everywhere the hackathon appears: the deck row, the detail
dialog, and its tracker card. One shared badge component rather than three
inlined icons, so the gold, the size and the wording cannot drift apart.

The badge carries both an aria-label and a title, naming the hackathon rather
than just saying "won" — a screen reader working down the deck would otherwise
hear the same word repeated with nothing to attach it to.

The control that records a win sits only on Going cards. A trophy on something
still in Interested would not mean anything, so claiming one also moves the
hackathon there.

On the passport a win takes the stamp over, reading CHAMPION in the cover's foil
gold instead of HACKED. The win is the more interesting fact about a hackathon
than the stage it reached, and the header counts wins alongside stamps and
cities. That count comes from the stamps, not from the win map, so it cannot
claim a trophy the pages have no room to show.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a large trophy over-stamp, drawn in the same rough ink-stamped style
as the passport's other visas, that lands on top of the CHAMPION stamp for
recorded wins. The generator now flags won stamps explicitly so the
renderer can layer and lift them above their neighbours.

Co-authored-by: Cursor <cursoragent@cursor.com>
…se-tracker-wins

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	web/package-lock.json
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hackhq Ready Ready Preview, Comment Jul 27, 2026 12:18am

Request Review

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Collaborator Author

Review findings — read this before merging

Posting the analysis behind opening this PR, so nobody has to re-derive it. Three
things are worth a second pair of eyes; one of them is a live database
discrepancy.

1. The table is already live, but off the migration ledger ⚠️

public.user_hackathons exists right now on project gvdhwygerbsuojwpnsgq
(RLS enabled, 0 rows), carrying the exact comment on table string from
20260725154500_user_hackathons.sql. I introspected it against the committed SQL
and it matches exactly:

cols     user_id text, hackathon_id uuid, stage text, is_win boolean,
         created_at timestamptz, updated_at timestamptz
policies delete own tracker/d, insert own tracker/a,
         read own tracker/r, update own tracker/w
grants   authenticated: SELECT/INSERT/UPDATE/DELETE
         service_role:  full   ·   anon: none

But list_migrations returns 17 versions, all 20260722*. 20260725154500
isn't among them — so the SQL was applied outside apply_migration.

Two consequences:

  • The file's header NOTE ("not yet applied") is now wrong, and it's the first
    thing the next person reads.
  • It breaks the invariant supabase/migrations/README.md is built on: "Each
    filename's timestamp prefix is the version Supabase recorded for it, so
    ls here and list_migrations there return the same list in the same order…
    a mismatch would make supabase db push replay migrations that are already
    applied."

Fix is cheap and safe. The SQL is idempotent end to end
(create table if not exists, drop policy if exists before each create policy), so re-sending it through apply_migration records the version and
leaves the existing schema untouched. Then rename the committed file to the
version it reports back, per the README's own rule, and drop the stale NOTE.

Nobody's data is at risk — the table is empty and #223 hasn't shipped yet.

2. Service role bypasses RLS — one file carries the whole guarantee

lib/tracker-store.ts uses the service role key, so ownership rests entirely on
the .eq("user_id", userId) in each of its four functions. The file is honest
about this and the design is right for shipping now. But a single missing filter
there is a cross-user read with no database backstop — the RLS policies can't
catch it, because service_role is exactly the role they don't apply to.

Given #203#211, that file specifically is worth reading line by line rather than
skimming with the rest of the diff. It's short.

Related nit: user_id text not null default auth.jwt() ->> 'sub' in the migration
is dead on this path — the app always writes user_id explicitly. Harmless, but
it reads like the database is enforcing ownership when it currently isn't. Worth
a comment saying it's there for the future browser-direct path.

Decision taken: ship service-role now, move enforcement into Postgres as a
follow-up (filed separately). It needs a trust relationship configured in both
the Clerk and Supabase dashboards, which shouldn't gate #226 or #223.

3. This branch had never run CI

web-ci.yml only triggers on pull_request and pushes to main. With no PR
open, 1,456 lines across 22 files had zero lint / test / build / tsc signal.
That's the main reason this PR exists. Check the run before reviewing content —
no point reviewing a diff that doesn't compile.


Not a problem, though it looks like one

Worth stating plainly, since "the repo is the source of truth" came up as an
objection to doing this at all:

Store Owns Read by the app?
.github/scripts/listings.json hackathon content ✅ frozen into the bundle at build
Supabase hackathons (79 rows) hourly mirror of the above ❌ nothing reads it
Supabase user_hackathons per-user state ✅ this PR

Per-user state was never something the repo could hold, so this doesn't compete
with listings.json — it fills a gap next to it. The no-FK decision documented in
the migration is the right call for exactly this reason: the mirror lags the file
by up to an hour, and an FK would reject saving any newly-added listing.

The one genuinely loose thread is unrelated to this PR: the Supabase hackathons
mirror is currently write-only — the cron populates it and nothing consumes
it. That's worth resolving, but it's a separate decision and it isn't blocking
here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Display Trophy Badges for Hackathon Wins

2 participants