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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73795eb4d207d3bf219b7c5913512b6b82ff71b71f5f9c5ea41acd18a016ade6
4
- data.tar.gz: 44ed0c0b84771995b3f858d6d3db3e5c8feb4cb872a5d23981022837c2c8f847
3
+ metadata.gz: 8b5fced09e0795a88c5dfc1cd5ef84584dc557c0c8f0c26f0c1ed8f0c62f5b9a
4
+ data.tar.gz: 612e81f2f3b4f7e15e6481c9910523fabc9fed36c9b8b265c83bd9fa1834b826
5
5
  SHA512:
6
- metadata.gz: 56fa3d866b2de53c7f58978403eb1a04c26a0da14a95d4a8447502dfed4123db703e84ef733385aa0b271774940031a362096a10b3606f0b6dda63dbcb3136ba
7
- data.tar.gz: 8343f2ec6ff570e80a833272763431b699d68b926648f5b0a98a8929fd321aa4df7636461cbd2b94c8685dfaef162131440cdd5a02a3610ab1a87ea090d7a02d
6
+ metadata.gz: 460533fe4a445145c9edd9e4c0fa1c9fa4c4fc6153105f0dea7e9cf2e873e423e6f967ba734931b5206a9309706a40d92c0a0a13b93d3c59bc57d65fc8be023f
7
+ data.tar.gz: 5f11f494bc107000bd15cf51a6ba29f43c64436f8803257f793488fce84f8d4549c15057f2c5b169460cc601a9fd5cea86bf5ec044999467fe013f9a5c6e5778
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.9.4)
37
+ spree_cm_commissioner (2.9.5)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -27,13 +27,15 @@ module SpreeCmCommissioner
27
27
  return unless result.failure?
28
28
 
29
29
  render json: {
30
- errors: [{
31
- status: '403',
32
- error_code: 'human_verification_failed',
33
- title: I18n.t('turnstile.verification_failed'),
34
- detail: result.message
35
- }
36
- ]
30
+ errors: [
31
+ {
32
+ status: '403',
33
+ error_code: 'human_verification_failed',
34
+ title: I18n.t('turnstile.verification_failed'),
35
+ detail: result.message,
36
+ turnstile_error_code: result.respond_to?(:error_code) ? result.error_code : nil
37
+ }
38
+ ]
37
39
  }, status: :forbidden
38
40
  end
39
41
 
@@ -36,6 +36,7 @@ module Spree
36
36
  SpreeCmCommissioner::EventChartQueries.new(
37
37
  taxon_id: params[:taxon_id],
38
38
  chart_type: params[:id] || 'participation',
39
+ check_in_session_id: params[:check_in_session_id],
39
40
  refreshed: params[:refreshed] || false
40
41
  )
41
42
  end
@@ -26,6 +26,8 @@ module Spree
26
26
 
27
27
  result = SpreeCmCommissioner::OperatorGuestJsonGzips::Create.call(
28
28
  exportable: exportable,
29
+ # Scope the offline file to the requesting operator's check-in role (nil = unrestricted).
30
+ role: spree_current_user.check_in_role_for(exportable),
29
31
  force_create: false,
30
32
  options: {
31
33
  created_by_id: spree_current_user.id.to_s,
@@ -53,11 +55,19 @@ module Spree
53
55
  end
54
56
 
55
57
  # override
58
+ # Scope the list to the requesting operator's check-in group, the same way the create cache
59
+ # does — otherwise a restricted crew could list (and download via exported_file_url) other
60
+ # groups' files for the event.
56
61
  def scope
57
- SpreeCmCommissioner::Exports::OperatorGuestJsonGzip.where(
58
- exportable_type: 'Spree::Taxon',
59
- exportable_id: params[:taxon_id]
60
- ).order(created_at: :desc)
62
+ exportable = Spree::Taxon.find(params[:taxon_id])
63
+
64
+ SpreeCmCommissioner::Exports::OperatorGuestJsonGzip
65
+ .where(
66
+ exportable_type: 'Spree::Taxon',
67
+ exportable_id: params[:taxon_id],
68
+ role_id: spree_current_user.check_in_role_for(exportable)&.id
69
+ )
70
+ .order(created_at: :desc)
61
71
  end
62
72
 
63
73
  # override
@@ -3,6 +3,10 @@ module Spree
3
3
  module V2
4
4
  module Storefront
5
5
  class AccountCheckerController < Spree::Api::V2::BaseController
6
+ include SpreeCmCommissioner::TurnstileProtectable
7
+
8
+ before_action :verify_turnstile!, only: :index
9
+
6
10
  def index
7
11
  checker = SpreeCmCommissioner::ExistingAccountChecker.call(filter_params.to_h)
8
12
 
@@ -26,7 +26,7 @@ module Spree
26
26
  end
27
27
 
28
28
  def scope
29
- spree_current_user.orders.complete
29
+ spree_current_user.orders.complete.exclude_vote_package
30
30
  end
31
31
 
32
32
  def collection_serializer
@@ -0,0 +1,51 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Tenant
5
+ # Returns the session's contestants each annotated with the current
6
+ # user's own vote tally (my_vote_count).
7
+ #
8
+ # Kept separate from the public VotingContestantsController — that
9
+ # endpoint does not require an authenticated user and is cacheable,
10
+ # whereas this one is strictly per-user and must never be cached.
11
+ class MyVotesController < BaseController
12
+ before_action :require_spree_current_user
13
+ before_action :load_voting_session
14
+
15
+ def show
16
+ render_serialized_payload do
17
+ serialize_collection(collection)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def collection
24
+ @collection ||= SpreeCmCommissioner::UserVotesPerContestantQuery.new(
25
+ user: spree_current_user,
26
+ voting_session: @voting_session
27
+ ).contestants
28
+ end
29
+
30
+ def load_voting_session
31
+ @voting_session = current_vendor.voting_sessions.find(params[:voting_session_id])
32
+ end
33
+
34
+ # Overrided: skip collection_options (pagination/links) — this is a
35
+ # bounded per-session collection, not a paginated one.
36
+ def serialize_collection(collection)
37
+ collection_serializer.new(collection).serializable_hash
38
+ end
39
+
40
+ def collection_serializer
41
+ Spree::V2::Tenant::MyVoteSerializer
42
+ end
43
+
44
+ def model_class
45
+ SpreeCmCommissioner::VotingContestant
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -15,10 +15,7 @@ module Spree
15
15
  scope = current_show.show_people
16
16
  .joins(:assignments)
17
17
  .distinct
18
- .includes(
19
- :assignments,
20
- assigned_contestants: %i[show_contestant_images current_voting_session]
21
- )
18
+ .includes(:assignments, :show_person_images)
22
19
 
23
20
  role = params[:role].presence
24
21
  return scope if role.blank?
@@ -15,7 +15,10 @@ module Spree
15
15
 
16
16
  model_class.where(show_id: show_ids)
17
17
  .where(show_person_id: params[:person_id], show_id: current_show.id)
18
- .includes(:show_person, :show)
18
+ .includes(:show_person, :show, assigned_contestants: %i[
19
+ show_contestant_images show_contestant_videos current_voting_session voting_contestants
20
+ ]
21
+ )
19
22
  end
20
23
 
21
24
  def current_show
@@ -6,13 +6,17 @@ module Spree
6
6
  before_action :load_voting_session, only: %i[index]
7
7
 
8
8
  # override
9
+ #
10
+ # Ordered by vote_number. vote_number is nullable — Postgres' default
11
+ # ASC ordering places nulls last, so contestants without a vote_number
12
+ # appear at the end.
9
13
  def collection
10
14
  @collection ||= @voting_session
11
15
  .voting_contestants
12
- .includes(:voting_session, :show_contestant,
16
+ .includes(:voting_session, { show_contestant: :assigned_show_person },
13
17
  :show_contestant_images, :show_contestant_videos,
14
18
  :advanced_to, :advanced_from
15
- ).order(:position)
19
+ ).order(:vote_number)
16
20
  end
17
21
 
18
22
  # override
@@ -5,10 +5,10 @@ module SpreeCmCommissioner
5
5
  def call
6
6
  normalized_host = normalize_host(host)
7
7
 
8
- context.store = find_store(normalized_host)
9
- return if context.store.present?
10
-
11
8
  context.tenant = find_tenant(normalized_host)
9
+ return if context.tenant.present?
10
+
11
+ context.store = find_store(normalized_host)
12
12
  end
13
13
 
14
14
  private
@@ -18,11 +18,12 @@ module SpreeCmCommissioner
18
18
  end
19
19
 
20
20
  def find_store(normalized_host)
21
- Spree::Store.find_by(code: normalized_host)
21
+ store_class = Spree::Store
22
+ store_class.find_by(code: normalized_host) || store_class.default
22
23
  end
23
24
 
24
25
  def find_tenant(normalized_host)
25
- SpreeCmCommissioner::Tenant.find_by(host: normalized_host)
26
+ SpreeCmCommissioner::Tenant.where('host = :host OR payment_host = :host', host: normalized_host).first
26
27
  end
27
28
  end
28
29
  end
@@ -2,22 +2,23 @@ module SpreeCmCommissioner
2
2
  class TurnstileTokenValidator < BaseInteractor
3
3
  SITE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'.freeze
4
4
 
5
+ # https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes
6
+ # Our own secret/setup is wrong -- not something a retry or a fresh token can fix.
7
+ CONFIG_ERROR_CODES = %w[missing-input-secret invalid-input-secret bad-request].freeze
8
+ # The token itself expired or was already redeemed -- a fresh token will fix this.
9
+ EXPIRED_ERROR_CODES = %w[timeout-or-duplicate].freeze
10
+ # The token we received was malformed/missing -- client should fetch a new one.
11
+ INVALID_TOKEN_ERROR_CODES = %w[missing-input-response invalid-input-response].freeze
12
+
5
13
  delegate :token, :remote_ip, to: :context
6
14
 
7
15
  def call
8
- context.fail!(message: I18n.t('turnstile.token_missing')) if token.blank?
16
+ context.fail!(message: I18n.t('turnstile.token_missing'), error_code: :token_missing) if token.blank?
9
17
 
10
18
  response = verify_token
11
19
  body = parse_response(response)
12
20
 
13
- unless body['success']
14
- error_codes = body['error-codes']&.join(', ') || 'unknown'
15
- Rails.logger.warn(
16
- "Turnstile verification failed: #{error_codes} " \
17
- "from IP #{remote_ip} | hostname=#{body['hostname']}"
18
- )
19
- context.fail!(message: I18n.t('turnstile.verification_failed'))
20
- end
21
+ handle_failure(body) unless body['success']
21
22
 
22
23
  context.challenge_ts = body['challenge_ts']
23
24
  context.hostname = body['hostname']
@@ -26,24 +27,53 @@ module SpreeCmCommissioner
26
27
 
27
28
  private
28
29
 
30
+ def handle_failure(body)
31
+ error_codes = Array(body['error-codes'])
32
+ Rails.logger.warn(
33
+ "Turnstile verification failed: #{error_codes.join(', ')} " \
34
+ "from IP #{remote_ip} | hostname=#{body['hostname']}"
35
+ )
36
+
37
+ if error_codes.intersect?(CONFIG_ERROR_CODES)
38
+ # Not the visitor's fault -- alert ops, but don't leak config details to the client.
39
+ Rails.logger.error("Turnstile misconfiguration detected: #{error_codes.join(', ')}")
40
+ context.fail!(message: I18n.t('turnstile.service_unavailable'), error_code: :service_unavailable)
41
+ elsif error_codes.intersect?(EXPIRED_ERROR_CODES)
42
+ context.fail!(message: I18n.t('turnstile.token_expired'), error_code: :token_expired)
43
+ elsif error_codes.intersect?(INVALID_TOKEN_ERROR_CODES)
44
+ context.fail!(message: I18n.t('turnstile.invalid_token'), error_code: :invalid_token)
45
+ else
46
+ context.fail!(message: I18n.t('turnstile.verification_failed'), error_code: :verification_failed)
47
+ end
48
+ end
49
+
29
50
  def parse_response(response)
30
51
  JSON.parse(response.body)
31
52
  rescue JSON::ParserError => e
32
53
  Rails.logger.error("Turnstile response parse error: #{e.message}")
33
- context.fail!(message: I18n.t('turnstile.service_unavailable'))
54
+ context.fail!(message: I18n.t('turnstile.service_unavailable'), error_code: :service_unavailable)
34
55
  end
35
56
 
36
57
  def verify_token
37
- connection.post do |req|
58
+ response = connection.post do |req|
38
59
  req.body = {
39
60
  secret: secret_key,
40
61
  response: token,
41
62
  remoteip: remote_ip
42
63
  }
43
64
  end
44
- rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
65
+
66
+ unless response.success?
67
+ Rails.logger.error("Turnstile API returned HTTP #{response.status}")
68
+ context.fail!(message: I18n.t('turnstile.service_unavailable'), error_code: :service_unavailable)
69
+ end
70
+
71
+ response
72
+ rescue Faraday::Error => e
73
+ # Covers timeouts, connection refused/failed, SSL errors, DNS failures, etc. --
74
+ # anything that means Cloudflare's siteverify endpoint is temporarily unreachable.
45
75
  Rails.logger.error("Turnstile API error: #{e.class} - #{e.message}")
46
- context.fail!(message: I18n.t('turnstile.service_unavailable'))
76
+ context.fail!(message: I18n.t('turnstile.service_unavailable'), error_code: :service_unavailable)
47
77
  end
48
78
 
49
79
  def connection
@@ -0,0 +1,39 @@
1
+ module SpreeCmCommissioner
2
+ module OrderQrCodeable
3
+ def generate_svg_qr
4
+ qrcode = RQRCode::QRCode.new(qr_data)
5
+ qrcode.as_svg(
6
+ color: '000',
7
+ shape_rendering: 'crispEdges',
8
+ module_size: 5,
9
+ standalone: true,
10
+ use_path: true,
11
+ viewbox: '0 0 20 10'
12
+ )
13
+ end
14
+
15
+ def generate_png_qr(size = 120)
16
+ qrcode = RQRCode::QRCode.new(qr_data)
17
+ qrcode.as_png(
18
+ bit_depth: 1,
19
+ border_modules: 1,
20
+ color_mode: ChunkyPNG::COLOR_GRAYSCALE,
21
+ color: 'black',
22
+ file: nil,
23
+ fill: 'white',
24
+ module_px_size: 4,
25
+ resize_exactly_to: false,
26
+ resize_gte_to: false,
27
+ size: size
28
+ )
29
+ end
30
+
31
+ def qr_data
32
+ "#{number}-#{token}"
33
+ end
34
+
35
+ def jwt_qr_data
36
+ SpreeCmCommissioner::Orders::JwtToken::Generate.call(order: self).value[:token]
37
+ end
38
+ end
39
+ end
@@ -40,6 +40,11 @@ module SpreeCmCommissioner
40
40
  scope :filter_by_vendor, lambda { |vendor|
41
41
  joins(:line_items).where(spree_line_items: { vendor_id: vendor }).distinct
42
42
  }
43
+
44
+ # Excludes orders containing a vote_package line item.
45
+ scope :exclude_vote_package, lambda {
46
+ where.not(id: Spree::LineItem.where(product_type: :vote_package).select(:order_id))
47
+ }
43
48
  end
44
49
  end
45
50
  end
@@ -5,15 +5,51 @@ module SpreeCmCommissioner
5
5
  belongs_to :event, class_name: 'Spree::Taxon', optional: false
6
6
 
7
7
  has_many :check_in_rules, class_name: 'SpreeCmCommissioner::CheckInRule', dependent: :destroy
8
+ has_many :check_ins, class_name: 'SpreeCmCommissioner::CheckIn', dependent: :restrict_with_error
8
9
 
9
10
  scope :active, -> { where(is_archived: false) }
10
11
 
11
12
  accepts_nested_attributes_for :check_in_rules, allow_destroy: true
12
13
 
14
+ validate :start_time_must_be_present_datetime
15
+ validate :end_time_must_be_present_datetime
16
+ validate :end_time_after_start_time
17
+
13
18
  after_commit :preload_check_in_session_ids_to_event_guests
14
19
 
20
+ self.whitelisted_ransackable_attributes = %w[is_archived]
21
+
15
22
  private
16
23
 
24
+ def start_time_must_be_present_datetime
25
+ validate_present_datetime(:start_time)
26
+ end
27
+
28
+ def end_time_must_be_present_datetime
29
+ validate_present_datetime(:end_time)
30
+ end
31
+
32
+ # Ensures the attribute was both entered by the user and is a real datetime.
33
+ # A datetime column type-casts unparseable input to nil while keeping the
34
+ # raw value in `*_before_type_cast`, so blank raw input means "not entered"
35
+ # and present raw input with a nil cast means "not a valid date/time".
36
+ def validate_present_datetime(attribute)
37
+ raw = read_attribute_before_type_cast(attribute)
38
+
39
+ if raw.blank?
40
+ errors.add(attribute, :blank)
41
+ elsif public_send(attribute).blank?
42
+ errors.add(attribute, :invalid)
43
+ end
44
+ end
45
+
46
+ def end_time_after_start_time
47
+ return if start_time.blank? || end_time.blank?
48
+ return if end_time > start_time
49
+
50
+ errors.add(:base, I18n.t('spree.check_in_session.end_time_after_start_time'))
51
+ end
52
+
17
53
  def preload_check_in_session_ids_to_event_guests
18
54
  SpreeCmCommissioner::Guests::PreloadCheckInSessionIdsJob.perform_later(event_id: event.id)
19
55
  end
@@ -17,6 +17,12 @@ module SpreeCmCommissioner
17
17
  'line_item.variant'
18
18
  ].freeze
19
19
 
20
+ # Check-in group scope: one role, one export. `role` is the requesting operator's check-in group
21
+ # (see User#check_in_role_for) — the file holds only guests whose ticket that role's product scope
22
+ # allows. `role_id` IS the cache key: callers filter on it directly (a plain `where(role_id:)`),
23
+ # no product-set comparison needed. `role` nil = unrestricted (the full event).
24
+ belongs_to :role, class_name: 'Spree::Role', optional: true
25
+
20
26
  store_public_metadata :guests_count, :integer, default: 0
21
27
  store_public_metadata :created_by_id, :string
22
28
  store_public_metadata :created_by_login, :string
@@ -35,7 +41,7 @@ module SpreeCmCommissioner
35
41
  track_messages << "Export started at #{Time.current.iso8601}"
36
42
  update(status: :progress, track_messages: track_messages)
37
43
 
38
- guests_query = SpreeCmCommissioner::GuestSearcherQuery.new(event_id: exportable_id).call
44
+ guests_query = scoped_guests_query
39
45
  self.guests_count = guests_query.count(:id)
40
46
 
41
47
  track_messages << "Processing #{guests_count} guests in batches"
@@ -68,6 +74,25 @@ module SpreeCmCommissioner
68
74
 
69
75
  private
70
76
 
77
+ # The event's guests, narrowed to the export's role's products when the export is scoped. Same
78
+ # GuestSearcherQuery (with its product_ids scope) the dashboard preview runs, so both always
79
+ # report the same guests. No role = unrestricted (full event).
80
+ def scoped_guests_query
81
+ SpreeCmCommissioner::GuestSearcherQuery.new(
82
+ event_id: exportable_id,
83
+ product_ids: role_scoped_product_ids
84
+ ).call
85
+ end
86
+
87
+ # Resolves this export's role into the concrete product ids it covers, computed at export-run
88
+ # time so a later edit to the role's config doesn't leave an export scoped to stale data.
89
+ def role_scoped_product_ids
90
+ return nil if role.nil?
91
+
92
+ event_product_ids = role.check_in_exclude? ? exportable.event_products.ids : []
93
+ role.check_in_scoped_product_ids(event_product_ids)
94
+ end
95
+
71
96
  def stream_export_data_and_includes_to_separate_files(guests_query, guests_file, included_file)
72
97
  # Track "user:123", "order:456" to avoid duplicates includes. For current event scale, Set growth
73
98
  # (~20-25 MB for 100K guests × 5 relationships) is manageable.
@@ -28,6 +28,10 @@ module SpreeCmCommissioner
28
28
  scope :complete_or_canceled, -> { joins(:line_item).merge(Spree::LineItem.complete_or_canceled) }
29
29
  scope :unassigned_event, -> { where(event_id: nil) }
30
30
  scope :none_bib, -> { where(bib_prefix: [nil, '']) }
31
+ # Excludes guests whose ticket was transferred away. The original guest keeps its
32
+ # (still complete) line_item, so `complete` alone would double-count it alongside the
33
+ # new active guest created for the buyer. Use for attendee counts / aggregates.
34
+ scope :not_transferred, -> { where.not(state: :transferred) }
31
35
 
32
36
  belongs_to :line_item, class_name: 'Spree::LineItem'
33
37
 
@@ -23,7 +23,15 @@ module SpreeCmCommissioner
23
23
  end
24
24
 
25
25
  def update_conversion_for(product_taxon)
26
- total_count = product_taxon.complete_line_items.sum(:quantity)
26
+ # Count guests (attendees) instead of line item quantity so this stays consistent
27
+ # with EventChartQueries, which counts DISTINCT cm_guests.id on complete orders.
28
+ # Flat join guest -> line_item -> variant -> product filtered by product_id: no
29
+ # IN-subquery, no self-join, cheapest plan of the variants we benchmarked.
30
+ total_count = SpreeCmCommissioner::Guest.complete
31
+ .not_transferred
32
+ .joins(line_item: { variant: :product })
33
+ .where(spree_products: { id: product_taxon.product_id })
34
+ .count
27
35
  revenue = product_taxon.complete_line_items.sum('quantity * price')
28
36
 
29
37
  product_taxon.update_columns( # rubocop:disable Rails/SkipsModelValidations
@@ -15,6 +15,7 @@ module SpreeCmCommissioner
15
15
  base.include SpreeCmCommissioner::RouteOrderCountable
16
16
  base.include SpreeCmCommissioner::OrderScopes
17
17
  base.include SpreeCmCommissioner::TicketTransferable
18
+ base.include SpreeCmCommissioner::OrderQrCodeable
18
19
 
19
20
  base.before_create :link_by_phone_number
20
21
  base.before_create :associate_customer
@@ -285,42 +286,6 @@ module SpreeCmCommissioner
285
286
  "order/#{id}/has_incomplete_guest_info"
286
287
  end
287
288
 
288
- def generate_svg_qr
289
- qrcode = RQRCode::QRCode.new(qr_data)
290
- qrcode.as_svg(
291
- color: '000',
292
- shape_rendering: 'crispEdges',
293
- module_size: 5,
294
- standalone: true,
295
- use_path: true,
296
- viewbox: '0 0 20 10'
297
- )
298
- end
299
-
300
- def generate_png_qr(size = 120)
301
- qrcode = RQRCode::QRCode.new(qr_data)
302
- qrcode.as_png(
303
- bit_depth: 1,
304
- border_modules: 1,
305
- color_mode: ChunkyPNG::COLOR_GRAYSCALE,
306
- color: 'black',
307
- file: nil,
308
- fill: 'white',
309
- module_px_size: 4,
310
- resize_exactly_to: false,
311
- resize_gte_to: false,
312
- size: size
313
- )
314
- end
315
-
316
- def qr_data
317
- "#{number}-#{token}"
318
- end
319
-
320
- def jwt_qr_data
321
- SpreeCmCommissioner::Orders::JwtToken::Generate.call(order: self).value[:token]
322
- end
323
-
324
289
  def enable_send_receipt?
325
290
  products.any?(&:enable_send_receipt?)
326
291
  end
@@ -331,7 +296,7 @@ module SpreeCmCommissioner
331
296
 
332
297
  # override spree_vpago method
333
298
  def payment_host
334
- tenant&.formatted_url.presence || ENV.fetch('DEFAULT_URL_HOST')
299
+ tenant&.payment_url.presence || tenant&.formatted_url.presence || ENV.fetch('DEFAULT_URL_HOST')
335
300
  end
336
301
 
337
302
  # Returns arrays of connected line_item IDs grouped by connected_trip_id.
@@ -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,48 @@ 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? }
25
39
  end
26
40
 
27
41
  def generate_unique_name
28
42
  return if name.present?
29
43
  return if presentation.blank?
30
44
 
31
- self.name = "#{vendor.slug}-#{presentation.downcase.parameterize(separator: '-')}"
45
+ slug = presentation.downcase.parameterize(separator: '-')
46
+ # Include the event so a vendor can reuse a group name (e.g. "VIP") across events without
47
+ # colliding on the globally-unique spree_roles.name.
48
+ self.name = check_in? ? "#{vendor.slug}-event-#{event_id}-#{slug}" : "#{vendor.slug}-#{slug}"
49
+ end
50
+
51
+ # Whether this check-in group lets a crew check in the given line item. The group's mode decides
52
+ # how product_ids is read: all = everything, include = allowlist, exclude = blocklist. A group
53
+ # with no products behaves like "all" regardless of mode.
54
+ def check_in_allows?(line_item)
55
+ return true if check_in_all? || product_ids.blank?
56
+
57
+ matched = product_ids.include?(line_item.product_id)
58
+ check_in_exclude? ? !matched : matched
59
+ end
60
+
61
+ # The concrete product ids this group covers, for scoping a data pull (the dashboard preview,
62
+ # the operator export). `nil` = unrestricted — an all-mode group or one with no products
63
+ # covers every ticket.
64
+ def check_in_scoped_product_ids(event_product_ids)
65
+ return nil if check_in_all? || product_ids.blank?
66
+
67
+ ids = product_ids.map(&:to_i)
68
+ check_in_exclude? ? event_product_ids.map(&:to_i) - ids : ids
32
69
  end
33
70
  end
34
71
  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