From 211ed5154b17ad6b4d66e84313dd9b8dd54f260f Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 08:46:32 -0700 Subject: [PATCH 01/18] Add user_hackathons table for per-user tracker state 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 --- .../20260725154500_user_hackathons.sql | 80 +++++++++++++++++++ supabase/migrations/README.md | 22 +++++ web/db/schema.ts | 27 +++++++ 3 files changed, 129 insertions(+) create mode 100644 supabase/migrations/20260725154500_user_hackathons.sql diff --git a/supabase/migrations/20260725154500_user_hackathons.sql b/supabase/migrations/20260725154500_user_hackathons.sql new file mode 100644 index 0000000..cf62223 --- /dev/null +++ b/supabase/migrations/20260725154500_user_hackathons.sql @@ -0,0 +1,80 @@ +-- supabase/migrations/20260725154500_user_hackathons.sql +-- NOTE: not yet applied. Every other file here is named after the version +-- `apply_migration` recorded, per README. Rename this one to the version that +-- call reports before treating the two lists as aligned. +-- +-- 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; + +-- 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. diff --git a/supabase/migrations/README.md b/supabase/migrations/README.md index 1a625f7..800c82d 100644 --- a/supabase/migrations/README.md +++ b/supabase/migrations/README.md @@ -28,6 +28,18 @@ 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 is **committed but not yet applied**, so for now `ls` here returns one +more entry than `list_migrations` does: + +| file | state | +| --- | --- | +| `20260725154500_user_hackathons.sql` | written, never sent to `apply_migration` | + +Its timestamp is therefore a placeholder rather than a recorded version. Send it +through `apply_migration`, rename the file to the version that call reports, then +delete this section — until that happens the two lists do not align, and +`supabase db push` would try to replay it. + 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 @@ -54,6 +66,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`, diff --git a/web/db/schema.ts b/web/db/schema.ts index e5ad954..109cb40 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -4,7 +4,9 @@ import { doublePrecision, integer, pgTable, + primaryKey, text, + timestamp, uuid, } from "drizzle-orm/pg-core"; @@ -30,3 +32,28 @@ export const hackathons = pgTable("hackathons", { lng: doublePrecision("lng"), geoStatus: text("geo_status"), }); + +/** + * Per-user tracker rows: which stage a hackathon sits in for one user, and + * whether they won it (#226). `userId` is the Clerk `sub`, matching the + * `submittedBy` convention above. + * + * There is intentionally no foreign key to `hackathons` — that table is an + * hourly mirror of `listings.json`, and the app renders from the JSON, so an FK + * would reject saves for listings newer than the last sync. The authoritative + * definition (defaults, RLS, grants) is + * `supabase/migrations/20260725154500_user_hackathons.sql`; this mirrors it for + * Drizzle's benefit and does not create the policies. + */ +export const userHackathons = pgTable( + "user_hackathons", + { + userId: text("user_id").notNull(), + hackathonId: uuid("hackathon_id").notNull(), + stage: text("stage").notNull().default("interested"), + isWin: boolean("is_win").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [primaryKey({ columns: [table.userId, table.hackathonId] })], +); From a7a8bf3f7af335c7843dfa4ca541717721933622 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 08:52:40 -0700 Subject: [PATCH 02/18] Add /api/tracker for reading and writing a user's pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package-lock.json | 6 ++ web/.env.example | 8 ++ web/app/api/tracker/route.ts | 163 +++++++++++++++++++++++++++++++++++ web/components/hq/store.tsx | 39 +++------ web/lib/env.test.ts | 101 ++++++++++++++++++++++ web/lib/env.ts | 47 +++++++++- web/lib/tracker-store.ts | 145 +++++++++++++++++++++++++++++++ web/lib/tracker.test.ts | 124 ++++++++++++++++++++++++++ web/lib/tracker.ts | 131 ++++++++++++++++++++++++++++ web/package-lock.json | 95 ++++++++++++++++++++ web/package.json | 2 + 11 files changed, 828 insertions(+), 33 deletions(-) create mode 100644 package-lock.json create mode 100644 web/app/api/tracker/route.ts create mode 100644 web/lib/env.test.ts create mode 100644 web/lib/tracker-store.ts create mode 100644 web/lib/tracker.test.ts create mode 100644 web/lib/tracker.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a1d5c54 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "hackhq", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/.env.example b/web/.env.example index a99a467..17c5063 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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= diff --git a/web/app/api/tracker/route.ts b/web/app/api/tracker/route.ts new file mode 100644 index 0000000..fd3485e --- /dev/null +++ b/web/app/api/tracker/route.ts @@ -0,0 +1,163 @@ +/* --------------------------------------------------------------------------- + /api/tracker — the signed-in user's pipeline and wins (#226). + + | Method | Does | + | ------ | ---- | + | GET | list every row for the caller | + | PUT | create or update one row (stage and/or win) | + | DELETE | remove one row | + | POST | hand a browser-local tracker over, additively, on first sign-in | + + Two responses mean "carry on locally" rather than "something broke": + `{ synced: false }` with a 200, returned when Clerk or Supabase is not + configured. The tracker has always worked without a server, that stays true, + and the client treats a 200 with `synced: false` as its cue to keep using + localStorage alone. A signed-out caller, by contrast, gets a 401 — on a + configured deployment that is a real failure, not a degraded mode. +--------------------------------------------------------------------------- */ + +import { auth } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; +import { isTrackerSyncConfigured } from "@/lib/env"; +import { isHackathonId, isStage, parseTrackerEntries } from "@/lib/tracker"; +import { + deleteTrackerRow, + importTrackerRows, + listTracker, + upsertTrackerRow, +} from "@/lib/tracker-store"; + +// Per-user data: never prerendered, never cached. +export const dynamic = "force-dynamic"; + +const NOT_SYNCED = { synced: false as const }; + +/** + * Resolve the caller, or the reason there is nothing to do. Both keys and the + * Clerk session are checked before any query, so an unconfigured deployment + * never constructs a Supabase client. + */ +async function resolveUser(): Promise< + { userId: string } | { response: NextResponse } +> { + if (!isTrackerSyncConfigured()) { + return { response: NextResponse.json(NOT_SYNCED) }; + } + const { userId } = await auth(); + if (!userId) { + return { + response: NextResponse.json({ error: "Not signed in" }, { status: 401 }), + }; + } + return { userId }; +} + +/** + * Log the real cause server-side, tell the client only that it failed. Supabase + * error messages can name columns and constraints, which is not something to + * hand back over the wire. + */ +function serverError(operation: string, error: unknown): NextResponse { + console.error(`[api/tracker] ${operation} failed:`, error); + return NextResponse.json({ error: "Tracker sync failed" }, { status: 500 }); +} + +export async function GET() { + const resolved = await resolveUser(); + if ("response" in resolved) return resolved.response; + + try { + const entries = await listTracker(resolved.userId); + return NextResponse.json({ synced: true, entries }); + } catch (error) { + return serverError("list", error); + } +} + +export async function PUT(request: Request) { + const resolved = await resolveUser(); + if ("response" in resolved) return resolved.response; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { hackathonId, stage, isWin } = (body ?? {}) as Record; + if (!isHackathonId(hackathonId)) { + return NextResponse.json({ error: "Invalid hackathonId" }, { status: 400 }); + } + if (stage !== undefined && !isStage(stage)) { + return NextResponse.json({ error: "Invalid stage" }, { status: 400 }); + } + if (isWin !== undefined && typeof isWin !== "boolean") { + return NextResponse.json({ error: "Invalid isWin" }, { status: 400 }); + } + if (stage === undefined && isWin === undefined) { + return NextResponse.json( + { error: "Provide stage, isWin, or both" }, + { status: 400 }, + ); + } + + try { + const entry = await upsertTrackerRow(resolved.userId, hackathonId, { + stage, + isWin, + }); + return NextResponse.json({ synced: true, entry }); + } catch (error) { + return serverError("upsert", error); + } +} + +export async function DELETE(request: Request) { + const resolved = await resolveUser(); + if ("response" in resolved) return resolved.response; + + const hackathonId = new URL(request.url).searchParams.get("hackathonId"); + if (!isHackathonId(hackathonId)) { + return NextResponse.json({ error: "Invalid hackathonId" }, { status: 400 }); + } + + try { + await deleteTrackerRow(resolved.userId, hackathonId); + return NextResponse.json({ synced: true }); + } catch (error) { + return serverError("delete", error); + } +} + +// A local tracker can only be as large as the listing set, but the bound is +// stated rather than assumed so an oversized body is rejected before it becomes +// one enormous upsert. +const MAX_IMPORT_ENTRIES = 500; + +export async function POST(request: Request) { + const resolved = await resolveUser(); + if ("response" in resolved) return resolved.response; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const entries = parseTrackerEntries((body as { entries?: unknown })?.entries); + if (entries.length > MAX_IMPORT_ENTRIES) { + return NextResponse.json({ error: "Too many entries" }, { status: 413 }); + } + + try { + await importTrackerRows(resolved.userId, entries); + // Return the merged result so the client adopts the server's view in one + // round trip, including any row the import deliberately left alone. + const merged = await listTracker(resolved.userId); + return NextResponse.json({ synced: true, entries: merged }); + } catch (error) { + return serverError("import", error); + } +} diff --git a/web/components/hq/store.tsx b/web/components/hq/store.tsx index 6bd3b0e..6b783af 100644 --- a/web/components/hq/store.tsx +++ b/web/components/hq/store.tsx @@ -9,35 +9,16 @@ import { useState, } from "react"; import type { Hackathon } from "@/lib/types-hq"; - -export type Stage = "interested" | "applied" | "accepted" | "going"; - -export const STAGES: { id: Stage; label: string; color: string }[] = [ - { id: "interested", label: "Interested", color: "#17b26a" }, - { id: "applied", label: "Applied", color: "#f5a623" }, - { id: "accepted", label: "Accepted", color: "#3b6bf0" }, - { id: "going", label: "Going", color: "#ed5b29" }, -]; - -type TrackerMap = Record; - -const STAGE_IDS = new Set(STAGES.map((s) => s.id)); - -/** - * Coerce an untrusted parsed localStorage value into a clean TrackerMap: - * keep only string ids mapped to a known Stage, drop everything else. Guards - * against a valid-JSON-but-wrong-shape payload being cast blindly. - */ -function sanitizeTrackerMap(value: unknown): TrackerMap { - if (!value || typeof value !== "object") return {}; - const out: TrackerMap = {}; - for (const [id, stage] of Object.entries(value as Record)) { - if (typeof stage === "string" && STAGE_IDS.has(stage)) { - out[id] = stage as Stage; - } - } - return out; -} +import { + sanitizeTrackerMap, + type Stage, + type TrackerMap, +} from "@/lib/tracker"; + +// The stage vocabulary lives in lib/tracker.ts so /api/tracker can validate +// against the same list. Re-exported here because this is where the app has +// always imported it from. +export { STAGES, type Stage } from "@/lib/tracker"; type TrackerContextValue = { tracked: TrackerMap; diff --git a/web/lib/env.test.ts b/web/lib/env.test.ts new file mode 100644 index 0000000..09bd113 --- /dev/null +++ b/web/lib/env.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const KEYS = [ + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + "CLERK_SECRET_KEY", + "SUPABASE_URL", + "SUPABASE_SERVICE_ROLE_KEY", +] as const; + +/** + * validateEnv() warns only once per module instance, so each case re-imports a + * fresh copy rather than sharing one. + */ +async function loadEnv(set: Partial>) { + for (const key of KEYS) delete process.env[key]; + Object.assign(process.env, set); + vi.resetModules(); + return import("./env"); +} + +afterEach(() => { + for (const key of KEYS) delete process.env[key]; + vi.restoreAllMocks(); +}); + +const CLERK = { + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_test", + CLERK_SECRET_KEY: "sk_test", +}; +const SUPABASE = { + SUPABASE_URL: "https://project.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "service-role", +}; + +describe("isTrackerSyncConfigured", () => { + it("is on only when Clerk and Supabase are both fully configured", async () => { + const { isTrackerSyncConfigured } = await loadEnv({ ...CLERK, ...SUPABASE }); + expect(isTrackerSyncConfigured()).toBe(true); + }); + + it("is off without Supabase, so the tracker stays browser-local", async () => { + const { isTrackerSyncConfigured } = await loadEnv(CLERK); + expect(isTrackerSyncConfigured()).toBe(false); + }); + + it("is off without Clerk — there would be no user to attribute a row to", async () => { + const { isTrackerSyncConfigured } = await loadEnv(SUPABASE); + expect(isTrackerSyncConfigured()).toBe(false); + }); + + it("is off when only one Supabase value is set", async () => { + const { isTrackerSyncConfigured } = await loadEnv({ + ...CLERK, + SUPABASE_URL: SUPABASE.SUPABASE_URL, + }); + expect(isTrackerSyncConfigured()).toBe(false); + }); +}); + +describe("validateEnv", () => { + it("reports a half-configured Supabase rather than failing quietly", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { validateEnv } = await loadEnv({ + ...CLERK, + SUPABASE_URL: SUPABASE.SUPABASE_URL, + }); + + expect(validateEnv().trackerSync).toBe("partial"); + expect(warn.mock.calls.flat().join(" ")).toContain( + "SUPABASE_SERVICE_ROLE_KEY", + ); + }); + + it("warns when Supabase is ready but Clerk is not, since sync still cannot run", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { validateEnv } = await loadEnv(SUPABASE); + const report = validateEnv(); + + expect(report.trackerSync).toBe("enabled"); + expect(report.clerk).toBe("disabled"); + expect(warn.mock.calls.flat().join(" ")).toContain( + "Supabase is configured but Clerk is not", + ); + }); + + it("stays quiet about auth and sync when everything is set", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { validateEnv } = await loadEnv({ + ...CLERK, + ...SUPABASE, + NEXT_PUBLIC_MAPBOX_TOKEN: "pk.mapbox", + } as Record); + + expect(validateEnv()).toEqual({ + mapbox: true, + clerk: "enabled", + trackerSync: "enabled", + }); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/web/lib/env.ts b/web/lib/env.ts index a13a494..2d2360f 100644 --- a/web/lib/env.ts +++ b/web/lib/env.ts @@ -4,9 +4,12 @@ // so we warn rather than throw. But a *partial* Clerk config (one key set, the // other missing) is a real misconfiguration worth flagging loudly at startup. +export type Availability = "enabled" | "disabled" | "partial"; + export type EnvReport = { mapbox: boolean; - clerk: "enabled" | "disabled" | "partial"; + clerk: Availability; + trackerSync: Availability; }; let reported = false; @@ -21,12 +24,33 @@ export function isClerkConfigured(): boolean { ); } +// Persisting a tracker needs somewhere to put it *and* someone to attribute it +// to, so this is deliberately Clerk-inclusive: with Supabase configured but +// sign-in switched off there is no user id, and /api/tracker would have nothing +// to scope a row by. Both variables are server-only — no NEXT_PUBLIC_ prefix — +// so the service role key never reaches the browser bundle. +export function isTrackerSyncConfigured(): boolean { + return Boolean( + isClerkConfigured() && + process.env.SUPABASE_URL && + process.env.SUPABASE_SERVICE_ROLE_KEY, + ); +} + +function availability(...flags: boolean[]): Availability { + if (flags.every(Boolean)) return "enabled"; + if (flags.every((f) => !f)) return "disabled"; + return "partial"; +} + export function validateEnv(): EnvReport { const mapbox = Boolean(process.env.NEXT_PUBLIC_MAPBOX_TOKEN); const pub = Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY); const secret = Boolean(process.env.CLERK_SECRET_KEY); - const clerk: EnvReport["clerk"] = - pub && secret ? "enabled" : !pub && !secret ? "disabled" : "partial"; + const supabaseUrl = Boolean(process.env.SUPABASE_URL); + const supabaseKey = Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY); + const clerk = availability(pub, secret); + const trackerSync = availability(supabaseUrl, supabaseKey); if (!reported) { reported = true; @@ -41,6 +65,21 @@ export function validateEnv(): EnvReport { "and CLERK_SECRET_KEY, or neither. Sign-in stays disabled until both exist.", ); } + if (trackerSync === "partial") { + console.warn( + "[env] Supabase is half-configured: set BOTH SUPABASE_URL and " + + "SUPABASE_SERVICE_ROLE_KEY, or neither. Trackers stay browser-local until both exist.", + ); + } + // Not "partial" in the half-configured sense — both Supabase values are + // present and valid — but the result is the same dead end, so it is worth + // the same warning rather than a silent fallback to localStorage. + if (trackerSync === "enabled" && clerk !== "enabled") { + console.warn( + "[env] Supabase is configured but Clerk is not, so tracker sync stays off: " + + "there is no signed-in user to attribute a saved hackathon to.", + ); + } } - return { mapbox, clerk }; + return { mapbox, clerk, trackerSync }; } diff --git a/web/lib/tracker-store.ts b/web/lib/tracker-store.ts new file mode 100644 index 0000000..4419f69 --- /dev/null +++ b/web/lib/tracker-store.ts @@ -0,0 +1,145 @@ +/* --------------------------------------------------------------------------- + Server-side reads and writes for public.user_hackathons. + + This is the app's first runtime database access — every other page reads + listings.json off disk — so it is deliberately narrow: four operations, all + scoped to one user, and no query in here takes a user id from a caller's + request body. The id always comes from the Clerk session, resolved in the + route handler. + + ## Why the service role, and what still guards the rows + + The client is built with the service role key, which bypasses RLS. Ownership + is therefore enforced here, by the `.eq("user_id", userId)` on every read, + update and delete and by writing `user_id` explicitly on every insert. + + The RLS policies in the migration are not redundant. They mean `anon` and + `authenticated` cannot touch this table at all, so nothing reachable with a + publishable key can read one user's tracker — and they are what a future + browser-direct path would run under. The trade-off of the service role is + that a missing filter in this file would not be caught by the database, which + is why the surface is kept this small and why `userId` is a required + parameter rather than an option on every function below. + + Upgrade path: Clerk's native Supabase integration lets the browser's own + session token satisfy those policies directly, moving enforcement back into + Postgres. That needs a trust relationship configured in both dashboards, so + it is a follow-up rather than a prerequisite for shipping this. +--------------------------------------------------------------------------- */ + +import "server-only"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import type { Stage, TrackerEntry } from "./tracker"; +import { parseTrackerEntries } from "./tracker"; + +const TABLE = "user_hackathons"; + +let cached: SupabaseClient | null = null; + +function client(): SupabaseClient { + if (cached) return cached; + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) { + throw new Error("Supabase is not configured for tracker sync"); + } + // No session persistence or refresh: this runs per request on the server and + // authenticates with a static key, so the auth machinery has nothing to do. + cached = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + return cached; +} + +/** Every row belonging to one user, ready for the UI to split into its maps. */ +export async function listTracker(userId: string): Promise { + const { data, error } = await client() + .from(TABLE) + .select("hackathon_id, stage, is_win") + .eq("user_id", userId); + if (error) throw new Error(error.message); + + return parseTrackerEntries( + (data ?? []).map((row) => ({ + hackathonId: row.hackathon_id, + stage: row.stage, + isWin: row.is_win, + })), + ); +} + +/** + * Create or update one row. Partial by design: moving a hackathon between + * stages must not clear its win, and recording a win must not reset its stage, + * so an omitted field falls back to the stored value (or the column default on + * a first insert) instead of being written as false. + */ +export async function upsertTrackerRow( + userId: string, + hackathonId: string, + patch: { stage?: Stage; isWin?: boolean }, +): Promise { + const { data: existing, error: readError } = await client() + .from(TABLE) + .select("stage, is_win") + .eq("user_id", userId) + .eq("hackathon_id", hackathonId) + .maybeSingle(); + if (readError) throw new Error(readError.message); + + const stage: Stage = patch.stage ?? (existing?.stage as Stage) ?? "interested"; + const isWin = patch.isWin ?? existing?.is_win === true; + + const { error } = await client() + .from(TABLE) + .upsert( + { + user_id: userId, + hackathon_id: hackathonId, + stage, + is_win: isWin, + updated_at: new Date().toISOString(), + }, + { onConflict: "user_id,hackathon_id" }, + ); + if (error) throw new Error(error.message); + + return { hackathonId, stage, isWin }; +} + +export async function deleteTrackerRow( + userId: string, + hackathonId: string, +): Promise { + const { error } = await client() + .from(TABLE) + .delete() + .eq("user_id", userId) + .eq("hackathon_id", hackathonId); + if (error) throw new Error(error.message); +} + +/** + * Hand a browser's local tracker over on first sign-in. `ignoreDuplicates` + * makes this additive: an account that already tracks a hackathon keeps the + * stage it has on the server, so signing in on a second device cannot roll the + * pipeline back to whatever that browser happened to remember. + */ +export async function importTrackerRows( + userId: string, + entries: TrackerEntry[], +): Promise { + if (entries.length === 0) return; + const { error } = await client() + .from(TABLE) + .upsert( + entries.map((e) => ({ + user_id: userId, + hackathon_id: e.hackathonId, + stage: e.stage, + is_win: e.isWin, + })), + { onConflict: "user_id,hackathon_id", ignoreDuplicates: true }, + ); + if (error) throw new Error(error.message); +} diff --git a/web/lib/tracker.test.ts b/web/lib/tracker.test.ts new file mode 100644 index 0000000..e9a18b1 --- /dev/null +++ b/web/lib/tracker.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + isHackathonId, + isStage, + parseTrackerEntries, + sanitizeTrackerMap, + sanitizeWinMap, + splitEntries, + toTrackerEntries, +} from "./tracker"; + +const ID_A = "57177cd1-cff8-4e80-b701-6811dbcdb1a4"; +const ID_B = "4c7865aa-543e-4ac8-9f47-808da1bffddc"; + +describe("isStage", () => { + it("accepts the four pipeline stages and nothing else", () => { + expect(isStage("interested")).toBe(true); + expect(isStage("going")).toBe(true); + expect(isStage("won")).toBe(false); + expect(isStage(undefined)).toBe(false); + }); +}); + +describe("isHackathonId", () => { + it("accepts a listing UUID and rejects anything that isn't one", () => { + expect(isHackathonId(ID_A)).toBe(true); + expect(isHackathonId(ID_A.toUpperCase())).toBe(true); + expect(isHackathonId("not-a-uuid")).toBe(false); + // A SQL fragment must not reach a query as an id. + expect(isHackathonId("' or 1=1 --")).toBe(false); + expect(isHackathonId(null)).toBe(false); + }); +}); + +describe("sanitizeTrackerMap", () => { + it("keeps known stages and drops everything else", () => { + expect( + sanitizeTrackerMap({ [ID_A]: "going", [ID_B]: "nonsense", x: 3 }), + ).toEqual({ [ID_A]: "going" }); + }); + + it("returns an empty map for a non-object", () => { + expect(sanitizeTrackerMap(null)).toEqual({}); + expect(sanitizeTrackerMap("[]")).toEqual({}); + }); +}); + +describe("sanitizeWinMap", () => { + it("keeps only entries that are exactly true", () => { + expect( + sanitizeWinMap({ [ID_A]: true, [ID_B]: false, c: "true", d: 1 }), + ).toEqual({ [ID_A]: true }); + }); +}); + +describe("parseTrackerEntries", () => { + it("keeps valid rows and defaults a missing win flag to false", () => { + expect( + parseTrackerEntries([{ hackathonId: ID_A, stage: "applied" }]), + ).toEqual([{ hackathonId: ID_A, stage: "applied", isWin: false }]); + }); + + it("drops invalid rows without losing the valid ones", () => { + const entries = parseTrackerEntries([ + { hackathonId: ID_A, stage: "going", isWin: true }, + { hackathonId: "nope", stage: "going" }, + { hackathonId: ID_B, stage: "invented" }, + null, + "junk", + ]); + expect(entries).toEqual([ + { hackathonId: ID_A, stage: "going", isWin: true }, + ]); + }); + + it("keeps the first of a duplicated id, so an upsert can't conflict with itself", () => { + const entries = parseTrackerEntries([ + { hackathonId: ID_A, stage: "going" }, + { hackathonId: ID_A, stage: "interested" }, + ]); + expect(entries).toEqual([ + { hackathonId: ID_A, stage: "going", isWin: false }, + ]); + }); + + it("returns an empty list for a non-array", () => { + expect(parseTrackerEntries({ hackathonId: ID_A })).toEqual([]); + }); +}); + +describe("splitEntries", () => { + it("splits rows into the stage map and the win map", () => { + expect( + splitEntries([ + { hackathonId: ID_A, stage: "going", isWin: true }, + { hackathonId: ID_B, stage: "applied", isWin: false }, + ]), + ).toEqual({ + tracked: { [ID_A]: "going", [ID_B]: "applied" }, + wins: { [ID_A]: true }, + }); + }); +}); + +describe("toTrackerEntries", () => { + it("pairs each stage with its win flag", () => { + expect( + toTrackerEntries({ [ID_A]: "going", [ID_B]: "applied" }, { [ID_A]: true }), + ).toEqual([ + { hackathonId: ID_A, stage: "going", isWin: true }, + { hackathonId: ID_B, stage: "applied", isWin: false }, + ]); + }); + + it("drops ids that aren't UUIDs, so a hand-edited localStorage can't poison the import", () => { + expect(toTrackerEntries({ "hack-1": "going" }, {})).toEqual([]); + }); + + it("drops ids missing from the known listing set", () => { + expect( + toTrackerEntries({ [ID_A]: "going", [ID_B]: "applied" }, {}, [ID_A]), + ).toEqual([{ hackathonId: ID_A, stage: "going", isWin: false }]); + }); +}); diff --git a/web/lib/tracker.ts b/web/lib/tracker.ts new file mode 100644 index 0000000..bf3f69c --- /dev/null +++ b/web/lib/tracker.ts @@ -0,0 +1,131 @@ +/* --------------------------------------------------------------------------- + The tracker vocabulary, shared by the browser and the server. + + Stages used to be declared in components/hq/store.tsx, which carries + "use client". Now that /api/tracker validates the same values, they live here + instead: a plain module both sides can import, with no client boundary and no + second copy of the stage list to keep in step. store.tsx re-exports `Stage` + and `STAGES` so existing importers are unaffected. + + Everything here is pure, so the parsing that guards both untrusted request + bodies and untrusted localStorage is unit tested directly (tracker.test.ts). +--------------------------------------------------------------------------- */ + +export type Stage = "interested" | "applied" | "accepted" | "going"; + +export const STAGES: { id: Stage; label: string; color: string }[] = [ + { id: "interested", label: "Interested", color: "#17b26a" }, + { id: "applied", label: "Applied", color: "#f5a623" }, + { id: "accepted", label: "Accepted", color: "#3b6bf0" }, + { id: "going", label: "Going", color: "#ed5b29" }, +]; + +const STAGE_IDS = new Set(STAGES.map((s) => s.id)); + +export function isStage(value: unknown): value is Stage { + return typeof value === "string" && STAGE_IDS.has(value); +} + +/** hackathon id -> pipeline stage. */ +export type TrackerMap = Record; + +/** hackathon id -> won it. Only `true` entries are kept. */ +export type WinMap = Record; + +/** One tracker row, as it crosses the wire and as it sits in the database. */ +export type TrackerEntry = { + hackathonId: string; + stage: Stage; + isWin: boolean; +}; + +/** + * Listing ids are UUIDs, both in listings.json and in the `hackathon_id` column. + * Checked before any query so a malformed id is a 400 here rather than a + * Postgres cast error surfacing as a 500. + */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isHackathonId(value: unknown): value is string { + return typeof value === "string" && UUID_RE.test(value); +} + +/** + * Coerce an untrusted value into a clean TrackerMap: keep only string ids + * mapped to a known Stage, drop everything else. Guards against a + * valid-JSON-but-wrong-shape payload being cast blindly. + */ +export function sanitizeTrackerMap(value: unknown): TrackerMap { + if (!value || typeof value !== "object") return {}; + const out: TrackerMap = {}; + for (const [id, stage] of Object.entries(value as Record)) { + if (isStage(stage)) out[id] = stage; + } + return out; +} + +/** The same guard for the win map. Anything not exactly `true` is dropped. */ +export function sanitizeWinMap(value: unknown): WinMap { + if (!value || typeof value !== "object") return {}; + const out: WinMap = {}; + for (const [id, won] of Object.entries(value as Record)) { + if (won === true) out[id] = true; + } + return out; +} + +/** + * Parse a list of entries from an untrusted source — an API response, or the + * import payload a browser sends when handing its local tracker over. Rows that + * don't validate are dropped rather than failing the batch: one bad id should + * not cost a user the rest of their pipeline. + */ +export function parseTrackerEntries(value: unknown): TrackerEntry[] { + if (!Array.isArray(value)) return []; + const out: TrackerEntry[] = []; + const seen = new Set(); + for (const row of value) { + if (!row || typeof row !== "object") continue; + const { hackathonId, stage, isWin } = row as Record; + if (!isHackathonId(hackathonId) || !isStage(stage)) continue; + if (seen.has(hackathonId)) continue; + seen.add(hackathonId); + out.push({ hackathonId, stage, isWin: isWin === true }); + } + return out; +} + +/** Entries -> the two maps the UI renders from. */ +export function splitEntries(entries: TrackerEntry[]): { + tracked: TrackerMap; + wins: WinMap; +} { + const tracked: TrackerMap = {}; + const wins: WinMap = {}; + for (const e of entries) { + tracked[e.hackathonId] = e.stage; + if (e.isWin) wins[e.hackathonId] = true; + } + return { tracked, wins }; +} + +/** + * The two maps -> entries, for the one-time handover of a browser-local tracker. + * Only ids the caller can still see are sent; a stale id from a delisted + * hackathon is not worth carrying into the database. + */ +export function toTrackerEntries( + tracked: TrackerMap, + wins: WinMap, + knownIds?: Iterable, +): TrackerEntry[] { + const known = knownIds ? new Set(knownIds) : null; + return Object.entries(tracked) + .filter(([id]) => isHackathonId(id) && (!known || known.has(id))) + .map(([hackathonId, stage]) => ({ + hackathonId, + stage, + isWin: wins[hackathonId] === true, + })); +} diff --git a/web/package-lock.json b/web/package-lock.json index 92575ee..6c0a7b1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@clerk/nextjs": "^7.5.12", + "@supabase/supabase-js": "^2.110.8", "drizzle-orm": "^0.45.2", "framer-motion": "^11.18.2", "mapbox-gl": "^3.25.0", @@ -16,6 +17,7 @@ "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", + "server-only": "^0.0.1", "sharp": "^0.35.0" }, "devDependencies": { @@ -2181,6 +2183,90 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.8.tgz", + "integrity": "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.8.tgz", + "integrity": "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.8.tgz", + "integrity": "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.8.tgz", + "integrity": "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.8.tgz", + "integrity": "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.8.tgz", + "integrity": "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.8", + "@supabase/functions-js": "2.110.8", + "@supabase/postgrest-js": "2.110.8", + "@supabase/realtime-js": "2.110.8", + "@supabase/storage-js": "2.110.8" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5345,6 +5431,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/web/package.json b/web/package.json index d4e970d..22d7388 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@clerk/nextjs": "^7.5.12", + "@supabase/supabase-js": "^2.110.8", "drizzle-orm": "^0.45.2", "framer-motion": "^11.18.2", "mapbox-gl": "^3.25.0", @@ -24,6 +25,7 @@ "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", + "server-only": "^0.0.1", "sharp": "^0.35.0" }, "devDependencies": { From b7f323bc67a1d00e1af1f8f779a6663c99b3309e Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 09:04:54 -0700 Subject: [PATCH 03/18] Remove a stray root package-lock.json 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 --- package-lock.json | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index a1d5c54..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "hackhq", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From 970075baffe5f30140acb3bbaf34dc18199e81ac Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 09:07:28 -0700 Subject: [PATCH 04/18] Sync the tracker to the signed-in user's account, and record wins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- web/components/hq/store.tsx | 256 ++++++++++++++++++++++++++++++++++-- 1 file changed, 245 insertions(+), 11 deletions(-) diff --git a/web/components/hq/store.tsx b/web/components/hq/store.tsx index 6b783af..5947b78 100644 --- a/web/components/hq/store.tsx +++ b/web/components/hq/store.tsx @@ -6,13 +6,19 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import type { Hackathon } from "@/lib/types-hq"; import { + parseTrackerEntries, sanitizeTrackerMap, + sanitizeWinMap, + splitEntries, + toTrackerEntries, type Stage, type TrackerMap, + type WinMap, } from "@/lib/tracker"; // The stage vocabulary lives in lib/tracker.ts so /api/tracker can validate @@ -22,10 +28,15 @@ export { STAGES, type Stage } from "@/lib/tracker"; type TrackerContextValue = { tracked: TrackerMap; + wins: WinMap; save: (id: string) => void; move: (id: string, stage: Stage) => void; remove: (id: string) => void; isTracked: (id: string) => boolean; + hasWin: (id: string) => boolean; + toggleWin: (id: string) => void; + /** True once this tracker is backed by the signed-in user's account. */ + synced: boolean; }; type SelectionContextValue = { @@ -39,19 +50,58 @@ const TrackerCtx = createContext(null); const SelectionCtx = createContext(null); const LS_KEY = "hackhq-tracker-v1"; +const LS_WINS_KEY = "hackhq-wins-v1"; +// Set once this browser has offered its local tracker to an account. Without it +// a later visit would re-upload rows the user has since deleted from their +// account, because the import is deliberately additive. +const LS_IMPORTED_KEY = "hackhq-tracker-imported-v1"; + +/* --------------------------------------------------------------------------- + Where the tracker lives + + localStorage first, always: it needs no account, and it is what a signed-out + visitor gets. On mount the provider then asks /api/tracker whether this + session has somewhere better to put it. Three answers, all of them fine: + + 200 { synced: false } sync isn't configured — stay local + 401 signed out — stay local + 200 { synced: true } adopt the account's rows as the truth + + The provider asks the route rather than reading Clerk hooks, because it also + mounts on deployments with no above it, where those hooks + throw. Only the third answer switches `synced` on, and only then does a + change get written through to the server. +--------------------------------------------------------------------------- */ export function HQProvider({ children }: { children: React.ReactNode }) { const [tracked, setTracked] = useState({}); + const [wins, setWins] = useState({}); const [selected, setSelected] = useState(null); const [hydrated, setHydrated] = useState(false); + const [synced, setSynced] = useState(false); + + // Mirrors of the state above, for the async paths: a rollback needs the value + // from before its request, and the sync effect must not re-run on every edit. + // Kept up to date in an effect, and declared before the effects that read + // them so they are current by the time those run. + const trackedRef = useRef(tracked); + const winsRef = useRef(wins); + const syncedRef = useRef(false); + + useEffect(() => { + trackedRef.current = tracked; + winsRef.current = wins; + }, [tracked, wins]); useEffect(() => { try { const raw = localStorage.getItem(LS_KEY); + const rawWins = localStorage.getItem(LS_WINS_KEY); // Deliberate post-mount hydration (localStorage is unavailable during // SSR); validate the shape rather than trusting any valid JSON. // eslint-disable-next-line react-hooks/set-state-in-effect if (raw) setTracked(sanitizeTrackerMap(JSON.parse(raw))); + if (rawWins) setWins(sanitizeWinMap(JSON.parse(rawWins))); } catch { /* first visit / corrupted - start fresh */ } @@ -62,34 +112,218 @@ export function HQProvider({ children }: { children: React.ReactNode }) { if (!hydrated) return; try { localStorage.setItem(LS_KEY, JSON.stringify(tracked)); + localStorage.setItem(LS_WINS_KEY, JSON.stringify(wins)); } catch { /* private mode - tracker just won't persist */ } - }, [tracked, hydrated]); + }, [tracked, wins, hydrated]); - const save = useCallback( - (id: string) => - setTracked((t) => (t[id] ? t : { ...t, [id]: "interested" })), + // Adopt the account's tracker, handing this browser's over first if it has one + // and hasn't already. Runs after hydration so there is something to hand over. + useEffect(() => { + if (!hydrated) return; + let cancelled = false; + + (async () => { + try { + const res = await fetch("/api/tracker"); + if (!res.ok) return; + const body = await res.json(); + if (body?.synced !== true) return; + + let entries = parseTrackerEntries(body.entries); + + if (!localStorage.getItem(LS_IMPORTED_KEY)) { + const local = toTrackerEntries(trackedRef.current, winsRef.current); + if (local.length > 0) { + const imported = await fetch("/api/tracker", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entries: local }), + }); + if (imported.ok) { + const merged = await imported.json(); + if (merged?.synced === true) { + entries = parseTrackerEntries(merged.entries); + } + } + } + localStorage.setItem(LS_IMPORTED_KEY, "1"); + } + + if (cancelled) return; + const next = splitEntries(entries); + setTracked(next.tracked); + setWins(next.wins); + syncedRef.current = true; + setSynced(true); + } catch { + /* offline or blocked - the local tracker carries on */ + } + })(); + + return () => { + cancelled = true; + }; + }, [hydrated]); + + /** + * Write one row through to the account, reverting the optimistic edit if it + * doesn't land. Reverting is the point: leaving the change on screen would + * show a save that isn't there, and the next visit would quietly replace it + * with the server's version. + */ + const push = useCallback( + async (request: () => Promise, rollback: () => void) => { + if (!syncedRef.current) return; + try { + const res = await request(); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (error) { + console.warn("[tracker] could not save to your account:", error); + rollback(); + } + }, [], ); - const move = useCallback( - (id: string, stage: Stage) => setTracked((t) => ({ ...t, [id]: stage })), + + const putRow = useCallback( + (body: { hackathonId: string; stage?: Stage; isWin?: boolean }) => + fetch("/api/tracker", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), [], ); + + /** Restore one id to the stage and win it held before an optimistic edit. */ + const restore = useCallback((id: string, stage?: Stage, won?: boolean) => { + setTracked((t) => { + if (!stage) { + const rest = { ...t }; + delete rest[id]; + return rest; + } + return { ...t, [id]: stage }; + }); + setWins((w) => { + if (won) return { ...w, [id]: true }; + const rest = { ...w }; + delete rest[id]; + return rest; + }); + }, []); + + const save = useCallback( + (id: string) => { + if (trackedRef.current[id]) return; + setTracked((t) => ({ ...t, [id]: "interested" })); + void push( + () => putRow({ hackathonId: id, stage: "interested" }), + () => restore(id, undefined, false), + ); + }, + [push, putRow, restore], + ); + + const move = useCallback( + (id: string, stage: Stage) => { + const prevStage = trackedRef.current[id]; + const prevWin = winsRef.current[id] === true; + setTracked((t) => ({ ...t, [id]: stage })); + void push( + () => putRow({ hackathonId: id, stage }), + () => restore(id, prevStage, prevWin), + ); + }, + [push, putRow, restore], + ); + const remove = useCallback( - (id: string) => + (id: string) => { + const prevStage = trackedRef.current[id]; + const prevWin = winsRef.current[id] === true; setTracked((t) => { const rest = { ...t }; delete rest[id]; return rest; - }), - [], + }); + setWins((w) => { + const rest = { ...w }; + delete rest[id]; + return rest; + }); + void push( + () => + fetch(`/api/tracker?hackathonId=${encodeURIComponent(id)}`, { + method: "DELETE", + }), + () => restore(id, prevStage, prevWin), + ); + }, + [push, restore], + ); + + /** + * Record or clear a win. Claiming one also moves the hackathon to Going: you + * cannot win an event you haven't attended, and that keeps the trophy from + * appearing on something still sitting in Interested. + */ + const toggleWin = useCallback( + (id: string) => { + const prevStage = trackedRef.current[id]; + const won = winsRef.current[id] !== true; + + setWins((w) => { + if (won) return { ...w, [id]: true }; + const rest = { ...w }; + delete rest[id]; + return rest; + }); + if (won && prevStage !== "going") { + setTracked((t) => ({ ...t, [id]: "going" })); + } + + void push( + () => + putRow({ + hackathonId: id, + isWin: won, + ...(won ? { stage: "going" as Stage } : {}), + }), + () => restore(id, prevStage, !won), + ); + }, + [push, putRow, restore], ); + const isTracked = useCallback((id: string) => id in tracked, [tracked]); + const hasWin = useCallback((id: string) => wins[id] === true, [wins]); const trackerValue = useMemo( - () => ({ tracked, save, move, remove, isTracked }), - [tracked, save, move, remove, isTracked], + () => ({ + tracked, + wins, + save, + move, + remove, + isTracked, + hasWin, + toggleWin, + synced, + }), + [ + tracked, + wins, + save, + move, + remove, + isTracked, + hasWin, + toggleWin, + synced, + ], ); const selectionValue = useMemo( () => ({ selected, setSelected }), From e1d71fd1eb6195c390fc3921c9312d4008f73366 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 09:37:56 -0700 Subject: [PATCH 05/18] Show trophy badges for recorded hackathon wins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/app/globals.css | 3 + web/components/hq/deck.tsx | 10 ++- web/components/hq/detail-modal.tsx | 4 +- web/components/hq/passport.tsx | 14 ++-- web/components/hq/tracker.tsx | 19 +++++- web/components/hq/trophy.tsx | 100 +++++++++++++++++++++++++++++ web/lib/passport-stamps.test.ts | 29 ++++++++- web/lib/passport-stamps.ts | 30 +++++++-- 8 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 web/components/hq/trophy.tsx diff --git a/web/app/globals.css b/web/app/globals.css index 1fe8b8c..49e1177 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -23,6 +23,9 @@ --color-soon: #f5a623; --color-register: #3b6bf0; --color-muted: #9ba1a5; + /* Trophy gold for hackathon wins (#226). Lifted from the passport cover's + foil gradient so a win reads the same on a card as it does on a stamp. */ + --color-trophy: #e7c874; } :root { diff --git a/web/components/hq/deck.tsx b/web/components/hq/deck.tsx index 18fd4b9..9126559 100644 --- a/web/components/hq/deck.tsx +++ b/web/components/hq/deck.tsx @@ -11,6 +11,7 @@ import { } from "@/lib/types-hq"; import { safeHttpUrl } from "@/lib/url"; import { useSelection, useTracker } from "./store"; +import { TrophyBadge } from "./trophy"; type StatusFilter = "all" | HackState; type FormatFilter = "all" | "In-Person" | "Virtual"; @@ -176,6 +177,8 @@ function SaveHeart({ h, dark }: { h: Hackathon; dark?: boolean }) { function HackRow({ h }: { h: Hackathon }) { const { setSelected } = useSelection(); + const { hasWin } = useTracker(); + const won = hasWin(h.id); const meta = STATE_META[h.state]; const cd = countdown(h); const deadline = deadlineDisplay(h); @@ -204,8 +207,11 @@ function HackRow({ h }: { h: Hackathon }) { title={meta.label} />
-
- {h.title} +
+ + {h.title} + + {won && }
{h.host} · {h.location} diff --git a/web/components/hq/detail-modal.tsx b/web/components/hq/detail-modal.tsx index 6262ba2..9f28860 100644 --- a/web/components/hq/detail-modal.tsx +++ b/web/components/hq/detail-modal.tsx @@ -10,10 +10,11 @@ import { import { lockScroll } from "@/lib/scroll-lock"; import { safeHttpUrl } from "@/lib/url"; import { useSelection, useTracker } from "./store"; +import { TrophyBadge } from "./trophy"; export function DetailModal() { const { selected, setSelected } = useSelection(); - const { isTracked, save, remove } = useTracker(); + const { isTracked, save, remove, hasWin } = useTracker(); const panelRef = useRef(null); const closeButtonRef = useRef(null); const previousFocusRef = useRef(null); @@ -103,6 +104,7 @@ export function DetailModal() { {h.format.toUpperCase()} + {hasWin(h.id) && }
diff --git a/web/components/hq/tracker.tsx b/web/components/hq/tracker.tsx index 4dfb0c4..7834f88 100644 --- a/web/components/hq/tracker.tsx +++ b/web/components/hq/tracker.tsx @@ -6,9 +6,10 @@ import { STATE_META, countdown } from "@/lib/types-hq"; import { countKnownTracked } from "@/lib/tracker-utils"; import { safeHttpUrl } from "@/lib/url"; import { STAGES, useSelection, useTracker, type Stage } from "./store"; +import { TrophyBadge, WinToggle } from "./trophy"; export function Tracker({ hackathons }: { hackathons: Hackathon[] }) { - const { tracked, move, remove } = useTracker(); + const { tracked, move, remove, hasWin, toggleWin } = useTracker(); const { setSelected } = useSelection(); const [dragId, setDragId] = useState(null); const [overStage, setOverStage] = useState(null); @@ -104,6 +105,7 @@ export function Tracker({ hackathons }: { hackathons: Hackathon[] }) { h={h} dragging={dragId === h.id} stageId={col.id} + won={hasWin(h.id)} onDragStart={() => setDragId(h.id)} onDragEnd={() => { setDragId(null); @@ -112,6 +114,7 @@ export function Tracker({ hackathons }: { hackathons: Hackathon[] }) { onOpen={() => setSelected(h)} onRemove={() => remove(h.id)} onMoveStage={(stage) => move(h.id, stage)} + onToggleWin={() => toggleWin(h.id)} /> ))} {col.items.length === 0 && ( @@ -164,20 +167,24 @@ function TrackerCard({ h, dragging, stageId, + won, onDragStart, onDragEnd, onOpen, onRemove, onMoveStage, + onToggleWin, }: { h: Hackathon; dragging: boolean; stageId: Stage; + won: boolean; onDragStart: () => void; onDragEnd: () => void; onOpen: () => void; onRemove: () => void; onMoveStage: (stage: Stage) => void; + onToggleWin: () => void; }) { const meta = STATE_META[h.state]; const cd = countdown(h); @@ -218,6 +225,11 @@ function TrackerCard({
{h.title}
+ {won && ( +
+ +
+ )}
{h.host}
@@ -242,6 +254,11 @@ function TrackerCard({
)}
+ {/* Only in Going: a win belongs to an event you actually attended, and + claiming one from an earlier stage would silently move the card. */} + {stageId === "going" && ( + + )} {prevStage && ( + ); +} diff --git a/web/lib/passport-stamps.test.ts b/web/lib/passport-stamps.test.ts index e399685..510675b 100644 --- a/web/lib/passport-stamps.test.ts +++ b/web/lib/passport-stamps.test.ts @@ -172,7 +172,34 @@ describe("buildPassport", () => { it("returns an empty passport for no tracked hackathons", () => { const p = buildPassport({}, hackathons); - expect(p).toEqual({ left: [], right: [], stampCount: 0, cityCount: 0 }); + expect(p).toEqual({ + left: [], + right: [], + stampCount: 0, + cityCount: 0, + winCount: 0, + }); + }); + + it("stamps a recorded win as CHAMPION in gold, overriding the stage label", () => { + const p = buildPassport({ a: "going", b: "going" }, hackathons, { a: true }); + const all = [...p.left, ...p.right]; + const stamp = (id: string) => all.find((s) => s.id === id)!; + expect(stamp("a").label).toBe("CHAMPION"); + expect(stamp("a").color).toBe("#c9992f"); + expect(stamp("b").label).toBe("HACKED"); + expect(p.winCount).toBe(1); + }); + + it("ignores a win on a hackathon with no stamp, so the count matches the pages", () => { + // `ghost` isn't in the listing set and `a` is only bookmarked, so neither + // earns a stamp — and neither should be counted as a win. + const p = buildPassport({ a: "interested" }, hackathons, { + a: true, + ghost: true, + }); + expect(p.stampCount).toBe(0); + expect(p.winCount).toBe(0); }); it.each([12, 20, 40])( diff --git a/web/lib/passport-stamps.ts b/web/lib/passport-stamps.ts index f95582d..fe1c018 100644 --- a/web/lib/passport-stamps.ts +++ b/web/lib/passport-stamps.ts @@ -14,12 +14,15 @@ Which stage earns a stamp, and its label (issue #199): applied -> VISA accepted -> ADMITTED going -> HACKED `interested` (a mere bookmark) earns nothing. + + A recorded win (#226) overrides that label with CHAMPION in trophy gold. The + win is the more interesting fact about a hackathon than the stage it reached, + and the store only lets a win sit on `going`, so nothing is lost by letting it + take the stamp over. --------------------------------------------------------------------------- */ import type { Hackathon } from "./types-hq"; -// Type-only import: erased at build, so this file stays free of the "use client" -// store module at runtime (and in tests). -import type { Stage } from "@/components/hq/store"; +import type { Stage, WinMap } from "./tracker"; /** A tracker map: hackathon id -> pipeline stage. Mirrors store.tsx. */ export type TrackerMap = Record; @@ -49,6 +52,7 @@ export type Passport = { right: PassportStamp[]; // right base page stampCount: number; cityCount: number; + winCount: number; }; /* Stage -> stamp presentation. Colours mirror STAGES in store.tsx; the labels @@ -59,6 +63,11 @@ const STAGE_STAMP: Record = { going: { label: "HACKED", color: "#ed5b29" }, }; +/* The win overlay. Gold matches the `trophy` token in globals.css and the + passport cover's own foil, so the stamp reads as the same award the trophy + badges elsewhere are marking. */ +const WIN_STAMP = { label: "CHAMPION", color: "#c9992f" }; + function earnsStamp(stage: Stage): stage is StampStage { return stage !== "interested"; } @@ -224,8 +233,9 @@ function makeStamp( h: Hackathon, stage: StampStage, index: number, + won: boolean, ): PassportStamp { - const { label, color } = STAGE_STAMP[stage]; + const { label, color } = won ? WIN_STAMP : STAGE_STAMP[stage]; const name = cleanName(h.title) || h.host || "HACKATHON"; const top = name.toUpperCase(); const sub = locationArc(h.location); @@ -257,6 +267,7 @@ function makeStamp( export function buildPassport( tracked: TrackerMap, hackathons: Hackathon[], + wins: WinMap = {}, ): Passport { const byId = new Map(hackathons.map((h) => [h.id, h])); @@ -266,7 +277,9 @@ export function buildPassport( .filter((e): e is { h: Hackathon; stage: StampStage } => Boolean(e.h)) .sort((a, b) => eventTime(a.h) - eventTime(b.h)); - const stamps = earned.map((e, i) => makeStamp(e.h, e.stage, i)); + const stamps = earned.map((e, i) => + makeStamp(e.h, e.stage, i, wins[e.h.id] === true), + ); // Split half/half; earliest events fill the left (inside-cover) page first. const perPage = Math.max(1, Math.ceil(stamps.length / 2)); @@ -277,5 +290,10 @@ export function buildPassport( earned.map((e) => cityKey(e.h.location)).filter((k): k is string => Boolean(k)), ).size; - return { left, right, stampCount: stamps.length, cityCount }; + // Counted from the stamps rather than from `wins` directly: a win on a + // delisted hackathon has no stamp to show, so counting it would leave the + // header claiming a trophy the pages don't have. + const winCount = earned.filter((e) => wins[e.h.id] === true).length; + + return { left, right, stampCount: stamps.length, cityCount, winCount }; } From 060f2b6f61cbdd37ae966ea20e58eaff0923e0f6 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 11:13:22 -0700 Subject: [PATCH 06/18] Overlay a gold trophy stamp on won hackathons 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 --- web/components/hq/passport.tsx | 65 ++++++++++++++++++++++++++++++++++ web/lib/passport-stamps.ts | 2 ++ 2 files changed, 67 insertions(+) diff --git a/web/components/hq/passport.tsx b/web/components/hq/passport.tsx index b10aa9e..ab88068 100644 --- a/web/components/hq/passport.tsx +++ b/web/components/hq/passport.tsx @@ -154,6 +154,53 @@ function StampMark({ stamp, index }: { stamp: Stamp; index: number }) { ); } +/* The win over-stamp: a large gold trophy slapped on top of a CHAMPION stamp + (#226). Same 220x220 coordinate space and rough-inked treatment as + — multiply blend + the shared pp-ink turbulence filter — so it + reads as the same foil ink, just bolder so it dominates the stamp beneath. + Flat gold (the win token), with the lighter trophy gold for the star. */ +function TrophyStamp() { + const gold = "#c9992f"; + const star = "#e7c874"; + return ( + + + {/* rim + bowl */} + + + {/* handles */} + + + {/* stem, collar, plinth */} + + + {/* star on the cup face */} + + + + ); +} + function StampLayer({ stamps, indexOffset, @@ -176,11 +223,29 @@ function StampLayer({ width: s.pos.size, height: s.pos.size, transform: `rotate(${s.rotate}deg)`, + // Won stamps sit above their neighbours so the trophy over-stamp + // isn't clipped by the next overlapping stamp in a dense grid. + zIndex: s.won ? 5 : 1, }} >
+ {s.won && ( +
+ +
+ )}
))} diff --git a/web/lib/passport-stamps.ts b/web/lib/passport-stamps.ts index fe1c018..86e99f9 100644 --- a/web/lib/passport-stamps.ts +++ b/web/lib/passport-stamps.ts @@ -39,6 +39,7 @@ export type PassportStamp = { mono: string; // big monogram in the middle label: string; // HACKED / VISA / ADMITTED color: string; + won: boolean; // true -> render the gold trophy over-stamp on top pos: { left: number; top: number; size: number }; rotate: number; delay: number; // stamp-in animation delay, ms @@ -248,6 +249,7 @@ function makeStamp( mono, label, color, + won, rotate: rotateFor(h.id), // Stagger the stamp-in, but cap it so a large passport doesn't leave the // last stamps waiting several seconds to appear. From cc3b80d59c82637a6db658c08eff0d3063e587db Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 13:01:45 -0700 Subject: [PATCH 07/18] remove extra in progress badge --- web/components/hq/resources.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/components/hq/resources.tsx b/web/components/hq/resources.tsx index 8bd952e..253525e 100644 --- a/web/components/hq/resources.tsx +++ b/web/components/hq/resources.tsx @@ -141,7 +141,6 @@ function ToolsStrip() {
Toolkit · Always useful

Tools & templates -

Steal these for any weekend—first hackathon or fiftieth. From d6b1adda078f5eb0ee82871ef877d6996836b346 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 13:43:22 -0700 Subject: [PATCH 08/18] test(#226): assert tracker-store scopes every query to the caller The service-role client bypasses RLS, so row ownership is enforced in tracker-store.ts by filtering on user_id. Lock that guarantee in: reads, updates and deletes must filter by the caller's user_id (and hackathon_id where applicable), and writes must stamp user_id onto the row. A dropped filter would leak or overwrite another user's tracker with no DB backstop. Co-authored-by: Cursor --- web/lib/tracker-store.test.ts | 165 ++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 web/lib/tracker-store.test.ts diff --git a/web/lib/tracker-store.test.ts b/web/lib/tracker-store.test.ts new file mode 100644 index 0000000..74b69dc --- /dev/null +++ b/web/lib/tracker-store.test.ts @@ -0,0 +1,165 @@ +/* --------------------------------------------------------------------------- + tracker-store is the only place the app touches the database with the + service-role key, which bypasses RLS. Row ownership is therefore enforced + here in code rather than by Postgres, so these tests exist to make that + guarantee load-bearing: every read, update and delete must be filtered by the + caller's user_id, and every write must stamp that same user_id onto the row. + A regression that dropped one of those filters would leak or overwrite another + user's tracker, and RLS would not catch it. + + The Supabase client is mocked with a chainable builder that records the calls + made against it, so the assertions are about *which filters were applied*, not + about a live database. +--------------------------------------------------------------------------- */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// server-only throws when imported outside a React Server Component; in the +// test runner it has nothing to guard, so stub it out. +vi.mock("server-only", () => ({})); + +type Call = [string, ...unknown[]]; +const calls: Call[] = []; + +// Result the awaited (non-maybeSingle) chain resolves to. Reads want `data`, +// writes only read `error`; a shape carrying both serves either. +let chainResult: { data: unknown; error: unknown } = { data: [], error: null }; +// Result the `.maybeSingle()` read inside an upsert resolves to. +let singleResult: { data: unknown; error: unknown } = { data: null, error: null }; + +const builder: Record = { + from: (t: string) => (calls.push(["from", t]), builder), + select: (s: string) => (calls.push(["select", s]), builder), + eq: (col: string, val: unknown) => (calls.push(["eq", col, val]), builder), + upsert: (payload: unknown, opts: unknown) => ( + calls.push(["upsert", payload, opts]), builder + ), + delete: () => (calls.push(["delete"]), builder), + maybeSingle: () => (calls.push(["maybeSingle"]), Promise.resolve(singleResult)), + // Makes the builder awaitable: `await client.from(...).select(...).eq(...)`. + then: (onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) => + Promise.resolve(chainResult).then(onFulfilled, onRejected), +}; + +vi.mock("@supabase/supabase-js", () => ({ + createClient: () => builder, +})); + +import { + deleteTrackerRow, + importTrackerRows, + listTracker, + upsertTrackerRow, +} from "./tracker-store"; + +const USER = "user_2abc"; +const OTHER = "user_2xyz"; +const ID_A = "57177cd1-cff8-4e80-b701-6811dbcdb1a4"; +const ID_B = "4c7865aa-543e-4ac8-9f47-808da1bffddc"; + +/** Every ["eq", col, val] recorded against the builder for a given column. */ +function eqFilters(col: string): unknown[] { + return calls.filter((c) => c[0] === "eq" && c[1] === col).map((c) => c[2]); +} + +function lastUpsert(): { payload: unknown; opts: unknown } | undefined { + const c = [...calls].reverse().find((c) => c[0] === "upsert"); + return c ? { payload: c[1], opts: c[2] } : undefined; +} + +beforeEach(() => { + process.env.SUPABASE_URL = "https://example.supabase.co"; + process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key"; + calls.length = 0; + chainResult = { data: [], error: null }; + singleResult = { data: null, error: null }; +}); + +describe("listTracker", () => { + it("reads user_hackathons scoped to the caller and never another user", async () => { + chainResult = { + data: [{ hackathon_id: ID_A, stage: "going", is_win: true }], + error: null, + }; + + const entries = await listTracker(USER); + + expect(calls).toContainEqual(["from", "user_hackathons"]); + expect(eqFilters("user_id")).toEqual([USER]); + expect(eqFilters("user_id")).not.toContain(OTHER); + expect(entries).toEqual([{ hackathonId: ID_A, stage: "going", isWin: true }]); + }); + + it("surfaces a database error rather than returning partial data", async () => { + chainResult = { data: null, error: { message: "boom" } }; + await expect(listTracker(USER)).rejects.toThrow("boom"); + }); +}); + +describe("upsertTrackerRow", () => { + it("scopes the pre-read by user_id AND hackathon_id and stamps user_id on the write", async () => { + const entry = await upsertTrackerRow(USER, ID_A, { stage: "applied" }); + + // The existence read is scoped to this user's row for this hackathon only. + expect(eqFilters("user_id")).toEqual([USER]); + expect(eqFilters("hackathon_id")).toEqual([ID_A]); + + // The written row carries the caller's id — a client body can't set it. + const up = lastUpsert(); + expect(up?.payload).toMatchObject({ + user_id: USER, + hackathon_id: ID_A, + stage: "applied", + is_win: false, + }); + expect(up?.opts).toMatchObject({ onConflict: "user_id,hackathon_id" }); + expect(entry).toEqual({ hackathonId: ID_A, stage: "applied", isWin: false }); + }); + + it("preserves the stored stage/win when the patch omits them (partial update)", async () => { + singleResult = { data: { stage: "going", is_win: true }, error: null }; + + const entry = await upsertTrackerRow(USER, ID_A, { isWin: false }); + + expect(entry).toEqual({ hackathonId: ID_A, stage: "going", isWin: false }); + expect(lastUpsert()?.payload).toMatchObject({ + user_id: USER, + stage: "going", + is_win: false, + }); + }); +}); + +describe("deleteTrackerRow", () => { + it("deletes only the caller's row for one hackathon", async () => { + await deleteTrackerRow(USER, ID_A); + + expect(calls).toContainEqual(["delete"]); + expect(eqFilters("user_id")).toEqual([USER]); + expect(eqFilters("hackathon_id")).toEqual([ID_A]); + }); +}); + +describe("importTrackerRows", () => { + it("stamps the caller's user_id on every imported row and stays additive", async () => { + await importTrackerRows(USER, [ + { hackathonId: ID_A, stage: "going", isWin: true }, + { hackathonId: ID_B, stage: "applied", isWin: false }, + ]); + + const up = lastUpsert(); + const rows = up?.payload as Array<{ user_id: string }>; + expect(rows).toHaveLength(2); + expect(rows.every((r) => r.user_id === USER)).toBe(true); + // ignoreDuplicates keeps the server's existing stage on a second device. + expect(up?.opts).toMatchObject({ + onConflict: "user_id,hackathon_id", + ignoreDuplicates: true, + }); + }); + + it("makes no database call for an empty import", async () => { + await importTrackerRows(USER, []); + expect(calls).toEqual([]); + }); +}); From df2efe751ce2baa81ab28a904a255f8e402cce11 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 14:02:07 -0700 Subject: [PATCH 09/18] fix(vercel): use Node-runtime proxy.ts instead of Edge middleware.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vercel runs Next middleware as an Edge Function, but Clerk pulls Node built-ins (#crypto, #safe-node-apis) the Edge runtime rejects — the "Edge Function is referencing unsupported modules" deploy error. Next 16 runs proxy.ts on the Node runtime, so restore that convention for this Vercel branch. Same auth logic; the Cloudflare/OpenNext branch keeps the Edge middleware.ts form it requires. Co-authored-by: Cursor --- web/{middleware.ts => proxy.ts} | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) rename web/{middleware.ts => proxy.ts} (59%) diff --git a/web/middleware.ts b/web/proxy.ts similarity index 59% rename from web/middleware.ts rename to web/proxy.ts index b08b7ad..53ab74f 100644 --- a/web/middleware.ts +++ b/web/proxy.ts @@ -2,19 +2,20 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; import { isClerkConfigured } from "@/lib/env"; -// This is deliberately `middleware.ts`, not Next 16's newer `proxy.ts`. +// Next 16's Node-runtime middleware convention (renamed from `middleware.ts`). // ----------------------------------------------------------------------------- -// Next 16 renamed Middleware -> Proxy and runs `proxy.ts` on the Node.js -// runtime. Our Cloudflare/OpenNext deploy target (issue #223) does NOT support -// Node.js middleware — `opennextjs-cloudflare build` hard-fails on it — but it -// does support the Edge runtime, which is exactly what the (now-deprecated) -// `middleware.ts` convention still compiles to. clerkMiddleware is Edge-safe, so -// keeping this as `middleware.ts` lets auth run unchanged while the app stays -// deployable to Workers. Next prints a middleware->proxy deprecation warning; -// that is expected and must stay until OpenNext supports Node proxy. +// This deploys to Vercel, where Clerk must run on the Node.js runtime: its +// shared modules pull Node built-ins (#crypto, #safe-node-apis) that the Edge +// runtime rejects — the "Edge Function is referencing unsupported modules" +// build error. Next 16 runs `proxy.ts` on Node, so keeping this as `proxy.ts` +// (not the deprecated Edge `middleware.ts`) is what lets Clerk auth build. // -// Clerk only takes over once its keys exist in .env.local — until then the -// site runs exactly as before (the /my hub shows setup instructions instead). +// The Cloudflare/OpenNext branch keeps this same logic as `middleware.ts` +// instead, because `opennextjs-cloudflare build` cannot compile Node middleware +// and only the Edge convention works there. Same code, different runtime file. +// +// Clerk only takes over once its keys exist — until then the site runs exactly +// as before (the /my hub shows setup instructions instead). // // /my is protected here, server-side: a signed-out visitor never reaches the // page. signInUrl/signUpUrl are pinned in code rather than left to From 6eb37939eaedfa9fce95a9c5fd4dccbf907cb0ac Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 14:34:15 -0700 Subject: [PATCH 10/18] chore: trigger Vercel production deploy on vercel branch Co-authored-by: Cursor From b894487915af7622ac5fe06a25253332a5a96aa1 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 14:43:04 -0700 Subject: [PATCH 11/18] docs(#226): record user_hackathons migration as applied via SQL Editor Applied by hand in the Supabase dashboard (no CLI/MCP configured), so it isn't in schema_migrations and its timestamp stays a placeholder. Update the file header and migrations README to state that plainly instead of claiming it's unapplied. Co-authored-by: Cursor --- .../20260725154500_user_hackathons.sql | 9 +++++--- supabase/migrations/README.md | 21 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/supabase/migrations/20260725154500_user_hackathons.sql b/supabase/migrations/20260725154500_user_hackathons.sql index cf62223..a837172 100644 --- a/supabase/migrations/20260725154500_user_hackathons.sql +++ b/supabase/migrations/20260725154500_user_hackathons.sql @@ -1,7 +1,10 @@ -- supabase/migrations/20260725154500_user_hackathons.sql --- NOTE: not yet applied. Every other file here is named after the version --- `apply_migration` recorded, per README. Rename this one to the version that --- call reports before treating the two lists as aligned. +-- 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). diff --git a/supabase/migrations/README.md b/supabase/migrations/README.md index 800c82d..04a8c9e 100644 --- a/supabase/migrations/README.md +++ b/supabase/migrations/README.md @@ -28,17 +28,22 @@ 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 is **committed but not yet applied**, so for now `ls` here returns one -more entry than `list_migrations` does: +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` | written, never sent to `apply_migration` | - -Its timestamp is therefore a placeholder rather than a recorded version. Send it -through `apply_migration`, rename the file to the version that call reports, then -delete this section — until that happens the two lists do not align, and -`supabase db push` would try to replay it. +| `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 From ae7b9301ff90961865fcefe5d0bd877985516a29 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 14:45:24 -0700 Subject: [PATCH 12/18] fix(#226): revoke Supabase default grants from anon on user_hackathons New public tables inherit anon/authenticated grants from Supabase's stock ALTER DEFAULT PRIVILEGES. A per-user tracker must not: revoke everything from anon, and drop TRUNCATE/REFERENCES/TRIGGER from authenticated (TRUNCATE bypasses RLS). RLS already blocked anon row access, so this is defense-in-depth plus matching the migration's stated intent. Co-authored-by: Cursor --- supabase/migrations/20260725154500_user_hackathons.sql | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/supabase/migrations/20260725154500_user_hackathons.sql b/supabase/migrations/20260725154500_user_hackathons.sql index a837172..f5b73fc 100644 --- a/supabase/migrations/20260725154500_user_hackathons.sql +++ b/supabase/migrations/20260725154500_user_hackathons.sql @@ -79,5 +79,14 @@ create policy "delete own tracker" 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. From 3e071fa495055a79dffde2feef76dec49b723ed6 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 14:47:27 -0700 Subject: [PATCH 13/18] chore: redeploy to pick up Supabase env Co-authored-by: Cursor From 00630ba355dcc9415d67bd3faaf9e7b8e7095800 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Mon, 27 Jul 2026 14:31:18 -0700 Subject: [PATCH 14/18] docs(#223): retarget deployment to Vercel and finish the proxy.ts rename The middleware.ts -> proxy.ts rename earlier on this branch changed the file but not the eight places that name it, so the README still documented Edge middleware and Cloudflare Workers as the production target while production had already moved to Vercel. Deployment section now states the real target and, more importantly, why it must deploy from `main`: six workflows push listing updates there, listing data is frozen into the bundle at build time, so those commits only reach users by triggering a rebuild. Deploying from a long-lived branch silently strips the site of every automated listing update. Cloudflare files stay. What blocks that path is auth and it is a genuine either/or -- Clerk needs Node (proxy.ts) on Vercel, OpenNext accepts only Edge (middleware.ts) -- so the section says plainly that preview/deploy fail today, that reviving them breaks Vercel the moment it lands, and that the clean exit is upstream Node-proxy support rather than a local workaround. Also documents the two Supabase variables the tracker needs in production, and drops the stale claim about a middleware->proxy deprecation warning: that warning is gone precisely because the file is now proxy.ts. Co-Authored-By: Claude Opus 5 (1M context) --- web/README.md | 119 ++++++++++++++++++++------------ web/app/layout.tsx | 2 +- web/components/hq/my-client.tsx | 2 +- web/proxy.ts | 8 ++- 4 files changed, 83 insertions(+), 48 deletions(-) diff --git a/web/README.md b/web/README.md index 4799601..3329916 100644 --- a/web/README.md +++ b/web/README.md @@ -88,7 +88,7 @@ repo-root files (`README.md`, `listings.json`, `geocodes.json`) into `lib/generated/` at build time, and the loaders **import** them — so the data is frozen into the deployment at build, and a revalidation re-runs the loader over that *deployed* copy, not whatever is on `main` now. (No request-time filesystem -read remains, which is what lets the site deploy to Cloudflare Workers — see +read remains, which is what keeps the app portable across hosts — see [Deployment](#deployment).) | Changes without a rebuild | Needs a new build + deploy | @@ -163,9 +163,9 @@ web/ │ ├── types-hq.ts # Hackathon types and display helpers │ └── types.ts # Legacy opportunity types ├── drizzle.config.ts # Drizzle Kit config -├── open-next.config.ts # OpenNext adapter for Cloudflare Workers +├── open-next.config.ts # OpenNext adapter — Cloudflare, see Deployment ├── wrangler.jsonc # Cloudflare Workers config (nodejs_compat) -└── middleware.ts # Clerk auth (Edge; see Deployment for why not proxy.ts) +└── proxy.ts # Clerk auth (Node runtime; see Deployment) ``` ## Getting started @@ -191,18 +191,20 @@ Copy `.env.example` to `.env.local` (gitignored) and set the values you need. | Variable | Required | Used by | If missing | | -------- | -------- | ------- | ---------- | | `NEXT_PUBLIC_MAPBOX_TOKEN` | For globe | `components/hq/globe-map.tsx` | Globe shows a placeholder instead of the Mapbox map | -| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | For auth | `app/layout.tsx`, `app/my/page.tsx`, `middleware.ts` | Site runs without Clerk; `/my` shows setup instructions and `/auth/*` redirects to `/my` | -| `CLERK_SECRET_KEY` | For auth | `app/my/page.tsx`, `middleware.ts` | Same as above — both Clerk keys are needed together | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | For auth | `app/layout.tsx`, `app/my/page.tsx`, `proxy.ts` | Site runs without Clerk; `/my` shows setup instructions and `/auth/*` redirects to `/my` | +| `CLERK_SECRET_KEY` | For auth | `app/my/page.tsx`, `proxy.ts` | Same as above — both Clerk keys are needed together | +| `SUPABASE_URL` | For tracker sync | `lib/tracker-store.ts` | Tracker stays browser-local; `/api/tracker` reports `synced: false` | +| `SUPABASE_SERVICE_ROLE_KEY` | For tracker sync | `lib/tracker-store.ts` | Same as above — both Supabase values are needed together, **and** Clerk must be configured or there is no user to attribute a row to | | `DATABASE_URL` | For DB scripts | `drizzle.config.ts` | `npm run db:*` commands fail fast before touching Supabase | The two keys are the only Clerk variables you need. The auth routes (`/auth/sign-in`, `/auth/sign-up`) and the post-sign-in landing (`/my`) are -pinned in `middleware.ts` and `components/hq/auth-screen.tsx` rather than read from +pinned in `proxy.ts` and `components/hq/auth-screen.tsx` rather than read from `NEXT_PUBLIC_CLERK_*_URL` env vars — when those are unset, Clerk redirects to its hosted account portal instead of the app's own screens. Clerk is **optional**. When both keys are set, `ClerkProvider` wraps the app, -`/my` is protected in `middleware.ts` (signed-out visitors are redirected to +`/my` is protected in `proxy.ts` (signed-out visitors are redirected to `/auth/sign-in`), and users can sign in with Google, GitHub, or email/password. Without them, the tracker still works locally; nothing is persisted server-side. @@ -220,8 +222,8 @@ connections, and enable email/password under email authentication. | `npm test` | Run the Vitest suite (what CI runs) | | `npm run copy-assets` | Refresh `public/repo-assets/` from `../assets/` | | `npm run prepare-data` | Regenerate `lib/generated/` from the repo-root data | -| `npm run preview` | Build for Cloudflare and preview the Worker locally | -| `npm run deploy` | Build for Cloudflare and deploy to Workers | +| `npm run preview` | Cloudflare only — currently fails, see [Deployment](#deployment) | +| `npm run deploy` | Cloudflare only — currently fails, see [Deployment](#deployment) | `dev`, `build`, and `test` run `copy-assets` and/or `prepare-data` for you; you only need them directly after changing something under `../assets/` or the @@ -241,40 +243,70 @@ between deploys; it does not fetch new content. See [Render model](#render-model ## Deployment -Production target: **Cloudflare Workers** via -[OpenNext](https://opennext.js.org/cloudflare) (issue #223). The app carries no -request-time filesystem dependency — repo data is imported as build-time -constants (see [Render model](#render-model)) — so the standard OpenNext adapter -builds and runs it unchanged. - -```bash -npm run preview # build for Workers and run it locally (wrangler dev) -npm run deploy # build for Workers and deploy -``` - -### Middleware runs on the Edge (why `middleware.ts`, not `proxy.ts`) - -Next 16 renamed Middleware to Proxy and runs `proxy.ts` on the **Node.js** -runtime. OpenNext's Cloudflare build does **not** support Node.js middleware -(`opennextjs-cloudflare build` fails on it), but it does support the **Edge** -runtime — which is what the older, now-deprecated `middleware.ts` convention -still compiles to. Clerk's `clerkMiddleware` is Edge-safe, so the auth -middleware lives in `middleware.ts` and runs on the Edge, keeping the app both -authenticated and Workers-deployable. `next build` prints a middleware→proxy -deprecation warning; that is expected and stays until OpenNext supports Node -proxy. - -Configuration lives in `wrangler.jsonc` (`nodejs_compat` is required) and -`open-next.config.ts`. Set production values as follows: - -- **Build-time, public** (`NEXT_PUBLIC_*` — Mapbox token, Clerk publishable key, - repo slug): pass as environment variables to the build, or via - `wrangler.jsonc` `vars`. They are inlined into the client bundle. -- **Runtime secret** (`CLERK_SECRET_KEY`): set with - `npx wrangler secret put CLERK_SECRET_KEY` — never commit it. - -Vercel remains a drop-in fallback (`git push`, zero config): the same build works -there because nothing is Workers-specific. +Production target: **Vercel** (issue #223), deploying from `main` via the Vercel +Git integration. There is no deploy workflow in `.github/workflows/` — the +integration *is* the pipeline, and it is what makes the listing automation work: +`closing_soon`, `auto_extract`, `contribution_approved`, `update_readmes` and the +gallery workflows all push commits to `main`, and because listing data is frozen +into the bundle at build time (see [Render model](#render-model)), each of those +pushes only reaches users because it triggers a rebuild. + +That coupling is the thing to protect. **Production must deploy from `main`.** +Pointing it at a long-lived branch silently strips the site of every automated +listing update, because those commits land on `main` and nowhere else. + +### Environment variables in production + +Set these in the Vercel project (Settings → Environment Variables). The +`NEXT_PUBLIC_*` values are inlined into the client bundle at build time; the rest +are server-only and must never gain a `NEXT_PUBLIC_` prefix. + +| Variable | Scope | Notes | +| -------- | ----- | ----- | +| `NEXT_PUBLIC_MAPBOX_TOKEN` | Build, public | Globe renders a placeholder without it | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Build, public | Both Clerk values or neither | +| `CLERK_SECRET_KEY` | Runtime, secret | Both Clerk values or neither | +| `SUPABASE_URL` | Runtime, secret | Both Supabase values or neither, **and** Clerk configured | +| `SUPABASE_SERVICE_ROLE_KEY` | Runtime, secret | Bypasses RLS — see [#235](https://github.com/Hack-HQ/hackhq/issues/235) | + +Every one is optional and degrades gracefully: without Mapbox the globe shows a +placeholder, without Clerk the tracker stays browser-local, without Supabase it +stays browser-local for signed-in users too. `validateEnv()` in `lib/env.ts` +warns on the half-configured cases rather than failing the build. + +### Auth runs on the Node runtime (why `proxy.ts`) + +Clerk's shared modules import Node built-ins (`#crypto`, `#safe-node-apis`). On +the Edge runtime that is the *"Edge Function is referencing unsupported modules"* +build error, so on Vercel the auth layer has to run on Node. Next 16 renamed +Middleware to Proxy and runs `proxy.ts` on the Node.js runtime — and per the Next +docs the `runtime` config option is **not available in Proxy files** and throws +if set. So `proxy.ts` is Node, not by preference but by construction. + +### Cloudflare Workers — retained, not currently deployable + +`wrangler.jsonc`, `open-next.config.ts` and the `preview` / `deploy` / +`cf-typegen` scripts are all still here, and the runtime work from #230 that made +them viable still stands: the app has no request-time filesystem dependency, so +it *builds* for Workers. + +What blocks it is auth, and it is a genuine either/or: + +| | Vercel | Cloudflare / OpenNext | +| --- | --- | --- | +| File convention | `proxy.ts` | `middleware.ts` | +| Runtime | Node | Edge | +| The other one fails with | Edge rejects Clerk's Node built-ins | `Node.js middleware is not currently supported.` | + +One file, two hosts, mutually exclusive — and whichever convention is committed, +the other host's build breaks. Reviving the Cloudflare path means renaming +`proxy.ts` back to `middleware.ts` (the logic is identical; only the filename and +runtime differ), which immediately breaks Vercel. Do not do it on `main` while +`main` is what production deploys. + +The clean exit is upstream: once OpenNext supports Node proxy, both hosts read +the same file and the fork disappears. Until then this is the standing reason +`npm run preview` and `npm run deploy` fail — not a misconfiguration. ## Tech stack @@ -283,6 +315,7 @@ there because nothing is Workers-specific. - [Tailwind CSS 4](https://tailwindcss.com) - [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js/) (globe) - [Clerk](https://clerk.com/) (optional auth) +- [Supabase](https://supabase.com/) (optional per-user tracker persistence) - TypeScript ## Notes diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 699cab8..5ccca8e 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -53,7 +53,7 @@ export default function RootLayout({ ); // Mount only when Clerk is FULLY configured (both keys), - // matching isClerkConfigured() used by middleware.ts and the /my + /auth gates. + // matching isClerkConfigured() used by proxy.ts and the /my + /auth gates. // A partial config (one key) previously mounted the provider here while the // proxy and pages treated auth as off — an inconsistent, fail-open state. // Now all surfaces agree: a half-configured deploy runs consistently in open diff --git a/web/components/hq/my-client.tsx b/web/components/hq/my-client.tsx index 68434a2..ed4a0e2 100644 --- a/web/components/hq/my-client.tsx +++ b/web/components/hq/my-client.tsx @@ -53,7 +53,7 @@ function GatedHub({ hackathons }: { hackathons: Hackathon[] }) { } /* ----- Signed-out: the members gate ----- - middleware.ts redirects signed-out visitors to /auth/sign-in before this page + proxy.ts redirects signed-out visitors to /auth/sign-in before this page renders, so this is only a backstop. It links to that screen rather than embedding again: the form uses path routing bound to /auth/sign-in and misbehaves when mounted on another route. */ diff --git a/web/proxy.ts b/web/proxy.ts index 53ab74f..d565f8e 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -10,9 +10,11 @@ import { isClerkConfigured } from "@/lib/env"; // build error. Next 16 runs `proxy.ts` on Node, so keeping this as `proxy.ts` // (not the deprecated Edge `middleware.ts`) is what lets Clerk auth build. // -// The Cloudflare/OpenNext branch keeps this same logic as `middleware.ts` -// instead, because `opennextjs-cloudflare build` cannot compile Node middleware -// and only the Edge convention works there. Same code, different runtime file. +// Reviving the Cloudflare/OpenNext path means renaming this file back to +// `middleware.ts` — same logic, Edge runtime — because `opennextjs-cloudflare +// build` cannot compile Node middleware. That rename breaks the Vercel build the +// moment it lands, so it must not happen on `main` while `main` is what +// production deploys. See the Deployment section of README.md. // // Clerk only takes over once its keys exist — until then the site runs exactly // as before (the /my hub shows setup instructions instead). From da8baa56b033599e0cca0d040a0bf1dc5436e4e9 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Mon, 27 Jul 2026 14:31:29 -0700 Subject: [PATCH 15/18] build: pin the Turbopack workspace root to web/ Next infers the workspace root by walking up for a lockfile, so any stray package-lock.json above web/ wins. On this machine it selected one in $HOME and built the app rooted outside the repo entirely. Two fixes, because the stray file and the inference are separate problems. b7f323b already had to delete an accidentally committed root lockfile once and it had come back untracked, so gitignore it -- there is no npm project at the repo root to need one. That alone was not enough, since the winning lockfile was outside the repo where gitignore cannot reach, hence pinning root explicitly. Pinning also matches what the app actually needs. Since #230 nothing outside web/ is read at build -- repo data is copied into lib/generated/ first -- and Turbopack does not resolve files outside the root, so this turns that invariant into something enforced rather than incidental. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 6 ++++++ web/next.config.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/.gitignore b/.gitignore index 61de2b0..2094144 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,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 diff --git a/web/next.config.ts b/web/next.config.ts index b3f3fa1..dd6436d 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,3 +1,6 @@ +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + import type { NextConfig } from "next"; // Single source of repo identity (mirrors lib/repo.ts; kept inline so the @@ -81,6 +84,14 @@ const nextConfig: NextConfig = { ]; }, turbopack: { + // Pin the workspace root to web/ instead of letting Turbopack infer it. + // Inference walks up looking for a lockfile, so a stray package-lock.json + // anywhere above this directory silently wins — it has resolved to $HOME on + // a dev machine. Pinning also matches what the app actually needs: since + // #230 nothing outside web/ is read at build (repo data is copied into + // lib/generated/ first), and Turbopack does not resolve files outside the + // root, so this makes that invariant enforced rather than incidental. + root: dirname(fileURLToPath(import.meta.url)), resolveAlias: { // Vendored Framer modules (components/vendor/*) import "framer" for // design-tool APIs; route that package to a tiny local shim. From e4f3e278fe2514b488f00e23e347bf0ec7c81920 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Sat, 1 Aug 2026 09:12:56 -0700 Subject: [PATCH 16/18] fix(#223): keep auth as Edge middleware so Workers Builds passes PR #236's Cloudflare check has been failing since the branch renamed middleware.ts -> proxy.ts. Next 16 runs proxy.ts on the Node runtime and `opennextjs-cloudflare build` hard-fails on it: ERROR Node.js middleware is not currently supported. Cloudflare is the deploy target and builds from `main`, so the rename has to go back. The diff between the two files was comment-only -- imports, clerkMiddleware, the route matcher and the config matcher are byte-identical -- so this restores the filename and rewrites the surrounding docs, changing no behaviour. The rename was made on the understanding that Clerk pulls Node built-ins (#crypto, #safe-node-apis) that Edge rejects. That is no longer true on @clerk/nextjs 7.6.0: `main` carries this file as Edge middleware and its Workers Builds run is green. Deleting the middleware outright is not an alternative. auth() requires clerkMiddleware to have run, so without it every server-side caller -- including /api/tracker, which the synced tracker in this PR depends on -- fails with "auth() was called but Clerk can't detect usage of clerkMiddleware()". Both the file header and the README now say so, so the approach is not retried. Verified: eslint, tsc --noEmit, 191 tests, next build, and `opennextjs-cloudflare build` all clean; the middleware manifest compiles to server/edge/chunks (Edge, not Node) and wrangler deploy --dry-run accepts the resulting worker. Co-Authored-By: Claude Opus 5 (1M context) --- web/README.md | 79 ++++++++++++++++++++------------- web/app/layout.tsx | 2 +- web/components/hq/my-client.tsx | 2 +- web/{proxy.ts => middleware.ts} | 29 +++++++----- 4 files changed, 67 insertions(+), 45 deletions(-) rename web/{proxy.ts => middleware.ts} (52%) diff --git a/web/README.md b/web/README.md index 3329916..d6906fd 100644 --- a/web/README.md +++ b/web/README.md @@ -165,7 +165,7 @@ web/ ├── drizzle.config.ts # Drizzle Kit config ├── open-next.config.ts # OpenNext adapter — Cloudflare, see Deployment ├── wrangler.jsonc # Cloudflare Workers config (nodejs_compat) -└── proxy.ts # Clerk auth (Node runtime; see Deployment) +└── middleware.ts # Clerk auth (Edge; see Deployment for why not proxy.ts) ``` ## Getting started @@ -191,20 +191,20 @@ Copy `.env.example` to `.env.local` (gitignored) and set the values you need. | Variable | Required | Used by | If missing | | -------- | -------- | ------- | ---------- | | `NEXT_PUBLIC_MAPBOX_TOKEN` | For globe | `components/hq/globe-map.tsx` | Globe shows a placeholder instead of the Mapbox map | -| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | For auth | `app/layout.tsx`, `app/my/page.tsx`, `proxy.ts` | Site runs without Clerk; `/my` shows setup instructions and `/auth/*` redirects to `/my` | -| `CLERK_SECRET_KEY` | For auth | `app/my/page.tsx`, `proxy.ts` | Same as above — both Clerk keys are needed together | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | For auth | `app/layout.tsx`, `app/my/page.tsx`, `middleware.ts` | Site runs without Clerk; `/my` shows setup instructions and `/auth/*` redirects to `/my` | +| `CLERK_SECRET_KEY` | For auth | `app/my/page.tsx`, `middleware.ts` | Same as above — both Clerk keys are needed together | | `SUPABASE_URL` | For tracker sync | `lib/tracker-store.ts` | Tracker stays browser-local; `/api/tracker` reports `synced: false` | | `SUPABASE_SERVICE_ROLE_KEY` | For tracker sync | `lib/tracker-store.ts` | Same as above — both Supabase values are needed together, **and** Clerk must be configured or there is no user to attribute a row to | | `DATABASE_URL` | For DB scripts | `drizzle.config.ts` | `npm run db:*` commands fail fast before touching Supabase | The two keys are the only Clerk variables you need. The auth routes (`/auth/sign-in`, `/auth/sign-up`) and the post-sign-in landing (`/my`) are -pinned in `proxy.ts` and `components/hq/auth-screen.tsx` rather than read from +pinned in `middleware.ts` and `components/hq/auth-screen.tsx` rather than read from `NEXT_PUBLIC_CLERK_*_URL` env vars — when those are unset, Clerk redirects to its hosted account portal instead of the app's own screens. Clerk is **optional**. When both keys are set, `ClerkProvider` wraps the app, -`/my` is protected in `proxy.ts` (signed-out visitors are redirected to +`/my` is protected in `middleware.ts` (signed-out visitors are redirected to `/auth/sign-in`), and users can sign in with Google, GitHub, or email/password. Without them, the tracker still works locally; nothing is persisted server-side. @@ -274,39 +274,54 @@ placeholder, without Clerk the tracker stays browser-local, without Supabase it stays browser-local for signed-in users too. `validateEnv()` in `lib/env.ts` warns on the half-configured cases rather than failing the build. -### Auth runs on the Node runtime (why `proxy.ts`) +### Auth runs as Edge middleware (why `middleware.ts`) -Clerk's shared modules import Node built-ins (`#crypto`, `#safe-node-apis`). On -the Edge runtime that is the *"Edge Function is referencing unsupported modules"* -build error, so on Vercel the auth layer has to run on Node. Next 16 renamed -Middleware to Proxy and runs `proxy.ts` on the Node.js runtime — and per the Next -docs the `runtime` config option is **not available in Proxy files** and throws -if set. So `proxy.ts` is Node, not by preference but by construction. +Next 16 renamed Middleware to Proxy and runs `proxy.ts` on the Node.js runtime. +This app stays on the older `middleware.ts` convention on purpose, because +`opennextjs-cloudflare build` rejects Node middleware outright: -### Cloudflare Workers — retained, not currently deployable +``` +ERROR Node.js middleware is not currently supported. Consider switching to Edge Middleware. +``` -`wrangler.jsonc`, `open-next.config.ts` and the `preview` / `deploy` / -`cf-typegen` scripts are all still here, and the runtime work from #230 that made -them viable still stands: the app has no request-time filesystem dependency, so -it *builds* for Workers. +Edge is what `middleware.ts` compiles to, and `clerkMiddleware` runs there +fine, so one file satisfies both hosts. Next prints a middleware→proxy +deprecation warning; that is expected and stays until OpenNext supports Node +proxy. + +An earlier revision moved this to `proxy.ts` on the understanding that Clerk +pulled Node built-ins (`#crypto`, `#safe-node-apis`) that Edge rejects with +*"Edge Function is referencing unsupported modules"*. As of `@clerk/nextjs` +7.6.0 that no longer happens: `main` carries the file as Edge middleware and +**both** hosts build it green. If you hit that error again, pin the Clerk +version in the fix rather than renaming the file — the rename breaks Cloudflare. -What blocks it is auth, and it is a genuine either/or: +**The middleware cannot simply be deleted** in favour of gating `/my` inside the +page. `auth()` requires `clerkMiddleware` to have run; without it every +server-side caller — including `/api/tracker`, which the synced tracker depends +on — fails with *"auth() was called but Clerk can't detect usage of +clerkMiddleware()"*. + +### Both hosts build from `main` + +`wrangler.jsonc`, `open-next.config.ts` and the `preview` / `deploy` / +`cf-typegen` scripts are all live, not vestigial. The runtime work from #230 +stands — no request-time filesystem dependency — so the app builds and deploys +for Workers. -| | Vercel | Cloudflare / OpenNext | +| | Vercel | Cloudflare Workers | | --- | --- | --- | -| File convention | `proxy.ts` | `middleware.ts` | -| Runtime | Node | Edge | -| The other one fails with | Edge rejects Clerk's Node built-ins | `Node.js middleware is not currently supported.` | - -One file, two hosts, mutually exclusive — and whichever convention is committed, -the other host's build breaks. Reviving the Cloudflare path means renaming -`proxy.ts` back to `middleware.ts` (the logic is identical; only the filename and -runtime differ), which immediately breaks Vercel. Do not do it on `main` while -`main` is what production deploys. - -The clean exit is upstream: once OpenNext supports Node proxy, both hosts read -the same file and the fork disappears. Until then this is the standing reason -`npm run preview` and `npm run deploy` fail — not a misconfiguration. +| Trigger | Vercel Git integration | Workers Builds (Git integration) | +| Branch | `main` | `main` | +| Build | `next build` | `npx opennextjs-cloudflare build` | +| Root | `web` | `/web` | + +Both watch `main`, so any commit that lands there — a PR merge or a listing +workflow's push — rebuilds both. Environment variables have to be set in **both** +dashboards; on Cloudflare the `NEXT_PUBLIC_*` pair are *build* variables +(Settings → Build → Variables and secrets) while the rest are runtime secrets +(Settings → Variables & Secrets), because build variables are not readable at +runtime. ## Tech stack diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 5ccca8e..699cab8 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -53,7 +53,7 @@ export default function RootLayout({ ); // Mount only when Clerk is FULLY configured (both keys), - // matching isClerkConfigured() used by proxy.ts and the /my + /auth gates. + // matching isClerkConfigured() used by middleware.ts and the /my + /auth gates. // A partial config (one key) previously mounted the provider here while the // proxy and pages treated auth as off — an inconsistent, fail-open state. // Now all surfaces agree: a half-configured deploy runs consistently in open diff --git a/web/components/hq/my-client.tsx b/web/components/hq/my-client.tsx index ed4a0e2..68434a2 100644 --- a/web/components/hq/my-client.tsx +++ b/web/components/hq/my-client.tsx @@ -53,7 +53,7 @@ function GatedHub({ hackathons }: { hackathons: Hackathon[] }) { } /* ----- Signed-out: the members gate ----- - proxy.ts redirects signed-out visitors to /auth/sign-in before this page + middleware.ts redirects signed-out visitors to /auth/sign-in before this page renders, so this is only a backstop. It links to that screen rather than embedding again: the form uses path routing bound to /auth/sign-in and misbehaves when mounted on another route. */ diff --git a/web/proxy.ts b/web/middleware.ts similarity index 52% rename from web/proxy.ts rename to web/middleware.ts index d565f8e..afe2089 100644 --- a/web/proxy.ts +++ b/web/middleware.ts @@ -2,19 +2,26 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; import { isClerkConfigured } from "@/lib/env"; -// Next 16's Node-runtime middleware convention (renamed from `middleware.ts`). +// This is deliberately `middleware.ts`, not Next 16's newer `proxy.ts`. // ----------------------------------------------------------------------------- -// This deploys to Vercel, where Clerk must run on the Node.js runtime: its -// shared modules pull Node built-ins (#crypto, #safe-node-apis) that the Edge -// runtime rejects — the "Edge Function is referencing unsupported modules" -// build error. Next 16 runs `proxy.ts` on Node, so keeping this as `proxy.ts` -// (not the deprecated Edge `middleware.ts`) is what lets Clerk auth build. +// Next 16 renamed Middleware -> Proxy and runs `proxy.ts` on the Node.js +// runtime. `opennextjs-cloudflare build` hard-fails on Node middleware +// ("Node.js middleware is not currently supported"), but it compiles the Edge +// runtime that `middleware.ts` still targets — so this filename is what keeps +// the app deployable to Workers. Next prints a middleware->proxy deprecation +// warning; that is expected and must stay until OpenNext supports Node proxy. // -// Reviving the Cloudflare/OpenNext path means renaming this file back to -// `middleware.ts` — same logic, Edge runtime — because `opennextjs-cloudflare -// build` cannot compile Node middleware. That rename breaks the Vercel build the -// moment it lands, so it must not happen on `main` while `main` is what -// production deploys. See the Deployment section of README.md. +// This file cannot simply be deleted in favour of gating /my inside the page: +// `auth()` requires clerkMiddleware to have run, and without it every server- +// side caller — including /api/tracker, which the whole synced tracker depends +// on — fails with "auth() was called but Clerk can't detect usage of +// clerkMiddleware()". +// +// An earlier revision moved this to `proxy.ts`, on the understanding that Clerk +// pulled Node built-ins (#crypto, #safe-node-apis) that Edge rejects. With +// @clerk/nextjs 7.6.0 that is no longer so: `main` carries this file as Edge +// middleware and both hosts build it green — Workers Builds and Vercel alike. +// Verify with a Vercel build before reintroducing the rename. // // Clerk only takes over once its keys exist — until then the site runs exactly // as before (the /my hub shows setup instructions instead). From 92efb51b4881f8863b29f582068ae4ea44cfc93d Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Sun, 9 Aug 2026 23:46:09 -0700 Subject: [PATCH 17/18] fix(tracker): make a partial tracker update atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upsertTrackerRow read the row, merged the patch in JS, then wrote 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 documented 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 enough. tracker-store.test.ts covered the partial merge but nothing concurrent, so it passed throughout. public.upsert_tracker_row does the coalesce inside the same ON CONFLICT DO UPDATE that writes the row, so there is no window to interleave with. A null argument means "leave this column alone", which is how an omitted patch field is expressed without a prior read. security invoker, matching every other function in supabase/migrations: 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 table policies apply to this write too. A security definer function would quietly opt out of the enforcement #235 exists to gain. The replacement test asserts there is no pre-read at all, rather than asserting the merged payload — the absence of the read is the property that matters, and it fails against the old implementation. NOTE: the migration is not applied yet. This project has no Supabase CLI wired up, so it has to be run by hand through the SQL Editor before this ships; until the function exists every PUT /api/tracker fails. See #254 for the wider drift between these files and the live schema. Co-Authored-By: Claude Opus 5 (1M context) --- .../20260810064325_atomic_tracker_upsert.sql | 73 +++++++++++++++++++ web/lib/tracker-store.test.ts | 71 ++++++++++++------ web/lib/tracker-store.ts | 56 +++++++------- 3 files changed, 153 insertions(+), 47 deletions(-) create mode 100644 supabase/migrations/20260810064325_atomic_tracker_upsert.sql diff --git a/supabase/migrations/20260810064325_atomic_tracker_upsert.sql b/supabase/migrations/20260810064325_atomic_tracker_upsert.sql new file mode 100644 index 0000000..fc10cc6 --- /dev/null +++ b/supabase/migrations/20260810064325_atomic_tracker_upsert.sql @@ -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; diff --git a/web/lib/tracker-store.test.ts b/web/lib/tracker-store.test.ts index 74b69dc..d97ed99 100644 --- a/web/lib/tracker-store.test.ts +++ b/web/lib/tracker-store.test.ts @@ -26,6 +26,9 @@ const calls: Call[] = []; let chainResult: { data: unknown; error: unknown } = { data: [], error: null }; // Result the `.maybeSingle()` read inside an upsert resolves to. let singleResult: { data: unknown; error: unknown } = { data: null, error: null }; +// Result `.rpc()` resolves to. The atomic upsert function `returns table`, so a +// real client hands back a one-row array. +let rpcResult: { data: unknown; error: unknown } = { data: [], error: null }; const builder: Record = { from: (t: string) => (calls.push(["from", t]), builder), @@ -35,6 +38,9 @@ const builder: Record = { calls.push(["upsert", payload, opts]), builder ), delete: () => (calls.push(["delete"]), builder), + rpc: (fn: string, args: unknown) => ( + calls.push(["rpc", fn, args]), Promise.resolve(rpcResult) + ), maybeSingle: () => (calls.push(["maybeSingle"]), Promise.resolve(singleResult)), // Makes the builder awaitable: `await client.from(...).select(...).eq(...)`. then: (onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) => @@ -67,12 +73,19 @@ function lastUpsert(): { payload: unknown; opts: unknown } | undefined { return c ? { payload: c[1], opts: c[2] } : undefined; } +function lastRpc(): { fn: unknown; args: unknown } | undefined { + const c = [...calls].reverse().find((c) => c[0] === "rpc"); + return c ? { fn: c[1], args: c[2] } : undefined; +} + beforeEach(() => { process.env.SUPABASE_URL = "https://example.supabase.co"; process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key"; calls.length = 0; chainResult = { data: [], error: null }; singleResult = { data: null, error: null }; + // Reset too, or the error case below leaks into whichever test runs next. + rpcResult = { data: [], error: null }; }); describe("listTracker", () => { @@ -97,36 +110,52 @@ describe("listTracker", () => { }); describe("upsertTrackerRow", () => { - it("scopes the pre-read by user_id AND hackathon_id and stamps user_id on the write", async () => { - const entry = await upsertTrackerRow(USER, ID_A, { stage: "applied" }); + it("stamps the caller's user_id on the write and never another user's", async () => { + rpcResult = { data: [{ stage: "applied", is_win: false }], error: null }; - // The existence read is scoped to this user's row for this hackathon only. - expect(eqFilters("user_id")).toEqual([USER]); - expect(eqFilters("hackathon_id")).toEqual([ID_A]); + const entry = await upsertTrackerRow(USER, ID_A, { stage: "applied" }); - // The written row carries the caller's id — a client body can't set it. - const up = lastUpsert(); - expect(up?.payload).toMatchObject({ - user_id: USER, - hackathon_id: ID_A, - stage: "applied", - is_win: false, - }); - expect(up?.opts).toMatchObject({ onConflict: "user_id,hackathon_id" }); + // The row is written for the caller resolved from the Clerk session — a + // client request body has no way to reach p_user_id. + const call = lastRpc(); + expect(call?.fn).toBe("upsert_tracker_row"); + expect(call?.args).toMatchObject({ p_user_id: USER, p_hackathon_id: ID_A }); + expect(call?.args).not.toMatchObject({ p_user_id: OTHER }); expect(entry).toEqual({ hackathonId: ID_A, stage: "applied", isWin: false }); }); - it("preserves the stored stage/win when the patch omits them (partial update)", async () => { - singleResult = { data: { stage: "going", is_win: true }, error: null }; + it("does the partial merge in one statement instead of reading first", async () => { + // The guarantee this protects: a concurrent PUT must not be able to observe + // a half-applied update. Reading the row and merging in JS left a window + // where two requests read the same snapshot and the second clobbered the + // first, so there must be no pre-read at all. + rpcResult = { data: [{ stage: "going", is_win: false }], error: null }; + + await upsertTrackerRow(USER, ID_A, { isWin: false }); + + expect(calls.some((c) => c[0] === "maybeSingle")).toBe(false); + expect(calls.some((c) => c[0] === "upsert")).toBe(false); + expect(lastRpc()?.fn).toBe("upsert_tracker_row"); + }); + + it("sends null for an omitted field so the database keeps the stored value", async () => { + rpcResult = { data: [{ stage: "going", is_win: false }], error: null }; const entry = await upsertTrackerRow(USER, ID_A, { isWin: false }); + // null, not a default: "leave stage alone" has to be distinguishable from + // "set stage to interested", or recording a win would reset the pipeline. + expect(lastRpc()?.args).toMatchObject({ p_stage: null, p_is_win: false }); + // The row the function returns is what the caller gets back, not the patch. expect(entry).toEqual({ hackathonId: ID_A, stage: "going", isWin: false }); - expect(lastUpsert()?.payload).toMatchObject({ - user_id: USER, - stage: "going", - is_win: false, - }); + }); + + it("surfaces a database error rather than reporting a write that did not land", async () => { + rpcResult = { data: null, error: { message: "deadlock detected" } }; + + await expect(upsertTrackerRow(USER, ID_A, { stage: "applied" })).rejects.toThrow( + "deadlock detected", + ); }); }); diff --git a/web/lib/tracker-store.ts b/web/lib/tracker-store.ts index 4419f69..ac69368 100644 --- a/web/lib/tracker-store.ts +++ b/web/lib/tracker-store.ts @@ -10,8 +10,10 @@ ## Why the service role, and what still guards the rows The client is built with the service role key, which bypasses RLS. Ownership - is therefore enforced here, by the `.eq("user_id", userId)` on every read, - update and delete and by writing `user_id` explicitly on every insert. + is therefore enforced here, by the `.eq("user_id", userId)` on every read and + delete, by writing `user_id` explicitly on every insert, and by passing it as + `p_user_id` to the upsert function — which stamps it onto the row rather than + taking the caller's word for it. The RLS policies in the migration are not redundant. They mean `anon` and `authenticated` cannot touch this table at all, so nothing reachable with a @@ -79,32 +81,34 @@ export async function upsertTrackerRow( hackathonId: string, patch: { stage?: Stage; isWin?: boolean }, ): Promise { - const { data: existing, error: readError } = await client() - .from(TABLE) - .select("stage, is_win") - .eq("user_id", userId) - .eq("hackathon_id", hackathonId) - .maybeSingle(); - if (readError) throw new Error(readError.message); - - const stage: Stage = patch.stage ?? (existing?.stage as Stage) ?? "interested"; - const isWin = patch.isWin ?? existing?.is_win === true; - - const { error } = await client() - .from(TABLE) - .upsert( - { - user_id: userId, - hackathon_id: hackathonId, - stage, - is_win: isWin, - updated_at: new Date().toISOString(), - }, - { onConflict: "user_id,hackathon_id" }, - ); + // One statement rather than read-merge-write. Reading the row here and + // merging the patch in JS left a window where two concurrent PUTs for the + // same (user, hackathon) both read the same snapshot and the second write + // clobbered the first — losing exactly the field the other request was + // preserving. public.upsert_tracker_row does the coalesce inside the same + // ON CONFLICT DO UPDATE that writes, so there is nothing to interleave with. + // A null argument means "leave that column at its stored value", which is how + // an omitted patch field is expressed without a prior read. + const { data, error } = await client().rpc("upsert_tracker_row", { + p_user_id: userId, + p_hackathon_id: hackathonId, + p_stage: patch.stage ?? null, + p_is_win: patch.isWin ?? null, + }); if (error) throw new Error(error.message); - return { hackathonId, stage, isWin }; + // `returns table (...)` arrives as a one-row array. Fall back to the patch if + // a client ever hands back a bare object instead, so the caller still gets + // the values it asked for rather than undefined. + const row = (Array.isArray(data) ? data[0] : data) as + | { stage?: string; is_win?: boolean } + | undefined; + + return { + hackathonId, + stage: (row?.stage as Stage) ?? patch.stage ?? "interested", + isWin: row?.is_win ?? patch.isWin ?? false, + }; } export async function deleteTrackerRow( From 6b10a4364d3a52dd4ccf2a72af86e8bcba337d9d Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Mon, 10 Aug 2026 00:06:20 -0700 Subject: [PATCH 18/18] fix(tracker): don't mark a failed handover as imported The first-sign-in effect set hackhq-tracker-imported-v1 and adopted the server's rows even when the POST that hands the browser-local tracker over came back non-ok. One transient 500 was enough to lose the local pipeline twice over: the flag meant no later visit would offer it again, and adopting the server's (incomplete) list overwrote the local copy the retry would have needed. A thrown fetch already bailed out via the catch; a non-ok response now bails the same way, staying local so the next visit retries the whole handover. Co-Authored-By: Claude Fable 5 --- web/components/hq/store.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/web/components/hq/store.tsx b/web/components/hq/store.tsx index 5947b78..2c815c0 100644 --- a/web/components/hq/store.tsx +++ b/web/components/hq/store.tsx @@ -141,12 +141,15 @@ export function HQProvider({ children }: { children: React.ReactNode }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entries: local }), }); - if (imported.ok) { - const merged = await imported.json(); - if (merged?.synced === true) { - entries = parseTrackerEntries(merged.entries); - } - } + // A failed handover must not be marked done: setting the flag here + // would lose these rows for good, and adopting the server's list + // below would wipe them from this browser too. Stay local instead — + // the next visit retries the whole handover. (A thrown fetch takes + // the catch below and bails the same way.) + if (!imported.ok) return; + const merged = await imported.json(); + if (merged?.synced !== true) return; + entries = parseTrackerEntries(merged.entries); } localStorage.setItem(LS_IMPORTED_KEY, "1"); }