@@ -15,14 +21,9 @@ const Register = () => {
}}
/>
-
+
- Register Now!
+ {label}
diff --git a/apps/bloomknights/src/app/api/auth/[...all]/route.ts b/apps/bloomknights/src/app/api/auth/[...all]/route.ts
new file mode 100644
index 000000000..eff641b97
--- /dev/null
+++ b/apps/bloomknights/src/app/api/auth/[...all]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from "~/auth/server";
+
+export const { GET, POST } = handlers;
diff --git a/apps/bloomknights/src/app/api/auth/signin/route.ts b/apps/bloomknights/src/app/api/auth/signin/route.ts
new file mode 100644
index 000000000..1bd23eebc
--- /dev/null
+++ b/apps/bloomknights/src/app/api/auth/signin/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+
+import { sanitizeCallbackURL } from "@forge/auth/callback-url";
+
+import { signInRoute } from "~/auth/server";
+import { env } from "~/env";
+
+export function GET(request: Request) {
+ if (env.NODE_ENV !== "development") return signInRoute(request);
+
+ const requestUrl = new URL(request.url);
+ const provider = requestUrl.searchParams.get("provider");
+ if (provider !== "discord") {
+ return NextResponse.json(
+ { error: "Unsupported provider parameter" },
+ { status: 400 },
+ );
+ }
+
+ const callbackPath = sanitizeCallbackURL(
+ requestUrl.searchParams.get("callbackURL"),
+ env.BLOOMKNIGHTS_URL,
+ "/dashboard",
+ );
+ const returnTo = new URL(callbackPath, env.BLOOMKNIGHTS_URL);
+ const bridgeUrl = new URL("/auth/bloom-return", env.BLADE_URL);
+ bridgeUrl.searchParams.set("returnTo", returnTo.toString());
+
+ const bladeSignInUrl = new URL("/api/auth/signin", env.BLADE_URL);
+ bladeSignInUrl.searchParams.set("provider", "discord");
+ bladeSignInUrl.searchParams.set(
+ "callbackURL",
+ `${bridgeUrl.pathname}${bridgeUrl.search}`,
+ );
+
+ return NextResponse.redirect(bladeSignInUrl);
+}
diff --git a/apps/bloomknights/src/app/api/trpc/[trpc]/route.ts b/apps/bloomknights/src/app/api/trpc/[trpc]/route.ts
new file mode 100644
index 000000000..8cd13115f
--- /dev/null
+++ b/apps/bloomknights/src/app/api/trpc/[trpc]/route.ts
@@ -0,0 +1,80 @@
+import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
+
+import { createTRPCContext, participantRouter } from "@forge/api/participant";
+
+import { validateToken } from "~/auth/server";
+
+const MAX_REQUEST_SIZE = 8 * 1024 * 1024;
+
+function requestTooLargeResponse() {
+ return Response.json(
+ { error: { message: "Request exceeds the 8MB upload limit." } },
+ { status: 413 },
+ );
+}
+
+function concatChunks(chunks: Uint8Array[], totalLength: number) {
+ if (chunks.length === 0) return undefined;
+
+ const body = new ArrayBuffer(totalLength);
+ const bytes = new Uint8Array(body);
+ let offset = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return body;
+}
+
+async function readRequestWithLimit(req: Request) {
+ if (req.method === "GET" || req.method === "HEAD" || !req.body) return req;
+
+ const reader = req.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let totalLength = 0;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ totalLength += value.byteLength;
+ if (totalLength > MAX_REQUEST_SIZE) {
+ await reader.cancel();
+ return null;
+ }
+
+ chunks.push(value);
+ }
+
+ return new Request(req.url, {
+ body: concatChunks(chunks, totalLength),
+ headers: req.headers,
+ method: req.method,
+ signal: req.signal,
+ });
+}
+
+const handler = async (req: Request) => {
+ const contentLength = Number(req.headers.get("content-length") ?? 0);
+ if (contentLength > MAX_REQUEST_SIZE) {
+ return requestTooLargeResponse();
+ }
+
+ const limitedRequest = await readRequestWithLimit(req);
+ if (!limitedRequest) return requestTooLargeResponse();
+
+ const session = await validateToken();
+ return fetchRequestHandler({
+ endpoint: "/api/trpc",
+ router: participantRouter,
+ req: limitedRequest,
+ createContext: () =>
+ createTRPCContext({ headers: limitedRequest.headers, session }),
+ onError({ error, path }) {
+ // eslint-disable-next-line no-console
+ console.error(`Bloom tRPC error on ${path ?? "unknown"}:`, error.message);
+ },
+ });
+};
+
+export { handler as GET, handler as POST };
diff --git a/apps/bloomknights/src/app/globals.css b/apps/bloomknights/src/app/globals.css
index 2a97a52b1..0544a7f43 100644
--- a/apps/bloomknights/src/app/globals.css
+++ b/apps/bloomknights/src/app/globals.css
@@ -71,6 +71,34 @@
--sidebar-ring: oklch(0.708 0 0);
}
+.dark {
+ --background: hsl(224 71.4% 4.1%);
+ --foreground: hsl(210 20% 98%);
+ --card: hsl(224 71.4% 4.1%);
+ --card-foreground: hsl(210 20% 98%);
+ --popover: hsl(224 71.4% 4.1%);
+ --popover-foreground: hsl(210 20% 98%);
+ --primary: hsl(263.4 70% 50.4%);
+ --primary-lighter: hsl(262.1 83.3% 60%);
+ --primary-foreground: hsl(210 20% 98%);
+ --secondary: hsl(215 27.9% 16.9%);
+ --secondary-foreground: hsl(210 20% 98%);
+ --muted: hsl(215 27.9% 16.9%);
+ --muted-foreground: hsl(217.9 10.6% 64.9%);
+ --accent: hsl(215 27.9% 16.9%);
+ --accent-foreground: hsl(210 20% 98%);
+ --destructive: hsl(0 62.8% 30.6%);
+ --destructive-foreground: hsl(210 20% 98%);
+ --border: hsl(215 27.9% 16.9%);
+ --input: hsl(215 27.9% 16.9%);
+ --ring: hsl(263.4 70% 50.4%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+}
+
html,
body {
overflow-anchor: none;
@@ -103,6 +131,540 @@ html {
font-family: var(--font-dm-sans), sans-serif;
}
+.bk-portal-nav,
+.bk-portal-panel {
+ position: relative;
+ border: 1.5px solid rgba(196, 168, 130, 0.42);
+ background: rgba(248, 243, 232, 0.9);
+ box-shadow:
+ 0 10px 34px rgba(36, 95, 53, 0.14),
+ inset 0 1px 0 rgba(255, 250, 230, 0.78);
+ -webkit-backdrop-filter: blur(14px) saturate(1.12);
+ backdrop-filter: blur(14px) saturate(1.12);
+}
+
+.bk-portal-panel {
+ overflow: hidden;
+ border-radius: 1.25rem;
+}
+
+.bk-portal-panel::before {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ content: "";
+ pointer-events: none;
+ background:
+ radial-gradient(
+ ellipse at 12% 8%,
+ rgba(184, 212, 232, 0.2),
+ transparent 46%
+ ),
+ radial-gradient(
+ ellipse at 88% 92%,
+ rgba(168, 196, 144, 0.16),
+ transparent 48%
+ );
+}
+
+.bk-portal-panel > * {
+ position: relative;
+ z-index: 1;
+}
+
+.bk-portal-kicker {
+ font-family: var(--font-righteous), cursive;
+ color: #7a4a1e;
+ font-size: 0.75rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.bk-portal-heading {
+ font-family: var(--font-righteous), cursive;
+ color: #245f35;
+ letter-spacing: -0.01em;
+ text-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.84),
+ 0 5px 16px rgba(36, 95, 53, 0.14);
+}
+
+.bk-portal-button {
+ min-height: 2.75rem;
+ border: 2px solid rgba(255, 255, 255, 0.68);
+ border-radius: 0.75rem;
+ background: linear-gradient(135deg, #f5d97a, #a8d471);
+ color: #245f35;
+ font-family: var(--font-righteous), cursive;
+ letter-spacing: 0.02em;
+ box-shadow:
+ 0 8px 24px rgba(36, 95, 53, 0.17),
+ inset 0 1px 0 rgba(255, 255, 255, 0.72);
+ transition:
+ border-color 180ms ease,
+ box-shadow 180ms ease,
+ filter 180ms ease,
+ transform 180ms ease;
+}
+
+.bk-portal-button:hover:not(:disabled) {
+ border-color: rgba(122, 171, 90, 0.78);
+ background: linear-gradient(135deg, #f8df88, #b2da7f);
+ filter: saturate(1.08);
+ transform: translateY(-1px);
+ box-shadow:
+ 0 10px 28px rgba(36, 95, 53, 0.22),
+ inset 0 1px 0 rgba(255, 255, 255, 0.82);
+}
+
+.bk-portal-button:active:not(:disabled) {
+ transform: translateY(1px) scale(0.99);
+}
+
+.bk-portal-button:focus-visible {
+ outline: 3px solid rgba(122, 171, 90, 0.38);
+ outline-offset: 2px;
+}
+
+.bk-portal-tools-grid {
+ display: grid;
+ gap: 1px;
+ background: rgba(196, 168, 130, 0.32);
+}
+
+.bk-portal-tool {
+ display: flex;
+ min-height: 7.75rem;
+ flex-direction: column;
+ padding: 1rem;
+ background: rgba(255, 250, 238, 0.92);
+ color: #3d2e1e;
+ text-align: left;
+ transition:
+ background-color 180ms ease,
+ box-shadow 180ms ease,
+ filter 180ms ease,
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.bk-portal-tool:hover:not([aria-disabled="true"]) {
+ background: rgba(218, 234, 245, 0.78);
+ filter: saturate(1.05);
+ transform: translateY(-0.2rem);
+ box-shadow: inset 0 0 0 2px rgba(122, 171, 90, 0.22);
+}
+
+.bk-portal-tool:active:not([aria-disabled="true"]) {
+ transform: translateY(0) scale(0.99);
+}
+
+.bk-portal-tool:focus-visible {
+ z-index: 2;
+ outline: 3px solid rgba(122, 171, 90, 0.58);
+ outline-offset: -3px;
+}
+
+.bk-portal-tool-icon {
+ display: flex;
+ width: 2.25rem;
+ height: 2.25rem;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid rgba(122, 171, 90, 0.32);
+ border-radius: 0.65rem;
+ background: linear-gradient(
+ 145deg,
+ rgba(218, 234, 245, 0.92),
+ rgba(218, 228, 148, 0.74)
+ );
+ color: #245f35;
+}
+
+.bk-dashboard-nav a,
+.bk-dashboard-nav button {
+ transition:
+ background-color 180ms ease,
+ color 180ms ease,
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.bk-dashboard-nav a:hover,
+.bk-dashboard-nav button:hover {
+ transform: translateY(-1px);
+}
+
+.bk-dashboard-stack {
+ --bk-dashboard-ease: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.bk-dashboard-panel {
+ transform-origin: center top;
+ animation: bk-dashboard-panel-in 220ms
+ var(--bk-dashboard-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;
+}
+
+.bk-dashboard-hero-panel::after {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ content: "";
+ pointer-events: none;
+ background: linear-gradient(
+ 115deg,
+ transparent 14%,
+ rgba(255, 255, 255, 0.22) 30%,
+ transparent 48%
+ );
+ transform: translateX(-70%);
+ animation: bk-dashboard-panel-wash 9s ease-in-out infinite;
+}
+
+.bk-dashboard-status {
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+ animation: bk-dashboard-status-breathe 4.8s ease-in-out infinite;
+}
+
+.bk-dashboard-status::after {
+ position: absolute;
+ inset: -35% auto -35% -45%;
+ z-index: -1;
+ width: 42%;
+ content: "";
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.55),
+ transparent
+ );
+ transform: skewX(-18deg);
+ animation: bk-dashboard-status-sweep 3.8s ease-in-out infinite;
+}
+
+.bk-dashboard-event-card {
+ position: relative;
+ overflow: hidden;
+}
+
+.bk-dashboard-event-card::after {
+ position: absolute;
+ right: -3rem;
+ bottom: -3rem;
+ width: 9rem;
+ aspect-ratio: 1;
+ content: "";
+ border-radius: 999px;
+ background: radial-gradient(
+ circle,
+ rgba(168, 212, 113, 0.24),
+ transparent 62%
+ );
+ animation: bk-dashboard-orbit-glow 7s ease-in-out infinite;
+ pointer-events: none;
+}
+
+.bk-dashboard-action-strip {
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+}
+
+.bk-dashboard-action-strip::after {
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ content: "";
+ background: linear-gradient(
+ 100deg,
+ transparent,
+ rgba(255, 255, 255, 0.26),
+ transparent
+ );
+ transform: translateX(-105%);
+ animation: bk-dashboard-panel-wash 7.5s ease-in-out 1.4s infinite;
+}
+
+.bk-dashboard-tool .bk-portal-tool-icon,
+.bk-dashboard-tool > svg:last-child {
+ transition:
+ box-shadow 180ms ease,
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.bk-dashboard-tool:hover:not([aria-disabled="true"]) .bk-portal-tool-icon {
+ transform: translateY(-0.1rem) rotate(-3deg) scale(1.04);
+ box-shadow: 0 0.65rem 1.2rem rgba(36, 95, 53, 0.16);
+}
+
+.bk-dashboard-tool:hover:not([aria-disabled="true"]) > svg:last-child {
+ transform: translate(0.16rem, -0.16rem);
+}
+
+@keyframes bk-dashboard-panel-in {
+ from {
+ opacity: 0;
+ transform: translateY(0.35rem);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes bk-dashboard-panel-wash {
+ 0%,
+ 55% {
+ transform: translateX(-75%);
+ }
+
+ 100% {
+ transform: translateX(115%);
+ }
+}
+
+@keyframes bk-dashboard-status-breathe {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 rgba(122, 171, 90, 0);
+ }
+
+ 50% {
+ box-shadow: 0 0.35rem 1.1rem rgba(122, 171, 90, 0.17);
+ }
+}
+
+@keyframes bk-dashboard-status-sweep {
+ 0%,
+ 48% {
+ transform: translateX(0) skewX(-18deg);
+ }
+
+ 100% {
+ transform: translateX(360%) skewX(-18deg);
+ }
+}
+
+@keyframes bk-dashboard-orbit-glow {
+ 0%,
+ 100% {
+ transform: translate(0, 0) scale(1);
+ opacity: 0.55;
+ }
+
+ 50% {
+ transform: translate(-0.8rem, -0.35rem) scale(1.08);
+ opacity: 0.9;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bk-dashboard-nav,
+ .bk-dashboard-panel,
+ .bk-dashboard-hero-panel::after,
+ .bk-dashboard-status,
+ .bk-dashboard-status::after,
+ .bk-dashboard-event-card::after,
+ .bk-dashboard-action-strip,
+ .bk-dashboard-action-strip::after,
+ .bk-dashboard-tool {
+ animation: none !important;
+ }
+
+ .bk-dashboard-nav a,
+ .bk-dashboard-nav button,
+ .bk-dashboard-tool,
+ .bk-dashboard-tool .bk-portal-tool-icon,
+ .bk-dashboard-tool > svg:last-child,
+ .bk-portal-button,
+ .bk-portal-tool {
+ transition-duration: 0.01ms;
+ }
+
+ .bk-dashboard-nav a:hover,
+ .bk-dashboard-nav button:hover,
+ .bk-dashboard-tool:hover,
+ .bk-dashboard-tool:hover .bk-portal-tool-icon,
+ .bk-dashboard-tool:hover > svg:last-child,
+ .bk-portal-button:hover,
+ .bk-portal-button:active,
+ .bk-portal-tool:hover,
+ .bk-portal-tool:active {
+ transform: none !important;
+ }
+}
+
+@media (min-width: 640px) {
+ .bk-portal-tools-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 1024px) {
+ .bk-portal-tools-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+
+.bk-profile-form
+ > div:not(.bk-profile-section-heading):not(.bk-profile-consent) {
+ min-height: 6.25rem;
+ min-width: 0;
+}
+
+.bk-profile-form > div:has(textarea) {
+ min-height: 12.5rem;
+}
+
+.bk-profile-form .bk-profile-section-heading {
+ grid-column: 1 / -1;
+ margin-top: 1.25rem;
+ border-top: 1px solid rgba(196, 168, 130, 0.32);
+ padding-top: 1.5rem;
+}
+
+.bk-profile-form .bk-profile-section-heading h2 {
+ color: #245f35;
+ font-family: var(--font-righteous), cursive;
+ font-size: 1.125rem;
+ font-weight: 900;
+ letter-spacing: -0.01em;
+}
+
+.bk-profile-form .bk-profile-section-heading p {
+ margin-top: 0.25rem;
+ color: #5a4535;
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+.bk-profile-form label {
+ color: #245f35;
+ font-weight: 750;
+}
+
+.bk-profile-form .text-gray-400 {
+ color: #6a5a4d;
+}
+
+.bk-profile-form input,
+.bk-profile-form textarea,
+.bk-profile-form button[role="combobox"] {
+ width: 100%;
+ border-color: rgba(196, 168, 130, 0.5);
+ border-radius: 0.5rem;
+ background: rgba(255, 250, 238, 0.86);
+ color: #3d2e1e;
+ font-size: 1rem;
+ box-shadow: none;
+}
+
+.bk-profile-form .bk-profile-combobox-trigger {
+ border-color: rgba(196, 168, 130, 0.5) !important;
+ background: rgba(255, 250, 238, 0.86) !important;
+ color: #3d2e1e !important;
+ box-shadow: none;
+}
+
+.bk-profile-form .bk-profile-combobox-trigger:hover,
+.bk-profile-form .bk-profile-combobox-trigger[data-state="open"] {
+ border-color: rgba(122, 171, 90, 0.58) !important;
+ background: rgba(255, 250, 238, 0.96) !important;
+ color: #3d2e1e !important;
+}
+
+.bk-profile-form .bk-profile-allergy-trigger {
+ border-color: rgba(196, 168, 130, 0.5) !important;
+ background: rgba(255, 250, 238, 0.86) !important;
+ color: #3d2e1e !important;
+ box-shadow: none;
+}
+
+.bk-profile-form .bk-profile-allergy-trigger:hover,
+.bk-profile-form .bk-profile-allergy-trigger[data-state="open"] {
+ border-color: rgba(122, 171, 90, 0.58) !important;
+ background: rgba(255, 250, 238, 0.96) !important;
+ color: #3d2e1e !important;
+}
+
+.bk-profile-form .bk-profile-allergy-trigger .text-gray-400 {
+ color: #6a5a4d !important;
+}
+
+.bk-profile-form input,
+.bk-profile-form button[role="combobox"] {
+ min-height: 2.75rem;
+}
+
+.bk-profile-form textarea {
+ min-height: 8rem;
+ resize: vertical;
+}
+
+.bk-profile-form input[type="file"] {
+ height: auto;
+ min-height: 3rem;
+ padding: 0.5rem;
+ line-height: 1.4;
+}
+
+.bk-profile-form input[type="file"]::file-selector-button {
+ max-width: 100%;
+ white-space: normal;
+}
+
+.bk-profile-form input:focus-visible,
+.bk-profile-form textarea:focus-visible,
+.bk-profile-form button[role="combobox"]:focus-visible,
+.bk-profile-form .bk-profile-combobox-trigger:focus-visible,
+.bk-profile-form .bk-profile-allergy-trigger:focus-visible {
+ border-color: #7aab5a;
+ outline: 3px solid rgba(122, 171, 90, 0.24);
+ outline-offset: 1px;
+}
+
+.bk-profile-form .bk-profile-consent {
+ min-height: 3rem;
+ min-width: 0;
+ border-left: 3px solid #c9b8d8;
+ padding: 0.6rem 0 0.6rem 0.8rem;
+}
+
+.bk-profile-form .bk-profile-consent label {
+ overflow-wrap: anywhere;
+ line-height: 1.45;
+}
+
+@media (max-width: 639px) {
+ .bk-profile-panel {
+ border-radius: 0.9rem;
+ }
+
+ .bk-profile-form
+ > div:not(.bk-profile-section-heading):not(.bk-profile-consent) {
+ min-height: auto;
+ }
+
+ .bk-profile-form > div:has(textarea) {
+ min-height: auto;
+ }
+
+ .bk-profile-form .bk-profile-section-heading {
+ margin-top: 0.75rem;
+ padding-top: 1rem;
+ }
+
+ .bk-profile-form .bk-profile-section-heading h2 {
+ font-size: 1rem;
+ }
+
+ .bk-profile-form .bk-profile-consent {
+ border-left-width: 2px;
+ padding: 0.75rem 0 0.75rem 0.75rem;
+ }
+}
+
.bloom-site-background {
position: relative;
isolation: isolate;
diff --git a/apps/bloomknights/src/app/layout.tsx b/apps/bloomknights/src/app/layout.tsx
index 07357c048..a9dd9982c 100644
--- a/apps/bloomknights/src/app/layout.tsx
+++ b/apps/bloomknights/src/app/layout.tsx
@@ -1,16 +1,9 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
+import { GeistMono } from "geist/font/mono";
+import { GeistSans } from "geist/font/sans";
-import Footer from "./_components/footer/footer";
-import AnimatedBirds from "./_components/graphics/AnimatedBirds";
-import BloomAssetPreloads from "./_components/graphics/BloomAssetPreloads";
-import FloatingFlowers from "./_components/graphics/FloatingFlowers";
-import FlowerCursor from "./_components/graphics/Flowercursor";
-import ParallaxBackground from "./_components/graphics/ParallaxBackground";
-import Squiggles from "./_components/graphics/squiggles";
-import Navbar from "./_components/navbar/Navbar";
import {
- eventJsonLd,
OG_IMAGE_ALT,
OG_IMAGE_HEIGHT,
OG_IMAGE_URL,
@@ -108,33 +101,10 @@ export default function RootLayout({
return (
-
-
-
-
-
-
-
-
-
-
-
-
{children}
-
-
-
-
-
-
+ {children}
);
}
diff --git a/apps/bloomknights/src/auth/client.ts b/apps/bloomknights/src/auth/client.ts
new file mode 100644
index 000000000..dd58c54ab
--- /dev/null
+++ b/apps/bloomknights/src/auth/client.ts
@@ -0,0 +1,9 @@
+"use client";
+
+import { createForgeAuthClient } from "@forge/auth/client-factory";
+
+export const bloomAuthClient = createForgeAuthClient({
+ defaultRedirectPath: "/dashboard",
+});
+
+export const { auth, authClient, signIn, signOut } = bloomAuthClient;
diff --git a/apps/bloomknights/src/auth/server.ts b/apps/bloomknights/src/auth/server.ts
new file mode 100644
index 000000000..44c6afafe
--- /dev/null
+++ b/apps/bloomknights/src/auth/server.ts
@@ -0,0 +1,10 @@
+import { createForgeAuthServer } from "@forge/auth/server-factory";
+
+import { env } from "~/env";
+
+export const bloomAuth = createForgeAuthServer({
+ baseURL: env.BLOOMKNIGHTS_URL,
+ defaultRedirectPath: "/dashboard",
+});
+
+export const { auth, handlers, signIn, signInRoute, validateToken } = bloomAuth;
diff --git a/apps/bloomknights/src/env.ts b/apps/bloomknights/src/env.ts
new file mode 100644
index 000000000..f44e4d962
--- /dev/null
+++ b/apps/bloomknights/src/env.ts
@@ -0,0 +1,35 @@
+import { createEnv } from "@t3-oss/env-nextjs";
+import { z } from "zod";
+
+import { hackathonPortalOriginSchema } from "@forge/validators";
+
+const bladeUrlSchema =
+ process.env.NODE_ENV === "production"
+ ? hackathonPortalOriginSchema
+ : hackathonPortalOriginSchema.default("http://localhost:3000");
+
+const bloomKnightsUrlSchema =
+ process.env.NODE_ENV === "production"
+ ? hackathonPortalOriginSchema
+ : hackathonPortalOriginSchema.default("http://localhost:3006");
+
+export const env = createEnv({
+ server: {
+ BLADE_URL: bladeUrlSchema,
+ BLOOMKNIGHTS_URL: bloomKnightsUrlSchema,
+ },
+ shared: {
+ NODE_ENV: z
+ .enum(["development", "production", "test"])
+ .default("development"),
+ PORT: z.coerce.number().default(3006),
+ },
+ runtimeEnv: {
+ BLADE_URL: process.env.BLADE_URL,
+ BLOOMKNIGHTS_URL: process.env.BLOOMKNIGHTS_URL,
+ NODE_ENV: process.env.NODE_ENV,
+ PORT: process.env.PORT,
+ },
+ skipValidation:
+ !!process.env.CI || process.env.npm_lifecycle_event === "lint",
+});
diff --git a/apps/bloomknights/src/lib/portal-config.ts b/apps/bloomknights/src/lib/portal-config.ts
new file mode 100644
index 000000000..845af2acb
--- /dev/null
+++ b/apps/bloomknights/src/lib/portal-config.ts
@@ -0,0 +1,17 @@
+import type { HackathonPortalConfig } from "@forge/hackathon";
+
+export const BLOOM_PORTAL_CONFIG = {
+ hackathonName: "bloomknights",
+ routes: {
+ home: "/",
+ dashboard: "/dashboard",
+ apply: "/apply",
+ profile: "/dashboard/profile",
+ },
+ termsUrl: "https://knight-hacks.notion.site/knight-hacks-26-tos",
+ guideUrl: "https://knight-hacks.notion.site/bloomknights2026",
+ copy: {
+ applicationName: "BloomKnights",
+ supportChannelUrl: "https://discord.gg/2W2HCvkKAy",
+ },
+} satisfies HackathonPortalConfig;
diff --git a/docs/API-AND-PERMISSIONS.md b/docs/API-AND-PERMISSIONS.md
index 5e51cb44e..ccf595bae 100644
--- a/docs/API-AND-PERMISSIONS.md
+++ b/docs/API-AND-PERMISSIONS.md
@@ -6,6 +6,18 @@ This guide covers how to work with the tRPC API in Forge, including our permissi
We have four types of procedures. Choose the right one based on authentication and permission requirements.
+### Participant router
+
+Active hackathon apps mount `participantRouter` from
+`@forge/api/participant` on their own origin. This router exposes only the
+`portal` workflow; it does not import or expose Blade admin procedures.
+
+Participant procedures must accept an explicit `hackathonName`, derive the
+user from `ctx.session`, and resolve the hacker/attendee row on the server.
+Never accept a client-supplied user or hacker ID for self-service operations.
+The host app is responsible for validating its local Better Auth session and
+passing it to `createTRPCContext`.
+
### `publicProcedure`
Use when the endpoint doesn't require authentication.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 05092d5b4..b3e1cc7b5 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -21,6 +21,7 @@ forge/
│ ├── auth/ # Authentication setup
│ ├── db/ # Database schema and client
│ ├── email/ # Email templates and sending
+│ ├── hackathon/ # Headless participant portal workflows
│ ├── ui/ # Shared UI components
│ └── consts/ # Shared constants
└── tooling/ # Shared configuration
@@ -32,33 +33,42 @@ forge/
## How Apps Communicate
-### Blade as the "Backend"
+### Blade and shared backend packages
-While `blade` is technically a Next.js app, it serves as the backend because:
+`blade` is the member and organizer application. It mounts the complete tRPC
+router because it owns administrative workflows such as acceptance, check-in,
+event management, and data exports.
-- It contains all write operations (create, update, delete)
-- It handles authentication
-- It manages role-based permissions
-- Other apps only have read access via tRPC
+Hackathon sites may also mount the participant-scoped router and their own
+Better Auth handler. Business procedures, schemas, and identity tables remain
+shared; only the host application and browser session are event-specific.
### Frontend-Only Apps
-Apps like `club`, `guild`, `2025`, and `gemiknights` are frontend-only and interact with `blade` for data:
+Marketing apps such as `club` and archived event sites are primarily
+frontend-only. Active hackathon apps can additionally own authenticated
+participant routes:
- **club**: Reads member count and other club stats
- **guild**: Reads member profiles for the networking directory
-- **2025/gemiknights**: Primarily static, minimal backend needs
+- **BloomKnights**: Owns participant auth, application, dashboard, and profile
+ routes through the shared participant API
+- **2025/gemiknights**: Archived static sites with minimal backend needs
-These apps use tRPC (via `@forge/api`) to make read-only API calls to `blade`.
+Frontend-only apps use tRPC (via `@forge/api`) to make read-only API calls to
+Blade. Active participant portals mount their restricted router locally.
### Authentication Flow
-All authentication is centralized in `blade`:
+Authentication is configured per application through `@forge/auth`:
-1. User clicks "Login" on any frontend app
-2. They're redirected to `blade` with a callback URL
-3. `blade` handles Discord OAuth via Better Auth
-4. After authentication, user is redirected to the necessary functional page on Blade
+1. A user starts Discord OAuth on the application they are using.
+2. That app handles the callback on its own origin.
+3. Every app uses the same Better Auth secret, database adapter, Discord
+ provider, and account tables, so the Discord identity resolves to the same
+ Forge user.
+4. Cookies remain host-scoped; signing into or out of one app does not silently
+ change another app's browser session.
## Shared Packages
@@ -87,10 +97,17 @@ Local development applies schema changes with `pnpm db:migrate`. Schema edits sh
Authentication setup using Better Auth with Discord OAuth.
-- Currently only used in `blade`
-- Separated as a package for potential future use in other apps
+- Exposes reusable server/client factories for Blade and hackathon apps
- Handles Discord OAuth flow and session management
+### `@forge/hackathon`
+
+Headless participant workflow package used by event applications.
+
+- Owns application, dashboard, profile, and participant tRPC state
+- Exposes no event-specific markup or assets
+- Lets each event app implement a completely independent visual system
+
### `@forge/email`
Email system using Listmonk.
diff --git a/packages/api/package.json b/packages/api/package.json
index 2e96d68b7..0743f1281 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -8,6 +8,10 @@
"types": "./dist/index.d.ts",
"default": "./src/index.ts"
},
+ "./participant": {
+ "types": "./dist/participant.d.ts",
+ "default": "./src/participant.ts"
+ },
"./env": {
"types": "./dist/env.d.ts",
"default": "./src/env.ts"
diff --git a/packages/api/src/minio/minio-client.ts b/packages/api/src/minio/minio-client.ts
index e15f6edc1..8707efa1b 100644
--- a/packages/api/src/minio/minio-client.ts
+++ b/packages/api/src/minio/minio-client.ts
@@ -1,11 +1,11 @@
import { Client } from "minio";
-import { env } from "../env";
+import { storageEnv } from "../storage-env";
export const minioClient = new Client({
- endPoint: env.MINIO_ENDPOINT,
+ endPoint: storageEnv.MINIO_ENDPOINT,
port: 443,
useSSL: true,
- accessKey: env.MINIO_ACCESS_KEY,
- secretKey: env.MINIO_SECRET_KEY,
+ accessKey: storageEnv.MINIO_ACCESS_KEY,
+ secretKey: storageEnv.MINIO_SECRET_KEY,
});
diff --git a/packages/api/src/participant-contract.ts b/packages/api/src/participant-contract.ts
new file mode 100644
index 000000000..07cc0e4b4
--- /dev/null
+++ b/packages/api/src/participant-contract.ts
@@ -0,0 +1,107 @@
+import type {
+ TRPCMutationProcedure,
+ TRPCQueryProcedure,
+ TRPCRouterRecord,
+} from "@trpc/server";
+
+import type { FORMS } from "@forge/consts";
+import type {
+ SelectEvent,
+ SelectHackathon,
+ SelectHacker,
+ SelectMember,
+} from "@forge/db/schemas/knight-hacks";
+import type { HackerApplicationWireInput } from "@forge/validators";
+
+type HackerStatus = (typeof FORMS.HACKATHON_APPLICATION_STATES)[number];
+interface HackathonInput {
+ hackathonName: string;
+}
+interface ProcedureDef
{
+ input: TInput;
+ output: TOutput;
+ meta: unknown;
+}
+type Query = TRPCQueryProcedure>;
+type Mutation = TRPCMutationProcedure<
+ ProcedureDef
+>;
+
+export type Participant = SelectHacker & {
+ status: HackerStatus;
+ points: number;
+ timeApplied: Date;
+ timeConfirmed: Date | null;
+};
+
+export type PublicHackathon = Pick<
+ SelectHackathon,
+ | "name"
+ | "displayName"
+ | "theme"
+ | "applicationBackgroundEnabled"
+ | "applicationBackgroundKey"
+ | "applicationOpen"
+ | "applicationDeadline"
+ | "startDate"
+ | "endDate"
+>;
+
+export interface ParticipantDashboard {
+ confirmedCount: number;
+ hackathon: SelectHackathon;
+ participant: Participant | null;
+ pastHackathons: {
+ id: string;
+ name: string;
+ displayName: string;
+ startDate: Date;
+ endDate: Date;
+ status: HackerStatus;
+ }[];
+}
+
+export interface ParticipantApplicationContext {
+ existingApplication: Participant | null;
+ hackathon: SelectHackathon;
+ memberProfile: SelectMember | null;
+ previousHacker: SelectHacker | null;
+}
+
+export type ParticipantScheduleEvent = Pick<
+ SelectEvent,
+ "id" | "name" | "description" | "tag" | "location" | "points"
+> & {
+ startDateTime: Date;
+ endDateTime: Date;
+};
+
+export interface ParticipantPortalContract {
+ getHackathon: Query;
+ getDashboard: Query;
+ getApplicationContext: Query;
+ submitApplication: Mutation<
+ HackerApplicationWireInput & HackathonInput,
+ void
+ >;
+ updateProfile: Mutation<
+ HackerApplicationWireInput & HackathonInput,
+ Participant | null
+ >;
+ uploadResume: Mutation<
+ HackathonInput & { fileName: string; fileContent: string },
+ string
+ >;
+ getResume: Query;
+ confirmAttendance: Mutation;
+ withdrawAttendance: Mutation;
+ getQRCode: Query;
+ getSchedule: Query;
+ reportIssue: Mutation<
+ HackathonInput & { description: string },
+ { submitted: true }
+ >;
+}
+
+export type ParticipantPortalRouterRecord = ParticipantPortalContract &
+ TRPCRouterRecord;
diff --git a/packages/api/src/participant.ts b/packages/api/src/participant.ts
new file mode 100644
index 000000000..07c057522
--- /dev/null
+++ b/packages/api/src/participant.ts
@@ -0,0 +1,41 @@
+import type {
+ inferRouterInputs,
+ inferRouterOutputs,
+ TRPCBuiltRouter,
+ TRPCRouterBuilder,
+ TRPCRouterRecord,
+} from "@trpc/server";
+
+import type { ParticipantPortalRouterRecord } from "./participant-contract";
+import { participantPortalRouter } from "./routers/participant-portal";
+import { createTRPCRouter } from "./trpc";
+
+type ParticipantRootTypes =
+ typeof createTRPCRouter extends TRPCRouterBuilder
+ ? TRoot
+ : never;
+export type ParticipantRouter = TRPCBuiltRouter<
+ ParticipantRootTypes,
+ { portal: ParticipantPortalRouterRecord } & TRPCRouterRecord
+>;
+
+export const participantRouter: ParticipantRouter = createTRPCRouter({
+ portal: participantPortalRouter,
+}) as unknown as ParticipantRouter;
+
+export type ParticipantRouterInputs = inferRouterInputs;
+export type ParticipantRouterOutputs = inferRouterOutputs;
+
+export function createParticipantCaller(
+ ...args: Parameters
+): ReturnType {
+ return participantRouter.createCaller(...args);
+}
+
+export type {
+ Participant,
+ ParticipantApplicationContext,
+ ParticipantDashboard,
+ ParticipantScheduleEvent,
+} from "./participant-contract";
+export { createTRPCContext } from "./trpc";
diff --git a/packages/api/src/resume-storage.ts b/packages/api/src/resume-storage.ts
index e30b171e7..c1887c48c 100644
--- a/packages/api/src/resume-storage.ts
+++ b/packages/api/src/resume-storage.ts
@@ -8,19 +8,19 @@ import { db } from "@forge/db/client";
import { Hacker, Member } from "@forge/db/schemas/knight-hacks";
import { logger } from "@forge/utils";
-import { env } from "./env";
import {
getResumeUserPrefix,
isResumeObjectOwnedByUser,
isServerGeneratedResumeObjectName,
RESUME_BUCKET_NAME,
} from "./resume-security";
+import { storageEnv } from "./storage-env";
export const resumeStorageClient = new Client({
- endPoint: env.MINIO_ENDPOINT,
+ endPoint: storageEnv.MINIO_ENDPOINT,
useSSL: true,
- accessKey: env.MINIO_ACCESS_KEY,
- secretKey: env.MINIO_SECRET_KEY,
+ accessKey: storageEnv.MINIO_ACCESS_KEY,
+ secretKey: storageEnv.MINIO_SECRET_KEY,
});
export async function ensureResumeBucketExists() {
diff --git a/packages/api/src/routers/hackathon.ts b/packages/api/src/routers/hackathon.ts
index 08eb58ccb..f47c90d47 100644
--- a/packages/api/src/routers/hackathon.ts
+++ b/packages/api/src/routers/hackathon.ts
@@ -18,7 +18,9 @@ import {
getHackathonBackgroundIssues,
getHackathonDateWindowIssues,
getHackathonEmailTemplateIssues,
+ hackathonConfirmationCapacitySchema,
hackathonDisplayNameSchema,
+ hackathonPortalBaseUrlSchema,
hackathonRouteNameSchema,
hackathonThemeSchema,
} from "@forge/validators";
@@ -41,6 +43,8 @@ const hackathonMutationInput = z.object({
applicationBackgroundKey: hackathonApplicationBackgroundKeySchema,
emailTemplateEnabled: z.boolean().default(false),
emailTemplateKey: hackathonEmailTemplateKeySchema,
+ portalBaseUrl: hackathonPortalBaseUrlSchema,
+ confirmationCapacity: hackathonConfirmationCapacitySchema,
applicationOpen: z.coerce.date(),
applicationDeadline: z.coerce.date(),
confirmationDeadline: z.coerce.date(),
@@ -94,6 +98,8 @@ function getHackathonMutationValues(
emailTemplateKey: input.emailTemplateEnabled
? input.emailTemplateKey
: null,
+ portalBaseUrl: input.portalBaseUrl,
+ confirmationCapacity: input.confirmationCapacity,
applicationOpen: input.applicationOpen,
applicationDeadline: input.applicationDeadline,
confirmationDeadline: input.confirmationDeadline,
diff --git a/packages/api/src/routers/hackers/mutations.ts b/packages/api/src/routers/hackers/mutations.ts
index 6f8947c55..d6bf25446 100644
--- a/packages/api/src/routers/hackers/mutations.ts
+++ b/packages/api/src/routers/hackers/mutations.ts
@@ -15,6 +15,7 @@ import {
import { sendHackathonEmail } from "@forge/email";
import { logger, permissions } from "@forge/utils";
import * as discord from "@forge/utils/discord";
+import { hackerApplicationWireSchema } from "@forge/validators";
import { ensureUserQRCode } from "../../qr-code";
import {
@@ -25,16 +26,7 @@ import { permProcedure, protectedProcedure } from "../../trpc";
export const hackerMutationRouter = {
createHacker: protectedProcedure
- .input(
- z.object({
- ...InsertHackerSchema.omit({
- userId: true,
- age: true,
- discordUser: true,
- }).shape,
- hackathonName: z.string(),
- }),
- )
+ .input(hackerApplicationWireSchema.extend({ hackathonName: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.id;
const { hackathonName, ...hackerData } = input;
@@ -175,7 +167,7 @@ export const hackerMutationRouter = {
}
}),
- updateHacker: protectedProcedure
+ updateHacker: permProcedure
.input(
InsertHackerSchema.omit({
userId: true,
@@ -184,6 +176,8 @@ export const hackerMutationRouter = {
}),
)
.mutation(async ({ input, ctx }) => {
+ permissions.controlPerms.or(["EDIT_HACKERS"], ctx);
+
if (!input.id) {
throw new TRPCError({
message: "Hacker ID is required to update a member!",
@@ -225,7 +219,7 @@ export const hackerMutationRouter = {
? undefined
: await normalizeResumeObjectNameForPersistence(
updateData.resumeUrl,
- ctx.session.user.id,
+ hacker.userId,
);
await db
@@ -237,9 +231,9 @@ export const hackerMutationRouter = {
age: newAge,
phoneNumber: normalizedPhone,
})
- .where(eq(Hacker.userId, ctx.session.user.id));
+ .where(eq(Hacker.id, id));
if (isResumeChanged) {
- await removeUnreferencedResumeObjectsForUser(ctx.session.user.id);
+ await removeUnreferencedResumeObjectsForUser(hacker.userId);
}
// Create a log of the changes for logger
@@ -309,6 +303,11 @@ export const hackerMutationRouter = {
});
}
+ const hacker = await db.query.Hacker.findFirst({
+ columns: { userId: true },
+ where: (table, { eq }) => eq(table.id, input.id ?? ""),
+ });
+
await db.delete(Hacker).where(eq(Hacker.id, input.id));
await discord.log({
@@ -318,8 +317,8 @@ export const hackerMutationRouter = {
userId: ctx.session.user.discordUserId,
});
- if (ctx.session.user.id) {
- await db.delete(Session).where(eq(Session.userId, ctx.session.user.id));
+ if (hacker?.userId) {
+ await db.delete(Session).where(eq(Session.userId, hacker.userId));
}
}),
diff --git a/packages/api/src/routers/participant-portal.ts b/packages/api/src/routers/participant-portal.ts
new file mode 100644
index 000000000..c3c574429
--- /dev/null
+++ b/packages/api/src/routers/participant-portal.ts
@@ -0,0 +1,579 @@
+import type { TRPCBuiltRouter, TRPCRouterBuilder } from "@trpc/server";
+import { TRPCError } from "@trpc/server";
+import QRCode from "qrcode";
+import { z } from "zod";
+
+import { DISCORD } from "@forge/consts";
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ getTableColumns,
+ inArray,
+ lt,
+ sql,
+} from "@forge/db";
+import { db } from "@forge/db/client";
+import {
+ Event,
+ Hackathon,
+ Hacker,
+ HackerAttendee,
+} from "@forge/db/schemas/knight-hacks";
+import { sendHackathonEmail } from "@forge/email";
+import { logger } from "@forge/utils";
+import * as discord from "@forge/utils/discord";
+import { hackerApplicationWireSchema } from "@forge/validators";
+
+import type {
+ ParticipantPortalContract,
+ ParticipantPortalRouterRecord,
+} from "../participant-contract";
+import { getUserQRCodePayload } from "../qr-code";
+import {
+ createResumeObjectName,
+ decodeAndValidateResumeDataUrl,
+ MAX_RESUME_DATA_URL_LENGTH,
+ normalizeOwnedResumeObjectName,
+ RESUME_BUCKET_NAME,
+} from "../resume-security";
+import {
+ ensureResumeBucketExists,
+ normalizeResumeObjectNameForPersistence,
+ removeUnreferencedResumeObjectsForUser,
+ resumeStorageClient,
+} from "../resume-storage";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
+import { hackerMutationRouter } from "./hackers/mutations";
+
+const hackathonInput = z.object({ hackathonName: z.string().min(1) });
+
+async function requireHackathon(hackathonName: string) {
+ const hackathon = await db.query.Hackathon.findFirst({
+ where: (table, { eq }) => eq(table.name, hackathonName),
+ });
+
+ if (!hackathon) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found." });
+ }
+
+ return hackathon;
+}
+
+async function getPublicHackathon(hackathonName: string) {
+ const [hackathon] = await db
+ .select({
+ name: Hackathon.name,
+ displayName: Hackathon.displayName,
+ theme: Hackathon.theme,
+ applicationBackgroundEnabled: Hackathon.applicationBackgroundEnabled,
+ applicationBackgroundKey: Hackathon.applicationBackgroundKey,
+ applicationOpen: Hackathon.applicationOpen,
+ applicationDeadline: Hackathon.applicationDeadline,
+ startDate: Hackathon.startDate,
+ endDate: Hackathon.endDate,
+ })
+ .from(Hackathon)
+ .where(eq(Hackathon.name, hackathonName))
+ .limit(1);
+
+ if (!hackathon) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found." });
+ }
+
+ return hackathon;
+}
+
+async function getParticipant(hackathonId: string, userId: string) {
+ const [participant] = await db
+ .select({
+ ...getTableColumns(Hacker),
+ status: HackerAttendee.status,
+ points: HackerAttendee.points,
+ timeApplied: HackerAttendee.timeApplied,
+ timeConfirmed: HackerAttendee.timeConfirmed,
+ })
+ .from(Hacker)
+ .innerJoin(HackerAttendee, eq(HackerAttendee.hackerId, Hacker.id))
+ .where(
+ and(
+ eq(Hacker.userId, userId),
+ eq(HackerAttendee.hackathonId, hackathonId),
+ ),
+ )
+ .limit(1);
+
+ return participant ?? null;
+}
+
+const participantPortalRouterImplementation = {
+ getHackathon: publicProcedure
+ .input(hackathonInput)
+ .query(async ({ input }) => getPublicHackathon(input.hackathonName)),
+
+ getDashboard: protectedProcedure
+ .input(hackathonInput)
+ .query(async ({ input, ctx }) => {
+ const hackathon = await requireHackathon(input.hackathonName);
+ const participant = await getParticipant(
+ hackathon.id,
+ ctx.session.user.id,
+ );
+ const [{ confirmedCount = 0 } = {}] = await db
+ .select({ confirmedCount: count() })
+ .from(HackerAttendee)
+ .where(
+ and(
+ eq(HackerAttendee.hackathonId, hackathon.id),
+ inArray(HackerAttendee.status, ["confirmed", "checkedin"]),
+ ),
+ );
+
+ const pastHackathons = await db
+ .select({
+ id: Hackathon.id,
+ name: Hackathon.name,
+ displayName: Hackathon.displayName,
+ startDate: Hackathon.startDate,
+ endDate: Hackathon.endDate,
+ status: HackerAttendee.status,
+ })
+ .from(Hackathon)
+ .innerJoin(HackerAttendee, eq(Hackathon.id, HackerAttendee.hackathonId))
+ .innerJoin(Hacker, eq(HackerAttendee.hackerId, Hacker.id))
+ .where(
+ and(
+ eq(Hacker.userId, ctx.session.user.id),
+ lt(Hackathon.endDate, new Date()),
+ ),
+ )
+ .orderBy(desc(Hackathon.startDate));
+
+ return {
+ confirmedCount: Number(confirmedCount),
+ hackathon,
+ participant,
+ pastHackathons,
+ };
+ }),
+
+ getApplicationContext: protectedProcedure
+ .input(hackathonInput)
+ .query(async ({ input, ctx }) => {
+ const hackathon = await requireHackathon(input.hackathonName);
+ const [existingApplication, previousHacker, memberProfile] =
+ await Promise.all([
+ getParticipant(hackathon.id, ctx.session.user.id),
+ db.query.Hacker.findFirst({
+ orderBy: (table, { desc }) => [
+ desc(table.dateCreated),
+ desc(table.timeCreated),
+ ],
+ where: (table, { eq }) => eq(table.userId, ctx.session.user.id),
+ }),
+ db.query.Member.findFirst({
+ where: (table, { eq }) => eq(table.userId, ctx.session.user.id),
+ }),
+ ]);
+
+ return {
+ existingApplication,
+ hackathon,
+ memberProfile: memberProfile ?? null,
+ previousHacker: previousHacker ?? null,
+ };
+ }),
+
+ submitApplication: hackerMutationRouter.createHacker,
+
+ updateProfile: protectedProcedure
+ .input(
+ hackerApplicationWireSchema.extend({
+ hackathonName: z.string().min(1),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const { hackathonName, dob, phoneNumber, ...profile } = input;
+ const hackathon = await requireHackathon(hackathonName);
+ const participant = await getParticipant(
+ hackathon.id,
+ ctx.session.user.id,
+ );
+
+ if (!participant) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Application not found.",
+ });
+ }
+
+ const today = new Date();
+ const birthDate = new Date(dob);
+ const birthdayPassed =
+ birthDate.getMonth() < today.getMonth() ||
+ (birthDate.getMonth() === today.getMonth() &&
+ birthDate.getDate() <= today.getDate());
+ const age =
+ today.getFullYear() -
+ birthDate.getFullYear() -
+ (birthdayPassed ? 0 : 1);
+ const normalizedResume =
+ profile.resumeUrl === participant.resumeUrl
+ ? participant.resumeUrl
+ : await normalizeResumeObjectNameForPersistence(
+ profile.resumeUrl,
+ ctx.session.user.id,
+ );
+
+ await db
+ .update(Hacker)
+ .set({
+ ...profile,
+ age,
+ dob,
+ phoneNumber: phoneNumber === "" ? "" : phoneNumber,
+ resumeUrl: normalizedResume,
+ })
+ .where(
+ and(
+ eq(Hacker.id, participant.id),
+ eq(Hacker.userId, ctx.session.user.id),
+ ),
+ );
+
+ await removeUnreferencedResumeObjectsForUser(ctx.session.user.id);
+ return getParticipant(hackathon.id, ctx.session.user.id);
+ }),
+
+ uploadResume: protectedProcedure
+ .input(
+ hackathonInput.extend({
+ fileName: z.string().min(1),
+ fileContent: z.string().max(MAX_RESUME_DATA_URL_LENGTH),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const hackathon = await requireHackathon(input.hackathonName);
+ const now = new Date();
+ if (now < hackathon.applicationOpen || now > hackathon.endDate) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Resume uploads are not available for this hackathon.",
+ });
+ }
+
+ const fileBuffer = decodeAndValidateResumeDataUrl(input.fileContent);
+ const filePath = createResumeObjectName(ctx.session.user.id);
+ await ensureResumeBucketExists();
+ await resumeStorageClient.putObject(
+ RESUME_BUCKET_NAME,
+ filePath,
+ fileBuffer,
+ fileBuffer.length,
+ { "Content-Type": "application/pdf" },
+ );
+ return filePath;
+ }),
+
+ getResume: protectedProcedure
+ .input(hackathonInput)
+ .query(async ({ input, ctx }) => {
+ const hackathon = await requireHackathon(input.hackathonName);
+ const participant = await getParticipant(
+ hackathon.id,
+ ctx.session.user.id,
+ );
+ if (!participant) return { url: null };
+
+ const filename = normalizeOwnedResumeObjectName(
+ participant.resumeUrl,
+ ctx.session.user.id,
+ );
+ if (!filename) return { url: null };
+
+ try {
+ const url = await resumeStorageClient.presignedUrl(
+ "GET",
+ RESUME_BUCKET_NAME,
+ filename,
+ 60 * 60,
+ );
+ return { url };
+ } catch {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Could not generate resume URL.",
+ });
+ }
+ }),
+
+ confirmAttendance: protectedProcedure
+ .input(hackathonInput)
+ .mutation(async ({ input, ctx }) => {
+ const result = await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select ${Hackathon.id} from ${Hackathon} where ${Hackathon.name} = ${input.hackathonName} for update`,
+ );
+ const hackathon = await tx.query.Hackathon.findFirst({
+ where: (table, { eq }) => eq(table.name, input.hackathonName),
+ });
+ if (!hackathon) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found.",
+ });
+ }
+
+ const [participant] = await tx
+ .select({
+ id: Hacker.id,
+ firstName: Hacker.firstName,
+ email: Hacker.email,
+ status: HackerAttendee.status,
+ })
+ .from(Hacker)
+ .innerJoin(HackerAttendee, eq(HackerAttendee.hackerId, Hacker.id))
+ .where(
+ and(
+ eq(Hacker.userId, ctx.session.user.id),
+ eq(HackerAttendee.hackathonId, hackathon.id),
+ ),
+ )
+ .limit(1);
+
+ if (!participant) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Application not found.",
+ });
+ }
+ if (participant.status !== "accepted") {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Only accepted hackers can confirm attendance.",
+ });
+ }
+ if (new Date() > hackathon.confirmationDeadline) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "The confirmation deadline has passed.",
+ });
+ }
+
+ if (hackathon.confirmationCapacity != null) {
+ const [{ total = 0 } = {}] = await tx
+ .select({ total: count() })
+ .from(HackerAttendee)
+ .where(
+ and(
+ eq(HackerAttendee.hackathonId, hackathon.id),
+ inArray(HackerAttendee.status, ["confirmed", "checkedin"]),
+ ),
+ );
+ if (Number(total) >= hackathon.confirmationCapacity) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "This hackathon has reached confirmation capacity.",
+ });
+ }
+ }
+
+ await tx
+ .update(HackerAttendee)
+ .set({ status: "confirmed", timeConfirmed: new Date() })
+ .where(
+ and(
+ eq(HackerAttendee.hackerId, participant.id),
+ eq(HackerAttendee.hackathonId, hackathon.id),
+ ),
+ );
+
+ return { hackathon, participant };
+ });
+
+ try {
+ await sendHackathonEmail({
+ from: "donotreply@knighthacks.org",
+ hackathon: {
+ applicationBackgroundKey: result.hackathon.applicationBackgroundKey,
+ displayName: result.hackathon.displayName,
+ emailTemplateKey: result.hackathon.emailTemplateEnabled
+ ? result.hackathon.emailTemplateKey
+ : null,
+ routeName: result.hackathon.name,
+ theme: result.hackathon.theme,
+ },
+ kind: "Confirmation",
+ recipient: {
+ name: result.participant.firstName,
+ to: result.participant.email,
+ },
+ });
+ } catch (error) {
+ logger.warn("Failed to send hackathon confirmation email:", error);
+ }
+
+ try {
+ await discord.log({
+ title: `Hacker Confirmed for ${result.hackathon.displayName}`,
+ message: `${result.participant.firstName} has confirmed attendance.`,
+ color: "success_green",
+ userId: ctx.session.user.discordUserId,
+ });
+ } catch (error) {
+ logger.warn("Failed to log hacker confirmation to Discord:", error);
+ }
+
+ return { status: "confirmed" as const };
+ }),
+
+ withdrawAttendance: protectedProcedure
+ .input(hackathonInput)
+ .mutation(async ({ input, ctx }) => {
+ const hackathon = await requireHackathon(input.hackathonName);
+ const participant = await getParticipant(
+ hackathon.id,
+ ctx.session.user.id,
+ );
+ if (!participant) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Application not found.",
+ });
+ }
+ if (participant.status !== "confirmed") {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Only confirmed hackers can withdraw attendance.",
+ });
+ }
+
+ const [withdrawnParticipant] = await db
+ .update(HackerAttendee)
+ .set({ status: "withdrawn", timeConfirmed: null })
+ .where(
+ and(
+ eq(HackerAttendee.hackerId, participant.id),
+ eq(HackerAttendee.hackathonId, hackathon.id),
+ eq(HackerAttendee.status, "confirmed"),
+ ),
+ )
+ .returning({ id: HackerAttendee.id });
+
+ if (!withdrawnParticipant) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Only confirmed hackers can withdraw attendance.",
+ });
+ }
+
+ return { status: "withdrawn" as const };
+ }),
+
+ getQRCode: protectedProcedure
+ .input(hackathonInput)
+ .query(async ({ input, ctx }) => {
+ const hackathon = await requireHackathon(input.hackathonName);
+ const participant = await getParticipant(
+ hackathon.id,
+ ctx.session.user.id,
+ );
+ if (!participant) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Application not found.",
+ });
+ }
+ if (
+ participant.status !== "confirmed" &&
+ participant.status !== "checkedin"
+ ) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "The hacker QR code is available after confirmation.",
+ });
+ }
+ return {
+ qrCodeUrl: await QRCode.toDataURL(
+ getUserQRCodePayload(ctx.session.user.id),
+ { type: "image/png" },
+ ),
+ };
+ }),
+
+ getSchedule: protectedProcedure
+ .input(hackathonInput)
+ .query(async ({ input, ctx }) => {
+ const hackathon = await requireHackathon(input.hackathonName);
+ const participant = await getParticipant(
+ hackathon.id,
+ ctx.session.user.id,
+ );
+ if (participant?.status !== "checkedin") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "The event schedule is available after check-in.",
+ });
+ }
+
+ return db
+ .select({
+ id: Event.id,
+ name: Event.name,
+ description: Event.description,
+ tag: Event.tag,
+ location: Event.location,
+ points: Event.points,
+ startDateTime: Event.start_datetime,
+ endDateTime: Event.end_datetime,
+ })
+ .from(Event)
+ .where(eq(Event.hackathonId, hackathon.id))
+ .orderBy(asc(Event.start_datetime));
+ }),
+
+ reportIssue: protectedProcedure
+ .input(
+ hackathonInput.extend({
+ description: z.string().trim().min(1).max(2_000),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const hackathon = await requireHackathon(input.hackathonName);
+ const participant = await getParticipant(
+ hackathon.id,
+ ctx.session.user.id,
+ );
+ if (participant?.status !== "checkedin") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Issue reporting is available after check-in.",
+ });
+ }
+
+ await discord.log({
+ message: `<@&${DISCORD.OFFICER_ROLE}> [${hackathon.displayName}] ${input.description}`,
+ title: "Hackathon Issue",
+ color: "uhoh_red",
+ userId: ctx.session.user.discordUserId,
+ });
+ return { submitted: true };
+ }),
+} satisfies ParticipantPortalContract;
+
+type ParticipantRootTypes =
+ typeof createTRPCRouter extends TRPCRouterBuilder
+ ? TRoot
+ : never;
+
+export const participantPortalRouter: TRPCBuiltRouter<
+ ParticipantRootTypes,
+ ParticipantPortalRouterRecord
+> = createTRPCRouter(
+ participantPortalRouterImplementation,
+) as unknown as TRPCBuiltRouter<
+ ParticipantRootTypes,
+ ParticipantPortalRouterRecord
+>;
diff --git a/packages/api/src/storage-env.ts b/packages/api/src/storage-env.ts
new file mode 100644
index 000000000..6b54ef6c5
--- /dev/null
+++ b/packages/api/src/storage-env.ts
@@ -0,0 +1,14 @@
+/* eslint-disable no-restricted-properties */
+import { createEnv } from "@t3-oss/env-core";
+import { z } from "zod";
+
+export const storageEnv = createEnv({
+ server: {
+ MINIO_ENDPOINT: z.string(),
+ MINIO_ACCESS_KEY: z.string(),
+ MINIO_SECRET_KEY: z.string(),
+ },
+ runtimeEnv: process.env,
+ skipValidation:
+ !!process.env.CI || process.env.npm_lifecycle_event === "lint",
+});
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index 904280825..1ef9dc6a2 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -13,7 +13,6 @@ import superjson from "superjson";
import { ZodError } from "zod";
import type { Session } from "@forge/auth/server";
-import { validateToken } from "@forge/auth/server";
import { PERMISSIONS } from "@forge/consts";
import { eq, sql } from "@forge/db";
import { db } from "@forge/db/client";
@@ -33,14 +32,19 @@ import * as permissionsServer from "@forge/utils/permissions.server";
*
* @see https://trpc.io/docs/server/context
*/
-export const createTRPCContext = async (opts: {
+export const createTRPCContext = (opts: {
headers: Headers;
session: Session | null;
}) => {
const authToken = opts.headers.get("Authorization") ?? null;
- const session = await validateToken();
+ const session = opts.session;
const source = opts.headers.get("x-trpc-source") ?? "unknown";
- console.log(">>> tRPC Request from", source, "by", session?.user);
+ console.log(
+ ">>> tRPC Request from",
+ source,
+ "by",
+ session?.user ? "authenticated user" : "anonymous",
+ );
return {
session,
diff --git a/packages/auth/package.json b/packages/auth/package.json
index 1ed53276b..164f7f42c 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -9,6 +9,10 @@
"default": "./src/index.ts"
},
"./server": "./src/index.rsc.ts",
+ "./factory": "./src/factory.ts",
+ "./server-factory": "./src/server-factory.ts",
+ "./client-factory": "./src/client-factory.ts",
+ "./callback-url": "./src/callback-url.ts",
"./env": "./src/env.ts"
},
"license": "MIT",
@@ -23,6 +27,7 @@
"@auth/drizzle-adapter": "^1.11.1",
"@better-auth/client": "0.0.2-alpha.3",
"@forge/db": "workspace:*",
+ "@forge/utils": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.10",
"better-auth": "^1.4.19",
"next": "^16.2.7",
diff --git a/packages/auth/src/callback-url.ts b/packages/auth/src/callback-url.ts
index 2b2a596d6..a45868f80 100644
--- a/packages/auth/src/callback-url.ts
+++ b/packages/auth/src/callback-url.ts
@@ -1,24 +1,24 @@
-import { env } from "./env";
-
-const HOME_PATH = "/";
-
-export function sanitizeCallbackURL(callbackURL?: string | null): string {
- if (!callbackURL) return HOME_PATH;
+export function sanitizeCallbackURL(
+ callbackURL: string | null | undefined,
+ appBaseURL: string,
+ defaultPath = "/",
+): string {
+ if (!callbackURL) return defaultPath;
try {
- const appURL = new URL(env.NEXT_PUBLIC_BLADE_URL);
+ const appURL = new URL(appBaseURL);
const resolved = new URL(callbackURL, appURL);
if (resolved.origin !== appURL.origin) {
- return HOME_PATH;
+ return defaultPath;
}
if (!resolved.pathname.startsWith("/")) {
- return HOME_PATH;
+ return defaultPath;
}
return `${resolved.pathname}${resolved.search}`;
} catch {
- return HOME_PATH;
+ return defaultPath;
}
}
diff --git a/packages/auth/src/client-factory.ts b/packages/auth/src/client-factory.ts
new file mode 100644
index 000000000..47dd75247
--- /dev/null
+++ b/packages/auth/src/client-factory.ts
@@ -0,0 +1,69 @@
+import { createAuthClient } from "better-auth/react";
+
+import { sanitizeCallbackURL } from "./callback-url";
+
+export interface ForgeAuthClientOptions {
+ baseURL?: string;
+ defaultRedirectPath?: string;
+}
+
+export function createForgeAuthClient({
+ baseURL,
+ defaultRedirectPath = "/",
+}: ForgeAuthClientOptions = {}) {
+ const authClient = createAuthClient({
+ ...(baseURL ? { baseURL } : {}),
+ plugins: [
+ {
+ id: "discord-user",
+ $InferServerPlugin: {} as {
+ id: string;
+ schema: {
+ user: {
+ fields: {
+ discordUserId: { type: "string" };
+ };
+ };
+ };
+ },
+ },
+ ],
+ });
+
+ const resolveBaseURL = () =>
+ baseURL ??
+ (typeof window === "undefined"
+ ? "http://localhost"
+ : window.location.origin);
+
+ const auth = async () => {
+ const session = await authClient.getSession();
+ return session.data ?? null;
+ };
+
+ const signIn = async (
+ provider: string,
+ { redirectTo }: { redirectTo: string },
+ ) => {
+ const callbackURL = sanitizeCallbackURL(
+ redirectTo,
+ resolveBaseURL(),
+ defaultRedirectPath,
+ );
+ const errorURL = new URL(callbackURL, resolveBaseURL());
+ errorURL.searchParams.set("authError", "oauth");
+
+ await authClient.signIn.social({
+ provider,
+ callbackURL,
+ errorCallbackURL: `${errorURL.pathname}${errorURL.search}`,
+ });
+ };
+
+ const signOut = async ({ redirectTo = "/" } = {}) => {
+ await authClient.signOut();
+ if (typeof window !== "undefined") window.location.assign(redirectTo);
+ };
+
+ return { auth, authClient, signIn, signOut };
+}
diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts
deleted file mode 100644
index 41ce503d7..000000000
--- a/packages/auth/src/config.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { randomUUID } from "crypto";
-import { headers } from "next/headers";
-import { betterAuth } from "better-auth";
-import { drizzleAdapter } from "better-auth/adapters/drizzle";
-import { eq } from "drizzle-orm";
-
-import { db } from "@forge/db/client";
-import { Account, Session, User, Verifications } from "@forge/db/schemas/auth";
-
-import * as discord from "../../utils/src/discord";
-import { env } from "./env";
-
-export const isSecureContext = env.NODE_ENV !== "development";
-
-export const auth = betterAuth({
- database: drizzleAdapter(db, {
- provider: "pg",
- schema: {
- user: User,
- account: Account,
- session: Session,
- verification: Verifications,
- },
- }),
- secret: env.BETTER_AUTH_SECRET,
-
- session: {
- fields: {
- expiresAt: "expires",
- token: "sessionToken",
- },
- },
-
- account: {
- fields: {
- accountId: "providerAccountId",
- providerId: "provider",
- refreshToken: "refresh_token",
- accessToken: "access_token",
- accessTokenExpiresAt: "expires_at",
- idToken: "id_token",
- },
- },
-
- socialProviders: {
- discord: {
- clientId: env.DISCORD_CLIENT_ID,
- clientSecret: env.DISCORD_CLIENT_SECRET,
- scope: ["guilds.join"],
- mapProfileToUser: (profile) => {
- return {
- id: randomUUID(),
- name: profile.username,
- email: profile.id + "@blade.org",
- image: profile.avatar ?? "",
- emailVerified: profile.verified || false,
- discordUserId: profile.id,
- };
- },
- },
- },
-
- databaseHooks: {
- session: {
- create: {
- after: async (session) => {
- try {
- const user = await db.query.User.findFirst({
- where: eq(User.id, session.userId),
- });
-
- const discordUserId = user?.discordUserId;
- if (!discordUserId) return;
-
- await discord.handleDiscordOAuthCallback(discordUserId);
- } catch (error) {
- // TODO: remove this eslint-disable
- // eslint-disable-next-line no-console
- console.error("Error in Discord auto join hook:", error);
- }
- },
- },
- },
- },
-
- baseURL:
- env.NODE_ENV === "production" ? env.BLADE_URL : "http://localhost:3000",
- user: {
- additionalFields: {
- discordUserId: {
- type: "string",
- required: true,
- },
- },
- },
-
- advanced: {
- useSecureCookies: isSecureContext,
- database: {
- generateId: () => randomUUID(),
- },
- },
-});
-
-export const validateToken = async () => {
- const headersList = await headers();
- const session = await auth.api.getSession({ headers: headersList });
-
- if (!session) return null;
- return {
- user: session.user,
- session: session.session,
- expires: session.session.expiresAt.toISOString(),
- };
-};
-
-export const invalidateSessionToken = async (token: string) => {
- const sessionToken = token.replace(/^Bearer\s+/i, "");
- await auth.api.revokeSession({
- body: { token: sessionToken },
- headers: new Headers({ Authorization: `Bearer ${sessionToken}` }),
- });
-};
diff --git a/packages/auth/src/env.ts b/packages/auth/src/env.ts
index 8568750ed..23d86d065 100644
--- a/packages/auth/src/env.ts
+++ b/packages/auth/src/env.ts
@@ -1,24 +1,13 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
+import { authSharedEnv } from "./shared-env";
+
export const env = createEnv({
- server: {
- DISCORD_CLIENT_ID: z.string().min(1),
- DISCORD_CLIENT_SECRET: z.string().min(1),
- BETTER_AUTH_SECRET:
- process.env.NODE_ENV === "production"
- ? z.string().min(1)
- : z.string().min(1).optional(),
- BLADE_URL: z.string(),
- },
- client: {
- NEXT_PUBLIC_BLADE_URL: z.string().url(),
- },
- shared: {
- NODE_ENV: z.enum(["development", "production"]).optional(),
- },
+ extends: [authSharedEnv],
+ server: { BLADE_URL: z.string().url() },
+ client: { NEXT_PUBLIC_BLADE_URL: z.string().url() },
experimental__runtimeEnv: {
- NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BLADE_URL:
process.env.NEXT_PUBLIC_BLADE_URL || "http://localhost:3000",
},
diff --git a/packages/auth/src/factory.ts b/packages/auth/src/factory.ts
new file mode 100644
index 000000000..3bba1b6c9
--- /dev/null
+++ b/packages/auth/src/factory.ts
@@ -0,0 +1,113 @@
+import { randomUUID } from "crypto";
+import { betterAuth } from "better-auth";
+import { drizzleAdapter } from "better-auth/adapters/drizzle";
+import { eq } from "drizzle-orm";
+
+import { db } from "@forge/db/client";
+import { Account, Session, User, Verifications } from "@forge/db/schemas/auth";
+import * as discord from "@forge/utils/discord";
+
+import { authSharedEnv } from "./shared-env";
+
+export interface ForgeAuthOptions {
+ baseURL: string;
+}
+
+export const isSecureContext = authSharedEnv.NODE_ENV !== "development";
+
+export function createForgeAuth({ baseURL }: ForgeAuthOptions) {
+ return betterAuth({
+ database: drizzleAdapter(db, {
+ provider: "pg",
+ schema: {
+ user: User,
+ account: Account,
+ session: Session,
+ verification: Verifications,
+ },
+ }),
+ secret: authSharedEnv.BETTER_AUTH_SECRET,
+
+ session: {
+ fields: {
+ expiresAt: "expires",
+ token: "sessionToken",
+ },
+ },
+
+ account: {
+ fields: {
+ accountId: "providerAccountId",
+ providerId: "provider",
+ refreshToken: "refresh_token",
+ accessToken: "access_token",
+ accessTokenExpiresAt: "expires_at",
+ idToken: "id_token",
+ },
+ },
+
+ socialProviders: {
+ discord: {
+ clientId: authSharedEnv.DISCORD_CLIENT_ID,
+ clientSecret: authSharedEnv.DISCORD_CLIENT_SECRET,
+ scope: ["guilds.join"],
+ mapProfileToUser: (profile) => ({
+ id: randomUUID(),
+ name: profile.username,
+ email: profile.id + "@blade.org",
+ image: profile.avatar ?? "",
+ emailVerified: profile.verified || false,
+ discordUserId: profile.id,
+ }),
+ },
+ },
+
+ databaseHooks: {
+ session: {
+ create: {
+ after: async (session) => {
+ try {
+ const user = await db.query.User.findFirst({
+ where: eq(User.id, session.userId),
+ });
+
+ if (user?.discordUserId) {
+ await discord.handleDiscordOAuthCallback(user.discordUserId);
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Error in Discord auto join hook:", error);
+ }
+ },
+ },
+ },
+ },
+
+ baseURL,
+ user: {
+ additionalFields: {
+ discordUserId: {
+ type: "string",
+ required: true,
+ },
+ },
+ },
+
+ advanced: {
+ useSecureCookies: isSecureContext,
+ database: {
+ generateId: () => randomUUID(),
+ },
+ },
+ });
+}
+
+export type ForgeAuthInstance = ReturnType;
+export type ForgeAuthSession = Omit<
+ ForgeAuthInstance["$Infer"]["Session"],
+ "user"
+> & {
+ user: ForgeAuthInstance["$Infer"]["Session"]["user"] & {
+ discordUserId: string;
+ };
+};
diff --git a/packages/auth/src/index.rsc.ts b/packages/auth/src/index.rsc.ts
index 172e60271..f52e746af 100644
--- a/packages/auth/src/index.rsc.ts
+++ b/packages/auth/src/index.rsc.ts
@@ -1,90 +1,20 @@
-import { headers } from "next/headers";
-import { redirect } from "next/navigation";
-import { NextResponse } from "next/server";
-import { toNextJsHandler } from "better-auth/next-js";
+import type { ForgeAuthSession } from "./factory";
+import { env } from "./env";
+import { createForgeAuthServer } from "./server-factory";
+
+const bladeAuth = createForgeAuthServer({
+ baseURL:
+ env.NODE_ENV === "production" ? env.BLADE_URL : "http://localhost:3000",
+});
-import { sanitizeCallbackURL } from "./callback-url";
-import {
- auth as betterAuthInstance,
+export const {
+ auth,
+ handlers,
invalidateSessionToken,
isSecureContext,
+ signIn,
+ signInRoute,
validateToken,
-} from "./config";
-import { env } from "./env";
-
-export { validateToken, invalidateSessionToken, isSecureContext };
-
-export type Session = Omit & {
- user: (typeof betterAuthInstance.$Infer.Session)["user"] & {
- discordUserId: string;
- };
-};
-
-export const handlers = toNextJsHandler(betterAuthInstance);
-
-export const auth = async () => {
- try {
- const headersList = await headers();
- const sess = await betterAuthInstance.api.getSession({
- headers: headersList,
- });
- return sess;
- } catch {
- return null;
- }
-};
-
-export async function signInRoute(req: Request) {
- const url = new URL(req.url);
- const provider = url.searchParams.get("provider");
- const callbackURL = sanitizeCallbackURL(url.searchParams.get("callbackURL"));
-
- if (!provider) {
- return NextResponse.json(
- { error: "Missing provider parameter" },
- { status: 400 },
- );
- }
-
- if (provider !== "discord") {
- return NextResponse.json(
- { error: "Unsupported provider parameter" },
- { status: 400 },
- );
- }
-
- // Call Better Auth API
- const res = await betterAuthInstance.api.signInSocial({
- body: {
- provider,
- callbackURL,
- },
- asResponse: true,
- });
-
- const data = (await res.json()) as { url?: string };
- if (!data.url) {
- return NextResponse.json(
- { error: "Failed to get redirect URL from Better Auth" },
- { status: 500 },
- );
- }
-
- // Forward cookies and redirect
- const response = NextResponse.redirect(data.url);
- const setCookie = res.headers.get("set-cookie");
- if (setCookie) response.headers.set("set-cookie", setCookie);
-
- return response;
-}
+} = bladeAuth;
-export const signIn = (
- provider: string,
- { redirectTo }: { redirectTo: string },
-) => {
- redirect(
- `${env.NEXT_PUBLIC_BLADE_URL}/api/auth/signin?provider=${encodeURIComponent(
- provider,
- )}&callbackURL=${encodeURIComponent(sanitizeCallbackURL(redirectTo))}`,
- );
-};
+export type Session = ForgeAuthSession;
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
index f0640b4fe..e01cd91a8 100644
--- a/packages/auth/src/index.ts
+++ b/packages/auth/src/index.ts
@@ -1,44 +1,8 @@
-import { createAuthClient } from "better-auth/react";
-
-import { sanitizeCallbackURL } from "./callback-url";
+import { createForgeAuthClient } from "./client-factory";
import { env } from "./env";
-export const authClient = createAuthClient({
+const bladeAuthClient = createForgeAuthClient({
baseURL: env.NEXT_PUBLIC_BLADE_URL,
- plugins: [
- {
- id: "discord-user",
- $InferServerPlugin: {} as {
- id: string;
- schema: {
- user: {
- fields: {
- discordUserId: { type: "string" };
- };
- };
- };
- },
- },
- ],
});
-export const auth = async () => {
- const sess = await authClient.getSession();
- if (!sess.data) return null;
- return sess.data;
-};
-
-export const signIn = async (
- provider: string,
- { redirectTo }: { redirectTo: string },
-) => {
- await authClient.signIn.social({
- provider: provider,
- callbackURL: sanitizeCallbackURL(redirectTo),
- });
-};
-
-export const signOut = async () => {
- await authClient.signOut();
- if (typeof window !== "undefined") window.location.reload();
-};
+export const { auth, authClient, signIn, signOut } = bladeAuthClient;
diff --git a/packages/auth/src/server-factory.ts b/packages/auth/src/server-factory.ts
new file mode 100644
index 000000000..dca865bfd
--- /dev/null
+++ b/packages/auth/src/server-factory.ts
@@ -0,0 +1,115 @@
+import { headers } from "next/headers";
+import { redirect } from "next/navigation";
+import { NextResponse } from "next/server";
+import { toNextJsHandler } from "better-auth/next-js";
+
+import type { ForgeAuthSession } from "./factory";
+import { sanitizeCallbackURL } from "./callback-url";
+import { createForgeAuth, isSecureContext } from "./factory";
+
+export interface ForgeAuthServerOptions {
+ baseURL: string;
+ defaultRedirectPath?: string;
+}
+
+export function createForgeAuthServer({
+ baseURL,
+ defaultRedirectPath = "/",
+}: ForgeAuthServerOptions) {
+ const authInstance = createForgeAuth({ baseURL });
+ const handlers = toNextJsHandler(authInstance);
+
+ const auth = async () => {
+ try {
+ return await authInstance.api.getSession({ headers: await headers() });
+ } catch {
+ return null;
+ }
+ };
+
+ const validateToken = async (): Promise => {
+ const session = await auth();
+ if (!session) return null;
+
+ return {
+ user: session.user,
+ session: session.session,
+ expires: session.session.expiresAt.toISOString(),
+ } as ForgeAuthSession;
+ };
+
+ const invalidateSessionToken = async (token: string) => {
+ const sessionToken = token.replace(/^Bearer\s+/i, "");
+ await authInstance.api.revokeSession({
+ body: { token: sessionToken },
+ headers: new Headers({ Authorization: `Bearer ${sessionToken}` }),
+ });
+ };
+
+ const signInRoute = async (req: Request) => {
+ const url = new URL(req.url);
+ const provider = url.searchParams.get("provider");
+ const callbackURL = sanitizeCallbackURL(
+ url.searchParams.get("callbackURL"),
+ baseURL,
+ defaultRedirectPath,
+ );
+ const errorURL = new URL(callbackURL, baseURL);
+ errorURL.searchParams.set("authError", "oauth");
+ const errorCallbackURL = `${errorURL.pathname}${errorURL.search}`;
+
+ if (!provider) {
+ return NextResponse.json(
+ { error: "Missing provider parameter" },
+ { status: 400 },
+ );
+ }
+
+ if (provider !== "discord") {
+ return NextResponse.json(
+ { error: "Unsupported provider parameter" },
+ { status: 400 },
+ );
+ }
+
+ const response = await authInstance.api.signInSocial({
+ body: { provider, callbackURL, errorCallbackURL },
+ asResponse: true,
+ });
+ const data = (await response.json()) as { url?: string };
+
+ if (!data.url) {
+ return NextResponse.json(
+ { error: "Failed to get redirect URL from Better Auth" },
+ { status: 500 },
+ );
+ }
+
+ const redirectResponse = NextResponse.redirect(data.url);
+ const setCookie = response.headers.get("set-cookie");
+ if (setCookie) redirectResponse.headers.set("set-cookie", setCookie);
+ return redirectResponse;
+ };
+
+ const signIn = (provider: string, { redirectTo }: { redirectTo: string }) => {
+ const callbackURL = sanitizeCallbackURL(
+ redirectTo,
+ baseURL,
+ defaultRedirectPath,
+ );
+ redirect(
+ `${baseURL}/api/auth/signin?provider=${encodeURIComponent(provider)}&callbackURL=${encodeURIComponent(callbackURL)}`,
+ );
+ };
+
+ return {
+ auth,
+ authInstance,
+ handlers,
+ invalidateSessionToken,
+ isSecureContext,
+ signIn,
+ signInRoute,
+ validateToken,
+ };
+}
diff --git a/packages/auth/src/shared-env.ts b/packages/auth/src/shared-env.ts
new file mode 100644
index 000000000..ca592a88b
--- /dev/null
+++ b/packages/auth/src/shared-env.ts
@@ -0,0 +1,20 @@
+/* eslint-disable no-restricted-properties */
+import { createEnv } from "@t3-oss/env-nextjs";
+import { z } from "zod";
+
+export const authSharedEnv = createEnv({
+ server: {
+ DISCORD_CLIENT_ID: z.string().min(1),
+ DISCORD_CLIENT_SECRET: z.string().min(1),
+ BETTER_AUTH_SECRET:
+ process.env.NODE_ENV === "production"
+ ? z.string().min(1)
+ : z.string().min(1).optional(),
+ },
+ shared: {
+ NODE_ENV: z.enum(["development", "production"]).optional(),
+ },
+ experimental__runtimeEnv: { NODE_ENV: process.env.NODE_ENV },
+ skipValidation:
+ !!process.env.CI || process.env.npm_lifecycle_event === "lint",
+});
diff --git a/packages/db/drizzle/0010_wooden_supreme_intelligence.sql b/packages/db/drizzle/0010_wooden_supreme_intelligence.sql
new file mode 100644
index 000000000..63dd35e95
--- /dev/null
+++ b/packages/db/drizzle/0010_wooden_supreme_intelligence.sql
@@ -0,0 +1,7 @@
+ALTER TABLE "knight_hacks_hackathon" ADD COLUMN "portal_base_url" varchar(512);--> statement-breakpoint
+ALTER TABLE "knight_hacks_hackathon" ADD COLUMN "confirmation_capacity" integer;--> statement-breakpoint
+UPDATE "knight_hacks_hackathon"
+SET
+ "portal_base_url" = 'https://bloom.knighthacks.org',
+ "confirmation_capacity" = 1100
+WHERE "name" = 'bloomknights';
diff --git a/packages/db/drizzle/meta/0010_snapshot.json b/packages/db/drizzle/meta/0010_snapshot.json
new file mode 100644
index 000000000..ec4aef66f
--- /dev/null
+++ b/packages/db/drizzle/meta/0010_snapshot.json
@@ -0,0 +1,2786 @@
+{
+ "id": "69a4131f-f9ee-46a5-9dc0-82cadcb6a2ce",
+ "prevId": "a91721d0-54e4-4a90-9b5a-ffdd13a6e889",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.auth_account": {
+ "name": "auth_account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_account_user_id_auth_user_id_fk": {
+ "name": "auth_account_user_id_auth_user_id_fk",
+ "tableFrom": "auth_account",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "auth_account_provider_provider_account_id_pk": {
+ "name": "auth_account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_judge_session": {
+ "name": "auth_judge_session",
+ "schema": "",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(255)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "room_name": {
+ "name": "room_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_permissions": {
+ "name": "auth_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_permissions_role_id_auth_roles_id_fk": {
+ "name": "auth_permissions_role_id_auth_roles_id_fk",
+ "tableFrom": "auth_permissions",
+ "tableTo": "auth_roles",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "auth_permissions_user_id_auth_user_id_fk": {
+ "name": "auth_permissions_user_id_auth_user_id_fk",
+ "tableFrom": "auth_permissions",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_roles": {
+ "name": "auth_roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "discord_role_id": {
+ "name": "discord_role_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_reminder_channel": {
+ "name": "issue_reminder_channel",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'1459204271655489567'"
+ },
+ "team_hexcode_color": {
+ "name": "team_hexcode_color",
+ "type": "varchar(7)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "auth_roles_discordRoleId_unique": {
+ "name": "auth_roles_discordRoleId_unique",
+ "nullsNotDistinct": false,
+ "columns": ["discord_role_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_session": {
+ "name": "auth_session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_session_user_id_auth_user_id_fk": {
+ "name": "auth_session_user_id_auth_user_id_fk",
+ "tableFrom": "auth_session",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_user": {
+ "name": "auth_user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "discord_user_id": {
+ "name": "discord_user_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_verification": {
+ "name": "auth_verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_challenges": {
+ "name": "knight_hacks_challenges",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hackathon_id": {
+ "name": "hackathon_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sponsor": {
+ "name": "sponsor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_challenges_hackathon_id_knight_hacks_hackathon_id_fk": {
+ "name": "knight_hacks_challenges_hackathon_id_knight_hacks_hackathon_id_fk",
+ "tableFrom": "knight_hacks_challenges",
+ "tableTo": "knight_hacks_hackathon",
+ "columnsFrom": ["hackathon_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "knight_hacks_challenges_title_hackathonId_unique": {
+ "name": "knight_hacks_challenges_title_hackathonId_unique",
+ "nullsNotDistinct": false,
+ "columns": ["title", "hackathon_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_dues_payment": {
+ "name": "knight_hacks_dues_payment",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "member_id": {
+ "name": "member_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount": {
+ "name": "amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payment_date": {
+ "name": "payment_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "year": {
+ "name": "year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_dues_payment_member_id_knight_hacks_member_id_fk": {
+ "name": "knight_hacks_dues_payment_member_id_knight_hacks_member_id_fk",
+ "tableFrom": "knight_hacks_dues_payment",
+ "tableTo": "knight_hacks_member",
+ "columnsFrom": ["member_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "knight_hacks_dues_payment_memberId_year_unique": {
+ "name": "knight_hacks_dues_payment_memberId_year_unique",
+ "nullsNotDistinct": false,
+ "columns": ["member_id", "year"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_event": {
+ "name": "knight_hacks_event",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "discord_id": {
+ "name": "discord_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "google_id": {
+ "name": "google_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag": {
+ "name": "tag",
+ "type": "event_tag",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_datetime": {
+ "name": "start_datetime",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_datetime": {
+ "name": "end_datetime",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dues_paying": {
+ "name": "dues_paying",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_operations_calendar": {
+ "name": "is_operations_calendar",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "roles": {
+ "name": "roles",
+ "type": "varchar(255)[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "points": {
+ "name": "points",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hackathon_id": {
+ "name": "hackathon_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "discord_channel_id": {
+ "name": "discord_channel_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_event_hackathon_id_knight_hacks_hackathon_id_fk": {
+ "name": "knight_hacks_event_hackathon_id_knight_hacks_hackathon_id_fk",
+ "tableFrom": "knight_hacks_event",
+ "tableTo": "knight_hacks_hackathon",
+ "columnsFrom": ["hackathon_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_event_attendee": {
+ "name": "knight_hacks_event_attendee",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "member_id": {
+ "name": "member_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_event_attendee_member_id_knight_hacks_member_id_fk": {
+ "name": "knight_hacks_event_attendee_member_id_knight_hacks_member_id_fk",
+ "tableFrom": "knight_hacks_event_attendee",
+ "tableTo": "knight_hacks_member",
+ "columnsFrom": ["member_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_event_attendee_event_id_knight_hacks_event_id_fk": {
+ "name": "knight_hacks_event_attendee_event_id_knight_hacks_event_id_fk",
+ "tableFrom": "knight_hacks_event_attendee",
+ "tableTo": "knight_hacks_event",
+ "columnsFrom": ["event_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_event_feedback": {
+ "name": "knight_hacks_event_feedback",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "member_id": {
+ "name": "member_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "overall_event_rating": {
+ "name": "overall_event_rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fun_rating": {
+ "name": "fun_rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "learned_rating": {
+ "name": "learned_rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "heard_about_us": {
+ "name": "heard_about_us",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "additional_feedback": {
+ "name": "additional_feedback",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "similar_event": {
+ "name": "similar_event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_event_feedback_member_id_knight_hacks_member_id_fk": {
+ "name": "knight_hacks_event_feedback_member_id_knight_hacks_member_id_fk",
+ "tableFrom": "knight_hacks_event_feedback",
+ "tableTo": "knight_hacks_member",
+ "columnsFrom": ["member_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_event_feedback_event_id_knight_hacks_event_id_fk": {
+ "name": "knight_hacks_event_feedback_event_id_knight_hacks_event_id_fk",
+ "tableFrom": "knight_hacks_event_feedback",
+ "tableTo": "knight_hacks_event",
+ "columnsFrom": ["event_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_form_response": {
+ "name": "knight_hacks_form_response",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form": {
+ "name": "form",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "response_data": {
+ "name": "response_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "edited_at": {
+ "name": "edited_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_form_response_form_knight_hacks_form_schemas_id_fk": {
+ "name": "knight_hacks_form_response_form_knight_hacks_form_schemas_id_fk",
+ "tableFrom": "knight_hacks_form_response",
+ "tableTo": "knight_hacks_form_schemas",
+ "columnsFrom": ["form"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_form_response_user_id_auth_user_id_fk": {
+ "name": "knight_hacks_form_response_user_id_auth_user_id_fk",
+ "tableFrom": "knight_hacks_form_response",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_form_response_roles": {
+ "name": "knight_hacks_form_response_roles",
+ "schema": "",
+ "columns": {
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_form_response_roles_form_id_knight_hacks_form_schemas_id_fk": {
+ "name": "knight_hacks_form_response_roles_form_id_knight_hacks_form_schemas_id_fk",
+ "tableFrom": "knight_hacks_form_response_roles",
+ "tableTo": "knight_hacks_form_schemas",
+ "columnsFrom": ["form_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_form_response_roles_role_id_auth_roles_id_fk": {
+ "name": "knight_hacks_form_response_roles_role_id_auth_roles_id_fk",
+ "tableFrom": "knight_hacks_form_response_roles",
+ "tableTo": "auth_roles",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "knight_hacks_form_response_roles_form_id_role_id_pk": {
+ "name": "knight_hacks_form_response_roles_form_id_role_id_pk",
+ "columns": ["form_id", "role_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_form_section_roles": {
+ "name": "knight_hacks_form_section_roles",
+ "schema": "",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_form_section_roles_section_id_knight_hacks_form_sections_id_fk": {
+ "name": "knight_hacks_form_section_roles_section_id_knight_hacks_form_sections_id_fk",
+ "tableFrom": "knight_hacks_form_section_roles",
+ "tableTo": "knight_hacks_form_sections",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_form_section_roles_role_id_auth_roles_id_fk": {
+ "name": "knight_hacks_form_section_roles_role_id_auth_roles_id_fk",
+ "tableFrom": "knight_hacks_form_section_roles",
+ "tableTo": "auth_roles",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "knight_hacks_form_section_roles_section_id_role_id_pk": {
+ "name": "knight_hacks_form_section_roles_section_id_role_id_pk",
+ "columns": ["section_id", "role_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_form_sections": {
+ "name": "knight_hacks_form_sections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "knight_hacks_form_sections_name_unique": {
+ "name": "knight_hacks_form_sections_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_form_schemas": {
+ "name": "knight_hacks_form_schemas",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug_name": {
+ "name": "slug_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "dues_only": {
+ "name": "dues_only",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "allow_resubmission": {
+ "name": "allow_resubmission",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "allow_edit": {
+ "name": "allow_edit",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "form_data": {
+ "name": "form_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "form_validator_json": {
+ "name": "form_validator_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section": {
+ "name": "section",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'General'"
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_closed": {
+ "name": "is_closed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_form_schemas_section_id_knight_hacks_form_sections_id_fk": {
+ "name": "knight_hacks_form_schemas_section_id_knight_hacks_form_sections_id_fk",
+ "tableFrom": "knight_hacks_form_schemas",
+ "tableTo": "knight_hacks_form_sections",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "knight_hacks_form_schemas_slugName_unique": {
+ "name": "knight_hacks_form_schemas_slugName_unique",
+ "nullsNotDistinct": false,
+ "columns": ["slug_name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_hackathon": {
+ "name": "knight_hacks_hackathon",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "theme": {
+ "name": "theme",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_background_enabled": {
+ "name": "application_background_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "application_background_key": {
+ "name": "application_background_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_template_enabled": {
+ "name": "email_template_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "email_template_key": {
+ "name": "email_template_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "portal_base_url": {
+ "name": "portal_base_url",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmation_capacity": {
+ "name": "confirmation_capacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "application_open": {
+ "name": "application_open",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "application_deadline": {
+ "name": "application_deadline",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "confirmation_deadline": {
+ "name": "confirmation_deadline",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "knight_hacks_hackathon_name_unique": {
+ "name": "knight_hacks_hackathon_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_hackathon_sponsor": {
+ "name": "knight_hacks_hackathon_sponsor",
+ "schema": "",
+ "columns": {
+ "hackathon_id": {
+ "name": "hackathon_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sponsor_id": {
+ "name": "sponsor_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tier": {
+ "name": "tier",
+ "type": "sponsor_tier",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_hackathon_sponsor_hackathon_id_knight_hacks_hackathon_id_fk": {
+ "name": "knight_hacks_hackathon_sponsor_hackathon_id_knight_hacks_hackathon_id_fk",
+ "tableFrom": "knight_hacks_hackathon_sponsor",
+ "tableTo": "knight_hacks_hackathon",
+ "columnsFrom": ["hackathon_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_hackathon_sponsor_sponsor_id_knight_hacks_sponsor_id_fk": {
+ "name": "knight_hacks_hackathon_sponsor_sponsor_id_knight_hacks_sponsor_id_fk",
+ "tableFrom": "knight_hacks_hackathon_sponsor",
+ "tableTo": "knight_hacks_sponsor",
+ "columnsFrom": ["sponsor_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_hacker": {
+ "name": "knight_hacks_hacker",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_name": {
+ "name": "last_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "gender": {
+ "name": "gender",
+ "type": "gender",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Prefer not to answer'"
+ },
+ "discord_user": {
+ "name": "discord_user",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "age": {
+ "name": "age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "country": {
+ "name": "country",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'United States of America'"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "phone_number": {
+ "name": "phone_number",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "school": {
+ "name": "school",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level_of_study": {
+ "name": "level_of_study",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "major": {
+ "name": "major",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Computer Science'"
+ },
+ "race_or_ethnicity": {
+ "name": "race_or_ethnicity",
+ "type": "race_or_ethnicity",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Prefer not to answer'"
+ },
+ "shirt_size": {
+ "name": "shirt_size",
+ "type": "shirt_size",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "github_profile_url": {
+ "name": "github_profile_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "linkedin_profile_url": {
+ "name": "linkedin_profile_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "website_url": {
+ "name": "website_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resume_url": {
+ "name": "resume_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dob": {
+ "name": "dob",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "grad_date": {
+ "name": "grad_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "survey_1": {
+ "name": "survey_1",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "survey_2": {
+ "name": "survey_2",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_first_time": {
+ "name": "is_first_time",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "food_allergies": {
+ "name": "food_allergies",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agrees_to_receive_emails_from_mlh": {
+ "name": "agrees_to_receive_emails_from_mlh",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "agrees_to_mlh_code_of_conduct": {
+ "name": "agrees_to_mlh_code_of_conduct",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "agrees_to_mlh_data_sharing": {
+ "name": "agrees_to_mlh_data_sharing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "date_created": {
+ "name": "date_created",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "time_created": {
+ "name": "time_created",
+ "type": "time",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_hacker_user_id_auth_user_id_fk": {
+ "name": "knight_hacks_hacker_user_id_auth_user_id_fk",
+ "tableFrom": "knight_hacks_hacker",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_hacker_attendee": {
+ "name": "knight_hacks_hacker_attendee",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "hacker_id": {
+ "name": "hacker_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hackathon_id": {
+ "name": "hackathon_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "time_applied": {
+ "name": "time_applied",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "time_confirmed": {
+ "name": "time_confirmed",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "points": {
+ "name": "points",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "class": {
+ "name": "class",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": null
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_hacker_attendee_hacker_id_knight_hacks_hacker_id_fk": {
+ "name": "knight_hacks_hacker_attendee_hacker_id_knight_hacks_hacker_id_fk",
+ "tableFrom": "knight_hacks_hacker_attendee",
+ "tableTo": "knight_hacks_hacker",
+ "columnsFrom": ["hacker_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_hacker_attendee_hackathon_id_knight_hacks_hackathon_id_fk": {
+ "name": "knight_hacks_hacker_attendee_hackathon_id_knight_hacks_hackathon_id_fk",
+ "tableFrom": "knight_hacks_hacker_attendee",
+ "tableTo": "knight_hacks_hackathon",
+ "columnsFrom": ["hackathon_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_hacker_event_attendee": {
+ "name": "knight_hacks_hacker_event_attendee",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "hacker_att_id": {
+ "name": "hacker_att_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hackathon_id": {
+ "name": "hackathon_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_hacker_event_attendee_hacker_att_id_knight_hacks_hacker_attendee_id_fk": {
+ "name": "knight_hacks_hacker_event_attendee_hacker_att_id_knight_hacks_hacker_attendee_id_fk",
+ "tableFrom": "knight_hacks_hacker_event_attendee",
+ "tableTo": "knight_hacks_hacker_attendee",
+ "columnsFrom": ["hacker_att_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_hacker_event_attendee_hackathon_id_knight_hacks_hackathon_id_fk": {
+ "name": "knight_hacks_hacker_event_attendee_hackathon_id_knight_hacks_hackathon_id_fk",
+ "tableFrom": "knight_hacks_hacker_event_attendee",
+ "tableTo": "knight_hacks_hackathon",
+ "columnsFrom": ["hackathon_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_hacker_event_attendee_event_id_knight_hacks_event_id_fk": {
+ "name": "knight_hacks_hacker_event_attendee_event_id_knight_hacks_event_id_fk",
+ "tableFrom": "knight_hacks_hacker_event_attendee",
+ "tableTo": "knight_hacks_event",
+ "columnsFrom": ["event_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_issue": {
+ "name": "knight_hacks_issue",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "status": {
+ "name": "status",
+ "type": "issue_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "links": {
+ "name": "links",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event": {
+ "name": "event",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "date": {
+ "name": "date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "issue_priority",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team": {
+ "name": "team",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator": {
+ "name": "creator",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent": {
+ "name": "parent",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "issue_team_idx": {
+ "name": "issue_team_idx",
+ "columns": [
+ {
+ "expression": "team",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_creator_idx": {
+ "name": "issue_creator_idx",
+ "columns": [
+ {
+ "expression": "creator",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_status_idx": {
+ "name": "issue_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_date_idx": {
+ "name": "issue_date_idx",
+ "columns": [
+ {
+ "expression": "date",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_parent_idx": {
+ "name": "issue_parent_idx",
+ "columns": [
+ {
+ "expression": "parent",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_priority_idx": {
+ "name": "issue_priority_idx",
+ "columns": [
+ {
+ "expression": "priority",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "knight_hacks_issue_event_knight_hacks_event_id_fk": {
+ "name": "knight_hacks_issue_event_knight_hacks_event_id_fk",
+ "tableFrom": "knight_hacks_issue",
+ "tableTo": "knight_hacks_event",
+ "columnsFrom": ["event"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_issue_team_auth_roles_id_fk": {
+ "name": "knight_hacks_issue_team_auth_roles_id_fk",
+ "tableFrom": "knight_hacks_issue",
+ "tableTo": "auth_roles",
+ "columnsFrom": ["team"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_issue_creator_auth_user_id_fk": {
+ "name": "knight_hacks_issue_creator_auth_user_id_fk",
+ "tableFrom": "knight_hacks_issue",
+ "tableTo": "auth_user",
+ "columnsFrom": ["creator"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "issue_parent_fk": {
+ "name": "issue_parent_fk",
+ "tableFrom": "knight_hacks_issue",
+ "tableTo": "knight_hacks_issue",
+ "columnsFrom": ["parent"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_issues_to_teams_visibility": {
+ "name": "knight_hacks_issues_to_teams_visibility",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team_id": {
+ "name": "team_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_issues_to_teams_visibility_issue_id_knight_hacks_issue_id_fk": {
+ "name": "knight_hacks_issues_to_teams_visibility_issue_id_knight_hacks_issue_id_fk",
+ "tableFrom": "knight_hacks_issues_to_teams_visibility",
+ "tableTo": "knight_hacks_issue",
+ "columnsFrom": ["issue_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_issues_to_teams_visibility_team_id_auth_roles_id_fk": {
+ "name": "knight_hacks_issues_to_teams_visibility_team_id_auth_roles_id_fk",
+ "tableFrom": "knight_hacks_issues_to_teams_visibility",
+ "tableTo": "auth_roles",
+ "columnsFrom": ["team_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "knight_hacks_issues_to_teams_visibility_issue_id_team_id_pk": {
+ "name": "knight_hacks_issues_to_teams_visibility_issue_id_team_id_pk",
+ "columns": ["issue_id", "team_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_issues_to_users_assignment": {
+ "name": "knight_hacks_issues_to_users_assignment",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_issues_to_users_assignment_issue_id_knight_hacks_issue_id_fk": {
+ "name": "knight_hacks_issues_to_users_assignment_issue_id_knight_hacks_issue_id_fk",
+ "tableFrom": "knight_hacks_issues_to_users_assignment",
+ "tableTo": "knight_hacks_issue",
+ "columnsFrom": ["issue_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_issues_to_users_assignment_user_id_auth_user_id_fk": {
+ "name": "knight_hacks_issues_to_users_assignment_user_id_auth_user_id_fk",
+ "tableFrom": "knight_hacks_issues_to_users_assignment",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "knight_hacks_issues_to_users_assignment_issue_id_user_id_pk": {
+ "name": "knight_hacks_issues_to_users_assignment_issue_id_user_id_pk",
+ "columns": ["issue_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_judged_submission": {
+ "name": "knight_hacks_judged_submission",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "hackathon_id": {
+ "name": "hackathon_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "submission_id": {
+ "name": "submission_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "judge_id": {
+ "name": "judge_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_feedback": {
+ "name": "private_feedback",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_feedback": {
+ "name": "public_feedback",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "originality_rating": {
+ "name": "originality_rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "design_rating": {
+ "name": "design_rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "technical_understanding_rating": {
+ "name": "technical_understanding_rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "implementation_rating": {
+ "name": "implementation_rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "wow_factor_rating": {
+ "name": "wow_factor_rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_judged_submission_hackathon_id_knight_hacks_hackathon_id_fk": {
+ "name": "knight_hacks_judged_submission_hackathon_id_knight_hacks_hackathon_id_fk",
+ "tableFrom": "knight_hacks_judged_submission",
+ "tableTo": "knight_hacks_hackathon",
+ "columnsFrom": ["hackathon_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_judged_submission_submission_id_knight_hacks_submissions_id_fk": {
+ "name": "knight_hacks_judged_submission_submission_id_knight_hacks_submissions_id_fk",
+ "tableFrom": "knight_hacks_judged_submission",
+ "tableTo": "knight_hacks_submissions",
+ "columnsFrom": ["submission_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_judged_submission_judge_id_knight_hacks_judges_id_fk": {
+ "name": "knight_hacks_judged_submission_judge_id_knight_hacks_judges_id_fk",
+ "tableFrom": "knight_hacks_judged_submission",
+ "tableTo": "knight_hacks_judges",
+ "columnsFrom": ["judge_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_judges": {
+ "name": "knight_hacks_judges",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "room_name": {
+ "name": "room_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "challenge_id": {
+ "name": "challenge_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_judges_challenge_id_knight_hacks_challenges_id_fk": {
+ "name": "knight_hacks_judges_challenge_id_knight_hacks_challenges_id_fk",
+ "tableFrom": "knight_hacks_judges",
+ "tableTo": "knight_hacks_challenges",
+ "columnsFrom": ["challenge_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_member": {
+ "name": "knight_hacks_member",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_name": {
+ "name": "last_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "discord_user": {
+ "name": "discord_user",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "age": {
+ "name": "age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "phone_number": {
+ "name": "phone_number",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "school": {
+ "name": "school",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level_of_study": {
+ "name": "level_of_study",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "major": {
+ "name": "major",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Computer Science'"
+ },
+ "gender": {
+ "name": "gender",
+ "type": "gender",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Prefer not to answer'"
+ },
+ "race_or_ethnicity": {
+ "name": "race_or_ethnicity",
+ "type": "race_or_ethnicity",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Prefer not to answer'"
+ },
+ "guild_profile_visible": {
+ "name": "guild_profile_visible",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "tagline": {
+ "name": "tagline",
+ "type": "varchar(80)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "about": {
+ "name": "about",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "profile_picture_url": {
+ "name": "profile_picture_url",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "shirt_size": {
+ "name": "shirt_size",
+ "type": "shirt_size",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "github_profile_url": {
+ "name": "github_profile_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "linkedin_profile_url": {
+ "name": "linkedin_profile_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "website_url": {
+ "name": "website_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resume_url": {
+ "name": "resume_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dob": {
+ "name": "dob",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "grad_date": {
+ "name": "grad_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company": {
+ "name": "company",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "points": {
+ "name": "points",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "date_created": {
+ "name": "date_created",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "time_created": {
+ "name": "time_created",
+ "type": "time",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_member_user_id_auth_user_id_fk": {
+ "name": "knight_hacks_member_user_id_auth_user_id_fk",
+ "tableFrom": "knight_hacks_member",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "knight_hacks_member_email_unique": {
+ "name": "knight_hacks_member_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ },
+ "knight_hacks_member_phoneNumber_unique": {
+ "name": "knight_hacks_member_phoneNumber_unique",
+ "nullsNotDistinct": false,
+ "columns": ["phone_number"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_companies": {
+ "name": "knight_hacks_companies",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": true,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_sponsor": {
+ "name": "knight_hacks_sponsor",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "website_url": {
+ "name": "website_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_submissions": {
+ "name": "knight_hacks_submissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "challenge_id": {
+ "name": "challenge_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team_id": {
+ "name": "team_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hackathon_id": {
+ "name": "hackathon_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_submissions_challenge_id_knight_hacks_challenges_id_fk": {
+ "name": "knight_hacks_submissions_challenge_id_knight_hacks_challenges_id_fk",
+ "tableFrom": "knight_hacks_submissions",
+ "tableTo": "knight_hacks_challenges",
+ "columnsFrom": ["challenge_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_submissions_team_id_knight_hacks_teams_id_fk": {
+ "name": "knight_hacks_submissions_team_id_knight_hacks_teams_id_fk",
+ "tableFrom": "knight_hacks_submissions",
+ "tableTo": "knight_hacks_teams",
+ "columnsFrom": ["team_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knight_hacks_submissions_hackathon_id_knight_hacks_hackathon_id_fk": {
+ "name": "knight_hacks_submissions_hackathon_id_knight_hacks_hackathon_id_fk",
+ "tableFrom": "knight_hacks_submissions",
+ "tableTo": "knight_hacks_hackathon",
+ "columnsFrom": ["hackathon_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "knight_hacks_submissions_teamId_challengeId_unique": {
+ "name": "knight_hacks_submissions_teamId_challengeId_unique",
+ "nullsNotDistinct": false,
+ "columns": ["team_id", "challenge_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_teams": {
+ "name": "knight_hacks_teams",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "hackathon_id": {
+ "name": "hackathon_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_title": {
+ "name": "project_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "submission_url": {
+ "name": "submission_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_created_at": {
+ "name": "project_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_project_submitted": {
+ "name": "is_project_submitted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "devpost_url": {
+ "name": "devpost_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "universities": {
+ "name": "universities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emails": {
+ "name": "emails",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "match_key": {
+ "name": "match_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_teams_hackathon_id_knight_hacks_hackathon_id_fk": {
+ "name": "knight_hacks_teams_hackathon_id_knight_hacks_hackathon_id_fk",
+ "tableFrom": "knight_hacks_teams",
+ "tableTo": "knight_hacks_hackathon",
+ "columnsFrom": ["hackathon_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "knight_hacks_teams_matchKey_unique": {
+ "name": "knight_hacks_teams_matchKey_unique",
+ "nullsNotDistinct": false,
+ "columns": ["match_key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_template": {
+ "name": "knight_hacks_template",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knight_hacks_trpc_form_connection": {
+ "name": "knight_hacks_trpc_form_connection",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form": {
+ "name": "form",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "proc": {
+ "name": "proc",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connections": {
+ "name": "connections",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "knight_hacks_trpc_form_connection_form_knight_hacks_form_schemas_id_fk": {
+ "name": "knight_hacks_trpc_form_connection_form_knight_hacks_form_schemas_id_fk",
+ "tableFrom": "knight_hacks_trpc_form_connection",
+ "tableTo": "knight_hacks_form_schemas",
+ "columnsFrom": ["form"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.event_tag": {
+ "name": "event_tag",
+ "schema": "public",
+ "values": [
+ "GBM",
+ "Social",
+ "Kickstart",
+ "Project Launch",
+ "Hello World",
+ "Sponsorship",
+ "Tech Exploration",
+ "Class Support",
+ "Workshop",
+ "OPS",
+ "Collabs",
+ "Check-in",
+ "Merch",
+ "Food",
+ "Ceremony",
+ "CAREER-FAIR",
+ "RSO-FAIR"
+ ]
+ },
+ "public.gender": {
+ "name": "gender",
+ "schema": "public",
+ "values": [
+ "Man",
+ "Woman",
+ "Non-binary",
+ "Prefer to self-describe",
+ "Prefer not to answer"
+ ]
+ },
+ "public.hackathon_application_state": {
+ "name": "hackathon_application_state",
+ "schema": "public",
+ "values": [
+ "withdrawn",
+ "pending",
+ "accepted",
+ "waitlisted",
+ "checkedin",
+ "confirmed",
+ "denied"
+ ]
+ },
+ "public.issue_priority": {
+ "name": "issue_priority",
+ "schema": "public",
+ "values": ["Lowest", "Low", "Medium", "High", "Highest"]
+ },
+ "public.issue_status": {
+ "name": "issue_status",
+ "schema": "public",
+ "values": ["Backlog", "Planning", "In Progress", "Finished"]
+ },
+ "public.race_or_ethnicity": {
+ "name": "race_or_ethnicity",
+ "schema": "public",
+ "values": [
+ "White",
+ "Black or African American",
+ "Hispanic / Latino / Spanish Origin",
+ "Asian",
+ "Native Hawaiian or Other Pacific Islander",
+ "Native American or Alaskan Native",
+ "Middle Eastern",
+ "Prefer not to answer",
+ "Other"
+ ]
+ },
+ "public.shirt_size": {
+ "name": "shirt_size",
+ "schema": "public",
+ "values": ["XS", "S", "M", "L", "XL", "2XL", "3XL"]
+ },
+ "public.sponsor_tier": {
+ "name": "sponsor_tier",
+ "schema": "public",
+ "values": ["gold", "silver", "bronze", "other"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 7d8417fc4..3c67a8cb4 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -71,6 +71,13 @@
"when": 1781733366922,
"tag": "0009_smooth_forgotten_one",
"breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "7",
+ "when": 1783110594620,
+ "tag": "0010_wooden_supreme_intelligence",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts
index 2133ddd70..a0639a228 100644
--- a/packages/db/src/schemas/knight-hacks.ts
+++ b/packages/db/src/schemas/knight-hacks.ts
@@ -41,6 +41,8 @@ export const Hackathon = createTable(
applicationBackgroundKey: t.varchar({ length: 255 }),
emailTemplateEnabled: t.boolean().notNull().default(false),
emailTemplateKey: t.varchar({ length: 255 }),
+ portalBaseUrl: t.varchar({ length: 512 }),
+ confirmationCapacity: t.integer(),
applicationOpen: t.timestamp().notNull().defaultNow(),
applicationDeadline: t.timestamp().notNull().defaultNow(),
confirmationDeadline: t.timestamp().notNull().defaultNow(),
diff --git a/packages/hackathon/README.md b/packages/hackathon/README.md
new file mode 100644
index 000000000..0d4b0ede6
--- /dev/null
+++ b/packages/hackathon/README.md
@@ -0,0 +1,58 @@
+# `@forge/hackathon`
+
+Headless participant workflows for Forge hackathon applications. This package
+owns typed participant API state and lifecycle operations; event applications
+own their markup, assets, animation, and branding.
+
+The client provider talks only to the same-origin `@forge/api/participant`
+router. Its hooks expose typed query/mutation state without importing the full
+Blade/admin API:
+
+- `useHackerApplicationFlow({ hackathonStartDate })` owns the browser schema,
+ resume validation/upload, prior-hacker/member prefill, duplicate state,
+ consent, submission, and step/navigation state.
+- `useHackerDashboardFlow()` owns participant lifecycle refresh, confirmation,
+ withdrawal, QR retrieval, schedule data, past attendance, and issue reports.
+- `useHackerProfileFlow()` owns hackathon-scoped profile and resume updates.
+
+`getHackerLifecycleState` is a pure helper for rendering application and
+attendance states. Visual components, class names, assets, and copy remain in
+the event app.
+
+## Starting a new portal
+
+1. Create a Next.js event app with an app-local `HackathonPortalConfig`.
+2. Instantiate `createForgeAuthServer` with the event app origin and mount its
+ Better Auth handlers at `/api/auth`.
+3. Mount `participantRouter` at the app's same-origin `/api/trpc` route and pass
+ the app-local validated session to `createTRPCContext`.
+4. Wrap participant routes in `HackathonPortalProvider`.
+5. Pass the event start date to the application flow and build event-specific
+ application, dashboard, and profile renderers around the three headless
+ workflow hooks.
+6. Set the hackathon's portal base URL in Blade admin.
+
+For KH9, copy only this wiring and an app-local config. Do not copy Bloom's
+markup or move its styling into this package; the renderer should be entirely
+KH9-owned.
+
+## Bloom rollout
+
+During localhost development, Bloom brokers Discord OAuth through Blade at port
+3000 and returns through Blade's `/auth/bloom-return` route. The bridge accepts
+only the configured Bloom origin. Because cookies are scoped to `localhost`
+rather than to ports, Blade and Bloom share the development session and logout.
+Production does not use this bridge; each deployed host keeps its own cookie and
+Bloom uses its own registered callback.
+
+Before enabling Blade redirects:
+
+1. Set `BLOOMKNIGHTS_URL` to the exact Bloom origin.
+2. Register Discord callbacks for
+ `https://bloom.knighthacks.org/api/auth/callback/discord` and
+ `http://localhost:3006/api/auth/callback/discord`.
+3. Apply the Hackathon portal/capacity migration.
+4. Deploy and smoke-test Bloom auth, application, dashboard, and profile.
+5. Deploy Blade only after the Bloom portal is healthy.
+
+Do not add event-specific visuals to this package or to `@forge/ui`.
diff --git a/packages/hackathon/eslint.config.js b/packages/hackathon/eslint.config.js
new file mode 100644
index 000000000..93d956c5d
--- /dev/null
+++ b/packages/hackathon/eslint.config.js
@@ -0,0 +1,4 @@
+import baseConfig from "@forge/eslint-config/base";
+import reactConfig from "@forge/eslint-config/react";
+
+export default [...baseConfig, ...reactConfig];
diff --git a/packages/hackathon/package.json b/packages/hackathon/package.json
new file mode 100644
index 000000000..2fb751a54
--- /dev/null
+++ b/packages/hackathon/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@forge/hackathon",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./client": "./src/client.tsx",
+ "./server": "./src/server.ts"
+ },
+ "scripts": {
+ "build": "tsc",
+ "clean": "git clean -xdf .cache .turbo dist node_modules",
+ "dev": "tsc",
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit --emitDeclarationOnly false"
+ },
+ "dependencies": {
+ "@forge/api": "workspace:*",
+ "@forge/auth": "workspace:*",
+ "@forge/consts": "workspace:*",
+ "@forge/validators": "workspace:*",
+ "@tanstack/react-query": "catalog:",
+ "@trpc/client": "catalog:",
+ "superjson": "2.2.6",
+ "zod": "catalog:"
+ },
+ "devDependencies": {
+ "@forge/eslint-config": "workspace:*",
+ "@forge/prettier-config": "workspace:*",
+ "@forge/tsconfig": "workspace:*",
+ "@types/react": "^19.2.14",
+ "eslint": "catalog:",
+ "prettier": "catalog:",
+ "react": "^19.2.4",
+ "typescript": "catalog:"
+ },
+ "peerDependencies": {
+ "react": "catalog:react18"
+ },
+ "prettier": "@forge/prettier-config"
+}
diff --git a/packages/hackathon/src/application-schema.ts b/packages/hackathon/src/application-schema.ts
new file mode 100644
index 000000000..31c5503ff
--- /dev/null
+++ b/packages/hackathon/src/application-schema.ts
@@ -0,0 +1,344 @@
+"use client";
+
+import { z } from "zod";
+
+import { FORMS, MINIO } from "@forge/consts";
+import { hackerApplicationWireSchema } from "@forge/validators";
+
+import type { PortalApplicationContext } from "./types";
+
+function calculateAge(birthDate: Date, referenceDate: Date) {
+ let age = referenceDate.getFullYear() - birthDate.getFullYear();
+ const monthDiff = referenceDate.getMonth() - birthDate.getMonth();
+
+ if (
+ monthDiff < 0 ||
+ (monthDiff === 0 && referenceDate.getDate() < birthDate.getDate())
+ ) {
+ age--;
+ }
+
+ return age;
+}
+
+function createResumeUploadSchema() {
+ return z
+ .custom(
+ (value) => typeof FileList !== "undefined" && value instanceof FileList,
+ "Resume upload must be a browser file selection",
+ )
+ .superRefine((fileList, ctx) => {
+ if (fileList.length !== 0 && fileList.length !== 1) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Only 0 or 1 files allowed",
+ });
+ }
+
+ if (fileList.length !== 1) return;
+ const file = fileList[0];
+ if (typeof File === "undefined" || !(file instanceof File)) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Object in FileList is undefined",
+ });
+ return;
+ }
+
+ if (file.name.split(".").pop()?.toLowerCase() !== "pdf") {
+ ctx.addIssue({
+ code: "custom",
+ message: "Resume must be a PDF",
+ });
+ }
+
+ if (file.size > MINIO.MAX_RESUME_SIZE) {
+ ctx.addIssue({
+ code: "too_big",
+ origin: "number",
+ maximum: MINIO.MAX_RESUME_SIZE,
+ inclusive: true,
+ message: "File too large: maximum 5MB",
+ });
+ }
+ })
+ .optional();
+}
+
+export function createHackerApplicationClientSchema(
+ hackathonStartDate: string,
+) {
+ return hackerApplicationWireSchema.extend({
+ firstName: z.string().min(1, "Required"),
+ lastName: z.string().min(1, "Required"),
+ email: z.string().email("Invalid email").min(1, "Required"),
+ phoneNumber: z
+ .string()
+ .min(1, "Required")
+ .regex(/^\d{10}$|^\d{3}-\d{3}-\d{4}$/, "Invalid phone number"),
+ country: z.enum(FORMS.COUNTRIES, { error: "Select your country" }),
+ school: z.enum(FORMS.SCHOOLS, { error: "Select a school" }),
+ levelOfStudy: z.enum(FORMS.LEVELS_OF_STUDY, {
+ error: "Select your level of study",
+ }),
+ major: z.enum(FORMS.MAJORS, { error: "Select a major" }),
+ gender: z
+ .enum(FORMS.GENDERS, { error: "Select a valid gender" })
+ .optional(),
+ raceOrEthnicity: z
+ .enum(FORMS.RACES_OR_ETHNICITIES, {
+ error: "Select a valid race or ethnicity",
+ })
+ .optional(),
+ shirtSize: z.enum(FORMS.SHIRT_SIZES, {
+ error: "Select your shirt size",
+ }),
+ dob: z
+ .string()
+ .pipe(z.coerce.date())
+ .refine(
+ (date) => calculateAge(date, new Date(hackathonStartDate)) >= 18,
+ {
+ message:
+ "You must be at least 18 years old by the hackathon start date to participate",
+ },
+ )
+ .transform((date) => date.toISOString()),
+ gradDate: z
+ .string()
+ .pipe(z.coerce.date())
+ .transform((date) => date.toISOString()),
+ survey1: z.string().min(1, "Required"),
+ survey2: z.string().min(1, "Required"),
+ isFirstTime: z.boolean(),
+ githubProfileUrl: z
+ .string()
+ .regex(/^https:\/\/.+/, "Invalid URL: Please try again with https://")
+ .regex(
+ /^https:\/\/(www\.)?github\.com\/.+/,
+ "Invalid URL: Enter a valid GitHub link",
+ )
+ .url({ message: "Invalid URL" })
+ .optional()
+ .or(z.literal("")),
+ linkedinProfileUrl: z
+ .string()
+ .regex(/^https:\/\/.+/, "Invalid URL: Please try again with https://")
+ .regex(
+ /^https:\/\/(www\.)?linkedin\.com\/.+/,
+ "Invalid URL: Enter a valid LinkedIn link",
+ )
+ .regex(
+ /^https:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9_-]+\/?$/,
+ "Invalid URL: Do not use a mobile URL/excessively long URL",
+ )
+ .url({ message: "Invalid URL" })
+ .optional()
+ .or(z.literal("")),
+ websiteUrl: z
+ .string()
+ .regex(
+ /^https?:\/\/.+/,
+ "Invalid URL: Please try again with https:// or http://",
+ )
+ .url({ message: "Invalid URL" })
+ .optional()
+ .or(z.literal("")),
+ resumeUpload: createResumeUploadSchema(),
+ agreesToMLHCodeOfConduct: z.boolean().refine((value) => value, {
+ message: "You must agree to the MLH Code of Conduct",
+ }),
+ agreesToMLHDataSharing: z.boolean().refine((value) => value, {
+ message: "You must agree to the MLH data sharing terms",
+ }),
+ agreesToReceiveEmailsFromMLH: z.boolean(),
+ });
+}
+
+export function createHackerProfileClientSchema() {
+ return hackerApplicationWireSchema.extend({
+ firstName: z.string().min(1, "Required"),
+ lastName: z.string().min(1, "Required"),
+ email: z.string().email("Invalid email").min(1, "Required"),
+ phoneNumber: z
+ .string()
+ .regex(/^\d{10}$|^\d{3}-\d{3}-\d{4}$|^$/, "Invalid phone number"),
+ gender: z
+ .enum(FORMS.GENDERS, { error: "Select a valid gender" })
+ .optional(),
+ raceOrEthnicity: z
+ .enum(FORMS.RACES_OR_ETHNICITIES, {
+ error: "Select a valid race or ethnicity",
+ })
+ .optional(),
+ dob: z
+ .string()
+ .pipe(z.coerce.date())
+ .transform((date) => date.toISOString()),
+ gradDate: z.string(),
+ survey1: z.string().min(1, "Required"),
+ survey2: z.string().min(1, "Required"),
+ isFirstTime: z.boolean(),
+ githubProfileUrl: z
+ .string()
+ .regex(/^https:\/\/.+/, "Invalid URL: Please try again with https://")
+ .regex(
+ /^https:\/\/(www\.)?github\.com\/.+/,
+ "Invalid URL: Enter a valid GitHub link",
+ )
+ .url({ message: "Invalid URL" })
+ .optional()
+ .or(z.literal("")),
+ linkedinProfileUrl: z
+ .string()
+ .regex(/^https:\/\/.+/, "Invalid URL: Please try again with https://")
+ .regex(
+ /^https:\/\/(www\.)?linkedin\.com\/.+/,
+ "Invalid URL: Enter a valid LinkedIn link",
+ )
+ .regex(
+ /^https:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9_-]+\/?$/,
+ "Invalid URL: Do not use a mobile URL/excessively long URL",
+ )
+ .url({ message: "Invalid URL" })
+ .optional()
+ .or(z.literal("")),
+ websiteUrl: z
+ .string()
+ .regex(
+ /^https?:\/\/.+/,
+ "Invalid URL: Please try again with https:// or http://",
+ )
+ .url({ message: "Invalid URL" })
+ .optional()
+ .or(z.literal("")),
+ resumeUpload: createResumeUploadSchema(),
+ agreesToMLHCodeOfConduct: z.boolean().refine((value) => value, {
+ message: "You must agree to the MLH Code of Conduct",
+ }),
+ agreesToMLHDataSharing: z.boolean().refine((value) => value, {
+ message: "You must agree to the MLH data sharing terms",
+ }),
+ agreesToReceiveEmailsFromMLH: z.boolean(),
+ });
+}
+
+export type HackerProfileFormValues = z.output<
+ ReturnType
+>;
+
+export type HackerApplicationFormValues = z.output<
+ ReturnType
+>;
+
+export const HACKER_APPLICATION_DEFAULT_VALUES: Partial =
+ {
+ firstName: "",
+ lastName: "",
+ gender: undefined,
+ email: "",
+ phoneNumber: "",
+ country: undefined,
+ school: undefined,
+ levelOfStudy: undefined,
+ major: undefined,
+ raceOrEthnicity: undefined,
+ shirtSize: undefined,
+ githubProfileUrl: "",
+ linkedinProfileUrl: "",
+ websiteUrl: "",
+ resumeUrl: "",
+ dob: "",
+ gradDate: "",
+ survey1: "",
+ survey2: "",
+ isFirstTime: false,
+ foodAllergies: "",
+ agreesToReceiveEmailsFromMLH: false,
+ agreesToMLHCodeOfConduct: false,
+ agreesToMLHDataSharing: false,
+ };
+
+function getDateInputValue(value: Date | string) {
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+ return date.toISOString().slice(0, 10);
+}
+
+export function getHackerApplicationPrefill(
+ context: PortalApplicationContext | undefined,
+): {
+ selectedAllergies: string[];
+ source: "hacker" | "member";
+ values: Partial;
+} | null {
+ if (!context) return null;
+
+ const { previousHacker, memberProfile } = context;
+ if (previousHacker) {
+ return {
+ source: "hacker",
+ selectedAllergies: previousHacker.foodAllergies
+ ? previousHacker.foodAllergies.split(",")
+ : [],
+ values: {
+ firstName: previousHacker.firstName,
+ lastName: previousHacker.lastName,
+ gender: previousHacker.gender,
+ raceOrEthnicity: previousHacker.raceOrEthnicity,
+ email: previousHacker.email,
+ phoneNumber: previousHacker.phoneNumber || "",
+ country: previousHacker.country,
+ school: previousHacker.school,
+ levelOfStudy: previousHacker.levelOfStudy,
+ major: previousHacker.major,
+ shirtSize: previousHacker.shirtSize,
+ githubProfileUrl: previousHacker.githubProfileUrl ?? undefined,
+ linkedinProfileUrl: previousHacker.linkedinProfileUrl ?? undefined,
+ websiteUrl: previousHacker.websiteUrl ?? undefined,
+ resumeUrl: previousHacker.resumeUrl ?? "",
+ dob: getDateInputValue(previousHacker.dob),
+ gradDate: getDateInputValue(previousHacker.gradDate),
+ survey1: "",
+ survey2: "",
+ isFirstTime: previousHacker.isFirstTime ?? false,
+ foodAllergies: previousHacker.foodAllergies,
+ agreesToReceiveEmailsFromMLH: false,
+ agreesToMLHCodeOfConduct: false,
+ agreesToMLHDataSharing: false,
+ },
+ };
+ }
+
+ if (!memberProfile) return null;
+ return {
+ source: "member",
+ selectedAllergies: [],
+ values: {
+ firstName: memberProfile.firstName,
+ lastName: memberProfile.lastName,
+ gender: memberProfile.gender,
+ raceOrEthnicity: memberProfile.raceOrEthnicity,
+ email: memberProfile.email,
+ phoneNumber: memberProfile.phoneNumber ?? "",
+ country: undefined,
+ school: memberProfile.school,
+ levelOfStudy: memberProfile.levelOfStudy,
+ major: memberProfile.major,
+ shirtSize: memberProfile.shirtSize,
+ githubProfileUrl: memberProfile.githubProfileUrl ?? undefined,
+ linkedinProfileUrl: memberProfile.linkedinProfileUrl ?? undefined,
+ websiteUrl: memberProfile.websiteUrl ?? undefined,
+ resumeUrl: memberProfile.resumeUrl ?? "",
+ dob: getDateInputValue(memberProfile.dob),
+ gradDate: getDateInputValue(memberProfile.gradDate),
+ survey1: "",
+ survey2: "",
+ isFirstTime: false,
+ foodAllergies: "",
+ agreesToReceiveEmailsFromMLH: false,
+ agreesToMLHCodeOfConduct: false,
+ agreesToMLHDataSharing: false,
+ },
+ };
+}
diff --git a/packages/hackathon/src/client.tsx b/packages/hackathon/src/client.tsx
new file mode 100644
index 000000000..d59f787a8
--- /dev/null
+++ b/packages/hackathon/src/client.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import type { QueryClient } from "@tanstack/react-query";
+import type { TRPCClient } from "@trpc/client";
+import type { ReactNode } from "react";
+import { createContext, useContext, useEffect, useMemo, useState } from "react";
+import {
+ QueryClientProvider,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { createTRPCClient, unstable_httpBatchStreamLink } from "@trpc/client";
+import SuperJSON from "superjson";
+
+import type { ParticipantRouter } from "@forge/api/participant";
+
+import type { HackathonPortalConfig } from "./config";
+import type { PortalApplicationInput } from "./types";
+import {
+ createHackerApplicationClientSchema,
+ createHackerProfileClientSchema,
+ getHackerApplicationPrefill,
+ HACKER_APPLICATION_DEFAULT_VALUES,
+} from "./application-schema";
+import { createHackathonQueryClient } from "./query-client";
+
+export type {
+ HackerApplicationFormValues,
+ HackerProfileFormValues,
+} from "./application-schema";
+
+const PortalConfigContext = createContext(null);
+type PortalTRPCClient = TRPCClient;
+
+const PortalClientContext = createContext(null);
+let browserQueryClient: QueryClient | undefined;
+
+function getQueryClient() {
+ if (typeof window === "undefined") return createHackathonQueryClient();
+ return (browserQueryClient ??= createHackathonQueryClient());
+}
+
+export function HackathonPortalProvider({
+ children,
+ config,
+}: {
+ children: ReactNode;
+ config: HackathonPortalConfig;
+}) {
+ const queryClient = getQueryClient();
+ const [trpcClient] = useState(() =>
+ createTRPCClient({
+ links: [
+ unstable_httpBatchStreamLink({
+ transformer: SuperJSON,
+ url: "/api/trpc",
+ headers: { "x-trpc-source": "hackathon-portal" },
+ }),
+ ],
+ }),
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+export function useHackathonPortalConfig() {
+ const config = useContext(PortalConfigContext);
+ if (!config) {
+ throw new Error(
+ "useHackathonPortalConfig must be used inside HackathonPortalProvider.",
+ );
+ }
+ return config;
+}
+
+function usePortalClient() {
+ const client = useContext(PortalClientContext);
+ if (!client) {
+ throw new Error(
+ "Hackathon portal hooks must be used inside HackathonPortalProvider.",
+ );
+ }
+ return client;
+}
+
+function portalQueryKey(name: string, hackathonName: string) {
+ return ["hackathon-portal", name, hackathonName] as const;
+}
+
+export function useHackerApplicationFlow({
+ hackathonStartDate,
+}: {
+ hackathonStartDate: string;
+}) {
+ const config = useHackathonPortalConfig();
+ const client = usePortalClient();
+ const queryClient = useQueryClient();
+ const [activeStep, setActiveStep] = useState(0);
+ const [applicationSubmitted, setApplicationSubmitted] = useState(false);
+ const [hasHydrated, setHasHydrated] = useState(false);
+ const [isStepTransitioning, setIsStepTransitioning] = useState(false);
+ const [stepDirection, setStepDirection] = useState<"forward" | "back">(
+ "forward",
+ );
+ const [tosAccepted, setTosAccepted] = useState(false);
+ const [tosError, setTosError] = useState(false);
+ const [transitionStep, setTransitionStep] = useState(null);
+ const contextKey = portalQueryKey(
+ "application-context",
+ config.hackathonName,
+ );
+ const dashboardKey = portalQueryKey("dashboard", config.hackathonName);
+ const contextQuery = useQuery({
+ queryKey: contextKey,
+ queryFn: () =>
+ client.portal.getApplicationContext.query({
+ hackathonName: config.hackathonName,
+ }),
+ });
+ const applicationSchema = useMemo(
+ () => createHackerApplicationClientSchema(hackathonStartDate),
+ [hackathonStartDate],
+ );
+ const applicationPrefill = useMemo(
+ () => getHackerApplicationPrefill(contextQuery.data),
+ [contextQuery.data],
+ );
+
+ useEffect(() => {
+ const hydrationTimeout = window.setTimeout(() => setHasHydrated(true), 0);
+ return () => window.clearTimeout(hydrationTimeout);
+ }, []);
+ const uploadMutation = useMutation({
+ mutationFn: ({
+ fileContent,
+ fileName,
+ }: {
+ fileContent: string;
+ fileName: string;
+ }) =>
+ client.portal.uploadResume.mutate({
+ fileContent,
+ fileName,
+ hackathonName: config.hackathonName,
+ }),
+ });
+ const submitMutation = useMutation({
+ mutationFn: (application: PortalApplicationInput) =>
+ client.portal.submitApplication.mutate({
+ ...application,
+ hackathonName: config.hackathonName,
+ }),
+ async onSuccess() {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: contextKey }),
+ queryClient.invalidateQueries({ queryKey: dashboardKey }),
+ ]);
+ },
+ });
+
+ return {
+ activeStep,
+ applicationContext: contextQuery.data,
+ applicationPrefill,
+ applicationSchema,
+ applicationSubmitted,
+ contextQuery,
+ defaultValues: HACKER_APPLICATION_DEFAULT_VALUES,
+ hasExistingApplication: Boolean(contextQuery.data?.existingApplication),
+ hasHydrated,
+ isStepTransitioning,
+ setActiveStep,
+ setApplicationSubmitted,
+ setIsStepTransitioning,
+ setStepDirection,
+ setTosAccepted,
+ setTosError,
+ setTransitionStep,
+ stepDirection,
+ submitApplication: submitMutation.mutateAsync,
+ submitMutation,
+ tosAccepted,
+ tosError,
+ transitionStep,
+ uploadResume: (fileName: string, fileContent: string) =>
+ uploadMutation.mutateAsync({ fileContent, fileName }),
+ uploadMutation,
+ };
+}
+
+export function useHackerDashboardFlow() {
+ const config = useHackathonPortalConfig();
+ const client = usePortalClient();
+ const queryClient = useQueryClient();
+ const dashboardKey = portalQueryKey("dashboard", config.hackathonName);
+ const dashboardQuery = useQuery({
+ queryKey: dashboardKey,
+ queryFn: () =>
+ client.portal.getDashboard.query({
+ hackathonName: config.hackathonName,
+ }),
+ });
+ const dashboard = dashboardQuery.data;
+ const isCheckedIn = dashboard?.participant?.status === "checkedin";
+ const scheduleQuery = useQuery({
+ enabled: isCheckedIn,
+ queryKey: portalQueryKey("schedule", config.hackathonName),
+ queryFn: () =>
+ client.portal.getSchedule.query({
+ hackathonName: config.hackathonName,
+ }),
+ });
+ const resumeQuery = useQuery({
+ enabled: Boolean(dashboard?.participant),
+ queryKey: portalQueryKey("resume", config.hackathonName),
+ queryFn: () =>
+ client.portal.getResume.query({
+ hackathonName: config.hackathonName,
+ }),
+ });
+ const refreshDashboard = () =>
+ queryClient.invalidateQueries({ queryKey: dashboardKey });
+ const confirmMutation = useMutation({
+ mutationFn: () =>
+ client.portal.confirmAttendance.mutate({
+ hackathonName: config.hackathonName,
+ }),
+ onSuccess: refreshDashboard,
+ });
+ const withdrawMutation = useMutation({
+ mutationFn: () =>
+ client.portal.withdrawAttendance.mutate({
+ hackathonName: config.hackathonName,
+ }),
+ onSuccess: refreshDashboard,
+ });
+ const qrMutation = useMutation({
+ mutationFn: () =>
+ client.portal.getQRCode.query({
+ hackathonName: config.hackathonName,
+ }),
+ });
+ const reportIssueMutation = useMutation({
+ mutationFn: (description: string) =>
+ client.portal.reportIssue.mutate({
+ description,
+ hackathonName: config.hackathonName,
+ }),
+ });
+
+ return {
+ config,
+ confirmAttendance: confirmMutation.mutateAsync,
+ confirmMutation,
+ dashboard,
+ dashboardQuery,
+ loadQRCode: qrMutation.mutateAsync,
+ qrCode: qrMutation.data?.qrCodeUrl,
+ qrMutation,
+ reportIssue: reportIssueMutation.mutateAsync,
+ reportIssueMutation,
+ resumeUrl: resumeQuery.data?.url,
+ resumeQuery,
+ schedule: scheduleQuery.data ?? [],
+ scheduleQuery,
+ withdrawAttendance: withdrawMutation.mutateAsync,
+ withdrawMutation,
+ };
+}
+
+export function useHackerProfileFlow() {
+ const config = useHackathonPortalConfig();
+ const client = usePortalClient();
+ const queryClient = useQueryClient();
+ const dashboardKey = portalQueryKey("dashboard", config.hackathonName);
+ const profileSchema = useMemo(() => createHackerProfileClientSchema(), []);
+ const dashboardQuery = useQuery({
+ queryKey: dashboardKey,
+ queryFn: () =>
+ client.portal.getDashboard.query({
+ hackathonName: config.hackathonName,
+ }),
+ });
+ const uploadMutation = useMutation({
+ mutationFn: ({
+ fileContent,
+ fileName,
+ }: {
+ fileContent: string;
+ fileName: string;
+ }) =>
+ client.portal.uploadResume.mutate({
+ fileContent,
+ fileName,
+ hackathonName: config.hackathonName,
+ }),
+ });
+ const updateMutation = useMutation({
+ mutationFn: (profile: PortalApplicationInput) =>
+ client.portal.updateProfile.mutate({
+ ...profile,
+ hackathonName: config.hackathonName,
+ }),
+ async onSuccess() {
+ await queryClient.invalidateQueries({ queryKey: dashboardKey });
+ },
+ });
+
+ return {
+ participant: dashboardQuery.data?.participant,
+ dashboardQuery,
+ profileSchema,
+ updateProfile: updateMutation.mutateAsync,
+ updateMutation,
+ uploadResume: (fileName: string, fileContent: string) =>
+ uploadMutation.mutateAsync({ fileContent, fileName }),
+ uploadMutation,
+ };
+}
diff --git a/packages/hackathon/src/config.ts b/packages/hackathon/src/config.ts
new file mode 100644
index 000000000..c1257f596
--- /dev/null
+++ b/packages/hackathon/src/config.ts
@@ -0,0 +1,78 @@
+export interface HackathonPortalConfig {
+ hackathonName: string;
+ routes: {
+ home: string;
+ dashboard: string;
+ apply: string;
+ profile: string;
+ };
+ termsUrl: string;
+ guideUrl: string;
+ copy: {
+ applicationName: string;
+ supportChannelUrl: string;
+ };
+}
+
+export const APPLICATION_STEPS = [
+ {
+ id: "profile",
+ title: "Basics",
+ eyebrow: "Start",
+ fields: ["firstName", "lastName"],
+ },
+ {
+ id: "contact",
+ title: "Contact",
+ eyebrow: "Reachability",
+ fields: ["email", "phoneNumber"],
+ },
+ {
+ id: "identity",
+ title: "About You",
+ eyebrow: "Profile",
+ fields: ["dob", "country", "gender", "raceOrEthnicity"],
+ },
+ {
+ id: "education",
+ title: "School",
+ eyebrow: "Education",
+ fields: ["levelOfStudy", "school", "major", "gradDate", "shirtSize"],
+ },
+ {
+ id: "application",
+ title: "Application",
+ eyebrow: "Application",
+ fields: ["survey1", "survey2"],
+ },
+ {
+ id: "links",
+ title: "Links",
+ eyebrow: "Portfolio",
+ fields: [
+ "githubProfileUrl",
+ "linkedinProfileUrl",
+ "websiteUrl",
+ "resumeUpload",
+ ],
+ },
+ {
+ id: "event",
+ title: "Event Details",
+ eyebrow: "Event Details",
+ fields: ["foodAllergies", "isFirstTime"],
+ },
+ {
+ id: "tosAccepted",
+ title: "Agreements",
+ eyebrow: "Finalize",
+ fields: [
+ "agreesToMLHCodeOfConduct",
+ "agreesToMLHDataSharing",
+ "agreesToReceiveEmailsFromMLH",
+ ],
+ },
+] as const;
+
+export type ApplicationStep = (typeof APPLICATION_STEPS)[number];
+export type ApplicationFieldName = ApplicationStep["fields"][number];
diff --git a/packages/hackathon/src/index.ts b/packages/hackathon/src/index.ts
new file mode 100644
index 000000000..ec4b5829c
--- /dev/null
+++ b/packages/hackathon/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./config";
+export * from "./lifecycle";
+export * from "./types";
diff --git a/packages/hackathon/src/lifecycle.ts b/packages/hackathon/src/lifecycle.ts
new file mode 100644
index 000000000..948b956c0
--- /dev/null
+++ b/packages/hackathon/src/lifecycle.ts
@@ -0,0 +1,60 @@
+import type { HackerStatus } from "./types";
+
+export type HackerLifecycleState =
+ | "application-before-open"
+ | "application-open"
+ | "application-closed"
+ | "pending"
+ | "accepted"
+ | "accepted-confirmation-closed"
+ | "accepted-at-capacity"
+ | "confirmed"
+ | "checked-in"
+ | "denied"
+ | "waitlisted"
+ | "withdrawn";
+
+export function getHackerLifecycleState({
+ applicationDeadline,
+ applicationOpen,
+ confirmationCapacity,
+ confirmationDeadline,
+ confirmedCount,
+ now = new Date(),
+ status,
+}: {
+ applicationDeadline: Date;
+ applicationOpen: Date;
+ confirmationCapacity: number | null;
+ confirmationDeadline: Date;
+ confirmedCount: number;
+ now?: Date;
+ status: HackerStatus | null;
+}): HackerLifecycleState {
+ if (!status) {
+ if (now < applicationOpen) return "application-before-open";
+ if (now > applicationDeadline) return "application-closed";
+ return "application-open";
+ }
+
+ if (status === "accepted") {
+ if (now > confirmationDeadline) return "accepted-confirmation-closed";
+ if (
+ confirmationCapacity != null &&
+ confirmedCount >= confirmationCapacity
+ ) {
+ return "accepted-at-capacity";
+ }
+ }
+
+ const states: Record = {
+ accepted: "accepted",
+ checkedin: "checked-in",
+ confirmed: "confirmed",
+ denied: "denied",
+ pending: "pending",
+ waitlisted: "waitlisted",
+ withdrawn: "withdrawn",
+ };
+ return states[status];
+}
diff --git a/packages/hackathon/src/query-client.ts b/packages/hackathon/src/query-client.ts
new file mode 100644
index 000000000..d739d46e8
--- /dev/null
+++ b/packages/hackathon/src/query-client.ts
@@ -0,0 +1,20 @@
+import {
+ defaultShouldDehydrateQuery,
+ QueryClient,
+} from "@tanstack/react-query";
+import SuperJSON from "superjson";
+
+export function createHackathonQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: 30_000 },
+ dehydrate: {
+ serializeData: SuperJSON.serialize,
+ shouldDehydrateQuery: (query) =>
+ defaultShouldDehydrateQuery(query) ||
+ query.state.status === "pending",
+ },
+ hydrate: { deserializeData: SuperJSON.deserialize },
+ },
+ });
+}
diff --git a/packages/hackathon/src/server.ts b/packages/hackathon/src/server.ts
new file mode 100644
index 000000000..75dc40f1a
--- /dev/null
+++ b/packages/hackathon/src/server.ts
@@ -0,0 +1,15 @@
+import type { Session } from "@forge/auth/server";
+import {
+ createParticipantCaller,
+ createTRPCContext,
+} from "@forge/api/participant";
+
+export function createHackathonPortalServerCaller({
+ headers,
+ session,
+}: {
+ headers: Headers;
+ session: Session | null;
+}): ReturnType {
+ return createParticipantCaller(() => createTRPCContext({ headers, session }));
+}
diff --git a/packages/hackathon/src/types.ts b/packages/hackathon/src/types.ts
new file mode 100644
index 000000000..ef4b50773
--- /dev/null
+++ b/packages/hackathon/src/types.ts
@@ -0,0 +1,18 @@
+import type {
+ Participant,
+ ParticipantApplicationContext,
+ ParticipantDashboard,
+ ParticipantScheduleEvent,
+} from "@forge/api/participant";
+import type { FORMS } from "@forge/consts";
+import type { HackerApplicationWireInput } from "@forge/validators";
+
+export type HackerStatus = (typeof FORMS.HACKATHON_APPLICATION_STATES)[number];
+
+export type PortalParticipant = Participant;
+export type PortalDashboardData = ParticipantDashboard;
+export type PortalApplicationContext = ParticipantApplicationContext;
+
+export type PortalApplicationInput = HackerApplicationWireInput;
+
+export type PortalScheduleEvent = ParticipantScheduleEvent;
diff --git a/packages/hackathon/tsconfig.json b/packages/hackathon/tsconfig.json
new file mode 100644
index 000000000..ea861c33a
--- /dev/null
+++ b/packages/hackathon/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@forge/tsconfig/internal-package.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "dom", "dom.iterable"],
+ "jsx": "preserve",
+ "rootDir": "."
+ },
+ "include": ["src"]
+}
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 1806eb329..3473762e3 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -22,7 +22,6 @@
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"devDependencies": {
- "@forge/auth": "workspace:*",
"@forge/consts": "workspace:*",
"@forge/db": "workspace:*",
"@forge/eslint-config": "workspace:*",
diff --git a/packages/utils/src/discord-env.ts b/packages/utils/src/discord-env.ts
new file mode 100644
index 000000000..3fcf9def5
--- /dev/null
+++ b/packages/utils/src/discord-env.ts
@@ -0,0 +1,9 @@
+import { createEnv } from "@t3-oss/env-core";
+import { z } from "zod";
+
+export const discordEnv = createEnv({
+ server: { DISCORD_BOT_TOKEN: z.string() },
+ runtimeEnv: process.env,
+ skipValidation:
+ !!process.env.CI || process.env.npm_lifecycle_event === "lint",
+});
diff --git a/packages/utils/src/discord.ts b/packages/utils/src/discord.ts
index 0e502280b..abe0d4b64 100644
--- a/packages/utils/src/discord.ts
+++ b/packages/utils/src/discord.ts
@@ -8,16 +8,17 @@ import { REST } from "@discordjs/rest";
import { Routes } from "discord-api-types/v10";
import { and, desc, eq } from "drizzle-orm";
-import type { Session } from "@forge/auth/server";
import { DISCORD } from "@forge/consts";
import { db } from "@forge/db/client";
import { Account } from "@forge/db/schemas/auth";
import { TEAMS } from "../../consts/src/team";
-import { env } from "./env";
+import { discordEnv } from "./discord-env";
import { logger } from "./logger";
-export const api = new REST({ version: "10" }).setToken(env.DISCORD_BOT_TOKEN);
+export const api = new REST({ version: "10" }).setToken(
+ discordEnv.DISCORD_BOT_TOKEN,
+);
export async function sendRecruitingApplication(
teamName: string,
@@ -169,10 +170,11 @@ export async function resolveDiscordUserId(
return members[0]?.user.id ?? null;
}
-// TODO: look into not using Session here so we can remove the auth import
-// which will let us clean up our imports.
+interface DiscordSessionUser {
+ discordUserId: string;
+}
-export const isDiscordAdmin = async (user: Session["user"]) => {
+export const isDiscordAdmin = async (user: DiscordSessionUser) => {
try {
const guildMember = (await api.get(
Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, user.discordUserId),
@@ -184,7 +186,7 @@ export const isDiscordAdmin = async (user: Session["user"]) => {
}
};
-export const isDiscordMember = async (user: Session["user"]) => {
+export const isDiscordMember = async (user: DiscordSessionUser) => {
try {
await api.get(
Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, user.discordUserId),
diff --git a/packages/utils/src/google-env.ts b/packages/utils/src/google-env.ts
new file mode 100644
index 000000000..22c7c1d20
--- /dev/null
+++ b/packages/utils/src/google-env.ts
@@ -0,0 +1,12 @@
+import { createEnv } from "@t3-oss/env-core";
+import { z } from "zod";
+
+export const googleEnv = createEnv({
+ server: {
+ GOOGLE_CLIENT_EMAIL: z.string(),
+ GOOGLE_PRIVATE_KEY_B64: z.string(),
+ },
+ runtimeEnv: process.env,
+ skipValidation:
+ !!process.env.CI || process.env.npm_lifecycle_event === "lint",
+});
diff --git a/packages/utils/src/google.ts b/packages/utils/src/google.ts
index 7d0d86269..13c98b3f7 100644
--- a/packages/utils/src/google.ts
+++ b/packages/utils/src/google.ts
@@ -4,9 +4,12 @@ import { google } from "googleapis";
import { EVENTS } from "@forge/consts";
-import { env } from "./env";
+import { googleEnv } from "./google-env";
-const GOOGLE_PRIVATE_KEY = Buffer.from(env.GOOGLE_PRIVATE_KEY_B64, "base64")
+const GOOGLE_PRIVATE_KEY = Buffer.from(
+ googleEnv.GOOGLE_PRIVATE_KEY_B64,
+ "base64",
+)
.toString("utf-8")
.replace(/\\n/g, "\n");
@@ -16,7 +19,7 @@ const gapiGmailSettingsSharing =
"https://www.googleapis.com/auth/gmail.settings.sharing";
const auth = new google.auth.JWT({
- email: env.GOOGLE_CLIENT_EMAIL,
+ email: googleEnv.GOOGLE_CLIENT_EMAIL,
key: GOOGLE_PRIVATE_KEY,
scopes: [gapiCalendar, gapiGmailSend, gapiGmailSettingsSharing],
subject: EVENTS.GOOGLE_PERSONIFY_EMAIL as string,
diff --git a/packages/utils/src/stripe-env.ts b/packages/utils/src/stripe-env.ts
new file mode 100644
index 000000000..4e53c0bdb
--- /dev/null
+++ b/packages/utils/src/stripe-env.ts
@@ -0,0 +1,9 @@
+import { createEnv } from "@t3-oss/env-core";
+import { z } from "zod";
+
+export const stripeEnv = createEnv({
+ server: { STRIPE_SECRET_KEY: z.string() },
+ runtimeEnv: process.env,
+ skipValidation:
+ !!process.env.CI || process.env.npm_lifecycle_event === "lint",
+});
diff --git a/packages/utils/src/stripe.ts b/packages/utils/src/stripe.ts
index 90f8fa73a..7b2988b4e 100644
--- a/packages/utils/src/stripe.ts
+++ b/packages/utils/src/stripe.ts
@@ -2,6 +2,8 @@ import "server-only";
import Stripe from "stripe";
-import { env } from "./env";
+import { stripeEnv } from "./stripe-env";
-export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { typescript: true });
+export const stripe = new Stripe(stripeEnv.STRIPE_SECRET_KEY, {
+ typescript: true,
+});
diff --git a/packages/validators/package.json b/packages/validators/package.json
index 1f931d5ef..eae38d95f 100644
--- a/packages/validators/package.json
+++ b/packages/validators/package.json
@@ -19,6 +19,7 @@
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
+ "@forge/consts": "workspace:*",
"minimatch": "^10.2.4",
"zod": "catalog:"
},
diff --git a/packages/validators/src/hackathons.ts b/packages/validators/src/hackathons.ts
index 3cfc0882d..1c8d2557e 100644
--- a/packages/validators/src/hackathons.ts
+++ b/packages/validators/src/hackathons.ts
@@ -23,6 +23,51 @@ export const hackathonThemeSchema = z
.min(1, "Theme is required.")
.max(255, "Theme must be 255 characters or fewer.");
+export const hackathonPortalOriginSchema = z
+ .string()
+ .trim()
+ .url("Enter a valid portal URL.")
+ .superRefine((value, ctx) => {
+ const url = new URL(value);
+
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ ctx.addIssue({
+ code: "custom",
+ message: "Portal URLs must use HTTP or HTTPS.",
+ });
+ }
+
+ if (
+ url.username ||
+ url.password ||
+ url.pathname !== "/" ||
+ url.search ||
+ url.hash
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Enter the portal origin without a path, query, or hash.",
+ });
+ }
+ })
+ .transform((value) => new URL(value).origin);
+
+export const hackathonPortalBaseUrlSchema = z.preprocess(
+ (value) =>
+ typeof value === "string" && value.trim().length === 0 ? null : value,
+ hackathonPortalOriginSchema.nullable(),
+);
+
+export const hackathonConfirmationCapacitySchema = z.preprocess(
+ (value) =>
+ value === "" || value === undefined || value === null ? null : value,
+ z.coerce
+ .number()
+ .int("Confirmation capacity must be a whole number.")
+ .positive("Confirmation capacity must be greater than zero.")
+ .nullable(),
+);
+
export function createHackathonApplicationBackgroundKeySchema<
T extends readonly [string, ...string[]],
>(backgroundKeys: T) {
diff --git a/packages/validators/src/hacker.ts b/packages/validators/src/hacker.ts
new file mode 100644
index 000000000..f467d7656
--- /dev/null
+++ b/packages/validators/src/hacker.ts
@@ -0,0 +1,44 @@
+import { z } from "zod";
+
+import { FORMS } from "@forge/consts";
+
+const optionalUrl = z.union([z.literal(""), z.string().url()]).optional();
+const requiredAgreement = z
+ .boolean()
+ .refine((value) => value, "This agreement is required.");
+
+export const hackerApplicationWireSchema = z.object({
+ firstName: z.string().trim().min(1).max(255),
+ lastName: z.string().trim().min(1).max(255),
+ email: z.string().email().max(255),
+ phoneNumber: z.string().max(255),
+ country: z.enum(FORMS.COUNTRIES),
+ school: z.enum(FORMS.SCHOOLS),
+ major: z.enum(FORMS.MAJORS),
+ levelOfStudy: z.enum(FORMS.LEVELS_OF_STUDY),
+ gender: z.enum(FORMS.GENDERS).default("Prefer not to answer"),
+ raceOrEthnicity: z
+ .enum(FORMS.RACES_OR_ETHNICITIES)
+ .default("Prefer not to answer"),
+ shirtSize: z.enum(FORMS.SHIRT_SIZES),
+ githubProfileUrl: optionalUrl,
+ linkedinProfileUrl: optionalUrl,
+ websiteUrl: optionalUrl,
+ resumeUrl: z.string().nullable().optional(),
+ dob: z.string().min(1),
+ gradDate: z.string().min(1),
+ survey1: z.string().min(1),
+ survey2: z.string().min(1),
+ isFirstTime: z.boolean().default(false),
+ foodAllergies: z.string().nullable().optional(),
+ agreesToReceiveEmailsFromMLH: z.boolean().default(false),
+ agreesToMLHCodeOfConduct: requiredAgreement,
+ agreesToMLHDataSharing: requiredAgreement,
+});
+
+export type HackerApplicationWireInput = z.input<
+ typeof hackerApplicationWireSchema
+>;
+export type HackerApplicationWireData = z.output<
+ typeof hackerApplicationWireSchema
+>;
diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts
index f13865c4c..0f4d13c93 100644
--- a/packages/validators/src/index.ts
+++ b/packages/validators/src/index.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
export * from "./hackathons";
+export * from "./hacker";
export const unused = z.string().describe(
`This lib is currently not used as we use drizzle-zod for simple schemas
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4ec326310..e154d2b0b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -359,12 +359,21 @@ importers:
'@forge/auth':
specifier: workspace:*
version: link:../../packages/auth
+ '@forge/consts':
+ specifier: workspace:*
+ version: link:../../packages/consts
'@forge/db':
specifier: workspace:*
version: link:../../packages/db
+ '@forge/hackathon':
+ specifier: workspace:*
+ version: link:../../packages/hackathon
'@forge/ui':
specifier: workspace:*
version: link:../../packages/ui
+ '@forge/validators':
+ specifier: workspace:*
+ version: link:../../packages/validators
'@gsap/react':
specifier: ^2.1.2
version: 2.1.2(gsap@3.14.2)(react@19.2.4)
@@ -380,6 +389,9 @@ importers:
'@svgr/webpack':
specifier: ^8.1.0
version: 8.1.0(typescript@5.9.3)
+ '@trpc/server':
+ specifier: 'catalog:'
+ version: 11.10.0(typescript@5.9.3)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -444,6 +456,9 @@ importers:
eslint:
specifier: 'catalog:'
version: 10.0.2(jiti@2.6.1)
+ jiti:
+ specifier: ^2.6.1
+ version: 2.6.1
prettier:
specifier: 'catalog:'
version: 3.8.1
@@ -1051,6 +1066,9 @@ importers:
'@forge/db':
specifier: workspace:*
version: link:../db
+ '@forge/utils':
+ specifier: workspace:*
+ version: link:../utils
'@t3-oss/env-nextjs':
specifier: ^0.13.10
version: 0.13.10(typescript@5.9.3)(zod@4.3.6)
@@ -1212,6 +1230,58 @@ importers:
specifier: 'catalog:'
version: 5.9.3
+ packages/hackathon:
+ dependencies:
+ '@forge/api':
+ specifier: workspace:*
+ version: link:../api
+ '@forge/auth':
+ specifier: workspace:*
+ version: link:../auth
+ '@forge/consts':
+ specifier: workspace:*
+ version: link:../consts
+ '@forge/validators':
+ specifier: workspace:*
+ version: link:../validators
+ '@tanstack/react-query':
+ specifier: 'catalog:'
+ version: 5.90.21(react@19.2.4)
+ '@trpc/client':
+ specifier: 'catalog:'
+ version: 11.10.0(@trpc/server@11.10.0(typescript@5.9.3))(typescript@5.9.3)
+ superjson:
+ specifier: 2.2.6
+ version: 2.2.6
+ zod:
+ specifier: 'catalog:'
+ version: 4.3.6
+ devDependencies:
+ '@forge/eslint-config':
+ specifier: workspace:*
+ version: link:../../tooling/eslint
+ '@forge/prettier-config':
+ specifier: workspace:*
+ version: link:../../tooling/prettier
+ '@forge/tsconfig':
+ specifier: workspace:*
+ version: link:../../tooling/typescript
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ eslint:
+ specifier: 'catalog:'
+ version: 10.0.2(jiti@2.6.1)
+ prettier:
+ specifier: 'catalog:'
+ version: 3.8.1
+ react:
+ specifier: ^19.2.4
+ version: 19.2.4
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+
packages/ui:
dependencies:
'@hookform/resolvers':
@@ -1360,9 +1430,6 @@ importers:
specifier: ^0.0.1
version: 0.0.1
devDependencies:
- '@forge/auth':
- specifier: workspace:*
- version: link:../auth
'@forge/consts':
specifier: workspace:*
version: link:../consts
@@ -1396,6 +1463,9 @@ importers:
packages/validators:
dependencies:
+ '@forge/consts':
+ specifier: workspace:*
+ version: link:../consts
minimatch:
specifier: ^10.2.4
version: 10.2.4
diff --git a/turbo.json b/turbo.json
index 85243969d..9c777866f 100644
--- a/turbo.json
+++ b/turbo.json
@@ -62,7 +62,9 @@
"DISCORD_DAILY_ANIMAL_WEBHOOK_URL",
"DISCORD_LEETCODE_DAILY_WEBHOOK_URL",
"DISCORD_WEATHER_API_KEY",
- "BLADE_URL"
+ "BLADE_URL",
+ "NEXT_PUBLIC_BLADE_URL",
+ "BLOOMKNIGHTS_URL"
],
"globalPassThroughEnv": ["NODE_ENV", "CI", "npm_lifecycle_event"]
}