feat(webapp): campaign ownership — owner may be a player, owner-set GMs, transfer with acceptance (#644) #648
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/644-campaign-ownership"
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?
Implements #644. A campaign gets an owner (
campaigns.owner_id), separate from its GMs: the one account accountable for the campaign, and the anchor for hosted billing (#642). The owner is always a member, as a player or a GM, and owning grants no content access. An owner who is a player still gets 403 on beat notes, GM notes and GM transcript views.⚠️ Before deploying to prod: run this read-only check
The migration fails loudly (it raises and makes no change) if any campaign has no GM to become its owner. Run this against prod first:
If it's non-zero, add a GM to each of those campaigns first. Dev: 3 campaigns, 0 without a GM (checked 2026-09-14).
Behaviour changes for self-hosters
/trash/mine) shows campaigns you own, since only the owner can restore them.Design
ownership_service, which never commits; each route commits once, after its audit entry.campaign_ownership_offers, with a partial unique index allowing one open offer per campaign.expiredthe next time it's read. There's no Celery job.FOR UPDATEwithpopulate_existing), so two accepts can't both succeed. It also re-checks that the recipient is still a member, and that the offer came from the current owner (an admin may have reassigned ownership meanwhile). Either failure cancels the offer with a 409.require_ownerandrequire_owner_or_admindependencies.invite_service.can_manage_membershipis filled in, so a player who owns the campaign can manage invites and join requests.POST /members,/members/search) is also open to the owner, since it's the same decision as approving a join request.PUT /api/admin/campaigns/{id}/owner(the target must be a member; any open offer is cancelled). API only, because there's no admin campaign page to put a control on.GET …/ownership/offer, which returns the offer to the owner and the recipient andnullto everyone else.Migration
e4f5a6b7c8da(ond3e4f5a6b7c8)e4f5a6b7c8d9, which already exists (chain #21, lore proposal targets), so the id's last character was bumped, following the precedentf4a5b6c7d8easet for #405.user_idso reruns are deterministic.DO $$ … RAISE EXCEPTIONguard names every campaign that couldn't get an owner.d3e4f5a6b7c8.upgrade()is also exercised bytest_campaign_ownership.py.webapp/CLAUDE.md's migration chain lists the new revision.Also fixed (found in passing)
to_campaign_responsenever copiedtranscription_language(#419). The edit form always showed "detect", and saving the form wiped the setting. There's a CHANGELOG "Fixed" entry.Bot
The only role-dependent bot call is
/recordauthorisation, viaGET /api/bot/guilds/{id}/gms. With no GM that returns an empty list, so the bot refuses unless the user has Manage Server, which is the intended no-GM behaviour. Nothing else depends on GM roles, so no contract change and noBOT_CONTRACT_VERSIONbump.Verification
can_manage_membership, the offer lock (a real two-connection race test), lazy expiry, the stale-offer re-checks, the RESTRICT backstop on account deletion, and the migration's RAISE and tiebreak.Not fixed here
docs/API.mdstill describesPATCH /members/{user_id}ascharacter_nameonly, and the members response fields are out of date._reject_if_trasheddocstring still says "the four dependencies".Closes #644
🤖 Generated with Claude Code