From 0cbcb64066b6db7a2f3ace5c1f610179aa9ba1fa Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 15:10:11 +0200 Subject: [PATCH 1/3] feat(matches): add match metadata updates --- app/controllers/matches_controller.rb | 73 ++++++++++- app/serializers/live_match_serializer.rb | 2 +- app/serializers/match_serializer.rb | 2 +- app/services/tournament_snapshot_importer.rb | 3 + app/services/tournament_sync_schema.rb | 2 +- config/routes.rb | 6 +- ...504100000_add_match_metadata_to_matches.rb | 9 ++ db/schema.rb | 5 +- spec/controllers/matches_controller_spec.rb | 121 +++++++++++++++++- spec/e2e/http/tournament_follow_sync_spec.rb | 62 +++++++++ spec/routing/matches_routing_spec.rb | 4 + spec/services/tournament_live_payload_spec.rb | 7 +- .../tournament_snapshot_importer_spec.rb | 18 +++ tasks.py | 7 +- 14 files changed, 308 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20260504100000_add_match_metadata_to_matches.rb diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 42477ba..2ea5ac3 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -7,9 +7,11 @@ class MatchesController < ApplicationController before_action :set_swap_target, only: %i[swap] before_action -> { require_owner! @match.owner }, only: %i[update swap] before_action -> { require_owner! @group.owner }, only: %i[create] - before_action :set_tournament, only: %i[index] + before_action :set_tournament, only: %i[index metadata] + before_action -> { require_owner! @tournament.owner }, only: %i[metadata] before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update swap] before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create] + before_action -> { require_writable_tournament!(@tournament) }, only: %i[metadata] # GET/tournaments/1/matches def index @@ -63,6 +65,28 @@ class MatchesController < ApplicationController TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? end + # PATCH /tournaments/1/matches/metadata + def metadata + attrs = match_metadata_params + if attrs.empty? + render json: { error: 'At least one metadata field is required' }, status: :bad_request + return + end + + matches = bulk_metadata_matches + return if performed? + + Match.transaction do + TournamentTransactionLogger.lock_tournament!(@tournament) + @tournament.reload + matches.each { |match| match.update!(attrs) } + end + + render json: matches.map(&:reload), each_serializer: LiveMatchSerializer, include: %w[match_scores.team] + push_sync_if_needed!(@tournament) + TournamentLiveBroadcast.call(@tournament.id) + end + # PATCH /matches/1/swap def swap unless @match.swappable_with?(@swap_target) @@ -141,7 +165,7 @@ class MatchesController < ApplicationController old_state = @match.state if @match.update(match_params) - record_successful_match_update(old_state, new_state) + record_successful_match_update(old_state, new_state) unless new_state.nil? render json: @match tournament else @@ -151,6 +175,8 @@ class MatchesController < ApplicationController end def validate_transition_inside_transaction(new_state) + return if new_state.nil? + transition_error = match_transition_error(@match, new_state) return if transition_error.nil? @@ -175,6 +201,8 @@ class MatchesController < ApplicationController end def validate_params + return unless params.key?(:state) + transition_error = match_transition_error(@match, match_params['state']) return if transition_error.nil? @@ -201,13 +229,52 @@ class MatchesController < ApplicationController end def match_params - params.slice(:state).permit! + params.slice(:state, :location, :start_time, :notes).permit! end def match_index_params params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit! end + def match_metadata_params + params.slice(:location, :start_time, :notes).permit! + end + + def bulk_metadata_matches + return bulk_metadata_matches_by_id if params.key?(:match_ids) + + return @tournament.matches if ActiveModel::Type::Boolean.new.cast(params[:all]) + return filtered_bulk_metadata_matches if match_index_params.values.any?(&:present?) + + render json: { error: 'Select matches with match_ids, filters, or all=true' }, status: :bad_request + [] + end + + def bulk_metadata_matches_by_id + ids = parse_match_ids + return [] if performed? + + matches = @tournament.matches.select { |match| ids.include?(match.id) } + missing_ids = ids - matches.map(&:id) + if missing_ids.any? + render json: { error: "Matches not found in tournament: #{missing_ids.join(', ')}" }, status: :not_found + return [] + end + + ids.map { |id| matches.find { |match| match.id == id } } + end + + def filtered_bulk_metadata_matches + TournamentMatchesPayload.matches_for(@tournament, match_index_params) + end + + def parse_match_ids + Array(params[:match_ids]).map { |id| Integer(id.to_s, 10) }.uniq + rescue ArgumentError, TypeError + render json: { error: 'match_ids must contain only integer ids' }, status: :bad_request + [] + end + def create_match_params params.slice(:team_ids).permit(team_ids: []) end diff --git a/app/serializers/live_match_serializer.rb b/app/serializers/live_match_serializer.rb index 3b0f27a..a91295a 100644 --- a/app/serializers/live_match_serializer.rb +++ b/app/serializers/live_match_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class LiveMatchSerializer < ApplicationSerializer - attributes :position, :state, :teams, :group, :stage + attributes :position, :state, :location, :start_time, :notes, :teams, :group, :stage has_many :match_scores diff --git a/app/serializers/match_serializer.rb b/app/serializers/match_serializer.rb index 50feade..3479d5a 100644 --- a/app/serializers/match_serializer.rb +++ b/app/serializers/match_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class MatchSerializer < ApplicationSerializer - attributes :state, :position, :winner, :decider + attributes :state, :position, :winner, :decider, :location, :start_time, :notes def winner ActiveModelSerializers::SerializableResource.new(object.winner).as_json diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index c2868da..5ddf768 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -139,6 +139,9 @@ class TournamentSnapshotImporter match.state = attrs.fetch(:state) match.position = attrs[:position] match.decider = attrs[:decider] + match.location = attrs[:location] + match.start_time = attrs[:start_time] + match.notes = attrs[:notes] end end diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index ee0a9ac..b4102ef 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -48,7 +48,7 @@ class TournamentSyncSchema ignored: %w[created_at updated_at sync_source_id].freeze }.freeze, Match => { - synced: %w[id state position stage_id group_id decider].freeze, + synced: %w[id state position stage_id group_id decider location start_time notes].freeze, ignored: %w[created_at updated_at sync_source_id].freeze }.freeze, MatchScore => { diff --git a/config/routes.rb b/config/routes.rb index 3176d07..ba25f79 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,11 @@ Rails.application.routes.draw do resources :team_action_lists, only: %i[create] patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' resources :statistics, only: %i[index] - resources :matches, only: %i[index] + resources :matches, only: %i[index] do + collection do + patch :metadata + end + end resources :beamers, only: %i[index show create update destroy] do collection do get :by_name diff --git a/db/migrate/20260504100000_add_match_metadata_to_matches.rb b/db/migrate/20260504100000_add_match_metadata_to_matches.rb new file mode 100644 index 0000000..4a7b131 --- /dev/null +++ b/db/migrate/20260504100000_add_match_metadata_to_matches.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddMatchMetadataToMatches < ActiveRecord::Migration[7.0] + def change + add_column :matches, :location, :string + add_column :matches, :start_time, :string + add_column :matches, :notes, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index 773e654..d700640 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false @@ -74,8 +74,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do t.datetime "created_at", precision: nil, null: false t.boolean "decider", default: false, null: false t.integer "group_id" + t.string "location" + t.text "notes" t.integer "position" t.integer "stage_id" + t.string "start_time" t.integer "state", default: 0 t.integer "sync_source_id" t.datetime "updated_at", precision: nil, null: false diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index 60c92a2..3835f84 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -47,7 +47,7 @@ RSpec.describe MatchesController, type: :controller do get :index, params: { tournament_id: @tournament.to_param } match = deserialize_response(response).first - expect(match.keys).to match_array(%i[id position state teams match_scores group stage]) + expect(match.keys).to match_array(%i[id position state location start_time notes teams match_scores group stage]) expect(match[:teams]).to all(include(:id, :name)) end @@ -196,6 +196,102 @@ RSpec.describe MatchesController, type: :controller do end end + describe 'PATCH #metadata' do + let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) } + + context 'as owner' do + before do + apply_authentication_headers_for group_stage_tournament.owner + allow(TournamentSyncEnqueue).to receive(:call) + allow(TournamentLiveBroadcast).to receive(:call) + end + + it 'updates selected matches by explicit ids' do + selected_matches = group_stage_tournament.matches.first(2) + + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + match_ids: selected_matches.map(&:id), + location: 'Court A', + start_time: '17:00', + notes: 'Bring player cards' + } + + expect(response).to be_successful + expect(selected_matches.map { |match| match.reload.location }).to all(eq('Court A')) + expect(selected_matches.map { |match| match.reload.start_time }).to all(eq('17:00')) + expect(selected_matches.map { |match| match.reload.notes }).to all(eq('Bring player cards')) + expect(deserialize_response(response).map { |match| match[:id] }).to eq(selected_matches.map(&:id)) + expect(TournamentSyncEnqueue).to have_received(:call).with(group_stage_tournament) + expect(TournamentLiveBroadcast).to have_received(:call).with(group_stage_tournament.id) + end + + it 'updates group stage matches by position filter' do + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + group_stage_position: 1, + start_time: '18:00' + } + + expect(response).to be_successful + group_stage_tournament.matches.each do |match| + expected_start_time = match.group_match? && match.position == 1 ? '18:00' : nil + expect(match.reload.start_time).to eq(expected_start_time) + end + end + + it 'updates playoff matches by stage level filter' do + playoff_tournament = create(:stage_tournament, stage_count: 3, user: group_stage_tournament.owner) + target_stage = playoff_tournament.stages.find_by!(level: 3) + + patch :metadata, params: { + tournament_id: playoff_tournament.to_param, + stage_level: target_stage.level, + start_time: '21:00' + } + + expect(response).to be_successful + playoff_tournament.matches.each do |match| + expected_start_time = match.stage == target_stage ? '21:00' : nil + expect(match.reload.start_time).to eq(expected_start_time) + end + end + + it 'requires a selector' do + patch :metadata, params: { tournament_id: group_stage_tournament.to_param, start_time: '17:00' } + + expect(response).to have_http_status(:bad_request) + expect(deserialize_response(response)[:error]).to eq('Select matches with match_ids, filters, or all=true') + end + + it 'rejects matches outside the tournament' do + other_match = create(:match) + + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + match_ids: [other_match.id], + start_time: '17:00' + } + + expect(response).to have_http_status(:not_found) + expect(deserialize_response(response)[:error]).to eq("Matches not found in tournament: #{other_match.id}") + end + + it 'rejects read only follower tournaments' do + group_stage_tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret') + + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + match_ids: [group_stage_tournament.matches.first.id], + start_time: '17:00' + } + + expect(response).to have_http_status(:locked) + expect(deserialize_response(response)[:error]).to eq('Tournament is in read only follower mode') + end + end + end + describe 'POST #update' do context 'on a running playoff match' do let(:valid_update) do @@ -237,6 +333,29 @@ RSpec.describe MatchesController, type: :controller do expect(body[:state]).to eq(valid_update[:state]) end + it 'updates metadata without changing match state' do + expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) + + put :update, params: { + id: @match.to_param, + location: 'Main Hall', + start_time: '17:00', + notes: 'Stream setup needed' + } + + expect(response).to be_successful + @match.reload + expect(@match.state).to eq('not_started') + expect(@match.location).to eq('Main Hall') + expect(@match.start_time).to eq('17:00') + expect(@match.notes).to eq('Stream setup needed') + expect(deserialize_response(response)).to include( + location: 'Main Hall', + start_time: '17:00', + notes: 'Stream setup needed' + ) + end + it 'writes a transaction log entry for the state change' do expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index aa3f87c..8ce75a2 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -50,6 +50,51 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty + log_phase!('update match metadata and verify follower mirrors it') + source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) } + single_metadata = source_owner_client.patch("/matches/#{first_group_matches.fetch(0).fetch(:id)}", body: { + location: 'Main Hall', + start_time: '17:00', + notes: 'Opening match' + }) + explicit_bulk_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: { + match_ids: first_group_matches.values_at(1, 2).map { |match| match.fetch(:id) }, + location: 'Court B', + start_time: '17:30' + }) + group_position_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: { + group_stage_position: 3, + start_time: '18:00', + notes: 'Group position 3 wave' + }) + expect(single_metadata[:status]).to eq(200) + expect(single_metadata.dig(:json, :location)).to eq('Main Hall') + expect(explicit_bulk_metadata[:status]).to eq(200) + expect(explicit_bulk_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Court B')) + expect(group_position_metadata[:status]).to eq(200) + expect(group_position_metadata[:json].map { |match| match.fetch(:start_time) }).to all(eq('18:00')) + + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + + follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + follower_first_match = follower_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first + locked_match_metadata = follower_owner_client.patch("/matches/#{follower_first_match.fetch(:id)}", body: { + location: 'Follower edit' + }) + locked_bulk_metadata = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}/matches/metadata", body: { + match_ids: [follower_first_match.fetch(:id)], + start_time: '19:00' + }) + expect(locked_match_metadata[:status]).to eq(423) + expect(locked_match_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode') + expect(locked_bulk_metadata[:status]).to eq(423) + expect(locked_bulk_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode') + log_phase!('swap matches on leader and verify follower behavior') source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first @@ -144,6 +189,20 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(source.fetch(:team_action_lists)).to eq([]) expect(follower.fetch(:team_action_lists)).to eq([]) + log_phase!('bulk update playoff match metadata and verify follower mirrors it') + source_playoff_stage = source.fetch(:stages).select { |stage| stage.fetch(:level) >= 0 }.max_by { |stage| stage.fetch(:level) } + playoff_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: { + stage_level: source_playoff_stage.fetch(:level), + location: 'Finals Arena', + start_time: '21:00', + notes: 'Playoff wave' + }) + expect(playoff_metadata[:status]).to eq(200) + expect(playoff_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Finals Arena')) + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + log_phase!('create team action list on source and verify follower mirrors it') created_list = create_team_action_list!(source.fetch(:id)) expect(created_list.fetch(:key)).to eq(playoff_token_list_key) @@ -548,6 +607,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do state: match.fetch(:state), position: match.fetch(:position), decider: match.fetch(:decider), + location: match[:location], + start_time: match[:start_time], + notes: match[:notes], scores: match.fetch(:match_scores).map do |score| { team_name: score.dig(:team, :name), diff --git a/spec/routing/matches_routing_spec.rb b/spec/routing/matches_routing_spec.rb index ac3a659..b832c37 100644 --- a/spec/routing/matches_routing_spec.rb +++ b/spec/routing/matches_routing_spec.rb @@ -15,5 +15,9 @@ RSpec.describe MatchesController, type: :routing do it 'routes to #swap' do expect(patch: '/matches/1/swap').to route_to('matches#swap', id: '1') end + + it 'routes nested tournament match metadata updates' do + expect(patch: '/tournaments/1/matches/metadata').to route_to('matches#metadata', tournament_id: '1') + end end end diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 8e5781d..7cb435f 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -26,9 +26,14 @@ RSpec.describe 'Tournament live payloads' do payload = described_class.build(tournament) serialized_match = payload.find { |candidate| candidate[:id] == match.id } - expect(serialized_match.keys).to match_array(%i[id position state teams match_scores group stage]) + expect(serialized_match.keys).to match_array( + %i[id position state location start_time notes teams match_scores group stage] + ) expect(serialized_match[:state]).to eq(match.state) expect(serialized_match[:position]).to eq(match.position) + expect(serialized_match[:location]).to eq(match.location) + expect(serialized_match[:start_time]).to eq(match.start_time) + expect(serialized_match[:notes]).to eq(match.notes) expect(serialized_match.dig(:group, :id)).to eq(match.group_id) expect(serialized_match.dig(:stage, :id)).to eq(match.group.stage_id) expect(serialized_match[:teams].map { |team| team[:id] }).to match_array(match.teams.map(&:id)) diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index b86e6fe..9947d4c 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -12,6 +12,11 @@ RSpec.describe TournamentSnapshotImporter do timer_reason: 'custom', timer_reason_text: 'Opening ceremony' ) + source.matches.first.update!( + location: 'Main Hall', + start_time: '17:00', + notes: 'Opening match' + ) source.beamers.create!( name: 'stream-a', display_state: 'during_game', @@ -37,6 +42,11 @@ RSpec.describe TournamentSnapshotImporter do expect(follower.team_action_lists.first.team_action_items.count).to eq(snapshot.fetch(:team_action_items).count) expect(follower.stages.count).to eq(source.stages.count) expect(follower.matches.count).to eq(source.matches.count) + expect(follower.matches.find { |match| match.sync_source_id == source.matches.first.id }).to have_attributes( + location: 'Main Hall', + start_time: '17:00', + notes: 'Opening match' + ) 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 @@ -136,6 +146,11 @@ RSpec.describe TournamentSnapshotImporter do tournament.reload play_some_playoff_matches!(tournament) + tournament.matches.min_by(&:id).update!( + location: 'Feature Court', + start_time: '18:30', + notes: 'Follower sync metadata fixture' + ) tournament.update!( timestamp: Time.utc(2026, 4, 24, 10, 15, 0), timer_mode: 'countdown', @@ -254,6 +269,9 @@ RSpec.describe TournamentSnapshotImporter do state: match.state, position: match.position, decider: match.decider, + location: match.location, + start_time: match.start_time, + notes: match.notes, scores: match.match_scores.includes(:team).sort_by { |score| score.team.name }.map do |score| { team_name: score.team.name, diff --git a/tasks.py b/tasks.py index 2e5efe6..e598c43 100644 --- a/tasks.py +++ b/tasks.py @@ -751,9 +751,10 @@ def start_test_local( @task -def test(c): - """Run the normal RSpec suite.""" - _stream_subprocess(_rspec_command(), env=_env()) +def test(c, spec=""): + """Run the normal RSpec suite, or one or more spec files via --spec.""" + spec_paths = shlex.split(spec) if spec else [] + _stream_subprocess(_rspec_command(*spec_paths), env=_env()) @task(name="test-shard") From 9c006e57d2f3d07627089214cb05926f76fb5465 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 17:41:13 +0200 Subject: [PATCH 2/3] docs(matches): document metadata API --- doc/match_metadata_api.md | 86 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 doc/match_metadata_api.md diff --git a/doc/match_metadata_api.md b/doc/match_metadata_api.md new file mode 100644 index 0000000..f516af8 --- /dev/null +++ b/doc/match_metadata_api.md @@ -0,0 +1,86 @@ +# Match Metadata API + +Matches can store optional scheduling/display metadata: + +- `location`: string, for venue, court, stream, or table +- `start_time`: string, for a local time like `17:00` or an agreed display value +- `notes`: free text for one-off match information + +All fields are optional and nullable. Omitted fields are left unchanged. + +## Update One Match + +Use `PATCH /matches/:id` to update metadata on one match. This endpoint still +accepts the existing `state` transitions, and metadata can be sent with or +without `state`. + +```json +{ + "location": "Main Hall", + "start_time": "17:00", + "notes": "Opening match. Stream setup needed." +} +``` + +Owner authentication is required. Read-only follower tournaments reject this +with `423 Locked`. + +## Bulk Update Matches + +Use `PATCH /tournaments/:tournament_id/matches/metadata` to update metadata for +multiple matches in one tournament. + +At least one metadata field is required: + +```json +{ + "start_time": "18:00" +} +``` + +At least one selector is required: + +- `match_ids`: explicit match ids in this tournament +- `group_stage_position`: all group stage matches at that match position +- `stage_level`: all playoff matches in that stage level +- `stage_id`: all matches in one stage +- `position`: all matches with that position +- `state`: all matches in that state, including `upcoming` +- `all: true`: every match in the tournament + +Explicit ids take precedence over filters. Missing ids outside the tournament +return `404 Not Found`. + +Examples: + +```json +{ + "match_ids": [1, 2, 5], + "start_time": "17:00" +} +``` + +```json +{ + "group_stage_position": 1, + "start_time": "18:00" +} +``` + +```json +{ + "stage_level": 3, + "location": "Finals Arena", + "start_time": "21:00", + "notes": "Playoff wave" +} +``` + +Owner authentication is required. Read-only follower tournaments reject this +with `423 Locked`. + +## Follower Sync + +`location`, `start_time`, and `notes` are part of tournament sync snapshots. +Leader updates enqueue follower sync and live tournament broadcasts. Followers +import these fields from the leader and remain read-only until takeover. From 0d466649957fbae6850c2584c0939753ee97d35b Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 17:47:12 +0200 Subject: [PATCH 3/3] fix(db): avoid duplicate migration version --- ...tches.rb => 20260504101000_add_match_metadata_to_matches.rb} | 0 db/schema.rb | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename db/migrate/{20260504100000_add_match_metadata_to_matches.rb => 20260504101000_add_match_metadata_to_matches.rb} (100%) diff --git a/db/migrate/20260504100000_add_match_metadata_to_matches.rb b/db/migrate/20260504101000_add_match_metadata_to_matches.rb similarity index 100% rename from db/migrate/20260504100000_add_match_metadata_to_matches.rb rename to db/migrate/20260504101000_add_match_metadata_to_matches.rb diff --git a/db/schema.rb b/db/schema.rb index 696dc12..46f5577 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_101000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false