From acdef9f5b43b86191593822820c442a1005cee6f Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 26 Apr 2026 16:13:12 +0200 Subject: [PATCH] feat(beamers): replace asset URLs with config Add beamer config JSON, drop obsolete asset URL fields, and narrow display_state to the new beamer API states. Migration maps legacy display states to supported values and initializes config to {}. --- app/controllers/beamers_controller.rb | 7 +++- app/models/beamer.rb | 12 +++++-- app/models/tournament.rb | 2 +- app/serializers/beamer_serializer.rb | 4 ++- app/services/beamer_broadcast_payload.rb | 10 +++--- app/services/tournament_snapshot_importer.rb | 3 +- app/services/tournament_sync_schema.rb | 2 +- ...replace_beamer_asset_fields_with_config.rb | 28 ++++++++++++++++ db/schema.rb | 7 ++-- doc/beamer_live_updates.md | 7 ++-- spec/controllers/beamers_controller_spec.rb | 26 +++++++++------ .../http/tournament_beamers_websocket_spec.rb | 24 ++++++++------ ...ment_follow_sync_beamers_websocket_spec.rb | 32 +++++++++++++++---- spec/factories/beamers.rb | 3 +- spec/models/beamer_spec.rb | 22 ++++++++++--- .../tournament_snapshot_importer_spec.rb | 19 +++++------ 16 files changed, 147 insertions(+), 61 deletions(-) create mode 100644 db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb diff --git a/app/controllers/beamers_controller.rb b/app/controllers/beamers_controller.rb index 74df102..5772eec 100644 --- a/app/controllers/beamers_controller.rb +++ b/app/controllers/beamers_controller.rb @@ -68,7 +68,12 @@ class BeamersController < ApplicationController end def beamer_params - params.permit(:name, :display_state, :qr_code_url, :logo_url) + permitted = params.permit(:name, :display_state).to_h + config = params.key?(:config) ? params[:config] : params.dig(:beamer, :config) + return permitted unless config + + permitted[:config] = config.respond_to?(:to_unsafe_h) ? config.to_unsafe_h : config + permitted end def push_sync_if_needed! diff --git a/app/models/beamer.rb b/app/models/beamer.rb index 47f44e0..3170cf0 100644 --- a/app/models/beamer.rb +++ b/app/models/beamer.rb @@ -3,14 +3,14 @@ class Beamer < ApplicationRecord DEFAULT_NAME = 'default' DISPLAY_STATES = %w[ - matches_all matches_in_progress matches_upcoming matches_not_started - matches_finished groups between_games during_game + during_game between_games static_image group_tables ].freeze belongs_to :tournament validates :name, presence: true, uniqueness: { scope: :tournament_id } validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES } + validate :config_is_hash delegate :owner, to: :tournament @@ -34,7 +34,7 @@ class Beamer < ApplicationRecord BeamerBroadcast.call( tournament_id: snapshot.fetch(:tournament_id), beamer_names: [@broadcast_previous_name, snapshot.fetch(:name)].compact.uniq, - payload: BeamerBroadcastPayload.build(snapshot: snapshot, event: broadcast_event) + payload: BeamerBroadcastPayload.build(snapshot:, event: broadcast_event) ) ensure @broadcast_previous_name = nil @@ -46,4 +46,10 @@ class Beamer < ApplicationRecord previous_changes.key?('id') ? 'created' : 'updated' end + + def config_is_hash + return if config.is_a?(Hash) + + errors.add(:config, 'must be an object') + end end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 65c22d0..b91176a 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -76,7 +76,7 @@ class Tournament < ApplicationRecord def ensure_default_beamer! beamers.find_or_create_by!(name: Beamer::DEFAULT_NAME) do |beamer| - beamer.display_state = 'matches_all' + beamer.display_state = 'during_game' end end diff --git a/app/serializers/beamer_serializer.rb b/app/serializers/beamer_serializer.rb index d6863f1..765ad11 100644 --- a/app/serializers/beamer_serializer.rb +++ b/app/serializers/beamer_serializer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true class BeamerSerializer < ApplicationSerializer - attributes :id, :name, :display_state, :qr_code_url, :logo_url + attributes :id, :name, :display_state, :config + + delegate :config, to: :object end diff --git a/app/services/beamer_broadcast_payload.rb b/app/services/beamer_broadcast_payload.rb index cc47b80..7472ffa 100644 --- a/app/services/beamer_broadcast_payload.rb +++ b/app/services/beamer_broadcast_payload.rb @@ -2,12 +2,12 @@ class BeamerBroadcastPayload class << self - def build(beamer: nil, snapshot: nil, event:) + def build(event:, beamer: nil, snapshot: nil) data = snapshot || self.snapshot(beamer) { type: "beamer.#{event}", - event: event, + event:, tournament_id: data.fetch(:tournament_id), beamer: serialized_beamer(data) } @@ -19,8 +19,7 @@ class BeamerBroadcastPayload tournament_id: beamer.tournament_id, name: beamer.name, display_state: beamer.display_state, - qr_code_url: beamer.qr_code_url, - logo_url: beamer.logo_url + config: beamer.config } end @@ -31,8 +30,7 @@ class BeamerBroadcastPayload id: snapshot.fetch(:id), name: snapshot.fetch(:name), display_state: snapshot.fetch(:display_state), - qr_code_url: snapshot[:qr_code_url], - logo_url: snapshot[:logo_url] + config: snapshot.fetch(:config) } end end diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index d03beba..af298c2 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -109,8 +109,7 @@ class TournamentSnapshotImporter 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.config = attrs.fetch(:config, {}) beamer.save! beamers_by_source_id[source_id] = beamer end diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 3d85a2d..64b4d33 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -33,7 +33,7 @@ class TournamentSyncSchema 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, + synced: %w[id name display_state is_default config].freeze, ignored: %w[tournament_id created_at updated_at sync_source_id].freeze }.freeze, Stage => { diff --git a/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb b/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb new file mode 100644 index 0000000..f9b8f99 --- /dev/null +++ b/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ReplaceBeamerAssetFieldsWithConfig < ActiveRecord::Migration[7.0] + def up + add_column :beamers, :config, :json, null: false, default: {} + + execute <<~SQL.squish + UPDATE beamers + SET display_state = CASE display_state + WHEN 'groups' THEN 'group_tables' + WHEN 'between_games' THEN 'between_games' + WHEN 'during_game' THEN 'during_game' + ELSE 'during_game' + END + SQL + + change_column_default :beamers, :display_state, from: 'matches_all', to: 'during_game' + remove_column :beamers, :qr_code_url, :string + remove_column :beamers, :logo_url, :string + end + + def down + add_column :beamers, :qr_code_url, :string + add_column :beamers, :logo_url, :string + change_column_default :beamers, :display_state, from: 'during_game', to: 'matches_all' + remove_column :beamers, :config, :json + end +end diff --git a/db/schema.rb b/db/schema.rb index 656b26b..1884bd4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,17 +10,16 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2026_04_24_120000) do +ActiveRecord::Schema[7.0].define(version: 2026_04_26_120000) do create_table "beamers", force: :cascade do |t| t.string "name", null: false - t.string "display_state", default: "matches_all", null: false + t.string "display_state", default: "during_game", null: false t.boolean "is_default", default: false - t.string "qr_code_url" - t.string "logo_url" t.integer "tournament_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "sync_source_id" + t.json "config", default: {}, null: false t.index ["tournament_id", "name"], name: "index_beamers_on_tournament_id_and_name", unique: true t.index ["tournament_id"], name: "index_beamers_on_tournament_id" end diff --git a/doc/beamer_live_updates.md b/doc/beamer_live_updates.md index 155349e..8465a1f 100644 --- a/doc/beamer_live_updates.md +++ b/doc/beamer_live_updates.md @@ -30,9 +30,10 @@ Initial subscribe snapshot: "beamer": { "id": 34, "name": "default", - "display_state": "matches_all", - "qr_code_url": null, - "logo_url": null + "display_state": "during_game", + "config": { + "match_filter": "state:in_progress" + } } } ``` diff --git a/spec/controllers/beamers_controller_spec.rb b/spec/controllers/beamers_controller_spec.rb index bc76847..3a4fb46 100644 --- a/spec/controllers/beamers_controller_spec.rb +++ b/spec/controllers/beamers_controller_spec.rb @@ -66,7 +66,11 @@ RSpec.describe BeamersController, type: :controller do describe 'POST #create' do let(:valid_params) do - { name: 'new-beamer', display_state: 'groups' } + { + name: 'new-beamer', + display_state: 'during_game', + config: { match_filter: 'state:in_progress' } + } end context 'as owner' do @@ -83,7 +87,8 @@ RSpec.describe BeamersController, type: :controller do post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params) body = deserialize_response response expect(body[:name]).to eq('new-beamer') - expect(body[:display_state]).to eq('groups') + expect(body[:display_state]).to eq('during_game') + expect(body[:config]).to eq(match_filter: 'state:in_progress') end it 'rejects invalid display_state' do @@ -95,7 +100,7 @@ RSpec.describe BeamersController, type: :controller do it 'rejects duplicate name in same tournament' do post :create, params: { - tournament_id: @tournament.to_param, name: @beamer.name, display_state: 'matches_all' + tournament_id: @tournament.to_param, name: @beamer.name, display_state: 'during_game' } expect(response).to have_http_status(:unprocessable_entity) end @@ -133,15 +138,18 @@ RSpec.describe BeamersController, type: :controller do expect(@beamer.reload.display_state).to eq('during_game') end - it 'updates optional fields' do + it 'replaces config' do + @beamer.update!(config: { match_filter: 'state:upcoming', image_key: 'old-logo' }) + patch :update, params: { tournament_id: @tournament.to_param, id: @beamer.to_param, - qr_code_url: 'https://example.com/qr', logo_url: '/static/logo.png' + config: { image_key: 'bpwstr_logo' } } expect(response).to be_successful + body = deserialize_response response + expect(body[:config]).to eq(image_key: 'bpwstr_logo') @beamer.reload - expect(@beamer.qr_code_url).to eq('https://example.com/qr') - expect(@beamer.logo_url).to eq('/static/logo.png') + expect(@beamer.config).to eq('image_key' => 'bpwstr_logo') end it 'rejects invalid display_state' do @@ -159,7 +167,7 @@ RSpec.describe BeamersController, type: :controller do it 'returns forbidden' do patch :update, params: { tournament_id: @tournament.to_param, id: @beamer.to_param, - display_state: 'groups' + display_state: 'group_tables' } expect(response).to have_http_status(:forbidden) end @@ -174,7 +182,7 @@ RSpec.describe BeamersController, type: :controller do it 'returns locked' do patch :update, params: { tournament_id: @tournament.to_param, id: @beamer.to_param, - display_state: 'groups' + display_state: 'group_tables' } expect(response).to have_http_status(:locked) end diff --git a/spec/e2e/http/tournament_beamers_websocket_spec.rb b/spec/e2e/http/tournament_beamers_websocket_spec.rb index 4588172..ffeff40 100644 --- a/spec/e2e/http/tournament_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_beamers_websocket_spec.rb @@ -26,7 +26,10 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do tournament = create_tournament(name_prefix: 'Ws Beamer') create_beamer = client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { name: 'projector-a', - display_state: 'matches_all' + display_state: 'during_game', + config: { + match_filter: 'state:in_progress' + } }) expect(create_beamer[:status]).to eq(201) beamer = create_beamer.fetch(:json) @@ -37,22 +40,25 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do initial_payload = cable_client.wait_for_beamer_payload! expect(initial_payload.fetch('type')).to eq('beamer.snapshot') expect(initial_payload.dig('beamer', 'id')).to eq(beamer.fetch(:id)) - expect(initial_payload.dig('beamer', 'display_state')).to eq('matches_all') + expect(initial_payload.dig('beamer', 'display_state')).to eq('during_game') + expect(initial_payload.dig('beamer', 'config', 'match_filter')).to eq('state:in_progress') update = client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { - display_state: 'during_game', - qr_code_url: 'https://example.com/qr', - logo_url: '/static/logo.png' + display_state: 'static_image', + config: { + image_key: 'bpwstr_logo' + } }) expect(update[:status]).to eq(200) updated_payload = wait_for_beamer_payload!( cable_client, event_type: 'beamer.updated', - display_state: 'during_game' + display_state: 'static_image' ) - expect(updated_payload.dig('beamer', 'qr_code_url')).to eq('https://example.com/qr') - expect(updated_payload.dig('beamer', 'logo_url')).to eq('/static/logo.png') + expect(updated_payload.dig('beamer', 'config')).to eq('image_key' => 'bpwstr_logo') + expect(updated_payload['beamer']).not_to have_key('qr_code_url') + expect(updated_payload['beamer']).not_to have_key('logo_url') delete = client.delete("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") expect(delete[:status]).to eq(204) @@ -60,7 +66,7 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do deleted_payload = wait_for_beamer_payload!( cable_client, event_type: 'beamer.deleted', - display_state: 'during_game' + display_state: 'static_image' ) expect(deleted_payload.dig('beamer', 'name')).to eq('projector-a') ensure diff --git a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb index 19eedf8..b550f67 100644 --- a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do follower = create_follower_tournament(name_prefix: 'Ws Beamer Follower') configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: { - sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state", + sync_target_url: follower_sync_url(follower), sync_auth_token: sync_token }) expect(configure_sync[:status]).to eq(200) @@ -40,21 +40,35 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do follower_default_beamer = wait_for_default_beamer!(tournament_id: follower.fetch(:id)) cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) - cable_client.subscribe_beamer!(tournament_id: follower.fetch(:id), beamer_name: follower_default_beamer.fetch(:name)) + cable_client.subscribe_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: follower_default_beamer.fetch(:name) + ) cable_client.wait_for_beamer_payload! - source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: DEFAULT_BEAMER_NAME) - update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: { - display_state: 'groups' - }) + source_default_beamer = fetch_beamer( + base_url: source_base_url, + tournament_id: source.fetch(:id), + beamer_name: DEFAULT_BEAMER_NAME + ) + update = source_client.patch( + "/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", + body: { + display_state: 'static_image', + config: { + image_key: 'bpwstr_logo' + } + } + ) expect(update[:status]).to eq(200) follower_payload = wait_for_beamer_payload!( cable_client, event_type: 'beamer.updated', - display_state: 'groups' + display_state: 'static_image' ) expect(follower_payload.dig('beamer', 'name')).to eq(DEFAULT_BEAMER_NAME) + expect(follower_payload.dig('beamer', 'config')).to eq('image_key' => 'bpwstr_logo') ensure cable_client&.close end @@ -99,6 +113,10 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do response.fetch(:json) end + def follower_sync_url(follower) + "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state" + end + def fetch_beamer(base_url:, tournament_id:, beamer_name:) client = TurniereE2E::ApiClient.new(base_url:) response = client.get("/tournaments/#{tournament_id}/beamers/by_name", params: { name: beamer_name }) diff --git a/spec/factories/beamers.rb b/spec/factories/beamers.rb index 0d6f3a0..354c43c 100644 --- a/spec/factories/beamers.rb +++ b/spec/factories/beamers.rb @@ -3,7 +3,8 @@ FactoryBot.define do factory :beamer do sequence(:name) { |n| "beamer-#{n}" } - display_state { 'matches_all' } + display_state { 'during_game' } + config { {} } tournament factory :default_beamer do diff --git a/spec/models/beamer_spec.rb b/spec/models/beamer_spec.rb index ba9681b..d3e82de 100644 --- a/spec/models/beamer_spec.rb +++ b/spec/models/beamer_spec.rb @@ -24,6 +24,12 @@ RSpec.describe Beamer, type: :model do expect(beamer).not_to be_valid end + it 'requires config to be an object' do + beamer = build(:beamer, config: ['not-an-object']) + expect(beamer).not_to be_valid + expect(beamer.errors[:config]).to include('must be an object') + end + Beamer::DISPLAY_STATES.each do |state| it "accepts display_state '#{state}'" do beamer = build(:beamer, display_state: state) @@ -54,10 +60,15 @@ RSpec.describe Beamer, type: :model do expect(args.fetch(:beamer_names)).to eq(['projector']) end - tournament.beamers.create!(name: 'projector', display_state: 'groups') + tournament.beamers.create!( + name: 'projector', + display_state: 'group_tables', + config: { 'match_filter' => 'state:in_progress' } + ) expect(payload[:type]).to eq('beamer.created') - expect(payload.dig(:beamer, :display_state)).to eq('groups') + expect(payload.dig(:beamer, :display_state)).to eq('group_tables') + expect(payload.dig(:beamer, :config)).to eq('match_filter' => 'state:in_progress') end it 'broadcasts update payload to previous and current beamer names when renamed' do @@ -69,15 +80,17 @@ RSpec.describe Beamer, type: :model do expect(args.fetch(:beamer_names)).to contain_exactly('old-name', 'new-name') end - beamer.update!(name: 'new-name', display_state: 'during_game') + beamer.update!(name: 'new-name', display_state: 'during_game', config: { 'image_key' => 'bpwstr_logo' }) expect(payload[:type]).to eq('beamer.updated') expect(payload.dig(:beamer, :name)).to eq('new-name') expect(payload.dig(:beamer, :display_state)).to eq('during_game') + expect(payload.dig(:beamer, :config)).to eq('image_key' => 'bpwstr_logo') end it 'broadcasts delete payload with last persisted snapshot' do - beamer = create(:beamer, name: 'projector', display_state: 'between_games') + beamer = create(:beamer, name: 'projector', display_state: 'between_games', + config: { 'match_filter' => 'state:in_progress' }) payload = nil expect(BeamerBroadcast).to receive(:call) do |args| @@ -90,6 +103,7 @@ RSpec.describe Beamer, type: :model do expect(payload[:type]).to eq('beamer.deleted') expect(payload.dig(:beamer, :name)).to eq('projector') expect(payload.dig(:beamer, :display_state)).to eq('between_games') + expect(payload.dig(:beamer, :config)).to eq('match_filter' => 'state:in_progress') end end end diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 29292ee..03f9b1a 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -6,7 +6,11 @@ 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') + source.beamers.create!( + name: 'stream-a', + display_state: 'during_game', + config: { match_filter: 'state:in_progress' } + ) 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') @@ -78,21 +82,18 @@ RSpec.describe TournamentSnapshotImporter do 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' + display_state: 'group_tables', + config: { match_filter: 'state:in_progress' } ) tournament.beamers.create!( name: 'stream-a', display_state: 'during_game', - qr_code_url: 'https://example.com/stream-a', - logo_url: '/logos/stream-a.png' + config: { match_filter: 'state:in_progress' } ) tournament.beamers.create!( name: 'stream-b', display_state: 'between_games', - qr_code_url: 'https://example.com/stream-b', - logo_url: '/logos/stream-b.png' + config: { image_key: 'bpwstr_logo' } ) group_stage = Stage.create!(tournament: tournament, level: -1, state: :finished) @@ -202,7 +203,7 @@ RSpec.describe TournamentSnapshotImporter do } end, 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, :config).symbolize_keys end, stages: tournament.stages.order(level: :asc).map do |stage| {