diff --git a/apps/app-portal/.env.example b/apps/app-portal/.env.example index 875492fb..7eb339bf 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 + +# --- 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/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/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/(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/(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/AdminSidebar.tsx b/apps/app-portal/src/components/admin/AdminSidebar.tsx index af744804..7c718656 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 + ); 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/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 ( -