spree_cm_commissioner 2.9.6 → 2.9.7

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +3 -8
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/spree/admin/users_controller_decorator.rb +18 -0
  5. data/app/controllers/spree/api/v2/storefront/telegram_oauth_tokens_controller.rb +35 -2
  6. data/app/controllers/spree/api/v2/tenant/order_histories_controller.rb +2 -0
  7. data/app/finders/spree_cm_commissioner/routes/find.rb +1 -1
  8. data/app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb +3 -9
  9. data/app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb +5 -5
  10. data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +11 -1
  11. data/app/mailers/spree/order_mailer_decorator.rb +1 -0
  12. data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +12 -2
  13. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +2 -0
  14. data/app/models/concerns/spree_cm_commissioner/store_preference.rb +2 -0
  15. data/app/models/concerns/spree_cm_commissioner/tenant_preference.rb +2 -0
  16. data/app/models/spree_cm_commissioner/seat_layout.rb +12 -4
  17. data/app/models/spree_cm_commissioner/show.rb +3 -1
  18. data/app/models/spree_cm_commissioner/show_episode.rb +3 -1
  19. data/app/models/spree_cm_commissioner/trip.rb +5 -0
  20. data/app/models/spree_cm_commissioner/vehicle_type.rb +1 -0
  21. data/app/overrides/spree/admin/stores/_form/telegram_auth.html.erb.deface +73 -0
  22. data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +5 -0
  23. data/app/queries/spree_cm_commissioner/single_leg_trips_query.rb +2 -1
  24. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +1 -0
  25. data/app/services/spree_cm_commissioner/voting_leaderboards/time_range_result.rb +79 -0
  26. data/app/views/spree/admin/tenants/_form.html.erb +27 -0
  27. data/app/views/spree/admin/tenants/form/_telegram_auth.html.erb +61 -0
  28. data/config/initializers/spree_permitted_attributes.rb +2 -0
  29. data/config/locales/en.yml +2 -0
  30. data/config/locales/km.yml +3 -0
  31. data/db/migrate/20260729000000_add_visibility_to_cm_trips.rb +6 -0
  32. data/db/migrate/20260729000001_add_session_created_at_index_to_cm_votes.rb +12 -0
  33. data/lib/spree_cm_commissioner/version.rb +1 -1
  34. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f187ec143a0546d2099b322062ef981cac276ced2ef0ae9714bd5d5984040d2c
4
- data.tar.gz: 1f2a838ce0779597fd0c5b06784b9e0fbb260085457612dcb7e646f61da0482b
3
+ metadata.gz: e267212ae81075d66a95e65c2eb320ea05bc9b18f53b1bc4a74896639ddd4ed2
4
+ data.tar.gz: cc28b8d31dd89f481864b6f5c366d264335403ca4f8aaf9a00dbd39e502012b6
5
5
  SHA512:
6
- metadata.gz: 9f722ef15d8fef51009e1da877520831551d33d4c2391036dcd31db1ddc6f485cc0d475b6144843fb660404e7e75ecfb77769a646bc6933378b4eca181cadfaa
7
- data.tar.gz: 8ef07513d2cb8b65635dfa960b60d84fdbb6d0ad2731cc098f3e99de64901bd3100e80b9a10c0ac1e1243a4c451f2633b93ca3496775f690b10bd6edc2f66369
6
+ metadata.gz: 7cb85b5d8aaac69dc845ddeb068ec96200cf400a7c6d9f3bd979681fe4d03dd08cf4358a059855825785d9524e1a4d04e0447e3b0bfc682f4827c1b09734fa7d
7
+ data.tar.gz: 5727d1541df27c51c58ef2b4a7e022096c62aa6666fcb58514dbe39621a44395478d1f5ea5ecef24280767c0d41f47e43f823276720ce63f45c7ef371278b0a8
data/.env.example CHANGED
@@ -32,14 +32,9 @@ WAITING_ROOM_MAX_ETA_BATCH_SIZE=50 # cap on batch size assumed when estimating t
32
32
  VATTANAC_AES_SECRET_KEY= ""
33
33
  VATTANAC_PUBLIC_KEY=""
34
34
 
35
- # Telegram OAuth (OpenID Connect) login.
36
- # client_id (audience) the mobile app uses when requesting an id_token from
37
- # Telegram. Verified as the `aud` claim in
38
- # app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb
39
- TELEGRAM_OAUTH_CLIENT_ID=""
40
- # client_secret injected server-side when proxying the PKCE code -> id_token
41
- # exchange in app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb
42
- TELEGRAM_OAUTH_CLIENT_SECRET=""
35
+ # Telegram OAuth (OpenID Connect) login client_id/client_secret are configured
36
+ # per tenant (or on the default store for tenant-less logins) from the admin
37
+ # under Telegram Authentication - see docs/authentication/telegram-login.md.
43
38
 
44
39
  # Organizer URL
45
40
  ORGANIZER_URL=http://127.0.0.1:4000/organizer
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.9.6)
37
+ spree_cm_commissioner (2.9.7)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -20,8 +20,26 @@ module Spree
20
20
  end
21
21
  end
22
22
 
23
+ # override
24
+ def update
25
+ preserve_non_form_roles
26
+ super
27
+ end
28
+
23
29
  private
24
30
 
31
+ # Role types the user form doesn't render (assigned in organizer dashboards), e.g. check-in groups and preview.
32
+ NON_FORM_ROLE_TYPES = %i[check_in preview].freeze
33
+
34
+ # spree_role_ids= replaces the whole role set, so a submit that omits these would drop them. Merge
35
+ # the user's existing ones back in so they survive. Skip when the form didn't submit roles at all.
36
+ def preserve_non_form_roles
37
+ return if params.dig(:user, :spree_role_ids).nil?
38
+
39
+ preserved_ids = @user.spree_roles.where(role_type: NON_FORM_ROLE_TYPES).pluck(:id).map(&:to_s)
40
+ params[:user][:spree_role_ids] = (params[:user][:spree_role_ids] + preserved_ids).uniq
41
+ end
42
+
25
43
  def build_profile
26
44
  return unless permitted_resource_params[:profile]
27
45
 
@@ -9,7 +9,11 @@ module Spree
9
9
  # has no access token yet (this is part of logging in).
10
10
  class TelegramOauthTokensController < Spree::Api::V2::BaseController
11
11
  def create
12
- result = SpreeCmCommissioner::TelegramOauthTokenExchanger.call(exchange_params.to_h.symbolize_keys)
12
+ return render_error_payload(credential_mismatch_message) if credential_mismatch_message
13
+
14
+ result = SpreeCmCommissioner::TelegramOauthTokenExchanger.call(
15
+ exchange_params.merge(client_secret: resolved_client_secret)
16
+ )
13
17
 
14
18
  return render_error_payload(result.message) unless result.success?
15
19
 
@@ -19,7 +23,36 @@ module Spree
19
23
  private
20
24
 
21
25
  def exchange_params
22
- params.permit(:client_id, :code, :redirect_uri, :code_verifier)
26
+ params.permit(:client_id, :code, :redirect_uri, :code_verifier).to_h.symbolize_keys
27
+ end
28
+
29
+ # `tenant_id` scopes which tenant's bot this login belongs to; with none
30
+ # given, the default store's bot is used. Distinguished from
31
+ # telegram_oauth_client_secret_not_configured (raised by the exchanger
32
+ # when the owner has no secret set) so a caller can tell "wrong
33
+ # tenant/client_id" apart from "correct tenant, nothing configured yet".
34
+ def credential_mismatch_message
35
+ return 'telegram_oauth_tenant_not_found' if params[:tenant_id].present? && credential_owner.nil?
36
+ return nil if credential_owner.nil? || credential_owner.preferred_telegram_oauth_client_id.blank?
37
+ return 'telegram_oauth_client_id_mismatch' unless client_id_matches_owner?
38
+
39
+ nil
40
+ end
41
+
42
+ def client_id_matches_owner?
43
+ credential_owner.preferred_telegram_oauth_client_id == exchange_params[:client_id]
44
+ end
45
+
46
+ def resolved_client_secret
47
+ credential_owner&.preferred_telegram_oauth_client_secret.presence
48
+ end
49
+
50
+ def credential_owner
51
+ @credential_owner ||= if params[:tenant_id].present?
52
+ SpreeCmCommissioner::Tenant.find_by(id: params[:tenant_id])
53
+ else
54
+ Spree::Store.default
55
+ end
23
56
  end
24
57
  end
25
58
  end
@@ -43,6 +43,7 @@ module Spree
43
43
  # It related to user cancel or complete order flow, which relies on the most recent orders being returned first.
44
44
  if spree_current_user.present?
45
45
  spree_current_user.orders
46
+ .exclude_vote_package
46
47
  .not_archived
47
48
  .incomplete
48
49
  .order(id: :desc)
@@ -51,6 +52,7 @@ module Spree
51
52
  return Spree::Order.none if order_tokens.empty?
52
53
 
53
54
  Spree::Order.not_archived
55
+ .exclude_vote_package
54
56
  .incomplete
55
57
  .without_user
56
58
  .where(token: order_tokens)
@@ -21,7 +21,7 @@ module SpreeCmCommissioner
21
21
  @destination_place_id = @params.dig(:filter, :destination_place_id)
22
22
  @query = @params.dig(:filter, :query) || @params[:query]
23
23
  @limit = (@params[:limit] || @params.dig(:page, :limit) || 50).to_i
24
- @order = @params[:order] || 'created_at DESC'
24
+ @order = @params[:order] || 'created_at ASC'
25
25
  end
26
26
 
27
27
  def execute
@@ -15,14 +15,12 @@ module SpreeCmCommissioner
15
15
  # "oidc-1") by default.
16
16
  SUPPORTED_ALGORITHMS = %w[RS256 ES256].freeze
17
17
 
18
- # :id_token
18
+ # :id_token, :client_id
19
19
  def call
20
20
  context.fail!(message: 'telegram_id_token_missing') if context.id_token.blank?
21
+ context.fail!(message: 'telegram_oauth_client_id_not_configured') if context.client_id.blank?
21
22
 
22
- audience = expected_audience
23
- context.fail!(message: 'telegram_oauth_client_id_not_configured') if audience.blank?
24
-
25
- claim = decode_id_token(audience)
23
+ claim = decode_id_token(context.client_id)
26
24
  return if claim.nil?
27
25
 
28
26
  context.claim = claim
@@ -83,10 +81,6 @@ module SpreeCmCommissioner
83
81
  context.fail!(message: 'telegram_jwks_fetch_failed', error: e.message)
84
82
  end
85
83
 
86
- def expected_audience
87
- ENV['TELEGRAM_OAUTH_CLIENT_ID'].presence
88
- end
89
-
90
84
  # sub is the Telegram user id (also the chat id once the user has /started
91
85
  # the bot), which keeps OIDC logins consistent with the Telegram Web App
92
86
  # flow that also keys UserIdentityProvider.telegram on the Telegram user id.
@@ -12,13 +12,17 @@ module SpreeCmCommissioner
12
12
  # still requires `client_secret`, which is why it must be injected here
13
13
  # server-side rather than sent by the app.
14
14
  #
15
+ # `client_secret` is resolved by the caller (which has tenant context from
16
+ # the request) and passed in like any other param - this interactor only
17
+ # forwards it to Telegram, it never looks up a tenant/store itself.
18
+ #
15
19
  # Docs: https://core.telegram.org/bots/telegram-login
16
20
  class TelegramOauthTokenExchanger < BaseInteractor
17
21
  TOKEN_ENDPOINT = 'https://oauth.telegram.org/token'.freeze
18
22
 
19
23
  REQUIRED_PARAMS = %i[client_id code redirect_uri code_verifier].freeze
20
24
 
21
- delegate :client_id, :code, :redirect_uri, :code_verifier, to: :context
25
+ delegate :client_id, :client_secret, :code, :redirect_uri, :code_verifier, to: :context
22
26
 
23
27
  def call
24
28
  missing = REQUIRED_PARAMS.find { |key| context[key].blank? }
@@ -33,10 +37,6 @@ module SpreeCmCommissioner
33
37
 
34
38
  private
35
39
 
36
- def client_secret
37
- ENV['TELEGRAM_OAUTH_CLIENT_SECRET'].presence
38
- end
39
-
40
40
  def post_to_telegram
41
41
  uri = URI(TOKEN_ENDPOINT)
42
42
  request = Net::HTTP::Post.new(uri)
@@ -19,12 +19,22 @@ module SpreeCmCommissioner
19
19
  private
20
20
 
21
21
  def verified_provider
22
- result = SpreeCmCommissioner::TelegramOauthIdTokenProvider.call(id_token: id_token)
22
+ result = SpreeCmCommissioner::TelegramOauthIdTokenProvider.call(id_token: id_token, client_id: telegram_client_id)
23
23
  context.fail!(message: result.message) unless result.success?
24
24
 
25
25
  result.provider
26
26
  end
27
27
 
28
+ # Each tenant runs its own Telegram bot, so the audience an id_token must
29
+ # carry is looked up on the tenant directly - no default-store or ENV
30
+ # fallback, so a misconfigured tenant fails loudly instead of silently
31
+ # authenticating against someone else's bot.
32
+ def telegram_client_id
33
+ return Spree::Store.default.preferred_telegram_oauth_client_id if tenant_id.blank?
34
+
35
+ SpreeCmCommissioner::Tenant.find_by(id: tenant_id)&.preferred_telegram_oauth_client_id
36
+ end
37
+
28
38
  def find_or_register_user(provider)
29
39
  identity_checker = SpreeCmCommissioner::UserIdentityChecker.call(
30
40
  identity_type: provider[:identity_type],
@@ -3,6 +3,7 @@ module Spree
3
3
  def cancel_email(order, resend: false)
4
4
  @order = order.respond_to?(:id) ? order : Spree::Order.find(order)
5
5
  return false if @order.email.blank?
6
+ return false if @order.products.all?(&:vote_package?) # disable cancel email for vote package orders
6
7
 
7
8
  setup_tenant_and_store
8
9
  if @tenant.present?
@@ -21,15 +21,25 @@ module SpreeCmCommissioner
21
21
  # ⚠️ CRITICAL: Cached seat layout IDs kept in sync by SeatLayout callback.
22
22
  # If callback is removed/modified without updating this, availability checks will fail.
23
23
  #
24
- # Sync callback (see seat_layout.rb:L26-35):
24
+ # `preload_seat_layout_ids` is every seat layout ever attached to this event, regardless of
25
+ # status — kept for callers that need the full history/count. `preload_active_seat_layout_ids`
26
+ # is the subset with status `active`, so consumers checking "can this still be booked?"
27
+ # (e.g. hasSeatSelection on the app) should use that one instead — deactivating/archiving a
28
+ # layout removes it from this list even though it stays in `preload_seat_layout_ids`.
29
+ #
30
+ # Sync callback (see seat_layout.rb:L32-44):
25
31
  # after_commit :sync_seat_layout_id_to_layoutable!, if: -> { layoutable.present? }
26
32
  # def sync_seat_layout_id_to_layoutable!
27
- # layoutable.update!(preload_seat_layout_ids: layoutable.seat_layouts.pluck(:id))
33
+ # layoutable.update!(
34
+ # preload_seat_layout_ids: layoutable.seat_layouts.pluck(:id),
35
+ # preload_active_seat_layout_ids: layoutable.seat_layouts.active.pluck(:id)
36
+ # )
28
37
  # end
29
38
  #
30
39
  # TODO: Not scalable for many seat layouts. Consider storing boolean flag
31
40
  # (has_active_seat_layout) instead when seat layouts become frequent.
32
41
  store_public_metadata :preload_seat_layout_ids, :array, default: []
42
+ store_public_metadata :preload_active_seat_layout_ids, :array, default: []
33
43
 
34
44
  before_validation :validate_at_least_one_check_in_flow_presence
35
45
  end
@@ -281,6 +281,8 @@ module SpreeCmCommissioner
281
281
  end
282
282
 
283
283
  def notify_order_complete_app_notification_to_user
284
+ return if products.all?(&:vote_package?) # disable app push notification for vote package orders
285
+
284
286
  SpreeCmCommissioner::OrderCompleteNotificationSender.call(order: self)
285
287
  end
286
288
 
@@ -11,6 +11,8 @@ module SpreeCmCommissioner
11
11
  preference :telegram_order_anomaly_alert_chat_id, :string
12
12
  preference :assetlinks, :string, default: ''
13
13
  preference :apple_app_site_association, :string, default: ''
14
+ preference :telegram_oauth_client_id, :string, default: ''
15
+ preference :telegram_oauth_client_secret, :string, default: ''
14
16
  end
15
17
  end
16
18
  end
@@ -15,6 +15,8 @@ module SpreeCmCommissioner
15
15
  preference :facebook, :string, default: ''
16
16
  preference :twitter, :string, default: ''
17
17
  preference :instagram, :string, default: ''
18
+ preference :telegram_oauth_client_id, :string, default: ''
19
+ preference :telegram_oauth_client_secret, :string, default: ''
18
20
  end
19
21
  end
20
22
  end
@@ -33,11 +33,19 @@ module SpreeCmCommissioner
33
33
  # Update layoutable's seat layout metadata after any change (create/update/destroy).
34
34
  # This keeps the cache in sync with the actual seat_layouts association.
35
35
  if layoutable.respond_to?(:seat_layouts)
36
- # Event has many seat layouts: cache all IDs for quick availability checks.
37
- layoutable.update!(preload_seat_layout_ids: layoutable.seat_layouts.pluck(:id))
36
+ # Event has many seat layouts: `..._ids` caches every layout ever attached (any status);
37
+ # `active_..._ids` caches only the `active` ones, for callers checking bookability.
38
+ layoutable.update!(
39
+ preload_seat_layout_ids: layoutable.seat_layouts.pluck(:id),
40
+ preload_active_seat_layout_ids: layoutable.seat_layouts.active.pluck(:id)
41
+ )
38
42
  elsif layoutable.respond_to?(:seat_layout)
39
- # Taxon/Vehicle has single seat layout: cache the ID (or nil if destroyed).
40
- layoutable.update!(preload_seat_layout_id: destroyed? ? nil : id)
43
+ # Taxon/Vehicle has single seat layout: cache the ID (or nil if destroyed); the active
44
+ # variant is also nil while the layout is inactive/archived, not just when destroyed.
45
+ layoutable.update!(
46
+ preload_seat_layout_id: destroyed? ? nil : id,
47
+ preload_active_seat_layout_id: destroyed? || !active? ? nil : id
48
+ )
41
49
  end
42
50
  end
43
51
  end
@@ -13,7 +13,9 @@ module SpreeCmCommissioner
13
13
  has_many :vote_packages, -> { where(product_type: :vote_package) }, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
14
14
  has_many :voting_sessions, through: :episodes, class_name: 'SpreeCmCommissioner::VotingSession'
15
15
 
16
- has_one :current_episode, -> { current_or_next_upcoming }, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
16
+ has_one :current_episode, lambda {
17
+ current_or_next_upcoming.where(status: :active)
18
+ }, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
17
19
  has_one :current_voting_session, through: :current_episode
18
20
 
19
21
  before_validation { self.type = self.class.name }
@@ -17,7 +17,9 @@ module SpreeCmCommissioner
17
17
  primary_key: :event_id
18
18
  has_many :contestants, through: :season, source: :show_contestants
19
19
 
20
- has_one :current_voting_session, -> { current_or_next_upcoming }, class_name: 'SpreeCmCommissioner::VotingSession', foreign_key: :episode_id
20
+ has_one :current_voting_session, lambda {
21
+ current_or_next_upcoming.where.not(status: :disabled)
22
+ }, class_name: 'SpreeCmCommissioner::VotingSession', foreign_key: :episode_id
21
23
 
22
24
  validates :ends_at, presence: true, if: -> { product_type == 'show_episode' }
23
25
 
@@ -8,10 +8,15 @@ module SpreeCmCommissioner
8
8
 
9
9
  enum trip_type: { direct: 0, multi_leg: 1 }
10
10
 
11
+ # public: shown in trip search results; unpublic: hidden from search (kept for the owning vendor).
12
+ # _prefix avoids generating a bare `public?` / `Trip.public` that would collide with Ruby's `public`.
13
+ enum visibility: { public: 0, unpublic: 1 }, _prefix: true, _default: :public
14
+
11
15
  # This model has no seat_layout column (polymorphic association), so we store the preload_seat_layout_id ID in public_metadata.
12
16
  # This lets us check if a seat layout exists without triggering a database query.
13
17
  # The ID is automatically updated whenever the seat_layout is saved.
14
18
  store_public_metadata :preload_seat_layout_id, :integer
19
+ store_public_metadata :preload_active_seat_layout_id, :integer
15
20
 
16
21
  # Trip: array of offsets for each leg # => [0, 1, 2]
17
22
  # Usage: trip.preload_offset_days_array[0] # => 0
@@ -7,6 +7,7 @@ module SpreeCmCommissioner
7
7
  # This lets us check if a seat layout exists without triggering a database query.
8
8
  # The ID is automatically updated whenever the seat_layout is saved.
9
9
  store_public_metadata :preload_seat_layout_id, :integer
10
+ store_public_metadata :preload_active_seat_layout_id, :integer
10
11
 
11
12
  attr_accessor :enable_seat_layout
12
13
 
@@ -0,0 +1,73 @@
1
+ <!-- insert_bottom "[data-hook='admin_store_form_fields']" -->
2
+
3
+ <% if @store.persisted? %>
4
+ <%
5
+ telegram_client_id = @store.preferred_telegram_oauth_client_id.presence
6
+ telegram_client_secret = @store.preferred_telegram_oauth_client_secret.presence
7
+ telegram_configured = telegram_client_id.present? && telegram_client_secret.present?
8
+ telegram_partial = telegram_client_id.present? ^ telegram_client_secret.present?
9
+ %>
10
+ <div class="card mb-4">
11
+ <div class="card-header d-flex flex-wrap align-items-center justify-content-between">
12
+ <h5 class="card-title mb-0 h6">Telegram Authentication</h5>
13
+ <% if telegram_configured %>
14
+ <span class="badge badge-success">Using this store's bot</span>
15
+ <% elsif telegram_partial %>
16
+ <span class="badge badge-warning">Incomplete</span>
17
+ <% else %>
18
+ <span class="badge badge-danger">Not configured</span>
19
+ <% end %>
20
+ </div>
21
+ <div class="card-body">
22
+ <div id="telegram-auth-settings" data-hook class="form-group mb-0">
23
+ <div class="row">
24
+ <!-- Telegram OAuth Client ID -->
25
+ <div class="col-md-6">
26
+ <%= f.field_container :preferred_telegram_oauth_client_id do %>
27
+ <%= f.label :preferred_telegram_oauth_client_id, 'Client ID' %>
28
+ <%= f.text_field :preferred_telegram_oauth_client_id, class: 'form-control text-monospace',
29
+ placeholder: 'e.g. 7123456789', autocomplete: 'off' %>
30
+ <small class="form-text text-muted">Bot ID &mdash; digits before the colon in the BotFather token.</small>
31
+ <%= f.error_message_on :preferred_telegram_oauth_client_id %>
32
+ <% end %>
33
+ </div>
34
+
35
+ <!-- Telegram OAuth Client Secret -->
36
+ <div class="col-md-6">
37
+ <%= f.field_container :preferred_telegram_oauth_client_secret do %>
38
+ <%= f.label :preferred_telegram_oauth_client_secret, 'Client Secret' %>
39
+ <div class="input-group">
40
+ <%= f.password_field :preferred_telegram_oauth_client_secret,
41
+ class: 'form-control text-monospace', id: 'store_telegram_oauth_client_secret',
42
+ autocomplete: 'new-password', value: f.object.preferred_telegram_oauth_client_secret %>
43
+ <div class="input-group-append">
44
+ <button type="button" class="btn btn-outline-secondary"
45
+ data-telegram-secret-toggle="store_telegram_oauth_client_secret">
46
+ Show
47
+ </button>
48
+ </div>
49
+ </div>
50
+ <small class="form-text text-muted">From BotFather. Server-side only &mdash; never sent to the app.</small>
51
+ <%= f.error_message_on :preferred_telegram_oauth_client_secret %>
52
+ <% end %>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+
59
+ <script>
60
+ (function () {
61
+ document.querySelectorAll('[data-telegram-secret-toggle]').forEach(function (button) {
62
+ button.addEventListener('click', function () {
63
+ var field = document.getElementById(button.dataset.telegramSecretToggle);
64
+ if (!field) return;
65
+
66
+ var hidden = field.type === 'password';
67
+ field.type = hidden ? 'text' : 'password';
68
+ button.textContent = hidden ? 'Hide' : 'Show';
69
+ });
70
+ });
71
+ })();
72
+ </script>
73
+ <% end %>
@@ -135,6 +135,11 @@ module SpreeCmCommissioner
135
135
  scope = scope.where(parent: { route_type: SpreeCmCommissioner::Trip.route_types[route_type] }) if route_type.present?
136
136
  scope = scope.where(parent: { vendor_id: vendor_id }) if vendor_id.present?
137
137
 
138
+ # Hide unpublished trips from search: filter both the leg (cm_trips) and the parent trip.
139
+ public_visibility = SpreeCmCommissioner::Trip.visibilities[:public]
140
+ scope = scope.visibility_public # filters cm_trips (the leg)
141
+ scope = scope.where(parent: { visibility: public_visibility })
142
+
138
143
  if tenant_id.present?
139
144
  scope = scope.joins('INNER JOIN spree_vendors v ON v.id = parent.vendor_id')
140
145
  .where(v: { tenant_id: tenant_id })
@@ -50,7 +50,8 @@ module SpreeCmCommissioner
50
50
 
51
51
  def direct_trips # rubocop:disable Metrics/MethodLength
52
52
  # Build base conditions
53
- base_conditions = { trip_type: :direct }
53
+ # visibility: :public hides unpublished trips from search results
54
+ base_conditions = { trip_type: :direct, visibility: :public }
54
55
  base_conditions[:vendor_id] = vendor_id if vendor_id.present?
55
56
  base_conditions[:route_type] = route_type if route_type.present?
56
57
  base_conditions[:spree_vendors] = { tenant_id: tenant_id } if tenant_id.present?
@@ -22,6 +22,7 @@ module Spree
22
22
 
23
23
  # To get full seat layout details, call the seat_layouts API with this ID.
24
24
  base.attribute :seat_layout_ids, &:preload_seat_layout_ids
25
+ base.attribute :active_seat_layout_ids, &:preload_active_seat_layout_ids
25
26
 
26
27
  base.attribute :purchasable_on_app do |taxon|
27
28
  taxon.purchasable_on == 'app' || taxon.purchasable_on == 'both'
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Aggregates vote counts for a voting session restricted to a created_at window.
4
+ #
5
+ # Redis counters and the finalized DB snapshot are both cumulative — they hold no time
6
+ # dimension — so any time-filtered leaderboard has to be recomputed from cm_votes.
7
+ # This service is the DB-backed equivalent of VoteCounters::PerContestantCounter and
8
+ # returns the exact same value shape so callers can swap between them.
9
+ #
10
+ # from / to are inclusive-exclusive (created_at >= from, created_at < to) so that
11
+ # adjacent windows never double-count a vote sitting on the boundary.
12
+ #
13
+ # Usage:
14
+ # SpreeCmCommissioner::VotingLeaderboards::TimeRangeResult.call(
15
+ # voting_session_id: session.id,
16
+ # from: 1.hour.ago,
17
+ # to: Time.current
18
+ # ).value
19
+ # # => { counts: { 12 => 40 }, unique_voters: 7, unique_voters_per_contestant: { 12 => 7 } }
20
+ module SpreeCmCommissioner
21
+ module VotingLeaderboards
22
+ class TimeRangeResult
23
+ prepend ::Spree::ServiceModule::Base
24
+
25
+ EMPTY_RESULT = {
26
+ counts: {}.freeze,
27
+ unique_voters: 0,
28
+ unique_voters_per_contestant: {}.freeze
29
+ }.freeze
30
+
31
+ def call(voting_session_id:, from: nil, to: nil)
32
+ return failure(nil, I18n.t('spree_cm_commissioner.voting_leaderboards.time_range_result.invalid_range')) if invalid_range?(from, to)
33
+
34
+ ids = contestant_ids(voting_session_id)
35
+ return success(EMPTY_RESULT) if ids.empty?
36
+
37
+ scope = votes_scope(voting_session_id, from, to)
38
+
39
+ success(
40
+ counts: fill(ids, scope.group(:contestant_id).sum(:quantity)),
41
+ unique_voters: unique_voters(scope),
42
+ unique_voters_per_contestant: fill(ids, unique_voters_per_contestant(scope))
43
+ )
44
+ rescue StandardError => e
45
+ failure(nil, e.message)
46
+ end
47
+
48
+ private
49
+
50
+ def invalid_range?(from, to)
51
+ from.present? && to.present? && from > to
52
+ end
53
+
54
+ def contestant_ids(voting_session_id)
55
+ SpreeCmCommissioner::VotingContestant.where(voting_session_id: voting_session_id).pluck(:id)
56
+ end
57
+
58
+ def votes_scope(voting_session_id, from, to)
59
+ scope = SpreeCmCommissioner::Vote.where(voting_session_id: voting_session_id)
60
+ scope = scope.where(created_at: from..) if from.present?
61
+ scope = scope.where(created_at: ...to) if to.present?
62
+ scope
63
+ end
64
+
65
+ def unique_voters(scope)
66
+ scope.where.not(user_id: nil).distinct.count(:user_id)
67
+ end
68
+
69
+ def unique_voters_per_contestant(scope)
70
+ scope.where.not(user_id: nil).group(:contestant_id).distinct.count(:user_id)
71
+ end
72
+
73
+ # Contestants with no votes in the window must still appear with zeros.
74
+ def fill(ids, rows)
75
+ ids.index_with { |id| rows.fetch(id, 0) }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -223,6 +223,33 @@
223
223
  </div>
224
224
  </div>
225
225
 
226
+ <!-- ======================== Telegram Authentication ======================== -->
227
+ <%
228
+ telegram_client_id = f.object.preferred_telegram_oauth_client_id.presence
229
+ telegram_client_secret = f.object.preferred_telegram_oauth_client_secret.presence
230
+ telegram_configured = telegram_client_id.present? && telegram_client_secret.present?
231
+ telegram_partial = telegram_client_id.present? ^ telegram_client_secret.present?
232
+ %>
233
+ <div class="card mb-4">
234
+ <div class="card-body">
235
+ <div data-hook="admin_tenant_form_fields" class="row mt-4">
236
+ <div class="col-12 mb-4 d-flex flex-wrap align-items-center justify-content-between">
237
+ <h4 class="mb-0">Telegram Authentication <span class="text-muted" style="font-size: 1rem; font-weight: normal;">(sign customers in with this tenant's own Telegram bot)</span></h4>
238
+ <% if telegram_configured %>
239
+ <span class="badge badge-success">Using this tenant's bot</span>
240
+ <% elsif telegram_partial %>
241
+ <span class="badge badge-warning">Incomplete</span>
242
+ <% else %>
243
+ <span class="badge badge-danger">Not configured</span>
244
+ <% end %>
245
+ </div>
246
+ <div class="col-12">
247
+ <%= render partial: 'spree/admin/tenants/form/telegram_auth', locals: { f: f } %>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ </div>
252
+
226
253
  <!-- ======================== Redirect Settings ======================== -->
227
254
  <div class="card mb-4">
228
255
  <div class="card-body">
@@ -0,0 +1,61 @@
1
+ <%
2
+ telegram_partial = f.object.preferred_telegram_oauth_client_id.presence.present? ^
3
+ f.object.preferred_telegram_oauth_client_secret.presence.present?
4
+ %>
5
+ <div data-hook="admin_tenant_telegram_auth_fields">
6
+ <% if telegram_partial %>
7
+ <p class="text-warning small mb-3">
8
+ Both fields are required &mdash; with only one filled in, login will fail for this tenant.
9
+ </p>
10
+ <% end %>
11
+
12
+ <div class="row">
13
+ <div class="col-md-6">
14
+ <%= f.field_container :preferred_telegram_oauth_client_id do %>
15
+ <%= f.label :preferred_telegram_oauth_client_id, 'Client ID' %>
16
+ <%= f.text_field :preferred_telegram_oauth_client_id, class: 'form-control text-monospace',
17
+ placeholder: 'e.g. 7123456789', autocomplete: 'off' %>
18
+ <small class="form-text text-muted">Bot ID &mdash; digits before the colon in the BotFather token.</small>
19
+ <% if f.object.errors[:preferred_telegram_oauth_client_id].any? %>
20
+ <div class="error text-danger"><%= f.object.errors[:preferred_telegram_oauth_client_id].join(', ') %></div>
21
+ <% end %>
22
+ <% end %>
23
+ </div>
24
+
25
+ <div class="col-md-6">
26
+ <%= f.field_container :preferred_telegram_oauth_client_secret do %>
27
+ <%= f.label :preferred_telegram_oauth_client_secret, 'Client Secret' %>
28
+ <div class="input-group">
29
+ <%= f.password_field :preferred_telegram_oauth_client_secret,
30
+ class: 'form-control text-monospace', id: 'tenant_telegram_oauth_client_secret',
31
+ autocomplete: 'new-password', value: f.object.preferred_telegram_oauth_client_secret %>
32
+ <div class="input-group-append">
33
+ <button type="button" class="btn btn-outline-secondary"
34
+ data-telegram-secret-toggle="tenant_telegram_oauth_client_secret">
35
+ Show
36
+ </button>
37
+ </div>
38
+ </div>
39
+ <small class="form-text text-muted">From BotFather. Server-side only &mdash; never sent to the app.</small>
40
+ <% if f.object.errors[:preferred_telegram_oauth_client_secret].any? %>
41
+ <div class="error text-danger"><%= f.object.errors[:preferred_telegram_oauth_client_secret].join(', ') %></div>
42
+ <% end %>
43
+ <% end %>
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <script>
49
+ (function () {
50
+ document.querySelectorAll('[data-telegram-secret-toggle]').forEach(function (button) {
51
+ button.addEventListener('click', function () {
52
+ var field = document.getElementById(button.dataset.telegramSecretToggle);
53
+ if (!field) return;
54
+
55
+ var hidden = field.type === 'password';
56
+ field.type = hidden ? 'text' : 'password';
57
+ button.textContent = hidden ? 'Hide' : 'Show';
58
+ });
59
+ });
60
+ })();
61
+ </script>
@@ -71,6 +71,8 @@ module Spree
71
71
  :preferred_telegram_order_anomaly_alert_chat_id,
72
72
  :preferred_assetlinks,
73
73
  :preferred_apple_app_site_association,
74
+ :preferred_telegram_oauth_client_id,
75
+ :preferred_telegram_oauth_client_secret,
74
76
  { default_notification_image_attributes: {} },
75
77
  :term_and_condition_promotion
76
78
  ]
@@ -925,6 +925,8 @@ en:
925
925
  voting_leaderboards:
926
926
  combined_result:
927
927
  pre_vote_not_finalized: "Cannot combine results: Pre-Vote sessions not yet finalized: %{names}"
928
+ time_range_result:
929
+ invalid_range: "Invalid time range: the start time must be before the end time."
928
930
 
929
931
  voting:
930
932
  errors:
@@ -681,6 +681,9 @@ km:
681
681
  voting_contestants:
682
682
  assigner:
683
683
  not_eliminated: "តែអ្នកចូលប្រកួតដែលត្រូវបានលុបចោលប៉ុណ្ណោះអាចបន្ថែមទៅ Save Contestants session បាន។ %{name} មានស្ថានភាព %{status}។"
684
+ voting_leaderboards:
685
+ time_range_result:
686
+ invalid_range: "ចន្លោះពេលមិនត្រឹមត្រូវ៖ ពេលចាប់ផ្តើមត្រូវតែមុនពេលបញ្ចប់។"
684
687
  voting:
685
688
  errors:
686
689
  no_winners_confirmed: "មិនមានអ្នកឈ្នះត្រូវបានបញ្ជាក់"
@@ -0,0 +1,6 @@
1
+ class AddVisibilityToCmTrips < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :cm_trips, :visibility, :integer, default: 0, null: false, if_not_exists: true # 0 = public, 1 = unpublic
4
+ add_index :cm_trips, :visibility, if_not_exists: true
5
+ end
6
+ end
@@ -0,0 +1,12 @@
1
+ # Supports the time-filtered leaderboard (VotingLeaderboards::TimeRangeResult), which runs
2
+ # WHERE voting_session_id = ? AND created_at >= ? AND created_at < ?
3
+ # grouped by contestant_id.
4
+ #
5
+ # The plain created_at index was dropped in OptimizeCmVotesIndexes because it was never
6
+ # selected on its own; this composite one leads with voting_session_id so the planner can
7
+ # use it for both the session filter and the range scan.
8
+ class AddSessionCreatedAtIndexToCmVotes < ActiveRecord::Migration[7.0]
9
+ def change
10
+ add_index :cm_votes, %i[voting_session_id created_at], if_not_exists: true
11
+ end
12
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.9.6'.freeze
2
+ VERSION = '2.9.7'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.9.6
4
+ version: 2.9.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-29 00:00:00.000000000 Z
11
+ date: 2026-08-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -1889,6 +1889,7 @@ files:
1889
1889
  - app/overrides/spree/admin/stock_locations/index/index.html.erb.deface
1890
1890
  - app/overrides/spree/admin/stores/_form/store_preferences.html.erb.deface
1891
1891
  - app/overrides/spree/admin/stores/_form/store_term_and_condition_promotion.html.erb.deface
1892
+ - app/overrides/spree/admin/stores/_form/telegram_auth.html.erb.deface
1892
1893
  - app/overrides/spree/admin/stores/form/_layout/default_notification_image.html.erb.deface
1893
1894
  - app/overrides/spree/admin/taxonomies/_form/kind.html.erb.deface
1894
1895
  - app/overrides/spree/admin/taxons/_form/assets_form.html.erb.deface
@@ -2408,6 +2409,7 @@ files:
2408
2409
  - app/services/spree_cm_commissioner/voting_credits/de_allocate.rb
2409
2410
  - app/services/spree_cm_commissioner/voting_leaderboards/calculate_score.rb
2410
2411
  - app/services/spree_cm_commissioner/voting_leaderboards/combined_result.rb
2412
+ - app/services/spree_cm_commissioner/voting_leaderboards/time_range_result.rb
2411
2413
  - app/services/spree_cm_commissioner/voting_sessions/finalize.rb
2412
2414
  - app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb
2413
2415
  - app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb
@@ -2636,6 +2638,7 @@ files:
2636
2638
  - app/views/spree/admin/tenants/edit.html.erb
2637
2639
  - app/views/spree/admin/tenants/form/_footer.html.erb
2638
2640
  - app/views/spree/admin/tenants/form/_social.html.erb
2641
+ - app/views/spree/admin/tenants/form/_telegram_auth.html.erb
2639
2642
  - app/views/spree/admin/tenants/index.html.erb
2640
2643
  - app/views/spree/admin/tenants/new.html.erb
2641
2644
  - app/views/spree/admin/ticket_transfer_rules/_form.html.erb
@@ -3458,6 +3461,8 @@ files:
3458
3461
  - db/migrate/20260723000000_add_payment_host_to_cm_tenants.rb
3459
3462
  - db/migrate/20260723000001_add_metadata_to_spree_roles.rb
3460
3463
  - db/migrate/20260728074153_add_role_to_cm_exports.rb
3464
+ - db/migrate/20260729000000_add_visibility_to_cm_trips.rb
3465
+ - db/migrate/20260729000001_add_session_created_at_index_to_cm_votes.rb
3461
3466
  - docker-compose.yml
3462
3467
  - docs/api/scoped-access-token-endpoints.md
3463
3468
  - docs/option_types/attr_types.md