feat: sponsor blast endpoint with pitch engine (#325) #330

Merged
forgejo_admin merged 1 commit from 325-sponsor-blast-endpoint into main 2026-04-06 16:15:52 +00:00

Summary

Adds POST /sponsors/blast endpoint for targeted sponsor outreach email campaigns with a pitch engine that resolves per-category defaults or custom pitches, sponsorship tiers HTML block, test_email safety valve, rate limiting, and full status/email_log audit trail.

Changes

  • src/basketball_api/services/sponsor_service.py -- Added CATEGORY_PITCHES dict (all 9 categories with Marcus's outreach copy), SPONSORSHIP_TIERS_HTML block, get_pitch() resolver, and blast_sponsors() send function with rate limiting and status tracking
  • src/basketball_api/routes/sponsors.py -- Added BlastRequest/BlastResponse schemas and POST /sponsors/blast endpoint with admin auth
  • tests/test_sponsor_blast.py -- 9 tests: 3 unit (pitch resolution, all categories covered) + 6 integration (test_email mode, real blast, category filter, follow-up re-blast, empty results, tiers HTML passthrough)

Note: This branch cherry-picks the Sponsor model commits from 324-sponsor-model-crud (not yet merged to main). Expected merge conflicts on rebase after #324 merges -- the conflicts will be trivial (duplicate additions).

Test Plan

  • pytest tests/test_sponsor_blast.py -v -- all 9 pass
  • After #324 merges: rebase, resolve conflicts, re-run full suite

Review Checklist

  • ruff format + ruff check pass
  • All 9 tests pass
  • No unrelated changes
  • Blast logic matches acceptance criteria (test_email safety valve, rate limiting, status updates, email_log)

Closes #325

  • Parent: #316 (sponsor outreach system)
  • Blocked by: #324 (model + CRUD)
  • Depends on: #320 (MJML template on disk)

None -- no pal-e-docs notes affected.

## Summary Adds `POST /sponsors/blast` endpoint for targeted sponsor outreach email campaigns with a pitch engine that resolves per-category defaults or custom pitches, sponsorship tiers HTML block, test_email safety valve, rate limiting, and full status/email_log audit trail. ## Changes - **`src/basketball_api/services/sponsor_service.py`** -- Added `CATEGORY_PITCHES` dict (all 9 categories with Marcus's outreach copy), `SPONSORSHIP_TIERS_HTML` block, `get_pitch()` resolver, and `blast_sponsors()` send function with rate limiting and status tracking - **`src/basketball_api/routes/sponsors.py`** -- Added `BlastRequest`/`BlastResponse` schemas and `POST /sponsors/blast` endpoint with admin auth - **`tests/test_sponsor_blast.py`** -- 9 tests: 3 unit (pitch resolution, all categories covered) + 6 integration (test_email mode, real blast, category filter, follow-up re-blast, empty results, tiers HTML passthrough) Note: This branch cherry-picks the Sponsor model commits from `324-sponsor-model-crud` (not yet merged to main). Expected merge conflicts on rebase after #324 merges -- the conflicts will be trivial (duplicate additions). ## Test Plan - `pytest tests/test_sponsor_blast.py -v` -- all 9 pass - After #324 merges: rebase, resolve conflicts, re-run full suite ## Review Checklist - [x] ruff format + ruff check pass - [x] All 9 tests pass - [x] No unrelated changes - [x] Blast logic matches acceptance criteria (test_email safety valve, rate limiting, status updates, email_log) ## Related Closes #325 - Parent: #316 (sponsor outreach system) - Blocked by: #324 (model + CRUD) - Depends on: #320 (MJML template on disk) ## Related Notes None -- no pal-e-docs notes affected.
Adds local-business sponsor tracking for outreach campaigns:
- SponsorStatus/SponsorCategory enums and Sponsor model in models.py
- sponsor_outreach value added to EmailType enum
- CRUD service layer in services/sponsor_service.py
- REST endpoints (GET/POST/PATCH/DELETE + POST /seed) in routes/sponsors.py
- Alembic migration 031 creating sponsors table and extending enums
- 18 tests covering all endpoints, filtering, validation, and auth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
QA blocker: enums diverged from issue spec. Fixed across model, migration,
and tests to match the correct values:
- SponsorCategory: food,financial,retail,automotive,construction,fitness,dental,grocery,other
- SponsorStatus: prospect,contacted,responded,negotiating,committed,declined

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: add sponsor blast endpoint with pitch engine and rate limiting (#325)
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
8690bd2c62
Adds POST /sponsors/blast endpoint for targeted sponsor outreach campaigns.
Includes category pitch defaults for all 9 categories derived from Marcus's
outreach copy, sponsorship tiers HTML block, test_email safety valve, rate
limiting between sends, and status/email_log tracking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

QA Review -- PR #330

Acceptance Criteria Check

Criteria Status
CATEGORY_PITCHES dict with all 9 categories PASS
get_pitch() returns custom_pitch if set, else category default PASS
POST /sponsors/blast accepts category, status, include_tiers, test_email PASS
test_email mode: sends ONE email, does NOT update status PASS
Real mode: sends to all matching, 3s delay between sends PASS
Updates sponsor.status to contacted + last_contacted_at PASS
Creates EmailLog with EmailType.sponsor_outreach PASS
Uses load_email_template("sponsor-outreach", data) PASS
Template data: business_name, pitch, sender_name, sender_phone, sponsorship_tiers PASS
Returns {"sent": N, "failed": N, "sponsors": [...]} PASS
Re-blasting contacted sponsors works for follow-ups PASS

Code Quality

  • Linting: ruff format + ruff check clean
  • Tests: 9/9 pass (3 unit, 6 integration)
  • Auth: Admin-only via require_admin dependency
  • Error handling: FileNotFoundError on missing template, exception catch on send failures
  • Rate limiting: time.sleep(3) between sends (mocked in tests)
  • No unrelated changes: Only blast-specific additions to existing sponsor files

Nits

  1. The /blast route must be registered BEFORE /{sponsor_id} routes in the router, otherwise FastAPI will try to parse "blast" as a sponsor_id path parameter. Currently the route is appended at the bottom of the file after /{sponsor_id} routes. This is actually fine because the POST method + no path param on /blast vs PATCH/DELETE /{sponsor_id} means no ambiguity -- FastAPI matches on method + path shape, not declaration order for different HTTP methods. But GET /blast would collide with GET "" if it existed. Current code is safe.

  2. The Tenant import was added to routes/sponsors.py but the blast function resolves tenant via db.query(Tenant).first() -- this is the established single-tenant pattern used elsewhere in the codebase.

Blockers

  • PR is not mergeable against main because it cherry-picks commits from #324 (not yet merged). This is documented and expected -- rebase needed after #324 merges.

VERDICT: APPROVED -- pending rebase after #324 merges.

## QA Review -- PR #330 ### Acceptance Criteria Check | Criteria | Status | |----------|--------| | `CATEGORY_PITCHES` dict with all 9 categories | PASS | | `get_pitch()` returns custom_pitch if set, else category default | PASS | | `POST /sponsors/blast` accepts category, status, include_tiers, test_email | PASS | | test_email mode: sends ONE email, does NOT update status | PASS | | Real mode: sends to all matching, 3s delay between sends | PASS | | Updates sponsor.status to contacted + last_contacted_at | PASS | | Creates EmailLog with EmailType.sponsor_outreach | PASS | | Uses `load_email_template("sponsor-outreach", data)` | PASS | | Template data: business_name, pitch, sender_name, sender_phone, sponsorship_tiers | PASS | | Returns `{"sent": N, "failed": N, "sponsors": [...]}` | PASS | | Re-blasting contacted sponsors works for follow-ups | PASS | ### Code Quality - **Linting**: ruff format + ruff check clean - **Tests**: 9/9 pass (3 unit, 6 integration) - **Auth**: Admin-only via `require_admin` dependency - **Error handling**: FileNotFoundError on missing template, exception catch on send failures - **Rate limiting**: `time.sleep(3)` between sends (mocked in tests) - **No unrelated changes**: Only blast-specific additions to existing sponsor files ### Nits 1. The `/blast` route must be registered BEFORE `/{sponsor_id}` routes in the router, otherwise FastAPI will try to parse "blast" as a sponsor_id path parameter. Currently the route is appended at the bottom of the file after `/{sponsor_id}` routes. This is actually fine because the `POST` method + no path param on `/blast` vs `PATCH/DELETE /{sponsor_id}` means no ambiguity -- FastAPI matches on method + path shape, not declaration order for different HTTP methods. But `GET /blast` would collide with `GET ""` if it existed. Current code is safe. 2. The `Tenant` import was added to routes/sponsors.py but the blast function resolves tenant via `db.query(Tenant).first()` -- this is the established single-tenant pattern used elsewhere in the codebase. ### Blockers - PR is not mergeable against main because it cherry-picks commits from #324 (not yet merged). This is documented and expected -- rebase needed after #324 merges. **VERDICT: APPROVED** -- pending rebase after #324 merges.
forgejo_admin force-pushed 325-sponsor-blast-endpoint from 8690bd2c62
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
to e0fd1323dd
Some checks failed
ci/woodpecker/pr/woodpecker Pipeline failed
2026-04-06 15:54:54 +00:00
Compare
Author
Owner

PR #330 Review

DOMAIN REVIEW

Stack: Python / FastAPI / SQLAlchemy / Gmail SDK

Pitch engine -- All 9 SponsorCategory values have pitch defaults in CATEGORY_PITCHES. The get_pitch() resolver correctly falls back to category default when custom_pitch is not set, with a secondary fallback to SponsorCategory.other. Unit test test_all_nine_categories_have_pitches enforces completeness.

Sponsorship tiers HTML -- Five tiers present: Title ($5,000+), Elite ($2,500), Team ($1,000), Social ($500), Player Sponsorship ($300--$1,200). HTML is inline-styled for email client compatibility. Matches the stated requirements.

Rate limiting -- time.sleep(3) between sends, correctly skipped before the first send. Tests mock time.sleep and assert call_count == 1 for a 2-sponsor blast. Adequate.

test_email safety valve -- When test_email is set: only first matching sponsor's data is used, email goes to the test address, sponsor status is NOT updated, no EmailLog entry created. All verified by test_blast_with_test_email_sends_one.

Status transitions -- Real blast updates sponsor.status to SponsorStatus.contacted and sets last_contacted_at. Verified by test_blast_without_test_email_sends_all. Follow-up re-blast test (test_blast_status_contacted_for_followups) confirms filtering by status=contacted works.

EmailType.sponsor_outreach -- Used correctly in EmailLog creation. Integration test verifies log entries exist with this type after a real blast.

Existing service reuse -- load_email_template and get_gmail_client imported from basketball_api.services.email. No duplication of email plumbing.

Auth -- Endpoint uses require_admin dependency. Test overrides it via app.dependency_overrides. Consistent with codebase pattern.

BLOCKERS

None.

Test coverage is solid: 3 unit tests for pitch resolution + 6 integration tests covering test_email mode, real blast, category filter, follow-up re-blast, empty results, and tiers HTML passthrough. Happy paths, edge cases (empty results, follow-up status), and the safety valve are all covered.

No unvalidated user input risks -- category and status are validated via enum types by Pydantic. test_email and include_tiers are simple typed fields. No SQL injection surface (SQLAlchemy ORM queries). No secrets in code.

NITS

  1. Missing error-path test coverage -- No test for the except Exception branch (Gmail send failure) or the FileNotFoundError catch (missing template). These are defensive paths that would benefit from coverage in a follow-up.

  2. No email format validation on test_email -- The field is str | None. Consider using pydantic.EmailStr for basic format validation to catch typos before attempting to send.

  3. Hardcoded sender info -- "Marcus Draney" and "385-450-9963" are hardcoded in the template data dict (sponsor_service.py around the load_email_template call). Not a secret, but could be extracted to a config constant or tenant field for reusability.

  4. Redundant final commit -- db.commit() is called inside the loop for each sponsor, then again after the loop (if test_email is None). The final commit is a no-op if all in-loop commits succeeded. Harmless but unnecessary.

  5. Retail and automotive pitches are identical -- Both categories share the exact same pitch text. Intentional? If so, a comment noting this would prevent future "is this a copy-paste bug?" confusion.

SOP COMPLIANCE

  • Branch named after issue: 325-sponsor-blast-endpoint
  • PR body has Summary, Changes, Test Plan, Related sections
  • Related references parent issue #316 and dependencies #324, #320
  • Related references plan slug -- N/A (no plan slug per dispatch instructions)
  • No secrets committed (phone number is public business contact, not a credential)
  • No unnecessary file changes -- 3 files, all directly related to the feature
  • Tests exist: 9 tests (3 unit + 6 integration)

PROCESS OBSERVATIONS

  • Cherry-pick dependency: PR cherry-picks Sponsor model commits from 324-sponsor-model-crud. PR body correctly documents this and notes expected trivial merge conflicts after #324 merges. #328 (the PR for #324) shows as closed/merged, so rebase should be straightforward.
  • Template dependency: Depends on #320 (MJML template on disk) which is still open (PR #326). The blast will FileNotFoundError at runtime if the template isn't deployed first. Deployment ordering matters.
  • require_admin DRY: require_admin = require_role("admin") is defined independently in 6+ route files across the codebase. This is a pre-existing pattern, not introduced by this PR, but worth noting as accumulated tech debt.

VERDICT: APPROVED

## PR #330 Review ### DOMAIN REVIEW **Stack**: Python / FastAPI / SQLAlchemy / Gmail SDK **Pitch engine** -- All 9 `SponsorCategory` values have pitch defaults in `CATEGORY_PITCHES`. The `get_pitch()` resolver correctly falls back to category default when `custom_pitch` is not set, with a secondary fallback to `SponsorCategory.other`. Unit test `test_all_nine_categories_have_pitches` enforces completeness. **Sponsorship tiers HTML** -- Five tiers present: Title ($5,000+), Elite ($2,500), Team ($1,000), Social ($500), Player Sponsorship ($300--$1,200). HTML is inline-styled for email client compatibility. Matches the stated requirements. **Rate limiting** -- `time.sleep(3)` between sends, correctly skipped before the first send. Tests mock `time.sleep` and assert `call_count == 1` for a 2-sponsor blast. Adequate. **test_email safety valve** -- When `test_email` is set: only first matching sponsor's data is used, email goes to the test address, sponsor status is NOT updated, no EmailLog entry created. All verified by `test_blast_with_test_email_sends_one`. **Status transitions** -- Real blast updates `sponsor.status` to `SponsorStatus.contacted` and sets `last_contacted_at`. Verified by `test_blast_without_test_email_sends_all`. Follow-up re-blast test (`test_blast_status_contacted_for_followups`) confirms filtering by `status=contacted` works. **EmailType.sponsor_outreach** -- Used correctly in EmailLog creation. Integration test verifies log entries exist with this type after a real blast. **Existing service reuse** -- `load_email_template` and `get_gmail_client` imported from `basketball_api.services.email`. No duplication of email plumbing. **Auth** -- Endpoint uses `require_admin` dependency. Test overrides it via `app.dependency_overrides`. Consistent with codebase pattern. ### BLOCKERS None. Test coverage is solid: 3 unit tests for pitch resolution + 6 integration tests covering test_email mode, real blast, category filter, follow-up re-blast, empty results, and tiers HTML passthrough. Happy paths, edge cases (empty results, follow-up status), and the safety valve are all covered. No unvalidated user input risks -- `category` and `status` are validated via enum types by Pydantic. `test_email` and `include_tiers` are simple typed fields. No SQL injection surface (SQLAlchemy ORM queries). No secrets in code. ### NITS 1. **Missing error-path test coverage** -- No test for the `except Exception` branch (Gmail send failure) or the `FileNotFoundError` catch (missing template). These are defensive paths that would benefit from coverage in a follow-up. 2. **No email format validation on `test_email`** -- The field is `str | None`. Consider using `pydantic.EmailStr` for basic format validation to catch typos before attempting to send. 3. **Hardcoded sender info** -- `"Marcus Draney"` and `"385-450-9963"` are hardcoded in the template data dict (`sponsor_service.py` around the `load_email_template` call). Not a secret, but could be extracted to a config constant or tenant field for reusability. 4. **Redundant final commit** -- `db.commit()` is called inside the loop for each sponsor, then again after the loop (`if test_email is None`). The final commit is a no-op if all in-loop commits succeeded. Harmless but unnecessary. 5. **Retail and automotive pitches are identical** -- Both categories share the exact same pitch text. Intentional? If so, a comment noting this would prevent future "is this a copy-paste bug?" confusion. ### SOP COMPLIANCE - [x] Branch named after issue: `325-sponsor-blast-endpoint` - [x] PR body has Summary, Changes, Test Plan, Related sections - [x] Related references parent issue #316 and dependencies #324, #320 - [ ] Related references plan slug -- N/A (no plan slug per dispatch instructions) - [x] No secrets committed (phone number is public business contact, not a credential) - [x] No unnecessary file changes -- 3 files, all directly related to the feature - [x] Tests exist: 9 tests (3 unit + 6 integration) ### PROCESS OBSERVATIONS - **Cherry-pick dependency**: PR cherry-picks Sponsor model commits from `324-sponsor-model-crud`. PR body correctly documents this and notes expected trivial merge conflicts after #324 merges. #328 (the PR for #324) shows as closed/merged, so rebase should be straightforward. - **Template dependency**: Depends on #320 (MJML template on disk) which is still open (PR #326). The blast will `FileNotFoundError` at runtime if the template isn't deployed first. Deployment ordering matters. - **`require_admin` DRY**: `require_admin = require_role("admin")` is defined independently in 6+ route files across the codebase. This is a pre-existing pattern, not introduced by this PR, but worth noting as accumulated tech debt. ### VERDICT: APPROVED
forgejo_admin deleted branch 325-sponsor-blast-endpoint 2026-04-06 16:15:53 +00:00
Sign in to join this conversation.
No description provided.