spree_cm_commissioner 2.9.4 → 2.9.5

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/concerns/spree_cm_commissioner/turnstile_protectable.rb +9 -7
  4. data/app/controllers/spree/api/v2/operator/event_charts_controller.rb +1 -0
  5. data/app/controllers/spree/api/v2/operator/guest_json_gzips_controller.rb +14 -4
  6. data/app/controllers/spree/api/v2/storefront/account_checker_controller.rb +4 -0
  7. data/app/controllers/spree/api/v2/tenant/account/orders_controller.rb +1 -1
  8. data/app/controllers/spree/api/v2/tenant/my_votes_controller.rb +51 -0
  9. data/app/controllers/spree/api/v2/tenant/show_people_controller.rb +1 -4
  10. data/app/controllers/spree/api/v2/tenant/show_person_assignments_controller.rb +4 -1
  11. data/app/controllers/spree/api/v2/tenant/voting_contestants_controller.rb +6 -2
  12. data/app/interactors/spree_cm_commissioner/host_matcher.rb +6 -5
  13. data/app/interactors/spree_cm_commissioner/turnstile_token_validator.rb +43 -13
  14. data/app/models/concerns/spree_cm_commissioner/order_qr_codeable.rb +39 -0
  15. data/app/models/concerns/spree_cm_commissioner/order_scopes.rb +5 -0
  16. data/app/models/spree_cm_commissioner/check_in_session.rb +36 -0
  17. data/app/models/spree_cm_commissioner/exports/operator_guest_json_gzip.rb +26 -1
  18. data/app/models/spree_cm_commissioner/guest.rb +4 -0
  19. data/app/models/spree_cm_commissioner/maintenance_tasks/event.rb +9 -1
  20. data/app/models/spree_cm_commissioner/order_decorator.rb +2 -37
  21. data/app/models/spree_cm_commissioner/role_decorator.rb +41 -4
  22. data/app/models/spree_cm_commissioner/role_user_decorator.rb +11 -0
  23. data/app/models/spree_cm_commissioner/show_person.rb +5 -1
  24. data/app/models/spree_cm_commissioner/show_person_assignment.rb +1 -1
  25. data/app/models/spree_cm_commissioner/taxon_decorator.rb +2 -0
  26. data/app/models/spree_cm_commissioner/tenant.rb +17 -6
  27. data/app/models/spree_cm_commissioner/user_decorator.rb +32 -0
  28. data/app/models/spree_cm_commissioner/voting_contestant.rb +2 -0
  29. data/app/queries/spree_cm_commissioner/event_chart_queries.rb +23 -7
  30. data/app/queries/spree_cm_commissioner/guest_searcher_query.rb +16 -3
  31. data/app/queries/spree_cm_commissioner/pie_chart_event_aggregator_queries.rb +1 -0
  32. data/app/queries/spree_cm_commissioner/user_votes_per_contestant_query.rb +48 -0
  33. data/app/serializers/spree/v2/tenant/my_vote_serializer.rb +19 -0
  34. data/app/serializers/spree/v2/tenant/show_serializer.rb +2 -1
  35. data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +2 -0
  36. data/app/services/spree_cm_commissioner/check_ins/create_bulk.rb +10 -6
  37. data/app/services/spree_cm_commissioner/imports/contestants/create.rb +19 -1
  38. data/app/services/spree_cm_commissioner/operator_guest_json_gzips/create.rb +14 -7
  39. data/app/services/spree_cm_commissioner/organizer/export_guest_csv_service.rb +12 -11
  40. data/app/services/spree_cm_commissioner/show_contestants/normalize_video_highlights.rb +5 -1
  41. data/app/views/spree/admin/tenants/_form.html.erb +13 -1
  42. data/config/locales/en.yml +12 -7
  43. data/config/locales/km.yml +12 -4
  44. data/config/routes.rb +1 -0
  45. data/db/migrate/20260720000001_add_check_in_fields_to_spree_roles.rb +11 -0
  46. data/db/migrate/20260723000000_add_payment_host_to_cm_tenants.rb +6 -0
  47. data/db/migrate/20260723000001_add_metadata_to_spree_roles.rb +11 -0
  48. data/db/migrate/20260728074153_add_role_to_cm_exports.rb +5 -0
  49. data/lib/spree_cm_commissioner/test_helper/factories/vehicle_factory.rb +1 -1
  50. data/lib/spree_cm_commissioner/version.rb +1 -1
  51. metadata +10 -2
@@ -5,7 +5,11 @@ module SpreeCmCommissioner
5
5
  has_many :show_person_images, -> { order(:position) }, as: :viewable, class_name: 'SpreeCmCommissioner::ShowPersonImage', dependent: :destroy
6
6
  has_many :assignments, class_name: 'SpreeCmCommissioner::ShowPersonAssignment', dependent: :destroy
7
7
  has_many :shows, through: :assignments, source: :show
8
- has_many :assigned_contestants, class_name: 'SpreeCmCommissioner::ShowContestant', foreign_key: :assigned_show_person_id, dependent: :nullify
8
+ has_many :assigned_contestants,
9
+ -> { active },
10
+ class_name: 'SpreeCmCommissioner::ShowContestant',
11
+ foreign_key: :assigned_show_person_id,
12
+ dependent: :nullify
9
13
 
10
14
  validates :name, presence: true
11
15
 
@@ -12,7 +12,7 @@ module SpreeCmCommissioner
12
12
  validates :role, presence: true
13
13
 
14
14
  has_many :assigned_contestants,
15
- -> (assignment) { where(show_id: assignment.show_id) },
15
+ -> (assignment) { where(show_id: assignment.show_id).active },
16
16
  class_name: 'SpreeCmCommissioner::ShowContestant',
17
17
  primary_key: :show_person_id,
18
18
  foreign_key: :assigned_show_person_id,
@@ -18,6 +18,8 @@ module SpreeCmCommissioner
18
18
  base.has_many :products, through: :classifications, class_name: 'Spree::Product'
19
19
  base.has_many :guests, foreign_key: :event_id, class_name: 'SpreeCmCommissioner::Guest', dependent: :nullify
20
20
  base.has_many :check_ins, as: :checkinable, class_name: 'SpreeCmCommissioner::CheckIn', dependent: :nullify
21
+ # Check-in groups for this event (Spree::Role, role_type :check_in). Scoped by event_id (indexed),
22
+ base.has_many :check_in_groups, -> { filter_check_in }, class_name: 'Spree::Role', foreign_key: :event_id
21
23
  base.has_many :customer_taxons, class_name: 'SpreeCmCommissioner::CustomerTaxon'
22
24
 
23
25
  base.has_many :seat_layouts, as: :layoutable, class_name: 'SpreeCmCommissioner::SeatLayout', dependent: :destroy
@@ -13,10 +13,13 @@ module SpreeCmCommissioner
13
13
  through: :vendors,
14
14
  source: :payment_methods
15
15
 
16
- validates :host, presence: true, uniqueness: true,
17
- format: { without: %r{\Ahttps?://},
18
- message: :format
19
- }
16
+ validates :host, presence: true,
17
+ uniqueness: true,
18
+ format: { without: %r{\Ahttps?://}, message: :format }
19
+
20
+ validates :payment_host, uniqueness: true,
21
+ allow_blank: true,
22
+ format: { without: %r{\Ahttps?://}, message: :format }
20
23
 
21
24
  validates :sms_sender_id, length: { maximum: 11 }, allow_blank: true
22
25
 
@@ -35,13 +38,21 @@ module SpreeCmCommissioner
35
38
  end
36
39
 
37
40
  def formatted_url
38
- return if host.blank?
41
+ format_host(host)
42
+ end
39
43
 
40
- Rails.env.development? || Rails.env.test? ? "http://#{host}" : "https://#{host}"
44
+ def payment_url
45
+ format_host(payment_host)
41
46
  end
42
47
 
43
48
  private
44
49
 
50
+ def format_host(host)
51
+ return if host.blank?
52
+
53
+ Rails.env.development? || Rails.env.test? ? "http://#{host}" : "https://#{host}"
54
+ end
55
+
45
56
  def generate_slug
46
57
  self.slug = name.parameterize if slug.blank?
47
58
  end
@@ -154,6 +154,38 @@ module SpreeCmCommissioner
154
154
  has_spree_role?('operator')
155
155
  end
156
156
 
157
+ # Check-in Group: this crew's check-in group (Spree::Role, role_type :check_in) for the event.
158
+ # One role per user per event — memoized per event so a bulk check-in looks it up once, not per
159
+ # guest. `nil` = no group (can check in any product). Uses `key?` (not `||=`) so the common "no
160
+ # group" result — itself `nil` — is cached too, instead of re-querying on every call.
161
+ def check_in_group_for(event_id)
162
+ @check_in_group_cache ||= {}
163
+ return @check_in_group_cache[event_id] if @check_in_group_cache.key?(event_id)
164
+
165
+ @check_in_group_cache[event_id] = spree_roles.find_by(role_type: :check_in, event_id: event_id)
166
+ end
167
+
168
+ # Whether this user may check in the given guest. Admins and organizers are never restricted by a
169
+ # check-in group; otherwise the crew may check in the guest's ticket if their check-in group allows
170
+ # it (include/exclude evaluated on that one group).
171
+ def can_check_in_guest?(guest)
172
+ return true if admin? || organizer?
173
+
174
+ group = check_in_group_for(guest.event_id)
175
+ return true if group.nil?
176
+
177
+ group.check_in_allows?(guest.line_item)
178
+ end
179
+
180
+ # This user's check-in role for the event, used to scope the operator offline export (one role, one
181
+ # export — see Exports::OperatorGuestJsonGzip#role). `nil` = unrestricted (admins/organizers, or
182
+ # crew in no group).
183
+ def check_in_role_for(event)
184
+ return nil if admin? || organizer?
185
+
186
+ check_in_group_for(event.id)
187
+ end
188
+
157
189
  def vendor_organizer?(vendor)
158
190
  spree_roles.any? do |role|
159
191
  role.name == "#{vendor.id}-organizer"
@@ -41,6 +41,8 @@ module SpreeCmCommissioner
41
41
  validate :advanced_to_and_advanced_from_must_differ
42
42
 
43
43
  delegate :name, :contestant_number, :category, :gender, :eliminated_at, to: :show_contestant, allow_nil: true
44
+ delegate :assigned_show_person, to: :show_contestant, allow_nil: true
45
+ delegate :name, to: :assigned_show_person, prefix: true, allow_nil: true
44
46
  delegate :can_vote?, to: :voting_session, allow_nil: true
45
47
 
46
48
  def special_rule_type=(value)
@@ -1,16 +1,19 @@
1
1
  module SpreeCmCommissioner
2
2
  class EventChartQueries
3
- attr_reader :taxon_id, :chart_type, :refreshed
3
+ attr_reader :taxon_id, :chart_type, :check_in_session_id, :refreshed
4
4
 
5
5
  # chart_type: 'participation | gender | entry_type' taxon_id: taxon_id, refreshed: false
6
- def initialize(taxon_id:, chart_type:, refreshed: false)
6
+ # check_in_session_id: scopes check-in counts to a single session. When nil, a guest
7
+ # is counted once regardless of how many sessions they checked into.
8
+ def initialize(taxon_id:, chart_type:, check_in_session_id: nil, refreshed: false)
7
9
  @taxon_id = taxon_id
8
10
  @chart_type = chart_type || 'participation'
11
+ @check_in_session_id = check_in_session_id.presence
9
12
  @refreshed = refreshed
10
13
  end
11
14
 
12
15
  def call
13
- cache_key = "#{chart_type}-#{taxon_id}"
16
+ cache_key = [chart_type, taxon_id, check_in_session_id].compact.join('-')
14
17
  Rails.cache.delete(cache_key) if refreshed
15
18
 
16
19
  product_charts = Rails.cache.fetch(cache_key, expires_in: 30.minutes) do
@@ -38,10 +41,10 @@ module SpreeCmCommissioner
38
41
 
39
42
  def base_joins
40
43
  query = SpreeCmCommissioner::Guest
41
- join_type = chart_type.in?(%w[participation occupation nationality]) ? 'LEFT' : 'INNER'
44
+ join_type = chart_type.in?(%w[participation guest_occupation guest_nationality]) ? 'LEFT' : 'INNER'
42
45
 
43
46
  query
44
- .joins("#{join_type} JOIN cm_check_ins ON cm_check_ins.guest_id = cm_guests.id")
47
+ .joins("#{join_type} JOIN cm_check_ins ON #{check_in_join_condition}")
45
48
  .joins('INNER JOIN spree_line_items ON spree_line_items.id = cm_guests.line_item_id')
46
49
  .joins('INNER JOIN spree_variants ON spree_variants.id = spree_line_items.variant_id')
47
50
  .joins('INNER JOIN spree_orders ON spree_orders.id = spree_line_items.order_id')
@@ -49,6 +52,19 @@ module SpreeCmCommissioner
49
52
  .joins('INNER JOIN spree_products_taxons ON spree_products.id = spree_products_taxons.product_id')
50
53
  .joins('INNER JOIN spree_taxons ON spree_taxons.id = spree_products_taxons.taxon_id')
51
54
  .where(spree_orders: { state: 'complete' }, spree_taxons: { parent_id: taxon_id })
55
+ .where.not(cm_guests: { state: :transferred })
56
+ end
57
+
58
+ # Filter check-ins to a single session inside the JOIN's ON clause (not WHERE), so
59
+ # LEFT JOINs still keep no-show guests. When no session is given, every check-in row
60
+ # is joined and the DISTINCT-guest counts below collapse multiple sessions into one.
61
+ def check_in_join_condition
62
+ condition = 'cm_check_ins.guest_id = cm_guests.id'
63
+ return condition if check_in_session_id.blank?
64
+
65
+ ActiveRecord::Base.sanitize_sql_array(
66
+ ["#{condition} AND cm_check_ins.check_in_session_id = ?", check_in_session_id]
67
+ )
52
68
  end
53
69
 
54
70
  def participation_event_chart
@@ -117,7 +133,7 @@ module SpreeCmCommissioner
117
133
  spree_products.name AS product_name,
118
134
  occupation_taxons.name AS name,
119
135
  COUNT(DISTINCT cm_guests.id) AS total,
120
- COUNT(DISTINCT cm_check_ins.id) AS show'
136
+ COUNT(DISTINCT cm_check_ins.guest_id) AS show'
121
137
  )
122
138
  .group('spree_products.id, spree_products.name, occupation_taxons.name')
123
139
  .group_by(&:product_id)
@@ -136,7 +152,7 @@ module SpreeCmCommissioner
136
152
  spree_products.name AS product_name,
137
153
  nationality_taxons.name AS name,
138
154
  COUNT(DISTINCT cm_guests.id) AS total,
139
- COUNT(DISTINCT cm_check_ins.id) AS show'
155
+ COUNT(DISTINCT cm_check_ins.guest_id) AS show'
140
156
  )
141
157
  .group('spree_products.id, spree_products.name, nationality_taxons.name')
142
158
  .group_by(&:product_id)
@@ -7,10 +7,25 @@ module SpreeCmCommissioner
7
7
  end
8
8
 
9
9
  def event_id = params[:event_id]
10
+ def product_ids = params[:product_ids]
10
11
 
11
- def call # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
12
+ def call
12
13
  return SpreeCmCommissioner::Guest.none if event_id.blank?
13
14
 
15
+ # Optional check-in-group scope (see Role#check_in_scoped_product_ids), set by the operator export
16
+ # and the dashboard preview. nil = unrestricted; an array narrows to guests whose ticket's product
17
+ # is in it — an EMPTY array is a real "no tickets" scope and correctly yields no guests.
18
+ if product_ids.nil?
19
+ query
20
+ else
21
+ query.joins(line_item: :variant).where(spree_variants: { product_id: product_ids })
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # The base guest search: QR (order / line item / guest / user), term, ids, or the whole event.
28
+ def query # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
14
29
  if params[:qr_data].present? && order_qr_data?
15
30
  search_by_order_qr
16
31
  elsif params[:qr_data].present? && line_item_qr_data?
@@ -31,8 +46,6 @@ module SpreeCmCommissioner
31
46
  end
32
47
  end
33
48
 
34
- private
35
-
36
49
  def search_by_order_qr
37
50
  order = Spree::Order.complete_or_canceled.search_by_qr_data!(params[:qr_data])
38
51
  order.guests.where(event_id: event_id)
@@ -48,6 +48,7 @@ module SpreeCmCommissioner
48
48
  .joins('INNER JOIN spree_products_taxons ON spree_products.id = spree_products_taxons.product_id')
49
49
  .joins('INNER JOIN spree_taxons ON spree_taxons.id = spree_products_taxons.taxon_id')
50
50
  .where(spree_orders: { state: 'complete' }, spree_taxons: { parent_id: taxon_id })
51
+ .where.not(cm_guests: { state: :transferred })
51
52
  end
52
53
 
53
54
  def participation_pie_chart
@@ -0,0 +1,48 @@
1
+ # Returns a voting_session's contestants, each carrying the current user's own
2
+ # vote tally for that contestant as a computed my_vote_count attribute.
3
+ #
4
+ # The DB (cm_votes) is the source of truth for per-user tallies — Redis only
5
+ # holds the global, non-user-scoped leaderboard totals, so it cannot answer
6
+ # "how many votes did THIS user give each contestant".
7
+ #
8
+ # Runs as two queries total, regardless of contestant count:
9
+ # 1. voting_contestants LEFT JOINed against a per-contestant SUM(quantity)
10
+ # subquery scoped to this user+session (no N+1, no per-record query)
11
+ # 2. a batched preload of show_contestant (needed for name/contestant_number)
12
+ #
13
+ # Usage:
14
+ # SpreeCmCommissioner::UserVotesPerContestantQuery.new(
15
+ # user: current_user, voting_session: session
16
+ # ).contestants
17
+ module SpreeCmCommissioner
18
+ class UserVotesPerContestantQuery
19
+ attr_reader :user, :voting_session
20
+
21
+ def initialize(user:, voting_session:)
22
+ @user = user
23
+ @voting_session = voting_session
24
+ end
25
+
26
+ def contestants
27
+ return SpreeCmCommissioner::VotingContestant.none if user.nil? || voting_session.nil?
28
+
29
+ voting_session.voting_contestants
30
+ .includes(:show_contestant)
31
+ .select('cm_voting_contestants.*, COALESCE(user_votes.total_quantity, 0) AS my_vote_count')
32
+ .joins(
33
+ "LEFT JOIN (#{user_votes_subquery.to_sql}) user_votes " \
34
+ 'ON user_votes.contestant_id = cm_voting_contestants.id'
35
+ )
36
+ .order(:vote_number)
37
+ end
38
+
39
+ private
40
+
41
+ def user_votes_subquery
42
+ SpreeCmCommissioner::Vote
43
+ .select(:contestant_id, 'SUM(quantity) AS total_quantity')
44
+ .where(user_id: user.id, voting_session_id: voting_session.id)
45
+ .group(:contestant_id)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,19 @@
1
+ module Spree
2
+ module V2
3
+ module Tenant
4
+ # Serializes a VotingContestant together with the current user's own vote
5
+ # tally (my_vote_count) for that contestant in a voting session.
6
+ #
7
+ # my_vote_count is a virtual attribute (see VotingContestant) that the
8
+ # controller sets on each record before serialization — see
9
+ # MyVotesController#collection.
10
+ class MyVoteSerializer < BaseSerializer
11
+ set_type :voting_contestant
12
+
13
+ attributes :name, :contestant_number, :vote_number, :my_vote_count
14
+
15
+ attribute :voting_session_id, &:voting_session_id
16
+ end
17
+ end
18
+ end
19
+ end
@@ -5,7 +5,8 @@ module Spree
5
5
  set_type :show
6
6
 
7
7
  attributes :slug, :description, :permalink, :from_date, :to_date, :preview, :created_at, :updated_at,
8
- :free_vote_limit, :free_vote_limit_type, :show_type, :credit_votable_type
8
+ :free_vote_limit, :free_vote_limit_type, :show_type, :credit_votable_type,
9
+ :background_color, :foreground_color
9
10
 
10
11
  attribute :name, &:display_name
11
12
 
@@ -10,6 +10,8 @@ module Spree
10
10
 
11
11
  attribute :voting_session_id, &:voting_session_id
12
12
 
13
+ attribute :assigned_person_name, &:assigned_show_person_name
14
+
13
15
  attribute :can_vote, &:can_vote?
14
16
 
15
17
  belongs_to :voting_session, serializer: ::Spree::V2::Tenant::VotingSessionSerializer
@@ -19,12 +19,7 @@ module SpreeCmCommissioner
19
19
  guest = indexed_guests[attrs[:guest_id].to_i]
20
20
  raise ActiveRecord::RecordNotFound, 'Guest or related record not found' if guest.blank?
21
21
 
22
- order = guest.line_item.order
23
- fail_check_in!(guest, 'Cannot check in Guest from cancelled order') if order.canceled?
24
-
25
- # Validate guest state for check-in
26
- fail_check_in!(guest, 'Guest is locked and cannot be checked in') if guest.locked?
27
- fail_check_in!(guest, 'Guest has been transferred and cannot be checked in') if guest.transferred?
22
+ validate_guest_for_check_in!(guest, check_in_by)
28
23
 
29
24
  check_in = check_in!(guest, attrs, check_in_by, confirmed_at)
30
25
  update_guest!(guest, attrs[:guest_attributes]) if attrs[:guest_attributes].present?
@@ -48,6 +43,15 @@ module SpreeCmCommissioner
48
43
  raise error
49
44
  end
50
45
 
46
+ def validate_guest_for_check_in!(guest, check_in_by)
47
+ fail_check_in!(guest, I18n.t('spree.check_in.order_canceled')) if guest.line_item.order.canceled?
48
+ fail_check_in!(guest, I18n.t('spree.check_in.guest_locked')) if guest.locked?
49
+ fail_check_in!(guest, I18n.t('spree.check_in.guest_transferred')) if guest.transferred?
50
+
51
+ # Check-in Groups: crew may only check in the ticket types in their check-in groups for this event.
52
+ fail_check_in!(guest, I18n.t('spree.check_in.check_in_group_not_allowed')) if check_in_by && !check_in_by.can_check_in_guest?(guest)
53
+ end
54
+
51
55
  def check_in!(guest, check_in_attributes, check_in_by, confirmed_at)
52
56
  check_in = SpreeCmCommissioner::CheckIn.new(
53
57
  guest: guest,
@@ -8,6 +8,7 @@ module SpreeCmCommissioner
8
8
  IMPORTABLE_COLUMNS = %w[
9
9
  contestant_number name gender category status origin age bio
10
10
  facebook instagram twitter linkedin youtube tiktok telegram
11
+ video_highlights
11
12
  ].freeze
12
13
  MAX_ROWS = 50
13
14
  SOCIAL_LINK_COLUMNS = %w[facebook instagram twitter linkedin youtube tiktok telegram].freeze
@@ -71,7 +72,7 @@ module SpreeCmCommissioner
71
72
  end
72
73
  end
73
74
 
74
- def extract_attrs(row)
75
+ def extract_attrs(row) # rubocop:disable Metrics/AbcSize
75
76
  normalized = row.transform_keys { |k| k.to_s.strip.downcase.tr(' ', '_') }
76
77
 
77
78
  social_links = SOCIAL_LINK_COLUMNS.each_with_object({}) do |key, hash|
@@ -91,9 +92,26 @@ module SpreeCmCommissioner
91
92
  }.compact
92
93
 
93
94
  attrs[:social_links] = social_links if social_links.any?
95
+
96
+ video_highlights = extract_video_highlights(normalized['video_highlights'])
97
+ attrs[:video_highlights] = video_highlights unless video_highlights.nil?
98
+
94
99
  attrs
95
100
  end
96
101
 
102
+ def extract_video_highlights(raw_value)
103
+ raw_value = raw_value.to_s.strip
104
+ return nil if raw_value.blank?
105
+
106
+ parsed = JSON.parse(raw_value)
107
+ raise "video_highlights must be a JSON array, got #{parsed.class}" unless parsed.is_a?(Array)
108
+
109
+ result = SpreeCmCommissioner::ShowContestants::NormalizeVideoHighlights.call(raw_video_highlights: parsed)
110
+ raise "invalid video_highlights: #{result.error.value}" unless result.success?
111
+
112
+ result.value[:video_highlights]
113
+ end
114
+
97
115
  def resolve_enum(mapping, value)
98
116
  return nil if value.blank?
99
117
 
@@ -5,13 +5,15 @@ module SpreeCmCommissioner
5
5
  class Create
6
6
  prepend ::Spree::ServiceModule::Base
7
7
 
8
- def call(exportable:, force_create: false, options: {})
8
+ # `role`: nil = unrestricted (full event); a Spree::Role (check_in type) scopes the file to that
9
+ # role's tickets (see User#check_in_role_for). One role, one export.
10
+ def call(exportable:, role: nil, force_create: false, options: {})
9
11
  unless force_create
10
- recent_export = find_recent_export(exportable)
12
+ recent_export = find_recent_export(exportable, role)
11
13
  return success(recent_export) if recent_export
12
14
  end
13
15
 
14
- export = build_export(exportable, options)
16
+ export = build_export(exportable, role, options)
15
17
  if export.save
16
18
  success(export)
17
19
  else
@@ -21,24 +23,29 @@ module SpreeCmCommissioner
21
23
 
22
24
  private
23
25
 
24
- def find_recent_export(exportable)
26
+ def find_recent_export(exportable, role)
25
27
  # Cache exports for 24 hours (configurable via OPERATOR_GUEST_JSON_GZIP_CACHE_HOURS).
26
- # This ensures all staff see consistent guest data throughout the event day,
28
+ # This ensures staff sharing the same scope see consistent guest data throughout the event day,
27
29
  # minimizes server load, and prevents mid-event data refresh issues.
28
30
  #
31
+ # The cache is keyed on exportable AND role_id so a scoped operator is never handed a broader —
32
+ # or another group's — cached file, while same-role operators still share one. `role_id: nil`
33
+ # correctly matches unrestricted exports.
34
+ #
29
35
  # Use force_create=true to manually trigger fresh exports when needed.
30
36
  cache_duration = ENV.fetch('OPERATOR_GUEST_JSON_GZIP_CACHE_HOURS', '24').to_i.hours
31
37
 
32
- SpreeCmCommissioner::Exports::OperatorGuestJsonGzip.where(exportable: exportable)
38
+ SpreeCmCommissioner::Exports::OperatorGuestJsonGzip.where(exportable: exportable, role_id: role&.id)
33
39
  .not_failed
34
40
  .where('created_at > ?', cache_duration.ago)
35
41
  .order(created_at: :desc)
36
42
  .first
37
43
  end
38
44
 
39
- def build_export(exportable, options)
45
+ def build_export(exportable, role, options)
40
46
  SpreeCmCommissioner::Exports::OperatorGuestJsonGzip.new(
41
47
  exportable: exportable,
48
+ role: role,
42
49
  **options
43
50
  )
44
51
  end
@@ -19,7 +19,7 @@ module SpreeCmCommissioner
19
19
  private
20
20
 
21
21
  def headers
22
- columns.map { |col| col.titleize(keep_id_suffix: true) }.uniq
22
+ columns.map { |col| dynamic_fields_by_id[col]&.label || col.titleize(keep_id_suffix: true) }.uniq
23
23
  end
24
24
 
25
25
  def build_row(guest)
@@ -41,15 +41,11 @@ module SpreeCmCommissioner
41
41
  end
42
42
 
43
43
  def dynamic_field?(column)
44
- return false unless dynamic_fields_exist?
45
-
46
- dynamic_field_labels.include?(column)
44
+ dynamic_fields_by_id.key?(column)
47
45
  end
48
46
 
49
- def dynamic_field_labels
50
- return [] unless dynamic_fields_exist?
51
-
52
- @dynamic_field_labels ||= load_dynamic_field_labels
47
+ def dynamic_fields_by_id
48
+ @dynamic_fields_by_id ||= load_dynamic_fields.index_by { |field| field.id.to_s }
53
49
  end
54
50
 
55
51
  def dynamic_fields_exist?
@@ -58,12 +54,17 @@ module SpreeCmCommissioner
58
54
  @has_dynamic_fields = event.event_products.joins(:product_dynamic_fields).exists?
59
55
  end
60
56
 
61
- def load_dynamic_field_labels
62
- event.event_products.joins(product_dynamic_fields: :dynamic_field).distinct.pluck('label')
57
+ def load_dynamic_fields
58
+ return [] unless dynamic_fields_exist?
59
+
60
+ SpreeCmCommissioner::DynamicField
61
+ .joins(:product_dynamic_fields)
62
+ .where(cm_product_dynamic_fields: { product_id: event.event_products.pluck(:id) })
63
+ .distinct
63
64
  end
64
65
 
65
66
  def fetch_dynamic_field_value(guest, column)
66
- guest_dynamic_field = guest.guest_dynamic_fields.find { |gdf| gdf.dynamic_field&.label == column }
67
+ guest_dynamic_field = guest.guest_dynamic_fields.find { |gdf| gdf.dynamic_field_id.to_s == column }
67
68
  return '' unless guest_dynamic_field
68
69
 
69
70
  if guest_dynamic_field.dynamic_field_option.present?
@@ -31,7 +31,7 @@ module SpreeCmCommissioner
31
31
 
32
32
  def normalize_video(payload)
33
33
  payload_hash = payload.to_h
34
- uri = extract_value(payload_hash, 'uri').to_s.strip
34
+ uri = extract_uri(payload_hash).to_s.strip
35
35
 
36
36
  {
37
37
  'title' => extract_value(payload_hash, 'title').to_s.strip,
@@ -44,6 +44,10 @@ module SpreeCmCommissioner
44
44
  hash[key] || hash[key.to_sym]
45
45
  end
46
46
 
47
+ def extract_uri(hash)
48
+ extract_value(hash, 'uri').presence || extract_value(hash, 'url')
49
+ end
50
+
47
51
  def embed_youtube_url(uri)
48
52
  result = UrlEmbed::YoutubeEmbed.call(url: uri)
49
53
  result.success? ? result.value : uri
@@ -34,7 +34,7 @@
34
34
  <% end %>
35
35
  <% end %>
36
36
  </div>
37
- <div class="col-12">
37
+ <div class="col-6">
38
38
  <%= f.field_container :host do %>
39
39
  <%= f.label :host, raw(Spree.t(:host) + required_span_tag) %>
40
40
  <%= f.text_field :host, required: true, class: 'form-control', placeholder: 'example.com' %>
@@ -43,6 +43,18 @@
43
43
  <% end %>
44
44
  <% end %>
45
45
  </div>
46
+ <div class="col-6">
47
+ <%= f.field_container :payment_host do %>
48
+ <%= f.label :payment_host, raw(Spree.t(:payment_host)) %>
49
+ <span class="text-muted" style="font-size: 0.875rem; font-weight: normal; margin-left: 8px;">(eg. server.example.com)</span>
50
+ <span title="When set, this tenant's payment URLs (ABA callback + iframe pages) point directly at this host instead of routing through the tenant's client domain."
51
+ style="cursor: help; margin-left: 4px;">&#9432;</span>
52
+ <%= f.text_field :payment_host, class: 'form-control', placeholder: 'server.example.com' %>
53
+ <% if f.object.errors[:payment_host].any? %>
54
+ <div class="error text-danger"><%= f.object.errors[:payment_host].join(', ') %></div>
55
+ <% end %>
56
+ <% end %>
57
+ </div>
46
58
  <div class="col-6">
47
59
  <%= f.field_container :state do %>
48
60
  <%= f.label :state, raw(Spree.t(:state)) %>
@@ -77,7 +77,11 @@ en:
77
77
  action_not_matched: "Action is not matched"
78
78
 
79
79
  turnstile:
80
+ token_missing: "Verification token is missing"
80
81
  verification_failed: "Human verification failed. Please try again."
82
+ token_expired: "Verification expired. Please try again."
83
+ invalid_token: "Verification token is invalid. Please try again."
84
+ service_unavailable: "Verification service is temporarily unavailable. Please try again shortly."
81
85
 
82
86
  taxon:
83
87
  icon: Icon (Square)
@@ -244,6 +248,7 @@ en:
244
248
  no_internal_note: "No note"
245
249
  save_internal_note: "Save Note"
246
250
  internal_note_updated: "Internal note updated"
251
+ payment_host: "Payment Host"
247
252
  voting_contestant_advancer:
248
253
  reversion_error: "Cannot eliminate here yet. This contestant appears in a later session."
249
254
  is_open_dated: "Is Open Dated Trip?"
@@ -538,6 +543,12 @@ en:
538
543
  already_checked_in: "Guest has already checked in for this session"
539
544
  check_in_session_required: "Check-in session required"
540
545
  check_in_session_is_not_allowed_or_invalid: "Check-in session is not allowed or invalid for this guest/ticket"
546
+ check_in_group_not_allowed: "This ticket is not in your check-in group"
547
+ order_canceled: "Cannot check in guest from a cancelled order"
548
+ guest_locked: "Guest is locked and cannot be checked in"
549
+ guest_transferred: "Guest has been transferred and cannot be checked in"
550
+ check_in_session:
551
+ end_time_after_start_time: "End time must be after start time"
541
552
  load_test:
542
553
  nav_label: "Load Testing"
543
554
  back_to_load_testing: "Back to Load Testing"
@@ -671,9 +682,7 @@ en:
671
682
  paid_votes_amount:
672
683
  less_than_used: "cannot be less than paid votes used (%{used})"
673
684
  spree_cm_commissioner/tenant:
674
- attributes:
675
- host:
676
- format: "must not include protocol (http:// or https://)"
685
+ format: "must not include protocol (http:// or https://)"
677
686
 
678
687
  mail:
679
688
  order_mailer:
@@ -907,10 +916,6 @@ en:
907
916
  past_date: "Cannot redeem for a past date"
908
917
  return_before_outbound: "Return date must be after the outbound trip date"
909
918
  trip_not_available: "Trip is not available for redemption"
910
- turnstile:
911
- token_missing: "Verification token is missing"
912
- verification_failed: "Human verification failed"
913
- service_unavailable: "Verification service is temporarily unavailable"
914
919
  spree_cm_commissioner:
915
920
  draft_order:
916
921
  line_items_missing: "Line items are missing"