spree_cm_commissioner 2.9.4 → 2.9.6

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 (52) 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/event_metadata.rb +5 -0
  15. data/app/models/concerns/spree_cm_commissioner/order_qr_codeable.rb +39 -0
  16. data/app/models/concerns/spree_cm_commissioner/order_scopes.rb +5 -0
  17. data/app/models/spree_cm_commissioner/check_in_session.rb +36 -0
  18. data/app/models/spree_cm_commissioner/exports/operator_guest_json_gzip.rb +26 -1
  19. data/app/models/spree_cm_commissioner/guest.rb +4 -0
  20. data/app/models/spree_cm_commissioner/maintenance_tasks/event.rb +9 -1
  21. data/app/models/spree_cm_commissioner/order_decorator.rb +2 -37
  22. data/app/models/spree_cm_commissioner/role_decorator.rb +52 -4
  23. data/app/models/spree_cm_commissioner/role_user_decorator.rb +11 -0
  24. data/app/models/spree_cm_commissioner/show_person.rb +5 -1
  25. data/app/models/spree_cm_commissioner/show_person_assignment.rb +1 -1
  26. data/app/models/spree_cm_commissioner/taxon_decorator.rb +2 -0
  27. data/app/models/spree_cm_commissioner/tenant.rb +17 -6
  28. data/app/models/spree_cm_commissioner/user_decorator.rb +35 -0
  29. data/app/models/spree_cm_commissioner/voting_contestant.rb +2 -0
  30. data/app/queries/spree_cm_commissioner/event_chart_queries.rb +23 -7
  31. data/app/queries/spree_cm_commissioner/guest_searcher_query.rb +16 -3
  32. data/app/queries/spree_cm_commissioner/pie_chart_event_aggregator_queries.rb +1 -0
  33. data/app/queries/spree_cm_commissioner/user_votes_per_contestant_query.rb +48 -0
  34. data/app/serializers/spree/v2/tenant/my_vote_serializer.rb +19 -0
  35. data/app/serializers/spree/v2/tenant/show_serializer.rb +2 -1
  36. data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +2 -0
  37. data/app/services/spree_cm_commissioner/check_ins/create_bulk.rb +10 -6
  38. data/app/services/spree_cm_commissioner/imports/contestants/create.rb +19 -1
  39. data/app/services/spree_cm_commissioner/operator_guest_json_gzips/create.rb +14 -7
  40. data/app/services/spree_cm_commissioner/organizer/export_guest_csv_service.rb +12 -11
  41. data/app/services/spree_cm_commissioner/show_contestants/normalize_video_highlights.rb +5 -1
  42. data/app/views/spree/admin/tenants/_form.html.erb +13 -1
  43. data/config/locales/en.yml +12 -7
  44. data/config/locales/km.yml +12 -4
  45. data/config/routes.rb +1 -0
  46. data/db/migrate/20260720000001_add_check_in_fields_to_spree_roles.rb +11 -0
  47. data/db/migrate/20260723000000_add_payment_host_to_cm_tenants.rb +6 -0
  48. data/db/migrate/20260723000001_add_metadata_to_spree_roles.rb +11 -0
  49. data/db/migrate/20260728074153_add_role_to_cm_exports.rb +5 -0
  50. data/lib/spree_cm_commissioner/test_helper/factories/vehicle_factory.rb +1 -1
  51. data/lib/spree_cm_commissioner/version.rb +1 -1
  52. metadata +10 -2
@@ -1,12 +1,20 @@
1
1
  module SpreeCmCommissioner
2
2
  module RoleDecorator
3
- def self.prepended(base)
4
- base.enum role_type: { internal: 0, external: 1, preview: 2 }
3
+ def self.prepended(base) # rubocop:disable Metrics/AbcSize
4
+ base.include SpreeCmCommissioner::StoreMetadata
5
+
6
+ base.enum role_type: { internal: 0, external: 1, preview: 2, check_in: 3 }
7
+ base.enum check_in_mode: { all: 0, include: 1, exclude: 2 }, _prefix: :check_in
5
8
 
6
9
  base.has_many :role_permissions, class_name: 'SpreeCmCommissioner::RolePermission'
7
10
  base.has_many :permissions, through: :role_permissions, class_name: 'SpreeCmCommissioner::Permission'
8
11
  base.has_many :preview_roles, class_name: 'SpreeCmCommissioner::PreviewRole'
9
12
 
13
+ # Check-in Group scoping: a check_in role belongs to one event (taxon) and lists the products
14
+ # (ticket types) it may check in, stored as a product_ids array — see #check_in_allows?.
15
+ base.store_public_metadata :product_ids, :array, default: []
16
+ base.belongs_to :event, class_name: 'Spree::Taxon', optional: true
17
+
10
18
  base.belongs_to :vendor, optional: true
11
19
 
12
20
  base.scope :non_vendor, -> { where(vendor_id: nil) }
@@ -16,19 +24,59 @@ module SpreeCmCommissioner
16
24
  base.scope :filter_external, -> { where(role_type: :external) }
17
25
  base.scope :filter_internal, -> { where(role_type: :internal) }
18
26
  base.scope :filter_preview, -> { where(role_type: :preview) }
27
+ base.scope :filter_check_in, -> { where(role_type: :check_in) }
19
28
 
20
29
  base.accepts_nested_attributes_for :role_permissions, allow_destroy: true
21
30
 
22
31
  base.before_validation :generate_unique_name, if: -> { vendor_id.present? }
23
32
 
24
- base.validates :presentation, uniqueness: { scope: :vendor_id }, presence: true, if: -> { vendor_id.present? }
33
+ base.validates :presentation, presence: true, if: -> { vendor_id.present? }
34
+ base.validates :event_id, presence: true, if: -> { check_in? }
35
+ # Permission roles are unique per vendor; check-in groups are unique per event, so two
36
+ # events under the same vendor can each own a "VIP" group.
37
+ base.validates :presentation, uniqueness: { scope: :vendor_id }, if: -> { vendor_id.present? && !check_in? }
38
+ base.validates :presentation, uniqueness: { scope: %i[vendor_id event_id] }, if: -> { vendor_id.present? && check_in? }
39
+
40
+ base.after_commit :sync_event_has_check_in_groups_flag, on: %i[create destroy], if: -> { check_in? }
25
41
  end
26
42
 
27
43
  def generate_unique_name
28
44
  return if name.present?
29
45
  return if presentation.blank?
30
46
 
31
- self.name = "#{vendor.slug}-#{presentation.downcase.parameterize(separator: '-')}"
47
+ slug = presentation.downcase.parameterize(separator: '-')
48
+ # Include the event so a vendor can reuse a group name (e.g. "VIP") across events without
49
+ # colliding on the globally-unique spree_roles.name.
50
+ self.name = check_in? ? "#{vendor.slug}-event-#{event_id}-#{slug}" : "#{vendor.slug}-#{slug}"
51
+ end
52
+
53
+ # Whether this check-in group lets a crew check in the given line item. The group's mode decides
54
+ # how product_ids is read: all = everything, include = allowlist, exclude = blocklist. A group
55
+ # with no products behaves like "all" regardless of mode.
56
+ def check_in_allows?(line_item)
57
+ return true if check_in_all? || product_ids.blank?
58
+
59
+ matched = product_ids.include?(line_item.product_id)
60
+ check_in_exclude? ? !matched : matched
61
+ end
62
+
63
+ # The concrete product ids this group covers, for scoping a data pull (the dashboard preview,
64
+ # the operator export). `nil` = unrestricted — an all-mode group or one with no products
65
+ # covers every ticket.
66
+ def check_in_scoped_product_ids(event_product_ids)
67
+ return nil if check_in_all? || product_ids.blank?
68
+
69
+ ids = product_ids.map(&:to_i)
70
+ check_in_exclude? ? event_product_ids.map(&:to_i) - ids : ids
71
+ end
72
+
73
+ private
74
+
75
+ # Keeps Taxon#has_check_in_groups? in sync so User#check_in_group_for can skip querying
76
+ # spree_roles for events with none. Recomputes from scratch rather than assuming true/false from
77
+ # the create/destroy alone, since an event can have more than one check-in group.
78
+ def sync_event_has_check_in_groups_flag
79
+ event.update!(has_check_in_groups: event.check_in_groups.exists?)
32
80
  end
33
81
  end
34
82
  end
@@ -1,5 +1,16 @@
1
1
  module SpreeCmCommissioner
2
2
  module RoleUserDecorator
3
+ def self.prepended(base)
4
+ base.validate :single_check_in_role_per_event, if: -> { role&.check_in? }
5
+ end
6
+
7
+ private
8
+
9
+ # Restricts a user to at most one check-in role per event.
10
+ def single_check_in_role_per_event
11
+ existing_check_in_roles = user.spree_roles.filter_check_in.where(event_id: role.event_id).where.not(id: role_id)
12
+ errors.add(:role, 'already has a check-in group for this event') if existing_check_in_roles.exists?
13
+ end
3
14
  end
4
15
  end
5
16
 
@@ -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,41 @@ 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. Events with no check-in groups at all are
169
+ # unrestricted. Otherwise admins and organizers are never restricted; everyone else may check in
170
+ # the guest's ticket only if their check-in group allows it (include/exclude evaluated on that one
171
+ # group).
172
+ def can_check_in_guest?(guest)
173
+ return true unless guest.event.has_check_in_groups?
174
+ return true if admin? || organizer?
175
+
176
+ group = check_in_group_for(guest.event_id)
177
+ return true if group.nil?
178
+
179
+ group.check_in_allows?(guest.line_item)
180
+ end
181
+
182
+ # This user's check-in role for the event, used to scope the operator offline export (one role, one
183
+ # export — see Exports::OperatorGuestJsonGzip#role). `nil` = unrestricted (admins/organizers, crew in
184
+ # no group, or an event with no check-in groups at all — see #can_check_in_guest?).
185
+ def check_in_role_for(event)
186
+ return nil unless event.has_check_in_groups?
187
+ return nil if admin? || organizer?
188
+
189
+ check_in_group_for(event.id)
190
+ end
191
+
157
192
  def vendor_organizer?(vendor)
158
193
  spree_roles.any? do |role|
159
194
  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