feat: track team action list completion

This commit is contained in:
Daniel Schädler 2026-04-22 16:51:17 +02:00
parent 164b7df625
commit 7d65e4df82
22 changed files with 343 additions and 3 deletions

View File

@ -65,9 +65,12 @@ class StagesController < ApplicationController
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end
return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, return unless AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
teams: GroupStageService.get_advancing_teams(@stage)).success? teams: GroupStageService.get_advancing_teams(@stage)).success?
TeamActionListService.ensure_group_stage_survivor_playoff_token_list!(@stage.tournament)
return
render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class TeamActionItemsController < ApplicationController
before_action :set_team_action_item, only: %i[update]
before_action :authenticate_user!, only: %i[update]
before_action -> { require_owner! @team_action_item.owner }, only: %i[update]
before_action -> { require_writable_tournament!(@team_action_item.tournament) }, only: %i[update]
# PATCH /team_action_items/1
def update
if @team_action_item.update(team_action_item_params)
render json: @team_action_item
else
render json: @team_action_item.errors, status: :unprocessable_entity
end
end
private
def set_team_action_item
@team_action_item = TeamActionItem.find(params[:id])
end
def team_action_item_params
params.slice(:completed).permit(:completed)
end
end

View File

@ -5,6 +5,7 @@ class Team < ApplicationRecord
has_many :group_scores, dependent: :destroy has_many :group_scores, dependent: :destroy
has_many :match_scores, dependent: :destroy has_many :match_scores, dependent: :destroy
has_many :bets, dependent: :destroy has_many :bets, dependent: :destroy
has_many :team_action_items, dependent: :destroy
validates :name, presence: true validates :name, presence: true

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class TeamActionItem < ApplicationRecord
belongs_to :team_action_list
belongs_to :team
validates :team_id, uniqueness: { scope: :team_action_list_id }
delegate :tournament, :owner, to: :team_action_list
before_validation :normalize_completed_at
private
def normalize_completed_at
self.completed_at = completed? ? (completed_at || Time.current) : nil
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class TeamActionList < ApplicationRecord
belongs_to :tournament
has_many :team_action_items, dependent: :destroy
validates :name, :key, :action_name, :source, presence: true
validates :key, uniqueness: { scope: :tournament_id }
delegate :owner, to: :tournament
def sync_teams!(teams)
desired_team_ids = teams.map(&:id).uniq
transaction do
team_action_items.where.not(team_id: desired_team_ids).destroy_all
desired_team_ids.each do |team_id|
team_action_items.find_or_create_by!(team_id: team_id)
end
end
end
end

View File

@ -7,6 +7,7 @@ class Tournament < ApplicationRecord
has_many :teams, dependent: :destroy has_many :teams, dependent: :destroy
has_many :stages, dependent: :destroy has_many :stages, dependent: :destroy
has_many :beamers, dependent: :destroy has_many :beamers, dependent: :destroy
has_many :team_action_lists, dependent: :destroy
validates :name, presence: true validates :name, presence: true
validates :code, presence: true, uniqueness: true validates :code, presence: true, uniqueness: true

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class TeamActionItemSerializer < ApplicationSerializer
attributes :completed, :completed_at, :team
attribute :completed_at do
object.completed_at&.iso8601
end
attribute :team do
{
id: object.team.id,
name: object.team.name
}
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class TeamActionListSerializer < ApplicationSerializer
attributes :name, :key, :action_name, :source
has_many :team_action_items
end

View File

@ -9,6 +9,7 @@ class TournamentSerializer < SimpleTournamentSerializer
# NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against # NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
has_many :stages has_many :stages
has_many :team_action_lists
attribute :timer_end do attribute :timer_end do
object.timer_end&.iso8601 object.timer_end&.iso8601

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class TeamActionListService
GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY = 'group_stage_survivor_playoff_tokens'
class << self
def ensure_group_stage_survivor_playoff_token_list!(tournament)
group_stage = tournament.group_stage
return nil unless group_stage&.finished?
teams = tournament.teams_advancing_from_group_stage
return nil if teams.empty?
ensure_list!(
tournament: tournament,
key: GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY,
name: 'Playoff token collection',
action_name: 'Collect playoff token',
source: 'group_stage_survivors',
teams: teams
)
end
def ensure_list!(tournament:, key:, name:, action_name:, source:, teams:)
list = tournament.team_action_lists.find_or_initialize_by(key: key)
list.name = name
list.action_name = action_name
list.source = source
list.save!
list.sync_teams!(teams)
list
end
end
end

View File

@ -9,6 +9,8 @@ class TournamentSnapshotBuilder
MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze
MATCH_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(MatchScore).map(&:to_sym).freeze MATCH_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(MatchScore).map(&:to_sym).freeze
GROUP_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(GroupScore).map(&:to_sym).freeze GROUP_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(GroupScore).map(&:to_sym).freeze
TEAM_ACTION_LIST_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(TeamActionList).map(&:to_sym).freeze
TEAM_ACTION_ITEM_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(TeamActionItem).map(&:to_sym).freeze
def self.build(tournament) def self.build(tournament)
new(tournament).build new(tournament).build
@ -29,6 +31,8 @@ class TournamentSnapshotBuilder
payload[:matches] = matches_payload payload[:matches] = matches_payload
payload[:match_scores] = match_scores_payload payload[:match_scores] = match_scores_payload
payload[:group_scores] = group_scores_payload payload[:group_scores] = group_scores_payload
payload[:team_action_lists] = team_action_lists_payload
payload[:team_action_items] = team_action_items_payload
payload payload
end end
@ -78,6 +82,20 @@ class TournamentSnapshotBuilder
end end
end end
def team_action_lists_payload
tournament.team_action_lists.order(:id).map do |team_action_list|
team_action_list.attributes.symbolize_keys.slice(*TEAM_ACTION_LIST_ATTRIBUTES)
end
end
def team_action_items_payload
tournament.team_action_lists.flat_map(&:team_action_items).sort_by(&:id).map do |team_action_item|
attrs = team_action_item.attributes.symbolize_keys.slice(*TEAM_ACTION_ITEM_ATTRIBUTES)
attrs[:completed_at] = team_action_item.completed_at&.iso8601
attrs
end
end
def teams_for_snapshot def teams_for_snapshot
( (
tournament.teams.to_a + tournament.teams.to_a +

View File

@ -32,6 +32,8 @@ class TournamentSnapshotImporter
existing_matches = tournament.matches existing_matches = tournament.matches
existing_match_scores = existing_matches.flat_map(&:match_scores) existing_match_scores = existing_matches.flat_map(&:match_scores)
existing_group_scores = existing_groups.flat_map(&:group_scores) existing_group_scores = existing_groups.flat_map(&:group_scores)
existing_team_action_lists = tournament.team_action_lists.to_a
existing_team_action_items = existing_team_action_lists.flat_map(&:team_action_items)
update_tournament! update_tournament!
teams_by_source_id = sync_teams! teams_by_source_id = sync_teams!
@ -41,7 +43,11 @@ class TournamentSnapshotImporter
matches_by_source_id = sync_matches!(stages_by_source_id, groups_by_source_id) matches_by_source_id = sync_matches!(stages_by_source_id, groups_by_source_id)
sync_match_scores!(matches_by_source_id, teams_by_source_id) sync_match_scores!(matches_by_source_id, teams_by_source_id)
sync_group_scores!(groups_by_source_id, teams_by_source_id) sync_group_scores!(groups_by_source_id, teams_by_source_id)
team_action_lists_by_source_id = sync_team_action_lists!
sync_team_action_items!(team_action_lists_by_source_id, teams_by_source_id)
destroy_stale_records(existing_team_action_items, snapshot.fetch(:team_action_items, []))
destroy_stale_records(existing_team_action_lists, snapshot.fetch(:team_action_lists, []))
destroy_stale_records(existing_beamers, snapshot.fetch(:beamers, [])) destroy_stale_records(existing_beamers, snapshot.fetch(:beamers, []))
destroy_stale_records(existing_group_scores, snapshot.fetch(:group_scores, [])) destroy_stale_records(existing_group_scores, snapshot.fetch(:group_scores, []))
destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, [])) destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, []))
@ -162,6 +168,33 @@ class TournamentSnapshotImporter
end end
end end
def sync_team_action_lists!
sync_records(
model_class: TeamActionList,
existing_records: tournament.team_action_lists.to_a,
payload: snapshot.fetch(:team_action_lists, [])
) do |team_action_list, attrs|
team_action_list.tournament = tournament
team_action_list.name = attrs.fetch(:name)
team_action_list.key = attrs.fetch(:key)
team_action_list.action_name = attrs.fetch(:action_name)
team_action_list.source = attrs.fetch(:source)
end
end
def sync_team_action_items!(team_action_lists_by_source_id, teams_by_source_id)
sync_records(
model_class: TeamActionItem,
existing_records: tournament.team_action_lists.flat_map(&:team_action_items),
payload: snapshot.fetch(:team_action_items, [])
) do |team_action_item, attrs|
team_action_item.team_action_list = team_action_lists_by_source_id.fetch(attrs.fetch(:team_action_list_id))
team_action_item.team = teams_by_source_id.fetch(attrs.fetch(:team_id))
team_action_item.completed = attrs.fetch(:completed)
team_action_item.completed_at = parse_time(attrs[:completed_at])
end
end
def sync_records(model_class:, existing_records:, payload:) def sync_records(model_class:, existing_records:, payload:)
existing_by_source_id = existing_records.index_by(&:sync_source_id) existing_by_source_id = existing_records.index_by(&:sync_source_id)

View File

@ -54,6 +54,14 @@ class TournamentSyncSchema
GroupScore => { GroupScore => {
synced: %w[id group_points scored_points received_points team_id group_id position].freeze, synced: %w[id group_points scored_points received_points team_id group_id position].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze,
TeamActionList => {
synced: %w[id name key action_name source].freeze,
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
}.freeze,
TeamActionItem => {
synced: %w[id team_action_list_id team_id completed completed_at].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze }.freeze
}.freeze }.freeze

View File

@ -17,6 +17,7 @@ Rails.application.routes.draw do
end end
resources :stages, only: %i[show update] resources :stages, only: %i[show update]
resources :teams, only: %i[show update] resources :teams, only: %i[show update]
resources :team_action_items, only: %i[update]
resources :tournaments do resources :tournaments do
resources :statistics, only: %i[index] resources :statistics, only: %i[index]
resources :matches, only: %i[index] resources :matches, only: %i[index]

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class CreateTeamActionLists < ActiveRecord::Migration[7.0]
def change
create_table :team_action_lists do |t|
t.references :tournament, null: false, foreign_key: { on_delete: :cascade }, index: true
t.string :name, null: false
t.string :key, null: false
t.string :action_name, null: false
t.string :source, null: false
t.bigint :sync_source_id, index: true
t.timestamps
end
add_index :team_action_lists, [:tournament_id, :key], unique: true
create_table :team_action_items do |t|
t.references :team_action_list, null: false, foreign_key: { on_delete: :cascade }, index: true
t.references :team, null: false, foreign_key: { on_delete: :cascade }, index: true
t.boolean :completed, null: false, default: false
t.datetime :completed_at
t.bigint :sync_source_id, index: true
t.timestamps
end
add_index :team_action_items, [:team_action_list_id, :team_id], unique: true
end
end

View File

@ -91,6 +91,16 @@ RSpec.describe StagesController, type: :controller do
expect(running_group_stage.tournament.reload.stages.max_by(&:level).teams) expect(running_group_stage.tournament.reload.stages.max_by(&:level).teams)
.to match_array(GroupStageService.get_advancing_teams(running_group_stage)) .to match_array(GroupStageService.get_advancing_teams(running_group_stage))
end end
it 'creates playoff token collection list for advancing teams' do
tournament = running_group_stage.tournament.reload
list = tournament.team_action_lists.find_by!(key: TeamActionListService::GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY)
expect(list.name).to eq('Playoff token collection')
expect(list.team_action_items.includes(:team).map(&:team))
.to match_array(GroupStageService.get_advancing_teams(running_group_stage))
expect(list.team_action_items.pluck(:completed).uniq).to eq([false])
end
end end
context 'as another user' do context 'as another user' do

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TeamActionItemsController, type: :controller do
let(:team_action_item) { create(:team_action_item) }
describe 'PATCH #update' do
context 'as owner' do
before do
apply_authentication_headers_for team_action_item.owner
end
it 'ticks item off' do
patch :update, params: { id: team_action_item.to_param, completed: true }
expect(response).to be_successful
expect(team_action_item.reload.completed).to eq(true)
expect(team_action_item.completed_at).not_to be_nil
end
it 'can untick item' do
team_action_item.update!(completed: true, completed_at: Time.zone.parse('2026-04-22T09:00:00Z'))
patch :update, params: { id: team_action_item.to_param, completed: false }
expect(response).to be_successful
expect(team_action_item.reload.completed).to eq(false)
expect(team_action_item.completed_at).to be_nil
end
end
context 'as another user' do
before do
apply_authentication_headers_for create(:user)
end
it 'returns forbidden' do
patch :update, params: { id: team_action_item.to_param, completed: true }
expect(response).to have_http_status(:forbidden)
end
end
end
end

View File

@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do
expected_keys = %i[ expected_keys = %i[
id name code public description playoff_teams_amount id name code public description playoff_teams_amount
instant_finalists_amount intermediate_round_participants_amount instant_finalists_amount intermediate_round_participants_amount
timer_end owner_username stages teams timer_end owner_username stages teams team_action_lists
] ]
expect(json.keys).to match_array(expected_keys) expect(json.keys).to match_array(expected_keys)
expect(json[:name]).to eq(@tournament.name) expect(json[:name]).to eq(@tournament.name)
@ -204,6 +204,19 @@ RSpec.describe TournamentsController, type: :controller do
advancing_teams = advancing_teams.map { |team| Team.find(team[:id]) } advancing_teams = advancing_teams.map { |team| Team.find(team[:id]) }
expect(advancing_teams).to match_array(expected_advancing_teams) expect(advancing_teams).to match_array(expected_advancing_teams)
end end
it 'includes team action lists and item status for all viewers' do
list = create(:team_action_list, tournament: @group_stage_tournament)
create(:team_action_item, team_action_list: list, team: @group_stage_tournament.teams.first, completed: true)
allow(GroupStageService).to receive(:get_advancing_teams).and_return([])
get :show, params: { id: @group_stage_tournament.to_param }
body = deserialize_response response
expect(body[:team_action_lists].size).to eq(1)
expect(body[:team_action_lists].first[:key]).to eq('group_stage_survivor_playoff_tokens')
expect(body[:team_action_lists].first[:team_action_items].first[:completed]).to eq(true)
end
end end
end end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :team_action_item do
team_action_list
team { create(:team, tournament: team_action_list.tournament) }
completed { false }
completed_at { nil }
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
FactoryBot.define do
factory :team_action_list do
tournament
name { 'Playoff token collection' }
key { 'group_stage_survivor_playoff_tokens' }
action_name { 'Collect playoff token' }
source { 'group_stage_survivors' }
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TeamActionItemsController, type: :routing do
describe 'routing' do
it 'routes to #update via PATCH' do
expect(patch: '/team_action_items/1').to route_to('team_action_items#update', id: '1')
end
end
end

View File

@ -7,6 +7,8 @@ RSpec.describe TournamentSnapshotImporter do
it 'replaces follower tournament graph using source ids as stable mapping keys' do it 'replaces follower tournament graph using source ids as stable mapping keys' do
source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
source.beamers.create!(name: 'stream-a', display_state: 'during_game', qr_code_url: 'https://example.com/qr') source.beamers.create!(name: 'stream-a', display_state: 'during_game', qr_code_url: 'https://example.com/qr')
list = create(:team_action_list, tournament: source)
create(:team_action_item, team_action_list: list, team: source.teams.first, completed: true)
follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret') follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
snapshot = TournamentSnapshotBuilder.build(source) snapshot = TournamentSnapshotBuilder.build(source)
@ -18,6 +20,8 @@ RSpec.describe TournamentSnapshotImporter do
expect(follower.name).to eq(source.name) expect(follower.name).to eq(source.name)
expect(follower.teams.count).to eq(snapshot.fetch(:teams).count) expect(follower.teams.count).to eq(snapshot.fetch(:teams).count)
expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count) expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count)
expect(follower.team_action_lists.count).to eq(snapshot.fetch(:team_action_lists).count)
expect(follower.team_action_lists.first.team_action_items.count).to eq(snapshot.fetch(:team_action_items).count)
expect(follower.stages.count).to eq(source.stages.count) expect(follower.stages.count).to eq(source.stages.count)
expect(follower.matches.count).to eq(source.matches.count) expect(follower.matches.count).to eq(source.matches.count)
expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) }) expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) })
@ -180,6 +184,21 @@ RSpec.describe TournamentSnapshotImporter do
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
timer_end: tournament.timer_end&.iso8601, timer_end: tournament.timer_end&.iso8601,
teams: tournament.teams.order(:name).map { |team| team.name }, teams: tournament.teams.order(:name).map { |team| team.name },
team_action_lists: tournament.team_action_lists.order(:key).map do |list|
{
key: list.key,
name: list.name,
action_name: list.action_name,
source: list.source,
items: list.team_action_items.includes(:team).sort_by { |item| item.team.name }.map do |item|
{
team_name: item.team.name,
completed: item.completed,
completed_at: item.completed_at&.iso8601
}
end
}
end,
beamers: tournament.beamers.order(:name).map do |beamer| beamers: tournament.beamers.order(:name).map do |beamer|
beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys
end, end,