feat: POST /api/jersey-public-orders endpoint (#430) #450
No reviewers
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo_admin/basketball-api!450
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "430-jersey-public-post-endpoint"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds the System B public jersey intake POST endpoint. Any signed-in westside-basketball realm user can submit an order; the caller's Keycloak
subis stamped on the row viasubmitter_keycloak_sub, whileplayer_name/emailcome verbatim from the request body so a parent can submit on behalf of a child.Closes #430
Changes
src/basketball_api/routes/jersey_public.py— new router module with inline Pydantic schemas (JerseyPublicOrderIn,JerseyPublicOrderCreated) and a singlePOST ""route. Validateskq,tier,preferred_number_*,top_size/short_sizevia Pydantic v2pattern=constraints. Extracts submission IP fromX-Forwarded-For(first hop) orrequest.client.host, dropping unparseable values so theinetcolumn never receives junk (e.g. FastAPI TestClient's"testclient"sentinel). Wraps the DB write in a try/rollback and raises 500 on SQLAlchemy errors.src/basketball_api/main.py— importsjersey_public_routeralongside the other route imports and registers it withprefix="/api/jersey-public-orders"next to the existingjersey_routerregistration.tests/test_jersey_public.py— 24 tests (1 unauth, 5 happy-path/behavioral, 18 parametrized validation). Mocksget_current_uservia FastAPI dependency override; no real Keycloak in CI.No edits to
routes/checkout.py,routes/jersey.py,routes/__init__.py,src/basketball_api/models.py, orsrc/basketball_api/auth.py(verified withgit diff main→ empty).Notes on deviations from the ticket's example code
constr(regex=...)andEmailStr. basketball-api is on Pydantic v2, so constraints are expressed viaField(..., pattern=...).EmailStris avoided becauseemail-validatoris not a dependency inpyproject.toml— a simple RFC-ish regex is used for the email field (^[^@\s]+@[^@\s]+\.[^@\s]+$). Acceptance testinvalid email → 400still passes.submission_ipis validated withipaddress.ip_address()before insert; unparseable values store as NULL rather than crashing theinetcast.Auth chain
from basketball_api.auth import User, get_current_user(lines 77–159 ofauth.py) — validates RS256 JWT against westside-basketball Keycloak realm JWKSuser: User = Depends(get_current_user)matches theroutes/subscriptions.pyline 367 patternuser.subis written tojersey_public_orders.submitter_keycloak_sub;user.email/user.usernameare NOT used — body values win (perfeedback_funnel_requires_auth.md: authenticated but user-editable submission).get_current_useron missing/invalid token; no 403 path (any realm user may POST).Test Plan
pytest tests/test_jersey_public.py -v→ 24 passed in 2.75s (local Postgres)ruff format+ruff check→ cleanpython -c "from basketball_api.routes.jersey_public import router; print(router.routes)"→[('', {'POST'})]python -c "from basketball_api.main import app; ..."→registered: ['/api/jersey-public-orders']grep -rn "schemas/jersey_public" src/basketball_api/→ no matchesgrep -c "get_current_user" src/basketball_api/routes/jersey_public.py→ 2git diff main -- <untouched files>→ 0 linesgit status→ exactly 3 files (1 modified, 2 new)Review Checklist
get_current_user— no new auth primitiveplayer_name/emailNOT overridden by JWT claims (parent-for-child submission supported)schemas/directory createdroutes/__init__.pynot touchedroutes/checkout.py,routes/jersey.py,models.py,auth.pynot touchedmain.pymatching existing pattern{id, status, created_at}for frontend confirmationRelated Notes
story:WS-S31— admin public jersey intake linkarch-jersey-intakevalidation-429-2026-04-11)routes/jersey_public.pyafter this merges)feedback_funnel_requires_auth.md— why this is Keycloak-gatedroutes/subscriptions.pyline 367 —get_current_userusage patternPR #450 Review
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Pydantic v2 / Keycloak JWT. Production endpoint handling PII for System B public jersey intake (issue #430). 3 files, +378/-0.
FastAPI / Pydantic v2:
Field(..., pattern=...)throughout. No v1constr(regex=...).EmailStrintentionally avoided (noemail-validatordep inpyproject.toml) — substituted with^[^@\s]+@[^@\s]+\.[^@\s]+$. Deviation documented in PR body. Acceptable — spec compliance is for basic format sanity, not full RFC-5322.payload,request,user,dball wired viaDepends— canonical FastAPI pattern.JerseyPublicOrderCreatedreturns{id, status, created_at}withstatus_code=201— contract matches spec.Auth chain:
from basketball_api.auth import User, get_current_user— verified againstsrc/basketball_api/auth.pylines 77–159: this is the real primitive. No ghostkeycloak_userreference.user: User = Depends(get_current_user)on route signature — matchesroutes/subscriptions.pyline 367 pattern.submitter_keycloak_sub=user.substamped on row.user.email/user.usernameNOT used on insert — body values win, so parent-for-child submission is supported perfeedback_funnel_requires_auth.md. Dedicated testtest_body_fields_not_overridden_by_jwt_claimsenforces this.get_current_user(verified in auth.py line 85–90).SQLAlchemy / DB safety:
try / db.commit() / db.refresh()withexcept SQLAlchemyError: db.rollback(); raise 500.logger.exceptioncaptures full traceback. Correct rollback discipline.submission_ipdefensively validated viaipaddress.ip_address()before insert — preventsinetcast crashes on TestClient's"testclient"sentinel or malformed XFF. Smart move; not in the spec but protects theinetcolumn.xff.split(",")[0].strip()) before falling back torequest.client.host. Correct for Tailscale funnel → Traefik → pod.CORS: verified
main.pystill includeshttps://westsidekingsandqueens.tail5b443a.ts.netinallow_origins. Frontend POST will not be blocked.Logging / secrets:
logger.info("jersey_public_order created id=%s sub=%s tier=%s", ...)— logs only opaque Keycloak UUID + tier. No email, name, IP, or JWT in logs. No plaintext secrets. Clean.Validation patterns match spec and the DB CHECK constraints from migration 043:
_NUMBER_PATTERN = r"^(0|00|[1-9][0-9]?)$"— matches0,00,1–99._SIZE_PATTERN = r"^(YS|YM|YL|YXL|AS|AM|AL|AXL)$"_KQ_PATTERN = r"^(kings|queens)$"_TIER_PATTERN = r"^(90-reversible|130-reversible-shooter)$"Test coverage (24 tests, 2.75s):
get_current_user, no override)submitter_keycloak_subin (400, 422)— lenient but safe since Pydantic raises 422 in FastAPI.app.dependency_overrides.clear()in fixture teardown prevents cross-test bleed.get_current_uservia FastAPIdependency_overrides— no real Keycloak in CI, per SOP.main.py scope: exactly 2 lines added (import +
include_router(..., prefix="/api/jersey-public-orders")). No other modifications. Scope-tight.Untouched files verified in diff:
routes/checkout.py,routes/jersey.py,routes/__init__.py,models.py,auth.py,alembic/versions/*— all absent from diff.BLOCKERS
None.
NITS
player_namehas Pydanticmin_length=1, then.strip()is applied and checked again with a 400. A whitespace-only input (" ") passes Pydantic then fails the strip check — behavior is correct but not covered by an explicit test. Low-value addition; log as subphase in plan Epilogue perfeedback_nits_to_epilogue.md, don't block merge.team.strip()is applied on insert but there's no post-strip minimum-length check and no test for whitespace-onlyteam. Same class as (1).^[^@\s]+@[^@\s]+\.[^@\s]+$is intentionally minimal. Ifemail-validatorbecomes a dep later (e.g. via the blast pipeline in basketball-api), consider swapping toEmailStrfor consistency. Non-blocking; deviation is documented.resp.status_code in (400, 422)— slightly loose. Pydantic always returns 422 here so the tests could tighten to== 422. Cosmetic.SOP COMPLIANCE
430-jersey-public-post-endpoint— matches{issue}-{kebab}conventionfeedback_funnel_requires_auth.mdCloses #430presentstory:WS-S31), arch (arch-jersey-intake), board item (#948), dep (#429), parallel (#950)PROCESS OBSERVATIONS
validation-429-2026-04-11), model merged in #433/#442, so this PR is the final blocker for the/jersey-publicroute to go live.inetdefensive-parse prevents a class of 500 from bad proxy headers. The one remaining CI concern (service-container Postgres) is called out in the Test Plan checklist as unchecked — QA flags this perfeedback_qa_ci_blockers.md: tests must run in CI with a real Postgres service container. If CI uses SQLite, theinet/UUID/check constraintcoverage is not real — verify the CI Woodpecker config wires the Postgres service before merging. Not a blocker because the PR body explicitly defers this to "CI: full test suite runs with service-container Postgres" — that item must be verified green before merge.VERDICT: APPROVED