spree_cm_commissioner 2.5.0.pre.pre8 → 2.5.0.pre.pre10

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +3 -2
  3. data/.github/workflows/test_and_build_gem.yml +123 -57
  4. data/.tool-versions +1 -1
  5. data/Gemfile.lock +1 -1
  6. data/Rakefile +55 -29
  7. data/app/controllers/spree/admin/base_controller_decorator.rb +3 -3
  8. data/app/controllers/spree/admin/base_import_orders_controller.rb +6 -1
  9. data/app/controllers/spree/admin/classifications_controller.rb +1 -1
  10. data/app/controllers/spree/admin/integration_mappings_controller.rb +21 -0
  11. data/app/controllers/spree/admin/integration_sessions_controller.rb +21 -0
  12. data/app/controllers/spree/admin/integrations_controller.rb +83 -0
  13. data/app/controllers/spree/admin/notification_sender_controller.rb +1 -1
  14. data/app/controllers/spree/api/v2/storefront/event_matches_controller.rb +15 -0
  15. data/app/controllers/spree/api/v2/storefront/queue_cart/line_items_controller.rb +6 -6
  16. data/app/controllers/spree/api/v2/storefront/trips_controller.rb +11 -0
  17. data/app/errors/spree_cm_commissioner/integrations/external_client_error.rb +10 -0
  18. data/app/errors/spree_cm_commissioner/integrations/sync_error.rb +4 -0
  19. data/app/finders/spree_cm_commissioner/events/find_matches.rb +15 -0
  20. data/app/helpers/spree_cm_commissioner/external_integrations_helper.rb +58 -0
  21. data/app/interactors/spree_cm_commissioner/create_event.rb +1 -1
  22. data/app/interactors/spree_cm_commissioner/customer_notification_cron_executor.rb +1 -1
  23. data/app/interactors/spree_cm_commissioner/stock/inventory_item_resetter.rb +1 -1
  24. data/app/interactors/spree_cm_commissioner/stock/stock_movement_creator.rb +3 -2
  25. data/app/jobs/spree_cm_commissioner/conversion_pre_calculator_job.rb +2 -2
  26. data/app/jobs/spree_cm_commissioner/customer_notification_sender_job.rb +3 -3
  27. data/app/jobs/spree_cm_commissioner/enqueue_cart/add_item_job.rb +7 -7
  28. data/app/jobs/spree_cm_commissioner/ensure_event_for_product_line_item_guests_job.rb +1 -1
  29. data/app/jobs/spree_cm_commissioner/event_line_items_date_syncer_job.rb +2 -2
  30. data/app/jobs/spree_cm_commissioner/export_csv_job.rb +2 -2
  31. data/app/jobs/spree_cm_commissioner/import_order_job.rb +5 -5
  32. data/app/jobs/spree_cm_commissioner/integrations/base_job.rb +39 -0
  33. data/app/jobs/spree_cm_commissioner/integrations/polling_job.rb +53 -0
  34. data/app/jobs/spree_cm_commissioner/integrations/polling_scheduler_job.rb +30 -0
  35. data/app/jobs/spree_cm_commissioner/invalidate_cache_request_job.rb +2 -2
  36. data/app/jobs/spree_cm_commissioner/inventory_item_syncer_job.rb +8 -2
  37. data/app/jobs/spree_cm_commissioner/option_type_variants_public_metadata_updater_job.rb +7 -3
  38. data/app/jobs/spree_cm_commissioner/option_value_variants_public_metadata_updater_job.rb +6 -2
  39. data/app/jobs/spree_cm_commissioner/order_complete_telegram_sender_job.rb +2 -2
  40. data/app/jobs/spree_cm_commissioner/product_event_id_to_children_syncer_job.rb +2 -2
  41. data/app/jobs/spree_cm_commissioner/reports_assigner_job.rb +2 -2
  42. data/app/jobs/spree_cm_commissioner/sms_pin_code_job.rb +1 -1
  43. data/app/jobs/spree_cm_commissioner/state_job.rb +2 -2
  44. data/app/jobs/spree_cm_commissioner/stock/inventory_items_adjuster_job.rb +6 -3
  45. data/app/jobs/spree_cm_commissioner/stock/inventory_items_generator_job.rb +2 -2
  46. data/app/jobs/spree_cm_commissioner/telegram_alerts/integration_sync_failure_job.rb +17 -0
  47. data/app/jobs/spree_cm_commissioner/transit/route_fulfilled_order_count_incrementer_job.rb +2 -2
  48. data/app/jobs/spree_cm_commissioner/transit/route_order_count_incrementer_job.rb +2 -2
  49. data/app/jobs/spree_cm_commissioner/transit/route_previous_trip_count_decrementer_job.rb +2 -2
  50. data/app/jobs/spree_cm_commissioner/transit/route_trip_count_decrementer_job.rb +2 -2
  51. data/app/jobs/spree_cm_commissioner/transit/route_trip_count_incrementer_job.rb +2 -2
  52. data/app/jobs/spree_cm_commissioner/vendor_creation_telegram_alert_sender_job.rb +2 -2
  53. data/app/jobs/spree_cm_commissioner/vendor_job.rb +2 -2
  54. data/app/jobs/spree_cm_commissioner/waiting_room_session_firebase_logger_job.rb +1 -1
  55. data/app/models/concerns/spree_cm_commissioner/integrations/integration_mappable.rb +61 -0
  56. data/app/models/concerns/spree_cm_commissioner/line_item_integration.rb +36 -0
  57. data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +16 -4
  58. data/app/models/concerns/spree_cm_commissioner/option_value_attr_type.rb +1 -1
  59. data/app/models/concerns/spree_cm_commissioner/order_integration.rb +33 -0
  60. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +4 -2
  61. data/app/models/concerns/spree_cm_commissioner/store_metadata.rb +14 -2
  62. data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +1 -7
  63. data/app/models/spree_cm_commissioner/export.rb +1 -1
  64. data/app/models/spree_cm_commissioner/guest.rb +13 -0
  65. data/app/models/spree_cm_commissioner/integration.rb +29 -0
  66. data/app/models/spree_cm_commissioner/integration_mapping.rb +41 -0
  67. data/app/models/spree_cm_commissioner/integration_sync_session.rb +15 -0
  68. data/app/models/spree_cm_commissioner/integrations/stadium_x_v1.rb +37 -0
  69. data/app/models/spree_cm_commissioner/inventory_item.rb +5 -1
  70. data/app/models/spree_cm_commissioner/line_item_decorator.rb +13 -1
  71. data/app/models/spree_cm_commissioner/option_type_decorator.rb +8 -0
  72. data/app/models/spree_cm_commissioner/option_value_decorator.rb +34 -0
  73. data/app/models/spree_cm_commissioner/order_decorator.rb +1 -0
  74. data/app/models/spree_cm_commissioner/product_decorator.rb +4 -3
  75. data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +2 -2
  76. data/app/models/spree_cm_commissioner/redis_stock/inventory_updater.rb +6 -3
  77. data/app/models/spree_cm_commissioner/stock_item_decorator.rb +4 -4
  78. data/app/models/spree_cm_commissioner/taxon_decorator.rb +2 -1
  79. data/app/models/spree_cm_commissioner/taxonomy_decorator.rb +13 -1
  80. data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -4
  81. data/app/models/spree_cm_commissioner/vendor_decorator.rb +6 -2
  82. data/app/overrides/spree/admin/shared/sub_menu/_integrations/external_integrations.html.erb.deface +6 -0
  83. data/app/presenters/spree/variants/{visable_options_presenter.rb → visible_options_presenter.rb} +2 -4
  84. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_serializer.rb +1 -1
  85. data/app/services/spree_cm_commissioner/integrations/base/sync_manager.rb +69 -0
  86. data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +183 -0
  87. data/app/services/spree_cm_commissioner/integrations/polling.rb +70 -0
  88. data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +79 -0
  89. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/external_client/client.rb +152 -0
  90. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/inventory/unstock_inventory.rb +83 -0
  91. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_matches.rb +113 -0
  92. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_zones.rb +215 -0
  93. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/base.rb +20 -0
  94. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/league.rb +19 -0
  95. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/match.rb +95 -0
  96. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket.rb +81 -0
  97. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket_image.rb +19 -0
  98. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/zone.rb +90 -0
  99. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_manager.rb +35 -0
  100. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/full_sync_strategy.rb +38 -0
  101. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/incremental_sync_strategy.rb +44 -0
  102. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/webhook_sync_strategy.rb +16 -0
  103. data/app/services/spree_cm_commissioner/telegram_alerts/integration_sync_failure.rb +49 -0
  104. data/app/views/spree/admin/integration_mappings/_integration_mappings.html.erb +107 -0
  105. data/app/views/spree/admin/integration_mappings/index.html.erb +33 -0
  106. data/app/views/spree/admin/integration_sessions/_integration_sync_sessions.html.erb +116 -0
  107. data/app/views/spree/admin/integration_sessions/index.html.erb +42 -0
  108. data/app/views/spree/admin/integrations/_form.html.erb +104 -0
  109. data/app/views/spree/admin/integrations/_stadium_x_v1_fields.html.erb +29 -0
  110. data/app/views/spree/admin/integrations/edit.html.erb +45 -0
  111. data/app/views/spree/admin/integrations/index.html.erb +75 -0
  112. data/app/views/spree/admin/integrations/new.html.erb +25 -0
  113. data/bin/run_spec_group +101 -0
  114. data/config/locales/en.yml +8 -0
  115. data/config/locales/km.yml +8 -0
  116. data/config/routes.rb +8 -0
  117. data/db/migrate/20251017094845_create_cm_integrations.rb +22 -0
  118. data/db/migrate/20251017101555_create_cm_integration_sync_sessions.rb +68 -0
  119. data/db/migrate/20251017101605_create_cm_integration_mappings.rb +52 -0
  120. data/lib/cm_app_logger.rb +36 -4
  121. data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +25 -0
  122. data/lib/spree_cm_commissioner/test_helper/factories/integration_mapping_factory.rb +14 -0
  123. data/lib/spree_cm_commissioner/test_helper/factories/integration_sync_session_factory.rb +7 -0
  124. data/lib/spree_cm_commissioner/test_helper/factories/vendor_factory.rb +1 -0
  125. data/lib/spree_cm_commissioner/test_helper/factories/vendor_place_factory.rb +3 -2
  126. data/lib/spree_cm_commissioner/version.rb +1 -1
  127. data/lib/spree_cm_commissioner.rb +8 -7
  128. metadata +58 -3
@@ -0,0 +1,29 @@
1
+ <!-- Stadium X V1 API Credentials -->
2
+ <div class="col-12">
3
+ <hr class="my-4">
4
+
5
+ <h6 class="text-muted">
6
+ <%= I18n.t('spree.admin.external_integrations.stadium_x_credentials', default: 'Stadium X API Credentials') %>
7
+ </h6>
8
+ </div>
9
+
10
+ <div class="col-6">
11
+ <%= f.field_container :public_key do %>
12
+ <%= f.label :public_key, raw(I18n.t('spree.admin.external_integrations.public_key', default: 'Public Key') + required_span_tag) %>
13
+ <%= text_field_tag 'spree_cm_commissioner_integration[private_metadata][public_key]', f.object.public_key, class: 'form-control', required: true %>
14
+ <% end %>
15
+ </div>
16
+
17
+ <div class="col-6">
18
+ <%= f.field_container :private_key do %>
19
+ <%= f.label :private_key, raw(I18n.t('spree.admin.external_integrations.private_key', default: 'Private Key') + required_span_tag) %>
20
+ <%= text_field_tag 'spree_cm_commissioner_integration[private_metadata][private_key]', f.object.private_key, class: 'form-control', required: true %>
21
+ <% end %>
22
+ </div>
23
+
24
+ <div class="col-6">
25
+ <%= f.field_container :base_url do %>
26
+ <%= f.label :base_url, raw(I18n.t('spree.admin.external_integrations.base_url', default: 'Base URL') + required_span_tag) %>
27
+ <%= text_field_tag 'spree_cm_commissioner_integration[private_metadata][base_url]', f.object.base_url, class: 'form-control', placeholder: 'https://api.stadiumx.com', required: true %>
28
+ <% end %>
29
+ </div>
@@ -0,0 +1,45 @@
1
+ <% content_for :page_title do %>
2
+ <%= link_to Spree.t('external_integrations'), spree.admin_integrations_url %> / <%= @object.name %>
3
+ <% end %>
4
+
5
+ <% content_for :page_tabs do %>
6
+ <li class="nav-item">
7
+ <%= link_to Spree.t(:edit),
8
+ edit_admin_integration_path(@object),
9
+ class: "nav-link active" %>
10
+ </li>
11
+
12
+ <li class="nav-item">
13
+ <%= link_to Spree.t(:integration_sync_sessions),
14
+ admin_integration_sessions_path(@object),
15
+ class: "nav-link" %>
16
+ </li>
17
+
18
+ <li class="nav-item">
19
+ <%= link_to Spree.t(:integration_mappings),
20
+ admin_integration_mappings_path(@object),
21
+ class: "nav-link" %>
22
+ </li>
23
+ <% end %>
24
+
25
+ <div data-hook="admin_integrations" class="card mb-3">
26
+ <div class="card-header">
27
+ <h5 class="card-title mb-0 h6"><%= Spree.t('external_integrations') %></h5>
28
+ </div>
29
+
30
+ <div class="card-body">
31
+ <div data-hook="admin_integrations_edit_form_header">
32
+ <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @object } %>
33
+ </div>
34
+
35
+ <div data-hook="admin_integrations_edit_form">
36
+ <%= form_with model: @object, url: admin_integration_path(@object), method: :patch, scope: :spree_cm_commissioner_integration do |f| %>
37
+ <%= render partial: 'form', locals: { f: f } %>
38
+
39
+ <div data-hook="admin_integrations_edit_form_buttons">
40
+ <%= render partial: 'spree/admin/shared/edit_resource_links', locals: { collection_url: spree.admin_integrations_path } %>
41
+ </div>
42
+ <% end %>
43
+ </div>
44
+ </div>
45
+ </div>
@@ -0,0 +1,75 @@
1
+ <% content_for :page_title do %>
2
+ <%= Spree.t('external_integrations') %>
3
+ <% end %>
4
+
5
+ <% content_for :page_actions do %>
6
+ <%= button_link_to Spree.t(:new_external_integration), new_admin_integration_path, class: "btn-success", icon: 'add.svg' %>
7
+ <% end if can? :create, SpreeCmCommissioner::Integration %>
8
+
9
+ <% if @integrations.any? %>
10
+ <div class="table-responsive border rounded bg-white mb-3">
11
+ <table class="table" id="listing_integrations" data-hook>
12
+ <thead class="text-muted">
13
+ <tr data-hook="admin_integrations_index_headers">
14
+ <th><%= Spree.t(:name) %></th>
15
+ <th><%= Spree.t(:base_url) %></th>
16
+ <th><%= Spree.t(:conflict_strategy) %></th>
17
+ <th><%= Spree.t(:incremental_sync_interval_seconds) %></th>
18
+ <th><%= Spree.t(:full_sync_interval_hours) %></th>
19
+ <th><%= Spree.t(:vendor) %></th>
20
+ <th><%= Spree.t(:status) %></th>
21
+ <th><%= Spree.t(:updated_at) %></th>
22
+ </tr>
23
+ </thead>
24
+
25
+ <tbody>
26
+ <% @integrations.each do |integration| %>
27
+ <tr
28
+ id="<%= spree_dom_id integration %>"
29
+ data-hook="admin_integrations_index_rows"
30
+ >
31
+ <td><%= integration.name %></td>
32
+
33
+ <td>
34
+ <%= integration_base_url_link(integration.base_url) %>
35
+ </td>
36
+
37
+ <td>
38
+ <%= integration_conflict_strategy_badge(integration.conflict_strategy) %>
39
+ </td>
40
+
41
+ <td>
42
+ <%= integration_interval_badge(integration.incremental_sync_interval_seconds, 's') %>
43
+ </td>
44
+
45
+ <td>
46
+ <%= integration_interval_badge(integration.full_sync_interval_hours, 'h') %>
47
+ </td>
48
+
49
+ <td><%= integration.vendor.name %></td>
50
+
51
+ <td>
52
+ <%= integration_status_badge(integration.status) %>
53
+ </td>
54
+
55
+ <td><%= integration.updated_at %></td>
56
+
57
+ <td
58
+ data-hook="admin_integrations_index_row_actions"
59
+ class="actions"
60
+ >
61
+ <span class="d-flex justify-content-end">
62
+ <%= link_to_edit integration, url: edit_admin_integration_path(integration), no_text: true if can?(:edit, integration) %>
63
+ <%= link_to_delete integration, url: admin_integration_path(integration), no_text: true if can?(:delete, integration) %>
64
+ </span>
65
+ </td>
66
+ </tr>
67
+ <% end %>
68
+ </tbody>
69
+ </table>
70
+ </div>
71
+ <% else %>
72
+ <small class="form-text text-muted">
73
+ <%= raw I18n.t('external_integrations.empty_info') %>
74
+ </small>
75
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <% content_for :page_title do %>
2
+ <%= link_to Spree.t('external_integrations'), spree.admin_integrations_url %> / <%= Spree.t(:new) %>
3
+ <% end %>
4
+
5
+ <div data-hook="admin_integrations" class="card mb-3">
6
+ <div class="card-header">
7
+ <h5 class="card-title mb-0 h6"><%= Spree.t('external_integrations') %></h5>
8
+ </div>
9
+
10
+ <div class="card-body">
11
+ <div data-hook="admin_integrations_new_form_header">
12
+ <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @object } %>
13
+ </div>
14
+
15
+ <div data-hook="admin_integrations_new_form">
16
+ <%= form_with model: @object, url: { action: 'create' }, scope: :spree_cm_commissioner_integration do |f| %>
17
+ <%= render partial: 'form', locals: { f: f } %>
18
+
19
+ <div data-hook="admin_integrations_new_form_buttons">
20
+ <%= render partial: 'spree/admin/shared/new_resource_links' %>
21
+ </div>
22
+ <% end %>
23
+ </div>
24
+ </div>
25
+ </div>
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # ============================================================================
4
+ # Spec Group Runner - Parallel Test Execution
5
+ # ============================================================================
6
+ #
7
+ # PURPOSE:
8
+ # Split all spec files into N equal groups and run a specific group.
9
+ # Used for parallel test execution in CI (GitHub Actions) and locally.
10
+ #
11
+ # HOW IT WORKS:
12
+ # 1. Finds all *_spec.rb files in spec/ directory
13
+ # 2. Sorts them alphabetically for consistent ordering
14
+ # 3. Divides them into N equal groups (e.g., 10 groups)
15
+ # 4. Runs RSpec on the files in the specified group
16
+ #
17
+ # USAGE:
18
+ # bin/run_spec_group <group_number> <total_groups>
19
+ #
20
+ # EXAMPLES:
21
+ # bin/run_spec_group 1 10 # Run group 1 out of 10 (files 1-15 if 150 total)
22
+ # bin/run_spec_group 5 10 # Run group 5 out of 10 (files 61-75 if 150 total)
23
+ # bin/run_spec_group 1 3 # Run first third of all specs
24
+ #
25
+ # CI USAGE (GitHub Actions):
26
+ # - Matrix strategy runs 10-12 parallel jobs (groups 1-12)
27
+ # - Each job runs ~1/10th or ~1/12th of total specs
28
+ # - Total test time = slowest group time (not sum of all)
29
+ # - Example: 150 specs × 2s each = 300s sequential
30
+ # 150 specs ÷ 10 groups = 15 specs × 2s = 30s parallel
31
+ # 150 specs ÷ 12 groups = 13 specs × 2s = 26s parallel
32
+ #
33
+ # LOCAL USAGE:
34
+ # - Run specific group to debug failing tests
35
+ # - Run multiple groups in parallel terminals
36
+ # - Faster iteration than running all specs
37
+ #
38
+ # OUTPUT:
39
+ # - Shows total spec count
40
+ # - Shows files per group
41
+ # - Lists all files in current group
42
+ # - Runs RSpec with those files
43
+ #
44
+ # WHERE USED:
45
+ # - .github/workflows/test_and_build_gem.yml (test job, matrix strategy)
46
+ # - Can be run locally for debugging
47
+ #
48
+ # ============================================================================
49
+
50
+ group_num = ARGV[0]&.to_i
51
+ total_groups = ARGV[1]&.to_i
52
+
53
+ unless group_num && group_num > 0 && group_num <= total_groups
54
+ puts "Usage: bin/run_spec_group <group_number> [total_groups]"
55
+ puts "Example: bin/run_spec_group 1 10"
56
+ puts " Runs group 1 out of 10 total groups"
57
+ exit 1
58
+ end
59
+
60
+ spec_dir = File.expand_path('../spec', __dir__)
61
+
62
+ # Find all spec files and sort for consistent ordering
63
+ all_spec_files = Dir.glob("#{spec_dir}/**/*_spec.rb").sort
64
+
65
+ if all_spec_files.empty?
66
+ puts "❌ No spec files found in #{spec_dir}"
67
+ exit 1
68
+ end
69
+
70
+ # Calculate files per group
71
+ files_per_group = (all_spec_files.length.to_f / total_groups).ceil
72
+
73
+ # Get the files for this specific group
74
+ start_idx = (group_num - 1) * files_per_group
75
+ end_idx = [start_idx + files_per_group - 1, all_spec_files.length - 1].min
76
+
77
+ if start_idx >= all_spec_files.length
78
+ puts "⚠️ Group #{group_num} has no spec files (total: #{all_spec_files.length} files across #{total_groups} groups)"
79
+ exit 0
80
+ end
81
+
82
+ group_files = all_spec_files[start_idx..end_idx]
83
+
84
+ puts "=" * 80
85
+ puts "Running Group #{group_num}/#{total_groups}"
86
+ puts "Total spec files: #{all_spec_files.length}"
87
+ puts "Files per group: ~#{files_per_group}"
88
+ puts "This group: #{group_files.length} files (#{start_idx + 1}-#{end_idx + 1})"
89
+ puts "=" * 80
90
+ puts
91
+ puts "📋 Files in this group:"
92
+ group_files.each_with_index do |file, idx|
93
+ relative_path = file.sub("#{spec_dir}/", '')
94
+ puts " #{start_idx + idx + 1}. #{relative_path}"
95
+ end
96
+ puts
97
+ puts "=" * 80
98
+ puts
99
+
100
+ # Run rspec with the specific files
101
+ exec("bundle", "exec", "rspec", *group_files)
@@ -454,6 +454,14 @@ en:
454
454
  admin:
455
455
  display_on:
456
456
  frontend_for_early_adopter: "Storefront for Early Adopter"
457
+ external_integrations_title: "External Integrations"
458
+ integration_sync_enqueued: "Integration sync has been enqueued"
459
+ integration_inactive: "Integration is inactive"
460
+ external_integrations:
461
+ view_result: "View Result"
462
+ sync_result: "Sync Result"
463
+ external_payload: "External Payload"
464
+ no_results: "No results found"
457
465
  event:
458
466
  check_in:
459
467
  success: "Guest check-in in successfully"
@@ -341,6 +341,14 @@ km:
341
341
  admin:
342
342
  display_on:
343
343
  frontend_for_early_adopter: "Storefront for Early Adopter"
344
+ external_integrations_title: "ការភ្ជាប់ខាងក្រៅ"
345
+ integration_sync_enqueued: "ការធ្វើសមកាលកម្មបានដាក់ក្នុងជួរ"
346
+ integration_inactive: "ការភ្ជាប់មិនដំណើរការ"
347
+ external_integrations:
348
+ view_result: "មើលលទ្ធផល"
349
+ sync_result: "លទ្ធផលធ្វើសមកាលកម្ម"
350
+ external_payload: "ទិន្នន័យខាងក្រៅ"
351
+ no_results: "មិនមានលទ្ធផលទេ"
344
352
  event:
345
353
  check_in:
346
354
  success: "Guest check-in in successfully"
data/config/routes.rb CHANGED
@@ -283,6 +283,13 @@ Spree::Core::Engine.add_routes do
283
283
  post :set_webhook
284
284
  end
285
285
  end
286
+ resources :integrations do
287
+ member do
288
+ post :enqueue_polling
289
+ end
290
+ resources :sessions, only: [:index], controller: 'integration_sessions'
291
+ resources :mappings, only: [:index], controller: 'integration_mappings'
292
+ end
286
293
 
287
294
  resources :webhooks_subscribers do
288
295
  resources :rules, controller: :webhooks_subscriber_rules, except: %i[index show]
@@ -695,6 +702,7 @@ Spree::Core::Engine.add_routes do
695
702
 
696
703
  resources :seat_layouts, only: %i[show index]
697
704
  resources :inventory_items, only: %i[index]
705
+ resources :event_matches, only: %i[index]
698
706
  namespace :transit do
699
707
  resources :draft_orders, only: %i[create]
700
708
  end
@@ -0,0 +1,22 @@
1
+ class CreateCmIntegrations < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :cm_integrations, if_not_exists: true do |t|
4
+ t.string :type, null: false
5
+ t.string :name, null: false
6
+
7
+ t.integer :status, null: false, default: 0
8
+ t.integer :conflict_strategy, null: false, default: 0
9
+
10
+ t.integer :incremental_sync_interval_seconds, null: false, default: 10
11
+ t.integer :full_sync_interval_hours, null: false, default: 24
12
+
13
+ t.references :tenant, foreign_key: { to_table: :cm_tenants }, null: true
14
+ t.references :vendor, foreign_key: { to_table: :spree_vendors }, null: false
15
+
16
+ t.jsonb :public_metadata, default: {}
17
+ t.jsonb :private_metadata, default: {}
18
+
19
+ t.timestamps
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,68 @@
1
+ class CreateCmIntegrationSyncSessions < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :cm_integration_sync_sessions, if_not_exists: true do |t|
4
+ t.integer :status, null: false
5
+ t.integer :sync_type, null: false
6
+
7
+ t.datetime :started_at
8
+ t.datetime :ended_at
9
+
10
+ t.references :integration, foreign_key: { to_table: :cm_integrations }, null: false
11
+ t.references :tenant, foreign_key: { to_table: :cm_tenants }, null: true
12
+
13
+ # Stores the serialized sync result (SyncResult#to_h) for this session.
14
+ # This field is used to persist the outcome of the sync operation,
15
+ # allowing for detailed tracking of results, whether the sync was successful or not.
16
+ #
17
+ # Base class: app/services/spree_cm_commissioner/integrations/base/sync_result.rb
18
+ #
19
+ # Example implementations include:
20
+ # - Full sync results: integrations/stadium_x_v1/sync_results/full_sync_result.rb
21
+ # - Incremental sync results: integrations/stadium_x_v1/sync_results/incremental_sync_result.rb
22
+ #
23
+ # The result is saved upon completion of the session in the SyncManager#run_execution method:
24
+ # - app/services/spree_cm_commissioner/integrations/base/sync_manager.rb
25
+ t.jsonb :sync_result, default: {}
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ # Indexes for PollingScheduler queries (optimized for performance)
31
+ # Initially used in polling_scheduler.rb
32
+ #
33
+ # Index 1: Find in_progress/pending syncs created within last hour
34
+ # Used by: should_run_full_sync? and should_run_incremental_sync?
35
+ # Query: .where(sync_type: [...], status: [...]).where('created_at > ?', 1.hour.ago).exists?
36
+ # Why: Composite index on all filter columns for fast lookup of active syncs
37
+ unless index_exists?(:cm_integration_sync_sessions, [:integration_id, :sync_type, :status, :created_at], name: 'idx_sync_sessions_integration_type_status_created_at')
38
+ add_index :cm_integration_sync_sessions, [:integration_id, :sync_type, :status, :created_at],
39
+ name: 'idx_sync_sessions_integration_type_status_created_at'
40
+ end
41
+
42
+ # Index 2: Find latest completed full sync (partial index - only completed rows)
43
+ # Used by: should_run_full_sync?
44
+ # Query: .where(sync_type: :full, status: :completed).order(created_at: :desc).first
45
+ # Why: Partial index reduces size by ~60% (excludes in_progress/pending/failed rows)
46
+ # Descending order on created_at allows DB to return latest without sorting
47
+ # Only 2 columns needed since sync_type and status are filtered by WHERE clause
48
+ unless index_exists?(:cm_integration_sync_sessions, [:integration_id, :created_at], name: 'idx_sync_sessions_full_completed')
49
+ add_index :cm_integration_sync_sessions, [:integration_id, :created_at],
50
+ name: 'idx_sync_sessions_full_completed',
51
+ where: "sync_type = 0 AND status = 2", # full=0, completed=2
52
+ order: { created_at: :desc }
53
+ end
54
+
55
+ # Index 3: Find latest completed incremental sync (partial index - only completed rows)
56
+ # Used by: should_run_incremental_sync?
57
+ # Query: .where(sync_type: :incremental, status: :completed).order(created_at: :desc).first
58
+ # Why: Partial index reduces size by ~60% (excludes in_progress/pending/failed rows)
59
+ # Descending order on created_at allows DB to return latest without sorting
60
+ # Only 2 columns needed since sync_type and status are filtered by WHERE clause
61
+ unless index_exists?(:cm_integration_sync_sessions, [:integration_id, :created_at], name: 'idx_sync_sessions_incremental_completed')
62
+ add_index :cm_integration_sync_sessions, [:integration_id, :created_at],
63
+ name: 'idx_sync_sessions_incremental_completed',
64
+ where: "sync_type = 1 AND status = 2", # incremental=1, completed=2
65
+ order: { created_at: :desc }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,52 @@
1
+ class CreateCmIntegrationMappings < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :cm_integration_mappings, if_not_exists: true do |t|
4
+ t.string :external_id, null: false
5
+ t.string :internal_type, null: false
6
+ t.bigint :internal_id, null: false
7
+ t.jsonb :external_payload, default: nil
8
+
9
+ # optional for record that can have different mappings on different dates
10
+ t.date :date, null: true
11
+ t.integer :status, null: false, default: 0
12
+ t.references :integration, null: false, foreign_key: { to_table: :cm_integrations }
13
+
14
+ t.jsonb :public_metadata, default: {}
15
+ t.jsonb :private_metadata, default: {}
16
+
17
+ t.datetime :last_synced_at
18
+ t.timestamps
19
+ end
20
+
21
+ # Index 1: Unique constraint for external_id mappings
22
+ # Used by: IntegrationMapping#validates :external_id
23
+ # Query: validates :external_id, uniqueness: { scope: %i[integration_id internal_type internal_id date] }
24
+ # Why: Ensures a single external_id maps to only one internal record per integration/date combination
25
+ # Prevents duplicate mappings that could cause sync conflicts
26
+ # Unique index enforces this at database level for data integrity
27
+ unless index_exists?(:cm_integration_mappings, [:integration_id, :external_id, :internal_type, :internal_id, :date], name: 'idx_external_internal_mapping')
28
+ add_index :cm_integration_mappings, [:integration_id, :external_id, :internal_type, :internal_id, :date], unique: true, name: 'idx_external_internal_mapping'
29
+ end
30
+
31
+ # Index 2: Efficient lookup by integration_id and external_id
32
+ # Used by: IntegrationMappable#find_by_integration, IntegrationMappable#find_or_initialize_by_integration,
33
+ # IntegrationMappable#find_or_initialize_by_integration_with_mapping
34
+ #
35
+ # Query: .find_by(integration_id: integration_id, external_id: external_id)
36
+ # Why: Composite index allows efficient lookup by both integration_id and external_id
37
+ # These methods are called frequently during sync operations
38
+ unless index_exists?(:cm_integration_mappings, [:integration_id, :internal_type, :external_id], name: 'idx_integration_external_id')
39
+ add_index :cm_integration_mappings, [:integration_id, :internal_type, :external_id], name: 'idx_integration_external_id'
40
+ end
41
+
42
+ # Index 3: Incremental sync query optimization
43
+ # Used by: IntegrationMappable#find_oldest_active_mapping (called by IncrementalSyncStrategy every 10 seconds)
44
+ # Query: .where(integration_id: integration_id, internal_type: model_name).active.order(:last_synced_at).first
45
+ # Why: Composite index filters by integration_id, internal_type, status efficiently
46
+ # Includes last_synced_at for ORDER BY to avoid expensive sort operation
47
+ # Runs every 10s so performance is critical - even small optimizations matter at this frequency
48
+ unless index_exists?(:cm_integration_mappings, [:integration_id, :internal_type, :status, :last_synced_at], name: 'idx_incremental_sync')
49
+ add_index :cm_integration_mappings, [:integration_id, :internal_type, :status, :last_synced_at], name: 'idx_incremental_sync'
50
+ end
51
+ end
52
+ end
data/lib/cm_app_logger.rb CHANGED
@@ -1,17 +1,18 @@
1
1
  # lib/cm_app_logger.rb
2
2
  module CmAppLogger
3
3
  def self.log(label:, data: nil)
4
- message = { label: label, data: data }
4
+ message = { label: label, data: safe_serialize(data) }
5
5
  start_time = Time.current
6
6
  Rails.logger.info(message.to_json)
7
7
 
8
8
  return unless block_given?
9
9
 
10
- # Capture the blocks return value and return it to preserve existing behavior for callers expecting that value.
10
+ # Capture the block's return value and return it to preserve existing behavior for callers expecting that value.
11
11
  block_result = yield
12
12
 
13
- message[:start_time] = start_time
13
+ message[:start_time] = start_time.iso8601(3)
14
14
  message[:duration_ms] = (Time.current - start_time) * 1000
15
+ message[:result] = safe_serialize(block_result)
15
16
  Rails.logger.info(message.to_json)
16
17
  block_result
17
18
  end
@@ -19,9 +20,40 @@ module CmAppLogger
19
20
  def self.error(label:, data: nil)
20
21
  message = {
21
22
  label: label,
22
- data: data
23
+ data: safe_serialize(data)
23
24
  }
24
25
 
25
26
  Rails.logger.error(message.to_json)
26
27
  end
28
+
29
+ # Safely serializes objects for JSON logging.
30
+ #
31
+ # @param obj [Object] The object to serialize
32
+ # @param depth [Integer] Internal parameter tracking recursion depth (max: 50)
33
+ # @return [Object] A JSON-safe representation of the input object
34
+ def self.safe_serialize(obj, depth: 0) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
35
+ return '[Max Depth Exceeded]' if depth > 50
36
+
37
+ if obj.is_a?(Hash)
38
+ obj.each_with_object({}) do |(k, v), memo|
39
+ memo[safe_serialize(k, depth: depth + 1)] = safe_serialize(v, depth: depth + 1)
40
+ end
41
+ elsif obj.is_a?(Array)
42
+ obj.map { |item| safe_serialize(item, depth: depth + 1) }
43
+ elsif obj.is_a?(Date)
44
+ obj.iso8601
45
+ elsif obj.is_a?(Time) || obj.is_a?(DateTime) || obj.is_a?(ActiveSupport::TimeWithZone)
46
+ obj.iso8601(3)
47
+ elsif obj.is_a?(ActiveJob::Base)
48
+ {
49
+ job_class: obj.class.name,
50
+ job_id: obj.job_id,
51
+ arguments: safe_serialize(obj.arguments, depth: depth + 1)
52
+ }
53
+ elsif obj.respond_to?(:id)
54
+ { class: obj.class.name, id: obj.id }
55
+ else
56
+ obj.to_s
57
+ end
58
+ end
27
59
  end
@@ -0,0 +1,25 @@
1
+ FactoryBot.define do
2
+ factory :cm_integration, class: SpreeCmCommissioner::Integration do
3
+ type { 'SpreeCmCommissioner::Integrations::StadiumXV1' }
4
+ name { FFaker::Company.name }
5
+ status { :active }
6
+ conflict_strategy { :newest_wins }
7
+ incremental_sync_interval_seconds { 300 }
8
+ full_sync_interval_hours { 24 }
9
+ association :vendor, factory: :cm_vendor
10
+
11
+ trait :active do
12
+ status { :active }
13
+ end
14
+
15
+ trait :inactive do
16
+ status { :inactive }
17
+ end
18
+ end
19
+
20
+ factory :cm_stadium_x_v1_integration, parent: :cm_integration, class: SpreeCmCommissioner::Integrations::StadiumXV1 do
21
+ public_key { FFaker::Lorem.characters(20) }
22
+ private_key { FFaker::Lorem.characters(40) }
23
+ base_url { 'https://api.stadiumx.com' }
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ FactoryBot.define do
2
+ factory :cm_integration_mapping, class: SpreeCmCommissioner::IntegrationMapping do
3
+ external_id { SecureRandom.alphanumeric(10) }
4
+ association :integration, factory: :cm_integration
5
+ status { :active }
6
+
7
+ transient do
8
+ internal { create(:cm_product) }
9
+ end
10
+
11
+ internal_id { internal.id }
12
+ internal_type { internal.class.name }
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ FactoryBot.define do
2
+ factory :cm_integration_sync_session, class: SpreeCmCommissioner::IntegrationSyncSession do
3
+ status { :pending }
4
+ sync_type { :full }
5
+ association :integration, factory: :cm_integration
6
+ end
7
+ end
@@ -5,6 +5,7 @@ FactoryBot.define do
5
5
  end
6
6
 
7
7
  factory :cm_vendor, parent: :vendor do
8
+ sequence(:name) { |n| "#{FFaker::Company.name} #{n}#{Kernel.rand(9999)}" }
8
9
  state { :active }
9
10
  default_state_id { Spree::State.first&.id }
10
11
  primary_product_type { :ecommerce }
@@ -1,6 +1,6 @@
1
1
  FactoryBot.define do
2
2
  factory :cm_location_vendor_place, class: SpreeCmCommissioner::VendorPlace do
3
- association :vendor, factory: :vendor
3
+ association :vendor, factory: :cm_vendor
4
4
  association :place, factory: :cm_place
5
5
 
6
6
  distance { FFaker::Number.decimal }
@@ -10,7 +10,7 @@ FactoryBot.define do
10
10
  end
11
11
 
12
12
  factory :cm_vendor_place, class: SpreeCmCommissioner::VendorPlace do
13
- association :vendor, factory: :vendor
13
+ association :vendor, factory: :cm_vendor
14
14
  association :place, factory: :cm_place
15
15
  association :location, factory: :cm_location_vendor_place
16
16
 
@@ -28,6 +28,7 @@ FactoryBot.define do
28
28
 
29
29
  trait :location do
30
30
  place_type { :location }
31
+ location { nil }
31
32
  end
32
33
  end
33
34
  end
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.5.0.pre.pre8'.freeze
2
+ VERSION = '2.5.0.pre.pre10'.freeze
3
3
 
4
4
  module_function
5
5
 
@@ -55,16 +55,17 @@ require 'byebug' if Rails.env.development? || Rails.env.test?
55
55
 
56
56
  module SpreeCmCommissioner
57
57
  class << self
58
- # Allows overriding the default Redis connection pool with a custom one
59
- attr_writer :redis_pool
58
+ # Allows overriding the default Redis connection pools with custom ones
59
+ attr_writer :inventory_redis_pool
60
60
 
61
- def redis_pool
62
- @redis_pool ||= default_redis_pool
61
+ # Inventory Redis pool for inventory management
62
+ def inventory_redis_pool
63
+ @inventory_redis_pool ||= default_redis_pool
63
64
  end
64
65
 
65
- # Resets the Redis pool, useful for testing or reinitialization
66
- def reset_redis_pool
67
- @redis_pool = nil
66
+ # Resets all Redis pools, useful for testing or reinitialization
67
+ def reset_redis_pools
68
+ @inventory_redis_pool = nil
68
69
  end
69
70
 
70
71
  private