Sync beamer state to followers

This commit is contained in:
Daniel Schädler 2026-04-16 15:55:20 +02:00
parent 8789c2cb2e
commit 03947ac0ca
7 changed files with 87 additions and 0 deletions

View File

@ -5,6 +5,7 @@ class BeamersController < ApplicationController
before_action :set_beamer, only: %i[show update destroy] before_action :set_beamer, only: %i[show update destroy]
before_action :authenticate_user!, only: %i[create update destroy] before_action :authenticate_user!, only: %i[create update destroy]
before_action -> { require_owner! @tournament.owner }, only: %i[create update destroy] before_action -> { require_owner! @tournament.owner }, only: %i[create update destroy]
before_action -> { require_writable_tournament!(@tournament) }, only: %i[create update destroy]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
# GET /tournaments/:tournament_id/beamers # GET /tournaments/:tournament_id/beamers
@ -32,6 +33,7 @@ class BeamersController < ApplicationController
def create def create
beamer = @tournament.beamers.new(beamer_params) beamer = @tournament.beamers.new(beamer_params)
if beamer.save if beamer.save
push_sync_if_needed!
render json: beamer, status: :created render json: beamer, status: :created
else else
render json: beamer.errors, status: :unprocessable_entity render json: beamer.errors, status: :unprocessable_entity
@ -41,6 +43,7 @@ class BeamersController < ApplicationController
# PATCH /tournaments/:tournament_id/beamers/:id # PATCH /tournaments/:tournament_id/beamers/:id
def update def update
if @beamer.update(beamer_params) if @beamer.update(beamer_params)
push_sync_if_needed!
render json: @beamer render json: @beamer
else else
render json: @beamer.errors, status: :unprocessable_entity render json: @beamer.errors, status: :unprocessable_entity
@ -50,6 +53,7 @@ class BeamersController < ApplicationController
# DELETE /tournaments/:tournament_id/beamers/:id # DELETE /tournaments/:tournament_id/beamers/:id
def destroy def destroy
@beamer.destroy @beamer.destroy
push_sync_if_needed!
head :no_content head :no_content
end end
@ -66,4 +70,8 @@ class BeamersController < ApplicationController
def beamer_params def beamer_params
params.permit(:name, :display_state, :qr_code_url, :logo_url) params.permit(:name, :display_state, :qr_code_url, :logo_url)
end end
def push_sync_if_needed!
TournamentSyncEnqueue.call(@tournament)
end
end end

View File

@ -3,6 +3,7 @@
class TournamentSnapshotBuilder class TournamentSnapshotBuilder
ROOT_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Tournament).map(&:to_sym).freeze ROOT_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Tournament).map(&:to_sym).freeze
TEAM_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Team).map(&:to_sym).freeze TEAM_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Team).map(&:to_sym).freeze
BEAMER_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Beamer).map(&:to_sym).freeze
STAGE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Stage).map(&:to_sym).freeze STAGE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Stage).map(&:to_sym).freeze
GROUP_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Group).map(&:to_sym).freeze GROUP_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Group).map(&:to_sym).freeze
MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze
@ -22,6 +23,7 @@ class TournamentSnapshotBuilder
payload[:timer_end] = tournament.timer_end&.iso8601 payload[:timer_end] = tournament.timer_end&.iso8601
payload[:sync_generated_at] = Time.current.iso8601(6) payload[:sync_generated_at] = Time.current.iso8601(6)
payload[:teams] = teams_payload payload[:teams] = teams_payload
payload[:beamers] = beamers_payload
payload[:stages] = stages_payload payload[:stages] = stages_payload
payload[:groups] = groups_payload payload[:groups] = groups_payload
payload[:matches] = matches_payload payload[:matches] = matches_payload
@ -46,6 +48,12 @@ class TournamentSnapshotBuilder
end end
end end
def beamers_payload
tournament.beamers.order(:id).map do |beamer|
beamer.attributes.symbolize_keys.slice(*BEAMER_ATTRIBUTES)
end
end
def groups_payload def groups_payload
tournament.stages.flat_map(&:groups).sort_by(&:id).map do |group| tournament.stages.flat_map(&:groups).sort_by(&:id).map do |group|
group.attributes.symbolize_keys.slice(*GROUP_ATTRIBUTES) group.attributes.symbolize_keys.slice(*GROUP_ATTRIBUTES)

View File

@ -26,6 +26,7 @@ class TournamentSnapshotImporter
return tournament if stale_snapshot? return tournament if stale_snapshot?
existing_teams = tournament.teams.to_a existing_teams = tournament.teams.to_a
existing_beamers = tournament.beamers.to_a
existing_stages = tournament.stages.to_a existing_stages = tournament.stages.to_a
existing_groups = existing_stages.flat_map(&:groups) existing_groups = existing_stages.flat_map(&:groups)
existing_matches = tournament.matches existing_matches = tournament.matches
@ -34,12 +35,14 @@ class TournamentSnapshotImporter
update_tournament! update_tournament!
teams_by_source_id = sync_teams! teams_by_source_id = sync_teams!
sync_beamers!
stages_by_source_id = sync_stages! stages_by_source_id = sync_stages!
groups_by_source_id = sync_groups!(stages_by_source_id) groups_by_source_id = sync_groups!(stages_by_source_id)
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)
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, []))
destroy_stale_records(existing_matches, snapshot.fetch(:matches, [])) destroy_stale_records(existing_matches, snapshot.fetch(:matches, []))
@ -87,6 +90,25 @@ class TournamentSnapshotImporter
end end
end end
def sync_beamers!
existing_by_source_id = tournament.beamers.index_by(&:sync_source_id)
existing_by_name = tournament.beamers.index_by(&:name)
snapshot.fetch(:beamers, []).each_with_object({}) do |attrs, beamers_by_source_id|
source_id = attrs.fetch(:id)
beamer = existing_by_source_id[source_id] || existing_by_name[attrs.fetch(:name)] || Beamer.new
beamer.sync_source_id = source_id
beamer.tournament = tournament
beamer.name = attrs.fetch(:name)
beamer.display_state = attrs.fetch(:display_state)
beamer.is_default = attrs.fetch(:is_default, false)
beamer.qr_code_url = attrs[:qr_code_url]
beamer.logo_url = attrs[:logo_url]
beamer.save!
beamers_by_source_id[source_id] = beamer
end
end
def sync_groups!(stages_by_source_id) def sync_groups!(stages_by_source_id)
sync_records( sync_records(
model_class: Group, model_class: Group,

View File

@ -31,6 +31,10 @@ class TournamentSyncSchema
synced: %w[id name].freeze, synced: %w[id name].freeze,
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
}.freeze, }.freeze,
Beamer => {
synced: %w[id name display_state is_default qr_code_url logo_url].freeze,
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
}.freeze,
Stage => { Stage => {
synced: %w[id level state].freeze, synced: %w[id level state].freeze,
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze ignored: %w[tournament_id created_at updated_at sync_source_id].freeze

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSyncSourceIdToBeamers < ActiveRecord::Migration[7.0]
def change
add_column :beamers, :sync_source_id, :integer
end
end

View File

@ -164,6 +164,21 @@ RSpec.describe BeamersController, type: :controller do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
end end
context 'on read only follower tournament' do
before(:each) do
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
apply_authentication_headers_for @owner
end
it 'returns locked' do
patch :update, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param,
display_state: 'groups'
}
expect(response).to have_http_status(:locked)
end
end
end end
describe 'DELETE #destroy' do describe 'DELETE #destroy' do

View File

@ -6,6 +6,7 @@ RSpec.describe TournamentSnapshotImporter do
describe '.import!' do describe '.import!' 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')
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)
@ -16,9 +17,11 @@ RSpec.describe TournamentSnapshotImporter do
expect(follower.sync_source_tournament_id).to eq(source.id) expect(follower.sync_source_tournament_id).to eq(source.id)
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.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) })
expect(follower.beamers.pluck(:sync_source_id)).to match_array(snapshot.fetch(:beamers).map { |beamer| beamer.fetch(:id) })
end end
it 'ignores stale snapshots that arrive after a newer one' do it 'ignores stale snapshots that arrive after a newer one' do
@ -69,6 +72,23 @@ RSpec.describe TournamentSnapshotImporter do
instant_finalists_amount: 64, instant_finalists_amount: 64,
intermediate_round_participants_amount: 0 intermediate_round_participants_amount: 0
) )
tournament.beamers.find_by!(name: Beamer::DEFAULT_NAME).update!(
display_state: 'groups',
qr_code_url: 'https://example.com/default-qr',
logo_url: '/logos/default.png'
)
tournament.beamers.create!(
name: 'stream-a',
display_state: 'during_game',
qr_code_url: 'https://example.com/stream-a',
logo_url: '/logos/stream-a.png'
)
tournament.beamers.create!(
name: 'stream-b',
display_state: 'between_games',
qr_code_url: 'https://example.com/stream-b',
logo_url: '/logos/stream-b.png'
)
group_stage = Stage.create!(tournament: tournament, level: -1, state: :finished) group_stage = Stage.create!(tournament: tournament, level: -1, state: :finished)
advancing_teams = [] advancing_teams = []
@ -160,6 +180,9 @@ 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 },
beamers: tournament.beamers.order(:name).map do |beamer|
beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys
end,
stages: tournament.stages.order(level: :asc).map do |stage| stages: tournament.stages.order(level: :asc).map do |stage|
{ {
level: stage.level, level: stage.level,