feat(recruitment): settings table, URL validation, and data layer (PR 0 of 3) - #203
Merged
Conversation
Foundation for the recruitment drive: /join embeds a Google Form, and /admin/recruitment points it at a form and opens or closes the drive. Applications themselves stay in Google Forms — this only owns the pointer. The form URL lives in the database rather than in code so opening a new cycle is a paste in the admin console, not a redeploy. That matters for a committee with no developer on call mid-drive. - recruitment_settings: one row (id = "current"). `updated_by` is deliberately not a foreign key — deleteUser() removes a user by deleting their child rows then the user, so an FK would make deleting any admin who had saved these settings fail on a constraint violation. - src/lib/recruitment.ts validates the URL with the URL parser rather than a regex, so lookalike hosts fail on the host comparison rather than on a pattern that has to anticipate them. It guards an iframe src, hence the test coverage; the frame-src allow-list in next.config.ts is the second layer and is added with the /join page. - getRecruitmentSettings() never fails on a missing row — a freshly migrated production database has none, and /join must render its closed state rather than an empty frame. Writes upsert. - Opening the drive with no form URL is refused rather than publishing a page whose whole purpose is missing. Splits out as PR 0 of three so the admin console and the public page can then be built in parallel without sharing a file. See docs/recruitment.md for the spec and docs/recruitment-workplan.md for the split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
barunaniket
requested review from
hagemaruwu,
shivanshpap and
vaibhavtulsian
as code owners
September 8, 2026 17:25
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
barunaniket
pushed a commit
that referenced
this pull request
Sep 8, 2026
Adds /admin/recruitment where an admin opens/closes the recruitment
drive and sets the Google Form URL /join embeds, with no redeploy.
- src/app/api/admin/recruitment/route.ts — PATCH, admin-gated, backed
by updateRecruitmentSettings() from PR 0. Returns the settings as
actually persisted (not an echo of the request), so the client syncs
to server truth.
- src/app/admin/recruitment/page.tsx + RecruitmentPanel.tsx — server
page + client form, mirroring admin/users + UserManagementPanel.
Save sends only the fields the admin changed — the backend does a
partial merge, so sending untouched fields too would silently
overwrite whatever another admin (or another tab) saved to them
since this panel loaded.
- src/app/admin/layout.tsx + AdminNav.tsx — lifts the nav shared by
all five admin pages into one place. Presentational only: every page
keeps its own getAdminUser()/redirect("/") gate and its own
`export const dynamic = "force-dynamic"`, and the route handler is
gated independently of the page.
Visible side effect: admin/teachers, admin/problems/new, and
admin/problems/[slug]/edit now show the nav too (they had none, or in
teachers' case were accidentally missing it).
Depends on PR 0 (#203, feat/recruitment-foundation) — branched off it
since #203 hasn't merged yet; rebase onto main once it does.
Verified locally (npm run dev): admin/non-admin/anonymous gating on
both the page and the route, bad-URL rejection with no write, real-URL
persistence across reload, partial-PATCH doesn't clobber other fields,
identical nav on all five admin pages. `npm run test`, `npx tsc
--noEmit`, `npm run lint`, `npm run build` all clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Foundation for this year's recruitment drive. PR 0 of 3 — it exists so the admin console and the public page can then be built in parallel without sharing a file.
Applications are handled entirely in Google Forms. This PR owns only the pointer to that form, and it lives in the database rather than in code so opening a new cycle is a paste in the admin console, not a redeploy. That's the point: a committee with no developer on call can still open and close a drive.
Spec:
docs/recruitment.md· Work split:docs/recruitment-workplan.mdWhat's here
src/server/db/schema.tsrecruitmentSettingstable + inferred typesmigrations/0011_flashy_sunfire.sqlCREATE TABLEonly — additive, touches no existing tablesrc/lib/recruitment.tsisGoogleFormUrl/toEmbedUrl/toShareUrlsrc/lib/recruitment.test.tssrc/server/recruitment.tsgetRecruitmentSettings/updateRecruitmentSettingsNo routes, pages or components — those are PR A and PR B.
Decisions worth reviewing
updated_byis deliberately not a foreign key.deleteUser(src/server/admin/users.ts:94) removes a user by deleting their child rows and then the user. An FK here would make deleting any admin who had ever saved recruitment settings fail on a constraint violation, with a confusing error. A stale id is a much smaller problem than an undeletable account.URL validation uses the URL parser, not a regex. It guards an iframe
src, so lookalike hosts should fail on a host comparison rather than on a pattern that has to anticipate them —https://docs.google.com.evil.test/...parses to that full host, andhttps://docs.google.com@evil.test/...parses toevil.test. Both are covered by tests. Theframe-srcallow-list innext.config.tsis the second layer and arrives with the/joinpage in PR B.Reads never fail on a missing row. A freshly migrated production database has no settings row, and
/joinmust render its closed state rather than an empty frame. Writes upsert.Opening the drive with no form URL is refused rather than publishing a page whose whole purpose is missing.
Verification
CI gate, all run locally:
tsc --noEmitclean · lint 0 errors · 90 tests pass (up from 72) ·npm run buildsucceeds.Beyond that, the migration chain was replayed onto an empty throwaway database and the module exercised end to end — 22 checks, all passing: closed default on a missing row, non-Google URL rejected,
javascript:rejected, opening with no URL refused, impossible date rejected, nothing written by any failed call, create-then-partial-patch preserving untouched fields, clearing via empty string, and the single-row invariant.Deploying
This adds a migration. After merge, production needs:
Nothing is user-visible yet, so it can ship ahead of PR A and PR B.