Skip to content

feat(recruitment): settings table, URL validation, and data layer (PR 0 of 3) - #203

Merged
barunaniket merged 1 commit into
mainfrom
feat/recruitment-foundation
Sep 8, 2026
Merged

feat(recruitment): settings table, URL validation, and data layer (PR 0 of 3)#203
barunaniket merged 1 commit into
mainfrom
feat/recruitment-foundation

Conversation

@barunaniket

@barunaniket barunaniket commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

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.md

What's here

File
src/server/db/schema.ts recruitmentSettings table + inferred types
migrations/0011_flashy_sunfire.sql CREATE TABLE only — additive, touches no existing table
src/lib/recruitment.ts isGoogleFormUrl / toEmbedUrl / toShareUrl
src/lib/recruitment.test.ts 18 tests
src/server/recruitment.ts getRecruitmentSettings / updateRecruitmentSettings

No routes, pages or components — those are PR A and PR B.

Decisions worth reviewing

updated_by is 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, and https://docs.google.com@evil.test/... parses to evil.test. Both are covered by tests. The frame-src allow-list in next.config.ts is the second layer and arrives with the /join page in PR B.

Reads never fail on a missing row. A freshly migrated production database has no settings row, 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.

Verification

CI gate, all run locally: tsc --noEmit clean · lint 0 errors · 90 tests pass (up from 72) · npm run build succeeds.

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:

npx wrangler d1 migrations apply pesuecc-arena --remote

Nothing is user-visible yet, so it can ship ahead of PR A and PR B.

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>
@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
code-chef-pesuecc-chapter Ready Ready Preview Sep 8, 2026 5:25pm UTC

@barunaniket
barunaniket merged commit 928e87f into main Sep 8, 2026
3 checks passed
@barunaniket
barunaniket deleted the feat/recruitment-foundation branch September 8, 2026 17:53
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.
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.

1 participant