diff --git a/apps/app-portal/.env.example b/apps/app-portal/.env.example
index 875492fb..c69e8dcb 100644
--- a/apps/app-portal/.env.example
+++ b/apps/app-portal/.env.example
@@ -6,5 +6,26 @@ BEEHIIV_API_KEY=
GOOGLE_CLOUD_PROJECT_ID=
GOOGLE_CLOUD_STORAGE_RESUME_BUCKET=
GOOGLE_CLOUD_STORAGE_RESUME_BUCKET_TEST=
-GOOGLE_CLOUD_PRIVATE_KEY=
-GOOGLE_CLOUD_EMAIL=
\ No newline at end of file
+GOOGLE_CLOUD_PRIVATE_KEY=
+GOOGLE_CLOUD_EMAIL=
+
+# --- MongoDB (see src/lib/db.ts) ---
+# Dev and prod share one Atlas cluster; collections get a `_test` suffix outside
+# production (see resolveCollectionName in src/lib/db.ts), so this is safe to point
+# at the same cluster used in production.
+MONGO_PROD_CONNECTION_STRING=
+MONGO_SERVER_DBNAME=
+
+# --- NextAuth (see src/lib/auth/config.ts) ---
+# Generate with: openssl rand -base64 32
+NEXTAUTH_SECRET=
+# Base URL of this app. Required in production — used to build absolute URLs in
+# outgoing emails (see src/lib/auth/email-transport.ts) and by the auth middleware.
+NEXTAUTH_URL=http://localhost:3000/auth
+
+# --- Outgoing email (magic-link sign-in, see src/lib/auth/email-transport.ts) ---
+EMAIL_SERVER_HOST=
+EMAIL_SERVER_PORT=
+EMAIL_SERVER_USER=
+EMAIL_SERVER_PASSWORD=
+EMAIL_FROM=
\ No newline at end of file
diff --git a/apps/app-portal/scripts/seed.ts b/apps/app-portal/scripts/seed.ts
index 1b693cef..828f82df 100644
--- a/apps/app-portal/scripts/seed.ts
+++ b/apps/app-portal/scripts/seed.ts
@@ -5,6 +5,7 @@
*
* Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root):
* yarn seed
+ * yarn seed --dry-run — validate and print, write nothing, connect to nothing
*
* Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via
* `node --env-file=.env`) — this always points at the shared Atlas cluster.
@@ -19,6 +20,7 @@
* app enums in src/lib/types/user.ts.
*/
import { getDb, resolveCollectionName } from "@/lib/db";
+import { APPLICATION_SECTIONS } from "@/lib/application/questions";
const TEST_COLLECTION_NAME = "applicant_data_test";
const COLLECTION = resolveCollectionName("applicant_data");
@@ -348,17 +350,87 @@ const ROWS: Row[] = [
],
];
-// Maps the seed table's free-text `year` to the real `year_of_study`
-// question's enum option values (src/lib/application/questions.ts).
-const YEAR_OF_STUDY_MAP: Record = {
- Junior: "third",
- Senior: "fourth",
- Graduate: "graduate",
+// Unmapped schools fall through to the question's "other" option, with the raw
+// name in `school_other`.
+const SCHOOL_MAP: Record = {
+ "Northeastern University": "northeastern_university",
+ MIT: "mit",
+ Harvard: "harvard_university",
+ "Boston University": "boston_university",
+};
+
+// The seed table's free-text `year` spans two real questions.
+const EDUCATION_MAP: Record = {
+ Junior: { level: "undergraduate", year: "3rd_year" },
+ Senior: { level: "undergraduate", year: "4th_year" },
+ Graduate: { level: "graduate", year: "1st_year" },
};
const HACKATHON_OPTIONS = ["0", "1-2", "3-5", "6+"];
-const INTEREST_OPTIONS = ["web", "mobile", "ai", "hardware", "design", "other"];
-const TSHIRT_OPTIONS = ["xs", "s", "m", "l", "xl"];
+const CS_CLASS_OPTIONS = ["0", "1-2", "3-5", "6+"];
+const WORKSHOP_OPTIONS = [
+ "mobile",
+ "web",
+ "design",
+ "backend",
+ "frontend",
+ "data_science",
+ "cybersecurity",
+ "ai_ml",
+ "product_management",
+ "entrepreneurship",
+];
+const IDENTITIES = [
+ { pronouns: "she/her", gender: "female" },
+ { pronouns: "he/him", gender: "male" },
+ { pronouns: "they/them", gender: "non_binary" },
+ { pronouns: "she/they", gender: "genderqueer" },
+ { pronouns: "he/him", gender: "prefer_not_to_say" },
+ { pronouns: "they/them", gender: "unlisted" },
+];
+const RACE_OPTIONS = [
+ "indigenous_american_or_alaska_native",
+ "asian",
+ "black_or_african_american",
+ "hispanic_or_latinx",
+ "native_hawaiian_or_pacific_islander",
+ "white",
+ "unlisted",
+ "prefer_not_to_say",
+];
+const LGBTQ_OPTIONS = ["yes", "no", "unsure", "prefer_not_to_say"];
+const REFERRAL_OPTIONS = [
+ "facebook",
+ "instagram",
+ "linkedin",
+ "twitter",
+ "tiktok",
+ "hbp_email_newsletter",
+ "word_of_mouth",
+ "hbp_outreach_events",
+ "school_communications",
+ "other_organization",
+ "other",
+];
+const HOMETOWNS = [
+ "Boston, MA",
+ "Providence, RI",
+ "Portland, ME",
+ "Hartford, CT",
+ "Nashua, NH",
+];
+const MAJORS = [
+ "Computer Science",
+ "Computer Science and Design",
+ "Data Science",
+ "Electrical Engineering",
+ "Mathematics",
+];
+
+// The application's own `tshirt_size` question allows 2XL; the RSVP payload schema
+// (src/lib/status/rsvp.ts) stops at XL. Kept separate so both match their writer.
+const TSHIRT_SIZES = ["xs", "s", "m", "l", "xl", "2xl"];
+const RSVP_TSHIRT_SIZES = ["xs", "s", "m", "l", "xl"];
// A couple of entries deliberately contain a comma/quote so the CSV export's
// escaping logic has real data to exercise during manual verification.
@@ -384,32 +456,61 @@ function toDoc(row: Row, index: number) {
appSubmissionTime,
] = row;
+ const schoolValue = SCHOOL_MAP[school] ?? "other";
+ const education = EDUCATION_MAP[year] ?? EDUCATION_MAP.Graduate;
+ const identity = IDENTITIES[index % IDENTITIES.length];
+
// Keyed by the real application question ids (questions.ts), not
// ad hoc names — otherwise seed data silently diverges from what the
// real form (and the CSV export/detail page built on top of it) expects.
const applicationResponses: Record = {
- legal_name: `${firstName} ${lastName}`,
- email,
- university: school,
- year_of_study: YEAR_OF_STUDY_MAP[year] ?? "graduate",
+ first_name: firstName,
+ last_name: lastName,
+ hometown: HOMETOWNS[index % HOMETOWNS.length],
+ pronouns: identity.pronouns,
+ gender: identity.gender,
+ race:
+ index % 3 === 0
+ ? [RACE_OPTIONS[index % RACE_OPTIONS.length]]
+ : [
+ RACE_OPTIONS[index % RACE_OPTIONS.length],
+ RACE_OPTIONS[(index + 3) % RACE_OPTIONS.length],
+ ],
+ lgbtq: LGBTQ_OPTIONS[index % LGBTQ_OPTIONS.length],
+ school: schoolValue,
+ education_level: education.level,
+ education_year: education.year,
+ major: MAJORS[index % MAJORS.length],
+ tshirt_size: TSHIRT_SIZES[index % TSHIRT_SIZES.length],
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
- interests:
+ cs_classes: CS_CLASS_OPTIONS[(index + 1) % CS_CLASS_OPTIONS.length],
+ workshop_interests:
index % 2 === 0
- ? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
+ ? [WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length]]
: [
- INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
- INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
+ WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length],
+ WORKSHOP_OPTIONS[(index + 2) % WORKSHOP_OPTIONS.length],
],
- why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
+ goals_long_answer: `${firstName} wants to ship a project end to end and find people to keep building with afterwards.`,
+ passion_long_answer: `${firstName} could talk for hours about why good developer tooling changes what teams are willing to attempt.`,
+ hackathon_reflection: `${firstName} has been to a few hackathons and wants more time for workshops and less time fighting deploys.`,
+ premade_team: "no",
+ referral_source: [REFERRAL_OPTIONS[index % REFERRAL_OPTIONS.length]],
};
+ if (schoolValue === "other") {
+ applicationResponses.school_other = school;
+ }
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
+ if (index % 4 === 0) {
+ applicationResponses.premade_team = "yes";
+ applicationResponses.team_captain_info = `${firstName} ${lastName}, ${email}`;
+ }
if (applicationStatus === "submitted" && index % 4 === 0) {
- // Placeholder uploadId — no real upload pipeline exists yet (separate,
- // in-flight uploads ticket); this just gives the detail page's resume
- // row something to render during manual verification.
+ // Placeholder ids with no matching row in the uploads collection.
applicationResponses.resume = `seed-upload-${index}`;
+ applicationResponses.vaccination_card = `seed-vax-${index}`;
}
// Only applicants who actually reached the RSVP step have post-acceptance
@@ -417,10 +518,12 @@ function toDoc(row: Row, index: number) {
const postAcceptanceResponses =
rsvpStatus === "confirmed" || rsvpStatus === "not-attending"
? {
- attending: rsvpStatus === "confirmed" ? "yes" : "no",
+ // saveRsvp writes the parsed payload verbatim, so `attending` holds the
+ // rsvpSchema enum value ("confirmed"/"unconfirmed"), not a yes/no string.
+ attending: rsvpStatus === "confirmed" ? "confirmed" : "unconfirmed",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
- tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
+ tshirtSize: RSVP_TSHIRT_SIZES[index % RSVP_TSHIRT_SIZES.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
@@ -441,8 +544,38 @@ function toDoc(row: Row, index: number) {
};
}
+function validate(docs: ReturnType[]): string[] {
+ const questions = new Map(
+ APPLICATION_SECTIONS.flatMap((section) =>
+ section.questions.map((q) => [q.id, q] as const),
+ ),
+ );
+ const errors: string[] = [];
+
+ for (const doc of docs) {
+ for (const [id, value] of Object.entries(doc.applicationResponses)) {
+ const question = questions.get(id);
+ if (!question) {
+ errors.push(`${doc.email}: no question with id "${id}"`);
+ continue;
+ }
+ if (!question.options) continue;
+ const allowed = new Set(question.options.map((o) => o.value));
+ for (const v of Array.isArray(value) ? value : [value]) {
+ if (!allowed.has(v)) {
+ errors.push(`${doc.email}: "${v}" is not an option of "${id}"`);
+ }
+ }
+ }
+ }
+
+ return errors;
+}
+
async function main() {
- if (COLLECTION !== TEST_COLLECTION_NAME) {
+ const dryRun = process.argv.includes("--dry-run");
+
+ if (!dryRun && COLLECTION !== TEST_COLLECTION_NAME) {
console.error(
`Refusing to seed: resolved collection is "${COLLECTION}", not ` +
`"${TEST_COLLECTION_NAME}". This script is destructive and only ` +
@@ -451,11 +584,28 @@ async function main() {
process.exit(1);
}
+ const docs = ROWS.map((row, index) => toDoc(row, index));
+
+ const errors = validate(docs);
+ if (errors.length > 0) {
+ console.error("Seed data does not match the questions in questions.ts:");
+ errors.forEach((e) => console.error(` - ${e}`));
+ process.exit(1);
+ }
+
+ if (dryRun) {
+ console.log(
+ `Dry run: ${docs.length} applicants validated against ` +
+ `${APPLICATION_SECTIONS.length} sections. Target would be "${COLLECTION}".`,
+ );
+ console.log(JSON.stringify(docs[0], null, 2));
+ process.exit(0);
+ }
+
const db = await getDb();
const col = db.collection(COLLECTION);
await col.deleteMany({});
- const docs = ROWS.map((row, index) => toDoc(row, index));
await col.insertMany(docs);
console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
diff --git a/apps/app-portal/scripts/setup-indexes.ts b/apps/app-portal/scripts/setup-indexes.ts
index a8153f32..9cd65279 100644
--- a/apps/app-portal/scripts/setup-indexes.ts
+++ b/apps/app-portal/scripts/setup-indexes.ts
@@ -43,7 +43,9 @@ export async function ensureApplicantIndexes(col: Collection): Promise {
export async function ensureUploadsCollection(): Promise {
const db = await getDb();
- const existing = await db.listCollections({ name: UPLOADS_COLLECTION }).toArray();
+ const existing = await db
+ .listCollections({ name: UPLOADS_COLLECTION })
+ .toArray();
if (existing.length === 0) {
await db.createCollection(UPLOADS_COLLECTION);
@@ -60,7 +62,7 @@ export async function ensureUploadIndexes(col: Collection): Promise {
async function main() {
const db = await getDb();
const col = db.collection(APPLICANT_COLLECTION);
-
+
await ensureApplicantIndexes(col);
await ensureUploadsCollection();
await ensureUploadIndexes(db.collection(UPLOADS_COLLECTION));
diff --git a/apps/app-portal/src/app/(admin)/admin/applicants/[id]/page.tsx b/apps/app-portal/src/app/(admin)/admin/applicants/[id]/page.tsx
index 9da8daeb..b45a0ff9 100644
--- a/apps/app-portal/src/app/(admin)/admin/applicants/[id]/page.tsx
+++ b/apps/app-portal/src/app/(admin)/admin/applicants/[id]/page.tsx
@@ -54,6 +54,7 @@ export default async function ApplicantDetailPage({
diff --git a/apps/app-portal/src/app/(admin)/admin/loading.tsx b/apps/app-portal/src/app/(admin)/admin/loading.tsx
new file mode 100644
index 00000000..a3765ae4
--- /dev/null
+++ b/apps/app-portal/src/app/(admin)/admin/loading.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function AdminLoading(): JSX.Element {
+ return (
+
+
+
+
+
+
+
+ {["a", "b", "c"].map((key) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/app-portal/src/app/(admin)/admin/page.tsx b/apps/app-portal/src/app/(admin)/admin/page.tsx
index ff8a0e20..99cabe45 100644
--- a/apps/app-portal/src/app/(admin)/admin/page.tsx
+++ b/apps/app-portal/src/app/(admin)/admin/page.tsx
@@ -42,7 +42,6 @@ export default function AdminPage() {
Open
diff --git a/apps/app-portal/src/app/(admin)/admin/settings/loading.tsx b/apps/app-portal/src/app/(admin)/admin/settings/loading.tsx
new file mode 100644
index 00000000..5a8e2420
--- /dev/null
+++ b/apps/app-portal/src/app/(admin)/admin/settings/loading.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function SettingsLoading(): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/app-portal/src/app/(admin)/admin/settings/page.tsx b/apps/app-portal/src/app/(admin)/admin/settings/page.tsx
index 0b2c102d..3c6f91d6 100644
--- a/apps/app-portal/src/app/(admin)/admin/settings/page.tsx
+++ b/apps/app-portal/src/app/(admin)/admin/settings/page.tsx
@@ -1,33 +1,29 @@
import React from "react";
-import { headers } from "next/headers";
import ShowDecisionToggle from "@/components/admin/ShowDecisionToggle";
import DateControls from "@/components/admin/DateControls";
import FormConfigEditor from "@/components/admin/FormConfigEditor";
+import { getSingleton } from "@/lib/admin/singleton-service";
+import { SingletonKey } from "@/lib/types/singleton";
-async function fetchJson(url: string, cookie: string) {
- const res = await fetch(url, {
- cache: "no-store",
- headers: { cookie },
- });
-
- if (!res.ok) {
- return { value: null };
- }
-
- return res.json();
-}
+export const dynamic = "force-dynamic";
export default async function Page() {
- const cookie = headers().get("cookie") ?? "";
-
- const [openData, closeData, confirmData, showDecisionData] =
+ // Read singletons directly (same pattern as admin/stats and admin/applicants) instead of
+ // self-fetching our own API routes over HTTP — that previously relied on a hardcoded
+ // http://localhost:3000 origin, which breaks in every deployed environment.
+ const [openValue, closeValue, confirmValue, showDecisionValue] =
await Promise.all([
- fetchJson("http://localhost:3000/api/v1/dates/registration-open", cookie),
- fetchJson("http://localhost:3000/api/v1/dates/registration-closed", cookie),
- fetchJson("http://localhost:3000/api/v1/dates/confirm-by", cookie),
- fetchJson("http://localhost:3000/api/v1/show-decision", cookie),
+ getSingleton(SingletonKey.RegistrationOpen),
+ getSingleton(SingletonKey.RegistrationClosed),
+ getSingleton(SingletonKey.ConfirmBy),
+ getSingleton(SingletonKey.ShowDecision),
]);
+ const openData = { value: openValue ?? undefined };
+ const closeData = { value: closeValue ?? undefined };
+ const confirmData = { value: confirmValue ?? undefined };
+ const showDecisionData = { value: showDecisionValue ?? false };
+
return (
Configure Portal Settings
diff --git a/apps/app-portal/src/app/(admin)/layout.tsx b/apps/app-portal/src/app/(admin)/layout.tsx
index 31336bcd..fec51c73 100644
--- a/apps/app-portal/src/app/(admin)/layout.tsx
+++ b/apps/app-portal/src/app/(admin)/layout.tsx
@@ -1,6 +1,6 @@
import React from "react";
-import UserMenu from "@/components/auth/UserMenu";
import AdminSidebar from "@/components/admin/AdminSidebar";
+import AdminContentArea from "@/components/admin/AdminContentArea";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth/session";
@@ -24,15 +24,7 @@ export default async function AdminLayout({
return (
-
-
-
-
- {children}
-
+
{children}
);
}
diff --git a/apps/app-portal/src/app/(applicant)/application/loading.tsx b/apps/app-portal/src/app/(applicant)/application/loading.tsx
new file mode 100644
index 00000000..30098388
--- /dev/null
+++ b/apps/app-portal/src/app/(applicant)/application/loading.tsx
@@ -0,0 +1,16 @@
+import React from "react";
+
+export default function Loading(): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/app-portal/src/app/(applicant)/dashboard/page.tsx b/apps/app-portal/src/app/(applicant)/dashboard/page.tsx
index 885f76e6..fa002169 100644
--- a/apps/app-portal/src/app/(applicant)/dashboard/page.tsx
+++ b/apps/app-portal/src/app/(applicant)/dashboard/page.tsx
@@ -20,12 +20,14 @@ export default async function DashboardPage(): Promise
{
showDecision: new Date().toISOString(),
confirmBy: new Date().toISOString(),
};
+ let completionPercent = 0;
try {
const res = await fetchPortalStatus();
branch = res.branch;
status = res.status;
decisionDates = res.decisionDates;
+ completionPercent = res.completionPercent;
} catch (err) {
// If fetch fails, render a simple error view instead of crashing the page.
return (
@@ -53,7 +55,9 @@ export default async function DashboardPage(): Promise {
case "pre-registration":
return ;
case "in-progress":
- return ;
+ return (
+
+ );
case "submitted":
return ;
case "admitted":
diff --git a/apps/app-portal/src/app/(applicant)/layout.tsx b/apps/app-portal/src/app/(applicant)/layout.tsx
index 06c42d6e..62972464 100644
--- a/apps/app-portal/src/app/(applicant)/layout.tsx
+++ b/apps/app-portal/src/app/(applicant)/layout.tsx
@@ -3,15 +3,20 @@ import Link from "next/link";
import UserMenu from "@/components/auth/UserMenu";
import Image from "next/image";
import icon from "@/app/icon.ico";
+import { getSession } from "@/lib/auth/session";
export const metadata = {
title: "Applicant Portal",
};
-export default function ApplicantLayout({
+export default async function ApplicantLayout({
children,
}: {
children: React.ReactNode;
-}): JSX.Element {
+}): Promise {
+ const session = await getSession();
+ const isAdmin = !!(session?.user as { isAdmin?: boolean } | undefined)
+ ?.isAdmin;
+
return (
@@ -46,12 +51,14 @@ export default function ApplicantLayout({
-
- Application
-
+ {isAdmin && (
+
+ Admin View
+
+ )}
diff --git a/apps/app-portal/src/app/(landing)/page.tsx b/apps/app-portal/src/app/(landing)/page.tsx
index a6c3f9d9..7a7a730f 100644
--- a/apps/app-portal/src/app/(landing)/page.tsx
+++ b/apps/app-portal/src/app/(landing)/page.tsx
@@ -3,9 +3,17 @@ import Link from "next/link";
import Image from "next/image";
import icon from "@/app/icon.ico";
import TiledBackground from "@/components/ui/tiled-background";
+import {redirect} from "next/navigation";
+import {isAdminEmail} from "@/lib/auth/roles.ts";
+import {getSession} from "@/lib/auth/session.ts";
+
+export default async function Page(): Promise {
+
+ const session = await getSession();
+ if (session?.user) {
+ redirect(isAdminEmail(session.user.email) ? "/admin" : "/dashboard");
+ }
-//TODO: update to redirect authed users to /dashboard
-export default function Page(): JSX.Element {
return (
diff --git a/apps/app-portal/src/app/api/joinMailingList/route.ts b/apps/app-portal/src/app/api/joinMailingList/route.ts
index eb9a2f99..aa69b0de 100644
--- a/apps/app-portal/src/app/api/joinMailingList/route.ts
+++ b/apps/app-portal/src/app/api/joinMailingList/route.ts
@@ -1,31 +1,56 @@
import { NextResponse, NextRequest } from "next/server";
+import { z } from "zod";
const PUBLICATION = "pub_e065c094-6f4b-4e8d-91d2-e39de7201fd4";
+const joinMailingListSchema = z.object({
+ email: z.string().email(),
+ reactivate_existing: z.boolean().optional(),
+});
+
export async function POST(req: NextRequest) {
- const body = await req.json();
- const airtableUrl = `https://api.beehiiv.com/v2/publications/${PUBLICATION}/subscriptions`;
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const parsed = joinMailingListSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "A valid email is required" },
+ { status: 400 },
+ );
+ }
+
+ const beehiivUrl = `https://api.beehiiv.com/v2/publications/${PUBLICATION}/subscriptions`;
try {
- const response = await fetch(`${airtableUrl}`, {
+ const response = await fetch(beehiivUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`,
"Content-Type": "application/json",
},
- body: JSON.stringify(body),
+ body: JSON.stringify(parsed.data),
});
if (!response.ok) {
- throw new Error("API request failed");
+ throw new Error(
+ `Beehiiv API request failed with status ${response.status}`,
+ );
}
return NextResponse.json({
success: "Successfully subscribed to mailing list",
});
} catch (err) {
+ // Log the real error server-side, but don't leak internal details to the client.
+ // eslint-disable-next-line no-console -- intentional server-side error log
+ console.error("joinMailingList: Beehiiv request failed:", err);
return NextResponse.json(
- { error: `Request to post email to beehiiv failed ${err}` },
- { status: 500 },
+ { error: "Could not subscribe to the mailing list. Please try again." },
+ { status: 502 },
);
}
}
diff --git a/apps/app-portal/src/app/api/v1/admin/form-config/route.ts b/apps/app-portal/src/app/api/v1/admin/form-config/route.ts
index 43b4f4f1..4822444f 100644
--- a/apps/app-portal/src/app/api/v1/admin/form-config/route.ts
+++ b/apps/app-portal/src/app/api/v1/admin/form-config/route.ts
@@ -5,25 +5,16 @@ import {
updateFormConfig,
} from "@/lib/admin/form-config-service";
-// type QuestionType = "text" | "textarea";
-
-// type Question = {
-// id: string;
-// label: string;
-// type: QuestionType;
-// };
-
-// type Section = {
-// id: string;
-// title: string;
-// questions: Question[];
-// };
-
-// // type FormConfig = {
-// // sections: Section[];
-// // };
-
export async function GET() {
+ try {
+ await requireAdmin();
+ } catch (error) {
+ if (error instanceof Error && error.message === "Forbidden") {
+ return NextResponse.json({ error: error.message }, { status: 403 });
+ }
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
const config = await getFormConfig();
return NextResponse.json(config);
diff --git a/apps/app-portal/src/app/api/v1/applicants/[id]/route.ts b/apps/app-portal/src/app/api/v1/applicants/[id]/route.ts
index e75524b4..09090bbf 100644
--- a/apps/app-portal/src/app/api/v1/applicants/[id]/route.ts
+++ b/apps/app-portal/src/app/api/v1/applicants/[id]/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth/guards";
import {
+ InvalidApplicantStateError,
InvalidApplicantUpdateError,
getApplicant,
updateApplicant,
@@ -55,6 +56,9 @@ export async function POST(
if (err instanceof InvalidApplicantUpdateError) {
return NextResponse.json({ error: err.message }, { status: 400 });
}
+ if (err instanceof InvalidApplicantStateError) {
+ return NextResponse.json({ error: err.message }, { status: 409 });
+ }
throw err;
}
}
diff --git a/apps/app-portal/src/app/api/v1/dates/confirm-by/route.ts b/apps/app-portal/src/app/api/v1/dates/confirm-by/route.ts
index f08982de..219e6643 100644
--- a/apps/app-portal/src/app/api/v1/dates/confirm-by/route.ts
+++ b/apps/app-portal/src/app/api/v1/dates/confirm-by/route.ts
@@ -1,43 +1,6 @@
-import { NextResponse } from "next/server";
+import { createDateSingletonHandlers } from "@/lib/admin/date-route-handlers";
import { SingletonKey } from "@/lib/types/singleton";
-import { requireAdmin } from "@/lib/auth/guards";
-import {
- getSingleton,
- setSingleton,
- validateDateSingleton,
-} from "@/lib/admin/singleton-service";
-export async function GET() {
- const value = await getSingleton(SingletonKey.ConfirmBy);
-
- return NextResponse.json({
- value,
- });
-}
-
-export async function POST(req: Request) {
- const admin = await requireAdmin();
-
- if (!admin.email) {
- return NextResponse.json(
- { error: "Admin email is required." },
- { status: 400 },
- );
- }
-
- const body = await req.json();
- const { value } = body;
-
- const result = validateDateSingleton(value);
-
- if (!result.ok) {
- return NextResponse.json({ error: result.error }, { status: 400 });
- }
-
- await setSingleton(SingletonKey.ConfirmBy, result.value, admin.email);
-
- return NextResponse.json({
- ok: true,
- value: result.value,
- });
-}
+export const { GET, POST } = createDateSingletonHandlers(
+ SingletonKey.ConfirmBy,
+);
diff --git a/apps/app-portal/src/app/api/v1/dates/registration-closed/route.ts b/apps/app-portal/src/app/api/v1/dates/registration-closed/route.ts
index 2c2ff590..852ecf3e 100644
--- a/apps/app-portal/src/app/api/v1/dates/registration-closed/route.ts
+++ b/apps/app-portal/src/app/api/v1/dates/registration-closed/route.ts
@@ -1,47 +1,6 @@
-import { NextResponse } from "next/server";
+import { createDateSingletonHandlers } from "@/lib/admin/date-route-handlers";
import { SingletonKey } from "@/lib/types/singleton";
-import { requireAdmin } from "@/lib/auth/guards";
-import {
- getSingleton,
- setSingleton,
- validateDateSingleton,
-} from "@/lib/admin/singleton-service";
-export async function GET() {
- const value = await getSingleton(SingletonKey.RegistrationClosed);
-
- return NextResponse.json({
- value,
- });
-}
-
-export async function POST(req: Request) {
- const admin = await requireAdmin();
-
- if (!admin.email) {
- return NextResponse.json(
- { error: "Admin email is required." },
- { status: 400 },
- );
- }
-
- const body = await req.json();
- const { value } = body;
-
- const result = validateDateSingleton(value);
-
- if (!result.ok) {
- return NextResponse.json({ error: result.error }, { status: 400 });
- }
-
- await setSingleton(
- SingletonKey.RegistrationClosed,
- result.value,
- admin.email,
- );
-
- return NextResponse.json({
- ok: true,
- value: result.value,
- });
-}
+export const { GET, POST } = createDateSingletonHandlers(
+ SingletonKey.RegistrationClosed,
+);
diff --git a/apps/app-portal/src/app/api/v1/dates/registration-open/route.ts b/apps/app-portal/src/app/api/v1/dates/registration-open/route.ts
index 0ce1e0ea..bc890098 100644
--- a/apps/app-portal/src/app/api/v1/dates/registration-open/route.ts
+++ b/apps/app-portal/src/app/api/v1/dates/registration-open/route.ts
@@ -1,43 +1,6 @@
-import { NextResponse } from "next/server";
+import { createDateSingletonHandlers } from "@/lib/admin/date-route-handlers";
import { SingletonKey } from "@/lib/types/singleton";
-import { requireAdmin } from "@/lib/auth/guards";
-import {
- getSingleton,
- setSingleton,
- validateDateSingleton,
-} from "@/lib/admin/singleton-service";
-export async function GET() {
- const value = await getSingleton(SingletonKey.RegistrationOpen);
-
- return NextResponse.json({
- value,
- });
-}
-
-export async function POST(req: Request) {
- const admin = await requireAdmin();
-
- if (!admin.email) {
- return NextResponse.json(
- { error: "Admin email is required." },
- { status: 400 },
- );
- }
-
- const body = await req.json();
- const { value } = body;
-
- const result = validateDateSingleton(value);
-
- if (!result.ok) {
- return NextResponse.json({ error: result.error }, { status: 400 });
- }
-
- await setSingleton(SingletonKey.RegistrationOpen, result.value, admin.email);
-
- return NextResponse.json({
- ok: true,
- value: result.value,
- });
-}
+export const { GET, POST } = createDateSingletonHandlers(
+ SingletonKey.RegistrationOpen,
+);
diff --git a/apps/app-portal/src/app/api/v1/export/applications/route.ts b/apps/app-portal/src/app/api/v1/export/applications/route.ts
index 984dfba9..ca68d57e 100644
--- a/apps/app-portal/src/app/api/v1/export/applications/route.ts
+++ b/apps/app-portal/src/app/api/v1/export/applications/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth/guards";
-import { getApplicantCursor } from "@/lib/applicants/service";
+import { getApplicantCursor, getApplicantName } from "@/lib/applicants/service";
import { toCsv, responseField, type CsvColumn } from "@/lib/applicants/csv";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";
import type { ApplicantDoc } from "@/lib/applicants/types";
@@ -21,7 +21,7 @@ const COLUMNS: CsvColumn
[] = [
{ header: "Email", value: (d) => d.email },
{
header: "Name",
- value: (d) => responseField(d.applicationResponses, "legal_name"),
+ value: (d) => getApplicantName(d.applicationResponses) ?? "",
},
{ header: "Status", value: (d) => d.applicationStatus },
{ header: "Decision", value: (d) => d.decisionStatus ?? "" },
diff --git a/apps/app-portal/src/app/api/v1/export/post-acceptance/route.ts b/apps/app-portal/src/app/api/v1/export/post-acceptance/route.ts
index a0281239..abccc8bb 100644
--- a/apps/app-portal/src/app/api/v1/export/post-acceptance/route.ts
+++ b/apps/app-portal/src/app/api/v1/export/post-acceptance/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth/guards";
-import { getApplicantCursor } from "@/lib/applicants/service";
+import { getApplicantCursor, getApplicantName } from "@/lib/applicants/service";
import { toCsv, responseField, type CsvColumn } from "@/lib/applicants/csv";
import type { ApplicantDoc } from "@/lib/applicants/types";
@@ -11,7 +11,7 @@ const COLUMNS: CsvColumn[] = [
{ header: "Email", value: (d) => d.email },
{
header: "Name",
- value: (d) => responseField(d.applicationResponses, "legal_name"),
+ value: (d) => getApplicantName(d.applicationResponses) ?? "",
},
{ header: "Status", value: (d) => d.applicationStatus },
{ header: "Decision", value: (d) => d.decisionStatus ?? "" },
diff --git a/apps/app-portal/src/app/api/v1/post-acceptance/route.ts b/apps/app-portal/src/app/api/v1/post-acceptance/route.ts
index 8fbae780..21fe1450 100644
--- a/apps/app-portal/src/app/api/v1/post-acceptance/route.ts
+++ b/apps/app-portal/src/app/api/v1/post-acceptance/route.ts
@@ -6,8 +6,13 @@ import { ZodError } from "zod";
export async function POST(request: Request) {
try {
const user = await requireUser();
+ const userId = (user as { id?: string }).id;
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
const body = await request.json();
- await saveRsvp((user as { id?: string }).id ?? "", body);
+ await saveRsvp(userId, body);
return NextResponse.json({ ok: true });
} catch (error) {
@@ -23,7 +28,10 @@ export async function POST(request: Request) {
}
if (error instanceof StatusError) {
- return NextResponse.json({ error: error.message }, { status: error.status });
+ return NextResponse.json(
+ { error: error.message },
+ { status: error.status },
+ );
}
return NextResponse.json(
diff --git a/apps/app-portal/src/app/api/v1/registration/route.ts b/apps/app-portal/src/app/api/v1/registration/route.ts
index 7b09d1fd..7567f399 100644
--- a/apps/app-portal/src/app/api/v1/registration/route.ts
+++ b/apps/app-portal/src/app/api/v1/registration/route.ts
@@ -1,4 +1,5 @@
import { type NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
import { requireUser } from "@/lib/auth/guards";
import {
@@ -15,6 +16,25 @@ import {
} from "@/lib/application/service";
import type { ApplicationResponses } from "@/lib/application/types";
+// Draft saves skip the full per-question schema (drafts are allowed to be incomplete —
+// submit() is what enforces required/enum/word-count rules against the live form config),
+// but the request body still needs *some* shape validation so garbage (wrong types,
+// nested objects, non-string keys) can't get written straight into Mongo.
+const draftBodySchema = z.object({
+ responses: z.record(
+ z.string(),
+ z.union([z.string(), z.array(z.string()), z.null()]),
+ ),
+});
+
+async function parseJsonBody(req: NextRequest): Promise {
+ try {
+ return await req.json();
+ } catch {
+ throw new SyntaxError("Invalid JSON body");
+ }
+}
+
async function getSessionUserId(): Promise {
try {
const user = await requireUser();
@@ -48,11 +68,25 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- const body = (await req.json()) as { responses: ApplicationResponses };
try {
- const draft = await saveDraft(userId, body.responses);
+ const rawBody = await parseJsonBody(req);
+ const parsedBody = draftBodySchema.safeParse(rawBody);
+ if (!parsedBody.success) {
+ return NextResponse.json(
+ { error: "Invalid request body" },
+ { status: 400 },
+ );
+ }
+
+ const draft = await saveDraft(
+ userId,
+ parsedBody.data.responses as ApplicationResponses,
+ );
return NextResponse.json({ ok: true, savedAt: draft.updatedAt });
} catch (err) {
+ if (err instanceof SyntaxError) {
+ return NextResponse.json({ error: err.message }, { status: 400 });
+ }
if (
err instanceof RegistrationNotOpenError ||
err instanceof RegistrationClosedError
@@ -69,11 +103,16 @@ export async function PUT(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- const body = (await req.json()) as { responses: ApplicationResponses };
try {
+ const body = (await parseJsonBody(req)) as {
+ responses: ApplicationResponses;
+ };
const result = await submit(userId, body.responses);
return NextResponse.json({ ok: true, submittedAt: result.submittedAt });
} catch (err) {
+ if (err instanceof SyntaxError) {
+ return NextResponse.json({ error: err.message }, { status: 400 });
+ }
if (err instanceof ValidationError) {
return NextResponse.json(
{ error: "Validation failed", issues: err.issues },
diff --git a/apps/app-portal/src/app/api/v1/show-decision/route.ts b/apps/app-portal/src/app/api/v1/show-decision/route.ts
index 52833e9a..1056a73c 100644
--- a/apps/app-portal/src/app/api/v1/show-decision/route.ts
+++ b/apps/app-portal/src/app/api/v1/show-decision/route.ts
@@ -27,13 +27,18 @@ export async function POST(req: Request) {
const body = await req.json();
if (typeof body.enabled !== "boolean") {
- return NextResponse.json({ error: "enabled must be a boolean" }, { status: 400 });
+ return NextResponse.json(
+ { error: "enabled must be a boolean" },
+ { status: 400 },
+ );
}
await setSingleton(
SingletonKey.ShowDecision,
body.enabled,
- (user as { id?: string; email?: string }).email ?? (user as { id?: string }).id ?? "unknown",
+ (user as { id?: string; email?: string }).email ??
+ (user as { id?: string }).id ??
+ "unknown",
);
return NextResponse.json({ ok: true, value: body.enabled });
diff --git a/apps/app-portal/src/app/api/v1/stats/route.ts b/apps/app-portal/src/app/api/v1/stats/route.ts
index d80733a3..7df98ea4 100644
--- a/apps/app-portal/src/app/api/v1/stats/route.ts
+++ b/apps/app-portal/src/app/api/v1/stats/route.ts
@@ -1,16 +1,25 @@
import { NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/auth/guards";
import { getStats } from "@/lib/stats/service";
export const dynamic = "force-dynamic";
// GET aggregate stats
-// TODO: gate with requireAdmin() once Ticket 1 ships its helpers.
export async function GET() {
try {
+ await requireAdmin();
const payload = await getStats();
return NextResponse.json(payload);
} catch (err) {
+ if (err instanceof Error && err.message === "Forbidden") {
+ return NextResponse.json({ error: err.message }, { status: 403 });
+ }
+
+ if (err instanceof Error && err.message === "Unauthorized") {
+ return NextResponse.json({ error: err.message }, { status: 401 });
+ }
+
return NextResponse.json(
{ error: `Failed to load stats: ${err}` },
{ status: 500 },
diff --git a/apps/app-portal/src/app/api/v1/status/route.ts b/apps/app-portal/src/app/api/v1/status/route.ts
index 84f80948..ea9398de 100644
--- a/apps/app-portal/src/app/api/v1/status/route.ts
+++ b/apps/app-portal/src/app/api/v1/status/route.ts
@@ -8,7 +8,10 @@ export async function GET() {
return NextResponse.json(await getPortalStatus());
} catch (error) {
if (error instanceof StatusError) {
- return NextResponse.json({ error: error.message }, { status: error.status });
+ return NextResponse.json(
+ { error: error.message },
+ { status: error.status },
+ );
}
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
diff --git a/apps/app-portal/src/app/api/v1/uploads/[id]/route.ts b/apps/app-portal/src/app/api/v1/uploads/[id]/route.ts
index a65c5046..56399cad 100644
--- a/apps/app-portal/src/app/api/v1/uploads/[id]/route.ts
+++ b/apps/app-portal/src/app/api/v1/uploads/[id]/route.ts
@@ -1,32 +1,46 @@
// GET --> returns signed download URL for an uploaded file
import { requireUser } from "@/lib/auth/guards";
-import { createSignedDownloadUrl, UploadNotFoundError } from "@/lib/uploads/service";
+import {
+ createSignedDownloadUrl,
+ UploadNotFoundError,
+} from "@/lib/uploads/service";
import { NextResponse } from "next/server";
-export async function GET(request: Request, { params }: {params: {id: string}}) {
+export async function GET(
+ request: Request,
+ { params }: { params: { id: string } },
+) {
const uploadId = params.id;
let user;
try {
user = await requireUser();
} catch {
- return NextResponse.json({ error: "Requester not allowed" }, { status: 403 });
+ return NextResponse.json(
+ { error: "Requester not allowed" },
+ { status: 403 },
+ );
}
- const requester = { userId: (user as { id: string }).id, isAdmin: !!(user as { isAdmin?: boolean }).isAdmin };
-
+ const requester = {
+ userId: (user as { id: string }).id,
+ isAdmin: !!(user as { isAdmin?: boolean }).isAdmin,
+ };
+
try {
- const res = await createSignedDownloadUrl({uploadId, requester});
+ const res = await createSignedDownloadUrl({ uploadId, requester });
if (res === null) {
- return NextResponse.json({ error: "Requester not allowed" }, { status: 403 });
+ return NextResponse.json(
+ { error: "Requester not allowed" },
+ { status: 403 },
+ );
}
return NextResponse.json(res);
-
} catch (err) {
if (err instanceof UploadNotFoundError) {
return NextResponse.json({ error: err.message }, { status: 404 });
}
return NextResponse.json({ error: "Unexpected error" }, { status: 500 });
}
-}
\ No newline at end of file
+}
diff --git a/apps/app-portal/src/app/api/v1/uploads/sign/route.ts b/apps/app-portal/src/app/api/v1/uploads/sign/route.ts
index 21936919..00bbf9c4 100644
--- a/apps/app-portal/src/app/api/v1/uploads/sign/route.ts
+++ b/apps/app-portal/src/app/api/v1/uploads/sign/route.ts
@@ -8,12 +8,14 @@ import {
import { NextResponse } from "next/server";
export async function POST(request: Request) {
-
let user;
try {
user = await requireUser();
} catch {
- return NextResponse.json({ error: "Requester not allowed" }, { status: 403 });
+ return NextResponse.json(
+ { error: "Requester not allowed" },
+ { status: 403 },
+ );
}
const userId = (user as { id: string }).id;
diff --git a/apps/app-portal/src/app/auth/signin/page.tsx b/apps/app-portal/src/app/auth/signin/page.tsx
index ac1c9ca7..28688a9d 100644
--- a/apps/app-portal/src/app/auth/signin/page.tsx
+++ b/apps/app-portal/src/app/auth/signin/page.tsx
@@ -3,14 +3,21 @@ import React from "react";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth/session";
import { SignInForm } from "@/components/auth/SignInForm";
-import {isAdminEmail} from "@/lib/auth/roles.ts";
+import { isAdminEmail } from "@/lib/auth/roles.ts";
-export default async function Page(): Promise {
+export default async function Page({
+ searchParams,
+}: {
+ searchParams: { callbackUrl?: string };
+}): Promise {
// read cookie - see if valid session in DB - if so, automatically redir user to logged in part
const session = await getSession();
if (session?.user) {
redirect(isAdminEmail(session.user.email) ? "/admin" : "/dashboard");
}
- return ;
+ // callbackUrl is only present when middleware bounced an unauthed user here
+ // from a protected route. The form uses it both to prompt "sign in first" and
+ // as the post-sign-in destination baked into the magic link.
+ return ;
}
diff --git a/apps/app-portal/src/app/error.tsx b/apps/app-portal/src/app/error.tsx
new file mode 100644
index 00000000..46de9552
--- /dev/null
+++ b/apps/app-portal/src/app/error.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import React from "react";
+
+export default function GlobalError({
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): JSX.Element {
+ return (
+
+
Something went wrong
+
+ An unexpected error occurred. Please try again.
+
+
reset()}
+ className="rounded-md bg-[#352A28] px-6 py-3 text-white text-sm font-medium hover:opacity-90 transition-opacity"
+ >
+ Try again
+
+
+ );
+}
diff --git a/apps/app-portal/src/app/uploads-demo/UploadsDemoClient.tsx b/apps/app-portal/src/app/uploads-demo/UploadsDemoClient.tsx
new file mode 100644
index 00000000..c9c3b93e
--- /dev/null
+++ b/apps/app-portal/src/app/uploads-demo/UploadsDemoClient.tsx
@@ -0,0 +1,42 @@
+"use client";
+// internal demo page that mounts so this ticket can be tested in isolation
+import FileUpload from "@/components/uploads/FileUpload";
+import React, { useState } from "react";
+
+export default function UploadsDemoClient(): JSX.Element {
+ const [uploadId, setUploadId] = useState(null);
+ const [fileName, setFileName] = useState(null);
+
+ return (
+
+
{
+ setUploadId(id);
+ setFileName(fileName);
+ }}
+ onUploadRemoved={() => {
+ setUploadId(null);
+ setFileName(null);
+ }}
+ />
+ {uploadId !== null && (
+
+
+ Upload ID: {uploadId}
+
+
+ File Name: {fileName}
+
+
{
+ // TODO: handle download URL response
+ }}
+ className="mt-3 px-4 py-2 bg-starlightBlue text-white text-sm rounded-md hover:opacity-80 transition-opacity"
+ >
+ Download file
+
+
+ )}
+
+ );
+}
diff --git a/apps/app-portal/src/app/uploads-demo/page.tsx b/apps/app-portal/src/app/uploads-demo/page.tsx
index 2fdae04c..ec805e85 100644
--- a/apps/app-portal/src/app/uploads-demo/page.tsx
+++ b/apps/app-portal/src/app/uploads-demo/page.tsx
@@ -1,42 +1,20 @@
-"use client";
-// internal demo page that mounts so this ticket can be tested in isolation
-import FileUpload from "@/components/uploads/FileUpload";
-import React, { useState } from "react";
+import React from "react";
+import { redirect } from "next/navigation";
+import { getSession } from "@/lib/auth/session";
+import UploadsDemoClient from "./UploadsDemoClient";
-export default function Page(): JSX.Element {
- const [uploadId, setUploadId] = useState(null);
- const [fileName, setFileName] = useState(null);
+// Internal demo page for exercising in isolation (see UploadsDemoClient).
+// Not applicant-facing — gated to admins only, same pattern as (admin)/layout.tsx.
+export default async function Page(): Promise {
+ const session = await getSession();
+ const user = session?.user as { isAdmin?: boolean } | undefined;
- return (
-
-
{
- setUploadId(id);
- setFileName(fileName);
- }}
- onUploadRemoved={() => {
- setUploadId(null);
- setFileName(null);
- }}
- />
- {uploadId !== null && (
-
-
- Upload ID: {uploadId}
-
-
- File Name: {fileName}
-
-
{
- // TODO: handle download URL response
- }}
- className="mt-3 px-4 py-2 bg-starlightBlue text-white text-sm rounded-md hover:opacity-80 transition-opacity"
- >
- Download file
-
-
- )}
-
- );
+ if (!user) {
+ redirect("/auth/signin");
+ }
+ if (!user.isAdmin) {
+ redirect("/dashboard");
+ }
+
+ return ;
}
diff --git a/apps/app-portal/src/components/admin/AdminContentArea.tsx b/apps/app-portal/src/components/admin/AdminContentArea.tsx
new file mode 100644
index 00000000..3db883e9
--- /dev/null
+++ b/apps/app-portal/src/components/admin/AdminContentArea.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import React from "react";
+import useDevice from "@repo/util/hooks/useDevice";
+import UserMenu from "@/components/auth/UserMenu";
+
+// Offsets for AdminSidebar's fixed-position width. Driven by the same isMobile check
+// AdminSidebar itself uses (rather than a CSS breakpoint) so the two can never drift
+// apart — the previous `desktop:ml-64`/`desktop:w-64` pairing relied on this repo's
+// custom "desktop" Tailwind breakpoint, which is a *max-width* 1920px query, not a
+// min-width one. Above 1920px both classes silently stopped applying, leaving the fixed
+// sidebar overlapping the content instead of being offset by it.
+export default function AdminContentArea({
+ children,
+}: {
+ children: React.ReactNode;
+}): JSX.Element {
+ const { isMobile } = useDevice();
+
+ return (
+
+
+
+ {children}
+
+ );
+}
diff --git a/apps/app-portal/src/components/admin/AdminSidebar.tsx b/apps/app-portal/src/components/admin/AdminSidebar.tsx
index af744804..dcfc1a14 100644
--- a/apps/app-portal/src/components/admin/AdminSidebar.tsx
+++ b/apps/app-portal/src/components/admin/AdminSidebar.tsx
@@ -64,6 +64,16 @@ export default function AdminSidebar() {
>
Stats
+
+
+
+
+ Applicant View
+
>
);
@@ -97,7 +107,7 @@ export default function AdminSidebar() {
return (
diff --git a/apps/app-portal/src/components/admin/DateControls.tsx b/apps/app-portal/src/components/admin/DateControls.tsx
index 724e2e98..2ffc3423 100644
--- a/apps/app-portal/src/components/admin/DateControls.tsx
+++ b/apps/app-portal/src/components/admin/DateControls.tsx
@@ -10,7 +10,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
-import { toast } from "sonner";
type Props = {
label: string;
@@ -31,11 +30,15 @@ export default function DateControls({ label, endpoint, initialValue }: Props) {
);
const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const [savedMessage, setSavedMessage] = React.useState(null);
async function handleSave() {
if (!date || !time) return;
setLoading(true);
+ setError(null);
+ setSavedMessage(null);
const previousDate = date;
const previousTime = time;
@@ -53,13 +56,26 @@ export default function DateControls({ label, endpoint, initialValue }: Props) {
body: JSON.stringify({ value: combined.toISOString() }),
});
- if (!res.ok) throw new Error("Failed to save");
-
- toast.success(`${label} saved`);
- } catch {
+ const body = await res.json().catch(() => null);
+
+ // Show the real server error (e.g. "Registration cannot close before it opens.")
+ // instead of a generic message — this previously relied on sonner's toast, but
+ // no is mounted anywhere in the admin layout, so those calls were
+ // silent no-ops: the request failed (visible in the console/network tab) with
+ // nothing shown on screen.
+ if (!res.ok) {
+ throw new Error(
+ typeof body?.error === "string"
+ ? body.error
+ : `Failed to save ${label}.`,
+ );
+ }
+
+ setSavedMessage(`${label} saved.`);
+ } catch (err) {
setDate(previousDate);
setTime(previousTime);
- toast.error(`Failed to save ${label}`);
+ setError(err instanceof Error ? err.message : `Failed to save ${label}.`);
} finally {
setLoading(false);
}
@@ -101,6 +117,9 @@ export default function DateControls({ label, endpoint, initialValue }: Props) {
{loading ? "Saving..." : "Save"}
+
+ {error && {error}
}
+ {savedMessage && {savedMessage}
}
);
}
diff --git a/apps/app-portal/src/components/admin/FormConfigEditor.tsx b/apps/app-portal/src/components/admin/FormConfigEditor.tsx
index f77f12d8..fcc86752 100644
--- a/apps/app-portal/src/components/admin/FormConfigEditor.tsx
+++ b/apps/app-portal/src/components/admin/FormConfigEditor.tsx
@@ -7,6 +7,7 @@ import QuestionsList from "./QuestionsList";
export default function FormConfigEditor() {
const [sections, setSections] = React.useState([]);
const [loading, setLoading] = React.useState(true);
+ const [saveError, setSaveError] = React.useState(null);
React.useEffect(() => {
async function loadConfig() {
@@ -22,6 +23,8 @@ export default function FormConfigEditor() {
}, []);
async function handleSave() {
+ setSaveError(null);
+
const res = await fetch("/api/v1/admin/form-config", {
method: "POST",
headers: {
@@ -33,7 +36,7 @@ export default function FormConfigEditor() {
const data = await res.json();
if (!res.ok) {
- alert(data.error);
+ setSaveError(data.error ?? "Failed to save form configuration.");
return;
}
}
@@ -53,6 +56,8 @@ export default function FormConfigEditor() {
>
Save Form Configuration
+
+ {saveError && {saveError}
}
);
}
diff --git a/apps/app-portal/src/components/admin/ShowDecisionToggle.tsx b/apps/app-portal/src/components/admin/ShowDecisionToggle.tsx
index 168927cc..9dcce2a3 100644
--- a/apps/app-portal/src/components/admin/ShowDecisionToggle.tsx
+++ b/apps/app-portal/src/components/admin/ShowDecisionToggle.tsx
@@ -13,9 +13,11 @@ export default function ShowDecisionToggle({
}: ShowDecisionToggleProps) {
const [enabled, setEnabled] = React.useState(initialValue);
const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
async function updateSetting(nextValue: boolean) {
setLoading(true);
+ setError(null);
const previous = enabled;
setEnabled(nextValue);
@@ -26,32 +28,46 @@ export default function ShowDecisionToggle({
headers: {
"Content-Type": "application/json",
},
- body: JSON.stringify({ value: nextValue }),
+ // The route expects `{ enabled }`, not `{ value }` — sending the wrong key meant
+ // body.enabled was always undefined, so the route always rejected with 400.
+ body: JSON.stringify({ enabled: nextValue }),
});
if (!res.ok) {
- throw new Error("Request failed");
+ const body = await res.json().catch(() => null);
+ throw new Error(
+ typeof body?.error === "string"
+ ? body.error
+ : "Failed to update this setting.",
+ );
}
- } catch {
+ } catch (err) {
setEnabled(previous);
+ setError(
+ err instanceof Error ? err.message : "Failed to update this setting.",
+ );
} finally {
setLoading(false);
}
}
return (
-
-
- Show Decisions
-
-
-
+
+
+
+ Show Decisions
+
+
+
+
+
+ {error &&
{error}
}
);
}
diff --git a/apps/app-portal/src/components/admin/applicants/RsvpEditor.tsx b/apps/app-portal/src/components/admin/applicants/RsvpEditor.tsx
index bb3f402f..a1eb8457 100644
--- a/apps/app-portal/src/components/admin/applicants/RsvpEditor.tsx
+++ b/apps/app-portal/src/components/admin/applicants/RsvpEditor.tsx
@@ -11,18 +11,32 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { RSVP_STATUSES, type RsvpStatus } from "@/lib/types/user";
+import {
+ RSVP_STATUSES,
+ type DecisionStatus,
+ type RsvpStatus,
+} from "@/lib/types/user";
+
+const GATED_REASON =
+ "Only admitted applicants can have an RSVP status other than unconfirmed.";
interface RsvpEditorProps {
applicantId: string;
value: RsvpStatus;
+ decisionStatus?: DecisionStatus;
}
-export function RsvpEditor({ applicantId, value }: RsvpEditorProps) {
+export function RsvpEditor({
+ applicantId,
+ value,
+ decisionStatus,
+}: RsvpEditorProps) {
const router = useRouter();
const [current, setCurrent] = React.useState
(value);
const [isSaving, setIsSaving] = React.useState(false);
+ const isAdmitted = decisionStatus === "admitted";
+
async function handleChange(next: RsvpStatus) {
const prev = current;
setCurrent(next);
@@ -45,19 +59,26 @@ export function RsvpEditor({ applicantId, value }: RsvpEditorProps) {
}
return (
- <>
-
-
-
-
-
- {RSVP_STATUSES.map((s) => (
-
- {s}
-
- ))}
-
-
- >
+
+
+
+
+
+ {RSVP_STATUSES.map((s) => {
+ const disabled = !isAdmitted && s !== "unconfirmed";
+ return (
+
+
+ {s}
+
+
+ );
+ })}
+
+
);
}
diff --git a/apps/app-portal/src/components/admin/stats/DemographicsChart.tsx b/apps/app-portal/src/components/admin/stats/DemographicsChart.tsx
index b9e01bba..1f658eed 100644
--- a/apps/app-portal/src/components/admin/stats/DemographicsChart.tsx
+++ b/apps/app-portal/src/components/admin/stats/DemographicsChart.tsx
@@ -35,13 +35,13 @@ interface DemographicsChartProps {
const DIMENSION_LABELS: Record = {
school: "School",
- yearOfEducation: "Year of Education",
- majors: "Majors",
+ education_year: "Year of Education",
+ major: "Major",
gender: "Gender",
- races: "Races",
- shirtSize: "Shirt Size",
- hackathonsAttended: "Hackathons Attended",
- csClassesTaken: "CS Classes Taken",
+ race: "Race",
+ tshirt_size: "Shirt Size",
+ hackathon_experience: "Hackathons Attended",
+ cs_classes: "CS Classes Taken",
};
function formatDimension(key: DemographicsDimension): string {
diff --git a/apps/app-portal/src/components/application/ApplicationForm.tsx b/apps/app-portal/src/components/application/ApplicationForm.tsx
index b918592b..5ff7e565 100644
--- a/apps/app-portal/src/components/application/ApplicationForm.tsx
+++ b/apps/app-portal/src/components/application/ApplicationForm.tsx
@@ -3,9 +3,10 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
-import type { Path } from "react-hook-form";
+import type { Path, Resolver } from "react-hook-form";
import { useForm } from "react-hook-form";
import { toast, Toaster } from "sonner";
+import type { z } from "zod";
import { Button } from "@/components/ui/button";
import {
@@ -17,14 +18,13 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
-import { APPLICATION_SECTIONS } from "@/lib/application/questions";
import {
- applicationSchema,
- createDefaultValues,
- type ApplicationSchemaValues,
+ buildApplicationSchema,
+ buildDefaultValues,
} from "@/lib/application/schema";
import type {
ApplicationResponses,
+ FormSection as FormSectionType,
RegistrationState,
} from "@/lib/application/types";
@@ -33,12 +33,15 @@ import { FormSection } from "./FormSection";
const REGISTRATION_API = "/api/v1/registration";
const AUTOSAVE_DELAY_MS = 2000;
+type ApplicationSchemaValues = Record;
+
export function ApplicationForm() {
const router = useRouter();
const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [regState, setRegState] = useState(null);
+ const [sections, setSections] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isNavigating, setIsNavigating] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@@ -48,9 +51,23 @@ export function ApplicationForm() {
const saveTimerRef = useRef>();
+ // The question set is only known once /api/v1/registration returns the live (possibly
+ // admin-edited) form config, so the zod schema has to be built dynamically. This ref lets the
+ // resolver always read whatever schema was most recently built, without having to recreate the
+ // whole useForm() instance (which would lose in-progress field state) once sections load.
+ const schemaRef = useRef(buildApplicationSchema([], "client"));
+
const form = useForm({
- resolver: zodResolver(applicationSchema),
- defaultValues: createDefaultValues(),
+ resolver: (values, context, options) => {
+ // The schema is only known at runtime (built from the live, possibly admin-edited
+ // section list — see the effect below), so it can't be statically typed against
+ // ApplicationSchemaValues the way a module-level zod schema normally would be.
+ const resolve = zodResolver(
+ schemaRef.current as unknown as Parameters[0],
+ ) as Resolver;
+ return resolve(values, context, options);
+ },
+ defaultValues: {},
mode: "onTouched",
});
@@ -62,9 +79,12 @@ export function ApplicationForm() {
if (!res.ok) throw new Error();
const state = (await res.json()) as RegistrationState;
setRegState(state);
- if (state.responses && Object.keys(state.responses).length > 0) {
- form.reset({ ...createDefaultValues(), ...state.responses });
- }
+ setSections(state.sections);
+ schemaRef.current = buildApplicationSchema(state.sections, "client");
+ form.reset({
+ ...buildDefaultValues(state.sections),
+ ...state.responses,
+ });
if (state.updatedAt) setLastSaved(new Date(state.updatedAt));
} catch {
toast.error("Could not load your application. Please refresh.");
@@ -129,9 +149,9 @@ export function ApplicationForm() {
};
}, [form, isLoading, doSave]);
- const currentSection = APPLICATION_SECTIONS[currentSectionIndex];
+ const currentSection = sections[currentSectionIndex];
const isFirstSection = currentSectionIndex === 0;
- const isLastSection = currentSectionIndex === APPLICATION_SECTIONS.length - 1;
+ const isLastSection = currentSectionIndex === sections.length - 1;
const handleSaveDraft = async () => {
clearTimeout(saveTimerRef.current);
@@ -153,6 +173,12 @@ export function ApplicationForm() {
setIsNavigating(false);
return;
}
+ // trigger() validates the *entire* schema when a resolver is used (documented
+ // react-hook-form behavior) regardless of which field names are passed in, which
+ // sets "required" errors for every other untouched section too. This section is
+ // confirmed valid, so clear those premature errors — later sections get validated
+ // for real when the user actually tries to leave them (or on final submit).
+ form.clearErrors();
clearTimeout(saveTimerRef.current);
await doSave(true);
setCurrentSectionIndex((i) => i + 1);
@@ -166,8 +192,8 @@ export function ApplicationForm() {
if (!isValid) {
// Navigate to the first section that has errors
const errors = form.formState.errors;
- for (let i = 0; i < APPLICATION_SECTIONS.length; i++) {
- const hasError = APPLICATION_SECTIONS[i].questions.some(
+ for (let i = 0; i < sections.length; i++) {
+ const hasError = sections[i].questions.some(
(q) => errors[q.id as keyof ApplicationSchemaValues],
);
if (hasError) {
@@ -255,14 +281,14 @@ export function ApplicationForm() {
@@ -289,7 +315,7 @@ export function ApplicationForm() {
{/* top bar: section label + autosave status + Save Draft */}
- Section {currentSectionIndex + 1} of {APPLICATION_SECTIONS.length}
+ Section {currentSectionIndex + 1} of {sections.length}
· {currentSection.title}
@@ -314,7 +340,7 @@ export function ApplicationForm() {
{/* progress bar */}
- {APPLICATION_SECTIONS.map((_, i) => (
+ {sections.map((_, i) => (
- Your application has been submitted. You can still make changes between now and when registration closes.
+ Your application has been submitted. You can still make changes
+ between now and when registration closes.
)}
@@ -343,7 +370,7 @@ export function ApplicationForm() {
control={form.control}
disabled={false}
sectionIndex={currentSectionIndex}
- totalSections={APPLICATION_SECTIONS.length}
+ totalSections={sections.length}
/>
{/* bottom navigation */}
@@ -413,7 +440,6 @@ export function ApplicationForm() {
function toResponses(values: ApplicationSchemaValues): ApplicationResponses {
const responses: ApplicationResponses = {};
for (const [key, value] of Object.entries(values)) {
- if (value instanceof File) continue;
if (Array.isArray(value)) {
responses[key] = value;
} else if (typeof value === "string") {
diff --git a/apps/app-portal/src/components/application/FileUploadField.tsx b/apps/app-portal/src/components/application/FileUploadField.tsx
index 10c0576c..b38599ce 100644
--- a/apps/app-portal/src/components/application/FileUploadField.tsx
+++ b/apps/app-portal/src/components/application/FileUploadField.tsx
@@ -2,12 +2,14 @@
import React from "react";
+import FileUpload from "@/components/uploads/FileUpload";
import type { Question } from "@/lib/application/types";
interface FileUploadFieldProps {
question: Question;
- value: File | null | undefined;
- onChange: (value: File | null) => void;
+ /** The upload ID returned by /api/v1/uploads/sign once the file has finished uploading. */
+ value: string | null | undefined;
+ onChange: (value: string | null) => void;
disabled?: boolean;
}
@@ -17,53 +19,29 @@ export function FileUploadField({
onChange,
disabled,
}: FileUploadFieldProps) {
- return (
-
-
-
-
+ if (disabled) {
+ return (
+
+ {value
+ ? "A file was uploaded for this question."
+ : "No file was uploaded for this question."}
+
+ );
+ }
- {value instanceof File ? (
- {value.name}
- ) : (
- <>
-
- Click to upload a PDF
-
-
- File upload will be connected to storage in a future release.
-
- >
+ return (
+
+ {value && (
+
+ A file is already uploaded for this question. Uploading a new one will
+ replace it.
+
)}
-
-
onChange(e.target.files?.[0] ?? null)}
- aria-required={question.required}
+
onChange(uploadId)}
+ onUploadRemoved={() => onChange(null)}
/>
-
+
);
}
diff --git a/apps/app-portal/src/components/application/LongTextField.tsx b/apps/app-portal/src/components/application/LongTextField.tsx
index 4a7bc344..c267ea49 100644
--- a/apps/app-portal/src/components/application/LongTextField.tsx
+++ b/apps/app-portal/src/components/application/LongTextField.tsx
@@ -20,16 +20,33 @@ export function LongTextField({
onBlur,
disabled,
}: LongTextFieldProps) {
+ const wordCount =
+ value.trim().length === 0 ? 0 : value.trim().split(/\s+/).length;
+ const overLimit = !!question.maxWords && wordCount > question.maxWords;
+
return (
-
}
diff --git a/apps/app-portal/src/components/dashboard/DeclinedView.tsx b/apps/app-portal/src/components/dashboard/DeclinedView.tsx
index 96ac3c7f..97f9f473 100644
--- a/apps/app-portal/src/components/dashboard/DeclinedView.tsx
+++ b/apps/app-portal/src/components/dashboard/DeclinedView.tsx
@@ -1,6 +1,7 @@
import React from "react";
import Link from "next/link";
import PortalShell from "./PortalShell";
+import JoinMailingListButton from "./JoinMailingListButton";
import {
//primaryActionClass,
secondaryActionClass,
@@ -28,11 +29,7 @@ export default function DeclinedView(): JSX.Element {
>
}
eyebrow="Decision update"
- primaryAction={
-
- Join the mailing list
-
- }
+ primaryAction={
}
secondaryAction={
Refresh status
diff --git a/apps/app-portal/src/components/dashboard/InProgressView.tsx b/apps/app-portal/src/components/dashboard/InProgressView.tsx
index fac55393..fbfa7e0e 100644
--- a/apps/app-portal/src/components/dashboard/InProgressView.tsx
+++ b/apps/app-portal/src/components/dashboard/InProgressView.tsx
@@ -11,12 +11,14 @@ import type { ApplicantStatus } from "../../lib/status/types";
type InProgressViewProps = {
status: ApplicantStatus;
+ completionPercent: number;
};
export default function InProgressView({
- status,
+ status: _status,
+ completionPercent,
}: InProgressViewProps): JSX.Element {
- const progressPercent = status.applicationStatus === "incomplete" ? 60 : 100;
+ void _status;
return (
Current state
- {formatPercentComplete(progressPercent)}
+ {formatPercentComplete(completionPercent)}
@@ -37,7 +39,10 @@ export default function InProgressView({
description={<>You've started your application.>}
eyebrow="Application draft"
primaryAction={
-
+
Continue application
}
@@ -53,10 +58,10 @@ export default function InProgressView({
Completion
- {formatPercentComplete(progressPercent)}
+ {formatPercentComplete(completionPercent)}
- This is a temporary completion value
+ Based on how many application questions you've answered so far.
diff --git a/apps/app-portal/src/components/dashboard/JoinMailingListButton.tsx b/apps/app-portal/src/components/dashboard/JoinMailingListButton.tsx
new file mode 100644
index 00000000..2584fb81
--- /dev/null
+++ b/apps/app-portal/src/components/dashboard/JoinMailingListButton.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import React from "react";
+import { useSession } from "next-auth/react";
+
+type Status = "idle" | "loading" | "success" | "error";
+
+// Subscribes the signed-in applicant's email to the mailing list via the existing
+// /api/joinMailingList route (Beehiiv-backed) — same request shape as
+// apps/main's NewsletterSignup.
+export default function JoinMailingListButton(): JSX.Element {
+ const { data: session } = useSession();
+ const [status, setStatus] = React.useState("idle");
+
+ async function handleClick() {
+ const email = session?.user?.email;
+ if (!email) {
+ setStatus("error");
+ return;
+ }
+
+ setStatus("loading");
+ try {
+ const res = await fetch("/api/joinMailingList", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, reactivate_existing: false }),
+ });
+ setStatus(res.ok ? "success" : "error");
+ } catch {
+ setStatus("error");
+ }
+ }
+
+ if (status === "success") {
+ return (
+
+ You're on the list! 🎉
+
+ );
+ }
+
+ return (
+
+
void handleClick()}
+ disabled={status === "loading"}
+ className="text-left text-blue-600 font-semibold hover:underline disabled:opacity-50"
+ >
+ {status === "loading" ? "Joining…" : "Join the mailing list"}
+
+ {status === "error" && (
+
+ Something went wrong. Please try again.
+
+ )}
+
+ );
+}
diff --git a/apps/app-portal/src/components/dashboard/PortalShell.tsx b/apps/app-portal/src/components/dashboard/PortalShell.tsx
index eeaf7c3a..738c7b84 100644
--- a/apps/app-portal/src/components/dashboard/PortalShell.tsx
+++ b/apps/app-portal/src/components/dashboard/PortalShell.tsx
@@ -55,10 +55,7 @@ export default function PortalShell({
) : (
-
- Review the status mock in the route handler to preview the other
- branches.
-
+
Nothing new to show here right now.
- The team is reviewing applications and will share decisions on XXX.
+ The team is reviewing applications and will share decisions soon.
Keep an eye on your inbox.
@@ -37,7 +37,10 @@ export default function SubmittedView({
description={<>We've received your application!>}
eyebrow="Application submitted"
primaryAction={
-
+
{isRegistrationOpen ? "Edit application" : "Back to dashboard"}
}
@@ -50,7 +53,7 @@ export default function SubmittedView({
>
- Review date: XXX
+ Under review
{status.rsvpStatus === "confirmed"
diff --git a/apps/app-portal/src/components/dashboard/WaitlistedView.tsx b/apps/app-portal/src/components/dashboard/WaitlistedView.tsx
index cf10f128..ba4e3904 100644
--- a/apps/app-portal/src/components/dashboard/WaitlistedView.tsx
+++ b/apps/app-portal/src/components/dashboard/WaitlistedView.tsx
@@ -2,6 +2,7 @@ import React from "react";
import Link from "next/link";
import PortalShell from "./PortalShell";
import { secondaryActionClass, statCardClass } from "./styles";
+import { SUPPORT_EMAIL } from "../../lib/config/site";
import type { ApplicantStatus } from "../../lib/status/types";
type WaitlistedViewProps = {
@@ -20,8 +21,8 @@ export default function WaitlistedView({
Questions?
- Email applications@hackbeanpot.com and we'll point you in the
- right direction.
+ Email {SUPPORT_EMAIL} and we'll point you in the right
+ direction.
}
diff --git a/apps/app-portal/src/components/uploads/FileUpload.tsx b/apps/app-portal/src/components/uploads/FileUpload.tsx
index 13238840..59faaa79 100644
--- a/apps/app-portal/src/components/uploads/FileUpload.tsx
+++ b/apps/app-portal/src/components/uploads/FileUpload.tsx
@@ -14,17 +14,21 @@ import { formatBytes } from "@/lib/uploads/utils";
/**
* @param description - Custom dropzone prompt text. Defaults to "Drag or drop files".
+ * @param accept - MIME types this instance accepts. Defaults to the app-wide allow-list
+ * (PDF/PNG/JPEG); pass a narrower list to restrict a specific field (e.g. PDF-only for resumes).
* @param onUploadComplete - Called with (uploadId, fileName) after a successful upload.
* @param onUploadRemoved - Called when the user removes an uploaded file.
*/
interface FileUploadProps {
description?: string;
+ accept?: readonly string[];
onUploadComplete?: (uploadId: string, fileName: string) => void;
onUploadRemoved?: () => void;
}
export default function FileUpload({
description,
+ accept: acceptMimeTypes = ALLOWED_MIME_TYPES,
onUploadComplete,
onUploadRemoved,
}: FileUploadProps): JSX.Element {
@@ -32,11 +36,13 @@ export default function FileUpload({
const [isUploading, setIsUploading] = useState(false);
const [transferredBytes, setTransferredBytes] = useState(0);
const [totalBytes, setTotalBytes] = useState(0);
+ const [uploadError, setUploadError] = useState(null);
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
setTransferredBytes(0);
setTotalBytes(0);
+ setUploadError(null);
if (acceptedFiles.length === 0) return;
const firstFile = acceptedFiles[0];
@@ -50,76 +56,66 @@ export default function FileUpload({
headers: { "Content-Type": "application/json" },
});
+ if (!res.ok) {
+ const body = await res.json().catch(() => null);
+ setUploadError(
+ typeof body?.error === "string"
+ ? body.error
+ : "Could not start the upload. Please try again.",
+ );
+ return;
+ }
+
const { uploadId, uploadUrl } = await res.json();
- if (isMockUrl(uploadUrl)) {
- // fake response
- const INTERVAL_MS = 200;
- const CHUNKS = 20;
- const chunkSize = Math.ceil(firstFile.size / CHUNKS);
-
- const interval = setInterval(() => {
- setTransferredBytes((prev) => {
- const next = Math.min(prev + chunkSize, firstFile.size);
- if (next >= firstFile.size) {
- clearInterval(interval);
- setIsUploading(false);
- setUploadedFile(firstFile);
- onUploadComplete?.(uploadId, firstFile.name);
+
+ setIsUploading(true);
+ setTotalBytes(firstFile.size);
+
+ try {
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("PUT", uploadUrl);
+ xhr.setRequestHeader("Content-Type", firstFile.type);
+
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable) setTransferredBytes(e.loaded);
+ };
+
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve();
+ } else {
+ reject(new Error(`Upload failed with status ${xhr.status}`));
}
- return next;
- });
- }, INTERVAL_MS);
-
- setIsUploading(true);
- setTotalBytes(firstFile.size);
- } else {
- setIsUploading(true);
- setTotalBytes(firstFile.size);
-
- try {
- await new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- xhr.open("PUT", uploadUrl);
- xhr.setRequestHeader("Content-Type", firstFile.type);
-
- xhr.upload.onprogress = (e) => {
- if (e.lengthComputable) setTransferredBytes(e.loaded);
- };
-
- xhr.onload = () => {
- if (xhr.status >= 200 && xhr.status < 300) {
- resolve();
- } else {
- reject(new Error(`Upload failed with status ${xhr.status}`));
- }
- };
-
- xhr.onerror = () => reject(new Error("Upload failed"));
-
- xhr.send(firstFile);
- });
-
- setTransferredBytes(firstFile.size);
- setIsUploading(false);
- setUploadedFile(firstFile);
- onUploadComplete?.(uploadId, firstFile.name);
- } catch {
- setIsUploading(false);
- // error to user here
- return;
- }
+ };
+
+ xhr.onerror = () => reject(new Error("Upload failed"));
+
+ xhr.send(firstFile);
+ });
+
+ setTransferredBytes(firstFile.size);
+ setIsUploading(false);
+ setUploadedFile(firstFile);
+ onUploadComplete?.(uploadId, firstFile.name);
+ } catch {
+ setIsUploading(false);
+ setUploadError("The upload failed. Please try again.");
}
},
[onUploadComplete],
);
- function isMockUrl(url: string): boolean {
- return !!url && !url.startsWith("https://storage.googleapis.com");
- }
+ const MIME_LABELS: Record = {
+ "application/pdf": "PDF",
+ "image/png": "PNG",
+ "image/jpeg": "JPEG",
+ };
+ const acceptedLabel = acceptMimeTypes
+ .map((mime) => MIME_LABELS[mime] ?? mime)
+ .join(", ");
- const accept = Object.fromEntries(
- ALLOWED_MIME_TYPES.map((mime) => [mime, []]),
- );
+ const accept = Object.fromEntries(acceptMimeTypes.map((mime) => [mime, []]));
const { getRootProps, getInputProps, isDragActive, fileRejections, open } =
useDropzone({
@@ -134,7 +130,7 @@ export default function FileUpload({
errors.map((e) => {
if (e.code === "file-too-large") return "File exceeds 5 MB limit.";
if (e.code === "file-invalid-type")
- return "Only PDF, PNG, and JPEG files are accepted.";
+ return `Only ${acceptedLabel} files are accepted.`;
return e.message;
}),
);
@@ -185,8 +181,14 @@ export default function FileUpload({
)}
- PDF, PNG, JPEG up to {formatBytes(MAX_FILE_SIZE_BYTES)}
+ {acceptedLabel} up to {formatBytes(MAX_FILE_SIZE_BYTES)}
+ {uploadError && (
+
+
+ {uploadError}
+
+ )}
)}
{isUploading && (
diff --git a/apps/app-portal/src/lib/admin/date-route-handlers.ts b/apps/app-portal/src/lib/admin/date-route-handlers.ts
new file mode 100644
index 00000000..5aa87063
--- /dev/null
+++ b/apps/app-portal/src/lib/admin/date-route-handlers.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/auth/guards";
+import { SingletonKey } from "@/lib/types/singleton";
+import {
+ getSingleton,
+ setSingleton,
+ validateDateOrdering,
+ validateDateSingleton,
+} from "./singleton-service";
+
+// Shared GET/POST implementation for the three date singleton routes
+// (registration-open, registration-closed, confirm-by) — they were previously three
+// near-identical copies of this logic, which meant a fix (auth error handling, date
+// ordering validation) had to be applied three times to stay consistent.
+export function createDateSingletonHandlers(key: SingletonKey) {
+ async function GET() {
+ const value = await getSingleton(key);
+ return NextResponse.json({ value });
+ }
+
+ async function POST(req: Request) {
+ let admin;
+ try {
+ admin = await requireAdmin();
+ } catch (error) {
+ if (error instanceof Error && error.message === "Forbidden") {
+ return NextResponse.json({ error: error.message }, { status: 403 });
+ }
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!admin.email) {
+ return NextResponse.json(
+ { error: "Admin email is required." },
+ { status: 400 },
+ );
+ }
+
+ const body = await req.json();
+ const { value } = body;
+
+ const result = validateDateSingleton(value);
+ if (!result.ok) {
+ return NextResponse.json({ error: result.error }, { status: 400 });
+ }
+
+ const ordering = await validateDateOrdering(key, result.value);
+ if (!ordering.ok) {
+ return NextResponse.json({ error: ordering.error }, { status: 400 });
+ }
+
+ await setSingleton(key, result.value, admin.email);
+
+ return NextResponse.json({ ok: true, value: result.value });
+ }
+
+ return { GET, POST };
+}
diff --git a/apps/app-portal/src/lib/admin/form-config-service.ts b/apps/app-portal/src/lib/admin/form-config-service.ts
index bc51864d..154e6d78 100644
--- a/apps/app-portal/src/lib/admin/form-config-service.ts
+++ b/apps/app-portal/src/lib/admin/form-config-service.ts
@@ -1,8 +1,8 @@
-import { getDb } from "../db";
-import { SingletonKey } from "../types/singleton";
+import { getDb, resolveCollectionName } from "@/lib/db";
+import { SingletonKey } from "@/lib/types/singleton";
+import { DEFAULT_FORM_CONFIG } from "@/lib/application/questions";
import { getSingleton, setSingleton } from "./singleton-service";
import { FormConfig } from "./types";
-import { DEFAULT_FORM_CONFIG } from "../application/questions";
function getQuestionIds(config: FormConfig): Set {
const ids = new Set();
@@ -30,10 +30,25 @@ function validateUniqueQuestionIds(config: FormConfig): void {
}
}
+// Without this, POSTing {"sections": []} (or sections that are all empty) passes every
+// other check — no duplicate IDs, no in-use IDs removed — and silently wipes the live
+// application form down to zero questions.
+function validateNotEmpty(config: FormConfig): void {
+ const totalQuestions = config.sections.reduce(
+ (sum, section) => sum + section.questions.length,
+ 0,
+ );
+ if (config.sections.length === 0 || totalQuestions === 0) {
+ throw new Error(
+ "Form config must have at least one section with at least one question.",
+ );
+ }
+}
+
async function getUsedQuestionIds(): Promise> {
const db = await getDb();
- const collection = db.collection("applicant_data");
+ const collection = db.collection(resolveCollectionName("applicant_data"));
const applicants = await collection
.find({})
@@ -69,6 +84,7 @@ export async function updateFormConfig(
config: FormConfig,
updatedBy: string,
): Promise {
+ validateNotEmpty(config);
validateUniqueQuestionIds(config);
const newQuestionIds = getQuestionIds(config);
diff --git a/apps/app-portal/src/lib/admin/singleton-keys.ts b/apps/app-portal/src/lib/admin/singleton-keys.ts
deleted file mode 100644
index 9dd6ff26..00000000
--- a/apps/app-portal/src/lib/admin/singleton-keys.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export type SingletonKey =
- | "registration-open"
- | "registration-closed"
- | "confirm-by"
- | "show-decision";
diff --git a/apps/app-portal/src/lib/admin/singleton-service.ts b/apps/app-portal/src/lib/admin/singleton-service.ts
index 137ddc91..5ff16b80 100644
--- a/apps/app-portal/src/lib/admin/singleton-service.ts
+++ b/apps/app-portal/src/lib/admin/singleton-service.ts
@@ -72,19 +72,70 @@ export function validateDateSingleton(
const normalized = date.toISOString();
- // if (value !== normalized) {
- // return {
- // ok: false,
- // error: "Value must be an ISO 8601 date string.",
- // };
- // }
-
return {
ok: true,
value: normalized,
};
}
+/**
+ * Cross-checks a proposed date singleton against the other two (registrationOpen ≤
+ * registrationClosed ≤ confirmBy) so an admin can't independently set one date route
+ * into an order that breaks downstream eligibility-window logic (e.g. closing
+ * registration before it opens, or confirm-by before registration even closes).
+ */
+export async function validateDateOrdering(
+ key: SingletonKey,
+ newValue: string,
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const [registrationOpen, registrationClosed, confirmBy] = await Promise.all([
+ key === SingletonKey.RegistrationOpen
+ ? newValue
+ : getSingleton(SingletonKey.RegistrationOpen),
+ key === SingletonKey.RegistrationClosed
+ ? newValue
+ : getSingleton(SingletonKey.RegistrationClosed),
+ key === SingletonKey.ConfirmBy
+ ? newValue
+ : getSingleton(SingletonKey.ConfirmBy),
+ ]);
+
+ if (
+ registrationOpen &&
+ registrationClosed &&
+ new Date(registrationOpen) > new Date(registrationClosed)
+ ) {
+ return {
+ ok: false,
+ error: "Registration cannot close before it opens.",
+ };
+ }
+
+ if (
+ registrationClosed &&
+ confirmBy &&
+ new Date(registrationClosed) > new Date(confirmBy)
+ ) {
+ return {
+ ok: false,
+ error: "The confirm-by deadline cannot be before registration closes.",
+ };
+ }
+
+ if (
+ registrationOpen &&
+ confirmBy &&
+ new Date(registrationOpen) > new Date(confirmBy)
+ ) {
+ return {
+ ok: false,
+ error: "The confirm-by deadline cannot be before registration opens.",
+ };
+ }
+
+ return { ok: true };
+}
+
export function validateBooleanSingleton(
value: unknown,
): { ok: true; value: boolean } | { ok: false; error: string } {
diff --git a/apps/app-portal/src/lib/admin/types.ts b/apps/app-portal/src/lib/admin/types.ts
index a2e93eb0..abd73027 100644
--- a/apps/app-portal/src/lib/admin/types.ts
+++ b/apps/app-portal/src/lib/admin/types.ts
@@ -1,4 +1,5 @@
import { SingletonKey } from "../types/singleton";
+import type { FormSection, Question } from "../application/types";
export type DateSingletonValue = string | null;
export type BooleanSingletonValue = boolean;
@@ -9,20 +10,14 @@ export type SingletonValue = K extends "show-decision"
? FormConfig
: DateSingletonValue;
-export interface FormConfigQuestion {
- id: string;
- label: string;
- type: "text" | "textarea" | "select" | "checkbox";
- required?: boolean;
- options?: string[];
- order?: number;
-}
-
-export interface FormConfigSection {
- id: string;
- title: string;
- questions: FormConfigQuestion[];
-}
+// The admin-editable form config and the live applicant-facing application form (schema
+// generation, rendering, autosave/submit validation — see lib/application/*) share this exact
+// same shape. They used to diverge (a leaner "text"/"textarea"/"select"/"checkbox" shape here
+// vs. the richer Question/FormSection used everywhere else), which meant editing the form in
+// /admin/settings had no effect on what applicants actually saw. Aliasing them here keeps that
+// from happening again.
+export type FormConfigQuestion = Question;
+export type FormConfigSection = FormSection;
export interface FormConfig {
sections: FormConfigSection[];
diff --git a/apps/app-portal/src/lib/applicants/queries.ts b/apps/app-portal/src/lib/applicants/queries.ts
index a376fca7..11789e8f 100644
--- a/apps/app-portal/src/lib/applicants/queries.ts
+++ b/apps/app-portal/src/lib/applicants/queries.ts
@@ -29,7 +29,11 @@ export function buildApplicantQuery(
if (filters.search) {
const rx = { $regex: escapeRegex(filters.search), $options: "i" };
- query.$or = [{ email: rx }, { "applicationResponses.legal_name": rx }];
+ query.$or = [
+ { email: rx },
+ { "applicationResponses.first_name": rx },
+ { "applicationResponses.last_name": rx },
+ ];
}
return query;
diff --git a/apps/app-portal/src/lib/applicants/service.ts b/apps/app-portal/src/lib/applicants/service.ts
index 355821ec..2e968afe 100644
--- a/apps/app-portal/src/lib/applicants/service.ts
+++ b/apps/app-portal/src/lib/applicants/service.ts
@@ -2,6 +2,7 @@ import { Collection, FindCursor, ObjectId } from "mongodb";
import { z } from "zod";
import { getDb, resolveCollectionName } from "@/lib/db";
+import { getUploadRecord } from "@/lib/uploads/service";
import { DECISION_STATUSES, RSVP_STATUSES } from "@/lib/types/user";
import { buildApplicantQuery } from "./queries";
@@ -22,12 +23,25 @@ async function applicantCollection(): Promise> {
return db.collection(APPLICANT_COLLECTION);
}
+// The 2026 application form splits name into "first_name"/"last_name" (the older
+// "legal_name" single-field question no longer exists) — combine them for display,
+// sorting, search, and CSV export so all of those stay in sync with the live form.
+export function getApplicantName(
+ responses: ApplicantDoc["applicationResponses"],
+): string | undefined {
+ const first = responses?.["first_name"];
+ const last = responses?.["last_name"];
+ const parts = [first, last].filter(
+ (v): v is string => typeof v === "string" && v.length > 0,
+ );
+ return parts.length > 0 ? parts.join(" ") : undefined;
+}
+
function docToSummary(doc: ApplicantDoc): ApplicantSummary {
- const name = doc.applicationResponses?.["legal_name"];
return {
id: doc._id.toString(),
email: doc.email,
- name: typeof name === "string" && name.length > 0 ? name : undefined,
+ name: getApplicantName(doc.applicationResponses),
applicationStatus: doc.applicationStatus,
decisionStatus: doc.decisionStatus,
rsvpStatus: doc.rsvpStatus,
@@ -36,22 +50,24 @@ function docToSummary(doc: ApplicantDoc): ApplicantSummary {
};
}
-function resolveResume(doc: ApplicantDoc): UploadedFile | undefined {
+async function resolveResume(
+ doc: ApplicantDoc,
+): Promise {
const uploadId = doc.applicationResponses?.["resume"];
if (typeof uploadId !== "string" || uploadId.length === 0) return undefined;
- // Placeholder filename until the (separate, in-flight) uploads ticket lands
- // real upload-record metadata — the id doubles as the displayed label.
- // TODO: fill out with real call to the uploads collection once that ticket lands.
- return { id: uploadId, filename: uploadId };
+ const record = await getUploadRecord(uploadId);
+ // Fall back to the raw id as the label if the upload record is missing (e.g. it
+ // was somehow deleted) rather than hiding the resume link entirely.
+ return { id: uploadId, filename: record?.filename ?? uploadId };
}
-function docToDetail(doc: ApplicantDoc): ApplicantDetail {
+async function docToDetail(doc: ApplicantDoc): Promise {
return {
...docToSummary(doc),
applicationResponses: doc.applicationResponses,
postAcceptanceResponses: doc.postAcceptanceResponses,
rsvpSubmissionTime: doc.rsvpSubmissionTime,
- resume: resolveResume(doc),
+ resume: await resolveResume(doc),
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
};
@@ -67,7 +83,10 @@ export async function listApplicants(
// `name` lives under the application response, not a top-level doc field.
const sort: Record =
sortBy === "name"
- ? { "applicationResponses.legal_name": dir }
+ ? {
+ "applicationResponses.last_name": dir,
+ "applicationResponses.first_name": dir,
+ }
: { [sortBy]: dir };
const [total, docs] = await Promise.all([
@@ -95,10 +114,14 @@ export async function getApplicant(
const col = await applicantCollection();
const doc = await col.findOne({ _id: new ObjectId(id) });
if (!doc) return null;
- return docToDetail(doc);
+ return await docToDetail(doc);
}
export class InvalidApplicantUpdateError extends Error {}
+// Thrown when a patch would put an applicant into an inconsistent state — e.g. giving
+// them an RSVP status without them actually being admitted (mirrors the same rule the
+// applicant-facing RSVP flow enforces in lib/status/service.ts's saveRsvp).
+export class InvalidApplicantStateError extends Error {}
const updateSchema = z
.object({
@@ -125,8 +148,20 @@ export async function updateApplicant(
}
const patchValue: ApplicantUpdate = parsed.data;
-
const col = await applicantCollection();
+
+ if (patchValue.rsvpStatus && patchValue.rsvpStatus !== "unconfirmed") {
+ const existing = await col.findOne({ _id: new ObjectId(id) });
+ if (!existing) return null;
+ const resultingDecisionStatus =
+ patchValue.decisionStatus ?? existing.decisionStatus;
+ if (resultingDecisionStatus !== "admitted") {
+ throw new InvalidApplicantStateError(
+ "Only admitted applicants can have an RSVP status other than unconfirmed.",
+ );
+ }
+ }
+
const updatedAt = new Date().toISOString();
const result = await col.findOneAndUpdate(
{ _id: new ObjectId(id) },
@@ -134,7 +169,7 @@ export async function updateApplicant(
{ returnDocument: "after" },
);
if (!result.value) return null;
- return docToDetail(result.value);
+ return await docToDetail(result.value);
}
/** Full, unfiltered cursor for streaming CSV export — never buffer into an array. */
diff --git a/apps/app-portal/src/lib/application/questions.ts b/apps/app-portal/src/lib/application/questions.ts
index 6b52456f..ca0e7940 100644
--- a/apps/app-portal/src/lib/application/questions.ts
+++ b/apps/app-portal/src/lib/application/questions.ts
@@ -1,33 +1,140 @@
import { FormConfig } from "../admin/types";
-import type { FormSection } from "./types";
+import type { FormSection, QuestionOption } from "./types";
+const CABIN_IMPORTANCE_OPTIONS: readonly QuestionOption[] = [
+ { value: "very_important", label: "That's very important to me" },
+ { value: "somewhat_important", label: "That's somewhat important to me" },
+ { value: "not_important", label: "That's not important to me" },
+ {
+ value: "dont_want_to",
+ label: "I don't want to do that at HackBeanpot",
+ },
+];
+
+// HackBeanpot 2026 registration questions. This is the code-level default — the
+// admin-editable config in Mongo (see lib/admin/form-config-service.ts) starts out
+// as a copy of this and can diverge once an admin saves changes in /admin/settings.
+//
+// Note on scope: a few source questions are conditionally visible in the original spec
+// (e.g. "if your gender isn't listed above, list it here") — this form doesn't yet support
+// show/hide-on-condition, so those are rendered as always-visible optional fields instead.
export const APPLICATION_SECTIONS: readonly FormSection[] = [
{
id: "personal",
- title: "Personal information",
- description: "Tell us a bit about yourself.",
+ title: "Let's Get to Know You!",
+ description:
+ "All questions are optional unless otherwise stated. We will not use/disclose your personal info for outside purposes.",
questions: [
{
- id: "legal_name",
- label: "Full legal name",
+ id: "first_name",
+ label: "First Name",
type: "short_text",
required: true,
maxLength: 200,
},
{
id: "preferred_name",
- label: "Preferred name (optional)",
+ label: "Preferred Name",
type: "short_text",
required: false,
maxLength: 200,
},
{
- id: "email",
- label: "Email address",
+ id: "last_name",
+ label: "Last Name",
+ type: "short_text",
+ required: true,
+ maxLength: 200,
+ },
+ {
+ id: "hometown",
+ label: "Hometown",
+ type: "short_text",
+ required: true,
+ maxLength: 200,
+ },
+ {
+ id: "pronouns",
+ label: "Pronouns",
type: "short_text",
required: true,
- description: "Use the same email you sign in with.",
- maxLength: 320,
+ maxLength: 100,
+ },
+ {
+ id: "gender",
+ label: "Gender",
+ type: "select",
+ required: true,
+ options: [
+ { value: "male", label: "Male" },
+ { value: "female", label: "Female" },
+ { value: "non_binary", label: "Non-binary" },
+ { value: "genderqueer", label: "Genderqueer" },
+ { value: "unlisted", label: "Unlisted" },
+ { value: "prefer_not_to_say", label: "Prefer not to say" },
+ ],
+ },
+ {
+ id: "gender_other",
+ label: "If your gender isn't listed above, list it here!",
+ type: "short_text",
+ required: false,
+ maxLength: 200,
+ },
+ {
+ id: "race",
+ label: "What race(s) do you identify as?",
+ type: "multi_select",
+ required: true,
+ options: [
+ {
+ value: "indigenous_american_or_alaska_native",
+ label: "Indigenous American or Alaska Native",
+ },
+ {
+ value: "asian",
+ label: "Asian (East, Southeast, South)",
+ },
+ {
+ value: "black_or_african_american",
+ label: "Black or African American",
+ },
+ { value: "hispanic_or_latinx", label: "Hispanic or Latinx" },
+ {
+ value: "native_hawaiian_or_pacific_islander",
+ label: "Native Hawaiian or Other Pacific Islander",
+ },
+ { value: "white", label: "White" },
+ { value: "unlisted", label: "Unlisted" },
+ { value: "prefer_not_to_say", label: "Prefer not to say" },
+ ],
+ },
+ {
+ id: "race_other",
+ label: "If your race isn't listed above, list it here!",
+ type: "short_text",
+ required: false,
+ maxLength: 200,
+ },
+ {
+ id: "lgbtq",
+ label: "Do you identify as part of the LGBTQIA+ community?",
+ type: "select",
+ required: true,
+ options: [
+ { value: "yes", label: "Yes" },
+ { value: "no", label: "No" },
+ { value: "unsure", label: "Unsure" },
+ { value: "prefer_not_to_say", label: "Prefer not to say" },
+ ],
+ },
+ {
+ id: "lgbtq_identity",
+ label:
+ "If you said yes to the question above, how do you identify yourself?",
+ type: "short_text",
+ required: false,
+ maxLength: 200,
},
],
},
@@ -36,30 +143,178 @@ export const APPLICATION_SECTIONS: readonly FormSection[] = [
title: "Education",
questions: [
{
- id: "university",
- label: "University",
- type: "short_text",
+ id: "school",
+ label: "What school do you attend?",
+ type: "select",
required: true,
+ options: [
+ {
+ value: "northeastern_university",
+ label: "Northeastern University",
+ },
+ { value: "boston_university", label: "Boston University" },
+ { value: "mit", label: "MIT" },
+ { value: "harvard_university", label: "Harvard University" },
+ { value: "tufts_university", label: "Tufts University" },
+ {
+ value: "umass_amherst",
+ label: "University of Massachusetts Amherst",
+ },
+ { value: "boston_college", label: "Boston College" },
+ { value: "emerson_college", label: "Emerson College" },
+ { value: "suffolk_university", label: "Suffolk University" },
+ { value: "brandeis_university", label: "Brandeis University" },
+ { value: "wellesley_college", label: "Wellesley College" },
+ {
+ value: "wentworth_institute_of_technology",
+ label: "Wentworth Institute of Technology",
+ },
+ {
+ value: "olin_college_of_engineering",
+ label: "Olin College of Engineering",
+ },
+ { value: "simmons_university", label: "Simmons University" },
+ {
+ value: "benjamin_franklin_institute_of_technology",
+ label: "Benjamin Franklin Institute of Technology",
+ },
+ {
+ value: "umass_boston",
+ label: "University of Massachusetts Boston",
+ },
+ {
+ value: "bunker_hill_community_college",
+ label: "Bunker Hill Community College",
+ },
+ {
+ value: "bristol_community_college",
+ label: "Bristol Community College",
+ },
+ {
+ value: "worcester_polytechnic_institute",
+ label: "Worcester Polytechnic Institute",
+ },
+ { value: "other", label: "Other" },
+ ],
+ },
+ {
+ id: "school_other",
+ label:
+ "If your school was not listed in the previous question, list it here!",
+ type: "short_text",
+ required: false,
maxLength: 200,
},
{
- id: "year_of_study",
- label: "Year of study",
+ id: "education_level",
+ label: "What level of education are you currently pursuing?",
type: "select",
required: true,
options: [
- { value: "first", label: "First year" },
- { value: "second", label: "Second year" },
- { value: "third", label: "Third year" },
- { value: "fourth", label: "Fourth year" },
- { value: "graduate", label: "Graduate student" },
+ { value: "undergraduate", label: "Undergraduate" },
+ { value: "graduate", label: "Graduate" },
],
},
+ {
+ id: "education_year",
+ label: "What year in your current education are you?",
+ type: "select",
+ required: true,
+ options: [
+ { value: "1st_year", label: "1st year" },
+ { value: "2nd_year", label: "2nd year" },
+ { value: "3rd_year", label: "3rd year" },
+ { value: "4th_year", label: "4th year" },
+ { value: "5th_year_plus", label: "5th year +" },
+ ],
+ },
+ {
+ id: "major",
+ label: "What are your major/concentration(s)? (N/A if not applicable)",
+ type: "short_text",
+ required: true,
+ maxLength: 300,
+ },
+ {
+ id: "minor",
+ label: "What are your minor(s)? (N/A if not applicable)",
+ type: "short_text",
+ required: false,
+ maxLength: 300,
+ },
+ ],
+ },
+ {
+ id: "documents",
+ title: "Documents",
+ questions: [
+ {
+ id: "resume",
+ label: "Resume",
+ type: "file_upload",
+ required: false,
+ description:
+ "Please upload your resume as a PDF! We do not read resumes as a part of the HBP application process. If you choose to upload your resume, it will be shared with select sponsors who may contact you about internship/job opportunities, and will only be read by them.",
+ accept: ["application/pdf"],
+ },
+ {
+ id: "github_url",
+ label: "Github URL",
+ type: "short_text",
+ required: false,
+ maxLength: 300,
+ },
+ {
+ id: "linkedin_url",
+ label: "LinkedIn URL",
+ type: "short_text",
+ required: false,
+ maxLength: 300,
+ },
+ {
+ id: "portfolio_url",
+ label: "Personal website/portfolio URL",
+ type: "short_text",
+ required: false,
+ maxLength: 300,
+ },
+ {
+ id: "tshirt_size",
+ label: "What is your t-shirt size?",
+ type: "select",
+ required: true,
+ description:
+ "Note: All sizes are unisex, and measurements are across the widest part of the chest!",
+ options: [
+ { value: "xs", label: "XS" },
+ { value: "s", label: "S" },
+ { value: "m", label: "M" },
+ { value: "l", label: "L" },
+ { value: "xl", label: "XL" },
+ { value: "2xl", label: "2XL" },
+ ],
+ },
+ {
+ id: "accommodations",
+ label:
+ "Do you require any special accommodations to fully participate in the event? If yes, please list your requested accommodations and the best form of contact so that we can reach out to you. Please fill out this question if you don't have access to a laptop for the event so we can look for arrangements.",
+ type: "long_text",
+ required: false,
+ maxLength: 2000,
+ },
+ {
+ id: "vaccination_card",
+ label:
+ "Since our hackathon will be in-person, we want to ensure the safety and health of all of our attendees. Please upload a picture or screenshot of your vaccination card.",
+ type: "file_upload",
+ required: true,
+ accept: ["image/png", "image/jpeg"],
+ },
],
},
{
id: "experience",
- title: "Experience & interests",
+ title: "Interests & Experience",
questions: [
{
id: "hackathon_experience",
@@ -67,85 +322,223 @@ export const APPLICATION_SECTIONS: readonly FormSection[] = [
type: "select",
required: true,
options: [
- { value: "0", label: "None" },
+ { value: "0", label: "0" },
{ value: "1-2", label: "1–2" },
{ value: "3-5", label: "3–5" },
- { value: "6+", label: "6 or more" },
+ { value: "6+", label: "6+" },
],
},
{
- id: "interests",
- label: "What are you interested in building or learning about?",
+ id: "cs_classes",
+ label: "How many CS classes have you taken or are currently taking?",
+ type: "select",
+ required: true,
+ options: [
+ { value: "0", label: "0" },
+ { value: "1-2", label: "1–2" },
+ { value: "3-5", label: "3–5" },
+ { value: "6+", label: "6+" },
+ ],
+ },
+ {
+ id: "workshop_interests",
+ label:
+ "Please indicate which of the following topics you would be interested in attending a workshop about!",
type: "multi_select",
required: true,
+ description:
+ "Disclaimer: This is just for data collection and planning purposes and will NOT impact your application!",
options: [
- { value: "web", label: "Web development" },
- { value: "mobile", label: "Mobile" },
- { value: "ai", label: "AI / ML" },
- { value: "hardware", label: "Hardware / IoT" },
- { value: "design", label: "Design" },
- { value: "other", label: "Other" },
+ { value: "mobile", label: "Mobile App Development" },
+ { value: "web", label: "Web Development" },
+ { value: "design", label: "UI/UX" },
+ { value: "backend", label: "Backend" },
+ { value: "frontend", label: "Frontend" },
+ { value: "data_science", label: "Data Science" },
+ { value: "cybersecurity", label: "Cybersecurity" },
+ { value: "ai_ml", label: "AI/Machine Learning" },
+ { value: "product_management", label: "Product Management" },
+ { value: "entrepreneurship", label: "Entrepreneurship" },
],
},
{
- id: "why_attend",
- label: "Why do you want to attend HackBeanpot?",
+ id: "other_disciplines",
+ label:
+ "Were there any disciplines not listed that you'd be interested in?",
+ type: "short_text",
+ required: false,
+ maxLength: 300,
+ },
+ ],
+ },
+ {
+ id: "personality",
+ title: "Personality Questions",
+ questions: [
+ {
+ id: "goals_long_answer",
+ label:
+ "At HackBeanpot 2026, we aim to create a welcoming environment where you can meet new friends, learn something new, and ultimately, pursue your goals. In the long term, what are you trying to learn or achieve? Think about personal or career goals, or something else entirely. What steps have you taken in the past to reach those goals, and how will participating in HackBeanpot help?",
+ type: "long_text",
+ required: true,
+ maxWords: 275,
+ },
+ {
+ id: "passion_long_answer",
+ label:
+ "What's a topic you're really passionate about? It can be anything — your favorite book, a world problem, the color purple, a project idea, or something else. Why should someone else care about it as much as you do?",
type: "long_text",
required: true,
- maxLength: 5000,
+ maxWords: 250,
+ },
+ {
+ id: "hackathon_reflection",
+ label:
+ "Have you attended HackBeanpot previously? If you've attended a hackathon previously, what did you like or dislike about it? If this is your first hackathon, what would you like to see at HackBeanpot?",
+ type: "long_text",
+ required: true,
+ maxWords: 250,
},
],
},
{
- id: "documents",
- title: "Documents",
+ id: "team",
+ title: "Team Formation",
+ description:
+ "This question does not get factored into how your application is read! It's for us to plan ahead for team formation; applicants are accepted on an individual basis, and it is not guaranteed that everyone in a premade team will be accepted.",
questions: [
{
- id: "resume",
- label: "Resume (PDF)",
- type: "file_upload",
+ id: "premade_team",
+ label: "Do you plan on attending HackBeanpot with a premade team?",
+ type: "select",
+ required: true,
+ options: [
+ { value: "yes", label: "Yes" },
+ { value: "no", label: "No" },
+ ],
+ },
+ {
+ id: "team_captain_info",
+ label:
+ "If yes, please list the first and last name and email of your team captain (captain is just for application purposes!). There is a limit of 5 members per team.",
+ type: "short_text",
required: false,
- description: "Optional. Upload will be enabled in a future release.",
+ maxLength: 300,
+ },
+ ],
+ },
+ {
+ id: "outreach",
+ title: "Outreach",
+ questions: [
+ {
+ id: "referral_source",
+ label: "How did you hear about HackBeanpot?",
+ type: "multi_select",
+ required: true,
+ options: [
+ { value: "facebook", label: "Facebook" },
+ { value: "instagram", label: "Instagram" },
+ { value: "linkedin", label: "LinkedIn" },
+ { value: "twitter", label: "Twitter" },
+ { value: "tiktok", label: "Tiktok" },
+ { value: "hbp_email_newsletter", label: "HBP Email/Newsletter" },
+ { value: "word_of_mouth", label: "Word of mouth/friends" },
+ { value: "hbp_outreach_events", label: "HBP Outreach events" },
+ {
+ value: "school_communications",
+ label: "School communications/newsletter features",
+ },
+ { value: "other_organization", label: "Other organization" },
+ { value: "other", label: "Other" },
+ ],
+ },
+ {
+ id: "referral_other",
+ label:
+ 'If you selected "Other organization" or "Other" above, please specify.',
+ type: "short_text",
+ required: false,
+ maxLength: 300,
+ },
+ ],
+ },
+ {
+ id: "cabin",
+ title: "Cabin Grouping",
+ description:
+ "Hackers come to HackBeanpot for many reasons. For each of the reasons listed, indicate how important it is to you!",
+ questions: [
+ {
+ id: "cabin_new_friends",
+ label: "Making new friends outside of your team",
+ type: "select",
+ required: true,
+ options: CABIN_IMPORTANCE_OPTIONS,
+ },
+ {
+ id: "cabin_workshops",
+ label: "Attending technical workshops",
+ type: "select",
+ required: true,
+ options: CABIN_IMPORTANCE_OPTIONS,
+ },
+ {
+ id: "cabin_fun",
+ label: "Having fun",
+ type: "select",
+ required: true,
+ options: CABIN_IMPORTANCE_OPTIONS,
+ },
+ {
+ id: "cabin_networking",
+ label: "Engaging in professional networking opportunities",
+ type: "select",
+ required: true,
+ options: CABIN_IMPORTANCE_OPTIONS,
+ },
+ {
+ id: "cabin_knowledge_exchange",
+ label: "Exchanging technical knowledge with others",
+ type: "select",
+ required: true,
+ options: CABIN_IMPORTANCE_OPTIONS,
+ },
+ {
+ id: "cabin_job_prep",
+ label: "Preparing for co-op/internship/job search",
+ type: "select",
+ required: true,
+ options: CABIN_IMPORTANCE_OPTIONS,
+ },
+ ],
+ },
+ {
+ id: "feedback",
+ title: "Core Feedback",
+ description:
+ "The HackBeanpot Core team is always looking to continue iterating and making this hackathon the best possible experience for everyone! We'd really appreciate it if you took a few minutes to leave some feedback for us :)",
+ questions: [
+ {
+ id: "feedback_comments",
+ label:
+ "Leave us any comments, questions, or suggestions on this application process!",
+ type: "long_text",
+ required: false,
+ maxLength: 2000,
+ },
+ {
+ id: "feedback_experience",
+ label:
+ "What can the Core team do to help you have the best experience at HackBeanpot 2026?",
+ type: "long_text",
+ required: false,
+ maxLength: 2000,
},
],
},
] as const;
export const DEFAULT_FORM_CONFIG: FormConfig = {
- sections: APPLICATION_SECTIONS.map((section) => ({
- id: section.id,
- title: section.title,
- questions: section.questions.map((question, index) => ({
- id: question.id,
- label: question.label,
- type: convertQuestionType(question.type),
- required: question.required,
- options: question.options?.map((option) => option.label),
- order: index + 1,
- })),
- })),
+ sections: APPLICATION_SECTIONS as unknown as FormConfig["sections"],
};
-
-function convertQuestionType(
- type: "short_text" | "long_text" | "select" | "multi_select" | "file_upload",
-): "text" | "textarea" | "select" | "checkbox" {
- switch (type) {
- case "short_text":
- return "text";
-
- case "long_text":
- return "textarea";
-
- case "select":
- return "select";
-
- case "multi_select":
- return "checkbox";
-
- case "file_upload":
- return "text";
-
- default:
- return "text";
- }
-}
diff --git a/apps/app-portal/src/lib/application/schema.test.ts b/apps/app-portal/src/lib/application/schema.test.ts
new file mode 100644
index 00000000..852cc3ce
--- /dev/null
+++ b/apps/app-portal/src/lib/application/schema.test.ts
@@ -0,0 +1,213 @@
+import { APPLICATION_SECTIONS } from "./questions";
+import { buildApplicationSchema, buildDefaultValues } from "./schema";
+import type { FormSection } from "./types";
+
+describe("buildApplicationSchema", () => {
+ it("builds a schema from the live 2026 question set with no required fields missing", () => {
+ const schema = buildApplicationSchema(APPLICATION_SECTIONS, "server");
+ // Every required question needs *some* answer for the whole thing to parse; fill in a
+ // representative valid value for every question (not just defaults, since selects/multi
+ // selects/file_uploads can't default to "" and still satisfy a required check).
+ const values: Record = {};
+ for (const section of APPLICATION_SECTIONS) {
+ for (const question of section.questions) {
+ if (question.type === "select") {
+ values[question.id] = question.options?.[0]?.value ?? "";
+ } else if (question.type === "multi_select") {
+ values[question.id] = question.options?.[0]
+ ? [question.options[0].value]
+ : [];
+ } else if (question.type === "file_upload") {
+ values[question.id] = "upload-id-123";
+ } else {
+ values[question.id] = "answer";
+ }
+ }
+ }
+
+ const result = schema.safeParse(values);
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects a submission missing a required field", () => {
+ const sections: FormSection[] = [
+ {
+ id: "s",
+ title: "S",
+ questions: [
+ {
+ id: "first_name",
+ label: "First name",
+ type: "short_text",
+ required: true,
+ },
+ ],
+ },
+ ];
+ const schema = buildApplicationSchema(sections, "server");
+ expect(schema.safeParse({ first_name: "" }).success).toBe(false);
+ expect(schema.safeParse({ first_name: "Ada" }).success).toBe(true);
+ });
+
+ it("enforces maxWords on long_text questions", () => {
+ const sections: FormSection[] = [
+ {
+ id: "s",
+ title: "S",
+ questions: [
+ {
+ id: "goals_long_answer",
+ label: "Goals",
+ type: "long_text",
+ required: true,
+ maxWords: 5,
+ },
+ ],
+ },
+ ];
+ const schema = buildApplicationSchema(sections, "client");
+ expect(
+ schema.safeParse({ goals_long_answer: "one two three four five" })
+ .success,
+ ).toBe(true);
+ expect(
+ schema.safeParse({
+ goals_long_answer: "one two three four five six",
+ }).success,
+ ).toBe(false);
+ });
+
+ it("requires file_upload questions to hold a non-empty upload ID string when required", () => {
+ const sections: FormSection[] = [
+ {
+ id: "s",
+ title: "S",
+ questions: [
+ {
+ id: "vaccination_card",
+ label: "Vaccination card",
+ type: "file_upload",
+ required: true,
+ },
+ ],
+ },
+ ];
+ const schema = buildApplicationSchema(sections, "server");
+ expect(schema.safeParse({ vaccination_card: null }).success).toBe(false);
+ expect(schema.safeParse({ vaccination_card: "" }).success).toBe(false);
+ expect(
+ schema.safeParse({ vaccination_card: "upload-id-123" }).success,
+ ).toBe(true);
+ });
+
+ it("accepts null on optional fields — the shape a saved draft reloads as after a refresh", () => {
+ // ApplicationForm's toResponses() converts an untouched "" to null before every
+ // autosave, so any optional field left blank comes back from Mongo as null once the
+ // page is reloaded. The client schema (used by the "Next" button's per-section
+ // validation) must accept that shape or a refresh makes optional fields block
+ // navigation as if they were required, even though nothing was actually filled in
+ // differently. Covers short_text/long_text, select, and multi_select.
+ const sections: FormSection[] = [
+ {
+ id: "s",
+ title: "S",
+ questions: [
+ { id: "text", label: "Text", type: "short_text", required: false },
+ {
+ id: "select",
+ label: "Select",
+ type: "select",
+ required: false,
+ options: [{ value: "a", label: "A" }],
+ },
+ {
+ id: "multi",
+ label: "Multi",
+ type: "multi_select",
+ required: false,
+ options: [{ value: "a", label: "A" }],
+ },
+ ],
+ },
+ ];
+ const clientSchema = buildApplicationSchema(sections, "client");
+ const result = clientSchema.safeParse({
+ text: null,
+ select: null,
+ multi: null,
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects unknown keys on the server (strict mode) but not on the client", () => {
+ const sections: FormSection[] = [
+ {
+ id: "s",
+ title: "S",
+ questions: [
+ {
+ id: "first_name",
+ label: "First name",
+ type: "short_text",
+ required: false,
+ },
+ ],
+ },
+ ];
+ const serverSchema = buildApplicationSchema(sections, "server");
+ const clientSchema = buildApplicationSchema(sections, "client");
+ expect(
+ serverSchema.safeParse({ first_name: "Ada", unexpected: "x" }).success,
+ ).toBe(false);
+ expect(
+ clientSchema.safeParse({ first_name: "Ada", unexpected: "x" }).success,
+ ).toBe(true);
+ });
+});
+
+describe("buildDefaultValues", () => {
+ it("gives every question in the live 2026 question set a default value", () => {
+ const defaults = buildDefaultValues(APPLICATION_SECTIONS);
+ for (const section of APPLICATION_SECTIONS) {
+ for (const question of section.questions) {
+ expect(
+ Object.prototype.hasOwnProperty.call(defaults, question.id),
+ ).toBe(true);
+ }
+ }
+ });
+
+ it("defaults multi_select to [] and file_upload to null", () => {
+ const sections: FormSection[] = [
+ {
+ id: "s",
+ title: "S",
+ questions: [
+ { id: "a", label: "A", type: "multi_select", required: false },
+ { id: "b", label: "B", type: "file_upload", required: false },
+ { id: "c", label: "C", type: "short_text", required: false },
+ ],
+ },
+ ];
+ expect(buildDefaultValues(sections)).toEqual({ a: [], b: null, c: "" });
+ });
+});
+
+describe("APPLICATION_SECTIONS (2026 content)", () => {
+ it("has no duplicate question IDs across sections", () => {
+ const ids = APPLICATION_SECTIONS.flatMap((s) =>
+ s.questions.map((q) => q.id),
+ );
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("gives every select/multi_select question at least one option", () => {
+ for (const section of APPLICATION_SECTIONS) {
+ for (const question of section.questions) {
+ if (question.type === "select" || question.type === "multi_select") {
+ expect(question.options?.length ?? 0).toBeGreaterThan(0);
+ }
+ }
+ }
+ });
+});
diff --git a/apps/app-portal/src/lib/application/schema.ts b/apps/app-portal/src/lib/application/schema.ts
index 8df7b088..00541f67 100644
--- a/apps/app-portal/src/lib/application/schema.ts
+++ b/apps/app-portal/src/lib/application/schema.ts
@@ -1,15 +1,16 @@
import { z } from "zod";
import { APPLICATION_SECTIONS } from "./questions";
-import type { Question, QuestionType } from "./types";
+import type { FormSection, Question, QuestionType } from "./types";
type SchemaTarget = "client" | "server";
-function fieldSchema(
- question: Question,
- target: SchemaTarget = "client",
-): z.ZodTypeAny {
- const { type, required, options, maxLength } = question;
+function countWords(value: string): number {
+ return value.trim().length === 0 ? 0 : value.trim().split(/\s+/).length;
+}
+
+function fieldSchema(question: Question): z.ZodTypeAny {
+ const { type, required, options, maxLength, maxWords } = question;
switch (type as QuestionType) {
case "short_text":
@@ -24,15 +25,24 @@ function fieldSchema(
`${question.label} must be ${maxLength} characters or fewer`,
);
}
+ if (type === "long_text" && maxWords) {
+ schema = schema.refine(
+ (value) => countWords(value) <= maxWords,
+ `${question.label} must be ${maxWords} words or fewer`,
+ );
+ }
if (required) {
return schema.min(1, requiredMessage);
}
- if (target === "server") {
- return z
- .union([schema, z.literal(""), z.null(), z.undefined()])
- .optional();
- }
- return schema.optional().or(z.literal(""));
+ // Optional fields round-trip through Mongo as `null` — ApplicationForm's
+ // toResponses() converts an untouched "" to null before every autosave — so both
+ // client and server need to accept that shape. Without this, reloading a saved
+ // draft (e.g. after a refresh) would populate untouched optional fields with
+ // `null`, which the client schema rejected, making them block "Next" as if they
+ // were required — even though the very same value validates fine on submit.
+ return z
+ .union([schema, z.literal(""), z.null(), z.undefined()])
+ .optional();
}
case "select": {
const values = options?.map((o) => o.value) ?? [];
@@ -42,12 +52,9 @@ function fieldSchema(
if (required) {
return enumSchema;
}
- if (target === "server") {
- return z
- .union([enumSchema, z.literal(""), z.null(), z.undefined()])
- .optional();
- }
- return z.union([enumSchema, z.literal("")]);
+ return z
+ .union([enumSchema, z.literal(""), z.null(), z.undefined()])
+ .optional();
}
case "multi_select": {
const values = options?.map((o) => o.value) ?? [];
@@ -61,71 +68,88 @@ function fieldSchema(
if (required) {
return schema.min(1, requiredMessage);
}
- if (target === "server") {
- return z
- .union([schema, z.null(), z.undefined()])
- .optional()
- .default([]);
- }
- return schema.optional().default([]);
+ return z.union([schema, z.null(), z.undefined()]).optional().default([]);
}
case "file_upload": {
+ // Both client and server hold the same value here: the upload ID returned by
+ // /api/v1/uploads/sign once the file has actually finished uploading to GCS (see
+ // FileUploadField / FileUpload). There's no separate "browser File object" stage in
+ // the schema — the upload happens before the field's value is ever set.
const requiredMessage = `${question.label} is required`;
- if (target === "server") {
- const uploadIdSchema = z.string().min(1, requiredMessage);
- if (required) return uploadIdSchema;
- return z.union([z.string(), z.null(), z.undefined()]).optional();
- }
+ const uploadIdSchema = z.string().min(1, requiredMessage);
+ if (required) return uploadIdSchema;
return z
- .union([z.instanceof(File), z.null(), z.undefined()])
- .refine((file) => !required || file instanceof File, {
- message: requiredMessage,
- });
+ .union([z.string(), z.literal(""), z.null(), z.undefined()])
+ .optional();
}
default:
return z.unknown();
}
}
-function buildShape(target: SchemaTarget): Record {
+function buildShape(
+ sections: readonly FormSection[],
+): Record {
const shape: Record = {};
- for (const section of APPLICATION_SECTIONS) {
+ for (const section of sections) {
for (const question of section.questions) {
- shape[question.id] = fieldSchema(question, target);
+ shape[question.id] = fieldSchema(question);
}
}
return shape;
}
-// Client-facing schema: used by the form's zodResolver, where file_upload
-// fields hold a browser File object.
-export const applicationSchema = z.object(buildShape("client"));
-
-export type ApplicationSchemaValues = z.infer;
-
-// Server-facing schema: used to validate a submission payload, where
-// file_upload fields hold an upload ID string instead of a File. Strict so
-// unknown keys in the payload are rejected.
-export const applicationSubmissionSchema = z
- .object(buildShape("server"))
- .strict();
-
-export type ApplicationSubmissionValues = z.infer<
- typeof applicationSubmissionSchema
->;
+// Builds a zod schema for a given (possibly admin-edited, possibly live-fetched) section list.
+// Used both for the client-facing resolver and the server-facing submission validator — per-field
+// validation is identical either way (see fieldSchema); "server" only additionally rejects
+// unknown keys via .strict(), since the client resolver has no such need.
+export function buildApplicationSchema(
+ sections: readonly FormSection[],
+ target: SchemaTarget,
+): z.ZodObject> {
+ const schema = z.object(buildShape(sections));
+ return target === "server" ? (schema.strict() as typeof schema) : schema;
+}
-export function createDefaultValues(): ApplicationSchemaValues {
+export function buildDefaultValues(
+ sections: readonly FormSection[],
+): Record {
const values: Record = {};
- for (const section of APPLICATION_SECTIONS) {
+ for (const section of sections) {
for (const question of section.questions) {
if (question.type === "multi_select") {
values[question.id] = [];
} else if (question.type === "file_upload") {
- values[question.id] = null; // "" is not in the file_upload union; null is
+ values[question.id] = null;
} else {
values[question.id] = "";
}
}
}
- return values as ApplicationSchemaValues;
+ return values;
+}
+
+// Client-facing schema: used by the form's zodResolver against the static default question set.
+// Call buildApplicationSchema(sections, "client") directly wherever the live (possibly
+// admin-edited) section list is available instead.
+export const applicationSchema = buildApplicationSchema(
+ APPLICATION_SECTIONS,
+ "client",
+);
+
+export type ApplicationSchemaValues = z.infer;
+
+// Server-facing schema against the static default question set — see submit() in
+// lib/application/service.ts, which validates against the *live* config instead.
+export const applicationSubmissionSchema = buildApplicationSchema(
+ APPLICATION_SECTIONS,
+ "server",
+);
+
+export type ApplicationSubmissionValues = z.infer<
+ typeof applicationSubmissionSchema
+>;
+
+export function createDefaultValues(): ApplicationSchemaValues {
+ return buildDefaultValues(APPLICATION_SECTIONS) as ApplicationSchemaValues;
}
diff --git a/apps/app-portal/src/lib/application/service.ts b/apps/app-portal/src/lib/application/service.ts
index d69d5002..bd283867 100644
--- a/apps/app-portal/src/lib/application/service.ts
+++ b/apps/app-portal/src/lib/application/service.ts
@@ -1,5 +1,6 @@
+import { getFormConfig } from "@/lib/admin/form-config-service";
import { getSingleton } from "@/lib/admin/singleton-service";
-import { getDb } from "@/lib/db";
+import { getDb, resolveCollectionName } from "@/lib/db";
import { SingletonKey } from "@/lib/types/singleton";
import {
@@ -8,11 +9,12 @@ import {
RegistrationNotOpenError,
ValidationError,
} from "./errors";
-import { applicationSubmissionSchema } from "./schema";
+import { buildApplicationSchema } from "./schema";
import type {
ApplicationDraft,
ApplicationResponses,
ApplicationSubmission,
+ FormSection,
RegistrationState,
} from "./types";
@@ -28,16 +30,7 @@ import type {
*
* Requires env vars: MONGO_PROD_CONNECTION_STRING, MONGO_SERVER_DBNAME
*/
-const COLLECTION = "applicant_data";
-
-// const MOCK_REGISTRATION_STATE: RegistrationState = {
-// registrationStatus: "open",
-// opensAt: "2026-01-01T00:00:00Z",
-// closesAt: "2026-12-01T00:00:00Z",
-// applicationStatus: "submitted",
-// responses: {},
-// updatedAt: null,
-// };
+const COLLECTION = resolveCollectionName("applicant_data");
async function getRegistrationWindow(): Promise<{
opensAt: string | null;
@@ -50,6 +43,13 @@ async function getRegistrationWindow(): Promise<{
return { opensAt, closesAt };
}
+// The live, admin-editable question set (see /admin/settings + FormConfigEditor). Falls back to
+// the code-level default (lib/application/questions.ts) until an admin saves a change.
+async function getSections(): Promise {
+ const config = await getFormConfig();
+ return config.sections;
+}
+
export async function getRegistrationState(
userId?: string,
): Promise {
@@ -72,6 +72,8 @@ export async function getRegistrationState(
}
}
+ const sections = await getSections();
+
return {
registrationStatus,
opensAt: opensAt ?? "",
@@ -79,9 +81,39 @@ export async function getRegistrationState(
applicationStatus,
responses: {},
updatedAt: null,
+ sections,
};
}
+function isAnswered(value: string | string[] | null | undefined): boolean {
+ if (value === null || value === undefined) return false;
+ if (Array.isArray(value)) return value.length > 0;
+ return value.trim().length > 0;
+}
+
+// Real completion percentage for the dashboard's in-progress view, based on how many of the
+// live form's questions have an answer saved in the applicant's draft.
+export async function getCompletionPercent(userId: string): Promise {
+ const sections = await getSections();
+ const totalQuestions = sections.reduce(
+ (sum, section) => sum + section.questions.length,
+ 0,
+ );
+ if (totalQuestions === 0) return 0;
+
+ const draft = await getDraft(userId);
+ if (!draft) return 0;
+
+ const answeredQuestions = sections.reduce(
+ (sum, section) =>
+ sum +
+ section.questions.filter((q) => isAnswered(draft.responses[q.id])).length,
+ 0,
+ );
+
+ return Math.round((answeredQuestions / totalQuestions) * 100);
+}
+
export async function isRegistrationOpen(): Promise {
const state = await getRegistrationState();
return state.registrationStatus === "open";
@@ -118,12 +150,30 @@ export async function saveDraft(
): Promise {
await assertRegistrationWindowOpen();
const db = await getDb();
+ const collection = db.collection(COLLECTION);
+ const existing = await collection.findOne({ userId });
+
+ // A submitted application is authoritative. Without this, a stray/delayed autosave
+ // request (e.g. one already in flight when the user clicks Submit) would still land
+ // here and silently overwrite the submitted responses back to whatever stale draft
+ // content was in the field values at the time it was queued — applicationStatus would
+ // stay "submitted" the whole time, so nothing would even look wrong to the applicant.
+ if (existing?.applicationStatus === "submitted") {
+ return {
+ responses: existing.applicationResponses as ApplicationResponses,
+ updatedAt: (existing.lastSavedAt as Date).toISOString(),
+ status: "draft",
+ };
+ }
+
const now = new Date();
- await db.collection(COLLECTION).updateOne(
- { userId },
+ await collection.updateOne(
+ // Re-check applicationStatus in the update filter too (not just the read above) to
+ // narrow the race window between the findOne and this write.
+ { userId, applicationStatus: { $ne: "submitted" } },
{
$set: { applicationResponses: responses, lastSavedAt: now },
- // $setOnInsert never overwrites an existing applicationStatus,which prevents a draft save from downgrading a submitted application.
+ // $setOnInsert never overwrites an existing applicationStatus, which prevents a draft save from downgrading a submitted application.
$setOnInsert: {
userId,
applicationStatus: "in-progress",
@@ -147,7 +197,9 @@ export async function submit(
throw new AlreadySubmittedError();
}
- const parsed = applicationSubmissionSchema.safeParse(responses);
+ const sections = await getSections();
+ const submissionSchema = buildApplicationSchema(sections, "server");
+ const parsed = submissionSchema.safeParse(responses);
if (!parsed.success) {
throw new ValidationError(parsed.error);
}
diff --git a/apps/app-portal/src/lib/application/types.ts b/apps/app-portal/src/lib/application/types.ts
index bc9d3d9f..c4fc3c4e 100644
--- a/apps/app-portal/src/lib/application/types.ts
+++ b/apps/app-portal/src/lib/application/types.ts
@@ -19,6 +19,10 @@ export interface Question {
description?: string;
/** Max character length; only meaningful for short_text/long_text. */
maxLength?: number;
+ /** Max word count; only meaningful for long_text. */
+ maxWords?: number;
+ /** Accepted MIME types; only meaningful for file_upload. Defaults to the app-wide allow-list. */
+ accept?: readonly string[];
}
export interface FormSection {
@@ -44,7 +48,7 @@ export interface ApplicationSubmission {
export type ApplicationFormValues = Record<
string,
- string | string[] | File | null | undefined
+ string | string[] | null | undefined
>;
export type RegistrationStatus = "before_open" | "open" | "closed";
@@ -56,4 +60,6 @@ export interface RegistrationState {
applicationStatus: "draft" | "submitted";
responses: ApplicationResponses;
updatedAt: string | null;
+ /** The live, admin-editable question set this application should render/validate against. */
+ sections: FormSection[];
}
diff --git a/apps/app-portal/src/lib/auth/config.ts b/apps/app-portal/src/lib/auth/config.ts
index 15b93bfc..579cf0ad 100644
--- a/apps/app-portal/src/lib/auth/config.ts
+++ b/apps/app-portal/src/lib/auth/config.ts
@@ -31,5 +31,10 @@ export const authOptions: NextAuthOptions = {
}
return session;
},
+ async redirect({ url, baseUrl }) {
+ if (url.startsWith("/")) return `${baseUrl}${url}`
+ else if (new URL(url).origin === baseUrl) return url
+ return baseUrl
+ }
},
};
diff --git a/apps/app-portal/src/lib/auth/email-transport.ts b/apps/app-portal/src/lib/auth/email-transport.ts
index 7fd36188..4e48a182 100644
--- a/apps/app-portal/src/lib/auth/email-transport.ts
+++ b/apps/app-portal/src/lib/auth/email-transport.ts
@@ -8,6 +8,22 @@ import { join } from "path";
const TEMPLATE_PATH = join(process.cwd(), "src/lib/auth/email-template.html");
+// Derive the origin (NEXTAUTH_URL may carry an /auth path we must strip). Falling back to
+// localhost silently would ship magic-link emails with a broken logo/link in production, so
+// that fallback is only allowed outside of it.
+function resolveOrigin(): string {
+ const nextAuthUrl = process.env.NEXTAUTH_URL;
+ if (!nextAuthUrl) {
+ if (process.env.NODE_ENV === "production") {
+ throw new Error(
+ "Missing NEXTAUTH_URL: required in production to build absolute email URLs.",
+ );
+ }
+ return "http://localhost:3000";
+ }
+ return new URL(nextAuthUrl).origin;
+}
+
async function customRequest(params: SendVerificationRequestParams) {
const { identifier, url, provider, theme } = params;
const { host } = new URL(url);
@@ -48,10 +64,7 @@ function html(params: { url: string; host: string; theme: Theme }) {
};
// Logo is served from /public; use an absolute URL so email clients can load it.
- // Derive the origin (NEXTAUTH_URL may carry an /auth path we must strip).
- const origin = new URL(process.env.NEXTAUTH_URL ?? "http://localhost:3000")
- .origin;
- const logoUrl = `${origin}/email_logo.png`;
+ const logoUrl = `${resolveOrigin()}/email_logo.png`;
const replacements: Record = {
url,
diff --git a/apps/app-portal/src/lib/config/site.ts b/apps/app-portal/src/lib/config/site.ts
new file mode 100644
index 00000000..ac1efeef
--- /dev/null
+++ b/apps/app-portal/src/lib/config/site.ts
@@ -0,0 +1,3 @@
+// Site-wide constants that would otherwise be duplicated as inline literals across components.
+
+export const SUPPORT_EMAIL = "applications@hackbeanpot.com";
diff --git a/apps/app-portal/src/lib/db.ts b/apps/app-portal/src/lib/db.ts
index 75cbc327..3d5949ac 100644
--- a/apps/app-portal/src/lib/db.ts
+++ b/apps/app-portal/src/lib/db.ts
@@ -20,12 +20,14 @@ declare global {
// Lazily creates and caches the connection so importing this module
// doesn't throw when MONGO_PROD_CONNECTION_STRING is absent (e.g. in dev
// without a local Mongo instance). The error surfaces only when getDb() is called.
+//
+// The cache is always stored on `global` (not just outside production): in dev this
+// survives Next.js's module reloads across HMR; in production it's what stops every
+// getDb() call from opening a brand-new MongoClient connection that's never closed.
function getClientPromise(uri: string): Promise {
if (global.__mongoClientPromise__) return global.__mongoClientPromise__;
const promise = new MongoClient(uri).connect();
- if (process.env.NODE_ENV !== "production") {
- global.__mongoClientPromise__ = promise;
- }
+ global.__mongoClientPromise__ = promise;
return promise;
}
diff --git a/apps/app-portal/src/lib/stats/aggregations.test.ts b/apps/app-portal/src/lib/stats/aggregations.test.ts
index f86f8c3d..454fff40 100644
--- a/apps/app-portal/src/lib/stats/aggregations.test.ts
+++ b/apps/app-portal/src/lib/stats/aggregations.test.ts
@@ -15,12 +15,18 @@ import {
// "_test" suffixed collection name.
const COLLECTION_NAME = "applicant_data_test";
-// Status casing mirrors production applicant_data (uppercase-first;
-// rsvp "Not Attending" is space-separated, not hyphenated).
+// Canonical lowercase-hyphenated casing — matches the real enums in lib/types/user.ts and
+// the values every actual write path (submit(), admin decision/RSVP edits, saveRsvp()) is
+// restricted to via zod z.enum(...). A previous version of this fixture used
+// capitalized/space-separated values ("Submitted", "Not Attending") that no real write path
+// can ever produce — that happened to make the aggregations bug (matching those same wrong
+// literals) look like it passed, without actually exercising real-world data shapes. One
+// record below keeps mixed casing (see #2) specifically to prove getTotals/getDecisionBreakdown
+// are still case-insensitive, not just literal-matching the canonical casing.
const APPLICANTS = [
// 1: not-started, no decision/rsvp, no submission
{ applicationStatus: "not-started" },
- // 2
+ // 2 — mixed casing, to prove case-insensitivity rather than just canonical-casing matching
{
applicationStatus: "Submitted",
decisionStatus: "Admitted",
@@ -30,46 +36,47 @@ const APPLICANTS = [
},
// 3
{
- applicationStatus: "Submitted",
- decisionStatus: "Admitted",
- rsvpStatus: "Confirmed",
+ applicationStatus: "submitted",
+ decisionStatus: "admitted",
+ rsvpStatus: "confirmed",
appSubmissionTime: "2024-01-02T00:00:00.000Z",
applicationResponses: { school: "NEU" },
},
- // 4
+ // 4 — includes a multi_select ("race") field to exercise the $unwind path for the
+ // now-corrected demographics dimension name (was "races", doesn't exist on real docs)
{
- applicationStatus: "Submitted",
- decisionStatus: "Admitted",
- rsvpStatus: "Not Attending",
+ applicationStatus: "submitted",
+ decisionStatus: "admitted",
+ rsvpStatus: "not-attending",
appSubmissionTime: "2024-01-02T00:00:00.000Z",
- applicationResponses: { school: "BU" },
+ applicationResponses: { school: "BU", race: ["white", "asian"] },
},
// 5
{
- applicationStatus: "Submitted",
- decisionStatus: "Waitlisted",
+ applicationStatus: "submitted",
+ decisionStatus: "waitlisted",
appSubmissionTime: "2024-01-03T00:00:00.000Z",
applicationResponses: { school: "BU" },
},
// 6
{
- applicationStatus: "Submitted",
- decisionStatus: "Declined",
+ applicationStatus: "submitted",
+ decisionStatus: "declined",
appSubmissionTime: "2024-01-03T00:00:00.000Z",
applicationResponses: { school: "MIT" },
},
// 7
{
- applicationStatus: "Submitted",
- decisionStatus: "Declined",
+ applicationStatus: "submitted",
+ decisionStatus: "declined",
appSubmissionTime: "2024-01-04T00:00:00.000Z",
applicationResponses: { school: "MIT" },
},
// 8
{
- applicationStatus: "Submitted",
- decisionStatus: "Admitted",
- rsvpStatus: "Confirmed",
+ applicationStatus: "submitted",
+ decisionStatus: "admitted",
+ rsvpStatus: "confirmed",
appSubmissionTime: "2024-01-04T00:00:00.000Z",
applicationResponses: { school: "NEU" },
},
@@ -77,9 +84,9 @@ const APPLICANTS = [
{ applicationStatus: "in-progress" },
// 10
{
- applicationStatus: "Submitted",
- decisionStatus: "Admitted",
- rsvpStatus: "Unconfirmed",
+ applicationStatus: "submitted",
+ decisionStatus: "admitted",
+ rsvpStatus: "unconfirmed",
appSubmissionTime: "2024-01-05T00:00:00.000Z",
applicationResponses: { school: "BU" },
},
@@ -147,7 +154,7 @@ describe("getRsvpBreakdown", () => {
it("groups rsvpStatus among admitted applicants", async () => {
expect(await getRsvpBreakdown(db)).toEqual([
{ status: "confirmed", count: 3 },
- { status: "not attending", count: 1 },
+ { status: "not-attending", count: 1 },
{ status: "unconfirmed", count: 1 },
]);
});
@@ -179,4 +186,12 @@ describe("getDemographics", () => {
const demographics = await getDemographics(db);
expect(demographics.gender).toEqual([]);
});
+
+ it("unwinds the multi_select race dimension", async () => {
+ const demographics = await getDemographics(db);
+ expect(byLabel(demographics.race)).toEqual([
+ { label: "asian", count: 1 },
+ { label: "white", count: 1 },
+ ]);
+ });
});
diff --git a/apps/app-portal/src/lib/stats/aggregations.ts b/apps/app-portal/src/lib/stats/aggregations.ts
index 338ab4c0..9c4c29b6 100644
--- a/apps/app-portal/src/lib/stats/aggregations.ts
+++ b/apps/app-portal/src/lib/stats/aggregations.ts
@@ -66,6 +66,11 @@ const lowerEq = (field: string, value: string): Document => ({
export async function getTotals(db: Db): Promise {
const col = db.collection(APPLICANT_COLLECTION);
+ // Real stored values are lowercase-hyphenated (see the enums in lib/types/user.ts, e.g.
+ // "submitted", "not-attending") — these previously used capitalized/spaced literals
+ // ("Submitted", "Not Attending") that never matched, so every count except the raw
+ // total was always 0. lowerEq does a case-insensitive comparison as a safety net on
+ // top of using the correct canonical values.
const [
applicants,
submitted,
@@ -77,13 +82,13 @@ export async function getTotals(db: Db): Promise {
rsvpUnconfirmed,
] = await Promise.all([
col.countDocuments({}),
- col.countDocuments({ applicationStatus: "Submitted" }),
- col.countDocuments({ decisionStatus: "Admitted" }),
- col.countDocuments({ decisionStatus: "Waitlisted" }),
- col.countDocuments({ decisionStatus: "Declined" }),
- col.countDocuments({ rsvpStatus: "Confirmed" }),
- col.countDocuments({ rsvpStatus: "Not Attending" }),
- col.countDocuments({ rsvpStatus: "Unconfirmed" }),
+ col.countDocuments(lowerEq("applicationStatus", "submitted")),
+ col.countDocuments(lowerEq("decisionStatus", "admitted")),
+ col.countDocuments(lowerEq("decisionStatus", "waitlisted")),
+ col.countDocuments(lowerEq("decisionStatus", "declined")),
+ col.countDocuments(lowerEq("rsvpStatus", "confirmed")),
+ col.countDocuments(lowerEq("rsvpStatus", "not-attending")),
+ col.countDocuments(lowerEq("rsvpStatus", "unconfirmed")),
]);
return {
applicants,
@@ -107,7 +112,7 @@ export async function getStatusBreakdown(db: Db): Promise {
export async function getDecisionBreakdown(db: Db): Promise {
const col = db.collection(APPLICANT_COLLECTION);
const pipeline = [
- { $match: { applicationStatus: "Submitted" } },
+ { $match: lowerEq("applicationStatus", "submitted") },
...statusBreakdownPipeline("decisionStatus"),
];
return col.aggregate(pipeline).toArray();
diff --git a/apps/app-portal/src/lib/stats/service.test.ts b/apps/app-portal/src/lib/stats/service.test.ts
new file mode 100644
index 00000000..e80381ca
--- /dev/null
+++ b/apps/app-portal/src/lib/stats/service.test.ts
@@ -0,0 +1,97 @@
+import { resolveDemographicsLabels } from "./service";
+import type { DemographicsBreakdown } from "./types";
+import type { FormSection } from "@/lib/application/types";
+
+const sections: FormSection[] = [
+ {
+ id: "school",
+ title: "School",
+ questions: [
+ {
+ id: "school",
+ label: "What school do you attend?",
+ type: "select",
+ required: true,
+ options: [
+ {
+ value: "northeastern_university",
+ label: "Northeastern University",
+ },
+ { value: "boston_university", label: "Boston University" },
+ ],
+ },
+ {
+ id: "major",
+ label: "What is your major?",
+ type: "short_text",
+ required: true,
+ },
+ ],
+ },
+];
+
+function demographics(
+ overrides: Partial,
+): DemographicsBreakdown {
+ return {
+ school: [],
+ education_year: [],
+ major: [],
+ gender: [],
+ race: [],
+ tshirt_size: [],
+ hackathon_experience: [],
+ cs_classes: [],
+ ...overrides,
+ };
+}
+
+describe("resolveDemographicsLabels", () => {
+ it("maps raw option values to their display label for select/multi_select dimensions", () => {
+ const raw = demographics({
+ school: [
+ { label: "northeastern_university", count: 3 },
+ { label: "boston_university", count: 1 },
+ ],
+ });
+
+ const resolved = resolveDemographicsLabels(raw, sections);
+
+ expect(resolved.school).toEqual([
+ { label: "Northeastern University", count: 3 },
+ { label: "Boston University", count: 1 },
+ ]);
+ });
+
+ it("falls back to the raw value for free-text dimensions with no options at all", () => {
+ const raw = demographics({
+ major: [{ label: "Computer Science", count: 5 }],
+ });
+
+ const resolved = resolveDemographicsLabels(raw, sections);
+
+ expect(resolved.major).toEqual([{ label: "Computer Science", count: 5 }]);
+ });
+
+ it("falls back to the raw value when it doesn't match any known option (e.g. a removed option)", () => {
+ const raw = demographics({
+ school: [{ label: "some_school_removed_from_the_form", count: 2 }],
+ });
+
+ const resolved = resolveDemographicsLabels(raw, sections);
+
+ expect(resolved.school).toEqual([
+ { label: "some_school_removed_from_the_form", count: 2 },
+ ]);
+ });
+
+ it("falls back to the raw value for a dimension with no matching question in the given sections", () => {
+ const raw = demographics({
+ gender: [{ label: "male", count: 4 }],
+ });
+
+ const resolved = resolveDemographicsLabels(raw, sections);
+
+ expect(resolved.gender).toEqual([{ label: "male", count: 4 }]);
+ });
+});
diff --git a/apps/app-portal/src/lib/stats/service.ts b/apps/app-portal/src/lib/stats/service.ts
index 7882ec22..02019e28 100644
--- a/apps/app-portal/src/lib/stats/service.ts
+++ b/apps/app-portal/src/lib/stats/service.ts
@@ -1,4 +1,6 @@
import { getDb } from "@/lib/db";
+import { getFormConfig } from "@/lib/admin/form-config-service";
+import type { FormSection } from "@/lib/application/types";
import {
getDecisionBreakdown,
@@ -8,10 +10,44 @@ import {
getTimeline,
getTotals,
} from "./aggregations";
-import type { StatsPayload } from "./types";
+import type { DemographicsBreakdown, StatsPayload } from "./types";
const CACHE_TTL_MS = 60_000;
+// getDemographics groups by the raw stored value (e.g. "black_or_african_american",
+// "1st_year") — the option's `value` slug, not its display `label`. Map each dimension's
+// entries through the live form config's option list so the dashboard shows the same
+// friendly text applicants actually saw ("Black or African American", "1st year")
+// instead of the slug. Falls back to the raw value for anything with no matching
+// option (free-text dimensions like "major", or a value from a since-removed option).
+export function resolveDemographicsLabels(
+ demographics: DemographicsBreakdown,
+ sections: FormSection[],
+): DemographicsBreakdown {
+ const optionLabelsByQuestionId = new Map>();
+ for (const section of sections) {
+ for (const question of section.questions) {
+ if (!question.options) continue;
+ optionLabelsByQuestionId.set(
+ question.id,
+ new Map(question.options.map((o) => [o.value, o.label])),
+ );
+ }
+ }
+
+ const resolved = {} as DemographicsBreakdown;
+ for (const dimension of Object.keys(demographics) as Array<
+ keyof DemographicsBreakdown
+ >) {
+ const optionLabels = optionLabelsByQuestionId.get(dimension);
+ resolved[dimension] = demographics[dimension].map((entry) => ({
+ ...entry,
+ label: optionLabels?.get(entry.label) ?? entry.label,
+ }));
+ }
+ return resolved;
+}
+
// In-memory cache, per server process only. Fine at this scale (single instance,
// admin-only traffic); won't stay in sync across multiple instances/replicas.
let cache: { result: StatsPayload; timestamp: number } | null = null;
@@ -28,8 +64,9 @@ export async function getStats(): Promise {
statusBreakdown,
decisionBreakdown,
rsvpBreakdown,
- demographics,
+ rawDemographics,
timeline,
+ formConfig,
] = await Promise.all([
getTotals(db),
getStatusBreakdown(db),
@@ -37,8 +74,14 @@ export async function getStats(): Promise {
getRsvpBreakdown(db),
getDemographics(db),
getTimeline(db),
+ getFormConfig(),
]);
+ const demographics = resolveDemographicsLabels(
+ rawDemographics,
+ formConfig.sections,
+ );
+
const metrics = [
{
label: "Total Applications",
diff --git a/apps/app-portal/src/lib/stats/types.ts b/apps/app-portal/src/lib/stats/types.ts
index 2111dab4..6858e861 100644
--- a/apps/app-portal/src/lib/stats/types.ts
+++ b/apps/app-portal/src/lib/stats/types.ts
@@ -33,15 +33,19 @@ export interface BreakdownEntry {
count: number;
}
+// Must match real question IDs in lib/application/questions.ts (applicationResponses.) —
+// these previously used names ("yearOfEducation", "majors", "races", "shirtSize",
+// "hackathonsAttended", "csClassesTaken") that don't exist on any applicant document, so
+// those charts were always empty.
export const DEMOGRAPHICS_DIMENSIONS = [
"school",
- "yearOfEducation",
- "majors",
+ "education_year",
+ "major",
"gender",
- "races",
- "shirtSize",
- "hackathonsAttended",
- "csClassesTaken",
+ "race",
+ "tshirt_size",
+ "hackathon_experience",
+ "cs_classes",
] as const;
export type DemographicsDimension = (typeof DEMOGRAPHICS_DIMENSIONS)[number];
diff --git a/apps/app-portal/src/lib/status/rsvp.ts b/apps/app-portal/src/lib/status/rsvp.ts
index 8b50afdf..194d439d 100644
--- a/apps/app-portal/src/lib/status/rsvp.ts
+++ b/apps/app-portal/src/lib/status/rsvp.ts
@@ -8,4 +8,4 @@ export const rsvpSchema = z.object({
additionalNotes: z.string().trim().max(400),
});
-export type RsvpSubmission = z.infer;
\ No newline at end of file
+export type RsvpSubmission = z.infer;
diff --git a/apps/app-portal/src/lib/status/service.ts b/apps/app-portal/src/lib/status/service.ts
index 76e335f6..2b92b287 100644
--- a/apps/app-portal/src/lib/status/service.ts
+++ b/apps/app-portal/src/lib/status/service.ts
@@ -1,6 +1,7 @@
-import { getDb } from "@/lib/db";
+import { getDb, resolveCollectionName } from "@/lib/db";
import { requireUser } from "@/lib/auth/guards";
import { getSingleton } from "@/lib/admin/singleton-service";
+import { getCompletionPercent } from "@/lib/application/service";
import { SingletonKey } from "@/lib/types/singleton";
import { returnDashboardBranch } from "./machine";
import { rsvpSchema } from "./rsvp";
@@ -12,6 +13,8 @@ import type {
const DEFAULT_FUTURE_DATE = new Date("9999-12-31T23:59:59.999Z");
+const APPLICANT_COLLECTION = resolveCollectionName("applicant_data");
+
export class StatusError extends Error {
status: number;
@@ -45,7 +48,7 @@ export async function getApplicantStatus(
userId: string,
): Promise {
const db = await getDb();
- const doc = await db.collection("applicant_data").findOne({ userId });
+ const doc = await db.collection(APPLICANT_COLLECTION).findOne({ userId });
if (!doc) {
return {
@@ -85,6 +88,11 @@ export async function getPortalStatus(): Promise {
now: new Date(),
});
+ // Only the in-progress view actually displays this; everything past it means the
+ // application is done, so there's nothing to compute.
+ const completionPercent =
+ branch === "in-progress" ? await getCompletionPercent(userId) : 100;
+
return {
branch,
status: user,
@@ -95,6 +103,7 @@ export async function getPortalStatus(): Promise {
? new Date().toISOString()
: DEFAULT_FUTURE_DATE.toISOString(),
},
+ completionPercent,
};
}
@@ -104,7 +113,7 @@ export async function saveRsvp(
): Promise {
const parsedPayload = rsvpSchema.parse(payload);
const db = await getDb();
- const collection = db.collection("applicant_data");
+ const collection = db.collection(APPLICANT_COLLECTION);
const applicant = await collection.findOne({ userId });
if (!applicant || applicant.decisionStatus !== "admitted") {
diff --git a/apps/app-portal/src/lib/status/types.ts b/apps/app-portal/src/lib/status/types.ts
index 32c13a8c..917633bf 100644
--- a/apps/app-portal/src/lib/status/types.ts
+++ b/apps/app-portal/src/lib/status/types.ts
@@ -55,4 +55,6 @@ export type PortalStatusResponse = {
branch: DashboardBranch;
status: ApplicantStatus;
decisionDates: SerializedDecisionDates;
+ /** Real completion % of the application draft; only meaningful for the "in-progress" branch. */
+ completionPercent: number;
};
diff --git a/apps/app-portal/src/lib/uploads/service.ts b/apps/app-portal/src/lib/uploads/service.ts
index cf8b1c0d..42244e14 100644
--- a/apps/app-portal/src/lib/uploads/service.ts
+++ b/apps/app-portal/src/lib/uploads/service.ts
@@ -7,7 +7,6 @@ import { UploadRecord } from "./types";
export class InvalidUploadError extends Error {}
export class UploadNotFoundError extends Error {}
-
const UPLOAD_COLLECTION = resolveCollectionName("uploads");
async function uploadCollection(): Promise> {
@@ -15,6 +14,15 @@ async function uploadCollection(): Promise> {
return db.collection(UPLOAD_COLLECTION);
}
+// fetches an upload's metadata (filename, mime, size) by id — used by the admin
+// applicant detail view to show a real filename instead of the raw upload id.
+export async function getUploadRecord(
+ uploadId: string,
+): Promise {
+ const col = await uploadCollection();
+ return col.findOne({ _id: uploadId });
+}
+
// creates a signed upload url
export async function createSignedUploadUrl({
userId,
@@ -46,13 +54,19 @@ export async function createSignedUploadUrl({
contentType: mime,
});
- await recordUpload({uploadId, userId, filename, mime, size, gcsPath: path });
+ await recordUpload({ uploadId, userId, filename, mime, size, gcsPath: path });
return { uploadUrl, uploadId, expiresAt: new Date(expireDate) };
}
// create signed download url
-export async function createSignedDownloadUrl({uploadId, requester} : {uploadId: string, requester: { userId: string; isAdmin: boolean }}): Promise<{ url: string; expiresAt: Date } | null> {
+export async function createSignedDownloadUrl({
+ uploadId,
+ requester,
+}: {
+ uploadId: string;
+ requester: { userId: string; isAdmin: boolean };
+}): Promise<{ url: string; expiresAt: Date } | null> {
const col = await uploadCollection();
const record = await col.findOne({ _id: uploadId });
@@ -76,15 +90,14 @@ export async function createSignedDownloadUrl({uploadId, requester} : {uploadId:
}
// inserts a document into the uploads collection
-export async function recordUpload(
- { uploadId,
- userId,
- filename,
- mime,
- size,
- gcsPath
- } :
- {
+export async function recordUpload({
+ uploadId,
+ userId,
+ filename,
+ mime,
+ size,
+ gcsPath,
+}: {
uploadId: string;
userId: string;
filename: string;
@@ -95,11 +108,11 @@ export async function recordUpload(
const col = await uploadCollection();
const doc: UploadRecord = {
- _id: uploadId,
- userId,
- filename,
- mime,
- size,
+ _id: uploadId,
+ userId,
+ filename,
+ mime,
+ size,
gcsPath,
createdAt: new Date(),
};
diff --git a/apps/app-portal/src/lib/uploads/types.ts b/apps/app-portal/src/lib/uploads/types.ts
index 5b31b58c..96aeb68c 100644
--- a/apps/app-portal/src/lib/uploads/types.ts
+++ b/apps/app-portal/src/lib/uploads/types.ts
@@ -1,19 +1,11 @@
// UploadRecord
export interface UploadRecord {
- _id: string;
- userId: string;
- filename: string;
- mime: string;
- size: number;
- gcsPath: string;
- createdAt: Date;
+ _id: string;
+ userId: string;
+ filename: string;
+ mime: string;
+ size: number;
+ gcsPath: string;
+ createdAt: Date;
}
-
-// SignUploadRequest
-
-export function signUploadRequest(): void {}
-
-// SignUploadResponse
-
-export function signUploadResponse(): void {}
diff --git a/apps/app-portal/src/lib/uploads/validation.ts b/apps/app-portal/src/lib/uploads/validation.ts
index d1eebce9..a7b6158c 100644
--- a/apps/app-portal/src/lib/uploads/validation.ts
+++ b/apps/app-portal/src/lib/uploads/validation.ts
@@ -27,6 +27,11 @@ export function validateUploadRequest({
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(mime)) {
return { ok: false, error: "Incorrect mime type" };
}
+ // `size <= 0` / `size > MAX` are both false for undefined/NaN, which would otherwise let
+ // a request with a missing or non-numeric size skip size validation entirely.
+ if (typeof size !== "number" || !Number.isFinite(size)) {
+ return { ok: false, error: "File size is required" };
+ }
if (size <= 0) {
return { ok: false, error: "File is empty" };
}
diff --git a/apps/app-portal/src/middleware.ts b/apps/app-portal/src/middleware.ts
index abeb9d96..4e18df3a 100644
--- a/apps/app-portal/src/middleware.ts
+++ b/apps/app-portal/src/middleware.ts
@@ -5,11 +5,22 @@
// having needed to login in advance.
//Unauthenticated requests to matched routes are redirected to sign-in
-import { NextResponse } from "next/server";
+import { withAuth } from "next-auth/middleware";
-export default function middleware() {
- return NextResponse.next();
-}
+export default withAuth({
+ callbacks: {
+ authorized({ req }) {
+ return Boolean(
+ req.cookies.get("next-auth.session-token") ??
+ req.cookies.get("__Secure-next-auth.session-token"),
+ );
+ },
+ },
+ pages: {
+ signIn: "/auth/signin",
+ error: "/auth/error",
+ },
+});
//the matcher was written with assistance of AI
export const config = {
@@ -21,6 +32,6 @@ export const config = {
* - the landing page "/" and /login public entry points
* Add any other public path to this negative lookahead as you build it.
*/
- "/((?!auth|_next/static|_next/image|favicon.ico|login$|$).*)",
+ "/((?!api/auth|_next/static|_next/image|favicon.ico|login$|$).*)",
],
};
diff --git a/apps/app-portal/vercel.json b/apps/app-portal/vercel.json
new file mode 100644
index 00000000..77803ae4
--- /dev/null
+++ b/apps/app-portal/vercel.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "buildCommand": "cd ../.. && npx turbo run build --filter=app-portal",
+ "installCommand": "cd ../.. && yarn install --frozen-lockfile",
+ "ignoreCommand": "cd ../.. && npx turbo-ignore app-portal"
+}
diff --git a/turbo.json b/turbo.json
index 7fb17346..71be6ef3 100644
--- a/turbo.json
+++ b/turbo.json
@@ -20,6 +20,21 @@
"MENTOR_BASE_ID",
"AIRTABLE_TOKEN_ID",
"SCHEDULE_BASE_ID",
- "BEEHIIV_API_KEY"
+ "BEEHIIV_API_KEY",
+ "GOOGLE_CLOUD_PROJECT_ID",
+ "GOOGLE_CLOUD_STORAGE_RESUME_BUCKET",
+ "GOOGLE_CLOUD_STORAGE_RESUME_BUCKET_TEST",
+ "GOOGLE_CLOUD_PRIVATE_KEY",
+ "GOOGLE_CLOUD_EMAIL",
+ "MONGO_PROD_CONNECTION_STRING",
+ "MONGO_SERVER_DBNAME",
+ "NEXTAUTH_SECRET",
+ "NEXTAUTH_URL",
+ "EMAIL_SERVER_HOST",
+ "EMAIL_SERVER_PORT",
+ "EMAIL_SERVER_USER",
+ "EMAIL_SERVER_PASSWORD",
+ "EMAIL_FROM",
+ "NODE_ENV"
]
}