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/.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/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..5947b78 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,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 }), 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.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 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": {