feat: email verification before payment #162
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!162
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "114-email-verification"
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 an email verification step before payment in the registration flow. Parents must verify their email address by clicking a link before they can proceed to pay, preventing bounced confirmation emails and locked-out registrations from typo'd addresses.
Changes
src/basketball_api/models.py-- AddedEmailVerificationTokenmodel (email, token, expires_at, used) andemail_verified/email_verified_atfields onParent. Addedemail_verificationtoEmailTypeenum.src/basketball_api/routes/register.py-- Added three new JSON API endpoints (POST /api/register/send-verification,GET /api/register/verify-email,GET /api/register/check-verification). Added email verification gate toPOST /api/registerthat returns 400 if email is not verified. Setsparent.email_verified = Trueafter successful registration.src/basketball_api/services/email.py-- Addedsend_email_verification()function with branded HTML email template matching existing Westside red/black style.alembic/versions/020_add_email_verification.py-- Migration addsemail_verifiedandemail_verified_atcolumns to parents table, createsemail_verification_tokenstable, addsemail_verificationenum value.tests/test_email_verification.py-- 14 new tests covering send, verify, check, gate, and full integration flow.tests/test_promo_registration.py-- Updated existing tests to provide verification tokens before registration (required by new gate).tests/test_register_upload.py-- Updated existing tests to provide verification tokens before registration.Test Plan
pytest tests/ -v)Review Checklist
Related
QA Review -- PR #162
VERDICT: NEEDS_FIXES
Blocker: Unrelated changes from other branches included in the diff
The commit includes unstaged modifications from other branches that were present in the working tree at commit time. These must be removed before merge:
player_teamsjunction table and Player/Team relationship refactor (models.py) -- This adds a many-to-manyplayer_teamsassociation table, removesPlayer.team_idcolumn, replacesPlayer.team/Team.playersrelationships with many-to-many via secondary table, and adds backwards-compat@propertymethods. This is unrelated scope from another branch.OAuthTokenmodel (models.py) -- Adds a fullOAuthTokenSQLAlchemy model withoauth_tokenstable. This belongs to issue #130 (Gmail OAuth token persistence), not #114.get_gmail_client()DB token store changes (services/email.py) -- Modifies the function signature to acceptdbparameter, adds token store import, updates all callers (send_confirmation_email,send_profile_reminder_email,send_roster_export_email,send_tryout_announcement_email,send_contract_signed_email,send_password_reset_email,send_jersey_reminder_email). This is unrelated OAuth persistence work.Duplicate email verification function (services/email.py) -- The file contains BOTH
send_email_verification()(compact, used by the routes) ANDsend_verification_email()(longer, with email logging). Only one should exist.Issue-scoped changes look correct
The email verification feature itself is well-implemented:
EmailVerificationTokenmodel with proper fields (email, token, expires_at, used)email_verified/email_verified_atfields on ParentPOST /api/register_ensure_verified()helperRequired fixes
player_teams,OAuthToken, and the Player/Team relationship changes -- only keepEmailVerificationToken,email_verifiedfields on Parent, andemail_verificationenum valueget_gmail_client()DB parameter changes and all caller updates -- only keep thesend_email_verification()function additionsend_verification_email()function (keep onlysend_email_verification()which the routes actually call)PR #162 Review
Title: feat: email verification before payment
Branch:
114-email-verification->mainDiff: +709 / -19 across 7 files
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Alembic / PostgreSQL
Email verification flow (the core feature):
POST /api/register/send-verification-- creates token, sends verification emailGET /api/register/verify-email?token=X-- marks token as used, updates Parent if existsGET /api/register/check-verification?email=X-- polling endpoint for frontendPOST /api/register-- blocks if no used verification token foundThe token lifecycle is sound:
secrets.token_urlsafe(32)for generation, 24-hour expiry, reuse of existing unexpired tokens (rate limiting), and the gate checksused == Truebefore allowing registration. Thehtml.escape()call on the verification URL in the email template prevents XSS in the HTML email body.BLOCKERS
1. DRY VIOLATION: Two nearly-identical email verification functions (BLOCKER)
The diff adds both
send_email_verification()(line ~1066 of email.py diff) andsend_verification_email()(line ~1229 of email.py diff) toservices/email.py. These two functions:_brand_wrapper()/_BRAND_RED/ CTA stylingemail_log, the other does notThe route code (
register.py) imports and callssend_email_verification. The second functionsend_verification_emailappears to be dead code or an earlier draft that was not cleaned up. This is a DRY violation -- there must be exactly one function for this purpose.Action required: Remove
send_verification_email()entirely. Keep onlysend_email_verification()(the one the route actually calls). If email logging is desired, add it to the surviving function.2. SCOPE CREEP: Unrelated changes bundled into this PR (BLOCKER)
This PR is scoped to issue #114 (email verification before payment). The diff includes several changes that belong to other issues:
OAuthTokenmodel (new SQLAlchemy model, ~25 lines) +get_gmail_client()refactor to support DB-backed OAuth token persistence. This is issue #163 scope ("Gmail OAuth token persistence in Postgres"), which is a separate open PR (#163).player_teamsmany-to-many association table + backwards-compatteam/team_idproperties on Player + relationship change on Team. This appears to be issue #124 scope ("many-to-many player-team assignments").send_password_reset_email()signature change (addeddbparameter) -- unrelated to email verification.get_gmail_client(tenant, db=db)changes across 7+ email functions -- this is OAuth persistence work, not email verification.These changes increase the blast radius of this PR significantly. If any of these unrelated changes introduce a regression, it will be entangled with the email verification feature.
Action required: Remove all changes not related to email verification. The
OAuthTokenmodel,token_storeintegration,player_teamstable, andget_gmail_clientrefactor should ship in their own PRs tied to their respective issues.3. Migration does not match model (BLOCKER)
The migration
020_add_email_verification.pycreatesemail_verification_tokenswith columns:id, email, token, created_at, expires_at, used. However, theOAuthTokenmodel is also added tomodels.pyin this diff but has no corresponding migration. WhileOAuthTokenis scope creep (see above), if it ships in this PR, Alembic's autogenerate will detect model/schema drift.Additionally, the migration uses raw SQL (
CREATE TABLE) instead ofop.create_table(). While functional, this bypasses Alembic's schema tracking and makes the migration less portable.Action required: Remove
OAuthTokenfrom this PR (scope creep). For theemail_verification_tokenstable, consider usingop.create_table()instead of raw SQL for consistency with Alembic conventions.NITS
noqa: E712placement incheck_verification()(register.py): The# noqa: E712comment is on the closing)of the query chain, not on the line containingEmailVerificationToken.used == True. Ruff may not suppress the lint warning when the comment is on a different line than the violation.Missing
db.commit()after token reuse path insend_verification: When an existing valid token is found and reused (theif existing_token:branch), the function skips thedb.commit()that only runs in theelsebranch. The token was already committed, so this is not a bug, but the asymmetry is worth noting for readability.send_email_verification()does not accept adbparameter: Unlike all other email functions in this PR that were updated to passdbtoget_gmail_client(tenant, db=db), the newsend_email_verification()callsget_gmail_client(tenant)withoutdb. This means email verification emails will always use file-based OAuth tokens, not DB-backed ones. This is inconsistent with the other changes (though the other changes are scope creep).send_email_verification()does not log toemail_log: Every other email function in this codebase logs sent emails to theemail_logtable. The verification email does not. The dead-code twinsend_verification_email()does log whendbis provided. If logging is desired, add it to the function that is actually called.Timezone handling inconsistency:
verify_email()setsparent.email_verified_at = datetime.now(timezone.utc)(timezone-aware), butapi_register()also setsparent.email_verified_at = datetime.now(timezone.utc)(timezone-aware). Meanwhile, all tokenexpires_atcomparisons use.replace(tzinfo=None)to strip timezone info. The column isDateTime(notDateTime(timezone=True)), so this works but is fragile -- a future change to timezone-aware columns could break comparisons.No rate limiting on
send-verification: The endpoint reuses existing unexpired tokens (good), but there is no limit on how many times the email can be re-sent with the same token. An attacker could trigger email floods to a target address. Consider adding a cooldown (e.g., 60 seconds between sends for the same email).SOP COMPLIANCE
114-email-verificationmatches issue #114Closes #114PROCESS OBSERVATIONS
Change failure risk: MEDIUM-HIGH. This PR bundles email verification (the stated scope) with OAuth token persistence and player-team many-to-many changes. The interleaving of unrelated changes increases the risk of a regression that is hard to bisect. If email verification needs to be reverted, the OAuthToken model and player_teams changes would also be reverted.
Test coverage for stated scope is strong: 14 new tests cover token creation, reuse/rate-limiting, already-verified parent, valid/invalid/expired token verification, parent record update, check-verification polling, unverified-returns-400 gate, verified-allows-registration, parent flag set after registration, and full end-to-end flow. Existing tests are properly updated to pass through the new gate.
Migration safety: The migration adds nullable/defaulted columns to
parents(backwards compatible) and creates a new table (no data migration needed). TheALTER TYPE emailtype ADD VALUE IF NOT EXISTSis safe. Downgrade drops the table and columns cleanly. Migration 020 correctly chains from 019.VERDICT: NOT APPROVED
Three blockers must be resolved:
send_verification_email()function (keep onlysend_email_verification())OAuthTokenmodel,player_teamstable,get_gmail_clientrefactor,send_password_reset_emailsignature change, and alldb=dbchanges to existing email functions. These belong in their respective PRs (#163, #124).Once the PR is scoped strictly to email verification, the core feature logic is solid and the test coverage is thorough.
PR #162 Review (Re-review after fix commit)
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Alembic / gmail-sdk
Previous blockers (3) -- all resolved:
DRY violation / scope creep from #124 (junction table): The
player_teamsjunction table and migration 019 already exist onmain. This PR's diff does NOT touch them. No contamination. Issue #124 / PR #164 are separate.Scope creep from #130 (OAuthToken): No
OAuthTokenmodel anywhere in the diff. Issue #163 is a separate open PR for Gmail OAuth persistence. Clean separation.Only ONE
send_email_verificationfunction: Defined once insrc/basketball_api/services/email.py, called once fromroutes/register.py. No duplication.get_gmail_clientretains its original signature(tenant: Tenant) -> GmailClientwith nodbparameter.Feature additions (all scoped to issue #114):
EmailVerificationTokenmodel -- standalone table, no FK contamination from other featuresemail_verified/email_verified_atfields onParent-- correct placementemail_verificationadded toEmailTypeenumPOST /api/register/send-verification,GET /api/register/verify-email,GET /api/register/check-verificationPOST /api/register-- returns 400 if email not verifiedSecurity review:
secrets.token_urlsafe(32)-- cryptographically secureescape(verification_url)-- XSS prevention on the CTA link_VERIFICATION_TOKEN_LIFETIMEcheck-verificationSQLAlchemy patterns:
get_gmail_client(tenant)-- original signature preserved, no session leakagefunc.lower()-- consistentdatetime.now(timezone.utc).replace(tzinfo=None)pattern matches existing codebase convention for naive UTC inDateTimecolumnssend_email_verificationinside route handler -- avoids circular import, acceptable patternBLOCKERS
None. All three previous blockers are resolved.
NITS
Migration 020 mixed style: Uses raw SQL (
CREATE TABLE) foremail_verification_tokensbutop.add_columnfor the parent fields. Consider usingop.create_tablefor consistency with other migrations (e.g., 019 usesop.create_table). Non-blocking since the raw SQL is correct and includes proper indexes.Tenant-scoped tokens:
EmailVerificationTokenhas notenant_idcolumn, meaning tokens are global across tenants. This is fine for the current single-tenant deployment (Westside) but worth tracking if multi-tenancy becomes a concern. Could be a future issue ticket.Missing
db.commit()after verification gate inapi_register: Theparent.email_verified = Trueassignment at line ~1208 in the diff relies on the laterdb.commit()at the end of the registration flow. If any exception occurs between setting the flag and the final commit, the flag won't persist. This is technically fine since the token itself is already markedused=True(set duringverify-email), so the gate will still pass on retry. Just noting the dependency.send_verificationendpoint missingdb.commit()before email send: When a new token is created,db.commit()happens beforesend_email_verification()is called -- good. But when an existing token is reused, no commit is needed. The flow is correct; just noting I verified it.SOP COMPLIANCE
114-email-verificationmatches issue #114Closes #114test_email_verification.pytest_promo_registration.py,test_register_upload.py) to work with new verification gateNote: PR body does not reference a plan slug in the Related section, but issue #114 is a standalone feature ticket, not a plan phase. Acceptable per kanban workflow.
PROCESS OBSERVATIONS
VERDICT: APPROVED
PR #162 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Gmail OAuth email
This PR adds an email verification gate to the registration flow: parents must verify their email via a link before they can POST to
/api/register. Three new endpoints are added (send-verification,verify-email,check-verification), a new modelEmailVerificationToken, and a migration adding columns toparentsplus a new table.14 new tests and updates to existing registration tests to pass through the new gate.
BLOCKERS
1. MIGRATION COLLISION -- Revision 020 already exists on main (CRITICAL)
The PR introduces
alembic/versions/020_add_email_verification.pywithdown_revision = "019". However:020_add_custom_notes_to_player.pywithdown_revision = "018".This creates two problems:
020. Alembic will refuse to run with "Multiple head revisions."020depends on019which only exists in PR #163. Merging this PR alone breaks the migration chain entirely.Resolution required: PR #163 must merge first. Then this PR's migration must be renumbered to
021withdown_revision = "020"(pointing to the existing020_add_custom_notes_to_player.pyon main). Alternatively, if the020_add_custom_notes_to_player.pycame from a different unmerged PR, the entire chain needs coordination.2. FRONTEND DOES NOT KNOW ABOUT VERIFICATION -- REGISTRATION IS BROKEN ON MERGE
The verification email links to
{settings.frontend_url}/register/verify-email?token={token_value}. But:/register/verify-emailroute. There is no SvelteKit page atsrc/routes/register/verify-email/+page.svelte.src/routes/register/+page.svelte) has no email verification step. It POSTs directly to/api/registerwith no pre-verification call./api/register/send-verificationor/api/register/check-verification.If this PR merges, every registration attempt will return HTTP 400 ("Email not verified") because the frontend never triggers the verification flow. This will lock out all new registrations.
This is not a nit -- this is a production-breaking change that requires a coordinated frontend PR.
3. VERIFICATION GATE TOO AGGRESSIVE -- EXISTING PARENTS LOCKED OUT
The verification gate in
api_register()checks for a usedEmailVerificationTokenrow matching the email. But it does NOT checkparent.email_verifiedon existing Parent records. Consider:Parentrecord) tries to register a second child.email_verification_tokenstable.email_verified = Trueset on their Parent record (from a prior registration), the gate ignores it.The
check-verificationendpoint correctly checks bothParent.email_verifiedAND token table, but the gate inapi_registeronly checks the token table. This is inconsistent and will frustrate returning parents.4. NO EMAIL LOG ENTRY FOR VERIFICATION EMAILS
Every other email send function in
email.pywrites to theemail_logtable (EmailLogmodel withEmailType). The newsend_email_verificationfunction does not log toemail_logat all, even thoughEmailType.email_verificationwas added. This breaks the audit trail pattern that exists for every other email type. In a production system where email deliverability is critical (the entire point of this feature), not logging verification emails is a significant gap.NITS
Inline import in route handler:
send_verification()usesfrom basketball_api.services.email import send_email_verificationinside the function body (line in diff). The existingsend_confirmation_emailimport in the same file also uses this pattern, so it is at least consistent, but a top-level import would be cleaner.Inconsistent error response format: The verification gate uses
HTTPException(status_code=400, detail="Email not verified...")which returns{"detail": "..."}, while the promo code validation usesJSONResponse(status_code=400, content={"error": "Invalid promo code"})which returns{"error": "..."}. The frontend error handler (register/+page.svelteline 140) readsbody?.error, notbody?.detail, so the verification error message will not display correctly to users._ensure_verifiedhelper duplicated across 3 test files:test_email_verification.pyhas_verify(),test_promo_registration.pyhas_ensure_verified(), andtest_register_upload.pyhasself._ensure_verified(). All three do the same thing (create a usedEmailVerificationToken). This could be a shared fixture inconftest.py.send_email_verificationdoes not acceptdbparameter: Unlike other email functions (which PR #163 updates to passdbfor the token store), this new function callsget_gmail_client(tenant)withoutdb. This means after PR #163 merges, verification emails will still use file-based tokens. Should acceptdb: Session | None = Nonefor consistency with the pattern #163 establishes.No rate limiting on
send-verification: The endpoint reuses an existing unexpired token (good), but there is no limit on how many times the email can be re-sent with that same token. An attacker could spam verification emails to any address. Consider a cooldown (e.g., 60 seconds between sends to the same email).Token cleanup: There is no mechanism to clean up expired/used tokens from the
email_verification_tokenstable. Over time this table will grow unbounded. Not urgent but should be tracked.Timezone handling: The code uses
datetime.now(timezone.utc).replace(tzinfo=None)to strip timezone info before DB comparison. This works but is fragile -- if the DB column ever stores timezone-aware datetimes, comparisons will silently break. Theverify_emailendpoint then usesdatetime.now(timezone.utc)(WITH timezone) when settingparent.email_verified_at, while the Parent model column isDateTime(notDateTime(timezone=True)). Inconsistent timezone handling across the same feature.SOP COMPLIANCE
114-email-verificationmatches issue #114PROCESS OBSERVATIONS
Merge order dependency: This PR (email verification, migration 020->019) depends on PR #163 (OAuth token persistence, migration 019->018) which depends on the existing 020 on main. The migration chain requires careful coordination:
Cross-repo coordination gap: The backend adds a verification gate but the frontend (westside-app) has no corresponding changes. This feature requires a coordinated release across both repos. Without the frontend changes, merging this PR breaks production registration.
Change failure risk: HIGH. Merging this PR as-is will:
Recommendation: This PR should be held until (1) PR #163 merges, (2) the migration is renumbered, (3) the gate logic includes a Parent.email_verified check, and (4) a coordinating westside-app PR adds the verification UI flow.
VERDICT: NOT APPROVED
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.