Add POST /admin/contract/offer endpoint (mints contract offers) #425
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#425
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Type
Feature
Lineage
Standalone — discovered during 2026-04-10 Westside Ops session. Marcus asked for 6 new contract offers (Alice, Brian, Vince, Kevin, Marie, Jacelyn). The existing
/admin/email/blastendpoint usesquery_unsigned_contractswhich only finds players already incontract_status='offered'— it does not create the offered state. There is NO committed code path that mints contract offers. The existing 20 offered players got there via un-committed means (ad-hoc SQL or scripts not in the repo). This endpoint closes that gap permanently.Revision history: review-931-2026-04-10 returned NEEDS_REFINEMENT with 5 precision fixes: (1) canonical field names, (2) contract_token in audit snapshot, (3) force param location, (4) dependency semantics, (5) secondary story label. All applied in this revision.
Repo
forgejo_admin/basketball-apiUser Story
As Ava (or any admin agent) executing a batch of contract offers from Coach Marcus
I want a single endpoint that mints a contract offer for a player given a fee and optional team move
So that tonight's Marcus batch and every future batch runs through a committed, auditable, reusable code path instead of ad-hoc SQL
Context
What the code already has (verified on basketball-api main, commit
9598c4d):POST /admin/email/blast— generic blast endpointemail_queries.py::query_unsigned_contracts(line 31, registered at line 157) — finds players withcontract_status='offered' AND contract_token IS NOT NULL AND contract_signed_at IS NULL, returns per-recipient data with a contract URL built fromwestside-contracts.tail5b443a.ts.net/contract/{token}/home/ldraney/westside-email-templates/compiled/(hostPath, live-editable)~/westside-email-agent/CLAUDE.mdroutes/admin.py:94usingsecrets.token_urlsafe(32)in/admin/generate-tokensWhat's missing:
contract_status='none' → 'offered'contract_token(secure random URL-safe string)monthly_feealongside the status transitionThe endpoint this ticket creates solves the common case (
none → offered) AND the special case (signed → offeredtier change), with explicit behavior for each.Why now: tonight's Marcus batch (basketball-api #424) is blocked on this. Without it, we'd have to do the work via ad-hoc SQL, which violates
feedback_never_alter_prod_directlyand leaves no audit trail. An endpoint is the right enterprise answer.File Targets
Files to modify:
src/basketball_api/routes/admin.py— addPOST /admin/contract/offerhandler + Pydantic request/response modelssrc/basketball_api/services/contract_offers.py— NEW file, holds the business logic (token mint, state transition, team move, audit log)tests/test_contract_offer.py— NEW file, tests for all state transitions and edge casesalembic/versions/022_add_contract_audit_log.py— NEW migration (022 is the next slot; 021 is the latest on main), createscontract_audit_logtable to track offer state changes (especially the signed → re-offer case) — simpler predecessor to the fullops_audit_logfrom ticket pal-e-deployments#104Files NOT to touch:
src/basketball_api/services/email.py— unchanged; the existing blast flow picks up newly-offered players viaquery_unsigned_contractsautomaticallysrc/basketball_api/services/email_queries.py— unchanged;query_unsigned_contractsalready returns exactly what we needsrc/basketball_api/models.py— no schema changes on theplayerstable; only the newcontract_audit_logtable is added via migrationAcceptance Criteria
Canonical player field names (verified against
models.py):contract_status(enum: none/offered/signed/declined)contract_token(varchar)contract_signed_at(timestamp)contract_signed_by(varchar)contract_signed_ip(varchar)contract_signature_url(varchar)contract_version(varchar)contract_overrides(jsonb)monthly_fee(integer)All references below use these exact names.
Request shape:
forceis a query parameter (?force=true), not a body field. This matches FastAPI convention for "modifier" flags that change endpoint behavior without being part of the resource payload.Response shape:
previous_signed_state_archivedis present on ALL responses:falsefor none→offered, declined→offered, offered→offered (force) — no archive happenedtrueONLY for signed→offered — confirms the archive row was written tocontract_audit_logState transitions the endpoint must handle:
none → offered: mintcontract_tokenviasecrets.token_urlsafe(32), setcontract_status='offered', setmonthly_fee. Response:previous_signed_state_archived=false,moved_teams=false. (Alice, Brian, Vince, Kevin, Marie cases.)declined → offered: same asnone → offered. Declined is not a terminal state. Response:previous_signed_state_archived=false.offered → offered(re-offer): refuse with HTTP 409 unless?force=truequery param is passed. With force, mints a NEW token (old token is replaced), updates fee, logs event. Response:previous_signed_state_archived=false.signed → offered(tier change, Jacelyn case): REQUIREStarget_team_idto be different from the player's current team (reject with 422 otherwise). Archives the following fields tocontract_audit_log(JSONB snapshot):contract_signed_at,contract_signed_by,contract_signed_ip,contract_signature_url,contract_version,contract_token, oldteam_id(s), oldmonthly_fee. THEN clearscontract_signed_at,contract_signed_by,contract_signed_ip,contract_signature_urlon the player row, mints newcontract_token, flipscontract_status='offered', updatesplayer_teams(remove old team, add new team), sets newmonthly_fee. Response:previous_signed_state_archived=true,moved_teams=true.contract_audit_logtable with columns:id SERIAL PK,ts TIMESTAMPTZ DEFAULT now(),player_id INT NOT NULL FK players(id),event_type TEXT NOT NULL(values:'re_offer','tier_change'),old_state JSONB NOT NULL(snapshot of archived fields),new_state JSONB NOT NULL(snapshot of incoming offer data),actor TEXT NOT NULL DEFAULT 'admin',source TEXT NOT NULL DEFAULT 'api'The archived JSONB snapshot in
old_stateMUST include:contract_token(critical for tracing disputed signed URLs later)contract_signed_atcontract_signed_bycontract_signed_ipcontract_signature_urlcontract_versionteam_ids(list of int, fromplayer_teams)monthly_feeSecurity / auth:
/admin/*endpoints —require_admindep)target_team_id, (b) signed with sametarget_team_idas current, (c) offered withoutforce=trueDatabase integrity:
secrets.token_urlsafe(32)— same pattern asroutes/admin.py:94begin_nested()(which is a SAVEPOINT, not a top-level transaction)Behavior the endpoint explicitly DOES NOT do:
/admin/email/blastwithquery=unsigned_contractsto send the actual email.jersey_option,jersey_order_status, or any jersey field.monthly_feeagainst the team'scontract_config.monthly_fee_default— custom fees are intentional (Marcus sets per-player).Test Expectations
test_offer_from_none— happy path,none → offered, verifies token format, status, fee, no audit row,previous_signed_state_archived=falsetest_offer_from_declined—declined → offeredworkstest_offer_from_offered_rejects— HTTP 409 withoutforce=truetest_offer_from_offered_with_force— allowed with?force=true, mints NEW token (old one is replaced)test_signed_to_offered_requires_team_change— HTTP 422 whentarget_team_idmissing or same as currenttest_signed_to_offered_archives_state— Jacelyn case: old signed fields archived tocontract_audit_log.old_stateJSONB (includingcontract_token), player row'scontract_signed_at/contract_signed_by/contract_signed_ip/contract_signature_urlcleared, team moved, new token, new feetest_transaction_rollback_on_failure— simulate FK violation onplayer_teams, verify player row is unchanged AND no audit row was writtentest_cross_tenant_rejected— HTTP 404 for player in different tenanttest_offer_then_blast_flow— mint via new endpoint, then call/admin/email/blastwithquery=unsigned_contracts, verify the player shows up in the recipient listcd ~/basketball-api && pytest tests/test_contract_offer.py -vConstraints
routes/admin.py— Pydantic models,require_admindep,db: Session = Depends(get_db), logger usagesecrets.token_urlsafe(32)— no other random sourcecontract_audit_logtable is a simpler precursor to theops_audit_logfrom ticket pal-e-deployments#104 — keep the schema narrow, it's not trying to be a general ops audit systemunsigned_contractsquery — it already returns exactly the right data/admin/email/blastdb.begin()/withblock, NOTbegin_nested()which is a SAVEPOINT)player_id,previous_status,new_status,actorfor every callcontract_urlfor operator convenience (so the caller can share it directly if email fails)models.pythroughout — no shorthandChecklist
email.py,email_queries.py, ormodels.py(player table)Dependency Semantics (revised)
PR-merge blockers (none):
Prod-smoke-test blockers (two, but only for specific batch runs):
target_team_idresolvesNeither blocks this endpoint's PR itself. They block specific invocations of the endpoint during Marcus's batch (#424).
Related
westside-basketball— project this affectssop-email-sendin pal-e-docs — documents the blast endpoint usage pattern~/westside-email-agent/CLAUDE.md— documents the Keycloak JWT auth flowstory:WS-S23(primary — custom contract terms per player),story:WS-S7(secondary — enables branded email blasts to new contracts)Scope Review: NEEDS_REFINEMENT (minor)
Review note:
review-931-2026-04-10All 11 template sections present. All file targets verified against main @
9598c4d(routes/admin.py, services/email.py, services/email_queries.py, models.py all exist; model fields confirmed on lines 254-267). Dependencies #420/#422/#424 all open. No decomposition needed — the signed->re-offer atomicity justifies keeping the ticket cohesive despite being larger than typical.Five small refinements before promoting backlog -> todo:
contract_signature_url(notsignature_url),contract_signed_ip(notsigned_ip),contract_signed_by(notsigned_by).contract_tokento the archived JSONB snapshot in the signed->re-offer case — important for tracing back which signed URL was used if a parent disputes the tier change.forcelocation: the request shape JSON doesn't showforce, but the offered->offered AC calls it a query param. State once (query-string is fine) and drop into the example. Also clarifyprevious_signed_state_archivedresponse value across all four transitions (true only for signed->offered; false otherwise).story:WS-S23(custom contract terms per player) alongsidestory:WS-S7. WS-S7 is the branded-email umbrella and defensible, but WS-S23 is the more literal fit for a custom-fee contract-offer flow.Deferred (not blocking this ticket)
arch-basketball-apibacking note exists in pal-e-docs. Pre-existing gap across many basketball-api tickets — file as a separate backlog item to clean up traceability triangles in one pass.Implementation nit (for the dev agent, not a refinement)
with db.begin_nested()creates a SAVEPOINT, not a top-level transaction. The outer FastAPIget_dbsession still owns commit/rollback. Verify the pattern matches how/admin/generate-tokenshandles commit in the existing admin.py.After the 5 refinements land in the issue body, this ticket is ready to move backlog -> todo.
Scope Review: APPROVED (second pass)
Review note:
review-931-2026-04-10-v2All 5 refinements from
review-931-2026-04-10verified applied:contract_tokenpresent in archived JSONBold_statesnapshotforceexplicit as query parameter (?force=true)story:WS-S23added as primary story label;story:WS-S7retained as secondaryBonus: transaction boundary nit addressed — explicit
db.begin()with note thatbegin_nested()is a SAVEPOINT, not a top-level transaction.Ticket is ready to move backlog → todo.