jumbotron 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +5 -0
  4. data/app/adapters/jumbotron/adapters/base.rb +136 -0
  5. data/app/adapters/jumbotron/adapters/cadence.rb +42 -0
  6. data/app/adapters/jumbotron/adapters/discovery_definition.rb +7 -0
  7. data/app/adapters/jumbotron/adapters/espn/nfl.rb +169 -0
  8. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/acquisition_scope.rb +29 -0
  9. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/competition_odds.rb +185 -0
  10. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/lifecycle.rb +30 -0
  11. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/line_acquisition_scope.rb +26 -0
  12. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/progress.rb +54 -0
  13. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/scoreboard.rb +146 -0
  14. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/season_phase.rb +33 -0
  15. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/teams.rb +35 -0
  16. data/app/adapters/jumbotron/adapters/line_acquisition.rb +19 -0
  17. data/app/adapters/jumbotron/adapters/operation.rb +23 -0
  18. data/app/adapters/jumbotron/adapters/policy_definition.rb +14 -0
  19. data/app/adapters/jumbotron/adapters/registry.rb +78 -0
  20. data/app/adapters/jumbotron/adapters.rb +11 -0
  21. data/app/boot/jumbotron/boot/enqueue_discoveries.rb +41 -0
  22. data/app/clients/jumbotron/clients/espn/competition_odds/get.rb +26 -0
  23. data/app/clients/jumbotron/clients/espn/competition_odds/get_input.rb +16 -0
  24. data/app/clients/jumbotron/clients/espn/scoreboard/get.rb +18 -0
  25. data/app/clients/jumbotron/clients/espn/scoreboard/get_input.rb +17 -0
  26. data/app/clients/jumbotron/clients/espn/teams/get.rb +18 -0
  27. data/app/clients/jumbotron/clients/espn/teams/get_input.rb +15 -0
  28. data/app/clients/jumbotron/clients/espn.rb +44 -0
  29. data/app/clients/jumbotron/clients.rb +8 -0
  30. data/app/deserializers/jumbotron/deserializers/clients/espn/competition.rb +79 -0
  31. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/draw_odds.rb +31 -0
  32. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/get.rb +66 -0
  33. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/item.rb +60 -0
  34. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/listed_provider.rb +31 -0
  35. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/market_window.rb +31 -0
  36. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/point_spread.rb +27 -0
  37. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/price.rb +35 -0
  38. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/side_quote.rb +35 -0
  39. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/team_odds.rb +40 -0
  40. data/app/deserializers/jumbotron/deserializers/clients/espn/competitor.rb +49 -0
  41. data/app/deserializers/jumbotron/deserializers/clients/espn/event.rb +81 -0
  42. data/app/deserializers/jumbotron/deserializers/clients/espn/scoreboard/get.rb +88 -0
  43. data/app/deserializers/jumbotron/deserializers/clients/espn/season.rb +27 -0
  44. data/app/deserializers/jumbotron/deserializers/clients/espn/status.rb +69 -0
  45. data/app/deserializers/jumbotron/deserializers/clients/espn/support.rb +61 -0
  46. data/app/deserializers/jumbotron/deserializers/clients/espn/team.rb +42 -0
  47. data/app/deserializers/jumbotron/deserializers/clients/espn/teams/get.rb +76 -0
  48. data/app/deserializers/jumbotron/deserializers/clients/espn/venue.rb +34 -0
  49. data/app/deserializers/jumbotron/deserializers/clients/espn/week.rb +23 -0
  50. data/app/deserializers/jumbotron/deserializers/public/game.rb +26 -0
  51. data/app/deserializers/jumbotron/deserializers/public/game_ids.rb +28 -0
  52. data/app/deserializers/jumbotron/deserializers/public/includes.rb +32 -0
  53. data/app/deserializers/jumbotron/deserializers/public/schedule.rb +151 -0
  54. data/app/deserializers/jumbotron/deserializers/public/schedule_groups.rb +91 -0
  55. data/app/errors/jumbotron/error.rb +6 -0
  56. data/app/errors/jumbotron/errors/adapters.rb +143 -0
  57. data/app/errors/jumbotron/errors/canonical.rb +67 -0
  58. data/app/errors/jumbotron/errors/public.rb +27 -0
  59. data/app/errors/jumbotron/invalid_request_error.rb +6 -0
  60. data/app/errors/jumbotron/not_found_error.rb +6 -0
  61. data/app/errors/jumbotron/unsupported_capability_error.rb +6 -0
  62. data/app/jobs/jumbotron/application_job.rb +6 -0
  63. data/app/jobs/jumbotron/discovery_job.rb +14 -0
  64. data/app/jobs/jumbotron/game_update_job.rb +12 -0
  65. data/app/jobs/jumbotron/line_update_job.rb +12 -0
  66. data/app/models/jumbotron/application_record.rb +8 -0
  67. data/app/models/jumbotron/bookmaker.rb +11 -0
  68. data/app/models/jumbotron/canonical.rb +128 -0
  69. data/app/models/jumbotron/game.rb +92 -0
  70. data/app/models/jumbotron/game_participant.rb +10 -0
  71. data/app/models/jumbotron/historical_change.rb +9 -0
  72. data/app/models/jumbotron/league.rb +12 -0
  73. data/app/models/jumbotron/line_observation.rb +18 -0
  74. data/app/models/jumbotron/observation_batch.rb +14 -0
  75. data/app/models/jumbotron/provider_identity.rb +32 -0
  76. data/app/models/jumbotron/public/translate_outcome.rb +57 -0
  77. data/app/models/jumbotron/public.rb +88 -0
  78. data/app/models/jumbotron/schedule_group.rb +22 -0
  79. data/app/models/jumbotron/season.rb +13 -0
  80. data/app/models/jumbotron/season_phase.rb +12 -0
  81. data/app/models/jumbotron/sport.rb +10 -0
  82. data/app/models/jumbotron/team.rb +11 -0
  83. data/app/models/jumbotron/venue.rb +10 -0
  84. data/app/providers/jumbotron/providers/espn/cooldown.rb +88 -0
  85. data/app/providers/jumbotron/providers/espn/cooldown_active_error.rb +13 -0
  86. data/app/providers/jumbotron/providers/espn/provider_failure.rb +40 -0
  87. data/app/providers/jumbotron/providers/espn/resources/competition_odds.rb +29 -0
  88. data/app/providers/jumbotron/providers/espn/resources/scoreboard.rb +90 -0
  89. data/app/providers/jumbotron/providers/espn/resources/teams.rb +19 -0
  90. data/app/scheduling/jumbotron/scheduling/backends/sidekiq_cron.rb +70 -0
  91. data/app/scheduling/jumbotron/scheduling/backends/solid_queue.rb +92 -0
  92. data/app/scheduling/jumbotron/scheduling/clock.rb +61 -0
  93. data/app/scheduling/jumbotron/scheduling/desired_schedules.rb +51 -0
  94. data/app/scheduling/jumbotron/scheduling/detect_integration.rb +45 -0
  95. data/app/scheduling/jumbotron/scheduling/materializer.rb +13 -0
  96. data/app/scheduling/jumbotron/scheduling/owned.rb +19 -0
  97. data/app/scheduling/jumbotron/scheduling/schedule_definition.rb +7 -0
  98. data/app/serializers/jumbotron/serializers/clients/espn/competition_odds/get.rb +17 -0
  99. data/app/serializers/jumbotron/serializers/clients/espn/scoreboard/get.rb +22 -0
  100. data/app/serializers/jumbotron/serializers/clients/espn/teams/get.rb +20 -0
  101. data/app/services/jumbotron/services/adapters/build_acquisition_scopes.rb +37 -0
  102. data/app/services/jumbotron/services/adapters/build_line_acquisition_scopes.rb +39 -0
  103. data/app/services/jumbotron/services/adapters/ensure_league.rb +29 -0
  104. data/app/services/jumbotron/services/adapters/execute_endpoint.rb +49 -0
  105. data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +75 -0
  106. data/app/services/jumbotron/services/adapters/execute_synchronization_scope.rb +64 -0
  107. data/app/services/jumbotron/services/adapters/resolve_discovery.rb +53 -0
  108. data/app/services/jumbotron/services/adapters/resolve_game_update_policy.rb +74 -0
  109. data/app/services/jumbotron/services/adapters/resolve_line_update_policy.rb +74 -0
  110. data/app/services/jumbotron/services/adapters/select_eligible_games.rb +41 -0
  111. data/app/services/jumbotron/services/adapters/validate_sync_request.rb +51 -0
  112. data/app/services/jumbotron/services/canonical/attach_provider_identities.rb +59 -0
  113. data/app/services/jumbotron/services/canonical/derive_consensus_for_games.rb +18 -0
  114. data/app/services/jumbotron/services/canonical/derive_consensus_line.rb +70 -0
  115. data/app/services/jumbotron/services/canonical/ensure_bookmaker.rb +70 -0
  116. data/app/services/jumbotron/services/canonical/ensure_schedule_group.rb +40 -0
  117. data/app/services/jumbotron/services/canonical/ensure_season.rb +22 -0
  118. data/app/services/jumbotron/services/canonical/ensure_season_phase.rb +22 -0
  119. data/app/services/jumbotron/services/canonical/persist_line_observations.rb +145 -0
  120. data/app/services/jumbotron/services/canonical/record_observation_evidence.rb +51 -0
  121. data/app/services/jumbotron/services/canonical/resolve_current_lines.rb +21 -0
  122. data/app/services/jumbotron/services/canonical/resolve_current_lines_for_games.rb +69 -0
  123. data/app/services/jumbotron/services/canonical/upsert_game.rb +186 -0
  124. data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +88 -0
  125. data/app/services/jumbotron/services/canonical/upsert_team.rb +90 -0
  126. data/app/services/jumbotron/services/canonical/upsert_venue.rb +92 -0
  127. data/app/services/jumbotron/services/public/assemble_public_game.rb +131 -0
  128. data/app/services/jumbotron/services/public/assemble_public_schedule.rb +63 -0
  129. data/app/services/jumbotron/services/public/derive_consensus_for_games.rb +21 -0
  130. data/app/services/jumbotron/services/public/load_games_by_ids.rb +18 -0
  131. data/app/services/jumbotron/services/public/load_included_lines.rb +47 -0
  132. data/app/services/jumbotron/services/public/map_public_consensus_lines.rb +44 -0
  133. data/app/services/jumbotron/services/public/map_public_current_lines.rb +27 -0
  134. data/app/services/jumbotron/services/public/read_game.rb +32 -0
  135. data/app/services/jumbotron/services/public/read_schedule.rb +116 -0
  136. data/app/services/jumbotron/services/public/read_schedule_groups.rb +90 -0
  137. data/app/services/jumbotron/services/public/resolve_current_lines_for_games.rb +21 -0
  138. data/app/synchronization/jumbotron/synchronization/lease.rb +127 -0
  139. data/app/workflows/jumbotron/workflows/execute_discovery_workflow.rb +131 -0
  140. data/app/workflows/jumbotron/workflows/execute_game_update_policy_workflow.rb +159 -0
  141. data/app/workflows/jumbotron/workflows/execute_line_update_policy_workflow.rb +156 -0
  142. data/app/workflows/jumbotron/workflows/public_read.rb +29 -0
  143. data/app/workflows/jumbotron/workflows/read_consensus_workflow.rb +37 -0
  144. data/app/workflows/jumbotron/workflows/read_current_lines_workflow.rb +27 -0
  145. data/app/workflows/jumbotron/workflows/read_game_workflow.rb +34 -0
  146. data/app/workflows/jumbotron/workflows/read_schedule_groups_workflow.rb +21 -0
  147. data/app/workflows/jumbotron/workflows/read_schedule_workflow.rb +48 -0
  148. data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +183 -0
  149. data/db/migrate/20260812000001_create_jumbotron_foundational_catalog.rb +68 -0
  150. data/db/migrate/20260812000002_create_jumbotron_game_graph.rb +70 -0
  151. data/db/migrate/20260812000003_add_freshness_to_jumbotron_provider_mutated_entities.rb +31 -0
  152. data/db/migrate/20260812000004_create_jumbotron_observation_evidence.rb +34 -0
  153. data/db/migrate/20260813000001_create_jumbotron_schedule_groups.rb +24 -0
  154. data/db/migrate/20260814000001_create_jumbotron_line_observations.rb +38 -0
  155. data/db/migrate/20260820000001_add_progress_to_jumbotron_games.rb +14 -0
  156. data/lib/jumbotron/client.rb +40 -0
  157. data/lib/jumbotron/engine.rb +32 -0
  158. data/lib/jumbotron/version.rb +5 -0
  159. data/lib/jumbotron.rb +13 -0
  160. data/lib/tasks/jumbotron_schedules.rake +12 -0
  161. metadata +239 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class AssemblePublicSchedule < CommandTower::Services::ApplicationService
7
+ validate :request, required: true
8
+ validate :league, required: true
9
+ validate :public_games, required: false
10
+ validate :season, required: false
11
+ validate :season_phase, required: false
12
+ validate :schedule_group, required: false
13
+
14
+ def call
15
+ context.schedule = Jumbotron::Public::Schedule.new(
16
+ league: build_league(league),
17
+ season: build_season(season),
18
+ season_phase: build_phase(season_phase),
19
+ group: build_group(schedule_group),
20
+ starts_at: request.starts_at,
21
+ ends_at: request.ends_at,
22
+ games: Array(public_games)
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def build_league(record)
29
+ sport = Jumbotron::Public::Sport.new(id: record.sport.id, name: record.sport.name)
30
+ Jumbotron::Public::League.new(id: record.id, name: record.name, sport: sport)
31
+ end
32
+
33
+ def build_season(record)
34
+ return if record.nil?
35
+
36
+ Jumbotron::Public::Season.new(
37
+ id: record.id,
38
+ name: record.name,
39
+ starts_on: record.starts_on,
40
+ ends_on: record.ends_on
41
+ )
42
+ end
43
+
44
+ def build_phase(record)
45
+ return if record.nil?
46
+
47
+ Jumbotron::Public::SeasonPhase.new(id: record.id, name: record.name)
48
+ end
49
+
50
+ def build_group(record)
51
+ return if record.nil?
52
+
53
+ Jumbotron::Public::ScheduleGroup.new(
54
+ id: record.id,
55
+ kind: record.kind,
56
+ number: record.number,
57
+ name: record.name
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class DeriveConsensusForGames < CommandTower::Services::ApplicationService
7
+ validate :current_lines, required: false
8
+
9
+ def call
10
+ derived = Canonical::DeriveConsensusForGames.call(current_lines: current_lines)
11
+ unless derived.success?
12
+ context.fail!(application_error: derived.errors.first)
13
+ return
14
+ end
15
+
16
+ context.consensus_lines = Array(derived.data[:consensus_lines])
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class LoadGamesByIds < CommandTower::Services::ApplicationService
7
+ validate :game_ids, required: false
8
+
9
+ def call
10
+ ids = Array(game_ids)
11
+ games = Game.includes(ReadGame::ASSOCIATIONS).where(id: ids).to_a
12
+ by_id = games.index_by(&:id)
13
+ context.games = ids.filter_map { |id| by_id[id] }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class LoadIncludedLines < CommandTower::Services::ApplicationService
7
+ validate :games, required: false
8
+ validate :includes, required: false
9
+
10
+ def call
11
+ context.current_lines = []
12
+ context.consensus_lines = []
13
+ return if Array(games).empty?
14
+ return unless needs_currents? || needs_consensus?
15
+
16
+ resolved = ResolveCurrentLinesForGames.call(games: games)
17
+ unless resolved.success?
18
+ context.fail!(application_error: resolved.errors.first)
19
+ return
20
+ end
21
+ context.current_lines = Array(resolved.data[:current_lines])
22
+
23
+ return unless needs_consensus?
24
+
25
+ derived = DeriveConsensusForGames.call(current_lines: context.current_lines)
26
+ unless derived.success?
27
+ context.fail!(application_error: derived.errors.first)
28
+ return
29
+ end
30
+
31
+ context.consensus_lines = Array(derived.data[:consensus_lines])
32
+ end
33
+
34
+ private
35
+
36
+ def needs_currents?
37
+ Array(includes).include?(:current_lines)
38
+ end
39
+
40
+ def needs_consensus?
41
+ includes = Array(self.includes)
42
+ includes.include?(:consensus) || includes.include?(:consensus_constituents)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class MapPublicConsensusLines < CommandTower::Services::ApplicationService
7
+ validate :consensus_lines, required: false
8
+ validate :current_lines, required: false
9
+ validate :include_constituents, required: false
10
+
11
+ def call
12
+ context.public_consensus_lines = Array(consensus_lines).map do |line|
13
+ Jumbotron::Public::ConsensusLine.new(
14
+ game_id: line.game_id,
15
+ market: line.market,
16
+ outcome: line.outcome,
17
+ line_value: line.line_value,
18
+ constituent_count: line.constituent_count,
19
+ observed_at: observed_at_for(line),
20
+ constituents: constituents_for(line)
21
+ )
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def observed_at_for(line)
28
+ matching = Array(current_lines).select do |current|
29
+ current.game_id == line.game_id && current.market == line.market && current.outcome == line.outcome
30
+ end
31
+ matching.map(&:observed_at).compact.max
32
+ end
33
+
34
+ def constituents_for(line)
35
+ return [] unless include_constituents
36
+
37
+ Array(line.constituents).map do |constituent|
38
+ Jumbotron::Public::ConsensusConstituent.new(bookmaker_id: constituent.bookmaker_id)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class MapPublicCurrentLines < CommandTower::Services::ApplicationService
7
+ validate :current_lines, required: false
8
+
9
+ def call
10
+ context.public_current_lines = Array(current_lines).map do |line|
11
+ Jumbotron::Public::CurrentLine.new(
12
+ game_id: line.game_id,
13
+ bookmaker_id: line.bookmaker_id,
14
+ bookmaker_name: line.bookmaker_name,
15
+ market: line.market,
16
+ outcome: line.outcome,
17
+ line_value: line.line_value,
18
+ price_american: line.price_american,
19
+ observed_at: line.observed_at,
20
+ changed_at: line.changed_at
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class ReadGame < CommandTower::Services::ApplicationService
7
+ ASSOCIATIONS = [
8
+ :venue,
9
+ :season_phase,
10
+ :schedule_group,
11
+ { league: :sport, season: :league, game_participants: :team }
12
+ ].freeze
13
+
14
+ validate :game_id, required: true
15
+
16
+ def call
17
+ game = Game.includes(ASSOCIATIONS).find_by(id: game_id)
18
+ if game.nil?
19
+ context.fail!(
20
+ application_error: Jumbotron::Errors::Public::NotFoundError.new(
21
+ details: { code: "game_not_found", message: "game was not found" }
22
+ )
23
+ )
24
+ return
25
+ end
26
+
27
+ context.game = game
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class ReadSchedule < CommandTower::Services::ApplicationService
7
+ validate :request, required: true
8
+
9
+ def call
10
+ league = resolve_league
11
+ return if context.failure?
12
+
13
+ if league.nil?
14
+ not_found("league_not_found", "league was not found")
15
+ return
16
+ end
17
+
18
+ season = resolve_season(league)
19
+ return if context.failure?
20
+
21
+ phase = resolve_phase(season)
22
+ return if context.failure?
23
+
24
+ group = resolve_group(season, phase)
25
+ return if context.failure?
26
+
27
+ games = load_games(league, season, phase, group)
28
+ context.league = league
29
+ context.season = season
30
+ context.season_phase = phase
31
+ context.schedule_group = group
32
+ context.games = games
33
+ end
34
+
35
+ private
36
+
37
+ def resolve_league
38
+ return League.includes(:sport).find_by(id: request.league_id) if request.league_id.present?
39
+
40
+ sport = Sport.where("LOWER(name) = ?", request.sport.to_s.downcase).first
41
+ return if sport.nil?
42
+
43
+ sport.leagues.where("LOWER(name) = ?", request.league.to_s.downcase).first
44
+ end
45
+
46
+ def resolve_season(league)
47
+ return if request.season_id.blank? && request.season.blank?
48
+
49
+ season =
50
+ if request.season_id.present?
51
+ Season.find_by(id: request.season_id, league_id: league.id)
52
+ else
53
+ league.seasons.where("LOWER(name) = ?", request.season.to_s.downcase).first
54
+ end
55
+ not_found("season_not_found", "season was not found") if season.nil?
56
+ season
57
+ end
58
+
59
+ def resolve_phase(season)
60
+ return if request.season_phase.blank?
61
+
62
+ if season.nil?
63
+ not_found("season_not_found", "season was not found")
64
+ return
65
+ end
66
+
67
+ phase = season.season_phases.where("LOWER(name) = ?", request.season_phase.to_s.downcase).first
68
+ not_found("season_phase_not_found", "season phase was not found") if phase.nil?
69
+ phase
70
+ end
71
+
72
+ def resolve_group(season, phase)
73
+ return if request.schedule_group_id.blank? && request.group.blank?
74
+
75
+ group =
76
+ if request.schedule_group_id.present?
77
+ ScheduleGroup.find_by(id: request.schedule_group_id)
78
+ else
79
+ if season.nil? || phase.nil?
80
+ not_found("schedule_group_not_found", "schedule group was not found")
81
+ return
82
+ end
83
+ ScheduleGroup.find_by(
84
+ season_id: season.id,
85
+ season_phase_id: phase.id,
86
+ kind: request.group[:kind],
87
+ number: request.group[:number]
88
+ )
89
+ end
90
+ not_found("schedule_group_not_found", "schedule group was not found") if group.nil?
91
+ group
92
+ end
93
+
94
+ def load_games(league, season, phase, group)
95
+ relation = Game.includes(ReadGame::ASSOCIATIONS).where(league_id: league.id)
96
+ relation = relation.where(season_id: season.id) if season
97
+ relation = relation.where(season_phase_id: phase.id) if phase
98
+ if group
99
+ relation = relation.where(schedule_group_id: group.id)
100
+ elsif request.starts_at && request.ends_at
101
+ relation = relation.where(scheduled_at: request.starts_at..request.ends_at)
102
+ end
103
+ relation.order(:scheduled_at, :id).to_a
104
+ end
105
+
106
+ def not_found(code, message)
107
+ context.fail!(
108
+ application_error: Jumbotron::Errors::Public::NotFoundError.new(
109
+ details: { code: code, message: message }
110
+ )
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ # Lists observed ScheduleGroups for a resolved SeasonPhase.
7
+ # Completeness is always :incomplete until a dedicated authoritative catalog exists.
8
+ class ReadScheduleGroups < CommandTower::Services::ApplicationService
9
+ validate :request, required: true
10
+
11
+ def call
12
+ league = resolve_league
13
+ return if context.failure?
14
+
15
+ if league.nil?
16
+ not_found("league_not_found", "league was not found")
17
+ return
18
+ end
19
+
20
+ season = resolve_season(league)
21
+ return if context.failure?
22
+ return if season.nil?
23
+
24
+ phase = resolve_phase(season)
25
+ return if context.failure?
26
+ return if phase.nil?
27
+
28
+ records = load_groups(season, phase)
29
+ groups = records.map { |record| build_group(record) }
30
+
31
+ context.schedule_groups = Jumbotron::Public::ScheduleGroupEnumeration.new(
32
+ groups: groups,
33
+ completeness: :incomplete
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ def resolve_league
40
+ return League.includes(:sport).find_by(id: request.league_id) if request.league_id.present?
41
+
42
+ sport = Sport.where("LOWER(name) = ?", request.sport.to_s.downcase).first
43
+ return if sport.nil?
44
+
45
+ sport.leagues.where("LOWER(name) = ?", request.league.to_s.downcase).first
46
+ end
47
+
48
+ def resolve_season(league)
49
+ season =
50
+ if request.season_id.present?
51
+ Season.find_by(id: request.season_id, league_id: league.id)
52
+ else
53
+ league.seasons.where("LOWER(name) = ?", request.season.to_s.downcase).first
54
+ end
55
+ not_found("season_not_found", "season was not found") if season.nil?
56
+ season
57
+ end
58
+
59
+ def resolve_phase(season)
60
+ phase = season.season_phases.where("LOWER(name) = ?", request.season_phase.to_s.downcase).first
61
+ not_found("season_phase_not_found", "season phase was not found") if phase.nil?
62
+ phase
63
+ end
64
+
65
+ def load_groups(season, phase)
66
+ relation = ScheduleGroup.where(season_id: season.id, season_phase_id: phase.id)
67
+ relation = relation.where(kind: request.kind) if request.kind.present?
68
+ relation.order(Arel.sql("number IS NULL, number ASC, name ASC, id ASC")).to_a
69
+ end
70
+
71
+ def build_group(record)
72
+ Jumbotron::Public::ScheduleGroup.new(
73
+ id: record.id,
74
+ kind: record.kind,
75
+ number: record.number,
76
+ name: record.name
77
+ )
78
+ end
79
+
80
+ def not_found(code, message)
81
+ context.fail!(
82
+ application_error: Jumbotron::Errors::Public::NotFoundError.new(
83
+ details: { code: code, message: message }
84
+ )
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class ResolveCurrentLinesForGames < CommandTower::Services::ApplicationService
7
+ validate :games, required: false
8
+
9
+ def call
10
+ resolved = Canonical::ResolveCurrentLinesForGames.call(game_ids: Array(games).map(&:id))
11
+ unless resolved.success?
12
+ context.fail!(application_error: resolved.errors.first)
13
+ return
14
+ end
15
+
16
+ context.current_lines = Array(resolved.data[:current_lines])
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Jumbotron
6
+ module Synchronization
7
+ class UnsupportedStoreError < StandardError; end
8
+
9
+ # Narrow single-writer lease. Contract is backend-neutral; atomic acquire/release
10
+ # require a capable cache/store (MemoryStore or RedisCacheStore). NullStore is rejected.
11
+ class Lease
12
+ DEFAULT_TTL_SECONDS = 120
13
+ KEY_PREFIX = "jumbotron:synchronization:lease"
14
+
15
+ COMPARE_AND_DELETE_LUA = <<~LUA
16
+ if redis.call("get", KEYS[1]) == ARGV[1] then
17
+ return redis.call("del", KEYS[1])
18
+ else
19
+ return 0
20
+ end
21
+ LUA
22
+
23
+ Result = Data.define(:acquired, :token, :scope) do
24
+ def acquired?
25
+ acquired
26
+ end
27
+ end
28
+
29
+ def self.scope(adapter_id:, endpoint:, acquisition:)
30
+ parts = [
31
+ adapter_id.to_s,
32
+ endpoint.to_s,
33
+ *normalized_acquisition_parts(acquisition)
34
+ ]
35
+ parts.join(":")
36
+ end
37
+
38
+ def self.normalized_acquisition_parts(acquisition)
39
+ hash = acquisition.respond_to?(:to_h) ? acquisition.to_h : {}
40
+ hash = hash.transform_keys(&:to_s)
41
+ hash.keys.sort.map { |key| hash[key].to_s }
42
+ end
43
+
44
+ def initialize(store: Rails.cache, ttl: DEFAULT_TTL_SECONDS)
45
+ @store = store
46
+ @ttl = Integer(ttl)
47
+ assert_capable_store!
48
+ end
49
+
50
+ def acquire(scope)
51
+ assert_capable_store!
52
+ token = SecureRandom.uuid
53
+ key = cache_key(scope)
54
+ written = store.write(
55
+ key,
56
+ token,
57
+ unless_exist: true,
58
+ expires_in: ttl,
59
+ raw: true
60
+ )
61
+ if written
62
+ Result.new(acquired: true, token: token, scope: scope)
63
+ else
64
+ Result.new(acquired: false, token: nil, scope: scope)
65
+ end
66
+ end
67
+
68
+ def release(scope, token:)
69
+ assert_capable_store!
70
+ return false if token.nil?
71
+
72
+ compare_and_delete(cache_key(scope), token.to_s)
73
+ end
74
+
75
+ private
76
+
77
+ attr_reader :store, :ttl
78
+
79
+ def cache_key(scope)
80
+ "#{KEY_PREFIX}:#{scope}"
81
+ end
82
+
83
+ def assert_capable_store!
84
+ case store
85
+ when ActiveSupport::Cache::NullStore
86
+ raise UnsupportedStoreError,
87
+ "ActiveSupport::Cache::NullStore cannot satisfy Synchronization::Lease"
88
+ when ActiveSupport::Cache::MemoryStore,
89
+ ActiveSupport::Cache::RedisCacheStore
90
+ nil
91
+ else
92
+ raise UnsupportedStoreError,
93
+ "#{store.class} cannot satisfy Synchronization::Lease atomic acquire/release"
94
+ end
95
+ end
96
+
97
+ def compare_and_delete(key, token)
98
+ case store
99
+ when ActiveSupport::Cache::MemoryStore
100
+ memory_compare_and_delete(key, token)
101
+ when ActiveSupport::Cache::RedisCacheStore
102
+ redis_compare_and_delete(key, token)
103
+ else
104
+ raise UnsupportedStoreError, "#{store.class} cannot ownership-safe release"
105
+ end
106
+ end
107
+
108
+ def memory_compare_and_delete(key, token)
109
+ store.send(:synchronize) do
110
+ current = store.read(key, raw: true)
111
+ next false unless current == token
112
+
113
+ store.delete(key)
114
+ true
115
+ end
116
+ end
117
+
118
+ def redis_compare_and_delete(key, token)
119
+ normalized = store.send(:normalize_key, key, store.options)
120
+ store.redis.then do |redis|
121
+ deleted = redis.call("eval", COMPARE_AND_DELETE_LUA, 1, normalized, token)
122
+ deleted.to_i.positive?
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end