diff --git a/.gitignore b/.gitignore index b8e8a5f..0d0405f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ deadline_proposals.md # node node_modules/ + +# There is no npm project at the repo root — the app is web/. Running npm here by +# mistake drops an empty stub lockfile, which Next then treats as a candidate +# workspace root and resolves the build against (it has picked $HOME before). +# One was committed by accident already; ignore it so it cannot happen again. +/package-lock.json diff --git a/supabase/migrations/20260725154500_user_hackathons.sql b/supabase/migrations/20260725154500_user_hackathons.sql new file mode 100644 index 0000000..f5b73fc --- /dev/null +++ b/supabase/migrations/20260725154500_user_hackathons.sql @@ -0,0 +1,92 @@ +-- supabase/migrations/20260725154500_user_hackathons.sql +-- NOTE: applied by hand through the Supabase SQL Editor on 2026-07-26, because +-- this project has no Supabase CLI or MCP `apply_migration` configured. That +-- path records nothing in `supabase_migrations.schema_migrations`, so unlike +-- every other file here this timestamp is NOT a recorded version — it will not +-- appear in `list_migrations`, and `supabase db push` would try to replay it if +-- a CLI is ever wired up. See README for the divergence. +-- +-- The tracker's per-user pipeline, moved off localStorage. One row per +-- (user, hackathon): which stage it sits in, and whether the user won it (#226). +-- +-- `user_id` is the Clerk `sub`, matching the `submitted_by` convention on +-- public.hackathons. It defaults from the JWT rather than being sent by the +-- client, so a caller cannot write a row owned by someone else even before the +-- policies below are consulted. +-- +-- Deliberately NO foreign key to public.hackathons. That table is a mirror the +-- hourly sync can leave up to an hour behind .github/scripts/listings.json, +-- which is what the app actually renders from — an FK would reject a save for +-- any listing added since the last sync. The id is validated in the app layer +-- against the live listing set instead. + +create table if not exists public.user_hackathons ( + user_id text not null default auth.jwt() ->> 'sub', + hackathon_id uuid not null, + stage text not null default 'interested', + is_win boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (user_id, hackathon_id), + constraint user_hackathons_stage_check + check (stage in ('interested', 'applied', 'accepted', 'going')) +); + +-- `origin` on public.hackathons is text + check for the same reason: adding a +-- stage means dropping and recreating one constraint, not altering a pg enum. + +comment on table public.user_hackathons is + 'Per-user hackathon tracker: pipeline stage and win flag. user_id is the Clerk sub.'; + +alter table public.user_hackathons enable row level security; + +-- Reads and writes are the owner's only. `(select auth.jwt())` is wrapped so +-- Postgres evaluates the claim once per statement instead of once per row — +-- the unwrapped form is what Supabase's performance advisor flags. + +drop policy if exists "read own tracker" on public.user_hackathons; + +create policy "read own tracker" + on public.user_hackathons for select + to authenticated + using ((select auth.jwt() ->> 'sub') = user_id); + +drop policy if exists "insert own tracker" on public.user_hackathons; + +create policy "insert own tracker" + on public.user_hackathons for insert + to authenticated + with check ((select auth.jwt() ->> 'sub') = user_id); + +drop policy if exists "update own tracker" on public.user_hackathons; + +create policy "update own tracker" + on public.user_hackathons for update + to authenticated + using ((select auth.jwt() ->> 'sub') = user_id) + with check ((select auth.jwt() ->> 'sub') = user_id); + +drop policy if exists "delete own tracker" on public.user_hackathons; + +create policy "delete own tracker" + on public.user_hackathons for delete + to authenticated + using ((select auth.jwt() ->> 'sub') = user_id); + +-- Explicit grants, matching 20260722190741: Supabase's stock bootstrap is not +-- relied on, so a replay onto a fresh database produces a usable table. +-- `anon` gets nothing — a tracker has no public read. +grant select, insert, update, delete on public.user_hackathons to authenticated; +grant all on public.user_hackathons to service_role; + +-- Supabase's stock bootstrap grants every new public table to anon and +-- authenticated via ALTER DEFAULT PRIVILEGES. A per-user tracker must not +-- inherit that, so undo it: anon gets nothing at all, and authenticated keeps +-- only the row DML the policies above gate. TRUNCATE in particular is NOT +-- subject to RLS, so it must never linger on an API role. Mirrors the intent of +-- 20260722154244 for the hackathons table. +revoke all on public.user_hackathons from anon; +revoke truncate, references, trigger on public.user_hackathons from authenticated; + +-- Listing a user's tracker is the only read pattern; the primary key already +-- serves it (user_id leads), so no extra index is created here. 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/supabase/migrations/README.md b/supabase/migrations/README.md index 1a625f7..04a8c9e 100644 --- a/supabase/migrations/README.md +++ b/supabase/migrations/README.md @@ -28,6 +28,23 @@ top saying so and saying how: | `20260722145817_rls_policies.sql` | executable SQL — gained four `drop policy if exists` lines | | `20260722144205_add_deck_columns.sql` | comments only — a stale claim about enforcement, corrected | +One file was **applied by hand**, not through `apply_migration`, so it is absent +from `list_migrations` and `ls` here returns one more entry than the recorded +ledger does: + +| file | state | +| --- | --- | +| `20260725154500_user_hackathons.sql` | applied via the Supabase SQL Editor on 2026-07-26; never sent to `apply_migration` | + +This project has no Supabase CLI or MCP configured, so the migration was run +directly in the dashboard. That records nothing in +`supabase_migrations.schema_migrations`, so its timestamp stays a placeholder +rather than a recorded version, the two lists do not align, and `supabase db +push` would try to replay it if a CLI is ever wired up. If you later adopt the +CLI/MCP, reconcile by inserting the version into the ledger (or re-running it +through `apply_migration` against a fresh database) rather than trusting the +filename alone. + The three SQL divergences all exist so the chain replays cleanly onto a fresh database. That makes the files the runnable artefact and the recorded statements the historical record; they are not interchangeable, and where they disagree the @@ -54,6 +71,16 @@ names, so renaming them is a breaking change for no benefit. **`host` is `company_name` renamed**, done by `build_row` in that same script. +**`user_hackathons` has no foreign key to `hackathons`.** It is the per-user +tracker (stage + win flag) added for #226, keyed on the Clerk `sub` like +`submitted_by`. An FK would look obvious and be wrong: this table's `hackathon_id` +comes from `listings.json`, which the app renders from directly, while +`hackathons` trails it by up to an hour — so a user saving a listing added since +the last sync would hit a constraint violation for a listing that plainly exists +on screen. The id is checked against the live listing set in the app layer +instead, and `user_id` defaults from `auth.jwt()` rather than being sent by the +client, so the owner cannot be spoofed even before the policies are consulted. + ## The two write paths `listings.json` reaches this table through `.github/workflows/sync_supabase.yml`, 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/README.md b/web/README.md index 4799601..d6906fd 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,7 +163,7 @@ 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) ``` @@ -193,6 +193,8 @@ Copy `.env.example` to `.env.local` (gitignored) and set the values you need. | `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 | +| `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 @@ -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,85 @@ 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. +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. -```bash -npm run preview # build for Workers and run it locally (wrangler dev) -npm run deploy # build for Workers and deploy -``` +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. -### Middleware runs on the Edge (why `middleware.ts`, not `proxy.ts`) +### Environment variables in production -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. +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. -Configuration lives in `wrangler.jsonc` (`nodejs_compat` is required) and -`open-next.config.ts`. Set production values as follows: +### Auth runs as Edge middleware (why `middleware.ts`) -- **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. +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: + +``` +ERROR Node.js middleware is not currently supported. Consider switching to Edge Middleware. +``` + +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. -Vercel remains a drop-in fallback (`git push`, zero config): the same build works -there because nothing is Workers-specific. +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. + +**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 Workers | +| --- | --- | --- | +| 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 @@ -283,6 +330,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/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/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 7f2f4e6..aa64607 100644 --- a/web/components/hq/deck.tsx +++ b/web/components/hq/deck.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/deck-order"; import { safeHttpUrl } from "@/lib/url"; import { useSelection, useTracker } from "./store"; +import { TrophyBadge } from "./trophy"; export function Deck({ hackathons }: { hackathons: Hackathon[] }) { const [q, setQ] = useState(""); @@ -169,6 +170,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); @@ -197,8 +200,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) && }
))}
@@ -316,10 +381,10 @@ function CoverCrest() { type Phase = "closed" | "flash" | "open"; export function Passport({ hackathons }: { hackathons: Hackathon[] }) { - const { tracked } = useTracker(); - const { left, right, stampCount, cityCount } = useMemo( - () => buildPassport(tracked, hackathons), - [tracked, hackathons], + const { tracked, wins } = useTracker(); + const { left, right, stampCount, cityCount, winCount } = useMemo( + () => buildPassport(tracked, hackathons, wins), + [tracked, hackathons, wins], ); const isEmpty = stampCount === 0; @@ -409,6 +474,12 @@ export function Passport({ hackathons }: { hackathons: Hackathon[] }) {
{pad2(stampCount)} stamps · {pad2(cityCount)} cities + {winCount > 0 && ( + <> + {" · "} + {pad2(winCount)} wins + + )}
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. diff --git a/web/components/hq/store.tsx b/web/components/hq/store.tsx index 6bd3b0e..2c815c0 100644 --- a/web/components/hq/store.tsx +++ b/web/components/hq/store.tsx @@ -6,45 +6,37 @@ 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"; -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; -} +// 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; + 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 = { @@ -58,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 */ } @@ -81,34 +112,221 @@ 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 }), + }); + // 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"); + } + + 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 }), 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/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] })], +); 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/passport-stamps.test.ts b/web/lib/passport-stamps.test.ts index 5ba7a77..8b6423e 100644 --- a/web/lib/passport-stamps.test.ts +++ b/web/lib/passport-stamps.test.ts @@ -173,7 +173,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..86e99f9 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; @@ -36,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 @@ -49,6 +53,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 +64,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 +234,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); @@ -238,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. @@ -257,6 +269,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 +279,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 +292,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 }; } diff --git a/web/lib/tracker-store.test.ts b/web/lib/tracker-store.test.ts new file mode 100644 index 0000000..d97ed99 --- /dev/null +++ b/web/lib/tracker-store.test.ts @@ -0,0 +1,194 @@ +/* --------------------------------------------------------------------------- + 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 }; +// 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), + 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), + 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) => + 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; +} + +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", () => { + 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("stamps the caller's user_id on the write and never another user's", async () => { + rpcResult = { data: [{ stage: "applied", is_win: false }], error: null }; + + const entry = await upsertTrackerRow(USER, ID_A, { stage: "applied" }); + + // 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("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 }); + }); + + 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", + ); + }); +}); + +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([]); + }); +}); diff --git a/web/lib/tracker-store.ts b/web/lib/tracker-store.ts new file mode 100644 index 0000000..ac69368 --- /dev/null +++ b/web/lib/tracker-store.ts @@ -0,0 +1,149 @@ +/* --------------------------------------------------------------------------- + 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 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 + 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 { + // 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); + + // `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( + 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/middleware.ts b/web/middleware.ts index b08b7ad..afe2089 100644 --- a/web/middleware.ts +++ b/web/middleware.ts @@ -5,16 +5,26 @@ import { isClerkConfigured } from "@/lib/env"; // This is deliberately `middleware.ts`, not Next 16's newer `proxy.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. +// 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. // -// 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). +// 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). // // /my is protected here, server-side: a signed-out visitor never reaches the // page. signInUrl/signUpUrl are pinned in code rather than left to 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. diff --git a/web/package-lock.json b/web/package-lock.json index ff1f495..55875e8 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": { @@ -4394,6 +4396,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", @@ -8396,6 +8482,15 @@ "ms": "^2.0.0" } }, + "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/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", diff --git a/web/package.json b/web/package.json index 1f71afe..cc95b6b 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,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", @@ -29,6 +30,7 @@ "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", + "server-only": "^0.0.1", "sharp": "^0.35.0" }, "devDependencies": {