spree_cm_commissioner 2.9.3.pre.pre1 → 2.9.4

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/concerns/spree_cm_commissioner/load_test_account_scope.rb +14 -21
  4. data/app/controllers/concerns/spree_cm_commissioner/load_test_bypassable.rb +29 -0
  5. data/app/controllers/spree/api/chatrace/guests_controller.rb +1 -1
  6. data/app/controllers/spree/api/v2/storefront/account/guest_dynamic_fields_controller.rb +19 -48
  7. data/app/controllers/spree/api/v2/storefront/account/mark_guest_info_complete_controller.rb +2 -2
  8. data/app/controllers/spree/api/v2/storefront/account/orders_controller_decorator.rb +5 -15
  9. data/app/controllers/spree/api/v2/storefront/account/saved_guests_controller.rb +65 -0
  10. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +3 -0
  11. data/app/controllers/spree/api/v2/storefront/guests_controller.rb +10 -9
  12. data/app/controllers/spree/api/v2/storefront/order_histories_controller.rb +0 -1
  13. data/app/controllers/spree/api/v2/tenant/account/saved_guests_controller.rb +70 -0
  14. data/app/controllers/spree/api/v2/tenant/guests_controller.rb +11 -21
  15. data/app/controllers/spree/api/v2/tenant/order_histories_controller.rb +0 -1
  16. data/app/controllers/spree_cm_commissioner/api/v2/storefront/checkout_controller_decorator.rb +3 -14
  17. data/app/jobs/spree_cm_commissioner/maintenance_tasks/guest_data_fill_stage_batch_job.rb +28 -0
  18. data/app/models/concerns/spree_cm_commissioner/guest_data_fill_stage_concern.rb +82 -0
  19. data/app/models/concerns/spree_cm_commissioner/line_item_durationable.rb +4 -0
  20. data/app/models/concerns/spree_cm_commissioner/line_item_guests_concern.rb +1 -1
  21. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +13 -31
  22. data/app/models/spree_cm_commissioner/current.rb +6 -3
  23. data/app/models/spree_cm_commissioner/dynamic_field.rb +12 -0
  24. data/app/models/spree_cm_commissioner/guest.rb +1 -144
  25. data/app/models/spree_cm_commissioner/guest_dynamic_field.rb +43 -71
  26. data/app/models/spree_cm_commissioner/maintenance_tasks/guest_data_fill_stage.rb +21 -0
  27. data/app/models/spree_cm_commissioner/order_decorator.rb +26 -0
  28. data/app/models/spree_cm_commissioner/product_decorator.rb +0 -22
  29. data/app/models/spree_cm_commissioner/product_dynamic_field.rb +17 -0
  30. data/app/models/spree_cm_commissioner/request_guard.rb +19 -0
  31. data/app/models/spree_cm_commissioner/user_decorator.rb +26 -1
  32. data/app/queries/spree_cm_commissioner/event_chart_queries.rb +2 -2
  33. data/app/queries/spree_cm_commissioner/pie_chart_event_aggregator_queries.rb +2 -2
  34. data/app/serializers/spree/v2/storefront/line_item_serializer_decorator.rb +2 -2
  35. data/app/serializers/spree/v2/storefront/user_serializer_decorator.rb +3 -1
  36. data/app/serializers/spree/v2/tenant/saved_guest_serializer.rb +23 -0
  37. data/app/serializers/spree_cm_commissioner/v2/storefront/cart_serializer_decorator.rb +1 -0
  38. data/app/serializers/spree_cm_commissioner/v2/storefront/guest_serializer.rb +3 -6
  39. data/app/serializers/spree_cm_commissioner/v2/storefront/ticket_transfer_serializer.rb +2 -2
  40. data/app/services/spree_cm_commissioner/{cart/create_guest.rb → guests/create.rb} +12 -10
  41. data/app/services/spree_cm_commissioner/guests/refresh_data_fill_stage.rb +20 -0
  42. data/app/services/spree_cm_commissioner/guests/send_dynamic_field_notification.rb +15 -0
  43. data/app/services/spree_cm_commissioner/guests/update.rb +68 -0
  44. data/app/services/spree_cm_commissioner/inventory_holds/validate_limits.rb +8 -3
  45. data/app/services/spree_cm_commissioner/load_test/destroy_accounts.rb +7 -10
  46. data/app/services/spree_cm_commissioner/order/guests/update.rb +5 -8
  47. data/config/initializers/spree_permitted_attributes.rb +19 -0
  48. data/config/locales/en.yml +0 -5
  49. data/config/routes.rb +3 -1
  50. data/db/migrate/20260714002626_rename_data_fill_stage_phase_on_cm_guests.rb +5 -0
  51. data/lib/spree_cm_commissioner/version.rb +1 -1
  52. data/lib/tasks/recompute_guest_data_fill_stage.rake +19 -0
  53. metadata +18 -14
  54. data/app/controllers/spree/api/v2/storefront/saved_guests_controller.rb +0 -77
  55. data/app/interactors/spree_cm_commissioner/guest_dynamic_field_notification_sender.rb +0 -11
  56. data/app/interactors/spree_cm_commissioner/guest_dynamic_fields_manager.rb +0 -33
  57. data/app/serializers/spree/v2/storefront/order_serializer_decorator.rb +0 -63
  58. data/app/serializers/spree_cm_commissioner/v2/storefront/guest_minimal_serializer.rb +0 -30
  59. data/app/serializers/spree_cm_commissioner/v2/storefront/line_item_minimal_serializer.rb +0 -57
  60. data/app/serializers/spree_cm_commissioner/v2/storefront/ticket_transfer_minimal_serializer.rb +0 -17
  61. data/app/services/spree_cm_commissioner/update_guest_service.rb +0 -129
  62. data/app/services/spree_cm_commissioner/users/incomplete_guest_checker_service.rb +0 -34
@@ -88,7 +88,7 @@ module SpreeCmCommissioner
88
88
 
89
89
  # public_metadata override for a guest count, only when it's a non-negative integer;
90
90
  # nil otherwise so callers fall back to the variant default. Keeps number_of_guests >= 0
91
- # (a negative would make custom_guest_count? false and spin the bump loop in Cart::CreateGuest).
91
+ # (a negative would make custom_guest_count? false and spin the bump loop in Guests::Create).
92
92
  def metadata_guest_count(key)
93
93
  return nil unless valid_integer_metadata?(key)
94
94
 
@@ -1,10 +1,12 @@
1
1
  # Make sure to put this concern below other concern or methods that generating additional order info like guests, seat number, etc.
2
2
  # This will ensure that when each notification is sent with neccessary data.
3
3
  module SpreeCmCommissioner
4
- module OrderStateMachine # rubocop:disable Metrics/ModuleLength
4
+ module OrderStateMachine
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do # rubocop:disable Metrics/BlockLength
8
+ after_commit :clear_has_incomplete_guest_info_cache, on: %i[create update]
9
+
8
10
  state_machine.before_transition to: :address, do: :hold_order_holds
9
11
 
10
12
  state_machine.before_transition to: :complete, do: :request, if: :need_confirmation?
@@ -19,7 +21,7 @@ module SpreeCmCommissioner
19
21
 
20
22
  state_machine.around_transition to: :complete, do: :unstock_inventory!
21
23
 
22
- state_machine.after_transition to: :complete, do: :mark_user_as_has_incomplete_guest_info, if: :has_incomplete_guest_info?
24
+ state_machine.after_transition to: :complete, do: :send_dynamic_field_notification_to_guests
23
25
  state_machine.after_transition to: :complete, do: :send_transaction_email_to_user, if: :user_has_email?
24
26
 
25
27
  state_machine.before_transition to: :resumed, do: :clear_cancellation_details
@@ -118,29 +120,6 @@ module SpreeCmCommissioner
118
120
  end
119
121
  end
120
122
 
121
- def mark_user_as_has_incomplete_guest_info
122
- user&.public_metadata&.[]=('has_incomplete_guest_info', true)
123
- user&.save!
124
-
125
- line_items.each do |line_item|
126
- line_item.guests.each do |guest|
127
- guest.user ||= user
128
- guest.save!(validate: false) if guest.changed?
129
- guest.save_and_move_to_next_stage
130
- # send notification only for guests that still have incomplete dynamic fields
131
- SpreeCmCommissioner::GuestDynamicFieldNotificationSender.call(guest: guest) if guest_incomplete?(guest)
132
- end
133
- end
134
- end
135
-
136
- def has_incomplete_guest_info? # rubocop:disable Naming/PredicateName
137
- line_items.any? do |line_item|
138
- line_item.guests.any? do |guest|
139
- guest_incomplete?(guest)
140
- end
141
- end
142
- end
143
-
144
123
  # allow authorized user to accept all requested line items
145
124
  def accepted_by(user)
146
125
  transaction do
@@ -152,12 +131,6 @@ module SpreeCmCommissioner
152
131
  end
153
132
  end
154
133
 
155
- def guest_incomplete?(guest)
156
- (guest.pre_registration_fields? && !guest.pre_registration_completed?) ||
157
- (guest.post_registration_fields? && !guest.post_registration_completed?) ||
158
- (guest.during_check_in_fields? && !guest.check_in_completed?)
159
- end
160
-
161
134
  def unstock_inventory!
162
135
  ActiveRecord::Base.transaction do
163
136
  yield if block_given? # Equal to block.call
@@ -285,6 +258,15 @@ module SpreeCmCommissioner
285
258
  @telegram_alert_enabled = line_items.any?(&:enable_telegram_alert?)
286
259
  end
287
260
 
261
+ def send_dynamic_field_notification_to_guests
262
+ incomplete_guests = line_items.flat_map(&:guests).select(&:incomplete?)
263
+ return if incomplete_guests.empty?
264
+
265
+ incomplete_guests.each do |guest|
266
+ SpreeCmCommissioner::Guests::SendDynamicFieldNotification.call(guest: guest)
267
+ end
268
+ end
269
+
288
270
  def send_order_complete_telegram_alert_to_vendors
289
271
  return unless telegram_alert_enabled?
290
272
 
@@ -1,8 +1,11 @@
1
1
  module SpreeCmCommissioner
2
2
  class Current < ActiveSupport::CurrentAttributes
3
- # Set to true for the duration of a load-test request so that rate-limiting
4
- # services deeper in the call chain (e.g. InventoryHolds::ValidateLimits) can
5
- # skip their checks without needing the request object threaded through them.
3
+ # True during a load-test request, letting deep services (e.g.
4
+ # InventoryHolds::ValidateLimits) skip anti-abuse checks without threading
5
+ # the request through them.
6
+ #
7
+ # Thread-safe: CurrentAttributes keeps values per thread/fiber and Rails
8
+ # resets them each request, so concurrent requests never share this value.
6
9
  attribute :load_test_bypass
7
10
  end
8
11
  end
@@ -18,6 +18,8 @@ module SpreeCmCommissioner
18
18
  validates :configurations, presence: true, allow_blank: false
19
19
  validate :validate_data_fill_stage_phase
20
20
 
21
+ after_commit :enqueue_data_fill_stage_maintenance_for_products, on: %i[update], if: :saved_change_to_data_fill_stage?
22
+
21
23
  scope :with_dynamic_field_options, -> { includes(dynamic_field_options: :dynamic_field) }
22
24
 
23
25
  def requires_dynamic_field_options?
@@ -45,6 +47,16 @@ module SpreeCmCommissioner
45
47
 
46
48
  private
47
49
 
50
+ # Reassigning which phase this field belongs to affects every product that has it enabled,
51
+ # not just one — same recompute/notify need as ProductDynamicField create.
52
+ def enqueue_data_fill_stage_maintenance_for_products
53
+ product_dynamic_fields.distinct.pluck(:product_id).each do |product_id|
54
+ SpreeCmCommissioner::MaintenanceTasks::GuestDataFillStage
55
+ .pending
56
+ .find_or_create_by(maintainable_type: 'Spree::Product', maintainable_id: product_id)
57
+ end
58
+ end
59
+
48
60
  def validate_data_fill_stage_phase
49
61
  return if data_fill_stage.blank?
50
62
 
@@ -4,6 +4,7 @@ module SpreeCmCommissioner
4
4
  include SpreeCmCommissioner::KycBitwise
5
5
  include SpreeCmCommissioner::PhoneNumberSanitizer
6
6
  include SpreeCmCommissioner::Integrations::IntegrationMappable
7
+ include SpreeCmCommissioner::GuestDataFillStageConcern
7
8
 
8
9
  delegate :kyc, to: :line_item, allow_nil: true
9
10
  delegate :allowed_upload_later?, to: :line_item, allow_nil: true
@@ -68,7 +69,6 @@ module SpreeCmCommissioner
68
69
  after_update :preload_order_block_ids, if: :saved_change_to_block_id?
69
70
  before_destroy :cancel_reserved_block!, if: -> { reserved_block.present? }
70
71
  after_destroy :preload_order_block_ids
71
- after_commit :update_user_incomplete_guest_info_status, if: :should_update_incomplete_guest_info?
72
72
 
73
73
  validates :bib_index, uniqueness: true, allow_nil: true
74
74
 
@@ -368,154 +368,11 @@ module SpreeCmCommissioner
368
368
  )
369
369
  end
370
370
 
371
- def all_required_dynamic_fields_completed?(stage)
372
- missing_dynamic_fields_for_stage(stage).blank?
373
- end
374
-
375
- state_machine :data_fill_stage_phase, initial: 'pre_registration' do
376
- state 'pre_registration', value: 'pre_registration'
377
- state 'post_registration', value: 'post_registration'
378
- state 'during_check_in', value: 'during_check_in'
379
- state 'completed', value: 'completed'
380
-
381
- event :complete_pre_registration do
382
- transition 'pre_registration' => 'post_registration',
383
- if: -> (guest) { guest.pre_registration_completed? && guest.post_registration_fields? }
384
- transition 'pre_registration' => 'during_check_in',
385
- if: -> (guest) { guest.pre_registration_completed? && !guest.post_registration_fields? && guest.during_check_in_fields? }
386
- transition 'pre_registration' => 'completed',
387
- if: -> (guest) { guest.pre_registration_completed? && !guest.post_registration_fields? && !guest.during_check_in_fields? }
388
- end
389
-
390
- event :complete_post_registration do
391
- transition 'post_registration' => 'during_check_in',
392
- if: -> (guest) { guest.post_registration_completed? && guest.during_check_in_fields? }
393
- transition 'post_registration' => 'completed',
394
- if: -> (guest) { guest.post_registration_completed? && !guest.during_check_in_fields? }
395
- end
396
-
397
- event :complete_check_in do
398
- transition 'during_check_in' => 'completed',
399
- if: -> (guest) { guest.check_in_completed? }
400
- end
401
- end
402
-
403
- def save_and_move_to_next_stage(_transition = nil)
404
- # Trigger state machine transitions based on completion status
405
- case data_fill_stage_phase
406
- when 'pre_registration'
407
- complete_pre_registration! if pre_registration_completed?
408
- when 'post_registration'
409
- complete_post_registration! if post_registration_completed?
410
- when 'during_check_in'
411
- complete_check_in! if check_in_completed?
412
- end
413
- end
414
-
415
- def completed_phases
416
- phases = []
417
- phases << 'pre_registration' if pre_registration_completed?
418
- phases << 'post_registration' if post_registration_completed?
419
- phases << 'during_check_in' if check_in_completed?
420
- phases
421
- end
422
-
423
- def pre_registration_completed?
424
- return false unless line_item&.order&.completed?
425
- return true unless line_item&.product&.dynamic_fields&.pre_registration&.any?
426
-
427
- required_fields = line_item.product.dynamic_fields.pre_registration.pluck(:id)
428
- filled_fields = guest_dynamic_fields.where(dynamic_field_id: required_fields).pluck(:dynamic_field_id)
429
- (required_fields - filled_fields).empty?
430
- end
431
-
432
- def post_registration_completed?
433
- return false unless line_item&.order&.completed?
434
- return true unless line_item&.product&.dynamic_fields&.post_registration&.any?
435
-
436
- required_fields = line_item.product.dynamic_fields.post_registration.pluck(:id)
437
- filled_fields = guest_dynamic_fields.where(dynamic_field_id: required_fields).pluck(:dynamic_field_id)
438
- (required_fields - filled_fields).empty?
439
- end
440
-
441
- def check_in_completed?
442
- return false unless line_item&.order&.completed?
443
- return true unless line_item&.product&.dynamic_fields&.during_check_in&.any?
444
-
445
- required_fields = line_item.product.dynamic_fields.during_check_in.pluck(:id)
446
- filled_fields = guest_dynamic_fields.where(dynamic_field_id: required_fields).pluck(:dynamic_field_id)
447
- (required_fields - filled_fields).empty?
448
- end
449
-
450
- def pre_registration_fields?
451
- line_item&.product&.dynamic_fields&.pre_registration&.any?
452
- end
453
-
454
- def post_registration_fields?
455
- line_item&.product&.dynamic_fields&.post_registration&.any?
456
- end
457
-
458
- def during_check_in_fields?
459
- line_item&.product&.dynamic_fields&.during_check_in&.any?
460
- end
461
-
462
- def product_dynamic_fields
463
- @product_dynamic_fields ||= line_item.product.dynamic_fields
464
- end
465
-
466
- def missing_dynamic_fields_for_stage(stage)
467
- return [] if line_item.blank? || line_item.product.blank?
468
-
469
- # Cache product dynamic fields to avoid multiple queries
470
- @product_dynamic_fields ||= line_item.product.dynamic_fields
471
- required_fields = @product_dynamic_fields.where(data_fill_stage: stage)
472
- filled_ids = guest_dynamic_fields&.pluck(:dynamic_field_id) || []
473
-
474
- required_fields.reject { |field| filled_ids.include?(field.id) }
475
- end
476
-
477
371
  def reject_guest_dynamic_field?(attributes)
478
372
  # Reject if dynamic_field_id is blank or if _destroy is true
479
373
  attributes[:dynamic_field_id].blank? || ActiveModel::Type::Boolean.new.cast(attributes[:_destroy])
480
374
  end
481
375
 
482
- def update_user_incomplete_guest_info_status
483
- return unless user
484
- return unless line_item&.order&.completed?
485
-
486
- user.reload
487
-
488
- has_incomplete = user.orders.complete.any? do |order|
489
- order_has_incomplete_guests?(order)
490
- end
491
-
492
- current_status = user.public_metadata['has_incomplete_guest_info'] || false
493
-
494
- return unless current_status != has_incomplete
495
-
496
- user.public_metadata['has_incomplete_guest_info'] = has_incomplete
497
- user.save!
498
- end
499
-
500
- def order_has_incomplete_guests?(order)
501
- order.line_items.any? do |line_item|
502
- line_item.guests.any? do |guest|
503
- guest_incomplete?(guest)
504
- end
505
- end
506
- end
507
-
508
- def guest_incomplete?(guest)
509
- (guest.pre_registration_fields? && !guest.pre_registration_completed?) ||
510
- (guest.post_registration_fields? && !guest.post_registration_completed?) ||
511
- (guest.during_check_in_fields? && !guest.check_in_completed?)
512
- end
513
-
514
- def should_update_incomplete_guest_info?
515
- saved_change_to_data_fill_stage_phase? ||
516
- (guest_dynamic_fields.any? && (guest_dynamic_fields.any?(&:saved_changes?) || guest_dynamic_fields.any?(&:destroyed?)))
517
- end
518
-
519
376
  # Ticket Transfer validations
520
377
  def allows_transfer?
521
378
  line_item&.product&.allow_transfer?
@@ -4,26 +4,49 @@ module SpreeCmCommissioner
4
4
  belongs_to :dynamic_field, class_name: 'SpreeCmCommissioner::DynamicField', optional: false
5
5
  belongs_to :dynamic_field_option, class_name: 'SpreeCmCommissioner::DynamicFieldOption', optional: true
6
6
 
7
+ before_validation :assign_dynamic_field_option, if: :requires_dynamic_field_options?
8
+
9
+ validates :value, presence: true
10
+
7
11
  validate :validate_value_format, if: -> { value.present? && dynamic_field.present? }
8
- validate :validate_option_reference, if: -> { dynamic_field_option.present? }
9
- validate :validate_phase_sequence
10
- validate :check_required_fields_based_on_phase
11
- validate :reject_empty_string_for_text_fields
12
+ validate :validate_phase_sequence, if: -> { dynamic_field.present? && guest.present? }
13
+ validate :dynamic_field_unchanged, on: :update
12
14
 
13
- after_commit :sync_guest_data_fill_stage_phase
15
+ # Fires once per changed field in a batch (e.g. Guests::Update saving N rows in one transaction).
16
+ # Each call recomputes the same already-correct value since all N changes are committed by the time
17
+ # any after_commit runs, so only the first call's write is real. Accepted: the alternative is either manual
18
+ # call sites (the exact bug this design replaced) or a fragile transaction-scoped dedup guard.
19
+ after_commit { guest&.refresh_data_fill_stage }
14
20
 
15
- scope :with_dynamic_field_and_options, lambda {
16
- includes(:dynamic_field, :dynamic_field_option)
17
- }
21
+ scope :with_dynamic_field_and_options, -> { includes(:dynamic_field, :dynamic_field_option) }
18
22
 
19
23
  self.whitelisted_ransackable_associations = %w[guest]
20
24
  self.whitelisted_ransackable_attributes = %w[value]
21
25
 
26
+ def requires_dynamic_field_options?
27
+ return false if dynamic_field.blank?
28
+
29
+ dynamic_field.requires_dynamic_field_options?
30
+ end
31
+
22
32
  private
23
33
 
24
- def validate_value_format
25
- return if dynamic_field.blank?
34
+ # If no option is set yet, look it up from `value` -- by option id first, then by
35
+ # option value (unifies the two shapes callers submit a chosen option in: `value: "3"`
36
+ # vs `value: "red"`). Then overwrite `value` with the option's canonical value, so it
37
+ # stays a uniform human-readable string for consumers that read it generically
38
+ # regardless of field type (chatrace payloads, CSV export) instead of a raw option id.
39
+ def assign_dynamic_field_option
40
+ if dynamic_field_option.blank?
41
+ self.dynamic_field_option =
42
+ dynamic_field.dynamic_field_options.find_by(id: value) ||
43
+ dynamic_field.dynamic_field_options.find_by(value: value)
44
+ end
26
45
 
46
+ self.value = dynamic_field_option.value if dynamic_field_option.present?
47
+ end
48
+
49
+ def validate_value_format # rubocop:disable Metrics/CyclomaticComplexity
27
50
  case dynamic_field.data_type.to_s
28
51
  when 'text', 'textarea'
29
52
  errors.add(:value, "can't be blank") if value.is_a?(String) && value.strip == ''
@@ -32,74 +55,23 @@ module SpreeCmCommissioner
32
55
  when 'boolean'
33
56
  errors.add(:value, 'must be true or false') unless %w[true false].include?(value.to_s.downcase)
34
57
  when 'checkbox', 'radio', 'selection'
35
- validate_option_value
36
- end
37
- end
38
-
39
- def validate_option_value
40
- if dynamic_field_option.present?
41
- self.value ||= dynamic_field_option.value
42
- else
43
- valid_options = dynamic_field.dynamic_field_options.pluck(:id).map(&:to_s)
44
- errors.add(:value, 'is not a valid option') unless valid_options.include?(value)
58
+ valid_option = dynamic_field_option.present? && dynamic_field_option.dynamic_field_id == dynamic_field_id
59
+ errors.add(:value, 'is not a valid option') unless valid_option
45
60
  end
46
61
  end
47
62
 
48
- def validate_option_reference
49
- return if dynamic_field.blank? || dynamic_field_option.blank?
50
- return if dynamic_field_option.dynamic_field_id == dynamic_field_id
51
-
52
- # Only validate if both dynamic_field and dynamic_field_option are persisted
53
- # This prevents validation errors during creation when associations might not be fully loaded
54
- return if dynamic_field.new_record? || dynamic_field_option.new_record?
55
-
56
- errors.add(:dynamic_field_option, 'must belong to the same dynamic field')
57
- end
58
-
63
+ # Blocks answering a later-stage field (e.g. post_registration) until every earlier stage
64
+ # (e.g. pre_registration) is complete for this guest.
59
65
  def validate_phase_sequence
60
- return if dynamic_field.blank? || guest.blank?
66
+ stages = GuestDataFillStageConcern::STAGES
67
+ prior_stages = stages.first(stages.index(dynamic_field.data_fill_stage))
61
68
 
62
- case dynamic_field.data_fill_stage.to_sym
63
- when :post_registration
64
- errors.add(:base, 'Must complete pre_registration fields first') unless guest.pre_registration_completed?
65
- when :during_check_in
66
- unless guest.post_registration_completed? || !guest.post_registration_fields?
67
- errors.add(:base, 'Must complete post_registration fields first')
68
- end
69
- end
70
- end
71
-
72
- def field_required_in_current_phase?
73
- return false if dynamic_field.blank? || guest.blank?
74
-
75
- case dynamic_field.data_fill_stage.to_sym
76
- when :pre_registration, :post_registration
77
- guest.line_item.order.completed? && guest.check_ins.empty?
78
- when :during_check_in
79
- guest.check_ins.exists?
80
- else
81
- false
82
- end
69
+ incomplete = prior_stages.find { |stage| !guest.stage_completed?(stage) }
70
+ errors.add(:base, "Must complete #{incomplete} fields first") if incomplete
83
71
  end
84
72
 
85
- def check_required_fields_based_on_phase
86
- return if dynamic_field.blank? || value.present?
87
- return unless field_required_in_current_phase?
88
-
89
- errors.add(:value, I18n.t("guest_dynamic_field.errors.#{dynamic_field.data_fill_stage}_required"))
90
- end
91
-
92
- def sync_guest_data_fill_stage_phase
93
- guest&.save_and_move_to_next_stage
94
- guest&.update_user_incomplete_guest_info_status
95
- end
96
-
97
- def reject_empty_string_for_text_fields
98
- return if dynamic_field.blank?
99
- return unless %w[text textarea].include?(dynamic_field.data_type.to_s)
100
- return unless value.is_a?(String)
101
-
102
- errors.add(:value, "can't be blank") if value.strip == ''
73
+ def dynamic_field_unchanged
74
+ errors.add(:dynamic_field_id, 'cannot be changed') if dynamic_field_id_changed?
103
75
  end
104
76
  end
105
77
  end
@@ -0,0 +1,21 @@
1
+ module SpreeCmCommissioner
2
+ module MaintenanceTasks
3
+ class GuestDataFillStage < MaintenanceTask
4
+ BATCH_SIZE = ENV.fetch('MAINTENANCE_TASKS_GUEST_DATA_FILL_STAGE_BATCH_SIZE', '300').to_i
5
+
6
+ def maintain
7
+ product = maintainable
8
+
9
+ SpreeCmCommissioner::Guest
10
+ .joins(line_item: %i[variant order])
11
+ .where(spree_variants: { product_id: product.id })
12
+ .merge(Spree::Order.complete)
13
+ .order(:id)
14
+ .pluck(:id)
15
+ .each_slice(BATCH_SIZE) do |guest_ids|
16
+ SpreeCmCommissioner::MaintenanceTasks::GuestDataFillStageBatchJob.perform_later(guest_ids: guest_ids, notify: true)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -259,6 +259,32 @@ module SpreeCmCommissioner
259
259
  guests.exists?(state: %i[locked transferred])
260
260
  end
261
261
 
262
+ def has_incomplete_guest_info? # rubocop:disable Naming/PredicateName
263
+ # expires_in is a safety net, not the primary invalidation path — clear_has_incomplete_guest_info_cache
264
+ # is still called explicitly wherever the underlying guest data can change. This just bounds
265
+ # how long a missed invalidation could leave a stale value in place.
266
+ #
267
+ # Reads the persisted data_fill_stage column via a join instead of Guest#incomplete? (which
268
+ # recomputes live per guest). post_registration is the only stage that matters here: a
269
+ # completed order already forces pre_registration complete (guest_data_fill_stage_concern.rb),
270
+ # and during_check_in is check-in crew's concern, not something this banner surfaces —
271
+ # matches find_orders_with_incomplete_guest_info in orders_controller_decorator.rb.
272
+ Rails.cache.fetch(incomplete_guest_info_cache_key, expires_in: 1.hour) do
273
+ guests.post_registration.exists?
274
+ end
275
+ end
276
+
277
+ # Clears this order's own cache plus the purchaser's cross-order aggregate cache —
278
+ # anything that can change the former can also change the latter.
279
+ def clear_has_incomplete_guest_info_cache
280
+ Rails.cache.delete(incomplete_guest_info_cache_key)
281
+ user&.clear_has_incomplete_guest_info_cache
282
+ end
283
+
284
+ def incomplete_guest_info_cache_key
285
+ "order/#{id}/has_incomplete_guest_info"
286
+ end
287
+
262
288
  def generate_svg_qr
263
289
  qrcode = RQRCode::QRCode.new(qr_data)
264
290
  qrcode.as_svg(
@@ -183,28 +183,6 @@ module SpreeCmCommissioner
183
183
  end
184
184
  end
185
185
 
186
- def dynamic_fields_by_collection_phase
187
- {
188
- pre_registration: dynamic_fields.pre_registration.includes(:dynamic_field_options).order(:position),
189
- post_registration: dynamic_fields.post_registration.includes(:dynamic_field_options).order(:position),
190
- during_check_in: dynamic_fields.during_check_in.includes(:dynamic_field_options).order(:position)
191
- }
192
- end
193
-
194
- def required_dynamic_fields_completed?(guest, phase)
195
- required_fields = dynamic_fields.where(data_fill_stage: phase)
196
- return true if required_fields.empty?
197
-
198
- filled_ids = guest.guest_dynamic_fields.where(dynamic_field_id: required_fields.select(:id)).pluck(:dynamic_field_id)
199
- (required_fields.pluck(:id) - filled_ids).empty?
200
- end
201
-
202
- def required_fields_for_guest(guest)
203
- dynamic_fields_by_collection_phase.transform_values do |fields|
204
- fields.select { |field| guest.guest_dynamic_fields.where(dynamic_field: field).empty? }
205
- end
206
- end
207
-
208
186
  private
209
187
 
210
188
  def set_tenant
@@ -4,5 +4,22 @@ module SpreeCmCommissioner
4
4
  belongs_to :dynamic_field, class_name: 'SpreeCmCommissioner::DynamicField', optional: false
5
5
 
6
6
  validates :dynamic_field_id, uniqueness: { scope: :product_id }
7
+
8
+ after_commit :enqueue_data_fill_stage_maintenance, on: %i[create]
9
+
10
+ private
11
+
12
+ # Only newly-added fields need active recompute: adding a field can make an
13
+ # already-complete guest newly incomplete, which needs a persisted-column fix and a
14
+ # catch-up notification. Removing a field can only shrink the required set, which
15
+ # stage_completed? already reflects correctly the instant it's checked live (no
16
+ # GuestDynamicField answers get destroyed by unlinking a field from a product) — no guest
17
+ # is ever made worse off, and nothing needs notifying, so it's not worth an async trigger.
18
+ # The persisted data_fill_stage column self-heals on that guest's next natural write.
19
+ def enqueue_data_fill_stage_maintenance
20
+ SpreeCmCommissioner::MaintenanceTasks::GuestDataFillStage
21
+ .pending
22
+ .find_or_create_by(maintainable_type: 'Spree::Product', maintainable_id: product_id)
23
+ end
7
24
  end
8
25
  end
@@ -0,0 +1,19 @@
1
+ module SpreeCmCommissioner
2
+ # Runs a protected block only for normal requests, so callers don't touch
3
+ # Current.load_test_bypass directly. Exempt requests (e.g. trusted load tests)
4
+ # skip the block and get nil back.
5
+ #
6
+ # include SpreeCmCommissioner::RequestGuard
7
+ #
8
+ # with_request_guard { validate_ip_rate_limit!(order) }
9
+ module RequestGuard
10
+ module_function
11
+
12
+ # Runs the block unless the request is exempt; returns its value, or nil.
13
+ def with_request_guard
14
+ return nil if SpreeCmCommissioner::Current.load_test_bypass
15
+
16
+ yield
17
+ end
18
+ end
19
+ end
@@ -52,7 +52,6 @@ module SpreeCmCommissioner
52
52
  base.scope :by_non_tenant, -> { where(tenant_id: nil) }
53
53
  base.scope :vendor_users, -> (vendor_id) { joins(:role_users => :role).where(spree_roles: { vendor_id: vendor_id }).distinct }
54
54
 
55
- base.store :public_metadata, accessors: [:has_incomplete_guest_info], coder: JSON
56
55
  base.store :private_metadata, accessors: %i[otp_secret otp_required_for_login consumed_timestep], coder: JSON
57
56
 
58
57
  base.store_public_metadata :primary_service_type, :string
@@ -66,6 +65,15 @@ module SpreeCmCommissioner
66
65
  # the id_token param. Nothing token-related is stored on the account itself.
67
66
  base.store_private_metadata :load_test_account, :boolean, default: false
68
67
 
68
+ # SQL counterpart of `load_test_account?`, so the flag can be filtered in the DB instead of
69
+ # loading rows into Ruby to call the predicate. private_metadata is stored double-encoded (a
70
+ # JSON string scalar, not a native jsonb object — see store_metadata), so unwrap the outer
71
+ # string with `#>> '{}'` before keying into it. Keeps the storage detail in one place next to
72
+ # the declaration above rather than duplicating the raw SQL in every caller.
73
+ base.scope :load_test_accounts, lambda {
74
+ where("(private_metadata #>> '{}')::jsonb ->> 'load_test_account' = 'true'")
75
+ }
76
+
69
77
  # Validations
70
78
  base.validates :primary_service_type,
71
79
  inclusion: { in: SpreeCmCommissioner::ServiceType::SERVICE_TYPES.map(&:to_s) },
@@ -223,6 +231,23 @@ module SpreeCmCommissioner
223
231
  super(request)
224
232
  end
225
233
  end
234
+
235
+ def has_incomplete_guest_info? # rubocop:disable Naming/PredicateName
236
+ # expires_in is a safety net, not the primary invalidation path — see the matching comment
237
+ # on Order#has_incomplete_guest_info?. Joins straight to guests.post_registration instead of
238
+ # calling Order#has_incomplete_guest_info? per order (which would be N+1 query calls).
239
+ Rails.cache.fetch(incomplete_guest_info_cache_key, expires_in: 1.hour) do
240
+ orders.complete.joins(:guests).merge(SpreeCmCommissioner::Guest.post_registration).exists?
241
+ end
242
+ end
243
+
244
+ def clear_has_incomplete_guest_info_cache
245
+ Rails.cache.delete(incomplete_guest_info_cache_key)
246
+ end
247
+
248
+ def incomplete_guest_info_cache_key
249
+ "user/#{id}/has_incomplete_guest_info"
250
+ end
226
251
  end
227
252
  end
228
253
 
@@ -59,7 +59,7 @@ module SpreeCmCommissioner
59
59
  WHEN cm_check_ins.id IS NOT NULL THEN 'show'
60
60
  ELSE 'no_show'
61
61
  END AS name,
62
- COUNT(*) AS total"
62
+ COUNT(DISTINCT cm_guests.id) AS total"
63
63
  )
64
64
  .group('spree_products.id, CASE WHEN cm_check_ins.id IS NOT NULL THEN \'show\' ELSE \'no_show\' END')
65
65
  .group_by(&:product_id)
@@ -98,7 +98,7 @@ module SpreeCmCommissioner
98
98
  WHEN cm_check_ins.entry_type = 0 THEN 'normal'
99
99
  WHEN cm_check_ins.entry_type = 1 THEN 'VIP'
100
100
  END AS name,
101
- COUNT(cm_check_ins.id) AS total"
101
+ COUNT(DISTINCT cm_guests.id) AS total"
102
102
  )
103
103
  .group('spree_products.id, cm_check_ins.entry_type')
104
104
  .group_by(&:product_id)
@@ -58,7 +58,7 @@ module SpreeCmCommissioner
58
58
  WHEN cm_check_ins.id IS NOT NULL THEN 'show'
59
59
  ELSE 'no_show'
60
60
  END AS name,
61
- COUNT(*) AS total"
61
+ COUNT(DISTINCT cm_guests.id) AS total"
62
62
  )
63
63
  .group('spree_products.id, CASE WHEN cm_check_ins.id IS NOT NULL THEN \'show\' ELSE \'no_show\' END')
64
64
  .group_by(&:product_id)
@@ -110,7 +110,7 @@ module SpreeCmCommissioner
110
110
  CASE WHEN cm_check_ins.entry_type = 0 THEN 'normal'
111
111
  WHEN cm_check_ins.entry_type = 1 THEN 'VIP'
112
112
  ELSE NULL END AS name,
113
- COUNT(cm_check_ins.id) AS total"
113
+ COUNT(DISTINCT cm_guests.id) AS total"
114
114
  )
115
115
  .where.not(cm_check_ins: { id: nil })
116
116
  .group('spree_products.id, cm_check_ins.entry_type')