From e3461cebac5265a92964d25c7f982b56a4f6ea1b Mon Sep 17 00:00:00 2001 From: Vicientt Date: Thu, 9 Jul 2026 14:02:35 -0400 Subject: [PATCH 1/4] chore: ignore env files and node_modules Root .gitignore did not cover .env, so a key pasted anywhere outside web/ would have been tracked. Negate .env.example so templates stay committed. --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index 4ddedcc..61de2b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ .github/scripts/__pycache__/ digest.md deadline_proposals.md + +# env files +.env +.env.* +!.env.example + +# node +node_modules/ From 325290432fbe5ee0c929a752f54e054a6568b5f2 Mon Sep 17 00:00:00 2001 From: Vicientt Date: Thu, 9 Jul 2026 14:02:46 -0400 Subject: [PATCH 2/4] chore(site): document supabase frontend env vars Anon key only. Flags the service key as never belonging here. --- site/.env.example | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 site/.env.example diff --git a/site/.env.example b/site/.env.example new file mode 100644 index 0000000..bfbf327 --- /dev/null +++ b/site/.env.example @@ -0,0 +1,6 @@ +# Supabase — public, browser-safe values only. +# Vite inlines any VITE_* var into the shipped JS bundle at build time. +# Never put SUPABASE_SERVICE_KEY here or in .env: it bypasses row-level security. + +VITE_SUPABASE_URL=https://your-project-ref.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key From 4b4870712a58dd4a3bd535d1d08f4fd1c87e390a Mon Sep 17 00:00:00 2001 From: Vicientt Date: Thu, 9 Jul 2026 14:02:46 -0400 Subject: [PATCH 3/4] feat(scripts): seed supabase hackathons table from listings.json Maps company_name to host and upserts on the id primary key with Prefer: resolution=merge-duplicates, so re-runs are idempotent. Carries deadline and featured, which listings.json gained after issue #11 was written; the table needs both columns added. Geo columns are left unset for the geocoder in #12. build_row and upsert are importable so the sync Action in #13 does not reimplement the mapping. --- .github/scripts/seed_supabase.py | 95 ++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/scripts/seed_supabase.py diff --git a/.github/scripts/seed_supabase.py b/.github/scripts/seed_supabase.py new file mode 100644 index 0000000..9a76cf7 --- /dev/null +++ b/.github/scripts/seed_supabase.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +seed_supabase.py — push listings.json into the Supabase `hackathons` table (issue #11). + +Maps each listing to a table row and upserts on the `id` primary key, so re-runs +are idempotent. The sync Action (#13) imports `build_row` and `upsert` from here +rather than reimplementing the mapping. + +Geo columns (`lat`, `lng`, `geo_status`) are left unset; the geocoder (#12) fills them. + +Requires two environment variables: + SUPABASE_URL e.g. https://.supabase.co + SUPABASE_SERVICE_KEY service_role key — bypasses RLS, so writes are allowed + +The service key is never logged. Response bodies are echoed on failure; PostgREST +does not include credentials in error payloads. + +Run with: python .github/scripts/seed_supabase.py +""" + +import json +import os +import sys + +import requests + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +LISTINGS_FILE = os.path.join(SCRIPT_DIR, "listings.json") + +TABLE = "hackathons" +CHUNK_SIZE = 100 +TIMEOUT = 30 + + +def build_row(listing): + """Translate one listings.json entry into a `hackathons` table row. + + `company_name` is stored as `host`. Absent `deadline` stays NULL; absent + `featured` becomes False so the column is never NULL for the site's filters. + """ + return { + "id": listing["id"], + "host": listing["company_name"], + "title": listing["title"], + "url": listing["url"], + "locations": listing.get("locations", []), + "format": listing.get("format"), + "prize": listing.get("prize"), + "state": listing.get("state"), + "active": listing.get("active", True), + "is_visible": listing.get("is_visible", True), + "date_posted": listing.get("date_posted"), + "date_updated": listing.get("date_updated"), + "source": listing.get("source"), + "deadline": listing.get("deadline"), + "featured": bool(listing.get("featured", False)), + } + + +def upsert(rows, base_url, service_key): + """Upsert rows into `hackathons`, chunked. Raises on any non-2xx response.""" + endpoint = f"{base_url.rstrip('/')}/rest/v1/{TABLE}" + headers = { + "apikey": service_key, + "Authorization": f"Bearer {service_key}", + "Content-Type": "application/json", + "Prefer": "resolution=merge-duplicates,return=minimal", + } + + for start in range(0, len(rows), CHUNK_SIZE): + chunk = rows[start : start + CHUNK_SIZE] + response = requests.post(endpoint, headers=headers, json=chunk, timeout=TIMEOUT) + if not response.ok: + raise RuntimeError( + f"upsert failed at row {start} with HTTP {response.status_code}: {response.text}" + ) + print(f"upserted rows {start}-{start + len(chunk) - 1}") + + +def main(): + base_url = os.environ.get("SUPABASE_URL") + service_key = os.environ.get("SUPABASE_SERVICE_KEY") + if not base_url or not service_key: + sys.exit("SUPABASE_URL and SUPABASE_SERVICE_KEY must be set") + + with open(LISTINGS_FILE, encoding="utf-8") as handle: + listings = json.load(handle) + + rows = [build_row(listing) for listing in listings] + upsert(rows, base_url, service_key) + print(f"seeded {len(rows)} listings into {TABLE}") + + +if __name__ == "__main__": + main() From 958c23e99e0880edcbfd19ed761f58d5ba01d59d Mon Sep 17 00:00:00 2001 From: Vicientt Date: Thu, 9 Jul 2026 14:02:46 -0400 Subject: [PATCH 4/4] test(scripts): cover listings to supabase row mapping --- .github/scripts/test_scripts.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/scripts/test_scripts.py b/.github/scripts/test_scripts.py index 4dce1f2..4c3bf10 100644 --- a/.github/scripts/test_scripts.py +++ b/.github/scripts/test_scripts.py @@ -10,6 +10,7 @@ import util import auto_extract as ax import deadline_watcher as dw +import seed_supabase as seed class ParseDeadlineDate(unittest.TestCase): @@ -129,5 +130,40 @@ def test_rejects_not_found_or_bad_date(self): self.assertIsNone(dw._accept(self._base(deadline="sometime in August"))) +class BuildRow(unittest.TestCase): + def _listing(self, **overrides): + base = { + "id": "c0182709-2212-41a1-94ef-47689f05192f", + "company_name": "MIT", + "title": "HackMIT 2026", + "url": "https://hackmit.org/", + } + base.update(overrides) + return base + + def test_renames_company_name_to_host(self): + row = seed.build_row(self._listing()) + self.assertEqual(row["host"], "MIT") + self.assertNotIn("company_name", row) + + def test_defaults_for_absent_optional_fields(self): + row = seed.build_row(self._listing()) + self.assertEqual(row["locations"], []) + self.assertIsNone(row["deadline"]) + self.assertFalse(row["featured"]) + self.assertTrue(row["active"]) + self.assertTrue(row["is_visible"]) + + def test_passes_through_deadline_and_featured(self): + row = seed.build_row(self._listing(deadline="2026-07-05", featured=True)) + self.assertEqual(row["deadline"], "2026-07-05") + self.assertTrue(row["featured"]) + + def test_omits_geo_columns_for_geocoder(self): + row = seed.build_row(self._listing()) + for column in ("lat", "lng", "geo_status"): + self.assertNotIn(column, row) + + if __name__ == "__main__": unittest.main()