Skip to content
Merged
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
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
10 changes: 4 additions & 6 deletions packages/api/src/routers/initiative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
20 changes: 16 additions & 4 deletions sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ 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"
| "scanner"
| "attendees"
| "analytics"
| "judges"
| "announcements";
| "announcements"
| "tables";

export default function AdminHackathonDashboard() {
const { status } = useSession();
Expand Down Expand Up @@ -59,6 +61,11 @@ export default function AdminHackathonDashboard() {
icon: <IconChart className="w-5 h-5" />,
},
{ id: "judges", label: "Judges", icon: <Gavel className="w-5 h-5" /> },
{
id: "tables",
label: "Table Cards",
icon: <QrCode className="w-5 h-5" />,
},
{
id: "announcements",
label: "Email",
Expand Down Expand Up @@ -189,22 +196,27 @@ export default function AdminHackathonDashboard() {
<AnalyticsTab hackathonId={hackathon.id} />
)}
{activeTab === "judges" && <JudgesTab hackathonId={hackathon.id} />}
{activeTab === "tables" && <TableCards hackathonId={hackathon.id} />}
{activeTab === "announcements" && (
<AnnouncementsTab hackathonId={hackathon.id} />
)}
</div>
</main>

{/* MOBILE BOTTOM NAVIGATION - Enhanced */}
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-[var(--bg-primary)]/98 backdrop-blur-2xl border-t border-[var(--border-subtle)] z-40 grid grid-cols-5 pb-safe">
{/* Scrolls horizontally rather than a fixed 5-column grid: there are
seven tabs, so Table Cards and Email were laid out off the right edge
of the phone an organiser actually carries — unreachable, with nothing
to suggest they existed. */}
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-[var(--bg-primary)]/98 backdrop-blur-2xl border-t border-[var(--border-subtle)] z-40 flex overflow-x-auto pb-safe [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{/* Top gradient overlay */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-white/10 to-transparent" />

{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`group relative flex flex-col items-center justify-center py-3 gap-1 transition-colors ${
className={`group relative shrink-0 basis-1/5 flex flex-col items-center justify-center py-3 gap-1 transition-colors ${
activeTab === tab.id ? "text-accent" : "text-text-muted"
}`}
>
Expand Down
6 changes: 5 additions & 1 deletion sites/mainweb/components/portal/MembershipTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,14 @@ export function MembershipTab() {
const save = () => {
setError(null);

// `null` is a graduation year that was left blank, which is allowed and is
// what clears the column. The guard compared against `undefined` instead,
// so `Number.isInteger(null)` failed it and the whole form refused to save
// — school, skills, socials and all — until a year was typed in.
const year = form.graduationYear.trim()
? Number(form.graduationYear)
: null;
if (year !== undefined && !Number.isInteger(year)) {
if (year !== null && !Number.isInteger(year)) {
setError("That graduation year does not look right.");
return;
}
Expand Down
Loading