From d3313a713e86986352317e46724237976656ffe1 Mon Sep 17 00:00:00 2001
From: Aamogh <120258212+aamoghS@users.noreply.github.com>
Date: Sun, 9 Aug 2026 01:16:14 -0400
Subject: [PATCH 1/3] docs: membership is no longer scoped to a hackathon
edition (#333)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
W1 changed `member` from unique(user_id, hackathon_id) to unique(user_id), but
two pieces of documentation still described the old shape — and one of them
contradicted the code directly beneath it.
- README said `member` is scoped to a hackathon and that "a paid year still
hangs off an edition, so membership resolves the current hackathon". That is
the bug W1 fixed: the day the next edition opened, every paying member read
as a non-member. It now says what is true, including that the club half works
with no hackathon in the database at all.
- `requireActiveMember`'s docblock claimed it resolves the current edition. The
function body does not, and an inner comment said the opposite — a reader
had to work out which half to believe.
Documentation only; no behaviour change.
---
README.md | 22 +++++++++++++---------
packages/api/src/routers/initiative.ts | 10 ++++------
2 files changed, 17 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 7fc619e6..edaeb8e3 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ Two entities anchor the graph:
`user_account_link`, and `stripe_payment.linked_user_id`.
- **`hackathon`** — every event-scoped table cascades from it: teams,
participants, projects, hackathon events, judges, judge assignments, judging
- projects, judge queue, and maps. `member` is also scoped to a hackathon.
+ projects, judge queue, and maps.
Nearly all foreign keys are `onDelete: "cascade"`, so deleting a user or a
hackathon removes its dependent rows rather than orphaning them.
@@ -69,14 +69,18 @@ Two aspects share the database and touch nowhere:
- **Hackathon** — editions, registration, teams, project submission, judging.
Everything here hangs off a `hackathon` row.
-- **Club** — `initiative`, its applications, and the `project_leader` role.
- Deliberately **not** scoped to a hackathon. A club project runs whenever
- somebody leads one, and leading is a standing appointment rather than a
- yearly re-grant. Nothing in this half is ever judged; judges only score
- `hackathon_project`.
-
-`member` is the one crossing case: a paid year still hangs off an edition, so
-membership resolves the current hackathon even though initiatives do not.
+- **Club** — `member`, `membership_history`, `event`, `event_check_in`,
+ `initiative`, its applications, and the `project_leader` role. Deliberately
+ **not** scoped to a hackathon. A club project runs whenever somebody leads
+ one, and leading is a standing appointment rather than a yearly re-grant.
+ Nothing in this half is ever judged; judges only score `hackathon_project`.
+
+The two halves no longer cross. `member` used to be `unique(user_id,
+hackathon_id)`, which welded a paid year to an edition: the day the next
+hackathon opened, every paying member read as a non-member. It is now
+`unique(user_id)` and a membership is defined entirely by its own dates, with
+`membership_history` recording which years somebody held one. The club half
+therefore works with no hackathon in the database at all.
#### One-off step — only for a database that already has the edition-scoped tables
diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts
index 57bb7ed2..430ee700 100644
--- a/packages/api/src/routers/initiative.ts
+++ b/packages/api/src/routers/initiative.ts
@@ -70,14 +70,12 @@ function canManage(
/**
* Applying is a member benefit, so it needs a membership that has not lapsed.
*
- * Initiatives are unscoped but membership is not — a paid year still hangs off
- * an edition, so this resolves the current one. No edition means nobody has a
- * live membership to check, which refuses rather than waving everyone through.
+ * Keyed on the person alone. This used to resolve a "current edition" and look
+ * the membership up against it, so between hackathons — when no edition
+ * qualified — it refused everybody, which is how the club half went dead
+ * outside event season.
*/
async function requireActiveMember(db: Reader, userId: string) {
- // Initiatives were deliberately un-scoped from hackathons; membership now is
- // too. This previously resolved a current edition and refused everyone when
- // none existed, which is how the club half went dead outside event season.
const member = await db.query.members.findFirst({
where: eq(members.userId, userId),
columns: { isActive: true, membershipEndDate: true },
From ec281195535596e7f0e50079acf280fe828b041e Mon Sep 17 00:00:00 2001
From: Aamogh <120258212+aamoghS@users.noreply.github.com>
Date: Sun, 9 Aug 2026 01:28:19 -0400
Subject: [PATCH 2/3] =?UTF-8?q?fix(admin):=20restore=20the=20Table=20Cards?=
=?UTF-8?q?=20tab=20=E2=80=94=20judging=20cannot=20start=20without=20it=20?=
=?UTF-8?q?(#338)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Judges score by scanning the QR on a team's table card: the scoring form only
opens after startByQrCode succeeds. TableCards.tsx renders those cards and is
the only consumer of judge.tableCards — and nothing imported it, so no card
could be printed and no judge could open the form. Judging was unstartable.
Lost when I split this work into a stack: branch 1 took the pre-stack version
of this page, so the tab and its import never reached main, and the mobile-nav
fix (seven tabs in a five-column grid, with Table Cards and Email laid out off
the right edge of the phone) went with it. Both are restored here.
Found by walking the organiser and judge journeys against main. Nothing failed
— the component compiled, 426 tests passed, and the missing tab was invisible
to all of it.
---
.../(portal)/admin/hackathons/[id]/page.tsx | 20 +++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
index 7b6a7864..23848239 100644
--- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
@@ -13,7 +13,8 @@ import { AnalyticsTab } from "@/components/admin/hackathons/AnalyticsTab";
import { EventsTab } from "@/components/admin/hackathons/EventsTab";
import { JudgesTab } from "@/components/admin/hackathons/JudgesTab";
import { AnnouncementsTab } from "@/components/admin/hackathons/AnnouncementsTab";
-import { Gavel, Megaphone } from "lucide-react";
+import { TableCards } from "@/components/admin/hackathons/TableCards";
+import { Gavel, Megaphone, QrCode } from "lucide-react";
type Tab =
| "events"
@@ -21,7 +22,8 @@ type Tab =
| "attendees"
| "analytics"
| "judges"
- | "announcements";
+ | "announcements"
+ | "tables";
export default function AdminHackathonDashboard() {
const { status } = useSession();
@@ -59,6 +61,11 @@ export default function AdminHackathonDashboard() {
icon: