Skip to content

feat: tournament model rebuild — roles, permissions, and visibility #55

Description

@ethnjs

Background

Tournament currently stores two structural concepts as opaque JSON blobs:

  • Tournament.blocks — a list of {number, label, date, start, end} dicts representing tournament-wide time blocks. No UI has ever been built against this — the field has sat unused (blocks: [] hardcoded on every create) since it was added.

  • Tournament.volunteer_schema — a dict containing positions (role definitions with attached permissions) and custom_fields (ad-hoc extra volunteer data fields). TournamentMembership.positions mirrors this as a plain JSON array of role key strings assigned to that member (both field names still say "position" in the current codebase — this issue proposes renaming the concept to "role").
    This was reasonable as a fast first pass, but it's become a real limitation:

  • Not queryable. "Who currently holds role X in this tournament" requires loading every membership row and scanning its JSON array in application code — there's no way to ask the database directly.

  • No relational integrity. Nothing stops a membership from referencing a role key that was renamed or deleted out from under it; nothing enforces that a role key is unique within a tournament beyond hand-written validation.

  • No rank/hierarchy concept. Every role is flat — there's no way to say "this role can manage staff, but not staff at a higher level than itself," which will be needed before staff-management permissions can be safely delegated below the TD.

  • No real ownership concept. "Who created/ultimately controls this tournament" isn't modeled — today it's implicitly whoever happens to hold the most-permissive role, which isn't transferable or protected.

  • Blocks conflate two different concerns. Time-block scheduling was originally modeled at the tournament level, but the actual need is time attached to events (an event runs 8am–3pm, not "the tournament has 8 generic blocks"). No UI was ever built on the old model precisely because this mismatch made it awkward to work with.

  • No tournament visibility/lifecycle model exists at all. There's currently no way to mark a tournament as publicly discoverable, admin-verified, or open for registration — every tournament today is equally "real" with no distinction between a TD's draft and an official, recruiting event.

  • Deadlines can't be fixed columns. TDs will need to define arbitrary custom deadlines/reminders (not just a fixed "interest due / registration due / confirmation due" set) — a fixed-column approach won't scale to that.

  • No audit trail. Once staff can assign/remove roles and manage other staff, there will be no record of who changed what.

Motivation

This is foundational cleanup that several planned features will depend on directly:

  • A staff-management flow will need a real permission (manage_staff), a real place to assign it, and a rank system so staff can't use it to outrank or remove people above them — not another key jammed into the existing JSON blob.
  • A public tournament directory ("Explore" page) will need an is_public/is_verified distinction so unofficial or unreviewed tournaments don't show up as legitimate.
  • Private tournaments will need an invite-link mechanism, which will need its own table.
  • Tournaments will need a protected, transferable ownership concept independent of the role system, so control of a tournament isn't just "whoever has the most-permissive role right now."
  • TDs will need to define their own deadlines/reminders rather than being limited to a fixed set of date fields.
  • The upcoming Forms system will need TournamentMembership to have relationally-sound role/permission data and a real status field to build on, not JSON it has to defensively re-parse.
    Rebuilding this now, before more features get stacked on top of the JSON blob approach, will be cheaper than migrating later once staff management, forms, and a public directory all depend on the old shape.

Proposed Changes

To be removed

Field Reason
Tournament.blocks Unused by any UI; time will be moved to individual events in a separate, not-yet-designed epic
Tournament.volunteer_schema Will be replaced by relational tables below
Tournament.volunteer_schema.custom_fields No direct replacement in this phase — will be redesigned later as part of a separate forms system
TournamentMembership.positions (JSON array) Will be replaced by a MembershipRole junction table

To be added — relational roles, with rank

Table Purpose
TournamentRole One row per role a tournament defines (key, label, permissions[], rank). Will replace volunteer_schema["positions"].
MembershipRole Junction — which roles a given membership holds. Will replace TournamentMembership.positions.

permissions will stay a JSON array within TournamentRole rather than its own junction table — permissions are a small, fixed, code-defined set (ALL_PERMISSIONS in permissions.py); adding one requires a deploy, not a runtime action, so there's no relational benefit to normalizing them further, and doing so would add a join to the permission-check hot path for no query gain.

rank will be new: lower number = higher authority, ties will be allowed and expected (e.g. the four coordinator roles sharing a rank). It will bound what a MANAGE_STAFF holder can do — see below.

Default role template (to be seeded on tournament creation): Tournament Director (rank 1); Volunteer/Test/Materials/Logistics Coordinator (rank 2, tied — collectively "tournament staff" alongside TD); Test Writer, Lead Event Supervisor, Volunteer (rank 3, tied, no permissions attached). TDs will be able to create custom roles at any rank between 1 and their own.

To be added — tournament ownership

Tournament.owner_id — a direct FK to the creating user, not a role and not ranked (it will sit structurally above rank 1). Exactly one owner per tournament at all times. Transferable only by the current owner, via a dedicated endpoint; the outgoing owner will keep whatever TournamentRole assignments they already held (e.g. if they were also TD, they'll remain TD after losing ownership).

To be added — new permission and rank-gated staff management

MANAGE_STAFF — a new entry in ALL_PERMISSIONS, not implied by any other permission except MANAGE_TOURNAMENT. Will let a TD grant "can assign/remove roles" without granting full tournament control.

New assign/remove-role routes will be gated by both the permission and a rank check: a MANAGE_STAFF holder will only be able to assign or remove roles at or below their own rank, never above. Self-modification (including self-demotion) will be allowed at the API level — this is intended as a frontend UX warning ("this is effectively irreversible without another staff member reassigning you"), not a backend restriction. Owner and MANAGE_TOURNAMENT holders will bypass the rank check entirely.

Staff-invite UI (search-by-name/email, invite emails) is explicitly not part of this phase — only the backend assign/remove-role mechanism is in scope.

To be added — tournament visibility & scheduling-window fields

Field Purpose
is_public TD-controlled. Will be shown on the public tournament directory.
is_verified Admin-controlled only, via a dedicated admin route. Will mark a tournament as officially reviewed.
registration_opens_at Tournament can be public before this date — will let people "watch" before signups open. Kept as a fixed column (unlike the deadlines below) because it's load-bearing logic, not just informational.
default_day_start, default_day_end Sane pre-fill bounds for use elsewhere (availability tooling) — not itself a scheduling feature.

To be added — TD-defined deadlines

TournamentDeadline — will replace the earlier idea of fixed interest_due_at / registration_due_at / confirmation_due_at columns. TDs need to be able to define arbitrary custom deadlines and reminders, not a fixed set of three, so this will be a table instead: label, due_at, reminder_offsets (days-before, for future notification scheduling), visibility (public vs internal).

To be added — membership status

TournamentMembership.status: "interested" (default on join) or "confirmed" (to be set automatically when the confirmation form is submitted, once the Forms system exists). Status will not gate role or event assignment — tournaments commonly want to plan ahead and assign roles/events to interested-but-not-yet-confirmed members. No declined/removed states: a member who doesn't confirm will be removed from the tournament by deleting their membership row rather than tracking a terminal status.

To be added — private-tournament invite links

TournamentJoinCode — same shape as the existing ChapterJoinCode pattern (8-char code, optional label/expiry, active flag). Redeeming a code will create a bare TournamentMembership with no roles attached and status="interested" — staff will assign roles afterward, same as a Discord invite link grants base membership only, not roles. Whether a code should auto-deactivate at a registration deadline is deferred, not decided in this phase.

To be added — per-tournament audit log

AuditLogEntry — will record role creation/assignment/removal, deadline changes, verification, join-code creation, and ownership transfers, each tied to tournament_id and actor_id (the Owner will be logged as a normal actor, no special case). Scoped per-tournament only — tournaments don't live inside chapters, so there's no natural cross-tournament scope for this log.

What Will Not Change

  • TournamentEvent and anything event/time related — explicitly out of scope, belongs to a separate future epic.
  • TournamentMembership.assigned_event_id, .schedule, .role_preference, .event_preference, .availability, .lunch_order, .extra_data — will stay untouched, each has its own later redesign planned (structural availability/lunch tables, Forms system).
  • Self-serve "leave tournament" for members — deferred to the later membership/volunteer-management refactor.
  • Roster/registration for competing schools, scoring/results, public results pages — out of scope for the project entirely, not just this phase.

Completion Criteria

  1. Creating a tournament will set owner_id to the creator and auto-populate TournamentRole rows (with rank) from the default template, or TD-supplied roles.
  2. Assigning a role to a membership will create a MembershipRole row; permission checks (get_user_permissions()) will reflect it correctly, and Owner status will grant full permissions independent of role assignments.
  3. A MANAGE_STAFF holder will not be able to assign or remove a role ranked above their own; Owner/MANAGE_TOURNAMENT will bypass this restriction.
  4. Tournament.blocks and Tournament.volunteer_schema will no longer exist as columns; interest_due_at/registration_due_at/confirmation_due_at will never exist as fixed columns (superseded by TournamentDeadline).
  5. A TD will be able to create a TournamentJoinCode; redeeming it will create a membership with status="interested" and no roles attached.
  6. is_verified will only be changeable via the admin-only route — a TD PATCHing their own tournament will not be able to set it.
  7. Only the current owner will be able to transfer ownership; the outgoing owner will retain their prior role assignments after transfer.
  8. Role, deadline, verification, join-code, and ownership-transfer actions will each produce a corresponding AuditLogEntry row.
  9. All existing tests will pass against the new shape; new tests will cover role CRUD (including rank bounds), permission resolution, rank-gated staff assignment, ownership transfer, and join-code redemption.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions