spree_cm_commissioner 2.4.2 → 2.5.0.pre.pre1
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.
- checksums.yaml +4 -4
- data/.github/workflows/test_and_build_gem.yml +1 -1
- data/.tool-versions +1 -1
- data/Gemfile.lock +1 -1
- data/README.md +32 -0
- data/app/controllers/spree/admin/integration_mappings_controller.rb +21 -0
- data/app/controllers/spree/admin/integration_sessions_controller.rb +21 -0
- data/app/controllers/spree/admin/integrations_controller.rb +83 -0
- data/app/controllers/spree/api/v2/storefront/event_matches_controller.rb +15 -0
- data/app/controllers/spree/api/v2/storefront/tenants_controller.rb +30 -0
- data/app/errors/spree_cm_commissioner/integrations/external_client_error.rb +10 -0
- data/app/errors/spree_cm_commissioner/integrations/sync_error.rb +4 -0
- data/app/finders/spree_cm_commissioner/events/find_matches.rb +15 -0
- data/app/helpers/spree_cm_commissioner/external_integrations_helper.rb +58 -0
- data/app/interactors/spree_cm_commissioner/stock/inventory_item_resetter.rb +1 -1
- data/app/interactors/spree_cm_commissioner/stock/stock_movement_creator.rb +4 -2
- data/app/jobs/spree_cm_commissioner/integrations/base_job.rb +39 -0
- data/app/jobs/spree_cm_commissioner/integrations/polling_job.rb +53 -0
- data/app/jobs/spree_cm_commissioner/integrations/polling_scheduler_job.rb +30 -0
- data/app/jobs/spree_cm_commissioner/stock/inventory_items_adjuster_job.rb +5 -4
- data/app/jobs/spree_cm_commissioner/telegram_alerts/integration_sync_failure_job.rb +17 -0
- data/app/models/concerns/spree_cm_commissioner/integrations/integration_mappable.rb +58 -0
- data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +15 -3
- data/app/models/concerns/spree_cm_commissioner/store_metadata.rb +14 -2
- data/app/models/concerns/spree_cm_commissioner/tenant_preference.rb +2 -0
- data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +1 -7
- data/app/models/spree_cm_commissioner/integration.rb +21 -0
- data/app/models/spree_cm_commissioner/integration_mapping.rb +37 -0
- data/app/models/spree_cm_commissioner/integration_sync_session.rb +15 -0
- data/app/models/spree_cm_commissioner/integrations/stadium_x_v1.rb +21 -0
- data/app/models/spree_cm_commissioner/inventory_item.rb +5 -1
- data/app/models/spree_cm_commissioner/option_type_decorator.rb +8 -0
- data/app/models/spree_cm_commissioner/option_value_decorator.rb +34 -0
- data/app/models/spree_cm_commissioner/product_decorator.rb +3 -2
- data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +2 -2
- data/app/models/spree_cm_commissioner/redis_stock/inventory_updater.rb +2 -2
- data/app/models/spree_cm_commissioner/taxon_decorator.rb +1 -0
- data/app/models/spree_cm_commissioner/taxonomy_decorator.rb +13 -1
- data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -4
- data/app/models/spree_cm_commissioner/vendor_decorator.rb +1 -0
- data/app/overrides/spree/admin/shared/sub_menu/_integrations/external_integrations.html.erb.deface +6 -0
- data/app/presenters/spree/variants/{visable_options_presenter.rb → visible_options_presenter.rb} +2 -4
- data/app/serializers/spree/v2/storefront/tenant_serializer.rb +14 -0
- data/app/services/spree_cm_commissioner/integrations/base/sync_manager.rb +69 -0
- data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +183 -0
- data/app/services/spree_cm_commissioner/integrations/polling.rb +70 -0
- data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +79 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/external_client/client.rb +152 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_matches.rb +113 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_zones.rb +215 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/base.rb +20 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/league.rb +19 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/match.rb +95 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket.rb +81 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket_image.rb +19 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/zone.rb +90 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_manager.rb +35 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/full_sync_strategy.rb +38 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/incremental_sync_strategy.rb +44 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/webhook_sync_strategy.rb +16 -0
- data/app/services/spree_cm_commissioner/telegram_alerts/integration_sync_failure.rb +49 -0
- data/app/views/spree/admin/integration_mappings/_integration_mappings.html.erb +107 -0
- data/app/views/spree/admin/integration_mappings/index.html.erb +33 -0
- data/app/views/spree/admin/integration_sessions/_integration_sync_sessions.html.erb +116 -0
- data/app/views/spree/admin/integration_sessions/index.html.erb +42 -0
- data/app/views/spree/admin/integrations/_form.html.erb +104 -0
- data/app/views/spree/admin/integrations/_stadium_x_v1_fields.html.erb +29 -0
- data/app/views/spree/admin/integrations/edit.html.erb +45 -0
- data/app/views/spree/admin/integrations/index.html.erb +75 -0
- data/app/views/spree/admin/integrations/new.html.erb +25 -0
- data/app/views/spree/admin/tenants/_form.html.erb +79 -36
- data/config/locales/en.yml +8 -0
- data/config/locales/km.yml +8 -0
- data/config/routes.rb +9 -1
- data/db/migrate/20251017094845_create_cm_integrations.rb +22 -0
- data/db/migrate/20251017101555_create_cm_integration_sync_sessions.rb +68 -0
- data/db/migrate/20251017101605_create_cm_integration_mappings.rb +52 -0
- data/lib/cm_app_logger.rb +1 -0
- data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +25 -0
- data/lib/spree_cm_commissioner/test_helper/factories/integration_mapping_factory.rb +14 -0
- data/lib/spree_cm_commissioner/test_helper/factories/integration_sync_session_factory.rb +7 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- data/lib/spree_cm_commissioner.rb +8 -7
- metadata +58 -5
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
module SpreeCmCommissioner::Integrations::Base
|
|
2
|
+
# Base class for sync results
|
|
3
|
+
# Provides standardized tracking methods for API calls and record changes
|
|
4
|
+
#
|
|
5
|
+
# Persists to database as JSONB in CmIntegrationSyncSession#sync_result column
|
|
6
|
+
# Enables comprehensive sync monitoring and debugging:
|
|
7
|
+
# - Track success/failure status
|
|
8
|
+
# - Monitor API call counts and patterns
|
|
9
|
+
# - Audit record creation/update counts
|
|
10
|
+
# - Capture error details with automatic error codes
|
|
11
|
+
# - Generate sync performance reports
|
|
12
|
+
# - Identify patterns in failed syncs
|
|
13
|
+
#
|
|
14
|
+
# Example persisted results:
|
|
15
|
+
#
|
|
16
|
+
# Success:
|
|
17
|
+
# {
|
|
18
|
+
# "metrics": { "match": { "created": 2, "updated": 1, "total": 3 } },
|
|
19
|
+
# "api_calls": { "get_matches": 1, "get_match_details": 3 }
|
|
20
|
+
# }
|
|
21
|
+
#
|
|
22
|
+
# Failure:
|
|
23
|
+
# {
|
|
24
|
+
# "metrics": { "match": { "created": 1, "total": 1 } },
|
|
25
|
+
# "api_calls": { "get_matches": 1 },
|
|
26
|
+
# "error": { "message": "Failed to sync matches: Connection timeout", "code": "ExternalClientError" }
|
|
27
|
+
# }
|
|
28
|
+
class SyncResult
|
|
29
|
+
def initialize
|
|
30
|
+
@metrics = {}
|
|
31
|
+
@api_calls = {}
|
|
32
|
+
@error = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Track API call with block
|
|
36
|
+
# Automatically increments the call count and executes the block
|
|
37
|
+
# Failed calls (exceptions) are not counted
|
|
38
|
+
#
|
|
39
|
+
# @param call_name [String] Name of the API call (e.g., 'get_matches', 'get_zones')
|
|
40
|
+
# @yield Block containing the API call
|
|
41
|
+
# @return Result of the block
|
|
42
|
+
#
|
|
43
|
+
# Example:
|
|
44
|
+
# external_matches = track_api_call('get_matches') { @client.get_matches! }
|
|
45
|
+
def track_api_call(call_name)
|
|
46
|
+
@api_calls[call_name] = (@api_calls[call_name] || 0) + 1
|
|
47
|
+
yield
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Track synced record with block
|
|
51
|
+
# Automatically detects if record was created or updated based on new_record? and saved_changes
|
|
52
|
+
#
|
|
53
|
+
# @param type [Symbol, String] Type of record (e.g., :match, :zone)
|
|
54
|
+
# @param record [Object] The record being synced (must respond to new_record? and saved_changes)
|
|
55
|
+
# @yield Block containing the save/update logic
|
|
56
|
+
# @return Result of the block
|
|
57
|
+
#
|
|
58
|
+
# Example:
|
|
59
|
+
# track_synced_record(:match, match_taxon) do
|
|
60
|
+
# match_taxon.assign_attributes(name: 'ISI Dangkorsenchey FC vs ANGKOR TIGER FC')
|
|
61
|
+
# match_taxon.save!
|
|
62
|
+
# end
|
|
63
|
+
def track_synced_record(type, record)
|
|
64
|
+
was_new = record.new_record?
|
|
65
|
+
result = yield
|
|
66
|
+
|
|
67
|
+
if was_new
|
|
68
|
+
increment_metric(type, :created)
|
|
69
|
+
elsif record.saved_changes.any?
|
|
70
|
+
increment_metric(type, :updated)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Track and sync record with explicit tracker object
|
|
77
|
+
# Provides a tracker object with helper methods for explicit save/tracking operations
|
|
78
|
+
#
|
|
79
|
+
# @param type [Symbol, String] Type of record (e.g., :league, :match, :zone, :variant)
|
|
80
|
+
# @param record [Object] The record being synced (must respond to new_record? and saved_changes)
|
|
81
|
+
# @yield Block receiving tracker object for explicit operations
|
|
82
|
+
# @return Result of the block
|
|
83
|
+
#
|
|
84
|
+
# Example:
|
|
85
|
+
# track(:zone, product) do |tracker|
|
|
86
|
+
# product.assign_attributes(name: 'Zone A', price: 100)
|
|
87
|
+
# tracker.save_if_changed!(product)
|
|
88
|
+
# end
|
|
89
|
+
def track(type, record)
|
|
90
|
+
tracker = RecordTracker.new(self, type, record)
|
|
91
|
+
yield tracker
|
|
92
|
+
tracker
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Helper class for explicit record tracking and saving
|
|
96
|
+
class RecordTracker
|
|
97
|
+
def initialize(sync_result, type, record)
|
|
98
|
+
@sync_result = sync_result
|
|
99
|
+
@type = type
|
|
100
|
+
@record = record
|
|
101
|
+
@was_new = record.new_record?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Save record if it's new or has changes, and track the operation
|
|
105
|
+
# Automatically detects create vs update based on initial state
|
|
106
|
+
#
|
|
107
|
+
# @param record [Object] The record to save (defaults to the tracked record)
|
|
108
|
+
def save_if_changed!(record = @record)
|
|
109
|
+
return unless record.new_record? || record.changed?
|
|
110
|
+
|
|
111
|
+
record.save!
|
|
112
|
+
|
|
113
|
+
# Track based on initial state
|
|
114
|
+
if @was_new
|
|
115
|
+
@sync_result.increment_metric(@type, :created)
|
|
116
|
+
else
|
|
117
|
+
@sync_result.increment_metric(@type, :updated)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Increment a metric counter
|
|
123
|
+
# Useful for tracking actions that don't fit the created/updated pattern
|
|
124
|
+
#
|
|
125
|
+
# @param type [Symbol, String] Type of metric (e.g., :match, :inventory)
|
|
126
|
+
# @param action [Symbol, String] Action performed (e.g., :archived, :adjustments)
|
|
127
|
+
# @param amount [Integer] Amount to increment by (default: 1)
|
|
128
|
+
#
|
|
129
|
+
# Example:
|
|
130
|
+
# increment_metric(:match, :archived) # increment by 1
|
|
131
|
+
# increment_metric(:inventory, :total_quantity_adjusted, 50) # increment by 50
|
|
132
|
+
def increment_metric(type, action, amount = 1)
|
|
133
|
+
@metrics[type.to_sym] ||= {}
|
|
134
|
+
@metrics[type.to_sym][action.to_sym] ||= 0
|
|
135
|
+
@metrics[type.to_sym][action.to_sym] += amount
|
|
136
|
+
|
|
137
|
+
update_metric_total(type)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Record error
|
|
141
|
+
# @param error [StandardError] The error that occurred
|
|
142
|
+
def record_error(error)
|
|
143
|
+
@error = {
|
|
144
|
+
message: error.message,
|
|
145
|
+
code: error.class.name.split('::').last
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Check if sync was successful
|
|
150
|
+
# @return [Boolean] True if no error, false otherwise
|
|
151
|
+
def success?
|
|
152
|
+
@error.nil?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Serialize to hash for JSONB persistence
|
|
156
|
+
# @return [Hash] Serialized representation
|
|
157
|
+
def to_h
|
|
158
|
+
{
|
|
159
|
+
metrics: @metrics,
|
|
160
|
+
api_calls: @api_calls,
|
|
161
|
+
error: @error
|
|
162
|
+
}.tap do |hash|
|
|
163
|
+
hash.delete(:error) if @error.nil?
|
|
164
|
+
hash.delete(:metrics) if @metrics.empty?
|
|
165
|
+
hash.delete(:api_calls) if @api_calls.empty?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# Update total for a metric type
|
|
172
|
+
# Sums all numeric values in the metric hash
|
|
173
|
+
def update_metric_total(type)
|
|
174
|
+
metric = @metrics[type.to_sym]
|
|
175
|
+
return unless metric.is_a?(Hash)
|
|
176
|
+
|
|
177
|
+
# Calculate total from numeric values only
|
|
178
|
+
numeric_values = metric.select { |k, v| v.is_a?(Integer) && k != :total }
|
|
179
|
+
total = numeric_values.values.sum
|
|
180
|
+
metric[:total] = total if total.positive?
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Integrations
|
|
3
|
+
class Polling
|
|
4
|
+
def call(integration:, sync_type:, event_type: nil, event_data: nil)
|
|
5
|
+
sync_manager = integration.sync_manager
|
|
6
|
+
|
|
7
|
+
case sync_type.to_sym
|
|
8
|
+
when :full
|
|
9
|
+
perform_full_sync!(sync_manager, integration)
|
|
10
|
+
when :incremental
|
|
11
|
+
perform_incremental_sync!(sync_manager, integration)
|
|
12
|
+
when :webhook_triggered
|
|
13
|
+
perform_webhook_sync!(sync_manager, integration, event_type, event_data)
|
|
14
|
+
else
|
|
15
|
+
raise ArgumentError, "Unknown sync type: #{sync_type}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# Perform a full sync
|
|
22
|
+
# @param sync_manager [Object] The integration's sync manager
|
|
23
|
+
# @param integration [SpreeCmCommissioner::Integration]
|
|
24
|
+
def perform_full_sync!(sync_manager, integration)
|
|
25
|
+
CmAppLogger.log(
|
|
26
|
+
label: 'Integrations::IntegrationPullJob#perform_full_sync! Starting full sync',
|
|
27
|
+
data: {
|
|
28
|
+
integration_id: integration.id,
|
|
29
|
+
integration_type: integration.class.name
|
|
30
|
+
}
|
|
31
|
+
) do
|
|
32
|
+
sync_manager.sync_full!
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Perform an incremental sync
|
|
37
|
+
# @param sync_manager [Object] The integration's sync manager
|
|
38
|
+
# @param integration [SpreeCmCommissioner::Integration]
|
|
39
|
+
def perform_incremental_sync!(sync_manager, integration)
|
|
40
|
+
CmAppLogger.log(
|
|
41
|
+
label: 'Integrations::IntegrationPullJob#perform_incremental_sync! Starting incremental sync',
|
|
42
|
+
data: {
|
|
43
|
+
integration_id: integration.id,
|
|
44
|
+
integration_type: integration.class.name
|
|
45
|
+
}
|
|
46
|
+
) do
|
|
47
|
+
sync_manager.sync_incremental!
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Perform a webhook-triggered sync
|
|
52
|
+
# @param sync_manager [Object] The integration's sync manager
|
|
53
|
+
# @param integration [SpreeCmCommissioner::Integration]
|
|
54
|
+
# @param event_type [String] The event type
|
|
55
|
+
# @param event_data [Hash] The event data
|
|
56
|
+
def perform_webhook_sync!(sync_manager, integration, event_type, event_data)
|
|
57
|
+
CmAppLogger.log(
|
|
58
|
+
label: 'Integrations::IntegrationPullJob#perform_webhook_sync! Starting webhook sync',
|
|
59
|
+
data: {
|
|
60
|
+
integration_id: integration.id,
|
|
61
|
+
integration_type: integration.class.name,
|
|
62
|
+
event_type: event_type
|
|
63
|
+
}
|
|
64
|
+
) do
|
|
65
|
+
sync_manager.sync_webhook!(event_type: event_type, event_data: event_data)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Integrations
|
|
3
|
+
class PollingScheduler
|
|
4
|
+
BATCH_SIZE = 10 # Process integrations in batches to avoid overwhelming the server
|
|
5
|
+
|
|
6
|
+
def call
|
|
7
|
+
SpreeCmCommissioner::Integration.active.find_each(batch_size: BATCH_SIZE) do |integration|
|
|
8
|
+
if should_run_full_sync?(integration)
|
|
9
|
+
schedule_full_sync(integration)
|
|
10
|
+
elsif should_run_incremental_sync?(integration)
|
|
11
|
+
schedule_incremental_sync(integration)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Check if full sync should run
|
|
19
|
+
# Run full sync if:
|
|
20
|
+
# 1. No full sync is running or pending (excluding sessions older than 1 hour)
|
|
21
|
+
# 2. No previous full sync exists, OR
|
|
22
|
+
# 3. Last full sync was more than the configured interval ago (eg. 24 hours ago)
|
|
23
|
+
def should_run_full_sync?(integration)
|
|
24
|
+
return false if integration.integration_sync_sessions
|
|
25
|
+
.where(sync_type: %i[full], status: %i[in_progress pending])
|
|
26
|
+
.exists?(['created_at > ?', 1.hour.ago])
|
|
27
|
+
|
|
28
|
+
last_full_sync = integration.integration_sync_sessions
|
|
29
|
+
.where(sync_type: :full, status: :completed)
|
|
30
|
+
.order(created_at: :desc)
|
|
31
|
+
.first
|
|
32
|
+
|
|
33
|
+
return true if last_full_sync.blank?
|
|
34
|
+
|
|
35
|
+
full_sync_interval = integration.full_sync_interval_hours.hours
|
|
36
|
+
(Time.current - last_full_sync.created_at) > full_sync_interval
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check if incremental sync should run
|
|
40
|
+
# Ignore sessions older than 1 hour to allow retry of stalled syncs
|
|
41
|
+
def should_run_incremental_sync?(integration)
|
|
42
|
+
return false if integration.integration_sync_sessions
|
|
43
|
+
.where(sync_type: %i[full incremental], status: %i[in_progress pending])
|
|
44
|
+
.exists?(['created_at > ?', 1.hour.ago])
|
|
45
|
+
|
|
46
|
+
last_incremental_sync = integration.integration_sync_sessions
|
|
47
|
+
.where(sync_type: :incremental, status: :completed)
|
|
48
|
+
.order(created_at: :desc)
|
|
49
|
+
.first
|
|
50
|
+
|
|
51
|
+
# Run incremental sync if:
|
|
52
|
+
# 1. No previous incremental sync exists, OR
|
|
53
|
+
# 2. Last incremental sync was more than the configured interval ago (eg. 10 seconds ago)
|
|
54
|
+
return true if last_incremental_sync.blank?
|
|
55
|
+
|
|
56
|
+
incremental_sync_interval = integration.incremental_sync_interval_seconds.seconds
|
|
57
|
+
(Time.current - last_incremental_sync.created_at) > incremental_sync_interval
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def schedule_full_sync(integration)
|
|
61
|
+
CmAppLogger.log(
|
|
62
|
+
label: 'Integrations::PollingSchedulerJob#schedule_full_sync Enqueued full sync',
|
|
63
|
+
data: { integration_id: integration.id, integration_type: integration.class.name }
|
|
64
|
+
) do
|
|
65
|
+
PollingJob.perform_later(integration_id: integration.id, sync_type: :full)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def schedule_incremental_sync(integration)
|
|
70
|
+
CmAppLogger.log(
|
|
71
|
+
label: 'Integrations::PollingSchedulerJob#schedule_incremental_sync Enqueued incremental sync',
|
|
72
|
+
data: { integration_id: integration.id, integration_type: integration.class.name }
|
|
73
|
+
) do
|
|
74
|
+
PollingJob.perform_later(integration_id: integration.id, sync_type: :incremental)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module ExternalClient
|
|
3
|
+
class Client
|
|
4
|
+
BASE_PATH = '/api/v1'.freeze
|
|
5
|
+
RATE_LIMIT = 100
|
|
6
|
+
|
|
7
|
+
def initialize(public_key:, private_key:, base_url:)
|
|
8
|
+
@public_key = public_key
|
|
9
|
+
@private_key = private_key
|
|
10
|
+
@base_url = base_url
|
|
11
|
+
@connection = build_connection
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Ticket Operations
|
|
15
|
+
|
|
16
|
+
# Get a specific ticket by ID
|
|
17
|
+
# @param id [String] The ticket ID
|
|
18
|
+
# @return [Resources::Ticket] The ticket object
|
|
19
|
+
def get_ticket!(id:)
|
|
20
|
+
response = @connection.get("#{BASE_PATH}/tickets/#{id}") do |req|
|
|
21
|
+
req.body = auth_params.to_json
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
handle_response(response, Resources::Ticket)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get all tickets for a specific user
|
|
28
|
+
# @param user_id [String] The user ID
|
|
29
|
+
# @return [Array<Resources::Ticket>] Array of ticket objects
|
|
30
|
+
def get_tickets_by_user!(user_id:)
|
|
31
|
+
response = @connection.get("#{BASE_PATH}/tickets/user/#{user_id}") do |req|
|
|
32
|
+
req.body = auth_params.merge(user_id: user_id).to_json
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
handle_response(response, Resources::Ticket, collection: true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Create a new ticket
|
|
39
|
+
# @param match_id [String] The match ID
|
|
40
|
+
# @param user_id [String] The user ID
|
|
41
|
+
# @param club_id [String] The club ID
|
|
42
|
+
# @param zone_id [String] The zone ID
|
|
43
|
+
# @param quantity [Integer] The quantity of tickets to create
|
|
44
|
+
# @return [Array<Resources::Ticket>] Array of created ticket objects
|
|
45
|
+
def create_tickets!(match_id:, user_id:, club_id:, zone_id:, quantity:)
|
|
46
|
+
params = {
|
|
47
|
+
match_id: match_id,
|
|
48
|
+
user_id: user_id,
|
|
49
|
+
club_id: club_id,
|
|
50
|
+
zone_id: zone_id,
|
|
51
|
+
quantity: quantity
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
response = @connection.post("#{BASE_PATH}/tickets") do |req|
|
|
55
|
+
req.body = auth_params.merge(params).to_json
|
|
56
|
+
req.headers['Content-Type'] = 'application/json'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
handle_response(response, Resources::Ticket, collection: true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Match Operations
|
|
63
|
+
|
|
64
|
+
# Get all available matches
|
|
65
|
+
# @return [Array<Resources::Match>] Array of match objects
|
|
66
|
+
def get_matches! # rubocop:disable Naming/AccessorMethodName
|
|
67
|
+
response = @connection.get("#{BASE_PATH}/matches") do |req|
|
|
68
|
+
req.params = auth_params
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
handle_response(response, Resources::Match, collection: true)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get a specific match by ID
|
|
75
|
+
# @param id [String] The match ID
|
|
76
|
+
# @return [Resources::Match] The match object
|
|
77
|
+
def get_match!(id:)
|
|
78
|
+
response = @connection.get("#{BASE_PATH}/matches/#{id}") do |req|
|
|
79
|
+
req.params = auth_params
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
handle_response(response, Resources::Match)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Zone Operations
|
|
86
|
+
|
|
87
|
+
# Get available zones for a club and match
|
|
88
|
+
# @param club_id [String] The club ID
|
|
89
|
+
# @param match_id [String] The match ID
|
|
90
|
+
# @return [Array<Resources::Zone>] Array of zone objects
|
|
91
|
+
def get_zones!(club_id:, match_id:)
|
|
92
|
+
response = @connection.get("#{BASE_PATH}/zones/club") do |req|
|
|
93
|
+
req.params = auth_params.merge(
|
|
94
|
+
club_id: club_id,
|
|
95
|
+
match_id: match_id
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
handle_response(response, Resources::Zone, collection: true)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def build_connection
|
|
105
|
+
Faraday.new(url: @base_url) do |faraday|
|
|
106
|
+
faraday.request :json
|
|
107
|
+
faraday.response :json, content_type: /\bjson$/
|
|
108
|
+
faraday.adapter Faraday.default_adapter
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def auth_params
|
|
113
|
+
{
|
|
114
|
+
public_key: @public_key,
|
|
115
|
+
private_key: @private_key
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def handle_response(response, model_class, collection: false)
|
|
120
|
+
if response.success?
|
|
121
|
+
data = response.body
|
|
122
|
+
unless data['success']
|
|
123
|
+
raise SpreeCmCommissioner::Integrations::ExternalClientError.new(
|
|
124
|
+
"API request failed: #{data['error']}",
|
|
125
|
+
response.status
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
collection ? model_class.from_collection(data) : model_class.new(data['data'])
|
|
130
|
+
else
|
|
131
|
+
handle_error_response(response)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle_error_response(response)
|
|
136
|
+
error_message = "API request failed with status #{response.status}"
|
|
137
|
+
|
|
138
|
+
# Response body is already parsed by Faraday's JSON middleware
|
|
139
|
+
error_body = response.body
|
|
140
|
+
|
|
141
|
+
if error_body.is_a?(Hash)
|
|
142
|
+
error = error_body['error'] || error_body['message'] || error_body
|
|
143
|
+
error_message = error.is_a?(Hash) ? error.to_json : error.to_s
|
|
144
|
+
else
|
|
145
|
+
error_message = error_body.to_s.presence || error_message
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
raise SpreeCmCommissioner::Integrations::ExternalClientError.new(error_message, response.status)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module Polling
|
|
3
|
+
class SyncMatches
|
|
4
|
+
def initialize(client:, integration:, sync_result: nil)
|
|
5
|
+
@client = client
|
|
6
|
+
@integration = integration
|
|
7
|
+
@sync_result = sync_result || SpreeCmCommissioner::Integrations::Base::SyncResult.new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
synced_match_mappings = sync_matches!
|
|
12
|
+
cleanup_stale_mappings!(synced_match_mappings)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Sync all available matches from the API and create them as events
|
|
16
|
+
def sync_matches!
|
|
17
|
+
external_matches = begin
|
|
18
|
+
@sync_result.track_api_call('get_matches') { @client.get_matches! }
|
|
19
|
+
rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
|
|
20
|
+
raise SpreeCmCommissioner::Integrations::SyncError, "Failed to sync matches: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
external_matches.map do |external_match|
|
|
24
|
+
sync_match!(external_match)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Sync a single match and create it as an event taxon under events/
|
|
29
|
+
# Match data might be incomplete in the matches list, so we fetch full details
|
|
30
|
+
def sync_match!(external_match)
|
|
31
|
+
external_match = begin
|
|
32
|
+
@sync_result.track_api_call('get_match_details') { @client.get_match!(id: external_match._id) }
|
|
33
|
+
rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
|
|
34
|
+
raise SpreeCmCommissioner::Integrations::SyncError, "Failed to sync matches: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
match_mapping = Spree::Taxon.find_or_initialize_integration_mapping(integration_id: @integration.id, external_id: external_match._id)
|
|
38
|
+
match_taxon = match_mapping.internal
|
|
39
|
+
|
|
40
|
+
# Track match sync and create event taxon directly under events/
|
|
41
|
+
@sync_result.track(:match, match_taxon) do |tracker|
|
|
42
|
+
match_taxon.assign_attributes(
|
|
43
|
+
vendor: @integration.vendor,
|
|
44
|
+
parent: events_root_taxon,
|
|
45
|
+
taxonomy: events_taxonomy,
|
|
46
|
+
name: "#{external_match.home_name} vs #{external_match.away_name}",
|
|
47
|
+
kind: :event,
|
|
48
|
+
from_date: external_match.match_datetime,
|
|
49
|
+
to_date: external_match.match_datetime + 120.minutes,
|
|
50
|
+
|
|
51
|
+
# seo
|
|
52
|
+
meta_title: external_match.meta_title,
|
|
53
|
+
meta_description: external_match.meta_description,
|
|
54
|
+
|
|
55
|
+
# Sync common match fields for cross-integration compatibility
|
|
56
|
+
# Only include fields that other integrations typically provide to avoid data gaps.
|
|
57
|
+
public_metadata: {
|
|
58
|
+
home_name: external_match.home_name,
|
|
59
|
+
home_score: external_match.home_score,
|
|
60
|
+
home_logo_url: external_match.home_logo,
|
|
61
|
+
away_name: external_match.away_name,
|
|
62
|
+
away_score: external_match.away_score,
|
|
63
|
+
away_logo_url: external_match.away_logo,
|
|
64
|
+
stadium: external_match.stadium,
|
|
65
|
+
league_name: external_match.league&.name,
|
|
66
|
+
league_logo_url: external_match.league&.logo
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
tracker.save_if_changed!(match_taxon)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
match_mapping.mark_as_active!(external_payload: external_match.to_h)
|
|
73
|
+
match_mapping
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Archive stale match mappings that are no longer in the external API
|
|
77
|
+
# Finds all active event taxon mappings for this integration and archives any
|
|
78
|
+
# that weren't included in the latest API response
|
|
79
|
+
def cleanup_stale_mappings!(synced_match_mappings)
|
|
80
|
+
synced_external_match_ids = synced_match_mappings.map(&:external_id)
|
|
81
|
+
|
|
82
|
+
# Find all active match mappings (event taxons) for this integration
|
|
83
|
+
active_match_mappings = SpreeCmCommissioner::IntegrationMapping.where(
|
|
84
|
+
integration_id: @integration.id,
|
|
85
|
+
internal_type: 'Spree::Taxon',
|
|
86
|
+
status: :active
|
|
87
|
+
).where(internal_id: Spree::Taxon.where(kind: :event).select(:id))
|
|
88
|
+
|
|
89
|
+
# Archive matches that are no longer in the API response
|
|
90
|
+
active_match_mappings.find_each do |mapping|
|
|
91
|
+
next if synced_external_match_ids.include?(mapping.external_id)
|
|
92
|
+
|
|
93
|
+
# Discontinue all products associated with this match
|
|
94
|
+
mapping.internal.event_products.find_each do |product|
|
|
95
|
+
product.discontinue!
|
|
96
|
+
@sync_result.increment_metric(:zone, :discontinued)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
mapping.mark_as_archived!
|
|
100
|
+
@sync_result.increment_metric(:match, :archived)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def events_root_taxon
|
|
105
|
+
events_taxonomy.root
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def events_taxonomy
|
|
109
|
+
@events_taxonomy ||= Spree::Taxonomy.events
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|