jumbotron 0.1.7 → 0.2.0

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/app/adapters/jumbotron/adapters/sandbox/nfl.rb +135 -0
  3. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/acquisition_scope.rb +74 -0
  4. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/competition_odds.rb +82 -0
  5. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/current_observation_state.rb +53 -0
  6. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/line_acquisition_scope.rb +30 -0
  7. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/monotonic_observation.rb +113 -0
  8. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/observation_values.rb +53 -0
  9. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/outcome_candidates.rb +38 -0
  10. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/projection.rb +87 -0
  11. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/scoreboard.rb +115 -0
  12. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/synthetic_observation.rb +32 -0
  13. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/timeline_observation.rb +119 -0
  14. data/app/deserializers/jumbotron/deserializers/public/register_sandbox_projection.rb +42 -0
  15. data/app/deserializers/jumbotron/deserializers/public/reset_sandbox_projection.rb +51 -0
  16. data/app/errors/jumbotron/error.rb +7 -0
  17. data/app/errors/jumbotron/errors/sandbox.rb +67 -0
  18. data/app/errors/jumbotron/sandbox_reset_rejected_error.rb +6 -0
  19. data/app/models/jumbotron/game.rb +11 -0
  20. data/app/models/jumbotron/public/translate_outcome.rb +13 -7
  21. data/app/models/jumbotron/public.rb +18 -1
  22. data/app/models/jumbotron/sandbox_game_mapping.rb +24 -0
  23. data/app/models/jumbotron/sandbox_game_plan.rb +23 -0
  24. data/app/models/jumbotron/sandbox_projection.rb +30 -0
  25. data/app/models/jumbotron/sandbox_projection_data.rb +32 -0
  26. data/app/models/jumbotron/season.rb +10 -0
  27. data/app/providers/jumbotron/providers/sandbox/resources/competition_odds.rb +24 -0
  28. data/app/providers/jumbotron/providers/sandbox/resources/projection.rb +23 -0
  29. data/app/providers/jumbotron/providers/sandbox/resources/scoreboard.rb +24 -0
  30. data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +2 -1
  31. data/app/services/jumbotron/services/canonical/upsert_game_graph.rb +126 -0
  32. data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +23 -3
  33. data/app/services/jumbotron/services/public/assemble_sandbox_projection.rb +30 -0
  34. data/app/services/jumbotron/services/public/assemble_sandbox_projection_reset.rb +49 -0
  35. data/app/services/jumbotron/services/sandbox/build_source_snapshot.rb +224 -0
  36. data/app/services/jumbotron/services/sandbox/project_game.rb +73 -0
  37. data/app/services/jumbotron/services/sandbox/register_projection.rb +76 -0
  38. data/app/services/jumbotron/services/sandbox/remove_empty_projection_geometry.rb +46 -0
  39. data/app/services/jumbotron/services/sandbox/remove_mapped_game.rb +29 -0
  40. data/app/services/jumbotron/services/sandbox/resolve_game_observation.rb +106 -0
  41. data/app/services/jumbotron/services/sandbox/seed_projected_game.rb +95 -0
  42. data/app/services/jumbotron/services/sandbox/select_line_basis.rb +69 -0
  43. data/app/services/jumbotron/services/sandbox/validate_projected_season.rb +77 -0
  44. data/app/workflows/jumbotron/workflows/public_read.rb +2 -1
  45. data/app/workflows/jumbotron/workflows/sandbox/register_projection_workflow.rb +33 -0
  46. data/app/workflows/jumbotron/workflows/sandbox/reset_projection_workflow.rb +158 -0
  47. data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +3 -51
  48. data/db/migrate/20260908000001_create_jumbotron_sandbox_game_plans.rb +16 -0
  49. data/db/migrate/20260908000002_create_jumbotron_sandbox_projections.rb +39 -0
  50. data/docs/upgrades/0.2.0.md +74 -0
  51. data/lib/jumbotron/client.rb +14 -0
  52. data/lib/jumbotron/version.rb +1 -1
  53. data/spec/factories/jumbotron/sandbox_game_mapping.rb +24 -0
  54. data/spec/factories/jumbotron/sandbox_game_plan.rb +10 -0
  55. data/spec/factories/jumbotron/sandbox_projection.rb +18 -0
  56. metadata +59 -2
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Sandbox
6
+ module Resources
7
+ class CompetitionOdds
8
+ def self.acquire(adapter:, **query)
9
+ return invalid_adapter unless adapter.provider == "sandbox"
10
+
11
+ CommandTower::Clients::ClientResult.success(output: query.symbolize_keys)
12
+ end
13
+
14
+ def self.invalid_adapter
15
+ CommandTower::Clients::ClientResult.failure(
16
+ error: ArgumentError.new("sandbox provider requires a sandbox adapter")
17
+ )
18
+ end
19
+ private_class_method :invalid_adapter
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Sandbox
6
+ module Resources
7
+ module Projection
8
+ module_function
9
+
10
+ def acquire(adapter:, **projection)
11
+ unless adapter.provider.to_s == "sandbox"
12
+ return CommandTower::Clients::ClientResult.failure(
13
+ error: ArgumentError.new("sandbox projection requires sandbox adapter")
14
+ )
15
+ end
16
+
17
+ CommandTower::Clients::ClientResult.success(output: projection)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Sandbox
6
+ module Resources
7
+ class Scoreboard
8
+ def self.acquire(adapter:, **query)
9
+ return invalid_adapter unless adapter.provider == "sandbox"
10
+
11
+ CommandTower::Clients::ClientResult.success(output: query.symbolize_keys)
12
+ end
13
+
14
+ def self.invalid_adapter
15
+ CommandTower::Clients::ClientResult.failure(
16
+ error: ArgumentError.new("sandbox provider requires a sandbox adapter")
17
+ )
18
+ end
19
+ private_class_method :invalid_adapter
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -6,6 +6,7 @@ module Jumbotron
6
6
  class ExecuteLineAcquisitionScope < CommandTower::Services::ApplicationService
7
7
  validate :adapter, required: true
8
8
  validate :acquisition, is_a: Hash, required: true
9
+ validate :observed_at, required: false
9
10
 
10
11
  def call
11
12
  if adapter.provider_cooling_down?
@@ -43,7 +44,7 @@ module Jumbotron
43
44
 
44
45
  ingest = adapter.translate_line_odds(
45
46
  provider_result.output,
46
- observed_at: Time.current,
47
+ observed_at: observed_at || Time.current,
47
48
  acquisition: acquisition
48
49
  )
49
50
  persisted = Canonical::PersistLineObservations.call(ingest: ingest)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class UpsertGameGraph < CommandTower::Services::ApplicationService
7
+ validate :game_input, required: true
8
+ validate :league, required: true
9
+ validate :observed_at, required: true
10
+ validate :change_set, required: true
11
+
12
+ def call
13
+ season = resolve(EnsureSeason.call(league: league, year: game_input.season_year), :season)
14
+ return if season.nil?
15
+
16
+ phase = resolve(
17
+ EnsureSeasonPhase.call(season: season, phase_key: game_input.season_phase_key),
18
+ :season_phase
19
+ )
20
+ return if phase.nil?
21
+
22
+ group = resolve_group(season, phase)
23
+ return if context.failure?
24
+
25
+ venue = resolve(
26
+ UpsertVenue.call(
27
+ venue_input: game_input.venue,
28
+ observed_at: observed_at,
29
+ change_set: change_set
30
+ ),
31
+ :venue,
32
+ allow_nil: true
33
+ )
34
+ return if context.failure?
35
+
36
+ game = upsert_game(season, phase, group, venue)
37
+ return if game.nil?
38
+ return unless attach_identities?(game)
39
+ return unless upsert_participants?(game)
40
+
41
+ context.game = game
42
+ end
43
+
44
+ private
45
+
46
+ def resolve_group(season, phase)
47
+ resolve(
48
+ EnsureScheduleGroup.call(
49
+ season: season,
50
+ season_phase: phase,
51
+ schedule_group_input: game_input.schedule_group
52
+ ),
53
+ :schedule_group,
54
+ allow_nil: true
55
+ )
56
+ end
57
+
58
+ def upsert_game(season, phase, group, venue)
59
+ resolve(
60
+ UpsertGame.call(
61
+ game_input: game_input,
62
+ league: league,
63
+ season: season,
64
+ season_phase: phase,
65
+ schedule_group: group,
66
+ venue: venue,
67
+ observed_at: observed_at,
68
+ change_set: change_set
69
+ ),
70
+ :game
71
+ )
72
+ end
73
+
74
+ def attach_identities?(game)
75
+ result = AttachProviderIdentities.call(target: game, refs: game_input.provider_identities)
76
+ return true if result.success?
77
+
78
+ fail_from(result)
79
+ false
80
+ end
81
+
82
+ def upsert_participants?(game)
83
+ game_input.participants.each do |participant_input|
84
+ result = UpsertGameParticipant.call(
85
+ game: game,
86
+ participant_input: participant_input,
87
+ observed_at: observed_at,
88
+ change_set: change_set,
89
+ preserve_existing_team: sandbox_game_input?
90
+ )
91
+ unless result.success?
92
+ fail_from(result)
93
+ return false
94
+ end
95
+ end
96
+ true
97
+ end
98
+
99
+ def sandbox_game_input?
100
+ game_input.provider_identities.any? { |identity| identity.provider == "sandbox" }
101
+ end
102
+
103
+ def resolve(result, key, allow_nil: false)
104
+ unless result.success?
105
+ fail_from(result)
106
+ return
107
+ end
108
+
109
+ value = result.data[key]
110
+ return value if allow_nil || !value.nil?
111
+
112
+ context.fail!(
113
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
114
+ details: { message: "#{key} was not resolved" }
115
+ )
116
+ )
117
+ nil
118
+ end
119
+
120
+ def fail_from(result)
121
+ context.fail!(application_error: Array(result.errors).first)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -8,9 +8,11 @@ module Jumbotron
8
8
  validate :participant_input, required: true
9
9
  validate :observed_at, required: true
10
10
  validate :change_set, required: true
11
+ validate :preserve_existing_team, required: false
11
12
 
12
13
  def call
13
- team_result = UpsertTeam.call(
14
+ team = existing_team if preserve_existing_team
15
+ team_result = team || UpsertTeam.call(
14
16
  team_input: Jumbotron::Canonical::TeamInput.new(
15
17
  provider_identities: participant_input.provider_identities,
16
18
  name: participant_input.team_name,
@@ -20,12 +22,20 @@ module Jumbotron
20
22
  observed_at: observed_at,
21
23
  change_set: change_set
22
24
  )
23
- unless team_result.success?
25
+ unless team
26
+ unless team_result.success?
27
+ context.fail!(application_error: team_result.errors.first)
28
+ return
29
+ end
30
+
31
+ team = team_result.data[:team]
32
+ end
33
+
34
+ if team.nil?
24
35
  context.fail!(application_error: team_result.errors.first)
25
36
  return
26
37
  end
27
38
 
28
- team = team_result.data[:team]
29
39
  participant = game.game_participants.lock.find_by(team_id: team.id)
30
40
  context.game_participant = resolve_participant(participant, team)
31
41
  rescue ActiveRecord::RecordInvalid => e
@@ -38,6 +48,16 @@ module Jumbotron
38
48
 
39
49
  private
40
50
 
51
+ def existing_team
52
+ ref = participant_input.provider_identities.first
53
+ identity = Jumbotron::ProviderIdentity.find_by(
54
+ provider: ref.provider,
55
+ object_namespace: ref.namespace,
56
+ provider_id: ref.id
57
+ )
58
+ identity&.target if identity&.target.is_a?(Jumbotron::Team)
59
+ end
60
+
41
61
  def resolve_participant(participant, team)
42
62
  if participant
43
63
  update_existing!(participant)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class AssembleSandboxProjection < CommandTower::Services::ApplicationService
7
+ validate :projection, required: true
8
+
9
+ def call
10
+ context.public_projection = Jumbotron::Public::SandboxProjection.new(
11
+ name: projection.name,
12
+ source: selector(projection.source_season, projection.source_anchor_phase.name),
13
+ sandbox: selector(projection.sandbox_season, projection.source_anchor_phase.name)
14
+ )
15
+ end
16
+
17
+ private
18
+
19
+ def selector(season, phase_name)
20
+ Jumbotron::Public::SandboxScheduleSelector.new(
21
+ sport: season.league.sport.name,
22
+ league: season.league.name,
23
+ season: season.name,
24
+ season_phase: phase_name
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class AssembleSandboxProjectionReset < CommandTower::Services::ApplicationService
7
+ validate :projection, required: true
8
+ validate :snapshot, required: true
9
+ validate :counts, required: true
10
+ validate :reset_at, required: true
11
+
12
+ def call
13
+ context.public_reset = Jumbotron::Public::SandboxProjectionReset.new(
14
+ projection: projection_identity,
15
+ source_anchor_date: snapshot.source_anchor_date,
16
+ target_anchor_date: snapshot.target_anchor_date,
17
+ date_shift_days: snapshot.date_shift_days,
18
+ projected_game_count: snapshot.games.count,
19
+ added_game_count: counts.fetch(:added),
20
+ updated_game_count: counts.fetch(:updated),
21
+ removed_game_count: counts.fetch(:removed),
22
+ start_week_game_count: counts.fetch(:start_week),
23
+ naturally_locked_game_count: counts.fetch(:naturally_locked),
24
+ reset_at: reset_at
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def projection_identity
31
+ Jumbotron::Public::SandboxProjection.new(
32
+ name: projection.name,
33
+ source: selector(projection.source_season, projection.source_anchor_phase.name),
34
+ sandbox: selector(projection.sandbox_season, projection.source_anchor_phase.name)
35
+ )
36
+ end
37
+
38
+ def selector(season, phase_name)
39
+ Jumbotron::Public::SandboxScheduleSelector.new(
40
+ sport: season.league.sport.name,
41
+ league: season.league.name,
42
+ season: season.name,
43
+ season_phase: phase_name
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ # This service validates and snapshots the complete source projection atomically.
7
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength
8
+ module Jumbotron
9
+ module Services
10
+ module Sandbox
11
+ class BuildSourceSnapshot < CommandTower::Services::ApplicationService
12
+ validate :projection, required: true
13
+ validate :start_week, required: true
14
+ validate :anchor_day, required: true
15
+ validate :now, required: true
16
+
17
+ def call
18
+ source_games = load_source_games
19
+ return if source_games.nil?
20
+
21
+ calendar = resolve_calendar(source_games)
22
+ return if calendar.nil?
23
+
24
+ raw_games = source_games.map { |game| raw_game(game) }
25
+ return if context.failure?
26
+
27
+ fingerprint = fingerprint_for(raw_games, calendar)
28
+ projected_games = raw_games.map { |raw| projected_game(raw, calendar, fingerprint) }
29
+ return if context.failure?
30
+
31
+ context.snapshot = Jumbotron::SandboxProjectionData::Snapshot.new(
32
+ games: projected_games,
33
+ source_anchor_date: calendar.fetch(:source_anchor_date),
34
+ target_anchor_date: calendar.fetch(:target_anchor_date),
35
+ date_shift_days: calendar.fetch(:date_shift_days),
36
+ fingerprint: fingerprint,
37
+ start_week: start_week
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def load_source_games
44
+ games = projection.source_season.games.includes(
45
+ :schedule_group,
46
+ :season_phase,
47
+ game_participants: { team: :provider_identities }
48
+ ).order(:scheduled_at, :id).to_a
49
+ return games if games.present? && games.all? { |game| complete_game?(game) }
50
+
51
+ reject("sandbox_projection_incomplete", "source season contains incomplete game structure")
52
+ nil
53
+ end
54
+
55
+ def complete_game?(game)
56
+ game.scheduled_at.present? &&
57
+ game.schedule_group&.kind == "week" &&
58
+ game.schedule_group.number.present? &&
59
+ game.season_phase.present? &&
60
+ %w[home away].all? { |role| participant(game, role).present? }
61
+ end
62
+
63
+ def resolve_calendar(games)
64
+ group = anchor_groups.one? ? anchor_groups.first : nil
65
+ return reject_calendar("source_week_not_found", "start week is missing or ambiguous") if group.nil?
66
+
67
+ group_games = games.select { |game| game.schedule_group_id == group.id }
68
+ return reject_calendar("source_week_not_found", "start week has no games") if group_games.empty?
69
+ return reject_calendar("sandbox_period_overlap", "source week windows overlap") unless non_overlapping?(games)
70
+
71
+ zone = ActiveSupport::TimeZone[projection.calendar_time_zone]
72
+ dates = group_games.map { |game| game.scheduled_at.in_time_zone(zone).to_date }
73
+ first_date = dates.min
74
+ last_date = dates.max
75
+ span = (last_date - first_date).to_i + 1
76
+ return reject_calendar("anchor_day_out_of_range", "anchor day exceeds source week span") if anchor_day > span
77
+
78
+ source_anchor = first_date + (anchor_day - 1)
79
+ target_anchor = now.in_time_zone(zone).to_date
80
+ {
81
+ zone: zone,
82
+ source_anchor_date: source_anchor,
83
+ target_anchor_date: target_anchor,
84
+ date_shift_days: (target_anchor - source_anchor).to_i
85
+ }
86
+ end
87
+
88
+ def anchor_groups
89
+ projection.source_anchor_phase.schedule_groups.where(
90
+ kind: "week",
91
+ number: start_week
92
+ ).to_a
93
+ end
94
+
95
+ def non_overlapping?(games)
96
+ windows = games.group_by(&:schedule_group_id).values.map do |members|
97
+ times = members.map(&:scheduled_at)
98
+ times.minmax
99
+ end.sort_by(&:first)
100
+ windows.each_cons(2).all? { |left, right| left.last < right.first }
101
+ end
102
+
103
+ def raw_game(game)
104
+ source_spread, source_total = source_consensus(game)
105
+ {
106
+ game: game,
107
+ source_spread: source_spread,
108
+ source_total: source_total,
109
+ home: team_data(participant(game, "home").team),
110
+ away: team_data(participant(game, "away").team)
111
+ }
112
+ rescue ArgumentError => e
113
+ reject("sandbox_projection_incomplete", e.message)
114
+ nil
115
+ end
116
+
117
+ def source_consensus(game)
118
+ result = Jumbotron::Services::Canonical::DeriveConsensusLine.call(game: game)
119
+ return [nil, nil] unless result.success?
120
+
121
+ lines = Array(result.data[:consensus_lines])
122
+ spread = lines.find { |line| line.market == "spread" && line.outcome == "home" }
123
+ total = lines.find { |line| line.market == "total" && line.outcome == "over" }
124
+ [spread&.line_value, total&.line_value]
125
+ end
126
+
127
+ def participant(game, role)
128
+ game.game_participants.find { |row| row.role == role }
129
+ end
130
+
131
+ def team_data(team)
132
+ identity = team.provider_identities.find do |candidate|
133
+ candidate.provider == "espn" && candidate.object_namespace == "team"
134
+ end
135
+ raise ArgumentError, "source team is missing ESPN identity" if identity.nil?
136
+
137
+ Jumbotron::SandboxProjectionData::Team.new(
138
+ provider_id: identity.provider_id,
139
+ name: team.name,
140
+ nickname: team.nickname,
141
+ abbreviation: team.abbreviation
142
+ )
143
+ end
144
+
145
+ def fingerprint_for(raw_games, calendar)
146
+ facts = raw_games.compact.map do |raw|
147
+ game = raw.fetch(:game)
148
+ [
149
+ game.id,
150
+ game.scheduled_at.iso8601,
151
+ game.season_phase.name,
152
+ game.schedule_group.number,
153
+ raw[:source_spread]&.to_s,
154
+ raw[:source_total]&.to_s
155
+ ]
156
+ end
157
+ Digest::SHA256.hexdigest(
158
+ JSON.generate(
159
+ version: 1,
160
+ projection: projection.name,
161
+ start_week: start_week,
162
+ anchor_day: anchor_day,
163
+ now: now.iso8601,
164
+ shift: calendar.fetch(:date_shift_days),
165
+ games: facts
166
+ )
167
+ )
168
+ end
169
+
170
+ def projected_game(raw, calendar, fingerprint)
171
+ game = raw.fetch(:game)
172
+ line = Jumbotron::Services::Sandbox::SelectLineBasis.call(
173
+ source_game_id: game.id,
174
+ reset_fingerprint: fingerprint,
175
+ source_spread: raw[:source_spread],
176
+ source_total: raw[:source_total]
177
+ )
178
+ unless line.success?
179
+ reject("sandbox_projection_incomplete", "sandbox line basis could not be selected")
180
+ return
181
+ end
182
+
183
+ Jumbotron::SandboxProjectionData::Game.new(
184
+ source_game_id: game.id,
185
+ event_id: "#{projection.name}:source-game:#{game.id}",
186
+ competition_id: "#{projection.name}:source-competition:#{game.id}",
187
+ scheduled_at: shift_time(game.scheduled_at, calendar),
188
+ season: projection.sandbox_season.name,
189
+ season_phase: game.season_phase.name,
190
+ week_number: game.schedule_group.number,
191
+ week_name: game.schedule_group.name,
192
+ neutral_site: game.neutral_site,
193
+ home: raw.fetch(:home),
194
+ away: raw.fetch(:away),
195
+ home_spread: line.data[:home_spread],
196
+ total: line.data[:total],
197
+ line_fingerprint: line.data[:line_fingerprint],
198
+ line_source: line.data[:line_source]
199
+ )
200
+ end
201
+
202
+ def shift_time(time, calendar)
203
+ time.in_time_zone(calendar.fetch(:zone))
204
+ .advance(days: calendar.fetch(:date_shift_days))
205
+ .utc
206
+ end
207
+
208
+ def reject_calendar(code, message)
209
+ reject(code, message)
210
+ nil
211
+ end
212
+
213
+ def reject(code, message)
214
+ context.fail!(
215
+ application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
216
+ details: { code: code, message: message }
217
+ )
218
+ )
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Sandbox
6
+ class ProjectGame < CommandTower::Services::ApplicationService
7
+ validate :projection, required: true
8
+ validate :projected_game, required: true
9
+ validate :observed_at, required: true
10
+ validate :change_set, required: true
11
+
12
+ def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
13
+ executed = Jumbotron::Services::Adapters::ExecuteEndpoint.call(
14
+ adapter: Jumbotron::Adapters::Sandbox::Nfl,
15
+ endpoint: :projection,
16
+ observed_at: observed_at,
17
+ acquisition: acquisition
18
+ )
19
+ return fail_from(executed) unless executed.success?
20
+
21
+ input = Array(executed.data[:sync_input]&.games).one? ? executed.data[:sync_input].games.first : nil
22
+ return reject("projection endpoint did not return one game") if input.nil?
23
+
24
+ graph = Jumbotron::Services::Canonical::UpsertGameGraph.call(
25
+ game_input: input,
26
+ league: projection.sandbox_season.league,
27
+ observed_at: observed_at,
28
+ change_set: change_set
29
+ )
30
+ return fail_from(graph) unless graph.success?
31
+
32
+ mapping = projection.sandbox_game_mappings.find_or_initialize_by(
33
+ source_game_id: projected_game.source_game_id
34
+ )
35
+ context.created = mapping.new_record?
36
+ mapping.assign_attributes(
37
+ sandbox_game: graph.data[:game],
38
+ home_spread: projected_game.home_spread,
39
+ total: projected_game.total,
40
+ line_fingerprint: projected_game.line_fingerprint
41
+ )
42
+ mapping.save!
43
+ context.mapping = mapping
44
+ context.game = graph.data[:game]
45
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
46
+ reject(e.message)
47
+ end
48
+
49
+ private
50
+
51
+ def acquisition
52
+ projected_game.to_h.merge(
53
+ scheduled_at: projected_game.scheduled_at.iso8601,
54
+ home: projected_game.home.to_h,
55
+ away: projected_game.away.to_h
56
+ )
57
+ end
58
+
59
+ def fail_from(result)
60
+ context.fail!(application_error: Array(result.errors).first)
61
+ end
62
+
63
+ def reject(message)
64
+ context.fail!(
65
+ application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
66
+ details: { code: "sandbox_projection_incomplete", message: message }
67
+ )
68
+ )
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end