Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
211ed51
Add user_hackathons table for per-user tracker state
akeight Jul 25, 2026
a7a8bf3
Add /api/tracker for reading and writing a user's pipeline
akeight Jul 25, 2026
b7f323b
Remove a stray root package-lock.json
akeight Jul 25, 2026
970075b
Sync the tracker to the signed-in user's account, and record wins
akeight Jul 25, 2026
e1d71fd
Show trophy badges for recorded hackathon wins
akeight Jul 25, 2026
060f2b6
Overlay a gold trophy stamp on won hackathons
akeight Jul 26, 2026
fed2721
Merge remote-tracking branch 'origin/main' into feat/issue-226-supaba…
akeight Jul 26, 2026
cc3b80d
remove extra in progress badge
akeight Jul 26, 2026
d6b1add
test(#226): assert tracker-store scopes every query to the caller
akeight Jul 26, 2026
df2efe7
fix(vercel): use Node-runtime proxy.ts instead of Edge middleware.ts
akeight Jul 26, 2026
6eb3793
chore: trigger Vercel production deploy on vercel branch
akeight Jul 26, 2026
b894487
docs(#226): record user_hackathons migration as applied via SQL Editor
akeight Jul 26, 2026
ae7b930
fix(#226): revoke Supabase default grants from anon on user_hackathons
akeight Jul 26, 2026
3e071fa
chore: redeploy to pick up Supabase env
akeight Jul 26, 2026
95c3ba2
Merge remote-tracking branch 'origin/main' into vercel
Jose-Gael-Cruz-Lopez Jul 27, 2026
00630ba
docs(#223): retarget deployment to Vercel and finish the proxy.ts rename
Jose-Gael-Cruz-Lopez Jul 27, 2026
da8baa5
build: pin the Turbopack workspace root to web/
Jose-Gael-Cruz-Lopez Jul 27, 2026
e333148
Merge branch 'main' of https://github.com/Jose-Gael-Cruz-Lopez/hackhq…
akeight Jul 28, 2026
e4f3e27
fix(#223): keep auth as Edge middleware so Workers Builds passes
Jose-Gael-Cruz-Lopez Aug 1, 2026
9a19fb3
Merge remote-tracking branch 'origin/main' into sync/vercel-with-main
Jose-Gael-Cruz-Lopez Aug 10, 2026
92efb51
fix(tracker): make a partial tracker update atomic
Jose-Gael-Cruz-Lopez Aug 10, 2026
6b10a43
fix(tracker): don't mark a failed handover as imported
Jose-Gael-Cruz-Lopez Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ deadline_proposals.md

# node
node_modules/

# There is no npm project at the repo root — the app is web/. Running npm here by
# mistake drops an empty stub lockfile, which Next then treats as a candidate
# workspace root and resolves the build against (it has picked $HOME before).
# One was committed by accident already; ignore it so it cannot happen again.
/package-lock.json
92 changes: 92 additions & 0 deletions supabase/migrations/20260725154500_user_hackathons.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
-- supabase/migrations/20260725154500_user_hackathons.sql
-- NOTE: applied by hand through the Supabase SQL Editor on 2026-07-26, because
-- this project has no Supabase CLI or MCP `apply_migration` configured. That
-- path records nothing in `supabase_migrations.schema_migrations`, so unlike
-- every other file here this timestamp is NOT a recorded version — it will not
-- appear in `list_migrations`, and `supabase db push` would try to replay it if
-- a CLI is ever wired up. See README for the divergence.
--
-- The tracker's per-user pipeline, moved off localStorage. One row per
-- (user, hackathon): which stage it sits in, and whether the user won it (#226).
--
-- `user_id` is the Clerk `sub`, matching the `submitted_by` convention on
-- public.hackathons. It defaults from the JWT rather than being sent by the
-- client, so a caller cannot write a row owned by someone else even before the
-- policies below are consulted.
--
-- Deliberately NO foreign key to public.hackathons. That table is a mirror the
-- hourly sync can leave up to an hour behind .github/scripts/listings.json,
-- which is what the app actually renders from — an FK would reject a save for
-- any listing added since the last sync. The id is validated in the app layer
-- against the live listing set instead.

create table if not exists public.user_hackathons (
user_id text not null default auth.jwt() ->> 'sub',
hackathon_id uuid not null,
stage text not null default 'interested',
is_win boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (user_id, hackathon_id),
constraint user_hackathons_stage_check
check (stage in ('interested', 'applied', 'accepted', 'going'))
);

-- `origin` on public.hackathons is text + check for the same reason: adding a
-- stage means dropping and recreating one constraint, not altering a pg enum.

comment on table public.user_hackathons is
'Per-user hackathon tracker: pipeline stage and win flag. user_id is the Clerk sub.';

alter table public.user_hackathons enable row level security;

-- Reads and writes are the owner's only. `(select auth.jwt())` is wrapped so
-- Postgres evaluates the claim once per statement instead of once per row —
-- the unwrapped form is what Supabase's performance advisor flags.

drop policy if exists "read own tracker" on public.user_hackathons;

create policy "read own tracker"
on public.user_hackathons for select
to authenticated
using ((select auth.jwt() ->> 'sub') = user_id);

drop policy if exists "insert own tracker" on public.user_hackathons;

create policy "insert own tracker"
on public.user_hackathons for insert
to authenticated
with check ((select auth.jwt() ->> 'sub') = user_id);

drop policy if exists "update own tracker" on public.user_hackathons;

create policy "update own tracker"
on public.user_hackathons for update
to authenticated
using ((select auth.jwt() ->> 'sub') = user_id)
with check ((select auth.jwt() ->> 'sub') = user_id);

drop policy if exists "delete own tracker" on public.user_hackathons;

create policy "delete own tracker"
on public.user_hackathons for delete
to authenticated
using ((select auth.jwt() ->> 'sub') = user_id);

-- Explicit grants, matching 20260722190741: Supabase's stock bootstrap is not
-- relied on, so a replay onto a fresh database produces a usable table.
-- `anon` gets nothing — a tracker has no public read.
grant select, insert, update, delete on public.user_hackathons to authenticated;
grant all on public.user_hackathons to service_role;

-- Supabase's stock bootstrap grants every new public table to anon and
-- authenticated via ALTER DEFAULT PRIVILEGES. A per-user tracker must not
-- inherit that, so undo it: anon gets nothing at all, and authenticated keeps
-- only the row DML the policies above gate. TRUNCATE in particular is NOT
-- subject to RLS, so it must never linger on an API role. Mirrors the intent of
-- 20260722154244 for the hackathons table.
revoke all on public.user_hackathons from anon;
revoke truncate, references, trigger on public.user_hackathons from authenticated;

-- Listing a user's tracker is the only read pattern; the primary key already
-- serves it (user_id leads), so no extra index is created here.
73 changes: 73 additions & 0 deletions supabase/migrations/20260810064325_atomic_tracker_upsert.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
-- supabase/migrations/20260810064325_atomic_tracker_upsert.sql
-- NOT YET APPLIED. Like 20260725154500_user_hackathons.sql, this project has no
-- Supabase CLI wired up, so this file has to be run by hand through the SQL
-- Editor before the code that calls it ships. tracker-store.ts calls this
-- function; until it exists, every PUT /api/tracker fails. See #254 for the
-- wider drift between these files and the live schema.
--
-- Makes a partial tracker update atomic.
--
-- upsertTrackerRow used to read the row, merge the patch in JS, then write the
-- merged result back. That read-modify-write leaves a window: two concurrent
-- PUTs for the same (user, hackathon) both read the same snapshot, and the
-- second write clobbers the first.
--
-- A: PUT {stage:"applied"} reads {interested, win:true} -> writes {applied, win:true}
-- B: PUT {isWin:false} reads {interested, win:true} -> writes {interested, win:false}
-- net: A's stage change is lost
--
-- The store's contract is that "moving a hackathon between stages must not
-- clear its win, and recording a win must not reset its stage". That held
-- single-threaded and broke under concurrency — two quick clicks on different
-- controls for one hackathon is all it takes.
--
-- Collapsing the whole thing into one statement closes the window: the
-- coalesce reads the *existing* row inside the same ON CONFLICT DO UPDATE that
-- writes it, so there is nothing for a competing statement to interleave with.
-- A null argument means "leave this column alone", which is how an omitted
-- field in the patch is expressed.
--
-- security invoker, matching every other function here: today the caller is
-- service_role and RLS is bypassed either way, but once #235 moves the client
-- onto the user's Clerk token the policies on user_hackathons apply to this
-- insert/update as well. A security definer function would quietly opt out of
-- exactly the enforcement #235 is trying to gain.

create or replace function public.upsert_tracker_row(
p_user_id text,
p_hackathon_id uuid,
p_stage text default null,
p_is_win boolean default null
)
returns table (stage text, is_win boolean)
language sql
security invoker
set search_path = ''
as $$
insert into public.user_hackathons as t (user_id, hackathon_id, stage, is_win, updated_at)
values (
p_user_id,
p_hackathon_id,
-- First insert: fall back to the same defaults the column would have used.
coalesce(p_stage, 'interested'),
coalesce(p_is_win, false),
now()
)
on conflict (user_id, hackathon_id) do update
set stage = coalesce(p_stage, t.stage),
is_win = coalesce(p_is_win, t.is_win),
updated_at = now()
returning t.stage, t.is_win;
$$;

comment on function public.upsert_tracker_row(text, uuid, text, boolean) is
'Atomic partial upsert of one tracker row. A null stage or is_win leaves that column at its stored value.';

-- Grants mirror 20260725154500: anon gets nothing, authenticated gets the call
-- (gated by the table policies once #235 lands), service_role keeps it for the
-- current server-side path. `from public` is revoked first because Postgres
-- grants EXECUTE to PUBLIC on every new function by default.
revoke execute on function public.upsert_tracker_row(text, uuid, text, boolean) from public;
revoke execute on function public.upsert_tracker_row(text, uuid, text, boolean) from anon;
grant execute on function public.upsert_tracker_row(text, uuid, text, boolean) to authenticated;
grant execute on function public.upsert_tracker_row(text, uuid, text, boolean) to service_role;
27 changes: 27 additions & 0 deletions supabase/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ top saying so and saying how:
| `20260722145817_rls_policies.sql` | executable SQL — gained four `drop policy if exists` lines |
| `20260722144205_add_deck_columns.sql` | comments only — a stale claim about enforcement, corrected |

One file was **applied by hand**, not through `apply_migration`, so it is absent
from `list_migrations` and `ls` here returns one more entry than the recorded
ledger does:

| file | state |
| --- | --- |
| `20260725154500_user_hackathons.sql` | applied via the Supabase SQL Editor on 2026-07-26; never sent to `apply_migration` |

This project has no Supabase CLI or MCP configured, so the migration was run
directly in the dashboard. That records nothing in
`supabase_migrations.schema_migrations`, so its timestamp stays a placeholder
rather than a recorded version, the two lists do not align, and `supabase db
push` would try to replay it if a CLI is ever wired up. If you later adopt the
CLI/MCP, reconcile by inserting the version into the ledger (or re-running it
through `apply_migration` against a fresh database) rather than trusting the
filename alone.

The three SQL divergences all exist so the chain replays cleanly onto a fresh
database. That makes the files the runnable artefact and the recorded statements
the historical record; they are not interchangeable, and where they disagree the
Expand All @@ -54,6 +71,16 @@ names, so renaming them is a breaking change for no benefit.

**`host` is `company_name` renamed**, done by `build_row` in that same script.

**`user_hackathons` has no foreign key to `hackathons`.** It is the per-user
tracker (stage + win flag) added for #226, keyed on the Clerk `sub` like
`submitted_by`. An FK would look obvious and be wrong: this table's `hackathon_id`
comes from `listings.json`, which the app renders from directly, while
`hackathons` trails it by up to an hour — so a user saving a listing added since
the last sync would hit a constraint violation for a listing that plainly exists
on screen. The id is checked against the live listing set in the app layer
instead, and `user_id` defaults from `auth.jwt()` rather than being sent by the
client, so the owner cannot be spoofed even before the policies are consulted.

## The two write paths

`listings.json` reaches this table through `.github/workflows/sync_supabase.yml`,
Expand Down
8 changes: 8 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ NEXT_PUBLIC_MAPBOX_TOKEN=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=

# Supabase — optional; both are required to persist trackers and wins to an
# account instead of the browser. Server-only on purpose: the service role key
# must never carry a NEXT_PUBLIC_ prefix. Clerk must be configured too, or there
# is no user to attribute a saved hackathon to.
# Project URL and service_role key: Supabase dashboard -> Project Settings -> API.
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=

# Drizzle/Supabase Postgres — only needed when running npm run db:* scripts.
# Use the direct or pooled Postgres connection string from Supabase.
DATABASE_URL=
Loading
Loading