feat: add jersey number selection to checkout flow #146
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!146
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "143-jersey-number-selection"
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
Add jersey number selection to the checkout flow. Parents pick a number (0, 00, or 1-99) during jersey purchase, validated for uniqueness within their child's division. Numbers are persisted through the full Stripe checkout-to-webhook pipeline.
Changes
src/basketball_api/models.py: addjersey_numbercolumn (String(2), nullable) to Player modelsrc/basketball_api/routes/jersey.py: acceptnumberparam inPOST /jersey/checkoutwith range + per-division uniqueness validation; addGET /jersey/taken-numbers?division=endpointsrc/basketball_api/routes/webhooks.py: readjersey_numberfrom Stripe metadata and persist to playeralembic/versions/016_add_jersey_number_to_player.py: new migration adding columntests/test_jersey.py: 16 new tests covering number validation, duplicate rejection, cross-division success, taken-numbers endpoint, and webhook persistenceTest Plan
pytest tests/ -k jersey-- 50 tests pass (34 existing updated + 16 new)Review Checklist
Related
plan-wkq-- Phase 11Self-Review
Reviewed the full diff (5 files, +465/-10). No issues found.
Checklist:
jersey_numberString(2), nullable -- correct for "0"/"00"/"1"-"99"GET /jersey/taken-numbers: filters by division, excludes NULLjersey_numberpassed through checkout and persisted in webhookjersey_numbergracefullyPR #146 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Stripe / pytest
This PR adds jersey number selection to the checkout flow: a new
jersey_numbercolumn on thePlayermodel, range + per-division uniqueness validation inPOST /jersey/checkout, a newGET /jersey/taken-numbersendpoint, Stripe metadata propagation, webhook persistence, a clean Alembic migration, and 16 new tests.Model layer (
models.py):jersey_number: Mapped[str | None] = mapped_column(String(2), nullable=True)-- correctly nullable,String(2)accommodates "0", "00", and "1"-"99". Migration 016 matches the model definition exactly. Good.Validation (
jersey.py):_VALID_JERSEY_NUMBERSuses a set comprehension with{"0", "00"} | {str(n) for n in range(1, 100)}-- correct NFHS rule coverage. Comment references NFHS 2025-2026, which is a nice touch.Player.division,Player.jersey_number, andPlayer.id != player.id. ThePlayer.jersey_number.isnot(None)in the uniqueness filter at line 209 is technically redundant (since== body.numberalready excludes NULLs), but harmless and arguably defensive.Taken-numbers endpoint (
jersey.py):Division(division)with a clean 422 on failure./jersey/optionsand/jersey/sizesendpoints being public.Webhook (
webhooks.py):jersey_numberfrom Stripe metadata and persists directly. Backwards compatible -- missingjersey_numberin metadata is handled gracefully (stays null).Stripe metadata:
jersey_numberadded to the Stripe checkout session metadata alongsidejersey_optionandjersey_size. Consistent with the existing pattern.Migration (016):
add_column/drop_columnpair. Nullable column, no data migration needed. Clean.Test coverage (
test_jersey.py):TestJerseyNumberValidation(8 tests): missing number, invalid number, negative number, duplicate in same division, cross-division success, opt-out no number, metadata passthrough, "0" and "00" edge cases.TestJerseyTakenNumbers(4 tests): returns correct numbers, empty list, invalid division, no auth required.TestJerseyWebhookadditions (2 tests): webhook saves number from metadata, webhook without number still works."number"in request payloads. Good backwards-compat hygiene.BLOCKERS
None.
All blocker criteria pass:
_VALID_JERSEY_NUMBERSset before any DB operation. Division parameter validated before query.NITS
Webhook does not validate
jersey_numberfrom Stripe metadata --webhooks.py:213-214writesjersey_number_strdirectly to the player without checking it against_VALID_JERSEY_NUMBERSor verifying it fitsString(2). In practice, the metadata comes from the checkout session this code created, so it should always be valid. However, defense-in-depth would suggest a quick validation or at minimum a length guard, since Stripe metadata is technically mutable by the merchant dashboard. Low risk but worth noting.Redundant
isnot(None)in uniqueness query --jersey.py:209(Player.jersey_number.isnot(None)) is redundant withPlayer.jersey_number == body.numberon line 208, since SQL equality with a non-null value already excludes NULLs. Not wrong, just unnecessary.No tenant scoping on
GET /jersey/taken-numbers-- the query filters by division but not tenant. This is consistent with the existing jersey routes (which have zero tenant references) and the plan explicitly says "built for Westside first, multi-tenant only if another program wants in." Not a blocker, but when multi-tenancy lands, this endpoint will need a tenant filter. Track as discovered scope.Race condition on uniqueness check -- the uniqueness check at checkout time (
jersey.py:204-213) is application-level, not a database unique constraint. Two concurrent requests for the same number in the same division could both pass validation. A composite unique index on(division, jersey_number)wherejersey_number IS NOT NULLwould be the robust solution. Given the current user base (53 players, low concurrency), this is low risk, but worth tracking for when the platform scales.Triple definition pattern continues -- the epilogue for PR #142 already notes that jersey sizes are defined in three places (enum, route list, validation set). This PR adds
_VALID_JERSEY_NUMBERSas yet another standalone definition. The set is algorithmically derived (not repeated data), so this is less fragile than the size case, but the pattern of parallel definitions is growing.SOP COMPLIANCE
143-jersey-number-selectionmatches issue #143plan-wkqPhase 11, links to prior PRs #140 and #142PROCESS OBSERVATIONS
Deployment frequency: This is the third jersey enhancement in sequence (#140 timing fix, #142 size selection, #146 number selection). Clean incremental delivery -- each PR is independently deployable and backwards compatible. Positive DORA signal.
Change failure risk: Low. The migration is additive (nullable column), the webhook is backwards compatible, and validation is thorough. The only persistence concern is the webhook accepting unvalidated metadata (nit 1), but the blast radius is limited to a single player record.
Documentation: The PR body is exemplary -- clear summary, change list, test plan with checkboxes, and related context. The NFHS rules citation in the code comment adds context for future maintainers.
VERDICT: APPROVED