Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions supabase/migrations/20260725154500_user_hackathons.sql
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions supabase/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`,
Expand Down
8 changes: 8 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ NEXT_PUBLIC_MAPBOX_TOKEN=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=

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

# Drizzle/Supabase Postgres — only needed when running npm run db:* scripts.
# Use the direct or pooled Postgres connection string from Supabase.
DATABASE_URL=
163 changes: 163 additions & 0 deletions web/app/api/tracker/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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);
}
}
3 changes: 3 additions & 0 deletions web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions web/components/hq/deck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -197,8 +200,11 @@ function HackRow({ h }: { h: Hackathon }) {
title={meta.label}
/>
<div className="min-w-0 flex-1">
<div className="truncate font-display text-[15px] font-semibold text-ink">
{h.title}
<div className="flex items-center gap-2">
<span className="truncate font-display text-[15px] font-semibold text-ink">
{h.title}
</span>
{won && <TrophyBadge hackathonTitle={h.title} compact />}
</div>
<div className="truncate text-[12px] text-ink/50">
{h.host} · {h.location}
Expand Down
4 changes: 3 additions & 1 deletion web/components/hq/detail-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
Expand Down Expand Up @@ -103,6 +104,7 @@ export function DetailModal() {
<span className="font-mono text-[10px] tracking-[0.22em] text-paper/50">
{h.format.toUpperCase()}
</span>
{hasWin(h.id) && <TrophyBadge hackathonTitle={h.title} compact />}
</div>
<button
ref={closeButtonRef}
Expand Down
Loading
Loading