spree_cm_commissioner 2.9.6 → 2.9.8

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +3 -8
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/order_concern.rb +5 -0
  5. data/app/controllers/spree/admin/users_controller_decorator.rb +18 -0
  6. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +2 -1
  7. data/app/controllers/spree/api/v2/storefront/telegram_oauth_tokens_controller.rb +35 -2
  8. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +2 -1
  9. data/app/controllers/spree/api/v2/tenant/order_histories_controller.rb +2 -0
  10. data/app/finders/spree_cm_commissioner/routes/find.rb +1 -1
  11. data/app/interactors/spree_cm_commissioner/create_ticket.rb +7 -3
  12. data/app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb +3 -9
  13. data/app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb +5 -5
  14. data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +11 -1
  15. data/app/mailers/spree/order_mailer_decorator.rb +1 -0
  16. data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +12 -2
  17. data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +0 -1
  18. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +2 -0
  19. data/app/models/concerns/spree_cm_commissioner/purchase_limitable.rb +59 -0
  20. data/app/models/concerns/spree_cm_commissioner/store_preference.rb +2 -0
  21. data/app/models/concerns/spree_cm_commissioner/tenant_preference.rb +2 -0
  22. data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +0 -1
  23. data/app/models/spree_cm_commissioner/line_item_decorator.rb +4 -3
  24. data/app/models/spree_cm_commissioner/product_decorator.rb +1 -0
  25. data/app/models/spree_cm_commissioner/seat_layout.rb +12 -4
  26. data/app/models/spree_cm_commissioner/show.rb +3 -1
  27. data/app/models/spree_cm_commissioner/show_episode.rb +3 -1
  28. data/app/models/spree_cm_commissioner/trip.rb +5 -0
  29. data/app/models/spree_cm_commissioner/variant_options.rb +0 -4
  30. data/app/models/spree_cm_commissioner/vehicle_type.rb +1 -0
  31. data/app/overrides/spree/admin/stores/_form/telegram_auth.html.erb.deface +73 -0
  32. data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +5 -0
  33. data/app/queries/spree_cm_commissioner/single_leg_trips_query.rb +2 -1
  34. data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +4 -0
  35. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +1 -0
  36. data/app/serializers/spree/v2/storefront/variant_serializer_decorator.rb +2 -1
  37. data/app/serializers/spree/v2/tenant/product_serializer.rb +5 -0
  38. data/app/serializers/spree/v2/tenant/variant_serializer.rb +2 -1
  39. data/app/services/spree_cm_commissioner/draft_order/create.rb +13 -4
  40. data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +97 -0
  41. data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +4 -4
  42. data/app/services/spree_cm_commissioner/voting_leaderboards/time_range_result.rb +79 -0
  43. data/app/views/spree/admin/tenants/_form.html.erb +27 -0
  44. data/app/views/spree/admin/tenants/form/_telegram_auth.html.erb +61 -0
  45. data/config/initializers/spree_permitted_attributes.rb +2 -0
  46. data/config/locales/en.yml +14 -1
  47. data/config/locales/km.yml +19 -5
  48. data/db/migrate/20260729000000_add_visibility_to_cm_trips.rb +6 -0
  49. data/db/migrate/20260729000001_add_session_created_at_index_to_cm_votes.rb +12 -0
  50. data/db/migrate/20260730000000_add_purchase_limit_scopes_to_spree_orders.rb +22 -0
  51. data/lib/spree_cm_commissioner/test_helper/factories/option_type_factory.rb +0 -6
  52. data/lib/spree_cm_commissioner/version.rb +1 -1
  53. metadata +10 -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: 8eb692e530b34906dfca4d757e6382b9b643e18760fd38ba31507ceb24bb687b
4
+ data.tar.gz: 782a651003a7817fb539def0a19697d434978c51f7f48c0f2184b48fe87c1260
5
5
  SHA512:
6
- metadata.gz: 9f722ef15d8fef51009e1da877520831551d33d4c2391036dcd31db1ddc6f485cc0d475b6144843fb660404e7e75ecfb77769a646bc6933378b4eca181cadfaa
7
- data.tar.gz: 8ef07513d2cb8b65635dfa960b60d84fdbb6d0ad2731cc098f3e99de64901bd3100e80b9a10c0ac1e1243a4c451f2633b93ca3496775f690b10bd6edc2f66369
6
+ metadata.gz: '090263c66bf5f79ea0edfcf6602185b0d80120f33308b2d0193cacf3bb2ebc5036543f1e1000b4e823a243a31cd3dc2eaf3e4b8772c6717d16891554b3da96a0'
7
+ data.tar.gz: 3540311c234819a354e154a532b7745e91ebf1faf7b15bcc40a5a6def80ea069d3820759b328decb2328f6ad60190a12686d5c23c0951c864ee89215160fd4a1
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.8)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -12,6 +12,7 @@ module SpreeCmCommissioner
12
12
  end
13
13
 
14
14
  @spree_current_order.last_ip_address = request.remote_ip
15
+ @spree_current_order.device_id = device_id if device_id.present?
15
16
  @spree_current_order
16
17
  end
17
18
 
@@ -25,5 +26,9 @@ module SpreeCmCommissioner
25
26
  state: params[:state]
26
27
  )
27
28
  end
29
+
30
+ def device_id
31
+ params[:device_id].presence
32
+ end
28
33
  end
29
34
  end
@@ -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
 
@@ -15,7 +15,8 @@ module Spree
15
15
  line_items: draft_order_line_items,
16
16
  user: spree_current_user,
17
17
  store: current_store,
18
- currency: current_currency
18
+ currency: current_currency,
19
+ options: { device_id: params[:device_id], last_ip_address: request.remote_ip }
19
20
  )
20
21
 
21
22
  if result.success?
@@ -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
@@ -13,7 +13,8 @@ module Spree
13
13
  user: spree_current_user,
14
14
  store: current_store,
15
15
  currency: current_currency,
16
- tenant: @tenant
16
+ tenant: @tenant,
17
+ options: { device_id: params[:device_id], last_ip_address: request.remote_ip }
17
18
  )
18
19
 
19
20
  if result.success?
@@ -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
@@ -117,12 +117,15 @@ module SpreeCmCommissioner
117
117
  end
118
118
  end
119
119
 
120
- def ticket_params
120
+ def ticket_params # rubocop:disable Metrics/AbcSize
121
121
  {
122
122
  name: context.params[:name],
123
123
  price: context.params[:price],
124
124
  compare_at_price: context.params[:compare_at_price],
125
- max_order_quantity: context.params[:max_order_quantity],
125
+ max_quantity_per_order: context.params[:max_quantity_per_order],
126
+ max_quantity_per_account: context.params[:max_quantity_per_account],
127
+ max_quantity_per_device: context.params[:max_quantity_per_device],
128
+ max_quantity_per_ip: context.params[:max_quantity_per_ip],
126
129
  available_on: context.params[:available_on],
127
130
  discontinue_on: context.params[:discontinue_on],
128
131
  description: context.params[:description],
@@ -132,7 +135,8 @@ module SpreeCmCommissioner
132
135
  shipping_category_id: context.params[:shipping_category_id],
133
136
  option_type_ids: context.params[:option_type_ids],
134
137
  allow_anonymous_booking: context.params[:allow_anonymous_booking],
135
- allow_self_check_in: context.params[:allow_self_check_in]
138
+ allow_self_check_in: context.params[:allow_self_check_in],
139
+ purchasable_on: context.params[:purchasable_on] || 'both'
136
140
  }
137
141
  end
138
142
  end
@@ -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
@@ -29,7 +29,6 @@ module SpreeCmCommissioner
29
29
  'duration-in-seconds' => 'integer',
30
30
  'payment-option' => 'payment_option',
31
31
  'delivery-option' => 'delivery_option',
32
- 'max-quantity-per-order' => 'integer',
33
32
  'due-date' => 'integer',
34
33
  'month' => 'integer',
35
34
  'number-of-adults' => 'integer',
@@ -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
 
@@ -0,0 +1,59 @@
1
+ # Product-level purchase caps per buyer identity (order, account, device, IP).
2
+ # Stored as public metadata and enforced at cart time by
3
+ # SpreeCmCommissioner::LineItems::ValidatePurchaseLimits.
4
+ module SpreeCmCommissioner
5
+ module PurchaseLimitable
6
+ extend ActiveSupport::Concern
7
+
8
+ PURCHASE_LIMIT_KEYS = %i[
9
+ max_quantity_per_order
10
+ max_quantity_per_account
11
+ max_quantity_per_device
12
+ max_quantity_per_ip
13
+ ].freeze
14
+
15
+ POSITIVE_INTEGER = { only_integer: true, greater_than: 0, message: :must_be_positive_integer }.freeze
16
+
17
+ included do
18
+ store_public_metadata :max_quantity_per_order, :integer
19
+ store_public_metadata :max_quantity_per_account, :integer
20
+ store_public_metadata :max_quantity_per_device, :integer
21
+ store_public_metadata :max_quantity_per_ip, :integer
22
+
23
+ validates :max_quantity_per_order, numericality: POSITIVE_INTEGER, allow_nil: true
24
+ validates :max_quantity_per_account, numericality: POSITIVE_INTEGER, allow_nil: true
25
+ validates :max_quantity_per_device, numericality: POSITIVE_INTEGER, allow_nil: true
26
+ validates :max_quantity_per_ip, numericality: POSITIVE_INTEGER, allow_nil: true
27
+
28
+ validate :validate_max_quantity_per_account
29
+ validate :validate_max_quantity_per_device
30
+
31
+ before_validation :normalize_purchase_limits
32
+ end
33
+
34
+ private
35
+
36
+ # A blank cap means "unset". store_public_metadata writes the raw value and its
37
+ # :integer reader casts with to_i, so a form-submitted "" would otherwise read back
38
+ # as 0 and fail both the numericality check and the metadata type check.
39
+ def normalize_purchase_limits
40
+ return if public_metadata.blank?
41
+
42
+ public_metadata.slice(*PURCHASE_LIMIT_KEYS.map(&:to_s)).each do |key, value|
43
+ public_metadata[key] = nil if value.blank?
44
+ end
45
+ end
46
+
47
+ def validate_max_quantity_per_account
48
+ return unless max_quantity_per_account.to_i.positive? && allow_anonymous_booking?
49
+
50
+ errors.add(:base, I18n.t('purchase_limit.account_cap_requires_named_booking'))
51
+ end
52
+
53
+ def validate_max_quantity_per_device
54
+ return unless max_quantity_per_device.to_i.positive? && !purchasable_on_app?
55
+
56
+ errors.add(:base, I18n.t('purchase_limit.device_cap_requires_app_only'))
57
+ end
58
+ end
59
+ end
@@ -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
@@ -14,7 +14,6 @@ module SpreeCmCommissioner
14
14
  :duration_in_seconds,
15
15
  :payment_option,
16
16
  :delivery_option,
17
- :max_quantity_per_order,
18
17
  :due_date,
19
18
  :month,
20
19
  :number_of_adults,
@@ -32,7 +32,7 @@ module SpreeCmCommissioner
32
32
  base.before_save :update_vendor_id
33
33
 
34
34
  base.before_create :add_due_date, if: :subscription?
35
- base.validate :ensure_not_exceed_max_quantity_per_order, if: -> { variant&.max_quantity_per_order.present? }
35
+ base.validate :ensure_not_exceed_purchase_limits, if: -> { product.present? }
36
36
  base.validate :ensure_all_guests_have_blocks, if: :required_seat_selection?
37
37
 
38
38
  base.whitelisted_ransackable_associations |= %w[guests order]
@@ -290,8 +290,9 @@ module SpreeCmCommissioner
290
290
 
291
291
  private
292
292
 
293
- def ensure_not_exceed_max_quantity_per_order
294
- errors.add(:quantity, I18n.t('line_item.validation.exceeded_max_quantity_per_order')) if quantity > variant.max_quantity_per_order
293
+ def ensure_not_exceed_purchase_limits
294
+ result = SpreeCmCommissioner::LineItems::ValidatePurchaseLimits.call(line_item: self)
295
+ errors.add(:quantity, result.error.to_s) unless result.success?
295
296
  end
296
297
 
297
298
  def ensure_all_guests_have_blocks
@@ -11,6 +11,7 @@ module SpreeCmCommissioner
11
11
  base.include SpreeCmCommissioner::Integrations::IntegrationMappable
12
12
  base.include SpreeCmCommissioner::StoreMetadata
13
13
  base.include SpreeCmCommissioner::HomepageSectionRelatableConcern
14
+ base.include SpreeCmCommissioner::PurchaseLimitable
14
15
 
15
16
  base.delegate :is_open_dated, :is_open_dated?, to: :trip, allow_nil: true
16
17
 
@@ -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
@@ -81,10 +81,6 @@ module SpreeCmCommissioner
81
81
  @delivery_option ||= option_value_name_for(option_type_name: 'delivery-option')
82
82
  end
83
83
 
84
- def max_quantity_per_order
85
- @max_quantity_per_order ||= option_value_name_for(option_type_name: 'max-quantity-per-order')&.to_i
86
- end
87
-
88
84
  def due_date
89
85
  @due_date ||= option_value_name_for(option_type_name: 'due-date')&.to_i
90
86
  end
@@ -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 %>