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 {}.
This commit is contained in:
Daniel Schädler 2026-04-26 16:13:12 +02:00
parent 4bd1531408
commit acdef9f5b4
16 changed files with 147 additions and 61 deletions

View File

@ -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!

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 => {

View File

@ -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

View File

@ -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

View File

@ -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"
}
}
}
```

View File

@ -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

View File

@ -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

View File

@ -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 })

View File

@ -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

View File

@ -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

View File

@ -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|
{