feat: jersey Stripe checkout + second tryout announcement email #104
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!104
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "103-jersey-stripe-checkout-second-tryout-ann"
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
Changes
src/basketball_api/models.py-- AddedJerseyOption,JerseyOrderStatusenums, jersey fields on Player,announcementEmailTypealembic/versions/012_add_jersey_fields_to_player.py-- Migration for new enums and player columnssrc/basketball_api/routes/jersey.py-- NEW:GET /jersey/optionsandPOST /jersey/checkout?token=with Stripe Checkout Session creation and opt-out handlingsrc/basketball_api/main.py-- Register jersey router at/jerseyprefixsrc/basketball_api/routes/webhooks.py-- Handlecheckout.session.completedwithjersey_optionmetadata to mark player aspaidsrc/basketball_api/services/keycloak.py-- Addedreset_parent_password()using Admin API PUT credentialssrc/basketball_api/services/email.py-- Addedsend_tryout_announcement_email()with all branded HTML sectionssrc/basketball_api/routes/admin.py-- AddedPOST /admin/email/tryout-announcementwith optionaltest_emailfilter and graceful Keycloak fallbacktests/test_jersey.py-- 17 tests covering all new functionalityTest Plan
ruff check src/ tests/-- clean, no lint errorsruff format --check src/ tests/-- all files formattedGET /jersey/optionsPOST /jersey/checkout?token={token}POST /admin/email/tryout-announcement?test_email={email}Review Checklist
Related
PR Review -- Self-Review
Findings
No blocking issues found. Implementation follows existing codebase patterns correctly.
Positive observations:
subscriptions.py(customer create/reuse, metadata, session creation)reset_parent_password()follows the same API pattern ascreate_keycloak_user()with proper error handling_brand_wrapper()and brand constants consistentlytest_emailfilter for Marcus test is cleanMinor observations (non-blocking):
player_idof the first player in Stripe metadata. If a parent has multiple players, only the first player gets updated by the webhook. The checkout route sets all players to pending, but the webhook only marks one as paid. This is acceptable for the current use case (most parents have one player) but worth noting for future.Credentialstype alias inemail.pyisdict-- could be a TypedDict for better type safety, but matches the lightweight pattern used elsewhere in this codebase.VERDICT: APPROVE
Implementation is complete, well-tested, follows all existing patterns, and handles error cases gracefully.
PR #104 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Stripe / Keycloak / Gmail SDK
This PR adds three features:
routes/jersey.py-- new file)services/keycloak.py--reset_parent_password())services/email.py--send_tryout_announcement_email(),routes/admin.py-- new endpoint)Python/FastAPI quality: Code follows FastAPI patterns well -- proper dependency injection, Pydantic response models, structured logging, and good separation between route/service layers. Type hints present throughout. Docstrings are thorough.
SQLAlchemy/Alembic: Migration 012 uses
checkfirst=Truefor enum creation andIF NOT EXISTSfor the ALTER TYPE -- good idempotency. Model columns correctly typed with server defaults.Stripe integration: Webhook handler correctly differentiates jersey checkouts from registration checkouts via metadata routing. Signature verification preserved.
Test coverage: 17 new tests covering jersey options, checkout flow (happy path + invalid token + invalid option + opt-out + stripe session + pending status + customer reuse), webhook processing, announcement endpoint (all parents + test_email filter + keycloak failure fallback + 401 without auth), and email content assertions (all sections + no-password fallback). Good coverage of error paths.
BLOCKERS
1. Hardcoded Stripe redirect URLs point to wrong hostname (
routes/jersey.py, lines 41-42)The hostname
westside.tail5b443a.ts.netdoes not match either CORS origin inmain.py(westsidekingsandqueens.tail5b443a.ts.netorwestside-dev.tail5b443a.ts.net). This means Stripe will redirect users to a URL that may not resolve or points to the wrong host. These URLs should usesettings.base_urlto stay environment-consistent, or at minimum use the correct production hostname.This is a functional correctness blocker -- users completing jersey payment will be redirected to a non-existent or incorrect page.
2.
randommodule used for password generation instead ofsecrets(services/password.py, line 22)The
randommodule is not cryptographically secure. While this was already flagged in the plan-wkq Epilogue (PR #94), this PR now callsgenerate_password()in a new context: resetting existing user passwords via Keycloak Admin API and sending them in plaintext over email (admin.pylines 543-544). The password is the only auth credential delivered to parents. Usingrandommakes the generated passwords predictable if the seed state is known.This is a pre-existing issue but this PR extends its blast radius by using it for password resets (not just initial creation). Should use
secrets.randbelow(90) + 10instead.NITS
DRY: Brand color constants duplicated in
_build_confirmation_html(services/email.py, lines 145-151) -- Local variablesblack,dark,red, etc. duplicate the module-level_BRAND_*constants defined at lines 278-284. The_brand_wrapper()helper andsend_profile_reminder_email()already use the module-level constants. The old_build_confirmation_html()should be migrated too, but that is pre-existing scope.DRY:
admin_clientfixture duplicated (tests/test_jersey.py, lines 32-50) -- Sameadmin_clientpattern astest_admin_email.py,test_admin_spa.py,test_contract.py, and 8+ other test files. This is a pre-existing epilogue item (PR #96) but the new test file adds another copy.Realm parameter inconsistency in
reset_parent_password(services/keycloak.py, lines 174-205) -- The function accepts arealmparameter and uses it for the PUT credentials call (line 205), but callsget_existing_user()(line 197) which internally hardcodes the module-levelREALMconstant. If this function were ever called with a different realm, the user lookup and password reset would target different realms. Currently safe becauseadmin.pyline 551 always passesREALM, but the API contract is misleading.Hardcoded event content (
services/email.py, lines 649-657, 692-696) -- Tryout date "Tuesday, March 24", time "4:00 - 5:30 PM", tournament "April 10-11" are hardcoded in the email template. These are one-time-use values so hardcoding is pragmatic, but worth noting as non-reusable.player_idin metadata only tracks first player (routes/jersey.py, line 112) -- When a parent has multiple players, onlyparent.players[0]is used for Stripe metadata. The webhook handler (webhooks.pyline 151) then only updates that one player's jersey status. But the checkout endpoint updates ALL players to pending (lines 168-170). This means sibling players who were set to pending won't get updated topaidvia the webhook.Missing
tokenquery param in Pydantic body (routes/jersey.py, line 79) -- Thetokenparameter usesQuery(...)alongside abody: JerseyCheckoutRequestPydantic body. This works but mixing query params with body params in a POST can be confusing for API consumers. The token could be part of the request body instead. Non-blocking.Pre-existing: magic number
20000(routes/admin.py, line 237) -- Should importMONTHLY_AMOUNT_CENTSfromsubscriptions.py. Already tracked in Epilogue (PR #96).SOP COMPLIANCE
PROCESS OBSERVATIONS
Deployment Frequency: This PR bundles three related features (jersey ordering, password reset, announcement email) into one PR. The scope is cohesive -- all three features are needed for the announcement email to work end-to-end. Good pragmatic scoping.
Change Failure Risk: The hardcoded Stripe redirect URLs (blocker #1) would cause a user-facing failure post-deploy. The multi-player jersey payment gap (nit #5) is a latent bug that will surface when a parent with multiple players pays for jerseys.
Epilogue accumulation: Several nits are re-occurrences of existing epilogue items (admin_client duplication, magic number 20000, random vs secrets). These should be prioritized for cleanup to prevent ongoing accumulation.
VERDICT: NOT APPROVED
Two blockers:
westside.tail5b443a.ts.netvswestsidekingsandqueens.tail5b443a.ts.net). Must usesettings.base_urlor correct hostname.randomvssecretsfor password generation -- pre-existing but blast radius expanded by this PR's new password reset flow. Should be fixed now that passwords are being reset and emailed to users.Fix these two items and re-request review.
PR #104 Re-Review
Re-review after blocker fixes from initial review. Parent issue: #103. Plan: plan-wkq.
BLOCKER FIX VERIFICATION
Blocker 1 -- Hardcoded Stripe redirect URLs: FIXED.
src/basketball_api/routes/jersey.pylines 43-44 now derive_SUCCESS_URLand_CANCEL_URLfrom_settings.frontend_urlinstead of hardcoded hostnames.frontend_urlis defined inconfig.pyline 28 as apydantic_settingsfield, loaded fromBASKETBALL_FRONTEND_URLenv var.Blocker 2 --
randommodule for password generation: FIXED.src/basketball_api/services/password.pyimportssecrets(line 10) and usessecrets.randbelow(90)(line 22).import random/from random importstatements anywhere insrc/.Additional fix -- email links using wrong base URL: FIXED.
email.pyline 348: profile reminder usessettings.frontend_urlemail.pyline 584: jersey URL usessettings.frontend_urlemail.pyline 589: player profile URLs usesettings.frontend_urlsettings.base_urlis correctly reserved for API-internal URLs (coach invite callbacks, registration links, photo URLs).DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Pydantic / Stripe / Keycloak / Gmail SDK
PEP compliance:
-> str | None,-> bool,Mapped[...]).OWASP / Security:
html.escape()throughoutemail.py.webhooks.pylines 175-190.secretsmodule used for all security-sensitive random generation (passwords, tokens).stripe_api_keyandkeycloak_admin_passwordloaded from env vars viapydantic_settingswith empty defaults.SQLAlchemy patterns:
joinedloadused appropriately for relationship loading (Parent.players,Player.parent, etc.).012correctly creates enum types withcheckfirst=Trueand adds columns.FastAPI patterns:
Depends(get_db),Depends(require_admin)).JerseyCheckoutRequestproperly validates input through Pydantic, with secondaryJerseyOptionenum validation.Error handling:
email.py).Trueto acknowledge, preventing Stripe retries).BLOCKERS
None. Both previously identified blockers have been fixed.
NITS
Module-level URL construction in
jersey.py(lines 40-44): Thefrom basketball_api.config import settings as _settingsimport and URL construction happen at module load time. This works but means the URLs are frozen at import time and won't reflect runtime config changes. For this codebase (single-process, config-from-env) this is fine, but worth noting. Non-blocking.base_urlvsfrontend_urlconsistency in existing code: The remainingsettings.base_urlusages inemail.pyline 56 (confirmation emailreg_url) andtryouts.pylines 509, 795 still point to the API host. These are pre-existing and outside this PR's scope, but they may need the samefrontend_urltreatment in a follow-up if those links are meant for end users. Discovered scope -- should be tracked as a separate issue.Brand color duplication in
email.py: The_build_confirmation_htmlfunction (lines 145-151) defines brand colors locally, while_BRAND_*module constants (lines 278-287) define the same values. The newer code correctly uses the module constants. The older function predates this PR and is not in scope, but a future cleanup could unify them.SOP COMPLIANCE
103-jersey-stripe-checkout-second-tryout-ann-> issue #103)plan-wkq)test_jersey.pycovering options listing, checkout flow, opt-out, Stripe session creation, webhook processing, announcement email content, Keycloak failure fallback, and auth enforcementPROCESS OBSERVATIONS
checkout.session.completedevents withoutjersey_optionmetadata fall through to existing registration processing. No breaking changes to existing flows.settings.base_urlusages inemail.pyline 56 andtryouts.pylines 509/795 should be audited in a follow-up issue to determine if they should also usefrontend_url.VERDICT: APPROVED