diff --git a/apps/app-portal/package.json b/apps/app-portal/package.json index c10898e1..acfc2db0 100644 --- a/apps/app-portal/package.json +++ b/apps/app-portal/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "eslint .", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "seed": "node --env-file=.env --import tsx scripts/seed.ts", + "setup-indexes": "node --env-file=.env --import tsx scripts/setup-indexes.ts" }, "dependencies": { "@google-cloud/storage": "^7.19.0", @@ -47,6 +49,7 @@ "@types/react": "^18.2.61", "@types/react-dom": "^18.2.19", "autoprefixer": "^10.4.18", + "tsx": "^4.19.2", "eslint-config-next": "15.0.3", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", diff --git a/apps/app-portal/scripts/seed.ts b/apps/app-portal/scripts/seed.ts new file mode 100644 index 00000000..235e905c --- /dev/null +++ b/apps/app-portal/scripts/seed.ts @@ -0,0 +1,403 @@ +/* eslint-disable no-console -- CLI seed script; console output is intentional. */ +/** + * Seed the `applicant_data_test` collection on the shared Atlas cluster with + * sample applicants, then create the indexes the list endpoint relies on. + * + * Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root): + * yarn seed + * + * Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via + * `node --env-file=.env`) — this always points at the shared Atlas cluster. + * DESTRUCTIVE — clears the target collection. Hardcoded to only ever run + * against `applicant_data_test`; refuses to run if NODE_ENV=production would + * resolve the real `applicant_data` collection instead (see + * resolveCollectionName in src/lib/db.ts). Since this writes to a collection + * shared across the team on Atlas (not an isolated local DB), coordinate + * before running if others may be relying on its current contents. + * + * Statuses are written in canonical enum form (lowercase-hyphen) to match the + * app enums in src/lib/types/user.ts. + */ +import { getDb, resolveCollectionName } from "@/lib/db"; + +const TEST_COLLECTION_NAME = "applicant_data_test"; +const COLLECTION = resolveCollectionName("applicant_data"); +const DRAFT_SAVED_AT = "2026-02-01T00:00:00.000Z"; + +// [email, first, last, school, year, appStatus, decision|null, rsvp, submitTime|null] +type Row = [ + string, + string, + string, + string, + string, + string, + string | null, + string, + string | null, +]; + +const ROWS: Row[] = [ + [ + "ada.lovelace@example.com", + "Ada", + "Lovelace", + "Northeastern University", + "Junior", + "submitted", + "admitted", + "confirmed", + "2026-02-12T14:03:00.000Z", + ], + [ + "grace.hopper@example.com", + "Grace", + "Hopper", + "Yale", + "Senior", + "submitted", + "admitted", + "not-attending", + "2026-02-10T09:45:00.000Z", + ], + [ + "alan.turing@example.com", + "Alan", + "Turing", + "Cambridge", + "Senior", + "submitted", + "waitlisted", + "unconfirmed", + "2026-02-14T22:10:00.000Z", + ], + [ + "linus.torvalds@example.com", + "Linus", + "Torvalds", + "University of Helsinki", + "Senior", + "submitted", + "declined", + "unconfirmed", + "2026-02-13T16:30:00.000Z", + ], + [ + "margaret.hamilton@example.com", + "Margaret", + "Hamilton", + "Earlham College", + "Senior", + "submitted", + "pending", + "unconfirmed", + "2026-02-15T11:15:00.000Z", + ], + [ + "katherine.johnson@example.com", + "Katherine", + "Johnson", + "West Virginia University", + "Senior", + "incomplete", + null, + "unconfirmed", + null, + ], + [ + "dennis.ritchie@example.com", + "Dennis", + "Ritchie", + "Harvard", + "Senior", + "not-started", + null, + "unconfirmed", + null, + ], + [ + "barbara.liskov@example.com", + "Barbara", + "Liskov", + "Stanford", + "Graduate", + "submitted", + "admitted", + "confirmed", + "2026-02-11T08:00:00.000Z", + ], + [ + "tim.berners-lee@example.com", + "Tim", + "Berners-Lee", + "MIT", + "Senior", + "submitted", + "admitted", + "confirmed", + "2026-02-09T10:00:00.000Z", + ], + [ + "edsger.dijkstra@example.com", + "Edsger", + "Dijkstra", + "UT Austin", + "Junior", + "submitted", + "pending", + "unconfirmed", + "2026-02-16T14:00:00.000Z", + ], + [ + "john.mccarthy@example.com", + "John", + "McCarthy", + "Stanford", + "Senior", + "submitted", + "waitlisted", + "unconfirmed", + "2026-02-17T09:20:00.000Z", + ], + [ + "claude.shannon@example.com", + "Claude", + "Shannon", + "MIT", + "Graduate", + "submitted", + "admitted", + "confirmed", + "2026-02-08T13:45:00.000Z", + ], + [ + "donald.knuth@example.com", + "Donald", + "Knuth", + "Stanford", + "Graduate", + "incomplete", + null, + "unconfirmed", + null, + ], + [ + "frances.allen@example.com", + "Frances", + "Allen", + "NYU", + "Senior", + "submitted", + "admitted", + "confirmed", + "2026-02-12T07:30:00.000Z", + ], + [ + "vint.cerf@example.com", + "Vint", + "Cerf", + "UCLA", + "Junior", + "submitted", + "declined", + "unconfirmed", + "2026-02-13T11:00:00.000Z", + ], + [ + "guido.vanrossum@example.com", + "Guido", + "van Rossum", + "University of Amsterdam", + "Graduate", + "submitted", + "admitted", + "not-attending", + "2026-02-10T16:00:00.000Z", + ], + [ + "james.gosling@example.com", + "James", + "Gosling", + "University of Calgary", + "Senior", + "submitted", + "pending", + "unconfirmed", + "2026-02-18T10:30:00.000Z", + ], + [ + "bjarne.stroustrup@example.com", + "Bjarne", + "Stroustrup", + "Aarhus University", + "Junior", + "not-started", + null, + "unconfirmed", + null, + ], + [ + "ken.thompson@example.com", + "Ken", + "Thompson", + "UC Berkeley", + "Senior", + "submitted", + "admitted", + "confirmed", + "2026-02-07T08:15:00.000Z", + ], + [ + "rob.pike@example.com", + "Rob", + "Pike", + "University of Toronto", + "Junior", + "submitted", + "admitted", + "confirmed", + "2026-02-09T15:45:00.000Z", + ], + [ + "niklaus.wirth@example.com", + "Niklaus", + "Wirth", + "ETH Zurich", + "Graduate", + "submitted", + "waitlisted", + "unconfirmed", + "2026-02-19T12:00:00.000Z", + ], + [ + "tony.hoare@example.com", + "Tony", + "Hoare", + "Oxford", + "Senior", + "submitted", + "pending", + "unconfirmed", + "2026-02-20T09:00:00.000Z", + ], + [ + "leslie.lamport@example.com", + "Leslie", + "Lamport", + "MIT", + "Graduate", + "incomplete", + null, + "unconfirmed", + null, + ], + [ + "ivan.sutherland@example.com", + "Ivan", + "Sutherland", + "MIT", + "Graduate", + "submitted", + "admitted", + "confirmed", + "2026-02-06T14:30:00.000Z", + ], + [ + "john.backus@example.com", + "John", + "Backus", + "Columbia", + "Senior", + "submitted", + "declined", + "unconfirmed", + "2026-02-21T11:30:00.000Z", + ], + [ + "peter.naur@example.com", + "Peter", + "Naur", + "University of Copenhagen", + "Junior", + "submitted", + "admitted", + "not-attending", + "2026-02-22T08:45:00.000Z", + ], + [ + "dana.scott@example.com", + "Dana", + "Scott", + "Princeton", + "Graduate", + "not-started", + null, + "unconfirmed", + null, + ], + [ + "michael.rabin@example.com", + "Michael", + "Rabin", + "NYU", + "Graduate", + "submitted", + "pending", + "unconfirmed", + "2026-02-23T16:00:00.000Z", + ], +]; + +function toDoc(row: Row) { + const [ + email, + firstName, + lastName, + school, + year, + applicationStatus, + decisionStatus, + rsvpStatus, + appSubmissionTime, + ] = row; + return { + email, + applicationStatus, + ...(decisionStatus ? { decisionStatus } : {}), + rsvpStatus, + isAdmin: false, + ...(appSubmissionTime ? { appSubmissionTime } : {}), + lastSavedAt: appSubmissionTime ?? DRAFT_SAVED_AT, + applicationResponses: { + name: `${firstName} ${lastName}`, + school, + yearOfEducation: year, + }, + }; +} + +async function main() { + if (COLLECTION !== TEST_COLLECTION_NAME) { + console.error( + `Refusing to seed: resolved collection is "${COLLECTION}", not ` + + `"${TEST_COLLECTION_NAME}". This script is destructive and only ` + + "ever runs against the test collection.", + ); + process.exit(1); + } + + const db = await getDb(); + const col = db.collection(COLLECTION); + + await col.deleteMany({}); + const docs = ROWS.map(toDoc); + await col.insertMany(docs); + + console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/app-portal/scripts/setup-indexes.ts b/apps/app-portal/scripts/setup-indexes.ts new file mode 100644 index 00000000..c6bfeaeb --- /dev/null +++ b/apps/app-portal/scripts/setup-indexes.ts @@ -0,0 +1,50 @@ +/* eslint-disable no-console -- CLI script; console output is intentional. */ +/** + * Create the Mongo indexes required by the applicant list endpoint. + * Idempotent: createIndex is a no-op when an index already exists. + * + * Usage (from apps/app-portal, or `yarn workspace app-portal setup-indexes`): + * yarn setup-indexes + * + * Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via + * `node --env-file=.env`) — this always points at the shared Atlas cluster. + * Outside production (NODE_ENV !== "production"), the collection resolves to + * `applicant_data_test` instead of the real `applicant_data`; see + * resolveCollectionName in src/lib/db.ts. Run with NODE_ENV=production set to + * target the real collection when provisioning a new deployment. + * + * Required indexes: + * { applicationStatus: 1 } — equality filter (?status=) + * { decisionStatus: 1 } — equality filter (?decision=) + * { rsvpStatus: 1 } — equality filter (?rsvp=) + * { appSubmissionTime: -1 } — default sort (desc) + * { email: 1 } — email sort and exact lookups + */ +import type { Collection } from "mongodb"; + +import { getDb, resolveCollectionName } from "@/lib/db"; + +const COLLECTION = resolveCollectionName("applicant_data"); + +export async function ensureApplicantIndexes(col: Collection): Promise { + await Promise.all([ + col.createIndex({ applicationStatus: 1 }), + col.createIndex({ decisionStatus: 1 }), + col.createIndex({ rsvpStatus: 1 }), + col.createIndex({ appSubmissionTime: -1 }), + col.createIndex({ email: 1 }), + ]); +} + +async function main() { + const db = await getDb(); + const col = db.collection(COLLECTION); + await ensureApplicantIndexes(col); + console.log(`Indexes ensured on ${COLLECTION}.`); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/app-portal/src/app/(admin)/admin/applicants/page.tsx b/apps/app-portal/src/app/(admin)/admin/applicants/page.tsx index 668b41ef..633dda96 100644 --- a/apps/app-portal/src/app/(admin)/admin/applicants/page.tsx +++ b/apps/app-portal/src/app/(admin)/admin/applicants/page.tsx @@ -3,21 +3,50 @@ import React, { Suspense } from "react"; import { ApplicantsFilters } from "@/components/admin/applicants/ApplicantsFilters"; import { ApplicantsTable } from "@/components/admin/applicants/ApplicantsTable"; import { ExportButtons } from "@/components/admin/applicants/ExportButtons"; +import { parseApplicantQueryOrDefault } from "@/lib/applicants/params"; import { listApplicants } from "@/lib/applicants/service"; export const dynamic = "force-dynamic"; -async function ApplicantsData() { - const { rows, total } = await listApplicants(); +type SearchParams = Record; + +function toURLSearchParams(searchParams: SearchParams): URLSearchParams { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(searchParams)) { + if (Array.isArray(value)) { + if (value[0] !== undefined) params.set(key, value[0]); + } else if (value !== undefined) { + params.set(key, value); + } + } + return params; +} + +async function ApplicantsData({ + searchParams, +}: { + searchParams: SearchParams; +}) { + const params = parseApplicantQueryOrDefault(toURLSearchParams(searchParams)); + const result = await listApplicants(params); return ( <> -

{total} total

- +

{result.total} total

+ ); } -export default function ApplicantsPage() { +export default function ApplicantsPage({ + searchParams, +}: { + searchParams: SearchParams; +}) { return (
@@ -29,8 +58,18 @@ export default function ApplicantsPage() { }> - }> - + {/* Re-keyed by the query string so each filter/sort/page change re-runs + the server fetch and shows the table skeleton while it streams. */} + +
+ + + } + > +
); diff --git a/apps/app-portal/src/app/api/v1/applicants/route.ts b/apps/app-portal/src/app/api/v1/applicants/route.ts index 5f9e3dfc..c047f604 100644 --- a/apps/app-portal/src/app/api/v1/applicants/route.ts +++ b/apps/app-portal/src/app/api/v1/applicants/route.ts @@ -1,8 +1,22 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { parseApplicantQuery } from "@/lib/applicants/params"; import { listApplicants } from "@/lib/applicants/service"; -export async function GET() { - const result = await listApplicants(); - return NextResponse.json(result); +export async function GET(req: NextRequest) { + // TODO: gate with requireAdmin() once Ticket 1 ships its helpers + const parsed = parseApplicantQuery(new URL(req.url).searchParams); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); + } + + try { + const result = await listApplicants(parsed.value); + return NextResponse.json(result); + } catch (err) { + return NextResponse.json( + { error: `Failed to list applicants: ${err}` }, + { status: 500 }, + ); + } } diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx index 502623ad..4669b237 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -19,19 +19,24 @@ import { } from "@/lib/types/user"; const ALL = "all"; +const SEARCH_DEBOUNCE_MS = 300; export function ApplicantsFilters() { + const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const writeParams = (mutate: (p: URLSearchParams) => void) => { - const next = new URLSearchParams(searchParams.toString()); - mutate(next); - const query = next.toString(); - const url = query ? `${pathname}?${query}` : pathname; - // Bypass router.replace to avoid an RSC refetch — filter state is client-only. - window.history.replaceState(null, "", url); - }; + const writeParams = React.useCallback( + (mutate: (p: URLSearchParams) => void) => { + const next = new URLSearchParams(searchParams.toString()); + mutate(next); + const query = next.toString(); + const url = query ? `${pathname}?${query}` : pathname; + // Navigate so the server component refetches with the new filters. + router.replace(url, { scroll: false }); + }, + [router, pathname, searchParams], + ); const setParam = (key: string, value: string) => { writeParams((p) => { @@ -44,17 +49,37 @@ export function ApplicantsFilters() { }); }; - const q = searchParams.get("q") ?? ""; const status = searchParams.get("status") ?? ALL; const decision = searchParams.get("decision") ?? ALL; const rsvp = searchParams.get("rsvp") ?? ALL; + const urlQuery = searchParams.get("search") ?? ""; + + // Local input state so typing stays responsive; pushed to the URL (which + // drives the server query) after a short debounce. + const [searchInput, setSearchInput] = React.useState(urlQuery); + + React.useEffect(() => { + if (searchInput === urlQuery) return; + const timeout = setTimeout(() => { + writeParams((p) => { + if (!searchInput) { + p.delete("search"); + } else { + p.set("search", searchInput); + } + p.delete("page"); + }); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timeout); + }, [searchInput, urlQuery, writeParams]); const hasActiveFilters = - q !== "" || status !== ALL || decision !== ALL || rsvp !== ALL; + searchInput !== "" || status !== ALL || decision !== ALL || rsvp !== ALL; const clearFilters = () => { + setSearchInput(""); writeParams((p) => { - p.delete("q"); + p.delete("search"); p.delete("status"); p.delete("decision"); p.delete("rsvp"); @@ -67,8 +92,8 @@ export function ApplicantsFilters() { setParam("q", e.target.value)} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} />