feat: GroupMe data model, outbox pattern, contract welcome email #158
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!158
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "156-groupme-data-model-outbox"
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: Addgroupme_group_idandgroupme_share_urlcolumns to Team model, addcontract_signedto EmailType enum, add newOutboxmodelalembic/versions/018_add_groupme_and_outbox.py: Migration for Team columns, emailtype enum value, and outbox tablealembic/env.py: Import Outbox model for autogenerate awarenesssrc/basketball_api/routes/players.py: Contract signing endpoint writes outbox event in same transaction as contract status updatesrc/basketball_api/services/email.py: Addsend_contract_signed_email()with GroupMe share link button (Westside branded HTML)src/basketball_api/services/outbox.py: Outbox worker -- polls pending events, resolves player/parent/team, calls email function, marks processed/failedscripts/create_groupme_groups.py: Reconciliation script -- queries GroupMe API, matches to teams by normalized name, creates missing groups, adds Lucas + Marcuspyproject.toml: Add groupme-sdk dependencytests/test_groupme.py: 14 tests covering migration, outbox event creation, worker processing, edge casesTest Plan
Review Checklist
Related
plan-wkqReview: PR #158
Files Reviewed (9 files, +990/-1)
alembic/env.pyalembic/versions/018_add_groupme_and_outbox.pyIF NOT EXISTSfor enum, downgrade notepyproject.tomlscripts/create_groupme_groups.pysrc/basketball_api/models.pysrc/basketball_api/routes/players.pysrc/basketball_api/services/email.pysend_contract_signed_email()follows existing patternsrc/basketball_api/services/outbox.pytests/test_groupme.pyFindings
Fixed: Docstring inaccuracy in outbox.py -- Module docstring said "Failed sends stay as 'pending' and are retried on the next cycle" but the code marks failed events as
"failed". Fixed in commit327f00bto say "Failed sends are marked as 'failed' to prevent infinite retry loops."Observation (not blocking): No worker entrypoint -- The outbox worker function
process_pending_events()exists but there is no cron/scheduler/CLI entrypoint to call it. This is expected per the issue scope -- the worker invocation mechanism (cron job, FastAPI background task, or CLI command) is a follow-up concern.Tests
pytest tests/ -k groupme -v)VERDICT: APPROVE
PR #158 Review
DOMAIN REVIEW
Stack: Python 3.12 / FastAPI / SQLAlchemy 2.0 / Alembic / PostgreSQL
This PR adds GroupMe integration to basketball-api using a well-implemented transactional outbox pattern. The core design is sound: contract signing writes an outbox event in the same DB transaction as the contract status update, and a separate async worker processes events later. This means the contract signing endpoint is resilient to GroupMe/email service failures.
Critical design verification (per review brief):
Outbox event in SAME transaction as contract signing -- CONFIRMED. In
/src/basketball_api/routes/players.py, theOutboxrow is added viadb.add(outbox_event)before the singledb.commit()at line 318. No separate commit. The testtest_outbox_event_in_same_transactionexplicitly verifies this atomicity.Email function follows existing pattern -- CONFIRMED.
send_contract_signed_email()follows the same structure assend_password_reset_email()and other email functions: usesget_gmail_client(tenant), builds branded HTML with_brand_wrapper(), includes plain-text fallback, logs toEmailLog, catches exceptions and returnsNoneon failure. All user-supplied values are escaped viahtml.escape().Migration is reversible -- PARTIALLY. The
downgrade()drops the outbox table and removes the GroupMe columns, which is correct. The comment correctly notes that PostgreSQL cannot remove enum values, socontract_signedpersists after downgrade. This is a known PostgreSQL limitation and the comment documents it properly.Contract signing works if GroupMe/email is down -- CONFIRMED. The outbox pattern decouples signing from notification. The endpoint only writes a DB row; the worker (invoked separately) handles email delivery. If email fails, the event is marked
failedand the contract remains signed.Reconciliation script idempotent -- CONFIRMED. The script skips teams that already have
groupme_group_idset (line 105:if team.groupme_group_id: ... skipped += 1; continue). Re-running won't duplicate groups.No secrets committed -- CONFIRMED. GroupMe user IDs (public identifiers) are hardcoded in the reconciliation script, which is acceptable. The
GROUPME_ACCESS_TOKENis read from env vars. No API keys, passwords, or tokens in code.groupme-sdk dependency -- CONFIRMED. Added to
pyproject.tomlasgroupme-sdk>=0.1.0. The reconciliation script does a gracefulImportErrorcheck with installation instructions pointing to the Forgejo PyPI registry.PEP compliance: Type hints present on all function signatures. Docstrings on all public functions. Code follows the existing project patterns.
SQLAlchemy patterns: The
Outboxmodel uses properMapped[]annotations consistent with the rest of the codebase. Thetenantrelationship is defined. Session management follows existing patterns (single commit per request).OWASP/Input validation: All HTML template interpolation uses
html.escape()for user-controlled values (parent_name,player_name,team_name,groupme_share_url). The outbox payload usesjson.dumps()/json.loads()for safe serialization. The contract signing endpoint already validates auth and input via PydanticContractSignRequest.BLOCKERS
None.
NITS
Outbox worker invocation not shown: The
process_pending_events()function exists but there is no CLI entry point, cron job registration, or route that calls it. The PR description says "async worker" but the mechanism to trigger it is not in this diff. This is likely intentional scope deferral, but worth confirming the invocation mechanism is tracked.Outbox status as string, not enum: The
statusfield usesString(20)with literal string comparisons ("pending","processed","failed"). The rest of the codebase uses Python enums for status fields (e.g.,ContractStatus,PaymentStatus,OrderStatus). Consider anOutboxStatusenum for type safety and consistency. Non-blocking since the values are well-defined and consistently used.No retry mechanism for failed events: Events that fail are marked
"failed"permanently. There is no retry logic, dead-letter queue, or mechanism to re-process failed events. For an initial implementation this is fine (manual DB update can re-set to"pending"), but worth noting for future iteration._mock_admin_userdefined but unused in tests: The_mock_admin_user()factory intest_groupme.py(line 33) is never called in any test. Minor dead code.Reconciliation script
sys.path.insert: The script usessys.path.insert(0, ...)to find thebasketball_apipackage. This works but is fragile. Consider documenting that the script should be run from the project root with the package installed (pip install -e .).SOP COMPLIANCE
156-groupme-data-model-outboxmatches issue #156plan-wkqreferencedCloses #156PROCESS OBSERVATIONS
VERDICT: APPROVED