spree_cm_commissioner 2.4.3 → 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.
Files changed (79) 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/errors/spree_cm_commissioner/integrations/external_client_error.rb +10 -0
  10. data/app/errors/spree_cm_commissioner/integrations/sync_error.rb +4 -0
  11. data/app/finders/spree_cm_commissioner/events/find_matches.rb +15 -0
  12. data/app/helpers/spree_cm_commissioner/external_integrations_helper.rb +58 -0
  13. data/app/interactors/spree_cm_commissioner/stock/inventory_item_resetter.rb +1 -1
  14. data/app/interactors/spree_cm_commissioner/stock/stock_movement_creator.rb +4 -2
  15. data/app/jobs/spree_cm_commissioner/integrations/base_job.rb +39 -0
  16. data/app/jobs/spree_cm_commissioner/integrations/polling_job.rb +53 -0
  17. data/app/jobs/spree_cm_commissioner/integrations/polling_scheduler_job.rb +30 -0
  18. data/app/jobs/spree_cm_commissioner/stock/inventory_items_adjuster_job.rb +5 -4
  19. data/app/jobs/spree_cm_commissioner/telegram_alerts/integration_sync_failure_job.rb +17 -0
  20. data/app/models/concerns/spree_cm_commissioner/integrations/integration_mappable.rb +58 -0
  21. data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +15 -3
  22. data/app/models/concerns/spree_cm_commissioner/store_metadata.rb +14 -2
  23. data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +1 -7
  24. data/app/models/spree_cm_commissioner/integration.rb +21 -0
  25. data/app/models/spree_cm_commissioner/integration_mapping.rb +37 -0
  26. data/app/models/spree_cm_commissioner/integration_sync_session.rb +15 -0
  27. data/app/models/spree_cm_commissioner/integrations/stadium_x_v1.rb +21 -0
  28. data/app/models/spree_cm_commissioner/inventory_item.rb +5 -1
  29. data/app/models/spree_cm_commissioner/option_type_decorator.rb +8 -0
  30. data/app/models/spree_cm_commissioner/option_value_decorator.rb +34 -0
  31. data/app/models/spree_cm_commissioner/product_decorator.rb +3 -2
  32. data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +2 -2
  33. data/app/models/spree_cm_commissioner/redis_stock/inventory_updater.rb +2 -2
  34. data/app/models/spree_cm_commissioner/taxon_decorator.rb +1 -0
  35. data/app/models/spree_cm_commissioner/taxonomy_decorator.rb +13 -1
  36. data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -4
  37. data/app/models/spree_cm_commissioner/vendor_decorator.rb +1 -0
  38. data/app/overrides/spree/admin/shared/sub_menu/_integrations/external_integrations.html.erb.deface +6 -0
  39. data/app/presenters/spree/variants/{visable_options_presenter.rb → visible_options_presenter.rb} +2 -4
  40. data/app/services/spree_cm_commissioner/integrations/base/sync_manager.rb +69 -0
  41. data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +183 -0
  42. data/app/services/spree_cm_commissioner/integrations/polling.rb +70 -0
  43. data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +79 -0
  44. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/external_client/client.rb +152 -0
  45. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_matches.rb +113 -0
  46. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_zones.rb +215 -0
  47. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/base.rb +20 -0
  48. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/league.rb +19 -0
  49. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/match.rb +95 -0
  50. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket.rb +81 -0
  51. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket_image.rb +19 -0
  52. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/zone.rb +90 -0
  53. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_manager.rb +35 -0
  54. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/full_sync_strategy.rb +38 -0
  55. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/incremental_sync_strategy.rb +44 -0
  56. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/webhook_sync_strategy.rb +16 -0
  57. data/app/services/spree_cm_commissioner/telegram_alerts/integration_sync_failure.rb +49 -0
  58. data/app/views/spree/admin/integration_mappings/_integration_mappings.html.erb +107 -0
  59. data/app/views/spree/admin/integration_mappings/index.html.erb +33 -0
  60. data/app/views/spree/admin/integration_sessions/_integration_sync_sessions.html.erb +116 -0
  61. data/app/views/spree/admin/integration_sessions/index.html.erb +42 -0
  62. data/app/views/spree/admin/integrations/_form.html.erb +104 -0
  63. data/app/views/spree/admin/integrations/_stadium_x_v1_fields.html.erb +29 -0
  64. data/app/views/spree/admin/integrations/edit.html.erb +45 -0
  65. data/app/views/spree/admin/integrations/index.html.erb +75 -0
  66. data/app/views/spree/admin/integrations/new.html.erb +25 -0
  67. data/config/locales/en.yml +8 -0
  68. data/config/locales/km.yml +8 -0
  69. data/config/routes.rb +8 -0
  70. data/db/migrate/20251017094845_create_cm_integrations.rb +22 -0
  71. data/db/migrate/20251017101555_create_cm_integration_sync_sessions.rb +68 -0
  72. data/db/migrate/20251017101605_create_cm_integration_mappings.rb +52 -0
  73. data/lib/cm_app_logger.rb +1 -0
  74. data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +25 -0
  75. data/lib/spree_cm_commissioner/test_helper/factories/integration_mapping_factory.rb +14 -0
  76. data/lib/spree_cm_commissioner/test_helper/factories/integration_sync_session_factory.rb +7 -0
  77. data/lib/spree_cm_commissioner/version.rb +1 -1
  78. data/lib/spree_cm_commissioner.rb +8 -7
  79. metadata +56 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f580394367847906251ac52666c73ae79bb1a117948805217616c0a83f9300a
4
- data.tar.gz: e8b7ae961151a074aab2084e7600b55da2969405143cf1b9e4cd1b95fcd7040b
3
+ metadata.gz: 766f3c96b11b0771314a8713c96706a97aaa777879fc7de568c6cde2d45a69b3
4
+ data.tar.gz: 61425d946b1b97f4d56f385fa3fef1e32482d00537b5ed4de2ace0bb7a66a41d
5
5
  SHA512:
6
- metadata.gz: 35e303df725eb93bb52a3eceb55672668cea800d77dd7c4e4d938b9caf0585477044e20fe646f2a2a7da5209a4ceff6c78ccf8e6adb862378373a66bd1525d19
7
- data.tar.gz: f91afe8506292cf778cfb3b8bfdc87f5d2545c2e079abdf5f5d881441c3ae758f218bcbb6798756f860734b5c3af82be30250c2c0a2f5a9e2aea96782d7623ed
6
+ metadata.gz: 0f76688e91c61895340e65c38dc8defe2e92eb2d96a8011cb6064d97811b982a26102aa217b257aee237af25cc9eace91fbafa97bd3d0199da1451ea46b2089c
7
+ data.tar.gz: ccb93229d83dbed6f5196b461dfa006f1be936e9a41b2a7226ba2fbbe3f63ca17425bbe5c1e22da66116ecef6f43402808e4ea1a649447c6ab5df7c5c292dd5d
@@ -10,7 +10,7 @@ on:
10
10
  - reopened
11
11
  branches:
12
12
  - develop
13
- - milestone-77-scalable-design
13
+ - 3114-external-integration-milestone
14
14
  push:
15
15
  tags:
16
16
  - "*"
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 3.3.5
1
+ ruby 3.2.0
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.4.3)
37
+ spree_cm_commissioner (2.5.0.pre.pre1)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -0,0 +1,21 @@
1
+ module Spree
2
+ module Admin
3
+ class IntegrationMappingsController < Spree::Admin::BaseController
4
+ helper SpreeCmCommissioner::ExternalIntegrationsHelper
5
+ before_action :load_integration
6
+
7
+ def index
8
+ @mappings = @integration.integration_mappings
9
+ .order(updated_at: :desc)
10
+ .page(params[:page])
11
+ .per(params[:per_page] || 25)
12
+ end
13
+
14
+ private
15
+
16
+ def load_integration
17
+ @integration ||= SpreeCmCommissioner::Integration.find(params[:integration_id])
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ module Spree
2
+ module Admin
3
+ class IntegrationSessionsController < Spree::Admin::BaseController
4
+ helper SpreeCmCommissioner::ExternalIntegrationsHelper
5
+ before_action :load_integration
6
+
7
+ def index
8
+ @sync_sessions = @integration.integration_sync_sessions
9
+ .order(created_at: :desc)
10
+ .page(params[:page])
11
+ .per(params[:per_page] || 25)
12
+ end
13
+
14
+ private
15
+
16
+ def load_integration
17
+ @integration ||= SpreeCmCommissioner::Integration.find(params[:integration_id])
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,83 @@
1
+ module Spree
2
+ module Admin
3
+ class IntegrationsController < Spree::Admin::ResourceController
4
+ helper SpreeCmCommissioner::ExternalIntegrationsHelper
5
+ before_action :load_form_collections, only: %i[new create edit update]
6
+ before_action :load_integration, only: %i[enqueue_polling]
7
+
8
+ def index
9
+ @integrations = model_class.all
10
+ end
11
+
12
+ def enqueue_polling
13
+ sync_type = params[:sync_type].presence || :full
14
+
15
+ if @integration.active?
16
+ SpreeCmCommissioner::Integrations::PollingJob.perform_later(
17
+ integration_id: @integration.id,
18
+ sync_type: sync_type
19
+ )
20
+ flash[:success] = Spree.t('admin.integration_sync_enqueued')
21
+ else
22
+ flash[:error] = Spree.t('admin.integration_inactive')
23
+ end
24
+
25
+ redirect_to admin_integration_sessions_path(@integration)
26
+ end
27
+
28
+ # override
29
+ def model_class
30
+ SpreeCmCommissioner::Integration
31
+ end
32
+
33
+ # override
34
+ def collection_url(options = {})
35
+ admin_integrations_url(options)
36
+ end
37
+
38
+ private
39
+
40
+ def permitted_resource_params
41
+ params.require(:spree_cm_commissioner_integration).permit(
42
+ :name,
43
+ :type,
44
+ :status,
45
+ :conflict_strategy,
46
+ :incremental_sync_interval_seconds,
47
+ :full_sync_interval_hours,
48
+ :tenant_id,
49
+ :vendor_id,
50
+ public_metadata: {},
51
+ private_metadata: {}
52
+ )
53
+ end
54
+
55
+ def load_integration
56
+ @integration ||= SpreeCmCommissioner::Integration.find(params[:id])
57
+ end
58
+
59
+ def load_form_collections
60
+ @integration_type_options ||= integration_type_options
61
+ @status_options ||= enum_to_options(SpreeCmCommissioner::Integration.statuses)
62
+ @conflict_strategy_options ||= enum_to_options(SpreeCmCommissioner::Integration.conflict_strategies)
63
+ @tenants ||= SpreeCmCommissioner::Tenant.select(:id, :name).order(:name)
64
+ @vendors ||= Spree::Vendor.select(:id, :name).order(:name)
65
+ end
66
+
67
+ def enum_to_options(enum_hash)
68
+ enum_hash.keys.map { |key| [key.humanize, key] }
69
+ end
70
+
71
+ def integration_type_options
72
+ files = Dir[SpreeCmCommissioner::Engine.root.join('app/models/spree_cm_commissioner/integrations/*.rb')]
73
+
74
+ return [[Spree.t(:type), 'SpreeCmCommissioner::Integration']] if files.blank?
75
+
76
+ files.map do |path|
77
+ class_name = "SpreeCmCommissioner::Integrations::#{File.basename(path, '.rb').camelize}"
78
+ [class_name, class_name]
79
+ end.sort_by(&:first)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,15 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Storefront
5
+ class EventMatchesController < TaxonsController
6
+ private
7
+
8
+ def collection_finder
9
+ SpreeCmCommissioner::Events::FindMatches
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ module SpreeCmCommissioner::Integrations
2
+ class ExternalClientError < StandardError
3
+ attr_reader :status_code
4
+
5
+ def initialize(message, status_code = nil)
6
+ super(message)
7
+ @status_code = status_code
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,4 @@
1
+ module SpreeCmCommissioner::Integrations
2
+ class SyncError < StandardError
3
+ end
4
+ end
@@ -0,0 +1,15 @@
1
+ module SpreeCmCommissioner
2
+ module Events
3
+ class FindMatches < ::Spree::BaseFinder
4
+ # currently, there is no way to determine if an event is a match yet.
5
+ # this finder returns all events that have active integration mappings (synced from external systems like StadiumX)
6
+ # as a temporary workaround to filter out non-match events.
7
+ def execute
8
+ scope.joins(:integration_mappings).where(
9
+ kind: :event,
10
+ integration_mappings: { status: :active }
11
+ ).distinct
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,58 @@
1
+ module SpreeCmCommissioner
2
+ module ExternalIntegrationsHelper
3
+ def integration_status_badge(status)
4
+ badge_class = case status.to_s
5
+ when 'active' then 'badge-success'
6
+ when 'paused' then 'badge-warning'
7
+ else 'badge-secondary'
8
+ end
9
+
10
+ content_tag(:span, status.to_s.humanize, class: "badge badge-pill #{badge_class}")
11
+ end
12
+
13
+ def integration_conflict_strategy_badge(strategy)
14
+ content_tag(:span, strategy.to_s.humanize, class: 'badge badge-pill badge-secondary')
15
+ end
16
+
17
+ def integration_interval_badge(value, unit)
18
+ content_tag(:span, "#{value}#{unit}", class: 'badge badge-pill badge-info')
19
+ end
20
+
21
+ def integration_base_url_link(url)
22
+ if url.present?
23
+ link_to url, url, target: '_blank', rel: 'noopener', class: 'text-decoration-underline'
24
+ else
25
+ content_tag(:span, '—', class: 'text-muted')
26
+ end
27
+ end
28
+
29
+ def integration_mapping_internal_link(mapping)
30
+ return mapping.internal_id if mapping.internal.blank?
31
+
32
+ begin
33
+ case mapping.internal_type
34
+ when 'Spree::Taxon'
35
+ taxon = mapping.internal
36
+ taxonomy_id = taxon.taxonomy_id || taxon.taxonomy&.id
37
+ if taxonomy_id
38
+ link_to mapping.internal_id,
39
+ edit_admin_taxonomy_taxon_path(taxonomy_id, taxon.id),
40
+ class: 'text-primary'
41
+ end
42
+ when 'Spree::Product'
43
+ link_to mapping.internal_id, edit_admin_product_path(mapping.internal), class: 'text-primary'
44
+ when 'Spree::Variant'
45
+ link_to mapping.internal_id, edit_admin_product_variant_path(mapping.internal.product_id, mapping.internal), class: 'text-primary'
46
+ when 'Spree::Vendor'
47
+ link_to mapping.internal_id, edit_admin_vendor_path(mapping.internal), class: 'text-primary'
48
+ else
49
+ # Try polymorphic route as fallback
50
+ link_to mapping.internal_id, [:edit, :admin, mapping.internal], class: 'text-primary'
51
+ end
52
+ rescue StandardError => e
53
+ Rails.logger.warn("Failed to generate link for mapping #{mapping.id}: #{e.message}")
54
+ mapping.internal_id
55
+ end
56
+ end
57
+ end
58
+ end
@@ -35,7 +35,7 @@ module SpreeCmCommissioner
35
35
  end
36
36
 
37
37
  def clear_inventory_cache
38
- SpreeCmCommissioner.redis_pool.with do |redis|
38
+ SpreeCmCommissioner.inventory_redis_pool.with do |redis|
39
39
  redis.del(inventory_item.redis_key)
40
40
  end
41
41
  end
@@ -23,8 +23,10 @@ module SpreeCmCommissioner
23
23
  private
24
24
 
25
25
  def adjust_inventory_items_async(variant_id, quantity)
26
- CmAppLogger.log(label: "#{self.class.name}#adjust_inventory_items_async", data: { variant_id: variant_id, quantity: quantity }) do
27
- SpreeCmCommissioner::Stock::InventoryItemsAdjusterJob.perform_later(variant_id: variant_id, quantity: quantity)
26
+ params = { variant_id: variant_id, quantity: quantity }
27
+
28
+ CmAppLogger.log(label: "#{self.class.name}#adjust_inventory_items_async", data: params) do
29
+ SpreeCmCommissioner::Stock::InventoryItemsAdjusterJob.perform_later(**params)
28
30
  end
29
31
  end
30
32
  end
@@ -0,0 +1,39 @@
1
+ module SpreeCmCommissioner
2
+ module Integrations
3
+ # Base class for integration jobs
4
+ #
5
+ # Provides common error handling and alerting functionality for all integration jobs.
6
+ # Subclasses should implement their own perform logic and call the appropriate
7
+ # error handler methods.
8
+ class BaseJob < ApplicationJob
9
+ queue_as :integration
10
+
11
+ protected
12
+
13
+ # Handle error by logging and sending alert
14
+ # @param label [String] The error label for logging
15
+ # @param error [StandardError] The error that occurred
16
+ # @param data [Hash] Additional context data (e.g., sync_type, match_id, event_type)
17
+ def handle_error(label:, error:, data: {})
18
+ error_message = "#{error.class.name}: #{error.message}"
19
+
20
+ # Log the error with structured data
21
+ CmAppLogger.error(
22
+ label: label,
23
+ data: data.merge(
24
+ message: error_message,
25
+ backtrace: error.backtrace
26
+ )
27
+ )
28
+
29
+ # Send alert via Telegram
30
+ TelegramAlerts::IntegrationSyncFailureJob.perform_later(
31
+ error_message: error_message,
32
+ data: data
33
+ )
34
+
35
+ error_message
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,53 @@
1
+ module SpreeCmCommissioner
2
+ module Integrations
3
+ # Job that performs the actual polling (syncing) for a specific integration
4
+ #
5
+ # This job is enqueued by PollingSchedulerJob and handles:
6
+ # - Full syncs: Complete refresh of all data
7
+ # - Incremental syncs: Fetch only recent changes
8
+ # - Webhook syncs: Real-time event processing (triggered externally)
9
+ #
10
+ # Each integration type (StadiumXV1, LarrytaV1, etc.) must implement
11
+ # a sync_manager that responds to sync_full!, sync_incremental!, and sync_webhook!
12
+ class PollingJob < BaseJob
13
+ # Perform the integration pull
14
+ # @param integration_id [Integer] The integration ID
15
+ # @param sync_type [String] The sync type: 'full', 'incremental', or 'webhook_triggered'
16
+ # @param event_type [String] Optional event type for webhook syncs
17
+ # @param event_data [Hash] Optional event data for webhook syncs
18
+ def perform(options)
19
+ integration_id = options[:integration_id]
20
+ sync_type = options[:sync_type]
21
+ event_type = options[:event_type]
22
+ event_data = options[:event_data]
23
+ integration = SpreeCmCommissioner::Integration.find(integration_id)
24
+
25
+ # integration is no longer active, skip processing.
26
+ return unless integration.active?
27
+
28
+ SpreeCmCommissioner::Integrations::Polling.new.call(
29
+ integration: integration,
30
+ sync_type: sync_type,
31
+ event_type: event_type,
32
+ event_data: event_data
33
+ )
34
+ rescue StandardError => e
35
+ handle_sync_error(e, options)
36
+ raise
37
+ end
38
+
39
+ private
40
+
41
+ # Handle sync errors and alert
42
+ # @param error [StandardError] The error that occurred
43
+ # @param options [Hash] The options passed to perform
44
+ def handle_sync_error(error, options)
45
+ handle_error(
46
+ label: 'Integrations::IntegrationPullJob#perform Sync failed',
47
+ error: error,
48
+ data: options
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,30 @@
1
+ module SpreeCmCommissioner
2
+ module Integrations
3
+ # Scheduler job that runs periodically to enqueue integration polling jobs
4
+ #
5
+ # This job is responsible for:
6
+ # - Running full syncs every 24 hours
7
+ # - Running incremental syncs every 10 seconds
8
+ # - Webhook syncs are triggered externally (not scheduled here)
9
+ #
10
+ # The job queries all active integrations and enqueues appropriate pull jobs
11
+ # based on their last sync time and sync type configuration.
12
+ class PollingSchedulerJob < BaseJob
13
+ def perform
14
+ SpreeCmCommissioner::Integrations::PollingScheduler.new.call
15
+ rescue StandardError => e
16
+ handle_scheduler_error(e)
17
+ raise
18
+ end
19
+
20
+ private
21
+
22
+ def handle_scheduler_error(error)
23
+ handle_error(
24
+ label: 'Integrations::PollingSchedulerJob#perform Error scheduling integration pulls',
25
+ error: error
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,10 +1,11 @@
1
1
  module SpreeCmCommissioner
2
2
  module Stock
3
- class InventoryItemsAdjusterJob < ApplicationUniqueJob
4
- def perform(variant_id:, quantity:)
5
- variant = Spree::Variant.find(variant_id)
3
+ class InventoryItemsAdjusterJob < ApplicationJob
4
+ # :variant_id, :quantity
5
+ def perform(options)
6
+ variant = Spree::Variant.find(options[:variant_id])
6
7
 
7
- SpreeCmCommissioner::Stock::InventoryItemsAdjuster.call(variant: variant, quantity: quantity)
8
+ SpreeCmCommissioner::Stock::InventoryItemsAdjuster.call(variant: variant, quantity: options[:quantity])
8
9
  end
9
10
  end
10
11
  end
@@ -0,0 +1,17 @@
1
+ module SpreeCmCommissioner
2
+ module TelegramAlerts
3
+ class IntegrationSyncFailureJob < ApplicationJob
4
+ queue_as :telegram_bot
5
+
6
+ # Handle error by logging and sending alert
7
+ # @param error_message [String] The error message
8
+ # @param data [Hash] Additional context data (e.g., sync_type, match_id, event_type)
9
+ def perform(options)
10
+ SpreeCmCommissioner::TelegramAlerts::IntegrationSyncFailure.call(
11
+ error_message: options[:error_message],
12
+ data: options[:data] || {}
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,58 @@
1
+ module SpreeCmCommissioner
2
+ module Integrations
3
+ # IntegrationMappable provides a polymorphic mapping layer between internal records and external integration IDs.
4
+ #
5
+ # Design Rationale: Why use a separate mapping table instead of adding external_id columns directly?
6
+ #
7
+ # 1. **No Schema Changes Required**: Adding a new integration doesn't require migrations to add columns to every table.
8
+ # The mapping table is reusable across all models that include this concern.
9
+ #
10
+ # 2. **Polymorphic Scalability**: A single mapping table handles multiple record types (Spree::Product, Spree::Taxon,
11
+ # SeatLayout, etc.) without duplicating external_id columns across the schema.
12
+ #
13
+ # 3. **Multiple Source Support**: Records can have mappings to multiple integrations simultaneously. A single product
14
+ # might be synced from StadiumXV1, LarrytaV1, and other sources. Direct columns would require one per integration.
15
+ #
16
+ # 4. **Acceptable Performance Trade-off**: While joins are slightly slower than direct column lookups, the performance
17
+ # impact is negligible in practice because:
18
+ # - Lookups occur primarily during background jobs (async, not blocking user requests)
19
+ # - Integration sync operations (incremental, full, webhook)
20
+ # - Occasional user-triggered operations (not on every page load)
21
+ # The flexibility and maintainability gains far outweigh the minimal performance cost.
22
+ module IntegrationMappable
23
+ extend ActiveSupport::Concern
24
+
25
+ included do
26
+ has_many :integration_mappings, as: :internal, class_name: 'SpreeCmCommissioner::IntegrationMapping', dependent: :destroy
27
+ end
28
+
29
+ class_methods do
30
+ def find_or_initialize_integration_mapping(integration_id:, external_id:)
31
+ IntegrationMapping.includes(:internal).find_or_initialize_by(
32
+ integration_id: integration_id,
33
+ internal_type: name,
34
+ external_id: external_id
35
+ ) do |mapping|
36
+ mapping.internal = new
37
+ mapping.external_id = external_id
38
+ end
39
+ end
40
+
41
+ def find_integration_mapping(integration_id:, external_id:)
42
+ IntegrationMapping.includes(:internal).find_by(
43
+ integration_id: integration_id,
44
+ internal_type: name,
45
+ external_id: external_id
46
+ )
47
+ end
48
+
49
+ def find_oldest_active_mapping(integration_id:)
50
+ IntegrationMapping.where(
51
+ integration_id: integration_id,
52
+ internal_type: name
53
+ ).active.order(:last_synced_at).first
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -45,7 +45,8 @@ module SpreeCmCommissioner
45
45
  'color' => 'color',
46
46
  'ticket-type' => 'string',
47
47
  'seat-type' => 'string',
48
- 'intercity-taxi' => 'string'
48
+ 'intercity-taxi' => 'string',
49
+ 'rules' => 'string'
49
50
  }.freeze
50
51
 
51
52
  included do
@@ -56,7 +57,7 @@ module SpreeCmCommissioner
56
57
 
57
58
  validate :ensure_name_is_not_changed, on: :update
58
59
 
59
- before_validation :set_reverved_options_attributes, if: :reserved_option?
60
+ before_validation :set_reserved_options_attributes, if: :reserved_option?
60
61
 
61
62
  after_save :sort_date_time_option_values, if: -> { attr_type == 'date' || attr_type == 'time' }
62
63
  after_save :update_variants_metadata, if: :saved_change_to_name?
@@ -68,6 +69,17 @@ module SpreeCmCommissioner
68
69
  end
69
70
  end
70
71
 
72
+ class_methods do
73
+ def rules_option_type = find_by(name: 'rules')
74
+
75
+ def ticket_type
76
+ Spree::OptionType.find_or_create_by!(name: 'ticket-type') do |ot|
77
+ ot.presentation = 'Ticket Type'
78
+ ot.kind = :variant
79
+ end
80
+ end
81
+ end
82
+
71
83
  def reserved_option?
72
84
  return name_was.in?(RESERVED_OPTIONS.keys) if name_changed?
73
85
 
@@ -78,7 +90,7 @@ module SpreeCmCommissioner
78
90
  name == 'ticket-type'
79
91
  end
80
92
 
81
- def set_reverved_options_attributes
93
+ def set_reserved_options_attributes
82
94
  self.attr_type = RESERVED_OPTIONS[name]
83
95
  self.kind = :variant
84
96
  end
@@ -13,6 +13,7 @@
13
13
  # - integer
14
14
  # - array
15
15
  # - datetime
16
+ # - hash
16
17
  #
17
18
  # Example usage:
18
19
  # ```
@@ -22,6 +23,7 @@
22
23
  # store_public_metadata :completed, :boolean, default: true
23
24
  # store_public_metadata :count, :integer, default: 5
24
25
  # store_public_metadata :tags, :array, default: []
26
+ # store_public_metadata :tags, :hash
25
27
  # store_private_metadata :app_token, :string, default: "XYZ"
26
28
  # store_public_metadata :scheduled_at, :datetime, default: Time.now
27
29
  # end
@@ -92,7 +94,7 @@ module SpreeCmCommissioner
92
94
  # - Return default if nil
93
95
  # - Cast to correct type
94
96
  # - Add `?` predicate for booleans
95
- def define_metadata_reader(column_name, key, type, default)
97
+ def define_metadata_reader(column_name, key, type, default) # rubocop:disable Metrics/CyclomaticComplexity
96
98
  define_method(key) do
97
99
  metadata = send(column_name) || {}
98
100
  raw_value = metadata[key.to_s]
@@ -109,6 +111,8 @@ module SpreeCmCommissioner
109
111
  Array(raw_value)
110
112
  when :datetime
111
113
  Time.zone.parse(raw_value.to_s)
114
+ when :hash
115
+ raw_value.is_a?(Hash) ? raw_value : {}
112
116
  else
113
117
  raw_value
114
118
  end
@@ -118,7 +122,7 @@ module SpreeCmCommissioner
118
122
  end
119
123
 
120
124
  # Validates only new assignments (raw JSON values) for type safety
121
- def define_metadata_validation(column_name, key, type) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
125
+ def define_metadata_validation(column_name, key, type) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity,Metrics/AbcSize
122
126
  case type
123
127
  when :boolean
124
128
  validates key, inclusion: { in: [true, false] }, allow_nil: true
@@ -160,6 +164,14 @@ module SpreeCmCommissioner
160
164
  errors.add(key, 'is not a valid datetime')
161
165
  end
162
166
  end
167
+ when :hash
168
+ validate do
169
+ metadata = send(column_name) || {}
170
+ raw_value = metadata[key.to_s]
171
+ next if raw_value.nil?
172
+
173
+ errors.add(key, 'must be a hash') unless raw_value.is_a?(Hash)
174
+ end
163
175
  end
164
176
  end
165
177
  end
@@ -35,12 +35,6 @@ module SpreeCmCommissioner
35
35
  to: :options
36
36
  end
37
37
 
38
- # Override variant.rb to return cached formatted options text, avoiding repeated database queries.
39
- # Falls back to database queries & computing the format if preload data is unavailable.
40
- def options_text
41
- @options_text ||= public_metadata[:preload_options_text].presence || Spree::Variants::VisableOptionsPresenter.new(self).to_sentence
42
- end
43
-
44
38
  def options
45
39
  @options ||= VariantOptions.new(self)
46
40
  end
@@ -111,7 +105,7 @@ module SpreeCmCommissioner
111
105
  # Precomputes the human-readable format (e.g., "Red, 256GB") to avoid
112
106
  # repeated formatting and association queries.
113
107
  # Example: {"color" => "red", "storage" => "256GB"} - option_type_name => option_value_name pairs
114
- self.public_metadata[:preload_options_text] = Spree::Variants::VisableOptionsPresenter.new(self).to_sentence
108
+ self.public_metadata[:preload_options_text] = Spree::Variants::VisibleOptionsPresenter.new(self).to_sentence
115
109
  end
116
110
 
117
111
  def set_options_to_public_metadata!
@@ -0,0 +1,21 @@
1
+ module SpreeCmCommissioner
2
+ class Integration < Base
3
+ include StoreMetadata
4
+
5
+ enum status: { inactive: 0, active: 1, paused: 2 }
6
+ enum conflict_strategy: { newest_wins: 0, internal_wins: 1, external_wins: 2, manual_resolution: 3 }
7
+
8
+ belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant', optional: true
9
+ belongs_to :vendor, class_name: 'Spree::Vendor', optional: false
10
+
11
+ has_many :integration_mappings, class_name: 'SpreeCmCommissioner::IntegrationMapping', dependent: :destroy, inverse_of: :integration
12
+ has_many :integration_sync_sessions, class_name: 'SpreeCmCommissioner::IntegrationSyncSession', dependent: :destroy, inverse_of: :integration
13
+
14
+ validates :incremental_sync_interval_seconds, presence: true, numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 3600 }
15
+ validates :full_sync_interval_hours, presence: true, numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 168 }
16
+
17
+ def sync_manager
18
+ raise NotImplementedError, 'Subclasses must implement the sync_manager method'
19
+ end
20
+ end
21
+ end