Skip to content

feat: alumni chapters — models and routes - #58

Merged
ethnjs merged 36 commits into
mainfrom
feat/alumni-chapters
Aug 1, 2026
Merged

feat: alumni chapters — models and routes#58
ethnjs merged 36 commits into
mainfrom
feat/alumni-chapters

Conversation

@ethnjs

@ethnjs ethnjs commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Background

Alumni chapters let Science Olympiad alumni organize by university — a lead/officer/member hierarchy, join-code-based membership, and a canonical University table that also normalizes User.university and Tournament location references. This PR is backend-first; the chapter dashboard, join page, and dashboard chapter section are tracked separately from #42 rather than bundled here. It does, however, include the minimal frontend fallout from switching User.university to an FK — see Frontend below.

Changes

Models + migration

  • University — canonical lookup (name unique, abbreviation, location); referenced by User, Tournament, and AlumniChapter
  • AlumniChapter, ChapterMembership (role: lead | officer | member, unique on user_id — one chapter per user), ChapterJoinCode (8-char alphanumeric excluding ambiguous chars, optional label/expiry, is_active, use_count), TournamentChapter junction table (exists for future tournament-chapter affiliation UI, unused otherwise)
  • Tournament.university_id + Tournament.location fallback, with a @validates schema check requiring at least one of the two
  • ChapterMembership.user_id cascades on user delete; ChapterJoinCode.created_by intentionally does not — a user who's created join codes can't be deleted while those rows exist
  • All chapter tables consolidated into a single migration (2cc03b477680_alumni_chapters.py), edited in place rather than stacking incremental migrations since the branch never shipped
  • models.py reorganized: University moved above the models that reference it; relationship names fixed to match cardinality — singular refs (alumni_chapter, user, tournament, chapter) instead of pluralized names on scalar/one-to-one relationships, and tournament_chapters instead of chapters/tournaments on the junction table so it's clear those return TournamentChapter rows, not Tournament/AlumniChapter objects directly

Routes

  • Admin (/admin/chapters/..., /admin/universities/...): chapter/university CRUD, lead assignment — mirrors the existing /admin/users/ convention (public GET, admin-gated POST/PATCH/DELETE under /admin/)
  • Public: GET /chapters/{id}/, GET /universities/
  • Chapter lead or admin: PATCH /chapters/{id}/ (update name/university)
  • Chapter officer, lead, or admin: GET /chapters/{id}/members/ — officers get read access to the member list; a require_officer_or_lead dependency was added alongside the existing require_lead/require_chapter_lead_or_admin, all three now sharing one _require_chapter_role helper instead of duplicating the same membership-role query three times
  • Chapter lead only: member role updates (validated against a ChapterMemberUpdate schema, not a raw string), member removal, member profile view, join-code CRUD (PATCH now supports updating label/expiry, not just deactivating — one-way is_active enforced in the route, not the schema)
  • Authenticated: POST /chapters/join/ — validates a join code and creates the membership; increments ChapterJoinCode.use_count in the same transaction
  • Dropped the standalone public join-code-preview endpoint — GET /chapters/{id}/ being public covers the same need without a second response shape
  • Every route has a section header + descriptive docstring for Swagger; core helpers used across module boundaries (create_alumni_chapter, assign_chapter_lead, is_join_code_expired, create_university_record) had their leading underscores removed since underscore was signaling module-private when they're actually imported elsewhere

Schemas

  • ChapterMemberResponse/ChapterMemberProfileResponse flatten the member's user fields onto the response directly (via a model_validator(mode="before")) instead of nesting under a user key, with membership_id kept distinct from the user's id
  • Deleted the ad-hoc ChapterUserSlimReponse in favor of the real UserSlimResponse/UserFullResponse schemas
  • Split schemas/user.py by audience: UserSlimResponse is now a minimal public-safe shape (no account-internal fields), AdminUserSlimResponse/AdminUserFullResponse carry role/status/email_verified/timestamps for admin-facing routes and auth responses, UserMeSlimResponse/UserMeFullResponse compose both via multiple inheritance for self-view. Net effect: chapter leads viewing a member's profile see profile data only, not account/role state — by construction, not a special case

Seed data

  • app/db/seed_universities.py (idempotent, same ON CONFLICT DO NOTHING pattern as seed_canon_events.py) — the 9 undergrad UC campuses, Stanford, Caltech, and Harvey Mudd. Wired into the lifespan alongside the events seed, runs on every startup in every environment

Frontend

The User.university FK migration turned usersApi/adminUsersApi types (UserFull.university) from a free-text string into a University | null object, which broke type-checking wherever the frontend still treated it as text. This PR includes the direct consumer fixes, not the broader chapter UI:

  • Combobox.tsx gained an optional getSearchText?: (option: T) => string prop (defaults to getLabel) so callers can search on more than just the displayed label without hardcoding that logic into the shared component
  • UniversityField (ProfileFields.tsx) now renders a Combobox<University> fetched from universitiesApi.list() instead of a plain text InputallowFreeText={false} since universities are admin-managed/seeded, not user-created; search matches on name or abbreviation (getSearchText), but the displayed label and stored value are just the name — an earlier version appended (ABBR) to the label, which desynced from getSearchText and made a freshly-selected value fail its own exact-match check
  • app/onboarding/page.tsx and app/profile/[id]/edit/page.tsx: fetch universitiesApi.list() alongside the existing canonicalEventsApi.list() call; ProfileDraft.university (string) replaced with university_id/university_name (mirrors the existing event_id/event_name draft pattern used for competition/volunteer experience); PATCH payloads send university_id and strip the display-only university_name field before hitting usersApi.updateMe()
  • EducationCareerSection.tsx (read-only profile view): renders university?.name instead of the old string field

Testing

Backend: full suite (pytest tests/), 544 passed. test_chapters.py/test_universities.py rewritten to match the final route paths, permission boundaries, and response shapes — including explicit coverage for both crash bugs above (they only shipped broken because the success paths were untested), the officer/lead/member permission boundary on the member list, invalid-role rejection (422), and partial join-code updates.

Frontend: npx tsc --noEmit clean on onboarding/page.tsx, profile/[id]/edit/page.tsx, ProfileFields.tsx, Combobox.tsx, and EducationCareerSection.tsx. Manually verified the university combobox search-by-abbreviation and selection round-trip in the browser.

Notes

  • Frontend beyond the university-picker consumer fix (dashboard chapter section, join page, chapter dashboard with QR codes) intentionally out of scope
  • Officer permissions beyond read-only member-list access remain deferred (# TODO throughout)
  • A lead assigning another lead for their own chapter (via the member role-update route, not the admin-only /leads/ endpoint) is intentional, not a privilege-escalation gap

brandonalo and others added 30 commits July 31, 2026 15:03
…etween user's university, chapter's school, and tournament location
…server to in-memory sqlite like the other test files
@ethnjs ethnjs linked an issue Aug 1, 2026 that may be closed by this pull request
6 tasks
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
nexus Ready Ready Preview Aug 1, 2026 1:59am

@railway-app

railway-app Bot commented Aug 1, 2026

Copy link
Copy Markdown

🚅 Deployed to the nexus-pr-58 environment in nexus

Service Status Web Updated (UTC)
nexus ✅ Success (View Logs) Aug 1, 2026 at 1:58 am

@railway-app
railway-app Bot temporarily deployed to nexus / nexus-pr-58 August 1, 2026 01:29 Destroyed
@railway-app
railway-app Bot temporarily deployed to nexus / nexus-pr-58 August 1, 2026 01:53 Destroyed
@ethnjs
ethnjs marked this pull request as ready for review August 1, 2026 01:54
@railway-app
railway-app Bot temporarily deployed to nexus / nexus-pr-58 August 1, 2026 01:58 Destroyed
@ethnjs
ethnjs merged commit 0e324ad into main Aug 1, 2026
3 of 4 checks passed
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.

feat: alumni chapters

2 participants