spree_cm_commissioner 2.14.5 → 2.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +1 -1
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +30 -7
  5. data/app/controllers/spree/admin/api_caches_controller.rb +23 -8
  6. data/app/controllers/spree/admin/homepage_section_relatable_controller.rb +24 -0
  7. data/app/controllers/spree/api/v2/organizer/invite_guests_controller.rb +2 -2
  8. data/app/controllers/spree/api/v2/resource_controller_decorator.rb +8 -0
  9. data/app/controllers/spree/api/v2/storefront/invite_guests_controller.rb +2 -2
  10. data/app/controllers/spree/api/v2/storefront/trip_search_controller.rb +18 -13
  11. data/app/controllers/spree/api/v2/storefront/user_contacts_controller.rb +2 -0
  12. data/app/controllers/spree/api/v2/tenant/trip_search_controller.rb +18 -13
  13. data/app/factory/spree_cm_commissioner/order_telegram_message_factory.rb +5 -1
  14. data/app/helpers/spree_cm_commissioner/admin/homepage_segment_helper.rb +28 -0
  15. data/app/interactors/spree_cm_commissioner/account_recover.rb +23 -2
  16. data/app/interactors/spree_cm_commissioner/firebase_id_token_provider.rb +6 -2
  17. data/app/interactors/spree_cm_commissioner/telegram_chats_auto_finder.rb +26 -2
  18. data/app/interactors/spree_cm_commissioner/telegram_chats_finder.rb +1 -1
  19. data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +2 -11
  20. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +1 -13
  21. data/app/models/spree_cm_commissioner/maintenance_tasks/cache_invalidation.rb +95 -11
  22. data/app/models/spree_cm_commissioner/pin_code.rb +2 -1
  23. data/app/models/spree_cm_commissioner/pin_code_event_telegram.rb +31 -0
  24. data/app/models/spree_cm_commissioner/service_calendar.rb +2 -2
  25. data/app/models/spree_cm_commissioner/taxon_decorator.rb +7 -0
  26. data/app/models/spree_cm_commissioner/vendor_decorator.rb +6 -0
  27. data/app/queries/spree_cm_commissioner/trip_query.rb +20 -2
  28. data/app/services/spree_cm_commissioner/api_caches/fetch_details.rb +128 -0
  29. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +31 -14
  30. data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +16 -19
  31. data/app/services/spree_cm_commissioner/api_caches/request_clear.rb +71 -0
  32. data/app/services/spree_cm_commissioner/guests/claim_invite_guest_service.rb +5 -3
  33. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_service_calendars.rb +3 -0
  34. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +29 -4
  35. data/app/services/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items.rb +15 -4
  36. data/app/services/spree_cm_commissioner/telegram/verify_oauth_id_token.rb +10 -0
  37. data/app/services/spree_cm_commissioner/telegram_alerts/send_telegram_order_alerts.rb +49 -0
  38. data/app/views/spree/admin/api_caches/_dialog.html.erb +47 -0
  39. data/app/views/spree/admin/api_caches/show.html.erb +88 -0
  40. data/app/views/spree/admin/homepage_section/edit.html.erb +16 -1
  41. data/app/views/spree/admin/shared/_api_cache_button.html.erb +5 -2
  42. data/config/locales/en.yml +39 -0
  43. data/config/locales/km.yml +39 -0
  44. data/config/routes.rb +3 -4
  45. data/lib/spree_cm_commissioner/version.rb +1 -1
  46. metadata +8 -3
  47. data/app/jobs/spree_cm_commissioner/invalidate_cache_request_job.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7720d10f3459b8933df3d2521bdce4983e03adf800dc852cb4e6f28d689f146
4
- data.tar.gz: 746c1c6e7cc864f5555020ab15d21eeac2c601d6f7d6b0bcfa1bff0b9633bb77
3
+ metadata.gz: 743a7213a36a799ebd7d68c8e2a770a9a253b9ca9e544c53dc89114344c35eec
4
+ data.tar.gz: c9697afd92b4175c73c1291224522b4b56fdf4056da7c8b4f441b33f0c828af4
5
5
  SHA512:
6
- metadata.gz: 504fa1a8ea3a50979c1c23e366713e6e6b10bfe38efb4e3a4019b3f5f8cefa35ec460fd8a8b013cd1324c4ead1359fdbc0b00a7c0b56e71bd606c294cf180c6c
7
- data.tar.gz: 5d1d182cbd5f0538d5d281c1c401b7724fe1bc27e0f3c5009345493a8335aa6bae62ed9dea22af2fc52b07ac8bfc1e617953851cbcaf7c5823773536ef4651ae
6
+ metadata.gz: 0bc416a5439a60e3daaa23cb031fee13bd2a5ab82795e05aa52005713bd27adf298f0b09ae793d7109f2b3b55bd8fd0b937d50323b3f53031c64217ad1396bff
7
+ data.tar.gz: '073149c66ec7fc0b4b32d0c9b76ee35c934a96e489ae68ca6116adc4b60f831dc4271dd696ce996dbe6f80cd1db2658d0ffdeabfc25630e238d9c7cff5301bbd'
data/.env.example CHANGED
@@ -49,7 +49,7 @@ DISTANCE_SIGNING_KEY=hei********************VY
49
49
 
50
50
  # CDN cache durations (s-maxage, in seconds) for listed public APIs; client max-age is always 0, unlisted APIs are never CDN-cached
51
51
  # See: app/controllers/concerns/spree_cm_commissioner/content_cachable.rb
52
- CACHE_AUTO_INVALIDATION_DISABLED="" # Set to yes to disable CDN cache invalidation entirely (kill switch)
52
+ CACHE_AUTO_INVALIDATION_DISABLED="" # Set to yes to stop automatic CDN cache invalidation on save (kill switch); a clear-cache click still sends one
53
53
  CACHE_SERIALIZER_DISABLED="" # Set to yes to turn off Spree's serializer record cache everywhere
54
54
  CACHE_STATIC_MAX_AGE=86400 # Static content (CMS pages) - 1 day
55
55
  CACHE_REALTIME_MAX_AGE=300 # Everything else listed (homepage, menus, products, events, vendors, trips, seat layouts, countries, provinces, guest card classes) - 5 minutes
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.14.5)
37
+ spree_cm_commissioner (2.14.6)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -74,14 +74,20 @@ module SpreeCmCommissioner
74
74
  'Spree::Api::V2::Tenant::VotingContestantsController' => { actions: %w[index].freeze, max_age: :fresh }
75
75
  }.freeze
76
76
 
77
- # CDN (s-maxage) TTL, or nil for an unlisted controller. Override in a controller for a different one.
78
- def shared_max_age
79
- case cdn_cache_config&.dig(:max_age)
77
+ # Seconds for a TTL tier, or nil for none. Public so the clear-cache dialog can say how long an
78
+ # edit takes to show without a clear.
79
+ def self.cdn_max_age(tier)
80
+ case tier
80
81
  when :static then ENV.fetch('CACHE_STATIC_MAX_AGE', 1.day.to_i).to_i
81
82
  when :fresh then ENV.fetch('CACHE_REALTIME_MAX_AGE', 5.minutes.to_i).to_i
82
83
  end
83
84
  end
84
85
 
86
+ # CDN (s-maxage) TTL, or nil for an unlisted controller. Override in a controller for a different one.
87
+ def shared_max_age
88
+ ContentCachable.cdn_max_age(cdn_cache_config&.dig(:max_age))
89
+ end
90
+
85
91
  def cdn_cacheable?
86
92
  request.get? && cdn_cache_config.present? && cdn_cache_config[:actions].include?(action_name)
87
93
  end
@@ -102,12 +108,10 @@ module SpreeCmCommissioner
102
108
  # list so a new secret can be added to CloudFront before the old one is dropped. With
103
109
  # CDN_ORIGIN_REPORT_ONLY=yes it only logs, to find clients still calling the origin before enforcing.
104
110
  def verify_cdn_origin!
105
- secrets = ENV.fetch('CDN_ORIGIN_SECRETS', '').split(',').map(&:strip).compact_blank
106
- return if secrets.empty?
111
+ return if cdn_origin_secrets.empty?
112
+ return if cdn_origin_verified?
107
113
 
108
114
  provided = request.headers[CDN_ORIGIN_HEADER].to_s
109
- return if secrets.any? { |secret| ActiveSupport::SecurityUtils.secure_compare(provided, secret) }
110
-
111
115
  if ENV['CDN_ORIGIN_REPORT_ONLY'] == 'yes'
112
116
  CmAppLogger.log(
113
117
  label: 'cdn_origin_unverified',
@@ -125,8 +129,27 @@ module SpreeCmCommissioner
125
129
  head :forbidden
126
130
  end
127
131
 
132
+ # True when CloudFront will cache this response, so a controller-level Rails.cache in front of it
133
+ # only stacks its TTL on CloudFront's and hands a stale body to the refetch that follows a
134
+ # CloudFront invalidation. Requires a matching secret, not just the header: a header any caller
135
+ # can set must not let them skip both caches and hit the query directly.
136
+ def served_to_cdn?
137
+ return @served_to_cdn if defined?(@served_to_cdn)
138
+
139
+ @served_to_cdn = cdn_cacheable? && cdn_origin_verified?
140
+ end
141
+
128
142
  private
129
143
 
144
+ def cdn_origin_verified?
145
+ provided = request.headers[CDN_ORIGIN_HEADER].to_s
146
+ cdn_origin_secrets.any? { |secret| ActiveSupport::SecurityUtils.secure_compare(provided, secret) }
147
+ end
148
+
149
+ def cdn_origin_secrets
150
+ ENV.fetch('CDN_ORIGIN_SECRETS', '').split(',').map(&:strip).compact_blank
151
+ end
152
+
130
153
  def cdn_cache_config
131
154
  CDN_CACHEABLE_ACTIONS[self.class.name.to_s]
132
155
  end
@@ -2,16 +2,16 @@ module Spree
2
2
  module Admin
3
3
  # Behind every admin clear-cache button (spree/admin/shared/_api_cache_button). Admin-only through
4
4
  # BaseController#authorize_admin, which checks :api_caches since there is no model class.
5
+ #
6
+ # Both actions take model and id, or no id to clear every record of model on one storefront:
7
+ # tenant_id for that tenant's API, none for the BookMe+ storefront.
5
8
  class ApiCachesController < Spree::Admin::BaseController
6
- # POST /admin/api_caches/clear?model=Spree::Product&id=1, or without id to clear every record of
7
- # model on one storefront: tenant_id for that tenant's API, none for the BookMe+ storefront.
9
+ before_action :load_tenant
10
+
8
11
  def clear
9
- result = if params[:id].present?
10
- SpreeCmCommissioner::ApiCaches::Invalidate.call(model: params[:model], id: params[:id])
11
- else
12
- tenant = SpreeCmCommissioner::Tenant.find(params[:tenant_id]) if params[:tenant_id].present?
13
- SpreeCmCommissioner::ApiCaches::InvalidateAll.call(model: params[:model], tenant: tenant)
14
- end
12
+ result = SpreeCmCommissioner::ApiCaches::RequestClear.call(
13
+ model: params[:model], id: params[:id], tenant: @tenant, user: try_spree_current_user
14
+ )
15
15
 
16
16
  if result.success?
17
17
  flash[:success] = Spree.t(:clear_cache_requested)
@@ -21,6 +21,21 @@ module Spree
21
21
 
22
22
  redirect_back fallback_location: admin_root_path
23
23
  end
24
+
25
+ # The dialog's contents, loaded into spree/admin/api_caches/_dialog's frame by the button.
26
+ def show
27
+ result = SpreeCmCommissioner::ApiCaches::FetchDetails.call(model: params[:model], id: params[:id], tenant: @tenant)
28
+ raise ActiveRecord::RecordNotFound if result.failure?
29
+
30
+ @details = result.value
31
+ render layout: false
32
+ end
33
+
34
+ private
35
+
36
+ def load_tenant
37
+ @tenant = SpreeCmCommissioner::Tenant.find(params[:tenant_id]) if params[:tenant_id].present?
38
+ end
24
39
  end
25
40
  end
26
41
  end
@@ -1,6 +1,8 @@
1
1
  module Spree
2
2
  module Admin
3
3
  class HomepageSectionRelatableController < Spree::Admin::ResourceController
4
+ skip_before_action :load_resource, only: :sort_by_start_date
5
+
4
6
  def model_class
5
7
  SpreeCmCommissioner::HomepageSectionRelatable
6
8
  end
@@ -27,6 +29,28 @@ module Spree
27
29
  def permitted_resource_params
28
30
  params.require(object_name).permit(:homepage_section_id, :relatable_id, :relatable_type, :available_on, :discontinue_on)
29
31
  end
32
+
33
+ # One-shot bulk reorder: ranks taxon relatables with an event `from_date` ascending, keeps every other
34
+ # relatable (dateless taxons, products) at the bottom in its current relative order. Drag-and-drop remains
35
+ # available afterward and simply overwrites these positions again, same as it would after a manual reorder.
36
+ def sort_by_start_date
37
+ homepage_section = SpreeCmCommissioner::HomepageSection.find(params[:homepage_section_id])
38
+ relatables = homepage_section.homepage_section_relatables.includes(:relatable).to_a
39
+
40
+ dated, undated = relatables.partition { |relatable| relatable.relatable.is_a?(Spree::Taxon) && relatable.relatable.from_date.present? }
41
+ dated.sort_by! { |relatable| relatable.relatable.from_date }
42
+ undated.sort_by!(&:position)
43
+
44
+ SpreeCmCommissioner::HomepageSectionRelatable.transaction do
45
+ (dated + undated).each.with_index(1) do |relatable, position|
46
+ relatable.update_columns(position: position, updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
47
+ end
48
+ end
49
+ homepage_section.update_columns(updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
50
+
51
+ redirect_to edit_admin_homepage_feed_homepage_section_url(homepage_section),
52
+ notice: Spree.t(:successfully_updated, resource: 'Homepage Section Relatable')
53
+ end
30
54
  end
31
55
  end
32
56
  end
@@ -20,7 +20,7 @@ module Spree
20
20
 
21
21
  if guest.save
22
22
  @invite_guest.update(claimed_status: :claimed) if @line_item.guests.count == @invite_guest.quantity
23
- send_guest_claimed_invitation_telegram_alert_to_vendor(guest) if guest.event.vendor.preferred_telegram_chat_id.present?
23
+ send_guest_claimed_invitation_telegram_alert_to_vendor(guest)
24
24
 
25
25
  render json: SpreeCmCommissioner::V2::Storefront::GuestSerializer.new(guest.reload).serializable_hash
26
26
  else
@@ -32,7 +32,7 @@ module Spree
32
32
 
33
33
  def send_guest_claimed_invitation_telegram_alert_to_vendor(guest)
34
34
  title = '📣 --- [NEW GUEST CLAIMED INVITATION] ---' # Style/StringLiterals
35
- chat_id = guest.event.vendor.preferred_telegram_chat_id
35
+ chat_id = guest.event.telegram_alert_chat_id || guest.event.vendor.preferred_telegram_chat_id
36
36
  return if chat_id.blank?
37
37
 
38
38
  factory = SpreeCmCommissioner::InviteGuestClaimedTelegramMessageFactory.new(
@@ -10,6 +10,14 @@ module Spree
10
10
  base.attr_reader :schema
11
11
  end
12
12
 
13
+ # override: Spree wraps this in a Rails.cache.fetch keyed on ids + max updated_at (1h TTL),
14
+ # which goes stale when only an association changes. See ContentCachable#served_to_cdn?.
15
+ def index
16
+ return render_serialized_payload { serialize_collection(paginated_collection) } if served_to_cdn?
17
+
18
+ super
19
+ end
20
+
13
21
  def app_version
14
22
  request.headers['X-Cm-App-Version']
15
23
  end
@@ -32,7 +32,7 @@ module Spree
32
32
 
33
33
  def send_guest_claimed_invitation_telegram_alert_to_vendor(guest)
34
34
  title = '📣 --- [NEW GUEST CLAIMED INVITATION] ---' # Style/StringLiterals
35
- chat_id = guest.event&.vendor&.preferred_telegram_chat_id
35
+ chat_id = guest.event&.telegram_alert_chat_id || guest.event&.vendor&.preferred_telegram_chat_id
36
36
  return if chat_id.blank?
37
37
 
38
38
  factory = SpreeCmCommissioner::InviteGuestClaimedTelegramMessageFactory.new(
@@ -97,7 +97,7 @@ module Spree
97
97
  def finalize_guest_claim(guest)
98
98
  if guest.save
99
99
  @invite_guest.update(claimed_status: :claimed) if @line_item.guests.count == @invite_guest.quantity
100
- send_guest_claimed_invitation_telegram_alert_to_vendor(guest) if guest.event&.vendor&.preferred_telegram_chat_id.present?
100
+ send_guest_claimed_invitation_telegram_alert_to_vendor(guest)
101
101
 
102
102
  order = @invite_guest.order
103
103
  render_serialized_payload { serialize_resource(order) }
@@ -6,25 +6,30 @@ module Spree
6
6
  CACHE_EXPIRES_IN = 5.minutes
7
7
 
8
8
  def index
9
- render_serialized_payload do
10
- Rails.cache.fetch(collection_cache_key, collection_cache_opts) do
11
- trips = SpreeCmCommissioner::TripQuery.new(
12
- origin_id: params[:origin_id],
13
- destination_id: params[:destination_id],
14
- date: params[:date],
15
- route_type: params[:route_type],
16
- vendor_id: params[:vendor_id],
17
- number_of_guests: params[:number_of_guests],
18
- options: params
19
- ).call
9
+ # CloudFront already caches this action; see ContentCachable#served_to_cdn?.
10
+ return render_serialized_payload { search_and_serialize } if served_to_cdn?
20
11
 
21
- serialize_collection(trips)
22
- end
12
+ render_serialized_payload do
13
+ Rails.cache.fetch(collection_cache_key, collection_cache_opts) { search_and_serialize }
23
14
  end
24
15
  end
25
16
 
26
17
  private
27
18
 
19
+ def search_and_serialize
20
+ trips = SpreeCmCommissioner::TripQuery.new(
21
+ origin_id: params[:origin_id],
22
+ destination_id: params[:destination_id],
23
+ date: params[:date],
24
+ route_type: params[:route_type],
25
+ vendor_id: params[:vendor_id],
26
+ number_of_guests: params[:number_of_guests],
27
+ options: params
28
+ ).call
29
+
30
+ serialize_collection(trips)
31
+ end
32
+
28
33
  # override
29
34
  def collection_cache_key
30
35
  cache_key_parts = [
@@ -3,6 +3,8 @@ module Spree
3
3
  module V2
4
4
  module Storefront
5
5
  class UserContactsController < Spree::Api::V2::ResourceController
6
+ before_action :require_spree_current_user
7
+
6
8
  def update
7
9
  options = user_with_pin_code_params
8
10
 
@@ -6,25 +6,30 @@ module Spree
6
6
  CACHE_EXPIRES_IN = 5.minutes
7
7
 
8
8
  def index
9
- render_serialized_payload do
10
- Rails.cache.fetch(collection_cache_key, collection_cache_opts) do
11
- trips = SpreeCmCommissioner::TripQuery.new(
12
- origin_id: params[:origin_id],
13
- destination_id: params[:destination_id],
14
- date: params[:date],
15
- route_type: params[:route_type],
16
- number_of_guests: params[:number_of_guests],
17
- tenant_id: @tenant.id,
18
- options: params
19
- ).call
9
+ # CloudFront already caches this action; see ContentCachable#served_to_cdn?.
10
+ return render_serialized_payload { search_and_serialize } if served_to_cdn?
20
11
 
21
- serialize_collection(trips)
22
- end
12
+ render_serialized_payload do
13
+ Rails.cache.fetch(collection_cache_key, collection_cache_opts) { search_and_serialize }
23
14
  end
24
15
  end
25
16
 
26
17
  private
27
18
 
19
+ def search_and_serialize
20
+ trips = SpreeCmCommissioner::TripQuery.new(
21
+ origin_id: params[:origin_id],
22
+ destination_id: params[:destination_id],
23
+ date: params[:date],
24
+ route_type: params[:route_type],
25
+ number_of_guests: params[:number_of_guests],
26
+ tenant_id: @tenant.id,
27
+ options: params
28
+ ).call
29
+
30
+ serialize_collection(trips)
31
+ end
32
+
28
33
  # override
29
34
  def collection_cache_key
30
35
  cache_key_parts = [
@@ -26,15 +26,19 @@ module SpreeCmCommissioner
26
26
  class OrderTelegramMessageFactory < TelegramMessageFactory # rubocop:disable Metrics/ClassLength
27
27
  attr_reader :order, :vendor, :show_details_link
28
28
 
29
- def initialize(title:, order:, subtitle: nil, show_details_link: nil, vendor: nil)
29
+ # `line_items` narrows the message to an explicit subset and wins over `vendor`: an event with its own
30
+ # group takes its items out of the vendor's message, so neither message is simply "all vendor items".
31
+ def initialize(title:, order:, subtitle: nil, show_details_link: nil, vendor: nil, line_items: nil) # rubocop:disable Metrics/ParameterLists
30
32
  @order = order
31
33
  @vendor = vendor
34
+ @line_items = line_items
32
35
  @show_details_link = show_details_link || false
33
36
 
34
37
  super(title: title, subtitle: subtitle)
35
38
  end
36
39
 
37
40
  def selected_line_items
41
+ return @line_items if @line_items
38
42
  return order.line_items.for_vendor(vendor) if vendor.present?
39
43
 
40
44
  order.line_items
@@ -43,8 +43,36 @@ module SpreeCmCommissioner
43
43
  calculate_bitwise_value(params, SpreeCmCommissioner::HomepageSectionBitwise::BIT_MINI_APP)
44
44
  end
45
45
 
46
+ # Only event-kind taxons carry `from_date`/`to_date` (see TaxonDecorator); plain category taxons and
47
+ # products return nil here so the caller can fall back to a placeholder.
48
+ def homepage_section_relatable_event_dates(section_relatable)
49
+ taxon = section_relatable.relatable
50
+ return unless taxon.is_a?(Spree::Taxon) && taxon.from_date.present?
51
+
52
+ range = taxon.to_date.present? ? "#{pretty_date(taxon.from_date)} – #{pretty_date(taxon.to_date)}" : pretty_date(taxon.from_date)
53
+ safe_join([content_tag(:div, range), content_tag(:small, event_date_status_label(taxon), class: 'text-muted')])
54
+ end
55
+
46
56
  private
47
57
 
58
+ # Date-only (no time-of-day/timezone), unlike `pretty_time` — a list column scanned for date ranges
59
+ # doesn't need per-row clock precision, and the full timestamp roughly doubled this column's width.
60
+ def pretty_date(time)
61
+ time.to_date.strftime('%d %B %Y')
62
+ end
63
+
64
+ def event_date_status_label(taxon)
65
+ now = Time.current
66
+
67
+ if taxon.from_date > now
68
+ "Starts in #{distance_of_time_in_words(now, taxon.from_date)}"
69
+ elsif taxon.to_date.present? && taxon.to_date > now
70
+ "Ongoing · ends in #{distance_of_time_in_words(now, taxon.to_date)}"
71
+ else
72
+ "Ended #{distance_of_time_in_words(now, taxon.to_date || taxon.from_date)} ago"
73
+ end
74
+ end
75
+
48
76
  # Sums the checked boxes into one integer. Keyed by name, not position, so a checkbox the
49
77
  # form omits cannot shift the rest onto the wrong bits. Returns 0, never nil: `value & bit`
50
78
  # raises on NULL.
@@ -18,8 +18,7 @@ module SpreeCmCommissioner
18
18
  context.user = Spree.user_class.find_user_by_login(login, tenant_id)
19
19
  # get social user
20
20
  elsif id_token.present?
21
- checker_context = SpreeCmCommissioner::UserIdTokenChecker.call(id_token: id_token)
22
- context.user = checker_context.user
21
+ context.user = id_token_user
23
22
  end
24
23
 
25
24
  if context.user.present?
@@ -28,5 +27,27 @@ module SpreeCmCommissioner
28
27
  context.fail!(message: 'User is not valid')
29
28
  end
30
29
  end
30
+
31
+ def id_token_user
32
+ if telegram_id_token?
33
+ result = SpreeCmCommissioner::Telegram::CheckIdToken.call(
34
+ id_token: id_token,
35
+ client_id: SpreeCmCommissioner::Telegram::VerifyOauthIdToken.client_id_for(tenant_id),
36
+ tenant_id: tenant_id
37
+ )
38
+ result.value[:user] if result.success?
39
+ else
40
+ SpreeCmCommissioner::UserIdTokenChecker.call(id_token: id_token).user
41
+ end
42
+ end
43
+
44
+ # The app sends Telegram OIDC tokens here as `id_token` too. The unverified `iss` only picks the
45
+ # checker; CheckIdToken still verifies the signature, issuer, audience and expiry before trusting it.
46
+ def telegram_id_token?
47
+ payload = JWT.decode(id_token, nil, false).first
48
+ payload.is_a?(Hash) && payload['iss'] == SpreeCmCommissioner::Telegram::VerifyOauthIdToken::ISSUER
49
+ rescue JWT::DecodeError
50
+ false
51
+ end
31
52
  end
32
53
  end
@@ -84,9 +84,13 @@ module SpreeCmCommissioner
84
84
 
85
85
  return nil if claim.nil?
86
86
 
87
- provider_name = claim['firebase']['sign_in_provider']
87
+ # A non-Firebase token (e.g. a Telegram OIDC id_token, or a crafted non-object payload)
88
+ # has no `firebase` section.
89
+ provider_name = claim.dig('firebase', 'sign_in_provider') if claim.is_a?(Hash)
90
+ context.fail!(message: I18n.t('firebase_id_token.failure')) if provider_name.blank?
91
+
88
92
  user_id = claim['user_id']
89
- sub = claim['firebase']['identities'][provider_name].first
93
+ sub = claim.dig('firebase', 'identities', provider_name)&.first
90
94
  email = claim['email'] || get_user_email(user_id)
91
95
  name = claim['name']
92
96
 
@@ -1,6 +1,9 @@
1
1
  module SpreeCmCommissioner
2
2
  class TelegramChatsAutoFinder < BaseInteractor
3
- delegate :contact, :pin_code, to: :context
3
+ # Pass `event:` to connect that event's alert group instead of the vendor's (contact is then
4
+ # the event id). Event mode writes only the event's metadata: no vendor preference and no
5
+ # vendor-owned TelegramChat row.
6
+ delegate :contact, :pin_code, :event, to: :context
4
7
 
5
8
  def call
6
9
  context.chats = []
@@ -59,6 +62,8 @@ module SpreeCmCommissioner
59
62
  pin_code_record = find_pin_code_record
60
63
  return unless pin_code_record
61
64
 
65
+ return connect_event(message, chat_id, pin_code_record) if event
66
+
62
67
  vendor = find_vendor
63
68
  return unless vendor
64
69
 
@@ -73,7 +78,8 @@ module SpreeCmCommissioner
73
78
  end
74
79
 
75
80
  def find_pin_code_record
76
- pin_code_record = SpreeCmCommissioner::PinCodeTelegram.find_by(code: pin_code, contact: contact)
81
+ pin_code_class = event ? SpreeCmCommissioner::PinCodeEventTelegram : SpreeCmCommissioner::PinCodeTelegram
82
+ pin_code_record = pin_code_class.find_by(code: pin_code, contact: contact)
77
83
  unless pin_code_record
78
84
  context.fail!(message: "Pin code #{pin_code} not found for contact #{contact}")
79
85
  return nil
@@ -114,6 +120,24 @@ module SpreeCmCommissioner
114
120
  context.verified_chat_ids << chat.chat_id
115
121
  end
116
122
 
123
+ def connect_event(message, chat_id, pin_code_record)
124
+ title = message.dig('chat', 'title') || 'Unnamed Chat'
125
+
126
+ unless event.update(telegram_alert_chat_id: chat_id.to_s, telegram_alert_chat_name: title,
127
+ telegram_alert_chat_type: message.dig('chat', 'type')
128
+ )
129
+ context.fail!(message: "Failed to connect event: #{event.errors.full_messages.join(', ')}")
130
+ return
131
+ end
132
+
133
+ context.chats << { id: chat_id, title: title, type: message.dig('chat', 'type') }
134
+ expire_pin_code(pin_code_record)
135
+ send_message(chat_id, "✅ PIN code verified successfully! This chat is now connected to <b>#{CGI.escapeHTML(event.name.to_s)}</b>.\n\n" \
136
+ 'Completed orders for this event will be sent to this chat.'
137
+ )
138
+ context.verified_chat_ids << chat_id
139
+ end
140
+
117
141
  def update_vendor_preferences(vendor, chat)
118
142
  vendor.update(
119
143
  preferred_telegram_chat_id: chat.chat_id,
@@ -11,7 +11,7 @@ module SpreeCmCommissioner
11
11
  updates['result'].filter_map do |update|
12
12
  chat = update.dig('my_chat_member', 'chat')
13
13
 
14
- if chat.present? && chat['title'] == telegram_chat_name.strip && chat['type'] == telegram_chat_type
14
+ if chat.present? && chat['title'] == telegram_chat_name.to_s.strip && chat['type'] == telegram_chat_type
15
15
  return {
16
16
  id: chat['id'],
17
17
  title: chat['title'],
@@ -22,22 +22,13 @@ module SpreeCmCommissioner
22
22
  private
23
23
 
24
24
  def verified_provider
25
- result = SpreeCmCommissioner::Telegram::VerifyOauthIdToken.call(id_token: id_token, client_id: telegram_client_id)
25
+ client_id = SpreeCmCommissioner::Telegram::VerifyOauthIdToken.client_id_for(tenant_id)
26
+ result = SpreeCmCommissioner::Telegram::VerifyOauthIdToken.call(id_token: id_token, client_id: client_id)
26
27
  context.fail!(message: result.error.to_s) unless result.success?
27
28
 
28
29
  result.value[:provider]
29
30
  end
30
31
 
31
- # Each tenant runs its own Telegram bot, so the audience an id_token must
32
- # carry is looked up on the tenant directly - no default-store or ENV
33
- # fallback, so a misconfigured tenant fails loudly instead of silently
34
- # authenticating against someone else's bot.
35
- def telegram_client_id
36
- return Spree::Store.default.preferred_telegram_oauth_client_id if tenant_id.blank?
37
-
38
- SpreeCmCommissioner::Tenant.find_by(id: tenant_id)&.preferred_telegram_oauth_client_id
39
- end
40
-
41
32
  def find_or_register_user(provider)
42
33
  identity_checker = SpreeCmCommissioner::UserIdentityChecker.call(
43
34
  identity_type: provider[:identity_type],
@@ -345,19 +345,7 @@ module SpreeCmCommissioner
345
345
  end
346
346
 
347
347
  def send_order_complete_telegram_alert_to_vendors
348
- return unless telegram_alert_enabled?
349
-
350
- vendor_list.each do |vendor|
351
- # `next`, not `return`: the mask is per-vendor, so one opted-out vendor on a
352
- # multi-vendor order must not suppress the others.
353
- next unless vendor.alerts_telegram_for_channel?(channel_key)
354
-
355
- chat_id = vendor.preferred_telegram_chat_id
356
- next if chat_id.blank?
357
-
358
- factory = OrderTelegramMessageFactory.new(title: vendor_alert_title(vendor), order: self, vendor: vendor)
359
- TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
360
- end
348
+ SpreeCmCommissioner::TelegramAlerts::SendTelegramOrderAlerts.call(order: self)
361
349
  end
362
350
 
363
351
  # White-label vendors announce their own brand; platform vendors stay BookMe+.