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 :authenticate_user!, 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
# GET /tournaments/:tournament_id/beamers
@ -32,6 +33,7 @@ class BeamersController < ApplicationController
def create
beamer = @tournament.beamers.new(beamer_params)
if beamer.save
push_sync_if_needed!
render json: beamer, status: :created
else
render json: beamer.errors, status: :unprocessable_entity
@ -41,6 +43,7 @@ class BeamersController < ApplicationController
# PATCH /tournaments/:tournament_id/beamers/:id
def update
if @beamer.update(beamer_params)
push_sync_if_needed!
render json: @beamer
else
render json: @beamer.errors, status: :unprocessable_entity
@ -50,6 +53,7 @@ class BeamersController < ApplicationController
# DELETE /tournaments/:tournament_id/beamers/:id
def destroy
@beamer.destroy
push_sync_if_needed!
head :no_content
end
@ -66,4 +70,8 @@ class BeamersController < ApplicationController
def beamer_params
params.permit(:name, :display_state, :qr_code_url, :logo_url)
end
def push_sync_if_needed!
TournamentSyncEnqueue.call(@tournament)
end
end

View File

@ -3,6 +3,7 @@
class TournamentSnapshotBuilder
ROOT_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Tournament).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
GROUP_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Group).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[:sync_generated_at] = Time.current.iso8601(6)
payload[:teams] = teams_payload
payload[:beamers] = beamers_payload
payload[:stages] = stages_payload
payload[:groups] = groups_payload
payload[:matches] = matches_payload
@ -46,6 +48,12 @@ class TournamentSnapshotBuilder
end
end
def beamers_payload
tournament.beamers.order(:id).map do |beamer|
beamer.attributes.symbolize_keys.slice(*BEAMER_ATTRIBUTES)
end
end
def groups_payload
tournament.stages.flat_map(&:groups).sort_by(&:id).map do |group|
group.attributes.symbolize_keys.slice(*GROUP_ATTRIBUTES)

View File

@ -26,6 +26,7 @@ class TournamentSnapshotImporter
return tournament if stale_snapshot?
existing_teams = tournament.teams.to_a
existing_beamers = tournament.beamers.to_a
existing_stages = tournament.stages.to_a
existing_groups = existing_stages.flat_map(&:groups)
existing_matches = tournament.matches
@ -34,12 +35,14 @@ class TournamentSnapshotImporter
update_tournament!
teams_by_source_id = sync_teams!
sync_beamers!
stages_by_source_id = sync_stages!
groups_by_source_id = sync_groups!(stages_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_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_match_scores, snapshot.fetch(:match_scores, []))
destroy_stale_records(existing_matches, snapshot.fetch(:matches, []))
@ -87,6 +90,25 @@ class TournamentSnapshotImporter
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)
sync_records(
model_class: Group,

View File

@ -31,6 +31,10 @@ class TournamentSyncSchema
synced: %w[id name].freeze,
ignored: %w[tournament_id created_at updated_at sync_source_id].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 => {
synced: %w[id level state].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)
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
describe 'DELETE #destroy' do

View File

@ -6,6 +6,7 @@ RSpec.describe TournamentSnapshotImporter do
describe '.import!' 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')
follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
snapshot = TournamentSnapshotBuilder.build(source)
@ -16,9 +17,11 @@ RSpec.describe TournamentSnapshotImporter do
expect(follower.sync_source_tournament_id).to eq(source.id)
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.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) })
expect(follower.beamers.pluck(:sync_source_id)).to match_array(snapshot.fetch(:beamers).map { |beamer| beamer.fetch(:id) })
end
it 'ignores stale snapshots that arrive after a newer one' do
@ -69,6 +72,23 @@ RSpec.describe TournamentSnapshotImporter do
instant_finalists_amount: 64,
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)
advancing_teams = []
@ -160,6 +180,9 @@ 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 },
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|
{
level: stage.level,