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,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Workflows
5
+ class ExecuteDiscoveryWorkflow < CommandTower::Workflows::ApplicationWorkflow
6
+ retry_strategy :delayed_continuation, max_attempts: 24
7
+
8
+ CONTENTION_WAIT_SECONDS = 5
9
+
10
+ def call(adapter_id:, discovery_id:)
11
+ resolved = Services::Adapters::ResolveDiscovery.call(
12
+ adapter_id: adapter_id,
13
+ discovery_id: discovery_id
14
+ )
15
+ return map_resolution_failure(resolved) unless resolved.success?
16
+
17
+ adapter = resolved.data[:adapter]
18
+ definition = resolved.data[:definition]
19
+ ensured = Services::Adapters::EnsureLeague.call(adapter: adapter)
20
+ return map_resolution_failure(ensured) unless ensured.success?
21
+
22
+ league = ensured.data[:league]
23
+ return cooldown_deferred(adapter) if adapter.provider_cooling_down?
24
+
25
+ expanded = adapter.scopes_for_discovery(definition)
26
+ return map_expand_failure(adapter, expanded) unless expanded.success?
27
+
28
+ scopes = Array(expanded.output).map do |acquisition|
29
+ { endpoint: definition.endpoint, acquisition: acquisition }
30
+ end
31
+ return empty_scopes_failure(adapter, definition) if scopes.empty?
32
+
33
+ run_scopes(adapter, league, scopes, discovery_id)
34
+ end
35
+
36
+ private
37
+
38
+ def run_scopes(adapter, league, scopes, discovery_id)
39
+ scopes.each do |scope|
40
+ return cooldown_deferred(adapter) if adapter.provider_cooling_down?
41
+
42
+ executed = Services::Adapters::ExecuteSynchronizationScope.call(
43
+ adapter: adapter,
44
+ league: league,
45
+ endpoint: scope.fetch(:endpoint),
46
+ acquisition: scope.fetch(:acquisition)
47
+ )
48
+ classified = classify_scope(adapter, executed)
49
+ return classified unless classified.nil?
50
+ end
51
+
52
+ success(
53
+ payload: { adapter_id: adapter.adapter_id, discovery_id: discovery_id.to_s },
54
+ http_status: :ok
55
+ )
56
+ end
57
+
58
+ def classify_scope(adapter, executed)
59
+ unless executed.success?
60
+ return failure(
61
+ errors: [{ code: "discovery_execution_failed", message: "synchronization scope failed" }],
62
+ http_status: :unprocessable_entity,
63
+ meta: { failure_class: "mutation" }
64
+ )
65
+ end
66
+
67
+ data = executed.data
68
+ case data[:outcome]
69
+ when :succeeded
70
+ nil
71
+ when :contention
72
+ deferred(reason: :lease_contention, retry_after: CONTENTION_WAIT_SECONDS)
73
+ when :cooldown_active
74
+ cooldown_deferred(adapter)
75
+ else
76
+ classify_failed_scope(adapter, data)
77
+ end
78
+ end
79
+
80
+ def classify_failed_scope(adapter, data)
81
+ return cooldown_deferred(adapter) if data[:failure_class] == "acquisition"
82
+
83
+ failure(
84
+ errors: [{ code: "discovery_execution_failed", message: "canonical or transform failure" }],
85
+ http_status: :unprocessable_entity,
86
+ meta: { failure_class: data[:failure_class] || "mutation" }
87
+ )
88
+ end
89
+
90
+ def map_expand_failure(adapter, expanded)
91
+ return cooldown_deferred(adapter) if adapter.provider_cooling_down?
92
+
93
+ error = expanded.respond_to?(:error) ? expanded.error : nil
94
+ failure(
95
+ errors: [{
96
+ code: "discovery_expansion_failed",
97
+ message: error.respond_to?(:message) ? error.message : "discovery expansion failed"
98
+ }],
99
+ http_status: :unprocessable_entity,
100
+ meta: { failure_class: "validation" }
101
+ )
102
+ end
103
+
104
+ def empty_scopes_failure(adapter, definition)
105
+ failure(
106
+ errors: [{
107
+ code: "discovery_expansion_failed",
108
+ message: "discovery #{definition.id} produced no acquisition scopes"
109
+ }],
110
+ http_status: :unprocessable_entity,
111
+ meta: { failure_class: "validation", adapter_id: adapter.adapter_id }
112
+ )
113
+ end
114
+
115
+ def cooldown_deferred(adapter)
116
+ wait = [Integer(adapter.provider_retry_after), 1].max
117
+ deferred(reason: :provider_cooldown, retry_after: wait)
118
+ end
119
+
120
+ def map_resolution_failure(result)
121
+ error = Array(result.errors).first
122
+ code = error.respond_to?(:code) ? error.code : "unknown_adapter"
123
+ failure(
124
+ errors: [{ code: code, message: error.respond_to?(:message) ? error.message : error.to_s }],
125
+ http_status: :unprocessable_entity,
126
+ meta: { failure_class: "validation" }
127
+ )
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Workflows
5
+ class ExecuteGameUpdatePolicyWorkflow < CommandTower::Workflows::ApplicationWorkflow
6
+ retry_strategy :scheduled_cadence
7
+
8
+ def call(adapter_id:, policy_id:)
9
+ now = Time.current
10
+ resolved = Services::Adapters::ResolveGameUpdatePolicy.call(
11
+ adapter_id: adapter_id,
12
+ policy_id: policy_id
13
+ )
14
+ return map_resolution_failure(resolved) unless resolved.success?
15
+
16
+ adapter = resolved.data[:adapter]
17
+ policy = resolved.data[:policy]
18
+ selected = Services::Adapters::SelectEligibleGames.call(
19
+ adapter: adapter,
20
+ policy: policy,
21
+ now: now
22
+ )
23
+ return map_selection_failure(selected) unless selected.success?
24
+
25
+ league = selected.data[:league]
26
+ games = selected.data[:games]
27
+ built = Services::Adapters::BuildAcquisitionScopes.call(adapter: adapter, games: games)
28
+ return map_selection_failure(built) unless built.success?
29
+
30
+ scopes = built.data[:scopes]
31
+ counts = blank_counts.merge(
32
+ eligible_games: games.size,
33
+ unique_scopes: scopes.size,
34
+ skipped_incomplete_scope: built.data[:skipped_incomplete_scope]
35
+ )
36
+
37
+ return success_payload(adapter, policy, counts) if scopes.empty?
38
+
39
+ run_scopes(adapter, league, scopes, counts)
40
+ finish(adapter, policy, counts)
41
+ end
42
+
43
+ private
44
+
45
+ def run_scopes(adapter, league, scopes, counts)
46
+ scopes.each_with_index do |scope, index|
47
+ if adapter.provider_cooling_down?
48
+ counts[:skipped_cooldown] += scopes.size - index
49
+ break
50
+ end
51
+
52
+ handle_scope(adapter, league, scope, counts)
53
+ next unless counts[:stop_remaining]
54
+
55
+ remaining = scopes.size - index - 1
56
+ counts[:skipped_cooldown] += remaining if remaining.positive?
57
+ break
58
+ end
59
+ counts.delete(:stop_remaining)
60
+ end
61
+
62
+ def handle_scope(adapter, league, scope, counts)
63
+ executed = Services::Adapters::ExecuteSynchronizationScope.call(
64
+ adapter: adapter,
65
+ league: league,
66
+ endpoint: scope.fetch(:endpoint),
67
+ acquisition: scope.fetch(:acquisition)
68
+ )
69
+ unless executed.success?
70
+ counts[:failed_scopes] += 1
71
+ counts[:failure_class] ||= "mutation"
72
+ return
73
+ end
74
+
75
+ record_scope_outcome(executed.data, counts)
76
+ end
77
+
78
+ def record_scope_outcome(data, counts)
79
+ case data[:outcome]
80
+ when :succeeded
81
+ counts[:succeeded_scopes] += 1
82
+ when :contention
83
+ counts[:skipped_contention] += 1
84
+ when :cooldown_active
85
+ counts[:skipped_cooldown] += 1
86
+ counts[:stop_remaining] = true
87
+ else
88
+ counts[:failed_scopes] += 1
89
+ counts[:failure_class] ||= data[:failure_class]
90
+ counts[:stop_remaining] = true if data[:failure_class] == "acquisition"
91
+ end
92
+ end
93
+
94
+ def finish(adapter, policy, counts)
95
+ payload = result_payload(adapter, policy, counts)
96
+ if counts[:failed_scopes].positive?
97
+ return failure(
98
+ errors: [{ code: "policy_execution_failed", message: "one or more scopes failed" }],
99
+ http_status: counts[:failure_class] == "acquisition" ? :bad_gateway : :unprocessable_entity,
100
+ meta: { failure_class: counts[:failure_class] || "mutation", execution: payload }
101
+ )
102
+ end
103
+
104
+ success(payload: payload, http_status: :ok)
105
+ end
106
+
107
+ def success_payload(adapter, policy, counts)
108
+ success(payload: result_payload(adapter, policy, counts), http_status: :ok)
109
+ end
110
+
111
+ def result_payload(adapter, policy, counts)
112
+ {
113
+ adapter_id: adapter.adapter_id,
114
+ policy_id: policy.id.to_s,
115
+ eligible_games: counts[:eligible_games],
116
+ unique_scopes: counts[:unique_scopes],
117
+ succeeded_scopes: counts[:succeeded_scopes],
118
+ skipped_contention: counts[:skipped_contention],
119
+ skipped_cooldown: counts[:skipped_cooldown],
120
+ skipped_incomplete_scope: counts[:skipped_incomplete_scope],
121
+ failed_scopes: counts[:failed_scopes]
122
+ }
123
+ end
124
+
125
+ def blank_counts
126
+ {
127
+ succeeded_scopes: 0,
128
+ skipped_contention: 0,
129
+ skipped_cooldown: 0,
130
+ skipped_incomplete_scope: 0,
131
+ failed_scopes: 0
132
+ }
133
+ end
134
+
135
+ def map_resolution_failure(result)
136
+ error = Array(result.errors).first
137
+ code = error.respond_to?(:code) ? error.code : "unknown_adapter"
138
+ failure(
139
+ errors: [{ code: code, message: error.respond_to?(:message) ? error.message : error.to_s }],
140
+ http_status: :unprocessable_entity,
141
+ meta: { failure_class: "validation" }
142
+ )
143
+ end
144
+
145
+ def map_selection_failure(result)
146
+ error = Array(result.errors).first
147
+ if error.is_a?(CommandTower::Errors::ApplicationError)
148
+ return failure(
149
+ errors: [error],
150
+ http_status: :unprocessable_entity,
151
+ meta: { failure_class: "validation" }
152
+ )
153
+ end
154
+
155
+ map_resolution_failure(result)
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Workflows
5
+ class ExecuteLineUpdatePolicyWorkflow < CommandTower::Workflows::ApplicationWorkflow
6
+ retry_strategy :scheduled_cadence
7
+
8
+ def call(adapter_id:, policy_id:)
9
+ now = Time.current
10
+ resolved = Services::Adapters::ResolveLineUpdatePolicy.call(
11
+ adapter_id: adapter_id,
12
+ policy_id: policy_id
13
+ )
14
+ return map_resolution_failure(resolved) unless resolved.success?
15
+
16
+ adapter = resolved.data[:adapter]
17
+ policy = resolved.data[:policy]
18
+ selected = Services::Adapters::SelectEligibleGames.call(
19
+ adapter: adapter,
20
+ policy: policy,
21
+ now: now
22
+ )
23
+ return map_selection_failure(selected) unless selected.success?
24
+
25
+ games = selected.data[:games]
26
+ built = Services::Adapters::BuildLineAcquisitionScopes.call(adapter: adapter, games: games)
27
+ return map_selection_failure(built) unless built.success?
28
+
29
+ scopes = built.data[:scopes]
30
+ counts = blank_counts.merge(
31
+ eligible_games: games.size,
32
+ unique_scopes: scopes.size,
33
+ skipped_incomplete_scope: built.data[:skipped_incomplete_scope]
34
+ )
35
+
36
+ return success_payload(adapter, policy, counts) if scopes.empty?
37
+
38
+ run_scopes(adapter, scopes, counts)
39
+ finish(adapter, policy, counts)
40
+ end
41
+
42
+ private
43
+
44
+ def run_scopes(adapter, scopes, counts)
45
+ scopes.each_with_index do |scope, index|
46
+ if adapter.provider_cooling_down?
47
+ counts[:skipped_cooldown] += scopes.size - index
48
+ break
49
+ end
50
+
51
+ handle_scope(adapter, scope, counts)
52
+ next unless counts[:stop_remaining]
53
+
54
+ remaining = scopes.size - index - 1
55
+ counts[:skipped_cooldown] += remaining if remaining.positive?
56
+ break
57
+ end
58
+ counts.delete(:stop_remaining)
59
+ end
60
+
61
+ def handle_scope(adapter, scope, counts)
62
+ executed = Services::Adapters::ExecuteLineAcquisitionScope.call(
63
+ adapter: adapter,
64
+ acquisition: scope.fetch(:acquisition)
65
+ )
66
+ unless executed.success?
67
+ counts[:failed_scopes] += 1
68
+ counts[:failure_class] ||= "mutation"
69
+ return
70
+ end
71
+
72
+ record_scope_outcome(executed.data, counts)
73
+ end
74
+
75
+ def record_scope_outcome(data, counts)
76
+ case data[:outcome]
77
+ when :succeeded
78
+ counts[:succeeded_scopes] += 1
79
+ when :contention
80
+ counts[:skipped_contention] += 1
81
+ when :cooldown_active
82
+ counts[:skipped_cooldown] += 1
83
+ counts[:stop_remaining] = true
84
+ else
85
+ counts[:failed_scopes] += 1
86
+ counts[:failure_class] ||= data[:failure_class]
87
+ counts[:stop_remaining] = true if data[:failure_class] == "acquisition"
88
+ end
89
+ end
90
+
91
+ def finish(adapter, policy, counts)
92
+ payload = result_payload(adapter, policy, counts)
93
+ if counts[:failed_scopes].positive?
94
+ return failure(
95
+ errors: [{ code: "policy_execution_failed", message: "one or more scopes failed" }],
96
+ http_status: counts[:failure_class] == "acquisition" ? :bad_gateway : :unprocessable_entity,
97
+ meta: { failure_class: counts[:failure_class] || "mutation", execution: payload }
98
+ )
99
+ end
100
+
101
+ success(payload: payload, http_status: :ok)
102
+ end
103
+
104
+ def success_payload(adapter, policy, counts)
105
+ success(payload: result_payload(adapter, policy, counts), http_status: :ok)
106
+ end
107
+
108
+ def result_payload(adapter, policy, counts)
109
+ {
110
+ adapter_id: adapter.adapter_id,
111
+ policy_id: policy.id.to_s,
112
+ eligible_games: counts[:eligible_games],
113
+ unique_scopes: counts[:unique_scopes],
114
+ succeeded_scopes: counts[:succeeded_scopes],
115
+ skipped_contention: counts[:skipped_contention],
116
+ skipped_cooldown: counts[:skipped_cooldown],
117
+ skipped_incomplete_scope: counts[:skipped_incomplete_scope],
118
+ failed_scopes: counts[:failed_scopes]
119
+ }
120
+ end
121
+
122
+ def blank_counts
123
+ {
124
+ succeeded_scopes: 0,
125
+ skipped_contention: 0,
126
+ skipped_cooldown: 0,
127
+ skipped_incomplete_scope: 0,
128
+ failed_scopes: 0
129
+ }
130
+ end
131
+
132
+ def map_resolution_failure(result)
133
+ error = Array(result.errors).first
134
+ code = error.respond_to?(:code) ? error.code : "unknown_adapter"
135
+ failure(
136
+ errors: [{ code: code, message: error.respond_to?(:message) ? error.message : error.to_s }],
137
+ http_status: :unprocessable_entity,
138
+ meta: { failure_class: "validation" }
139
+ )
140
+ end
141
+
142
+ def map_selection_failure(result)
143
+ error = Array(result.errors).first
144
+ if error.is_a?(CommandTower::Errors::ApplicationError)
145
+ return failure(
146
+ errors: [error],
147
+ http_status: :unprocessable_entity,
148
+ meta: { failure_class: "validation" }
149
+ )
150
+ end
151
+
152
+ map_resolution_failure(result)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Workflows
5
+ module PublicRead
6
+ def map_deserializer_failure(deserialized)
7
+ error = Array(deserialized.errors).first
8
+ message =
9
+ if error.is_a?(Hash)
10
+ error[:message].presence || error[:code].to_s
11
+ else
12
+ error.to_s
13
+ end
14
+ failure(
15
+ errors: [{ code: "invalid_request", message: message.presence || "invalid_request" }],
16
+ http_status: :unprocessable_entity
17
+ )
18
+ end
19
+
20
+ def map_service_failure(result)
21
+ error = Array(result.errors).first
22
+ code = error.respond_to?(:code) ? error.code.to_s : "invalid_request"
23
+ message = error.respond_to?(:message) ? error.message : error.to_s
24
+ status = Public::TranslateOutcome::NOT_FOUND_CODES.include?(code) ? :not_found : :unprocessable_entity
25
+ failure(errors: [{ code: code, message: message }], http_status: status)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Workflows
5
+ class ReadConsensusWorkflow < CommandTower::Workflows::ApplicationWorkflow
6
+ include PublicRead
7
+
8
+ retry_strategy :none
9
+
10
+ def call(**kwargs)
11
+ deserialized = Deserializers::Public::GameIds.call(kwargs)
12
+ return map_deserializer_failure(deserialized) unless deserialized.success?
13
+
14
+ loaded = Services::Public::LoadGamesByIds.call(game_ids: deserialized.input.game_ids)
15
+ return map_service_failure(loaded) unless loaded.success?
16
+
17
+ games = loaded.data[:games]
18
+ resolved = Services::Public::ResolveCurrentLinesForGames.call(games: games)
19
+ return map_service_failure(resolved) unless resolved.success?
20
+
21
+ derived = Services::Public::DeriveConsensusForGames.call(
22
+ current_lines: resolved.data[:current_lines]
23
+ )
24
+ return map_service_failure(derived) unless derived.success?
25
+
26
+ mapped = Services::Public::MapPublicConsensusLines.call(
27
+ consensus_lines: derived.data[:consensus_lines],
28
+ current_lines: resolved.data[:current_lines],
29
+ include_constituents: false
30
+ )
31
+ return map_service_failure(mapped) unless mapped.success?
32
+
33
+ success(payload: { consensus_lines: mapped.data[:public_consensus_lines] }, http_status: :ok)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Workflows
5
+ class ReadCurrentLinesWorkflow < CommandTower::Workflows::ApplicationWorkflow
6
+ include PublicRead
7
+
8
+ retry_strategy :none
9
+
10
+ def call(**kwargs)
11
+ deserialized = Deserializers::Public::GameIds.call(kwargs)
12
+ return map_deserializer_failure(deserialized) unless deserialized.success?
13
+
14
+ loaded = Services::Public::LoadGamesByIds.call(game_ids: deserialized.input.game_ids)
15
+ return map_service_failure(loaded) unless loaded.success?
16
+
17
+ resolved = Services::Public::ResolveCurrentLinesForGames.call(games: loaded.data[:games])
18
+ return map_service_failure(resolved) unless resolved.success?
19
+
20
+ mapped = Services::Public::MapPublicCurrentLines.call(current_lines: resolved.data[:current_lines])
21
+ return map_service_failure(mapped) unless mapped.success?
22
+
23
+ success(payload: { current_lines: mapped.data[:public_current_lines] }, http_status: :ok)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Workflows
5
+ class ReadGameWorkflow < CommandTower::Workflows::ApplicationWorkflow
6
+ include PublicRead
7
+
8
+ retry_strategy :none
9
+
10
+ def call(**kwargs)
11
+ deserialized = Deserializers::Public::Game.call(kwargs)
12
+ return map_deserializer_failure(deserialized) unless deserialized.success?
13
+
14
+ request = deserialized.input
15
+ read = Services::Public::ReadGame.call(game_id: request.id)
16
+ return map_service_failure(read) unless read.success?
17
+
18
+ game = read.data[:game]
19
+ included = Services::Public::LoadIncludedLines.call(games: [game], includes: request.includes)
20
+ return map_service_failure(included) unless included.success?
21
+
22
+ assembled = Services::Public::AssemblePublicGame.call(
23
+ game: game,
24
+ includes: request.includes,
25
+ current_lines: included.data[:current_lines],
26
+ consensus_lines: included.data[:consensus_lines]
27
+ )
28
+ return map_service_failure(assembled) unless assembled.success?
29
+
30
+ success(payload: { game: assembled.data[:public_game] }, http_status: :ok)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Workflows
5
+ class ReadScheduleGroupsWorkflow < CommandTower::Workflows::ApplicationWorkflow
6
+ include PublicRead
7
+
8
+ retry_strategy :none
9
+
10
+ def call(**kwargs)
11
+ deserialized = Deserializers::Public::ScheduleGroups.call(kwargs)
12
+ return map_deserializer_failure(deserialized) unless deserialized.success?
13
+
14
+ read = Services::Public::ReadScheduleGroups.call(request: deserialized.input)
15
+ return map_service_failure(read) unless read.success?
16
+
17
+ success(payload: { schedule_groups: read.data[:schedule_groups] }, http_status: :ok)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Workflows
5
+ class ReadScheduleWorkflow < CommandTower::Workflows::ApplicationWorkflow
6
+ include PublicRead
7
+
8
+ retry_strategy :none
9
+
10
+ def call(**kwargs)
11
+ deserialized = Deserializers::Public::Schedule.call(kwargs)
12
+ return map_deserializer_failure(deserialized) unless deserialized.success?
13
+
14
+ request = deserialized.input
15
+ read = Services::Public::ReadSchedule.call(request: request)
16
+ return map_service_failure(read) unless read.success?
17
+
18
+ games = Array(read.data[:games])
19
+ included = Services::Public::LoadIncludedLines.call(games: games, includes: request.includes)
20
+ return map_service_failure(included) unless included.success?
21
+
22
+ public_games = games.map do |game|
23
+ assembled = Services::Public::AssemblePublicGame.call(
24
+ game: game,
25
+ includes: request.includes,
26
+ current_lines: included.data[:current_lines],
27
+ consensus_lines: included.data[:consensus_lines]
28
+ )
29
+ return map_service_failure(assembled) unless assembled.success?
30
+
31
+ assembled.data[:public_game]
32
+ end
33
+
34
+ schedule = Services::Public::AssemblePublicSchedule.call(
35
+ request: request,
36
+ league: read.data[:league],
37
+ season: read.data[:season],
38
+ season_phase: read.data[:season_phase],
39
+ schedule_group: read.data[:schedule_group],
40
+ public_games: public_games
41
+ )
42
+ return map_service_failure(schedule) unless schedule.success?
43
+
44
+ success(payload: { schedule: schedule.data[:schedule] }, http_status: :ok)
45
+ end
46
+ end
47
+ end
48
+ end