feat: add generic /email/blast endpoint with query registry #299
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!299
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "295-admin-blast-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 a generic
POST /admin/email/blastendpoint that accepts layout, email_type, query, subject, and data params to send templated email campaigns. New campaigns only need a compiled MJML template and a registered query function -- no new endpoint code required. Ships with theunsigned_contractsquery for contract-offer reminders.Changes
src/basketball_api/services/email_queries.py(new): Query registry module withQUERY_REGISTRYdict andquery_unsigned_contractsfunction. Queries players withcontract_status='offered',contract_signed_at IS NULL, and a validcontract_token, grouped by parent.src/basketball_api/routes/admin.py: AddedPOST /email/blastendpoint withBlastRequest/BlastResponsemodels. Validates query name, email_type enum, and template existence. Merges per-recipient query data with request-leveldataoverrides. Supports{{placeholder}}replacement in both subject and template body. Usessend_templated_email()for sending.tests/test_email_blast.py(new): 15 tests covering query unit tests (unsigned offered, excludes signed, excludes no-token, test_email filter, registry registration) and endpoint integration tests (single send, multiple recipients, empty results, unknown query 422, invalid email_type 422, missing template 422, subject placeholders, data overrides, send failure errors, no-match test_email).Test Plan
pytest tests/test_email_blast.py -v-- all 15 passruff formatandruff checkcleanReview Checklist
Related
Related Notes
QA Review
Findings
Fixed (pushed):
QUERY_REGISTRYtype annotation used lowercasecallable(builtin function) instead ofCallablefromcollections.abc. Fixed in follow-up commit.Verified clean:
/email/profile-reminder,/email/jersey-reminder,/email/contract-reminder-- admin auth viarequire_admin, tenant lookup viaDEFAULT_TENANT_SLUG,test_emailfilter,sent_count/errorsresponse shapeplayer_teams, filtercontract_status,contract_token,contract_signed_at)send_templated_email()used correctly with all required kwargsFileNotFoundErrorfromsend_templated_emailre-raised as 422 (not 500)send_templated_emailreturnsNoneon gmail failure (does not raise), correctly captured in errors listEmailTypevalidated before use -- mirrorsCoachRolevalidation pattern at line 133strvalues (skips intplayer_count)to,parent_id,players) stripped from template data before sendNo issues found.
VERDICT: APPROVED
PR #299 Review
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Pydantic
Architecture: The query registry pattern (
QUERY_REGISTRYdict mapping names to functions) is a clean extension point. New campaigns only need a query function and a dict entry -- no new endpoint code. This is the right abstraction after the project accumulated three bespoke blast endpoints (profile-reminder,jersey-reminder,contract-reminder).Query correctness (
query_unsigned_contracts):contract_status == ContractStatus.offered-- correct enum usage, matches the model atmodels.py:226.contract_signed_at.is_(None)-- correct SQLAlchemy NULL check.contract_token.isnot(None)-- ensures only players with active offer tokens are included.player_teamsto ensure players are on a roster -- correct, since the query needs team name for template data.joinedload(Player.parent)andjoinedload(Player.teams)-- proper eager loading to avoid N+1 queries.parent_mapdict -- correct, each parent gets one email listing all unsigned players.test_emailfiltering happens post-query onparent.email-- correct and safe..distinct()on the query prevents duplicate rows from the join -- good.Endpoint correctness (
admin_email_blast):Depends(require_admin)-- confirmedrequire_admin = require_role("admin")inauth.py:166. Consistent with all other admin email endpoints.DEFAULT_TENANT_SLUGpattern identical to existing endpoints at lines 449, 493, 569, 859 ofadmin.py.QUERY_REGISTRY.get(body.query)and returns 422 with available query names on miss.EmailType(body.email_type)with try/except ValueError returning 422. Depends on PR #298 addingcontract_offer/contract_remindervalues to the enum (currentlymodels.py:63-71has 7 values, no contract-specific ones).{**recipient, **body.data}-- request-level data overrides per-recipient data. Correct precedence.to,parent_id,playersfrom template data. Correct -- these are routing metadata, not template placeholders.template_dataitems, replaces{{key}}with string values. Only replaces string-typed values, skipping ints/lists safely.FileNotFoundErrorre-raised as 422 (template missing). General exceptions caught, logged, appended to errors list.send_templated_emailreturningNonerecorded as error.BlastResponsewithsent_countanderrorslist -- clean.Pydantic model:
BlastRequest.data: dict = {}-- the mutable default is safe in Pydantic (Pydantic creates a new dict per instance via its__init__). This would be a bug in a regular Python class but is fine here.BLOCKERS
None.
require_admindependency present. Not duplicated.body.queryfield is used as a dict lookup key againstQUERY_REGISTRY, not interpolated into SQL._CONTRACT_BASE_URLis a public-facing URL, not a secret.queryagainst registry,email_typeagainst enum,layoutagainst filesystem (viaload_email_template).NITS
Hardcoded
_CONTRACT_BASE_URL(email_queries.py:28):"https://westside-contracts.tail5b443a.ts.net/contract"-- this follows the existing pattern in the codebase (seeconfig.py:15,config.py:29,auth.py:134) where Tailscale URLs are hardcoded. Consistent with current convention, but worth noting as a future DRY candidate when the codebase extracts these into a central config. Not blocking since the whole codebase does this.Bare
Callabletype (email_queries.py:119):QUERY_REGISTRY: dict[str, Callable]-- could beCallable[[Session, Tenant], list[dict]]or aProtocolfor better IDE support and self-documentation. Minor -- no existing code in the repo uses typed Callables either.FileNotFoundErrorshort-circuits the loop (admin.py, blast endpoint): If the first recipient triggersFileNotFoundError, the endpoint raises 422 immediately, skipping remaining recipients. This is correct behavior (a missing template is a configuration error, not a per-recipient issue), but the re-raise inside the loop is slightly misleading. Consider moving template existence validation before the loop. Non-blocking since the behavior is correct.No
player_idinsend_templated_emailcall: TheEmailLogmodel has aplayer_idfield (nullable), but the blast endpoint only passesparent_id. For grouped-by-parent emails this is reasonable (one email per parent, multiple players), but means email logs won't link to specific players. Matches the "per-parent" design intent.SOP COMPLIANCE
295-admin-blast-endpointfollows{issue-number}-{kebab-case-purpose}PROCESS OBSERVATIONS
VERDICT: APPROVED