Skip to content
Draft
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
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
6 changes: 5 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
Loading
Loading