POST /api/jersey-public-orders — public submission endpoint #430

Closed
opened 2026-04-10 21:52:20 +00:00 by forgejo_admin · 1 comment
Contributor

Type

Feature

Lineage

Depends on basketball-api#429 (migration 031). Part of System B production rollout. Architecture in arch-jersey-intake. Revised 2026-04-10 (three times): Keycloak-gated per feedback_funnel_requires_auth.md; migration number corrected to 031 (head is 030); auth dependency corrected from non-existent keycloak_user to real get_current_user from basketball_api.auth; dropped non-existent schemas/ directory target.

Repo

forgejo_admin/basketball-api

User Story

As a signed-in player or parent (via westside-basketball Keycloak realm)
I want a POST endpoint accepting my jersey intake submission
So that my order lands in jersey_public_orders with my verified Keycloak identity attached

Context

Verified 2026-04-10 against live basketball-api source:

  • Auth primitive: get_current_user lives in src/basketball_api/auth.py lines 77–159. Returns a User dataclass with .sub, .email, .username, .roles. Validates RS256 via JWKS against the westside-basketball realm.
  • Reference modules using get_current_user: routes/subscriptions.py (line 367: user: User = Depends(get_current_user)), routes/account.py, routes/players.py, routes/teams.py, routes/tryouts.py, routes/upload.py, routes/coaches_api.py
  • routes/checkout.py is NOT a valid reference for Keycloak user auth — it only uses get_db and require_admin. Ignore earlier versions of this ticket that pointed at it.
  • Pydantic schemas are declared inline in the route file — no schemas/ directory exists in basketball-api. See routes/checkout.py lines 38–63 or routes/jersey.py for inline convention.
  • routes/__init__.py is empty and unused; routers are imported directly in main.py. Do NOT modify routes/__init__.py.
  • Migration head is 030_add_registration_type_to_registrations.py. #429 creates migration 031. This ticket depends on 031 merging first.

File Targets

Files to create:

  • src/basketball_api/routes/jersey_public.py — new route module with POST endpoint, Pydantic request/response schemas declared inline at the top of the file (match routes/subscriptions.py style)

Files to modify:

  • src/basketball_api/main.py — register the new router (match the existing router registration pattern near lines 67–94)

Files the agent should NOT touch:

  • routes/checkout.py — System C, irrelevant to this ticket
  • routes/jersey.py — System A, hands off
  • routes/__init__.py — empty and unused
  • src/basketball_api/models.py — already modified by #429 to add JerseyPublicOrder; do not touch
  • src/basketball_api/auth.py — read-only reference
  • Any other route file

Endpoint spec

POST /api/jersey-public-orders
Content-Type: application/json
Authorization: Bearer <JWT from westside-basketball realm>

Request body:
{
  "player_name": "Jane Smith",
  "email": "parent@example.com",
  "team": "marcus-kings",
  "kq": "kings" | "queens",
  "preferred_number_1": "23",    // optional
  "preferred_number_2": null,
  "preferred_number_3": null,
  "top_size": "AM",
  "short_size": "AM",
  "tier": "90-reversible" | "130-reversible-shooter"
}

Success (201):
{
  "id": "uuid",
  "status": "pending",
  "created_at": "2026-04-10T22:00:00Z"
}

Errors:
- 401: missing / invalid Bearer token (returned by get_current_user dep)
- 400: Pydantic field validation failure
- 422: malformed JSON
- 500: DB write failure

Note: no 403 path on this endpoint — it's authenticated-but-not-admin-gated. Any valid user of the westside-basketball realm may POST.

Keycloak integration

  • Import: from basketball_api.auth import User, get_current_user
  • Route signature: async def create_jersey_public_order(..., user: User = Depends(get_current_user), db: Session = Depends(get_db))
  • Extract user.sub and insert into jersey_public_orders.submitter_keycloak_sub
  • email and player_name in the request body are user-editable — do NOT override with user.email / user.username (parent may submit for child)

Pydantic schemas (inline, in routes/jersey_public.py)

from pydantic import BaseModel, EmailStr, Field, constr
from typing import Optional
from uuid import UUID
from datetime import datetime

NumberStr = constr(regex=r"^(0|00|[1-9][0-9]?)$")
SizeStr   = constr(regex=r"^(YS|YM|YL|YXL|AS|AM|AL|AXL)$")

class JerseyPublicOrderIn(BaseModel):
    player_name: constr(min_length=1, max_length=200)
    email: EmailStr
    team: constr(min_length=1, max_length=100)
    kq: constr(regex=r"^(kings|queens)$")
    preferred_number_1: Optional[NumberStr] = None
    preferred_number_2: Optional[NumberStr] = None
    preferred_number_3: Optional[NumberStr] = None
    top_size: SizeStr
    short_size: SizeStr
    tier: constr(regex=r"^(90-reversible|130-reversible-shooter)$")

class JerseyPublicOrderCreated(BaseModel):
    id: UUID
    status: str
    created_at: datetime

Acceptance Criteria

  • POST without Authorization header → 401
  • POST with invalid/expired Bearer token → 401
  • POST with valid token + valid body → 201 with id, status='pending', created_at
  • Row lands with submitter_keycloak_sub equal to user.sub from the JWT
  • player_name and email stored as request-body values (NOT overridden with user.username / user.email)
  • Missing required field → 400 with field name
  • Invalid kq or tier → 400
  • Invalid preferred_number_N pattern → 400
  • All three preferred numbers blank → 201 (optional)
  • submission_ip populated from X-Forwarded-For (first entry) or request.client.host
  • CORS allows westsidekingsandqueens.tail5b443a.ts.net
  • Router registered in main.py with prefix /api/jersey-public-orders
  • Pydantic schemas declared inline in routes/jersey_public.py (no new schemas/ directory)

Test Expectations

  • Unit test: unauth → 401
  • Unit test: valid JWT + valid body → 201, row correct
  • Unit test: submitter_keycloak_sub matches mocked user.sub
  • Unit test: body email/name NOT overridden by JWT claims
  • Unit test: missing required field → 400
  • Unit test: invalid email → 400
  • Unit test: invalid kq → 400
  • Unit test: invalid tier → 400
  • Unit test: invalid number pattern → 400
  • Unit test: all optional fields blank → 201
  • Integration test: happy path with TestClient + mocked get_current_user
  • Run command: pytest tests/ -k jersey_public

Constraints

  • Reuse get_current_user — do NOT roll new auth
  • Match inline-Pydantic convention from routes/subscriptions.py and routes/checkout.py
  • Do NOT create src/basketball_api/schemas/ — no such directory exists
  • Do NOT modify routes/__init__.py — it's unused
  • Do NOT send email from this endpoint (separate ticket #431)
  • Response must include id for frontend confirmation
  • Tests mock get_current_user — no real Keycloak in CI

Checklist

  • PR opened against basketball-api main
  • Depends on #429 (migration 031) merged first
  • Tests pass in CI with real DB + mocked get_current_user
  • Auth chain documented in PR body per feedback_funnel_requires_auth.md
  • No unrelated changes
  • westside-basketball — project
  • story:WS-S31 — admin public jersey intake link
  • arch-jersey-intake — architecture doc
  • feedback_funnel_requires_auth.md — why this is Keycloak-gated
  • Depends on: basketball-api#429 (migration 031)
  • Reference: routes/subscriptions.py line 367 for get_current_user usage pattern
  • Reference: src/basketball_api/auth.py lines 77–159 for the auth primitive
### Type Feature ### Lineage Depends on `basketball-api#429` (migration 031). Part of System B production rollout. Architecture in `arch-jersey-intake`. **Revised 2026-04-10 (three times):** Keycloak-gated per `feedback_funnel_requires_auth.md`; migration number corrected to 031 (head is 030); auth dependency corrected from non-existent `keycloak_user` to real `get_current_user` from `basketball_api.auth`; dropped non-existent `schemas/` directory target. ### Repo `forgejo_admin/basketball-api` ### User Story As a signed-in player or parent (via westside-basketball Keycloak realm) I want a POST endpoint accepting my jersey intake submission So that my order lands in `jersey_public_orders` with my verified Keycloak identity attached ### Context **Verified 2026-04-10 against live basketball-api source:** - Auth primitive: `get_current_user` lives in `src/basketball_api/auth.py` lines 77–159. Returns a `User` dataclass with `.sub`, `.email`, `.username`, `.roles`. Validates RS256 via JWKS against the `westside-basketball` realm. - Reference modules using `get_current_user`: `routes/subscriptions.py` (line 367: `user: User = Depends(get_current_user)`), `routes/account.py`, `routes/players.py`, `routes/teams.py`, `routes/tryouts.py`, `routes/upload.py`, `routes/coaches_api.py` - **`routes/checkout.py` is NOT a valid reference** for Keycloak user auth — it only uses `get_db` and `require_admin`. Ignore earlier versions of this ticket that pointed at it. - Pydantic schemas are declared **inline in the route file** — no `schemas/` directory exists in basketball-api. See `routes/checkout.py` lines 38–63 or `routes/jersey.py` for inline convention. - `routes/__init__.py` is empty and unused; routers are imported directly in `main.py`. Do NOT modify `routes/__init__.py`. - Migration head is `030_add_registration_type_to_registrations.py`. `#429` creates migration 031. This ticket depends on 031 merging first. ### File Targets Files to create: - `src/basketball_api/routes/jersey_public.py` — new route module with POST endpoint, Pydantic request/response schemas declared **inline** at the top of the file (match `routes/subscriptions.py` style) Files to modify: - `src/basketball_api/main.py` — register the new router (match the existing router registration pattern near lines 67–94) Files the agent should NOT touch: - `routes/checkout.py` — System C, irrelevant to this ticket - `routes/jersey.py` — System A, hands off - `routes/__init__.py` — empty and unused - `src/basketball_api/models.py` — already modified by #429 to add `JerseyPublicOrder`; do not touch - `src/basketball_api/auth.py` — read-only reference - Any other route file ### Endpoint spec ``` POST /api/jersey-public-orders Content-Type: application/json Authorization: Bearer <JWT from westside-basketball realm> Request body: { "player_name": "Jane Smith", "email": "parent@example.com", "team": "marcus-kings", "kq": "kings" | "queens", "preferred_number_1": "23", // optional "preferred_number_2": null, "preferred_number_3": null, "top_size": "AM", "short_size": "AM", "tier": "90-reversible" | "130-reversible-shooter" } Success (201): { "id": "uuid", "status": "pending", "created_at": "2026-04-10T22:00:00Z" } Errors: - 401: missing / invalid Bearer token (returned by get_current_user dep) - 400: Pydantic field validation failure - 422: malformed JSON - 500: DB write failure ``` Note: no 403 path on this endpoint — it's authenticated-but-not-admin-gated. Any valid user of the `westside-basketball` realm may POST. ### Keycloak integration - Import: `from basketball_api.auth import User, get_current_user` - Route signature: `async def create_jersey_public_order(..., user: User = Depends(get_current_user), db: Session = Depends(get_db))` - Extract `user.sub` and insert into `jersey_public_orders.submitter_keycloak_sub` - `email` and `player_name` in the request body are **user-editable** — do NOT override with `user.email` / `user.username` (parent may submit for child) ### Pydantic schemas (inline, in routes/jersey_public.py) ```python from pydantic import BaseModel, EmailStr, Field, constr from typing import Optional from uuid import UUID from datetime import datetime NumberStr = constr(regex=r"^(0|00|[1-9][0-9]?)$") SizeStr = constr(regex=r"^(YS|YM|YL|YXL|AS|AM|AL|AXL)$") class JerseyPublicOrderIn(BaseModel): player_name: constr(min_length=1, max_length=200) email: EmailStr team: constr(min_length=1, max_length=100) kq: constr(regex=r"^(kings|queens)$") preferred_number_1: Optional[NumberStr] = None preferred_number_2: Optional[NumberStr] = None preferred_number_3: Optional[NumberStr] = None top_size: SizeStr short_size: SizeStr tier: constr(regex=r"^(90-reversible|130-reversible-shooter)$") class JerseyPublicOrderCreated(BaseModel): id: UUID status: str created_at: datetime ``` ### Acceptance Criteria - [ ] POST without `Authorization` header → 401 - [ ] POST with invalid/expired Bearer token → 401 - [ ] POST with valid token + valid body → 201 with `id`, `status='pending'`, `created_at` - [ ] Row lands with `submitter_keycloak_sub` equal to `user.sub` from the JWT - [ ] `player_name` and `email` stored as request-body values (NOT overridden with `user.username` / `user.email`) - [ ] Missing required field → 400 with field name - [ ] Invalid `kq` or `tier` → 400 - [ ] Invalid `preferred_number_N` pattern → 400 - [ ] All three preferred numbers blank → 201 (optional) - [ ] `submission_ip` populated from `X-Forwarded-For` (first entry) or `request.client.host` - [ ] CORS allows `westsidekingsandqueens.tail5b443a.ts.net` - [ ] Router registered in `main.py` with prefix `/api/jersey-public-orders` - [ ] Pydantic schemas declared inline in `routes/jersey_public.py` (no new `schemas/` directory) ### Test Expectations - [ ] Unit test: unauth → 401 - [ ] Unit test: valid JWT + valid body → 201, row correct - [ ] Unit test: `submitter_keycloak_sub` matches mocked `user.sub` - [ ] Unit test: body email/name NOT overridden by JWT claims - [ ] Unit test: missing required field → 400 - [ ] Unit test: invalid email → 400 - [ ] Unit test: invalid kq → 400 - [ ] Unit test: invalid tier → 400 - [ ] Unit test: invalid number pattern → 400 - [ ] Unit test: all optional fields blank → 201 - [ ] Integration test: happy path with TestClient + mocked `get_current_user` - [ ] Run command: `pytest tests/ -k jersey_public` ### Constraints - Reuse `get_current_user` — do NOT roll new auth - Match inline-Pydantic convention from `routes/subscriptions.py` and `routes/checkout.py` - Do NOT create `src/basketball_api/schemas/` — no such directory exists - Do NOT modify `routes/__init__.py` — it's unused - Do NOT send email from this endpoint (separate ticket #431) - Response must include `id` for frontend confirmation - Tests mock `get_current_user` — no real Keycloak in CI ### Checklist - [ ] PR opened against `basketball-api` main - [ ] Depends on #429 (migration 031) merged first - [ ] Tests pass in CI with real DB + mocked `get_current_user` - [ ] Auth chain documented in PR body per `feedback_funnel_requires_auth.md` - [ ] No unrelated changes ### Related - `westside-basketball` — project - `story:WS-S31` — admin public jersey intake link - `arch-jersey-intake` — architecture doc - `feedback_funnel_requires_auth.md` — why this is Keycloak-gated - Depends on: `basketball-api#429` (migration 031) - Reference: `routes/subscriptions.py` line 367 for `get_current_user` usage pattern - Reference: `src/basketball_api/auth.py` lines 77–159 for the auth primitive
Author
Contributor

Scope Review: NEEDS_REFINEMENT

Review note: review-948-2026-04-10

Architectural intent is sound (Keycloak-authenticated intake, persist JWT sub as submitter). Problems are in file/symbol references — an agent following the ticket literally will hit dead ends.

[BODY] fixes required:

  • keycloak_user dep does NOT exist in this repo. routes/checkout.py does NOT use any Keycloak user dep (only Depends(get_db) and Depends(require_admin)). The real primitive is get_current_user in src/basketball_api/auth.py (lines 77-159), returning a User with .sub. Use routes/account.py / routes/players.py / routes/subscriptions.py as the real reference pattern. Remove "System C pattern" framing.
  • Drop src/basketball_api/schemas/jersey_public.py — that directory does not exist and repo convention is inline Pydantic models per route (see checkout.py lines 38-63).
  • Drop the routes/__init__.py modification — file is empty and unused; main.py imports routers directly.
  • Migration "014" is already taken (014_add_password_reset_tokens.py). Repo is at 030. Next free slot is 031. Fix Lineage to reference the corrected number once T2 (#429) is refined.
  • Reword test constraint to "use app.dependency_overrides[get_current_user] to inject a fake User" instead of the vaguer "mock JWT validation".

[SCOPE] items:

  • Create architecture note arch-jersey-intake in pal-e-docs (referenced but search_notes empty).
  • Create or verify feedback_funnel_requires_auth note (cited as policy justification but not found).

Full details in review note. Once [BODY] fixes land and [SCOPE] items are resolved or explicitly deferred, ready for next_up (after T2 also passes review).

## Scope Review: NEEDS_REFINEMENT Review note: `review-948-2026-04-10` Architectural intent is sound (Keycloak-authenticated intake, persist JWT sub as submitter). Problems are in file/symbol references — an agent following the ticket literally will hit dead ends. **[BODY] fixes required:** - `keycloak_user` dep does NOT exist in this repo. `routes/checkout.py` does NOT use any Keycloak user dep (only `Depends(get_db)` and `Depends(require_admin)`). The real primitive is `get_current_user` in `src/basketball_api/auth.py` (lines 77-159), returning a `User` with `.sub`. Use `routes/account.py` / `routes/players.py` / `routes/subscriptions.py` as the real reference pattern. Remove "System C pattern" framing. - Drop `src/basketball_api/schemas/jersey_public.py` — that directory does not exist and repo convention is inline Pydantic models per route (see checkout.py lines 38-63). - Drop the `routes/__init__.py` modification — file is empty and unused; main.py imports routers directly. - Migration "014" is already taken (`014_add_password_reset_tokens.py`). Repo is at 030. Next free slot is **031**. Fix Lineage to reference the corrected number once T2 (#429) is refined. - Reword test constraint to "use `app.dependency_overrides[get_current_user]` to inject a fake `User`" instead of the vaguer "mock JWT validation". **[SCOPE] items:** - Create architecture note `arch-jersey-intake` in pal-e-docs (referenced but search_notes empty). - Create or verify `feedback_funnel_requires_auth` note (cited as policy justification but not found). Full details in review note. Once [BODY] fixes land and [SCOPE] items are resolved or explicitly deferred, ready for next_up (after T2 also passes review).
forgejo_admin 2026-04-11 20:51:22 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ldraney/basketball-api#430
No description provided.