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,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class ResolveCurrentLinesForGames < CommandTower::Services::ApplicationService
7
+ GRAIN = %i[game_id bookmaker_id market outcome source].freeze
8
+
9
+ validate :game_ids, required: false
10
+
11
+ def call
12
+ ids = Array(game_ids).map(&:to_i).uniq
13
+ if ids.empty?
14
+ context.current_lines = []
15
+ context.current_lines_by_game_id = {}
16
+ return
17
+ end
18
+
19
+ lines = latest_observed(ids).map { |row| to_current_line(row) }
20
+ context.current_lines = lines
21
+ context.current_lines_by_game_id = lines.group_by(&:game_id)
22
+ end
23
+
24
+ private
25
+
26
+ def latest_observed(ids)
27
+ scope = LineObservation.where(game_id: ids, source: "observed")
28
+ observation_ids = scope.joins(latest_observed_at_join(scope)).group(*GRAIN).maximum(:id).values
29
+ return [] if observation_ids.empty?
30
+
31
+ LineObservation.where(id: observation_ids).includes(:bookmaker).to_a
32
+ end
33
+
34
+ def latest_observed_at_join(scope)
35
+ observations = LineObservation.arel_table
36
+ max_observed = scope.select(
37
+ *GRAIN,
38
+ observations[:observed_at].maximum.as("observed_at")
39
+ ).group(*GRAIN)
40
+
41
+ <<~SQL.squish
42
+ INNER JOIN (#{max_observed.to_sql}) latest
43
+ ON jumbotron_line_observations.game_id = latest.game_id
44
+ AND jumbotron_line_observations.bookmaker_id = latest.bookmaker_id
45
+ AND jumbotron_line_observations.market = latest.market
46
+ AND jumbotron_line_observations.outcome = latest.outcome
47
+ AND jumbotron_line_observations.source = latest.source
48
+ AND jumbotron_line_observations.observed_at = latest.observed_at
49
+ SQL
50
+ end
51
+
52
+ def to_current_line(row)
53
+ Jumbotron::Canonical::CurrentLine.new(
54
+ line_observation_id: row.id,
55
+ game_id: row.game_id,
56
+ bookmaker_id: row.bookmaker_id,
57
+ bookmaker_name: row.bookmaker.name,
58
+ market: row.market,
59
+ outcome: row.outcome,
60
+ line_value: row.line_value,
61
+ price_american: row.price_american,
62
+ observed_at: row.observed_at,
63
+ changed_at: row.changed_at
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ # Persists the Game row only. Callers supply resolved season/phase/venue.
7
+ class UpsertGame < CommandTower::Services::ApplicationService
8
+ MATERIAL_ATTRS = %i[
9
+ season_id
10
+ season_phase_id
11
+ schedule_group_id
12
+ scheduled_at
13
+ lifecycle
14
+ venue_id
15
+ neutral_site
16
+ progress_state
17
+ progress_segment_kind
18
+ progress_segment_number
19
+ progress_clock_mode
20
+ ].freeze
21
+
22
+ SILENT_ATTRS = %i[
23
+ progress_clock_seconds
24
+ progress_clock_display
25
+ ].freeze
26
+
27
+ validate :game_input, required: true
28
+ validate :league, required: true
29
+ validate :season, required: true
30
+ validate :season_phase, required: true
31
+ validate :schedule_group, required: false
32
+ validate :venue, required: false
33
+ validate :observed_at, required: true
34
+ validate :change_set, required: true
35
+
36
+ def call
37
+ game = find_game
38
+ context.game = resolve_game(game)
39
+ rescue ActiveRecord::RecordInvalid => e
40
+ context.fail!(
41
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
42
+ details: { message: e.message }
43
+ )
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ def resolve_game(game)
50
+ if game
51
+ update_existing!(Game.lock.find(game.id))
52
+ else
53
+ create_new!
54
+ end
55
+ end
56
+
57
+ def find_game
58
+ game_input.provider_identities.each do |ref|
59
+ identity = ProviderIdentity.find_by(
60
+ provider: ref.provider,
61
+ object_namespace: ref.namespace,
62
+ provider_id: ref.id
63
+ )
64
+ return identity.target if identity&.target.is_a?(Game)
65
+ end
66
+ nil
67
+ end
68
+
69
+ def create_new!
70
+ game = Game.create!(create_attrs)
71
+ record_create_history!(game)
72
+ game
73
+ end
74
+
75
+ def create_attrs
76
+ {
77
+ league: league,
78
+ season: season,
79
+ season_phase: season_phase,
80
+ schedule_group: schedule_group,
81
+ venue: venue,
82
+ scheduled_at: game_input.scheduled_at,
83
+ lifecycle: game_input.lifecycle,
84
+ neutral_site: game_input.neutral_site.nil? ? false : game_input.neutral_site,
85
+ observed_at: observed_at,
86
+ changed_at: observed_at
87
+ }.merge(progress_column_values)
88
+ end
89
+
90
+ def record_create_history!(game)
91
+ change_set.record(subject: game, attribute: "lifecycle", previous: nil, new_value: game.lifecycle)
92
+ change_set.record(
93
+ subject: game,
94
+ attribute: "scheduled_at",
95
+ previous: nil,
96
+ new_value: game.scheduled_at&.iso8601
97
+ )
98
+ MATERIAL_ATTRS.grep(/\Aprogress_/).each do |key|
99
+ value = game.public_send(key)
100
+ next if value.nil?
101
+
102
+ change_set.record(subject: game, attribute: key.to_s, previous: nil, new_value: serialize(value))
103
+ end
104
+ end
105
+
106
+ def update_existing!(game)
107
+ attrs = {
108
+ season_id: season.id,
109
+ season_phase_id: season_phase.id,
110
+ schedule_group_id: schedule_group&.id,
111
+ scheduled_at: game_input.scheduled_at,
112
+ lifecycle: game_input.lifecycle
113
+ }
114
+ attrs[:venue_id] = venue&.id unless game_input.venue.nil?
115
+ attrs[:neutral_site] = game_input.neutral_site unless game_input.neutral_site.nil?
116
+ attrs.merge!(progress_column_values)
117
+
118
+ material = apply_material_attrs!(game, attrs)
119
+ apply_silent_attrs!(game, attrs)
120
+
121
+ game.observed_at = observed_at
122
+ game.changed_at = observed_at if material
123
+ game.save!
124
+ game
125
+ end
126
+
127
+ def apply_material_attrs!(game, attrs)
128
+ material = false
129
+ attrs.slice(*MATERIAL_ATTRS).each do |key, value|
130
+ current = game.public_send(key)
131
+ next if serialize(current) == serialize(value)
132
+
133
+ change_set.record(
134
+ subject: game,
135
+ attribute: key.to_s,
136
+ previous: serialize(current),
137
+ new_value: serialize(value)
138
+ )
139
+ game.public_send("#{key}=", value)
140
+ material = true
141
+ end
142
+ material
143
+ end
144
+
145
+ def apply_silent_attrs!(game, attrs)
146
+ attrs.slice(*SILENT_ATTRS).each do |key, value|
147
+ game.public_send("#{key}=", value)
148
+ end
149
+ end
150
+
151
+ def progress_column_values
152
+ progress = game_input.progress
153
+ if progress.nil?
154
+ {
155
+ progress_state: nil,
156
+ progress_segment_kind: nil,
157
+ progress_segment_number: nil,
158
+ progress_clock_mode: nil,
159
+ progress_clock_seconds: nil,
160
+ progress_clock_display: nil
161
+ }
162
+ else
163
+ clock = progress.clock
164
+ {
165
+ progress_state: progress.state,
166
+ progress_segment_kind: progress.segment.kind,
167
+ progress_segment_number: progress.segment.number,
168
+ progress_clock_mode: clock&.mode,
169
+ progress_clock_seconds: clock&.seconds,
170
+ progress_clock_display: clock&.display
171
+ }
172
+ end
173
+ end
174
+
175
+ def serialize(value)
176
+ case value
177
+ when Time, ActiveSupport::TimeWithZone
178
+ value.iso8601
179
+ else
180
+ value
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class UpsertGameParticipant < CommandTower::Services::ApplicationService
7
+ validate :game, required: true
8
+ validate :participant_input, required: true
9
+ validate :observed_at, required: true
10
+ validate :change_set, required: true
11
+
12
+ def call
13
+ team_result = UpsertTeam.call(
14
+ team_input: Jumbotron::Canonical::TeamInput.new(
15
+ provider_identities: participant_input.provider_identities,
16
+ name: participant_input.team_name
17
+ ),
18
+ observed_at: observed_at,
19
+ change_set: change_set
20
+ )
21
+ unless team_result.success?
22
+ context.fail!(application_error: team_result.errors.first)
23
+ return
24
+ end
25
+
26
+ team = team_result.data[:team]
27
+ participant = game.game_participants.lock.find_by(team_id: team.id)
28
+ context.game_participant = resolve_participant(participant, team)
29
+ rescue ActiveRecord::RecordInvalid => e
30
+ context.fail!(
31
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
32
+ details: { message: e.message }
33
+ )
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ def resolve_participant(participant, team)
40
+ if participant
41
+ update_existing!(participant)
42
+ else
43
+ create_new!(team)
44
+ end
45
+ end
46
+
47
+ def create_new!(team)
48
+ gp = game.game_participants.create!(
49
+ team: team,
50
+ role: participant_input.role,
51
+ score: participant_input.score,
52
+ result: participant_input.result,
53
+ observed_at: observed_at,
54
+ changed_at: observed_at
55
+ )
56
+ change_set.record(subject: gp, attribute: "role", previous: nil, new_value: gp.role)
57
+ change_set.record(subject: gp, attribute: "score", previous: nil, new_value: gp.score)
58
+ gp
59
+ end
60
+
61
+ def update_existing!(participant)
62
+ material = false
63
+ {
64
+ role: participant_input.role,
65
+ score: participant_input.score,
66
+ result: participant_input.result
67
+ }.each do |key, value|
68
+ current = participant.public_send(key)
69
+ next if current == value
70
+
71
+ change_set.record(
72
+ subject: participant,
73
+ attribute: key.to_s,
74
+ previous: current,
75
+ new_value: value
76
+ )
77
+ participant.public_send("#{key}=", value)
78
+ material = true
79
+ end
80
+ participant.observed_at = observed_at
81
+ participant.changed_at = observed_at if material
82
+ participant.save!
83
+ participant
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class UpsertTeam < CommandTower::Services::ApplicationService
7
+ validate :team_input, required: true
8
+ validate :observed_at, required: true
9
+ validate :change_set, required: true
10
+
11
+ def call
12
+ ref = team_input.provider_identities.first
13
+ identity = ProviderIdentity.find_by(
14
+ provider: ref.provider,
15
+ object_namespace: ref.namespace,
16
+ provider_id: ref.id
17
+ )
18
+
19
+ team = resolve_team(identity)
20
+
21
+ attach = AttachProviderIdentities.call(target: team, refs: team_input.provider_identities)
22
+ unless attach.success?
23
+ context.fail!(application_error: attach.errors.first)
24
+ return
25
+ end
26
+
27
+ context.team = team
28
+ rescue ActiveRecord::RecordNotUnique
29
+ context.team = recover_after_race!
30
+ rescue ActiveRecord::RecordInvalid => e
31
+ context.fail!(
32
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
33
+ details: { message: e.message }
34
+ )
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ def resolve_team(identity)
41
+ if identity&.target.is_a?(Team)
42
+ update_existing!(identity.target_id)
43
+ else
44
+ create_new!
45
+ end
46
+ end
47
+
48
+ def update_existing!(team_id)
49
+ team = Team.lock.find(team_id)
50
+ if team.name != team_input.name
51
+ change_set.record(
52
+ subject: team,
53
+ attribute: "name",
54
+ previous: team.name,
55
+ new_value: team_input.name
56
+ )
57
+ team.name = team_input.name
58
+ team.changed_at = observed_at
59
+ end
60
+ team.observed_at = observed_at
61
+ team.save!
62
+ team
63
+ end
64
+
65
+ def create_new!
66
+ team = Team.create!(
67
+ name: team_input.name,
68
+ observed_at: observed_at,
69
+ changed_at: observed_at
70
+ )
71
+ change_set.record(subject: team, attribute: "name", previous: nil, new_value: team.name)
72
+ team
73
+ end
74
+
75
+ def recover_after_race!
76
+ ref = team_input.provider_identities.first
77
+ identity = ProviderIdentity.find_by!(
78
+ provider: ref.provider,
79
+ object_namespace: ref.namespace,
80
+ provider_id: ref.id
81
+ )
82
+ team = Team.lock.find(identity.target_id)
83
+ team.observed_at = observed_at
84
+ team.save!
85
+ team
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class UpsertVenue < CommandTower::Services::ApplicationService
7
+ validate :venue_input, required: false
8
+ validate :observed_at, required: true
9
+ validate :change_set, required: true
10
+
11
+ def call
12
+ if venue_input.nil?
13
+ context.venue = nil
14
+ return
15
+ end
16
+
17
+ ref = venue_input.provider_identities.first
18
+ identity = ProviderIdentity.find_by(
19
+ provider: ref.provider,
20
+ object_namespace: ref.namespace,
21
+ provider_id: ref.id
22
+ )
23
+
24
+ venue = resolve_venue(identity)
25
+
26
+ attach = AttachProviderIdentities.call(target: venue, refs: venue_input.provider_identities)
27
+ unless attach.success?
28
+ context.fail!(application_error: attach.errors.first)
29
+ return
30
+ end
31
+
32
+ context.venue = venue
33
+ rescue ActiveRecord::RecordNotUnique
34
+ context.venue = recover_after_race!
35
+ rescue ActiveRecord::RecordInvalid => e
36
+ context.fail!(
37
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
38
+ details: { message: e.message }
39
+ )
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def resolve_venue(identity)
46
+ if identity&.target.is_a?(Venue)
47
+ update_existing!(identity.target_id)
48
+ else
49
+ create_new!
50
+ end
51
+ end
52
+
53
+ def update_existing!(venue_id)
54
+ venue = Venue.lock.find(venue_id)
55
+ if venue.name != venue_input.name
56
+ change_set.record(
57
+ subject: venue,
58
+ attribute: "name",
59
+ previous: venue.name,
60
+ new_value: venue_input.name
61
+ )
62
+ venue.name = venue_input.name
63
+ venue.changed_at = observed_at
64
+ end
65
+ venue.observed_at = observed_at
66
+ venue.save!
67
+ venue
68
+ end
69
+
70
+ def create_new!
71
+ venue = Venue.create!(
72
+ name: venue_input.name,
73
+ observed_at: observed_at,
74
+ changed_at: observed_at
75
+ )
76
+ change_set.record(subject: venue, attribute: "name", previous: nil, new_value: venue.name)
77
+ venue
78
+ end
79
+
80
+ def recover_after_race!
81
+ ref = venue_input.provider_identities.first
82
+ identity = ProviderIdentity.find_by!(
83
+ provider: ref.provider,
84
+ object_namespace: ref.namespace,
85
+ provider_id: ref.id
86
+ )
87
+ Venue.lock.find(identity.target_id)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class AssemblePublicGame < CommandTower::Services::ApplicationService
7
+ validate :game, required: true
8
+ validate :includes, required: false
9
+ validate :current_lines, required: false
10
+ validate :consensus_lines, required: false
11
+
12
+ def call
13
+ context.public_game = build_game(game)
14
+ end
15
+
16
+ private
17
+
18
+ def build_game(record)
19
+ game_currents = Array(current_lines).select { |line| line.game_id == record.id }
20
+ game_consensus = Array(consensus_lines).select { |line| line.game_id == record.id }
21
+ Jumbotron::Public::Game.new(
22
+ id: record.id,
23
+ scheduled_at: record.scheduled_at,
24
+ lifecycle: record.lifecycle,
25
+ progress: build_progress(record),
26
+ neutral_site: record.neutral_site,
27
+ observed_at: record.observed_at,
28
+ changed_at: record.changed_at,
29
+ venue: build_venue(record.venue),
30
+ participants: record.game_participants.map { |participant| build_participant(participant) },
31
+ consensus_lines: map_consensus(game_consensus, game_currents),
32
+ current_lines: map_currents(game_currents)
33
+ )
34
+ end
35
+
36
+ def build_progress(record)
37
+ return if record.progress_state.blank?
38
+
39
+ Jumbotron::Public::GameProgress.new(
40
+ state: record.progress_state,
41
+ segment: Jumbotron::Public::GameSegment.new(
42
+ kind: record.progress_segment_kind,
43
+ number: record.progress_segment_number
44
+ ),
45
+ clock: build_clock(record)
46
+ )
47
+ end
48
+
49
+ def build_clock(record)
50
+ return if record.progress_clock_mode.nil? &&
51
+ record.progress_clock_seconds.nil? &&
52
+ record.progress_clock_display.nil?
53
+
54
+ Jumbotron::Public::GameClock.new(
55
+ mode: record.progress_clock_mode,
56
+ seconds: record.progress_clock_seconds,
57
+ display: record.progress_clock_display
58
+ )
59
+ end
60
+
61
+ def build_venue(venue)
62
+ return if venue.nil?
63
+
64
+ Jumbotron::Public::Venue.new(id: venue.id, name: venue.name)
65
+ end
66
+
67
+ def build_participant(participant)
68
+ Jumbotron::Public::Participant.new(
69
+ role: participant.role,
70
+ score: participant.score,
71
+ result: participant.result,
72
+ team: Jumbotron::Public::Team.new(id: participant.team.id, name: participant.team.name)
73
+ )
74
+ end
75
+
76
+ def map_currents(lines)
77
+ return [] unless Array(includes).include?(:current_lines)
78
+
79
+ lines.map do |line|
80
+ Jumbotron::Public::CurrentLine.new(
81
+ game_id: line.game_id,
82
+ bookmaker_id: line.bookmaker_id,
83
+ bookmaker_name: line.bookmaker_name,
84
+ market: line.market,
85
+ outcome: line.outcome,
86
+ line_value: line.line_value,
87
+ price_american: line.price_american,
88
+ observed_at: line.observed_at,
89
+ changed_at: line.changed_at
90
+ )
91
+ end
92
+ end
93
+
94
+ def map_consensus(lines, currents)
95
+ return [] unless consensus_requested?
96
+
97
+ with_constituents = Array(includes).include?(:consensus_constituents)
98
+ lines.map do |line|
99
+ Jumbotron::Public::ConsensusLine.new(
100
+ game_id: line.game_id,
101
+ market: line.market,
102
+ outcome: line.outcome,
103
+ line_value: line.line_value,
104
+ constituent_count: line.constituent_count,
105
+ observed_at: consensus_observed_at(line, currents),
106
+ constituents: with_constituents ? map_constituents(line) : []
107
+ )
108
+ end
109
+ end
110
+
111
+ def consensus_requested?
112
+ includes = Array(self.includes)
113
+ includes.include?(:consensus) || includes.include?(:consensus_constituents)
114
+ end
115
+
116
+ def consensus_observed_at(line, currents)
117
+ currents.select { |current| current.market == line.market && current.outcome == line.outcome }
118
+ .map(&:observed_at)
119
+ .compact
120
+ .max
121
+ end
122
+
123
+ def map_constituents(line)
124
+ Array(line.constituents).map do |constituent|
125
+ Jumbotron::Public::ConsensusConstituent.new(bookmaker_id: constituent.bookmaker_id)
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end