diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index fcdac4d..5cada55 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -65,8 +65,11 @@ class StagesController < ApplicationController raise ActiveRecord::Rollback end - return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, - teams: GroupStageService.get_advancing_teams(@stage)).success? + return unless AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, + 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 raise ActiveRecord::Rollback diff --git a/app/controllers/team_action_items_controller.rb b/app/controllers/team_action_items_controller.rb new file mode 100644 index 0000000..8011ef6 --- /dev/null +++ b/app/controllers/team_action_items_controller.rb @@ -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 diff --git a/app/models/team.rb b/app/models/team.rb index d81563e..4ad8508 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -5,6 +5,7 @@ class Team < ApplicationRecord has_many :group_scores, dependent: :destroy has_many :match_scores, dependent: :destroy has_many :bets, dependent: :destroy + has_many :team_action_items, dependent: :destroy validates :name, presence: true diff --git a/app/models/team_action_item.rb b/app/models/team_action_item.rb new file mode 100644 index 0000000..9724f59 --- /dev/null +++ b/app/models/team_action_item.rb @@ -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 diff --git a/app/models/team_action_list.rb b/app/models/team_action_list.rb new file mode 100644 index 0000000..cd68cc9 --- /dev/null +++ b/app/models/team_action_list.rb @@ -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 diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 4b284d8..729fdab 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -7,6 +7,7 @@ class Tournament < ApplicationRecord has_many :teams, dependent: :destroy has_many :stages, dependent: :destroy has_many :beamers, dependent: :destroy + has_many :team_action_lists, dependent: :destroy validates :name, presence: true validates :code, presence: true, uniqueness: true diff --git a/app/serializers/team_action_item_serializer.rb b/app/serializers/team_action_item_serializer.rb new file mode 100644 index 0000000..f7a617e --- /dev/null +++ b/app/serializers/team_action_item_serializer.rb @@ -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 diff --git a/app/serializers/team_action_list_serializer.rb b/app/serializers/team_action_list_serializer.rb new file mode 100644 index 0000000..5695a5c --- /dev/null +++ b/app/serializers/team_action_list_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TeamActionListSerializer < ApplicationSerializer + attributes :name, :key, :action_name, :source + + has_many :team_action_items +end diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index a01caf1..9389365 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -9,6 +9,7 @@ class TournamentSerializer < SimpleTournamentSerializer # NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against has_many :stages + has_many :team_action_lists attribute :timer_end do object.timer_end&.iso8601 diff --git a/app/services/team_action_list_service.rb b/app/services/team_action_list_service.rb new file mode 100644 index 0000000..e613ee3 --- /dev/null +++ b/app/services/team_action_list_service.rb @@ -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 diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index a1b4ee4..48b61d4 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -9,6 +9,8 @@ class TournamentSnapshotBuilder MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).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 + 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) new(tournament).build @@ -29,6 +31,8 @@ class TournamentSnapshotBuilder payload[:matches] = matches_payload payload[:match_scores] = match_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 end @@ -78,6 +82,20 @@ class TournamentSnapshotBuilder 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 ( tournament.teams.to_a + diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 72630d7..0a1657f 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -32,6 +32,8 @@ class TournamentSnapshotImporter existing_matches = tournament.matches existing_match_scores = existing_matches.flat_map(&:match_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! 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) sync_match_scores!(matches_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_group_scores, snapshot.fetch(:group_scores, [])) destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, [])) @@ -162,6 +168,33 @@ class TournamentSnapshotImporter 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:) existing_by_source_id = existing_records.index_by(&:sync_source_id) diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 230c908..d31f49f 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -54,6 +54,14 @@ class TournamentSyncSchema GroupScore => { 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 + }.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 diff --git a/config/routes.rb b/config/routes.rb index b3dcc6e..1bd4721 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,7 @@ Rails.application.routes.draw do end resources :stages, only: %i[show update] resources :teams, only: %i[show update] + resources :team_action_items, only: %i[update] resources :tournaments do resources :statistics, only: %i[index] resources :matches, only: %i[index] diff --git a/db/migrate/20260422100000_create_team_action_lists.rb b/db/migrate/20260422100000_create_team_action_lists.rb new file mode 100644 index 0000000..fa7547d --- /dev/null +++ b/db/migrate/20260422100000_create_team_action_lists.rb @@ -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 diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index 355cd6e..d6e5ab0 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -91,6 +91,16 @@ RSpec.describe StagesController, type: :controller do expect(running_group_stage.tournament.reload.stages.max_by(&:level).teams) .to match_array(GroupStageService.get_advancing_teams(running_group_stage)) 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 context 'as another user' do diff --git a/spec/controllers/team_action_items_controller_spec.rb b/spec/controllers/team_action_items_controller_spec.rb new file mode 100644 index 0000000..5eac4ef --- /dev/null +++ b/spec/controllers/team_action_items_controller_spec.rb @@ -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 diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4f53e0a..6939459 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do expected_keys = %i[ id name code public description playoff_teams_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[: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]) } expect(advancing_teams).to match_array(expected_advancing_teams) 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 diff --git a/spec/factories/team_action_items.rb b/spec/factories/team_action_items.rb new file mode 100644 index 0000000..bd272f5 --- /dev/null +++ b/spec/factories/team_action_items.rb @@ -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 diff --git a/spec/factories/team_action_lists.rb b/spec/factories/team_action_lists.rb new file mode 100644 index 0000000..12e240c --- /dev/null +++ b/spec/factories/team_action_lists.rb @@ -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 diff --git a/spec/routing/team_action_items_routing_spec.rb b/spec/routing/team_action_items_routing_spec.rb new file mode 100644 index 0000000..12efa95 --- /dev/null +++ b/spec/routing/team_action_items_routing_spec.rb @@ -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 diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 4788941..d761fe8 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -7,6 +7,8 @@ RSpec.describe TournamentSnapshotImporter 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.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') snapshot = TournamentSnapshotBuilder.build(source) @@ -18,6 +20,8 @@ RSpec.describe TournamentSnapshotImporter do expect(follower.name).to eq(source.name) expect(follower.teams.count).to eq(snapshot.fetch(:teams).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.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) }) @@ -180,6 +184,21 @@ RSpec.describe TournamentSnapshotImporter do intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, timer_end: tournament.timer_end&.iso8601, 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| beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys end,