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
6 changes: 3 additions & 3 deletions docs/sites/mainweb.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,17 @@ Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `p
| `/judge`, `/judge/register` | Judge home / apply |
| `/scan` | QR scanning |
| `/admin` | Staff home |
| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events) |
| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events). `[id]` is the name slug (`hacklytics-digital-bloom`), not a percent-encoded name. |
| `/admin/members` | Membership admin |
| `/admin/attendees` | Attendee tools |
| `/admin/judging` | Judging admin |
| `/admin/judging` | Judging admin (sync submissions + assign judges live here) |
| `/admin/initiatives` | Initiative / proposal review |
| `/admin/bootcamp` | Bootcamp attendance |
| `/admin/staff` | Admin users |
| `/admin/analytics` | Overview |
| `/admin/audit` | Audit log |
| `/admin/projects` | Project admin |
| `/admin/setup` | First-run wizard |
| `/admin/setup` | Redirects to `/admin/judging`. Do not create a second hackathon from judging — create it under Hackathons. |

## API routes

Expand Down
16 changes: 16 additions & 0 deletions packages/api/src/.internal-tests/hackathon-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,22 @@ describe("Hackathon end-to-end flow", () => {
);
});

it("opens an edition from a percent-encoded name (old admin dashboard links)", async () => {
// `/admin/hackathons/${encodeURIComponent(name)}` used to put
// "Hacklytics%3A%20Digital%20Bloom" in the path. That string is not the
// stored name, and slugging it produces "hacklytics-3a-20digital-20bloom".
mockFindFirst.mockImplementation(() => undefined);
mockFindMany.mockImplementation((table) =>
table === "hackathons" ? [bloom()] : [],
);
const caller = appRouter.createCaller(createMockCtx("user_a"));

const res = await caller.hackathon.getById({
id: encodeURIComponent("Hacklytics: Digital Bloom"),
});
expect(res.id).toBe(HACK_A);
});

it("404s on a slug matching no edition", async () => {
mockFindFirst.mockImplementation(() => undefined);
mockFindMany.mockImplementation((table) =>
Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/.internal-tests/hackathon-interest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,5 +480,22 @@ describe("Hackathon interest list", () => {
// changes their address stays reachable.
expect(rows[0]!.email).toBe("ada@example.com");
});

it("counts this edition's interest rows for staff", async () => {
lookups({ hackathon: announced(), isAdmin: true });
mockSelectRows.mockReturnValue([{ count: 34 }]);

await expect(
callerFor(ADMIN).hackathon.interestCount({ hackathonId: HACK }),
).resolves.toBe(34);
});

it("hides the count from a caller who is not staff", async () => {
lookups({ hackathon: announced(), isAdmin: false });

await expect(
callerFor(VISITOR).hackathon.interestCount({ hackathonId: HACK }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
});
53 changes: 42 additions & 11 deletions packages/api/src/routers/hackathon/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,50 @@ const toSlug = (value: string) =>
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");

// Exact name first, then the slug the portal links with. The slug pass reads
// every edition — there are a handful, and no index covers the normalisation.
/** Must match `decodeHackathonParam` in sites/mainweb/lib/hackathon-slug.ts. */
function decodeHackathonParam(value: string) {
let current = value;
for (let i = 0; i < 2; i++) {
try {
const next = decodeURIComponent(current);
if (next === current) break;
current = next;
} catch {
break;
}
}
return current;
}

function uniqueStrings(values: string[]) {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
if (!value || seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}

// Exact name first (including a percent-encoded name from old admin links),
// then the slug the portal links with. The slug pass reads every edition —
// there are a handful, and no index covers the normalisation.
async function findByNameOrSlug(db: DrizzleDB, value: string) {
const exact = await db.query.hackathons.findFirst({
where: eq(hackathons.name, value),
});
if (exact) return exact;
const candidates = uniqueStrings([value, decodeHackathonParam(value)]);

for (const candidate of candidates) {
const exact = await db.query.hackathons.findFirst({
where: eq(hackathons.name, candidate),
});
if (exact) return exact;
}

const slug = toSlug(value);
if (!slug) return undefined;
const slugs = uniqueStrings(candidates.map(toSlug));
if (slugs.length === 0) return undefined;

const all = await db.query.hackathons.findMany();
return all.find((row) => toSlug(row.name) === slug);
return all.find((row) => slugs.includes(toSlug(row.name)));
}

export const hackathonCrudRouter = createTRPCRouter({
Expand Down Expand Up @@ -99,8 +130,8 @@ export const hackathonCrudRouter = createTRPCRouter({
).query.hackathons.findMany({
where: and(
// Hidden means hidden from the public funnel, not from the people running the
// event. Filtering it for staff too emptied Judging Setup and the judging
// dashboard, which both pick an edition off here.
// event. Filtering it for staff too emptied the judging dashboard, which
// picks an edition off here.
adminViewer ? undefined : eq(hackathons.isPublic, true),
input.status ? eq(hackathons.status, input.status) : undefined,
adminViewer
Expand Down
13 changes: 13 additions & 0 deletions packages/api/src/routers/hackathon/interest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,4 +424,17 @@ export const hackathonInterestRouter = createTRPCRouter({
.orderBy(desc(hackathonInterest.createdAt))
.limit(5000);
}),

// How many people asked to be told about THIS edition. The admin card used
// to show currentParticipants (registrations), which is zero while the
// event is announced — so a live interest list looked unlinked.
interestCount: isAdmin
.input(z.object({ hackathonId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const [row] = await (ctx.db as DrizzleDB)
.select({ count: sql<number>`count(*)::int` })
.from(hackathonInterest)
.where(eq(hackathonInterest.hackathonId, input.hackathonId));
return row?.count ?? 0;
}),
});
2 changes: 1 addition & 1 deletion sites/mainweb/app/(portal)/admin/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export default function AnalyticsPage() {
<div className="absolute inset-0 pointer-events-none bg-gradient-to-r from-accent/5 via-transparent to-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="absolute -top-24 -right-24 w-56 h-56 bg-accent/10 rounded-sm blur-[100px] opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<p className="text-[10px] font-mono text-accent/60 uppercase tracking-[0.2em] mb-1 relative z-10 flex items-center gap-2">
<QrCode className="w-3 h-3" /> Club Events
<QrCode className="w-3 h-3" /> Operations
</p>
<h1 className="relative text-3xl font-black text-[var(--text-primary)] tracking-tighter mb-2 group-hover:text-transparent group-hover:bg-clip-text group-hover:bg-gradient-to-r group-hover:from-white group-hover:via-emerald-100 to-gray-400 transition-ui duration-500">
Analytics <span className="text-accent italic">Dashboard</span>
Expand Down
3 changes: 2 additions & 1 deletion sites/mainweb/app/(portal)/admin/attendees/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ export default function AttendeesPage() {
<span className="text-accent italic font-bold">Registry</span>
</h1>
<p className="relative text-text-muted text-sm font-mono">
View and manage attendee registrations for club events.
View and manage attendee check-ins for club meetings. Hackathon
applications live on each edition&apos;s dashboard.
</p>
{/* Decorative Corner Accent */}
<div className="absolute -bottom-16 -right-16 w-48 h-48 bg-accent/5 rounded-sm blur-[60px] opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
Expand Down
7 changes: 6 additions & 1 deletion sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { usePortalContext } from "@/lib/use-portal-context";
import { useParams } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import { decodeHackathonParam } from "@/lib/hackathon-slug";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
import { ScannerTab } from "@/components/admin/hackathons/ScannerTab";
import { AttendeesTab } from "@/components/admin/hackathons/AttendeesTab";
Expand All @@ -28,7 +29,10 @@ type Tab =
export default function AdminHackathonDashboard() {
const { status } = useSession();
const params = useParams();
const hackathonId = params?.id as string;
const rawId = params?.id;
const hackathonId = decodeHackathonParam(
Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""),
);
const [activeTab, setActiveTab] = useState<Tab>("attendees");

const { data: portalContext, isLoading: portalLoading } = usePortalContext();
Expand Down Expand Up @@ -219,6 +223,7 @@ export default function AdminHackathonDashboard() {
<AttendeesTab
hackathonId={hackathon.id}
hackathonName={hackathon.name}
status={hackathon.status}
/>
)}
{activeTab === "analytics" && (
Expand Down
119 changes: 104 additions & 15 deletions sites/mainweb/app/(portal)/admin/judging/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Zap } from "lucide-react";
import { useSession } from "next-auth/react";
import { trpc } from "@/lib/trpc";
Expand All @@ -13,6 +13,7 @@ import { JudgeMatrixView } from "@/components/admin/judging/JudgeMatrixView";
import { RankingsView } from "@/components/admin/judging/RankingsView";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
import { JudgeLiveBoard } from "@/components/admin/hackathons/JudgeLiveBoard";
import { judgingPrepIsCurrent } from "@/lib/judging-prep";

export default function AdminResultsPage() {
const { data: session, status } = useSession();
Expand Down Expand Up @@ -68,37 +69,105 @@ export default function AdminResultsPage() {
message: string | null;
error: string | null;
}>({ busy: false, message: null, error: null });
// The edition whose assign call was refused. Rebuild anyway sends force
// for this id, not whatever is selected now — a late CONFLICT from A must
// not rebuild B.
const [assignConflictId, setAssignConflictId] = useState<string | null>(
null,
);
const selectedHackathonRef = useRef(selectedHackathon);
selectedHackathonRef.current = selectedHackathon;
const prepGen = useRef(0);

const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation();
const assignJudges = trpc.judge.assignJudgesToProjects.useMutation();

const isAssignConflict = (error: unknown) =>
typeof error === "object" &&
error !== null &&
"data" in error &&
(error as { data?: { code?: string } }).data?.code === "CONFLICT";

const stillThisRun = (hackathonId: string, gen: number) =>
judgingPrepIsCurrent(
hackathonId,
gen,
selectedHackathonRef.current,
prepGen.current,
);

const finishPrepare = async (
hackathonId: string,
gen: number,
message: string,
) => {
await utils.judge.getRankings.invalidate({ hackathonId });
if (!stillThisRun(hackathonId, gen)) return;
await refetchJudgingStatus();
if (!stillThisRun(hackathonId, gen)) return;
setAssignConflictId(null);
setPrepState({ busy: false, error: null, message });
};

const prepareJudging = async () => {
if (!selectedHackathon) return;
const hackathonId = selectedHackathon;
if (!hackathonId) return;
const gen = ++prepGen.current;
setAssignConflictId(null);
setPrepState({ busy: true, message: null, error: null });
try {
const promoted = await promoteSubmissions.mutateAsync({
hackathonId: selectedHackathon,
hackathonId,
});
const assigned = await assignJudges.mutateAsync({
hackathonId: selectedHackathon,
});
await utils.judge.getRankings.invalidate({
hackathonId: selectedHackathon,
hackathonId,
});
await refetchJudgingStatus();
if (!stillThisRun(hackathonId, gen)) return;
const warning = promoted.queuesNeedRebuild
? " One or more new projects carry a track no active judge covers — fix the track, then run this again."
: "";
await finishPrepare(
hackathonId,
gen,
`Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`,
);
} catch (e) {
if (!stillThisRun(hackathonId, gen)) return;
setAssignConflictId(isAssignConflict(e) ? hackathonId : null);
setPrepState({
busy: false,
error: null,
message: `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`,
message: null,
error: e instanceof Error ? e.message : "Could not prepare judging.",
});
}
};

// The server names how many completed slots (or that judging is live) and
// asks for confirmation. This is the only control that sends force: true —
// /admin/setup used to, and now redirects here.
const rebuildQueuesAnyway = async () => {
const hackathonId = assignConflictId;
if (!hackathonId || hackathonId !== selectedHackathonRef.current) return;
const gen = ++prepGen.current;
setPrepState((s) => ({ ...s, busy: true }));
try {
const assigned = await assignJudges.mutateAsync({
hackathonId,
force: true,
});
if (!stillThisRun(hackathonId, gen)) return;
await finishPrepare(
hackathonId,
gen,
`Rebuilt queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Completed slots were kept.`,
);
} catch (e) {
if (!stillThisRun(hackathonId, gen)) return;
setAssignConflictId(isAssignConflict(e) ? hackathonId : null);
setPrepState({
busy: false,
message: null,
error: e instanceof Error ? e.message : "Could not prepare judging.",
error: e instanceof Error ? e.message : "Could not rebuild queues.",
});
}
};
Expand All @@ -120,6 +189,12 @@ export default function AdminResultsPage() {
}
}, [hackathons, selectedHackathon]);

useEffect(() => {
prepGen.current += 1;
setAssignConflictId(null);
setPrepState({ busy: false, message: null, error: null });
}, [selectedHackathon]);

const categories = useMemo(() => {
if (!rankings?.rankings) return ["ALL"];
const cats = new Set(
Expand Down Expand Up @@ -338,12 +413,26 @@ export default function AdminResultsPage() {
</p>
)}
{prepState.error && (
<p
<div
role="alert"
className="mt-4 px-4 py-3 border border-red-500/30 bg-red-500/10 text-sm font-mono text-red-300"
className={`mt-4 px-4 py-3 text-sm font-mono ${
assignConflictId === selectedHackathon
? "border border-amber-500/30 bg-amber-500/10 text-amber-200"
: "border border-red-500/30 bg-red-500/10 text-red-300"
}`}
>
{prepState.error}
</p>
<p>{prepState.error}</p>
{assignConflictId === selectedHackathon && (
<button
type="button"
onClick={rebuildQueuesAnyway}
disabled={prepState.busy}
className="mt-4 px-6 py-3 bg-amber-500/10 border border-amber-500/40 text-amber-200 font-bold text-xs uppercase tracking-widest rounded-none hover:bg-amber-500/20 transition-ui font-mono disabled:opacity-40"
>
{prepState.busy ? "Rebuilding…" : "Rebuild anyway"}
</button>
)}
</div>
)}
</div>
</LiquidGlass>
Expand Down
Loading
Loading