spree_cm_commissioner 2.5.0.pre.pre2 → 2.5.0.pre.pre3

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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test_and_build_gem.yml +1 -1
  3. data/.tool-versions +1 -1
  4. data/Gemfile.lock +1 -1
  5. data/app/controllers/spree/admin/integration_mappings_controller.rb +21 -0
  6. data/app/controllers/spree/admin/integration_sessions_controller.rb +21 -0
  7. data/app/controllers/spree/admin/integrations_controller.rb +83 -0
  8. data/app/controllers/spree/api/v2/storefront/event_matches_controller.rb +15 -0
  9. data/app/controllers/spree/api/v2/storefront/popular_route_places_controller.rb +1 -7
  10. data/app/controllers/spree/api/v2/storefront/route_places_controller.rb +9 -9
  11. data/app/errors/spree_cm_commissioner/integrations/external_client_error.rb +10 -0
  12. data/app/errors/spree_cm_commissioner/integrations/sync_error.rb +4 -0
  13. data/app/finders/spree_cm_commissioner/events/find_matches.rb +15 -0
  14. data/app/finders/spree_cm_commissioner/places/find_with_route.rb +10 -10
  15. data/app/finders/spree_cm_commissioner/routes/find_popular.rb +14 -10
  16. data/app/helpers/spree_cm_commissioner/external_integrations_helper.rb +58 -0
  17. data/app/interactors/spree_cm_commissioner/stock/inventory_item_resetter.rb +1 -1
  18. data/app/jobs/spree_cm_commissioner/integrations/base_job.rb +39 -0
  19. data/app/jobs/spree_cm_commissioner/integrations/polling_job.rb +53 -0
  20. data/app/jobs/spree_cm_commissioner/integrations/polling_scheduler_job.rb +30 -0
  21. data/app/jobs/spree_cm_commissioner/stock/inventory_items_adjuster_job.rb +3 -3
  22. data/app/jobs/spree_cm_commissioner/telegram_alerts/integration_sync_failure_job.rb +17 -0
  23. data/app/models/concerns/spree_cm_commissioner/integrations/integration_mappable.rb +61 -0
  24. data/app/models/concerns/spree_cm_commissioner/line_item_integration.rb +36 -0
  25. data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +15 -3
  26. data/app/models/concerns/spree_cm_commissioner/order_integration.rb +33 -0
  27. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +2 -0
  28. data/app/models/concerns/spree_cm_commissioner/store_metadata.rb +14 -2
  29. data/app/models/concerns/spree_cm_commissioner/tenant_preference.rb +0 -3
  30. data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +1 -7
  31. data/app/models/spree_cm_commissioner/guest.rb +13 -0
  32. data/app/models/spree_cm_commissioner/integration.rb +29 -0
  33. data/app/models/spree_cm_commissioner/integration_mapping.rb +41 -0
  34. data/app/models/spree_cm_commissioner/integration_sync_session.rb +15 -0
  35. data/app/models/spree_cm_commissioner/integrations/stadium_x_v1.rb +37 -0
  36. data/app/models/spree_cm_commissioner/inventory_item.rb +5 -1
  37. data/app/models/spree_cm_commissioner/line_item_decorator.rb +13 -1
  38. data/app/models/spree_cm_commissioner/option_type_decorator.rb +8 -0
  39. data/app/models/spree_cm_commissioner/option_value_decorator.rb +34 -0
  40. data/app/models/spree_cm_commissioner/order_decorator.rb +1 -0
  41. data/app/models/spree_cm_commissioner/product_decorator.rb +3 -2
  42. data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +2 -2
  43. data/app/models/spree_cm_commissioner/redis_stock/inventory_updater.rb +2 -2
  44. data/app/models/spree_cm_commissioner/taxon_decorator.rb +1 -0
  45. data/app/models/spree_cm_commissioner/taxonomy_decorator.rb +13 -1
  46. data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -4
  47. data/app/models/spree_cm_commissioner/vendor_decorator.rb +4 -0
  48. data/app/overrides/spree/admin/shared/sub_menu/_integrations/external_integrations.html.erb.deface +6 -0
  49. data/app/presenters/spree/variants/{visable_options_presenter.rb → visible_options_presenter.rb} +2 -4
  50. data/app/request_schemas/spree_cm_commissioner/route_places_request_schema.rb +0 -5
  51. data/app/services/spree_cm_commissioner/integrations/base/sync_manager.rb +69 -0
  52. data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +183 -0
  53. data/app/services/spree_cm_commissioner/integrations/polling.rb +70 -0
  54. data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +79 -0
  55. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/external_client/client.rb +152 -0
  56. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/inventory/unstock_inventory.rb +83 -0
  57. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_matches.rb +113 -0
  58. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_zones.rb +215 -0
  59. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/base.rb +20 -0
  60. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/league.rb +19 -0
  61. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/match.rb +95 -0
  62. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket.rb +81 -0
  63. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket_image.rb +19 -0
  64. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/zone.rb +90 -0
  65. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_manager.rb +35 -0
  66. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/full_sync_strategy.rb +38 -0
  67. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/incremental_sync_strategy.rb +44 -0
  68. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/webhook_sync_strategy.rb +16 -0
  69. data/app/services/spree_cm_commissioner/telegram_alerts/integration_sync_failure.rb +49 -0
  70. data/app/views/spree/admin/integration_mappings/_integration_mappings.html.erb +107 -0
  71. data/app/views/spree/admin/integration_mappings/index.html.erb +33 -0
  72. data/app/views/spree/admin/integration_sessions/_integration_sync_sessions.html.erb +116 -0
  73. data/app/views/spree/admin/integration_sessions/index.html.erb +42 -0
  74. data/app/views/spree/admin/integrations/_form.html.erb +104 -0
  75. data/app/views/spree/admin/integrations/_stadium_x_v1_fields.html.erb +29 -0
  76. data/app/views/spree/admin/integrations/edit.html.erb +45 -0
  77. data/app/views/spree/admin/integrations/index.html.erb +75 -0
  78. data/app/views/spree/admin/integrations/new.html.erb +25 -0
  79. data/app/views/spree/admin/tenants/_form.html.erb +0 -18
  80. data/app/views/spree/order_mailer/confirm_email.html.erb +1 -1
  81. data/app/views/spree_cm_commissioner/layouts/order_mailer.html.erb +1 -1
  82. data/app/views/spree_cm_commissioner/order_mailer/tenant/_footer.html.erb +6 -13
  83. data/app/views/spree_cm_commissioner/order_mailer/tenant/_support_contact.html.erb +24 -23
  84. data/config/locales/en.yml +8 -0
  85. data/config/locales/km.yml +8 -0
  86. data/config/routes.rb +8 -0
  87. data/db/migrate/20251017094845_create_cm_integrations.rb +22 -0
  88. data/db/migrate/20251017101555_create_cm_integration_sync_sessions.rb +68 -0
  89. data/db/migrate/20251017101605_create_cm_integration_mappings.rb +52 -0
  90. data/lib/cm_app_logger.rb +1 -0
  91. data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +25 -0
  92. data/lib/spree_cm_commissioner/test_helper/factories/integration_mapping_factory.rb +14 -0
  93. data/lib/spree_cm_commissioner/test_helper/factories/integration_sync_session_factory.rb +7 -0
  94. data/lib/spree_cm_commissioner/version.rb +1 -1
  95. data/lib/spree_cm_commissioner.rb +8 -7
  96. metadata +56 -6
  97. data/app/request_schemas/spree_cm_commissioner/popular_route_places_request_schema.rb +0 -12
  98. data/app/views/spree/admin/tenants/form/_footer.html.erb +0 -31
  99. data/app/views/spree/admin/tenants/form/_social.html.erb +0 -31
  100. data/db/migrate/20251209022924_add_contact_fields_to_cm_tenants.rb +0 -9
@@ -5,6 +5,7 @@ module SpreeCmCommissioner
5
5
  base.include SpreeCmCommissioner::ProductDelegation
6
6
  base.include SpreeCmCommissioner::VariantOptionsConcern
7
7
  base.include SpreeCmCommissioner::KycBitwise
8
+ base.include SpreeCmCommissioner::Integrations::IntegrationMappable
8
9
 
9
10
  base.after_commit :update_vendor_price
10
11
  base.validate :validate_option_types
@@ -16,10 +17,6 @@ module SpreeCmCommissioner
16
17
  base.has_one :vehicle, class_name: 'SpreeCmCommissioner::Vehicle', through: :trip
17
18
 
18
19
  base.has_many :taxons, class_name: 'Spree::Taxon', through: :product
19
- base.has_many :visible_option_values, lambda {
20
- joins(:option_type).where(spree_option_types: { hidden: false })
21
- }, through: :option_value_variants, source: :option_value
22
-
23
20
  base.has_many :video_on_demands, class_name: 'SpreeCmCommissioner::VideoOnDemand', dependent: :destroy
24
21
  base.has_many :complete_line_items, -> { complete }, class_name: 'Spree::LineItem'
25
22
 
@@ -42,6 +39,12 @@ module SpreeCmCommissioner
42
39
  base.before_validation -> { self.product_type = product.product_type }, if: -> { product_type.nil? }
43
40
  end
44
41
 
42
+ # Override variant.rb to return cached formatted options text, avoiding repeated database queries.
43
+ # Falls back to database queries & computing the format if preload data is unavailable.
44
+ def options_text
45
+ public_metadata[:preload_options_text].presence || Spree::Variants::VisibleOptionsPresenter.new(self).to_sentence
46
+ end
47
+
45
48
  def delivery_required?
46
49
  delivery_option == 'delivery'
47
50
  end
@@ -9,6 +9,7 @@ module SpreeCmCommissioner
9
9
  base.include SpreeCmCommissioner::VendorPreference
10
10
  base.include SpreeCmCommissioner::TenantUpdatable
11
11
  base.include SpreeCmCommissioner::StoreMetadata
12
+ base.include SpreeCmCommissioner::Integrations::IntegrationMappable
12
13
 
13
14
  base.attr_accessor :service_availabilities
14
15
 
@@ -39,6 +40,9 @@ module SpreeCmCommissioner
39
40
  base.has_many :vendor_kind_option_values,
40
41
  through: :option_value_vendors, source: :option_value
41
42
 
43
+ # currently a vendor can have only one integration, but in future it can be many.
44
+ base.has_one :integration, class_name: 'SpreeCmCommissioner::Integration', dependent: :destroy, inverse_of: :vendor
45
+
42
46
  base.has_many :branches, -> { branch }, class_name: 'SpreeCmCommissioner::VendorPlace'
43
47
  base.has_many :stops, -> { stop }, class_name: 'SpreeCmCommissioner::VendorPlace'
44
48
  base.has_many :locations, -> { location }, class_name: 'SpreeCmCommissioner::VendorPlace'
@@ -0,0 +1,6 @@
1
+ <!-- insert_bottom "[data-hook='admin_integrations']" -->
2
+
3
+ <%= tab :integrations,
4
+ match_path: '/integrations',
5
+ label: Spree.t('admin.external_integrations_title'),
6
+ if: can?(:manage, SpreeCmCommissioner::Integration) %>
@@ -1,11 +1,9 @@
1
1
  module Spree
2
2
  module Variants
3
- class VisableOptionsPresenter < OptionsPresenter
4
- delegate :visible_option_values, to: :variant
5
-
3
+ class VisibleOptionsPresenter < OptionsPresenter
6
4
  # override
7
5
  def to_sentence
8
- options = visible_option_values
6
+ options = variant.option_values.reject { |ov| ov.option_type.hidden? }
9
7
  options = sort_options(options)
10
8
  options = present_options(options)
11
9
 
@@ -6,11 +6,6 @@ module SpreeCmCommissioner
6
6
  optional(:place_id).maybe(:integer)
7
7
  optional(:route_type).maybe(:string)
8
8
  end
9
- route_types = SpreeCmCommissioner::RouteType::ROUTE_TYPES.map(&:to_s)
10
-
11
- rule(:route_type) do
12
- key.failure("Invalid route_type. Use #{route_types}") if value.present? && route_types.exclude?(value)
13
- end
14
9
 
15
10
  rule(:place_type) do
16
11
  key.failure('Invalid place_type. Use origin or destination') unless %w[origin destination].include?(value)
@@ -0,0 +1,69 @@
1
+ module SpreeCmCommissioner::Integrations::Base
2
+ class SyncManager
3
+ def initialize(integration:, client:)
4
+ @integration = integration
5
+ @client = client
6
+ @sync_session = nil
7
+ end
8
+
9
+ # Perform a full synchronization (initial setup or complete refresh)
10
+ # Override in subclass and use run_execution to wrap your sync logic
11
+ # @return [SpreeCmCommissioner::IntegrationSyncSession] The sync session record
12
+ # @raise [SyncError] if sync fails
13
+ def sync_full!
14
+ raise NotImplementedError, "#{self.class.name} must implement #sync_full!"
15
+ end
16
+
17
+ # Perform an incremental synchronization (delta updates)
18
+ # Override in subclass and use run_execution to wrap your sync logic
19
+ # @return [SpreeCmCommissioner::IntegrationSyncSession] The sync session record
20
+ # @raise [SyncError] if sync fails or not supported
21
+ def sync_incremental!
22
+ raise NotImplementedError, "#{self.class.name} must implement #sync_incremental!"
23
+ end
24
+
25
+ # Perform a webhook-triggered synchronization (real-time event)
26
+ # Override in subclass and use run_execution to wrap your sync logic
27
+ # @param event_type [String] The type of event (e.g., 'match.updated', 'ticket.created')
28
+ # @param event_data [Hash] The event payload
29
+ # @return [SpreeCmCommissioner::IntegrationSyncSession] The sync session record
30
+ # @raise [SyncError] if sync fails or not supported
31
+ def sync_webhook!(event_type:, event_data:)
32
+ raise NotImplementedError, "#{self.class.name} must implement #sync_webhook!"
33
+ end
34
+
35
+ protected
36
+
37
+ # Wrapper for sync execution that handles session creation and persistence
38
+ # Strategies handle errors internally and always return a SyncResult object
39
+ # This method never throws errors - it always returns a sync session
40
+ #
41
+ # @param sync_type [Symbol] The type of sync (:full, :incremental, :webhook_triggered)
42
+ # @yield Block containing the actual sync logic, must return a SyncResult object
43
+ # @return [SpreeCmCommissioner::IntegrationSyncSession] The sync session record
44
+ def run_execution(sync_type)
45
+ @sync_session = create_sync_session(sync_type)
46
+ sync_result = yield
47
+
48
+ @sync_session.update!(
49
+ status: sync_result.success? ? :completed : :failed,
50
+ ended_at: Time.zone.now,
51
+ sync_result: sync_result.to_h
52
+ )
53
+
54
+ @sync_session
55
+ end
56
+
57
+ private
58
+
59
+ def create_sync_session(sync_type)
60
+ SpreeCmCommissioner::IntegrationSyncSession.create!(
61
+ integration: @integration,
62
+ tenant: @integration.tenant,
63
+ status: :in_progress,
64
+ started_at: Time.zone.now,
65
+ sync_type: sync_type
66
+ )
67
+ end
68
+ end
69
+ end
@@ -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