From 03947ac0ca487da14d9ca252f9ad3ed4afee59a1 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 16 Apr 2026 15:55:20 +0200 Subject: [PATCH] Sync beamer state to followers --- app/controllers/beamers_controller.rb | 8 +++++++ app/services/tournament_snapshot_builder.rb | 8 +++++++ app/services/tournament_snapshot_importer.rb | 22 ++++++++++++++++++ app/services/tournament_sync_schema.rb | 4 ++++ ...416131500_add_sync_source_id_to_beamers.rb | 7 ++++++ spec/controllers/beamers_controller_spec.rb | 15 ++++++++++++ .../tournament_snapshot_importer_spec.rb | 23 +++++++++++++++++++ 7 files changed, 87 insertions(+) create mode 100644 db/migrate/20260416131500_add_sync_source_id_to_beamers.rb diff --git a/app/controllers/beamers_controller.rb b/app/controllers/beamers_controller.rb index 7dba9a3..74df102 100644 --- a/app/controllers/beamers_controller.rb +++ b/app/controllers/beamers_controller.rb @@ -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 diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 9ea2363..a1b4ee4 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -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) diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index e960ea5..72630d7 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -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, diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 4604a2a..230c908 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -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 diff --git a/db/migrate/20260416131500_add_sync_source_id_to_beamers.rb b/db/migrate/20260416131500_add_sync_source_id_to_beamers.rb new file mode 100644 index 0000000..8ef90e3 --- /dev/null +++ b/db/migrate/20260416131500_add_sync_source_id_to_beamers.rb @@ -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 diff --git a/spec/controllers/beamers_controller_spec.rb b/spec/controllers/beamers_controller_spec.rb index 1cdbe92..bc76847 100644 --- a/spec/controllers/beamers_controller_spec.rb +++ b/spec/controllers/beamers_controller_spec.rb @@ -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 diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 9668045..4788941 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -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,