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 (
- -
-
-

Admin Portal

- -
- -
{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..a697290f 100644 --- a/apps/app-portal/src/app/(landing)/page.tsx +++ b/apps/app-portal/src/app/(landing)/page.tsx @@ -1,11 +1,17 @@ import React from "react"; import Link from "next/link"; import Image from "next/image"; +import { redirect } from "next/navigation"; import icon from "@/app/icon.ico"; import TiledBackground from "@/components/ui/tiled-background"; +import { getSession } from "@/lib/auth/session"; + +export default async function Page(): Promise { + const session = await getSession(); + if (session?.user) { + redirect("/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..a5577d9c 100644 --- a/apps/app-portal/src/app/auth/signin/page.tsx +++ b/apps/app-portal/src/app/auth/signin/page.tsx @@ -3,7 +3,7 @@ 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 { // read cookie - see if valid session in DB - if so, automatically redir user to logged in part 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. +

+ +
+ ); +} 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} +

+ +
+ )} +
+ ); +} 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} -

- -
- )} -
- ); + 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 ( +
+
+

Admin Portal

+ +
+ +
{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 ( -
- - - +
+
+ + + +
+ + {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 ( - <> - - + ); } 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() {
- {APPLICATION_SECTIONS.map((section, i) => ( + {sections.map((section, i) => ( ))} @@ -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 ( -