Skip to content

Ap/admin applicant - #511

Open
adityapat24 wants to merge 2 commits into
devfrom
Ap/adminApplicant
Open

Ap/admin applicant#511
adityapat24 wants to merge 2 commits into
devfrom
Ap/adminApplicant

Conversation

@adityapat24

Copy link
Copy Markdown
Contributor

App Portal: bug-fix + 2026 application-form pass

This PR spans a broad backend/frontend cleanup pass across the applicant portal and admin panel: real 2026 application questions, a fix connecting the admin form editor to the live application form, a real resume/vaccination-card upload pipeline, a stats-dashboard data bug fix, and a long list of smaller correctness/security fixes (auth guards, validation gaps, a data-corruption race condition, stale references left over from renaming form fields).

Sections below are split by owner area so each of you can review/test just your part.

General setup: pull, yarn install, yarn dev:app. You'll need to sign in with an @hackbeanpot.com email to reach /admin, or any email for the applicant side.


Michael — Auth + Sign-in flow (admin and application)

What changed:

  • middleware.ts was a no-op — despite comments claiming it redirected unauthenticated users, it just called NextResponse.next() unconditionally, and its route matcher also didn't actually exclude /api/* (only checked for paths starting with auth, not api/auth). It now really checks the session (via a call to the /auth/session endpoint, since sessions are DB-backed and can't be decoded in the Edge runtime) and redirects unauthenticated users to sign-in, and the matcher correctly excludes all API routes so fetch() callers get JSON errors instead of being redirected to an HTML page.
  • /uploads-demo (Mehana's test page for the upload component) had no auth guard at all — reachable by anyone. Gated it behind the same admin check (admin)/layout.tsx uses.
  • Landing page (/) had a TODO: redirect authed users to /dashboard that was never implemented — now does.
  • SignInForm.tsx had two //todo comments and raw hex/rgb colors (#FF0000, rgb(120,255,150)) for error/success text — swapped for the app's real design tokens (firecrackerRed, darkGreen), which are already used for the same purpose elsewhere.
  • lib/auth/email-transport.ts: magic-link emails silently fell back to http://localhost:3000 for the logo URL if NEXTAUTH_URL was unset — now throws loudly in production instead of shipping a broken link.

Files: middleware.ts, app/(landing)/page.tsx, app/uploads-demo/{page.tsx,UploadsDemoClient.tsx}, components/auth/SignInForm.tsx, lib/auth/email-transport.ts

How to test:

  1. Open an incognito window, go directly to /dashboard, /application, /rsvp, or /admin — should redirect to /auth/signin (not render anything, not throw an error).
  2. Sign in with a non-@hackbeanpot.com email, confirm you land on /dashboard and that visiting /admin redirects you back to /dashboard.
  3. Sign in with an @hackbeanpot.com email, confirm you land on /admin, then visit /uploads-demo — should work for you but redirect a non-admin.
  4. While signed in, visit / — should redirect straight to /dashboard instead of showing the landing page.
  5. Trigger a sign-in error (e.g. invalid email) and confirm the error text is legible (red) and the "check your email" success text is legible (green) — no more raw unstyled colors.

Mehana — GCS setup and resume handling

What changed:

  • The resume/vaccination-card upload component (FileUpload.tsx) had a dead "mock upload" branch left over from an earlier iteration — real GCS is fully configured now, so that path could never legitimately trigger, but if the sign request ever failed, the code didn't check res.ok and fell through into calling xhr.open("PUT", undefined). Removed the dead branch, added a real res.ok check, and actual error messages are now shown to the user instead of failing silently.
  • Added an accept prop to FileUpload so a given instance can restrict to specific MIME types (resume → PDF only, vaccination card → PNG/JPEG only) instead of always allowing all three app-wide types.
  • lib/uploads/validation.ts: size <= 0 / size > MAX_FILE_SIZE_BYTES are both false for undefined/NaN, so a request with a missing/non-numeric size skipped size validation entirely. Fixed.
  • Added getUploadRecord(uploadId) to lib/uploads/service.ts — used by the admin applicants page (Andre's area) to show a real filename instead of the raw upload ID.
  • Cleaned up two dead no-op placeholder functions (signUploadRequest/signUploadResponse) in lib/uploads/types.ts that were never called anywhere.
  • Separately (see Phaedra's section): the application form's resume/vaccination-card fields were previously completely non-functional — they held a raw browser File object that got silently dropped before saving. They're now wired to your real GCS upload flow.

Files: components/uploads/FileUpload.tsx, lib/uploads/{service,types,validation}.ts

How to test:

  1. Go to /application → Documents section, upload a resume as a PDF — should show real upload progress (not the old instant-fake-progress-bar) and complete successfully.
  2. Try uploading a .docx or oversized file for the vaccination card (PNG/JPEG only) — should be rejected client-side with a clear message, not silently accepted.
  3. Kill your network mid-upload (or throttle it in devtools) — you should see an actual error message on the dropzone, not a silent reset.
  4. As an admin, open that applicant's detail page (/admin/applicants/[id]) and confirm the resume shows a real filename, not the raw upload ID string.

Yurika — Admin Stats page

What changed:

  • lib/stats/aggregations.ts: getTotals() and getDecisionBreakdown() were matching against literal capitalized/space-separated strings ("Submitted", "Admitted", "Not Attending") that no real write path in the app can ever produce — every write goes through a zod enum restricted to lowercase-hyphenated values ("submitted", "not-attending", etc., per lib/types/user.ts). This meant every stat tile except "Total Applications" always showed 0, and the decision-breakdown chart was always empty. Fixed to match the real values (case-insensitively, as a safety net).
  • lib/stats/types.ts: DEMOGRAPHICS_DIMENSIONS listed dimension names (yearOfEducation, majors, races, shirtSize, hackathonsAttended, csClassesTaken) that don't match any real question ID — those 6 of 8 demographics charts were always empty. Renamed to the real 2026 question IDs.
  • lib/stats/service.ts: demographics were also displaying raw option-value slugs as chart labels (e.g. "black_or_african_american" instead of "Black or African American"). Added resolveDemographicsLabels(), which maps every dimension's values through the live form config's option list — so edits admins make in Form Configuration (Roslyn's area) are reflected in the chart labels too.
  • api/v1/stats/route.ts had no auth guard at all — added requireAdmin().
  • Added real test coverage (aggregations.test.ts, service.test.ts) exercising all of the above against an in-memory Mongo instance.

Files: lib/stats/{aggregations,types,service}.ts, components/admin/stats/DemographicsChart.tsx, app/api/v1/stats/route.ts

How to test:

  1. Go to /admin/stats with some real/seeded submitted applicants in the DB. Confirm the stat tiles (Submitted, Admitted, RSVP'd) show real non-zero numbers, and the Decision Breakdown chart isn't empty.
  2. Open the Demographics chart, switch through all 8 dimension dropdowns — confirm School/Gender/Year of Education/Major/Race/Shirt Size/Hackathons Attended/CS Classes Taken all show data (not just School, which happened to work before) and that select-based dimensions show friendly labels ("1st year") rather than raw slugs ("1st_year").
  3. Log out and hit GET /api/v1/stats directly (curl or browser) — should now 401, not return data.

Roslyn — Admin Settings and Form Configuration

What changed:

  • admin/settings/page.tsx was self-fetching its own API routes over HTTP using a hardcoded http://localhost:3000 — worked in dev, would've been completely broken in any deployed environment. Rewired to read the underlying singletons directly (same pattern the stats/applicants pages already used).
  • The core fix this PR is built around: the admin form-config editor and the real applicant-facing application form used to be two different, incompatible type shapes — editing/adding questions in /admin/settings had no effect on what applicants actually saw, because the real form imported a hardcoded static question list. lib/admin/types.ts's FormConfig now aliases the same Question/FormSection types the real form uses, and the form itself fetches its live schema from the same Mongo-backed config your editor writes to. Editing/adding a question here now really does change the live application form (see Phaedra's section for the consumer side).
  • FormConfigEditor.tsx: replaced a raw alert(data.error) on save failure with an inline error message.
  • form-config-service.ts: updateFormConfig had no check that the submitted config isn't empty — POSTing {"sections": []} passed every other check and would've silently wiped the live application form to zero questions. Now rejected. Also fixed a bug where it queried the wrong (unsuffixed) Mongo collection name in dev/test.
  • api/v1/admin/form-config/route.ts's GET had no auth guard (only POST did) — added.
  • Registration date routes (registration-open, registration-closed, confirm-by) had no cross-field validation — you could set registration-closed before registration-open, or confirm-by before either, and each route would accept it independently. Added ordering validation (registration-open ≤ registration-closed ≤ confirm-by) shared across all three routes, which were also deduplicated from three near-identical copies into one shared handler, and fixed to return proper 401/403 instead of an uncaught 500 for unauthenticated requests.
  • Added missing loading.tsx for /admin and /admin/settings (previously only some admin routes had one).

Files: app/(admin)/admin/settings/page.tsx, app/(admin)/admin/{loading,page}.tsx, app/(admin)/admin/settings/loading.tsx, components/admin/FormConfigEditor.tsx, lib/admin/{types,form-config-service,singleton-service,date-route-handlers}.ts, app/api/v1/admin/form-config/route.ts, app/api/v1/dates/{registration-open,registration-closed,confirm-by}/route.ts

How to test:

  1. Go to /admin/settings → Form Configuration. Add a new question or edit an existing one's label, save, then immediately open /application in another tab (or as another user) — the change should show up on the real form. This is the main thing to verify; it didn't work at all before this PR.
  2. Try saving a form config with all questions deleted from every section — should be rejected with a clear error, not silently accepted.
  3. Try setting Registration Closes to a date before Registration Opens — should be rejected. Same for Confirm By before Registration Closes.
  4. Toggle "Show Decisions" and edit the three dates normally — confirm the happy path still works end to end and the settings page loads correctly (no console errors about a failed localhost fetch).

Shreeya — Applicant Dashboard and RSVP

What changed:

  • InProgressView.tsx: the "Continue application" button linked to / instead of /application (never actually took you back to your draft). Also, the completion percentage was hardcoded to 60% or 100% based only on whether the application was submitted — now computed for real from how many questions in the live form you've actually answered (getCompletionPercent() in lib/status/service.ts / lib/application/service.ts).
  • AdmittedView.tsx: "RSVP before XXX to hold your spot" was a literal, un-filled-in placeholder — now shows your real confirm-by date (the data was already being passed in, just never used).
  • SubmittedView.tsx: same broken / link, plus "decisions on XXX" / "Review date: XXX" placeholders — there's no real "decision announcement date" field in the data model to back a specific date here, so rephrased to accurate, non-fabricated copy instead of inventing one.
  • DeclinedView.tsx: "Join the mailing list" was a dead link to /. Built JoinMailingListButton.tsx, which actually POSTs your email to the (previously unused!) /api/joinMailingList endpoint.
  • WaitlistedView.tsx: hardcoded contact email centralized into lib/config/site.ts.
  • PortalShell.tsx: removed leftover dev-only placeholder copy ("Review the status mock in the route handler...") from the fallback state.
  • post-acceptance/route.ts (RSVP submission): added a guard against a missing session user ID instead of silently querying Mongo with an empty string.

Files: app/(applicant)/dashboard/page.tsx, components/dashboard/{InProgressView,AdmittedView,SubmittedView,DeclinedView,WaitlistedView,PortalShell,JoinMailingListButton}.tsx, lib/status/{service,types}.ts, lib/config/site.ts, app/api/v1/post-acceptance/route.ts

How to test:

  1. Start a fresh application, save a few answers, go to /dashboard — confirm the completion % actually reflects how much of the form you've filled in (not stuck at 60%), and "Continue application" takes you back into the form.
  2. Get an application to each decision state if you can seed/set them (admitted, waitlisted, declined) and check each dashboard view: Admitted shows a real confirm-by date, no XXX appears anywhere on Submitted, and Declined's "Join the mailing list" button actually works (check it doesn't error).
  3. Run through the RSVP flow end to end from the Admitted view.

Phaedra — Applicant Application Form

What changed:

  • Full content rewritelib/application/questions.ts now has the real 2026 HackBeanpot questions (9 sections, 43 questions) instead of the old placeholder set. A few things from the original doc were deliberately simplified: conditional "if not listed, specify" fields render as always-visible optional fields (no show/hide-on-condition support yet), and "how did you hear about us" was flattened to one multi-select + one free-text "other" field.
  • The form now fetches its live question set from the admin-editable config (see Roslyn's section) instead of a hardcoded static import — ApplicationForm.tsx builds its validation schema dynamically per-load.
  • Added real word-count limits (e.g. "275 words max") on the long-answer questions, enforced via zod and shown as a live counter in LongTextField.tsx.
  • Resume and vaccination-card fields are now wired to Mehana's real upload pipeline — previously the field held a raw File object that got silently dropped before saving (selecting a file did nothing at all, even though it looked like it worked).
  • Fixed a real navigation bug: clicking "Next" validates the entire form schema (documented react-hook-form behavior when using a resolver, regardless of which fields you pass to trigger()), which was setting "required" errors on fields in sections you hadn't even reached yet — you'd land on section 3 and immediately see a red "required" error on a field you never touched. Now clears those premature errors once the current section is confirmed valid.
  • Blocking data bug, lib/application/service.ts: saveDraft() had no guard against writing over an already-submitted application. A stray/delayed autosave request landing just after a Submit click could silently revert the submitted answers back to stale draft content, while the status still said "submitted." Fixed — a submitted application is now treated as authoritative and autosave becomes a no-op once that's happened.
  • registration/route.ts: draft-save requests had no body validation at all (garbage could be written straight to Mongo) and malformed JSON caused an uncaught 500. Both fixed.
  • Added application/loading.tsx (previously missing).

Files: lib/application/{questions,schema,service,types}.ts, components/application/{ApplicationForm,FileUploadField,LongTextField,QuestionField}.tsx, app/api/v1/registration/route.ts, app/(applicant)/application/loading.tsx

How to test:

  1. Go through the entire new application form start to finish — confirm all 9 sections and their questions render, required fields block progression appropriately, and optional ones don't.
  2. On a long-answer question, type past the word limit — confirm the counter turns red and submission is blocked; well under the limit should pass fine.
  3. Navigate Next through a couple of sections without filling anything in on later sections, then go back — confirm you don't see premature "required" errors on sections you haven't tried to leave yet.
  4. Upload a resume, save as draft, refresh the page — confirm the upload persisted (shows "already uploaded" state, not empty).
  5. Submit the application, then (if you can simulate it) trigger another autosave request right after — confirm your submitted answers aren't reverted.

Andre — Admin Applicants Page

What changed:

  • Real bug introduced by the question rename (Phaedra's section), fixed here: the old form's name field was legal_name; the 2026 rewrite splits it into first_name/last_name. That silently broke the applicants list's name column, sort-by-name, search-by-name, and both CSV exports — all still referenced the dead legal_name key and would've shown blank names for every 2026 applicant. Fixed with a shared getApplicantName() helper used consistently everywhere names are displayed, sorted, searched, or exported.
  • Added an RSVP state-machine check to the applicant-edit endpoint: an admin can no longer set rsvpStatus to anything other than "unconfirmed" unless the applicant's decision status is actually "admitted" — mirrors the rule the self-service RSVP flow already enforces, just wasn't checked on the admin edit path.
  • Resume filenames in the applicant detail view now show the real filename (via Mehana's getUploadRecord()) instead of the raw upload ID string.
  • applicants/[id]/route.ts now returns a proper 409 for the new RSVP-state error instead of an uncaught exception.

Files: lib/applicants/{service,queries}.ts, app/api/v1/applicants/[id]/route.ts, app/api/v1/export/{applications,post-acceptance}/route.ts

How to test:

  1. Go to /admin/applicants for an applicant who filled out the 2026 form — confirm their name shows up in the list (not blank), search for them by first or last name, and sort the list by name.
  2. Export both CSVs (Applications, Post-Acceptance) and confirm the Name column is populated for 2026 applicants.
  3. Open an applicant who is not admitted, try to set their RSVP status to "Confirmed" via the admin UI — should be rejected with a clear error, not silently applied or a raw 500.
  4. Open an admitted applicant's detail page and confirm their resume shows a real filename if they uploaded one.

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
core-live Ready Ready Preview Aug 12, 2026 4:54pm
core-main Ready Ready Preview Aug 12, 2026 4:54pm

@mehanana mehanana left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some small changes regarding the error messages, but other than that, the upload section looks good!
I did notice 1 weird issue, not sure exactly which section this relates to, but when I refreshed the application, I was forced to answer the questions that said optional in order to move to the next section. This didn't happen when I was going through the application the first time.

Image

setUploadError(
typeof body?.error === "string"
? body.error
: "Could not start the upload. Please try again.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw a different error when I turned the network off & tried to upload a resume file.

Image Image

errors.map((e) => {
if (e.code === "file-too-large") return "File exceeds 5 MB limit.";
if (e.code === "file-invalid-type")
return "Only PDF, PNG, and JPEG files are accepted.";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error message is hardcoded to include all mime types, but I think it would be clearer to the user if it was tailored to the mime types allowed for this specific dropzone (eg. for resume, it should say only pdf allowed).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants