[Backend+Frontend] Campaign ownership: an owner who may be a player, owner-set GMs, transfer with acceptance #644

Closed
opened 2026-09-14 03:37:23 +00:00 by claude-bot · 2 comments
Collaborator

Part of #642 (hosted billing, phase 1 prerequisite), and useful on self-hosted instances now. Model agreed with the owner on 2026-09-13.

Why

campaign_members.role is only gm | player, and "GM" currently means two things: runs the game and is responsible for the campaign. That doesn't hold up:

  • Anyone who is a GM can trash or archive the campaign. delete_campaign and archive_campaign both depend only on require_gm (routers/campaigns.py:1146, :1283).
  • There is no way to make someone a GM. Only campaign creation (campaign_service.py:113) and import (import_service.py:206) set role=gm. AddMemberRequest carries only user_id; MemberUpdate has no role field.
  • Account deletion has a dead end. A sole GM is told "Transfer GM to another member or delete the campaign first" (routers/users.py:229), and no transfer exists.
  • A GM can't step down. "The last GM cannot leave or be removed" (campaign_service.py:738-796), so a GM who quits the group is stuck in it.
  • Hosted billing needs one accountable account per campaign (#642), and it must not be tied to who runs the game. A player may own and pay for their group's campaign.

The model

Owner is a property of the campaign (campaigns.owner_id), not a role.

  • The owner is always a member, as either a player or a gm.
  • Owning grants no content access. An owner who is a player sees exactly what players see: no beat notes, no :::gm blocks, no GM-only transcript views. Paying for the group must not expose the GM's prep.

Owner-only actions:

  • Make a member a GM, or remove the GM role.
  • Archive and un-archive. Un-archive is admin-only today (POST /{id}/restore → require_admin); the owner gains it and admins keep it for recovery.
  • Trash the campaign.
  • Transfer ownership.
  • Billing, once hosted (#642).

GM actions (unchanged): sessions, recording, the wiki, invites (#643), characters, and removing players. A GM cannot remove the owner or change anyone's role.

Invariants:

  • owner_id is NOT NULL, with an FK to users ON DELETE RESTRICT.
  • The owner cannot leave or be removed while they own the campaign. They transfer ownership first.
  • A campaign may have no GM. The last GM can leave. The owner then sees "This campaign has no GM. Assign one", and GM-only actions stay unavailable until they do. This replaces both last-GM counts in campaign_service.py.
  • The owner can make themselves GM. Like any role change, it's recorded in the audit log and shown in the member list, not done silently.

Transfer requires acceptance.

  1. The owner offers ownership to another member.
  2. The recipient accepts or declines; the offer expires if left.
  3. Until they accept, nothing changes.
  4. Hosted: accepting needs a free slot on the recipient's plan (#642). Self-hosted uses the same flow, so there is one code path.

Account deletion blocks on campaigns the user owns ("transfer ownership first"). It replaces get_sole_gm_campaigns (account_deletion_service.py:71), which counts GMs; a sole GM who isn't the owner can now delete their account.

Recovery: an instance admin can reassign ownership to any member, for an owner who has disappeared.

Data

  • Migration (raw SQL):
    1. Add campaigns.owner_id.
    2. Backfill each campaign to its earliest-joined GM (campaign_members.joined_at, then user_id as a deterministic tiebreak).
    3. Fail loudly if any campaign has no GM, rather than leaving a NULL.
    4. Set NOT NULL.
    • The earliest-GM rule matters only for the co-GM campaigns that imports produce (the #411 shape); nearly every campaign has exactly one GM.
  • campaign_ownership_offers: campaign_id, from_user_id, to_user_id, created_at, expires_at, resolved_at, outcome (accepted | declined | cancelled | expired). At most one open offer per campaign.
  • Creation and import: the creating or importing user becomes owner and GM, as they become GM today.

API (additive; the bot is untouched, so no contract bump)

  • Role: PUT /api/campaigns/{id}/members/{user_id}/role → {role: gm|player}. Owner only.
  • Transfer:
    • POST /api/campaigns/{id}/ownership/offer → {user_id}. Owner only.
    • POST /api/campaigns/{id}/ownership/accept and /decline: the recipient.
    • DELETE /api/campaigns/{id}/ownership/offer: the owner cancels.
  • Admin recovery: PUT /api/admin/campaigns/{id}/owner → {user_id}, which must be a member.
  • archive, restore and DELETE /{id} move to a new require_owner dependency; restore also accepts admins.
  • CampaignResponse and the admin campaign inventory (routers/admin.py:845) gain owner / is_owner.

UI

  • Member list:
    • an "Owner" badge
    • owner-only controls: Make GM / Remove GM, "Transfer ownership…"
    • the no-GM banner, shown to the owner
  • Pending offer: a notice to the recipient, with Accept / Decline; the owner sees that the offer is pending and can cancel it.
  • Archive / Delete shown only to the owner.
  • Account deletion screen: lists owned campaigns with a transfer link, replacing the sole-GM message.

Tests

  • Backfill: a single-GM campaign becomes owned by its GM; a two-GM campaign by the earlier-joined; a campaign with no GM makes the migration fail. Head pin moved.
  • A GM who isn't the owner gets 403 on archive, un-archive, trash, role change and transfer.
  • A player who is the owner can archive, un-archive, trash, set roles and transfer, and still gets 403 on GM-only content (beat notes, GM notes, the GM transcript view).
  • The last GM can leave; the campaign then reports no GM; GM-only actions are refused; the owner assigns a GM and they work again.
  • The owner can't leave or be removed; a GM can't remove the owner.
  • Transfer:
    • no change until accepted
    • decline, cancel and expiry leave the owner unchanged
    • accept moves ownership
    • a second open offer on the same campaign is refused
    • offering to a non-member is refused
  • Account deletion: blocked for an owner; allowed for a sole GM who isn't the owner.
  • Admin reassignment works and requires the target to be a member.
  • Every role change and transfer writes an audit entry.
  • Every guard mutation-checked (#441).

Out of scope

  • Slots, plans and payment (#642 phases 2–3).
  • Notifying the recipient of an offer through Discord (a follow-up).

Size

Medium: one migration and one new table; the campaigns, users and admin routers; campaign_service and account_deletion_service; CampaignDetail member list; the account deletion screen; docs (GM guide, docs/API.md, CHANGELOG). Independent of #643: either can land first.

Part of #642 (hosted billing, phase 1 prerequisite), and useful on self-hosted instances now. Model agreed with the owner on 2026-09-13. ## Why `campaign_members.role` is only `gm | player`, and "GM" currently means two things: *runs the game* and *is responsible for the campaign*. That doesn't hold up: - **Anyone who is a GM can trash or archive the campaign.** `delete_campaign` and `archive_campaign` both depend only on `require_gm` (`routers/campaigns.py:1146`, `:1283`). - **There is no way to make someone a GM.** Only campaign creation (`campaign_service.py:113`) and import (`import_service.py:206`) set `role=gm`. `AddMemberRequest` carries only `user_id`; `MemberUpdate` has no role field. - **Account deletion has a dead end.** A sole GM is told "Transfer GM to another member or delete the campaign first" (`routers/users.py:229`), and no transfer exists. - **A GM can't step down.** "The last GM cannot leave or be removed" (`campaign_service.py:738-796`), so a GM who quits the group is stuck in it. - **Hosted billing needs one accountable account per campaign** (#642), and it must not be tied to who runs the game. A player may own and pay for their group's campaign. ## The model **Owner is a property of the campaign (`campaigns.owner_id`), not a role.** - The owner is always a **member**, as either a `player` or a `gm`. - **Owning grants no content access.** An owner who is a player sees exactly what players see: no beat notes, no `:::gm` blocks, no GM-only transcript views. Paying for the group must not expose the GM's prep. **Owner-only actions:** - Make a member a GM, or remove the GM role. - **Archive and un-archive.** Un-archive is admin-only today (`POST /{id}/restore` → `require_admin`); the owner gains it and admins keep it for recovery. - Trash the campaign. - Transfer ownership. - Billing, once hosted (#642). **GM actions (unchanged):** sessions, recording, the wiki, invites (#643), characters, and removing players. **A GM cannot remove the owner or change anyone's role.** **Invariants:** - `owner_id` is NOT NULL, with an FK to `users` **ON DELETE RESTRICT**. - The owner cannot leave or be removed while they own the campaign. They transfer ownership first. - **A campaign may have no GM.** The last GM *can* leave. The owner then sees "This campaign has no GM. Assign one", and GM-only actions stay unavailable until they do. This replaces both last-GM counts in `campaign_service.py`. - The owner can make themselves GM. Like any role change, it's recorded in the audit log and shown in the member list, not done silently. **Transfer requires acceptance.** 1. The owner offers ownership to another member. 2. The recipient accepts or declines; the offer expires if left. 3. Until they accept, nothing changes. 4. Hosted: accepting needs a free slot on the recipient's plan (#642). Self-hosted uses the same flow, so there is one code path. **Account deletion** blocks on campaigns the user **owns** ("transfer ownership first"). It replaces `get_sole_gm_campaigns` (`account_deletion_service.py:71`), which counts GMs; a sole GM who isn't the owner can now delete their account. **Recovery:** an instance admin can reassign ownership to any member, for an owner who has disappeared. ## Data - **Migration** (raw SQL): 1. Add `campaigns.owner_id`. 2. Backfill each campaign to its **earliest-joined GM** (`campaign_members.joined_at`, then `user_id` as a deterministic tiebreak). 3. **Fail loudly** if any campaign has no GM, rather than leaving a NULL. 4. Set NOT NULL. - The earliest-GM rule matters only for the co-GM campaigns that imports produce (the #411 shape); nearly every campaign has exactly one GM. - **`campaign_ownership_offers`:** `campaign_id`, `from_user_id`, `to_user_id`, `created_at`, `expires_at`, `resolved_at`, `outcome` (`accepted | declined | cancelled | expired`). At most one open offer per campaign. - **Creation and import:** the creating or importing user becomes owner and GM, as they become GM today. ## API (additive; the bot is untouched, so no contract bump) - **Role:** `PUT /api/campaigns/{id}/members/{user_id}/role` → `{role: gm|player}`. Owner only. - **Transfer:** - `POST /api/campaigns/{id}/ownership/offer` → `{user_id}`. Owner only. - `POST /api/campaigns/{id}/ownership/accept` and `/decline`: the recipient. - `DELETE /api/campaigns/{id}/ownership/offer`: the owner cancels. - **Admin recovery:** `PUT /api/admin/campaigns/{id}/owner` → `{user_id}`, which must be a member. - `archive`, `restore` and `DELETE /{id}` move to a new `require_owner` dependency; restore also accepts admins. - `CampaignResponse` and the admin campaign inventory (`routers/admin.py:845`) gain `owner` / `is_owner`. ## UI - **Member list:** - an "Owner" badge - owner-only controls: Make GM / Remove GM, "Transfer ownership…" - the no-GM banner, shown to the owner - **Pending offer:** a notice to the recipient, with Accept / Decline; the owner sees that the offer is pending and can cancel it. - **Archive / Delete** shown only to the owner. - **Account deletion screen:** lists owned campaigns with a transfer link, replacing the sole-GM message. ## Tests - [ ] **Backfill:** a single-GM campaign becomes owned by its GM; a two-GM campaign by the earlier-joined; a campaign with no GM makes the migration fail. Head pin moved. - [ ] A GM who isn't the owner gets 403 on archive, un-archive, trash, role change and transfer. - [ ] A player who is the owner can archive, un-archive, trash, set roles and transfer, and **still gets 403 on GM-only content** (beat notes, GM notes, the GM transcript view). - [ ] The last GM can leave; the campaign then reports no GM; GM-only actions are refused; the owner assigns a GM and they work again. - [ ] The owner can't leave or be removed; a GM can't remove the owner. - [ ] **Transfer:** - [ ] no change until accepted - [ ] decline, cancel and expiry leave the owner unchanged - [ ] accept moves ownership - [ ] a second open offer on the same campaign is refused - [ ] offering to a non-member is refused - [ ] **Account deletion:** blocked for an owner; allowed for a sole GM who isn't the owner. - [ ] Admin reassignment works and requires the target to be a member. - [ ] Every role change and transfer writes an audit entry. - [ ] Every guard mutation-checked (#441). ## Out of scope - Slots, plans and payment (#642 phases 2–3). - Notifying the recipient of an offer through Discord (a follow-up). ## Size Medium: one migration and one new table; the campaigns, users and admin routers; `campaign_service` and `account_deletion_service`; `CampaignDetail` member list; the account deletion screen; docs (GM guide, `docs/API.md`, CHANGELOG). **Independent of #643:** either can land first.
Author
Collaborator

Queued (owner go-ahead 2026-09-13). Starts once #643 merges, since both change routers/campaigns.py, campaign_service, the CampaignDetail member list and the migration chain. The migration will chain onto #643's d3e4f5a6b7c8.

Queued (owner go-ahead 2026-09-13). Starts once #643 merges, since both change `routers/campaigns.py`, `campaign_service`, the `CampaignDetail` member list and the migration chain. The migration will chain onto #643's `d3e4f5a6b7c8`.
Author
Collaborator

Done in #648. CI run 1101 passed; locally, the full backend suite on the branch rebased onto main passed 3606.

Shipped as specified:

  • The owner may be a player.
  • Owner-only actions: set GMs, archive and un-archive, trash and restore, transfer.
  • A campaign may have no GM, and the owner gets a banner saying so.
  • Transfer needs the recipient to accept: the offer row is locked, expiry is lazy (7 days), and accept re-checks membership and the current owner.
  • Account deletion is blocked on owned campaigns.
  • Admin can reassign ownership through the API.
  • Ownership grants no GM content access.

Deviations (all explained in the PR):

  • The migration id is e4f5a6b7c8da; e4f5a6b7c8d9 was already taken.
  • The owner can also add members directly, and can remove GMs.
  • The trash list is scoped to the owner.
  • The pending offer has its own GET endpoint.
  • Offers cascade on user deletion.
  • Unrelated fix found in passing: transcription_language was missing from to_campaign_response (#419).

Before the release reaches prod: the migration raises if any campaign has no GM. The PR has the read-only query to run first. Dev has 0 such campaigns.

Not fixed: docs/API.md is out of date on PATCH /members and the members response; the _reject_if_trashed docstring's count is stale.

Done in #648. CI run 1101 passed; locally, the full backend suite on the branch rebased onto main passed 3606. **Shipped as specified:** - The owner may be a player. - Owner-only actions: set GMs, archive and un-archive, trash and restore, transfer. - A campaign may have no GM, and the owner gets a banner saying so. - Transfer needs the recipient to accept: the offer row is locked, expiry is lazy (7 days), and accept re-checks membership and the current owner. - Account deletion is blocked on owned campaigns. - Admin can reassign ownership through the API. - Ownership grants no GM content access. **Deviations** (all explained in the PR): - The migration id is `e4f5a6b7c8da`; `e4f5a6b7c8d9` was already taken. - The owner can also add members directly, and can remove GMs. - The trash list is scoped to the owner. - The pending offer has its own GET endpoint. - Offers cascade on user deletion. - Unrelated fix found in passing: `transcription_language` was missing from `to_campaign_response` (#419). **Before the release reaches prod:** the migration raises if any campaign has no GM. The PR has the read-only query to run first. Dev has 0 such campaigns. **Not fixed:** `docs/API.md` is out of date on `PATCH /members` and the members response; the `_reject_if_trashed` docstring's count is stale.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#644
No description provided.