spree_cm_commissioner 2.8.3.pre.pre11 → 2.8.3.pre.pre13

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/Gemfile.lock +1 -1
  3. data/app/controllers/spree/admin/system/waiting_room_controller.rb +15 -0
  4. data/app/controllers/spree/api/v2/storefront/transit/draft_orders_controller.rb +3 -1
  5. data/app/controllers/spree/api/v2/tenant/transit/draft_orders_controller.rb +3 -1
  6. data/app/interactors/spree_cm_commissioner/pin_code_sender.rb +62 -5
  7. data/app/interactors/spree_cm_commissioner/waiting_guests_caller.rb +49 -7
  8. data/app/jobs/spree_cm_commissioner/telegram_gateway/pin_code_sender_job.rb +18 -0
  9. data/app/jobs/spree_cm_commissioner/waiting_room/publish_lobby_path_job.rb +17 -0
  10. data/app/models/concerns/spree_cm_commissioner/line_item_open_dated_trippable.rb +50 -7
  11. data/app/models/concerns/spree_cm_commissioner/line_item_transitable.rb +0 -1
  12. data/app/models/spree_cm_commissioner/pin_code.rb +62 -9
  13. data/app/models/spree_cm_commissioner/product_decorator.rb +6 -0
  14. data/app/models/spree_cm_commissioner/show.rb +0 -4
  15. data/app/models/spree_cm_commissioner/trip.rb +2 -12
  16. data/app/models/spree_cm_commissioner/voting_contestant.rb +0 -1
  17. data/app/queries/spree_cm_commissioner/single_leg_trips_query.rb +24 -3
  18. data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +3 -1
  19. data/app/serializers/spree/v2/tenant/show_episode_serializer.rb +1 -1
  20. data/app/serializers/spree/v2/tenant/show_serializer.rb +1 -2
  21. data/app/serializers/spree_cm_commissioner/v2/storefront/pin_code_serializer.rb +1 -1
  22. data/app/serializers/spree_cm_commissioner/v2/storefront/ticket_transfer_minimal_serializer.rb +1 -1
  23. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_query_result_serializer.rb +3 -1
  24. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_result_serializer.rb +2 -1
  25. data/app/services/spree_cm_commissioner/open_dated_trips/redeem.rb +80 -36
  26. data/app/services/spree_cm_commissioner/telegram_gateway/pin_code_sender.rb +99 -0
  27. data/app/services/spree_cm_commissioner/transit_order/create.rb +48 -14
  28. data/app/services/spree_cm_commissioner/trips/add_ons/create.rb +124 -0
  29. data/app/services/spree_cm_commissioner/trips/add_ons/update_price.rb +36 -0
  30. data/app/services/spree_cm_commissioner/trips/create_multi_leg.rb +1 -2
  31. data/app/services/spree_cm_commissioner/trips/variants/create.rb +1 -2
  32. data/app/services/spree_cm_commissioner/voting_sessions/finalize.rb +28 -29
  33. data/app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb +48 -0
  34. data/app/services/spree_cm_commissioner/waiting_room_lobby_metadata_fetcher.rb +47 -0
  35. data/app/services/telegram_gateway_adapter/client.rb +124 -0
  36. data/app/views/spree/admin/system/waiting_room/show.html.erb +53 -0
  37. data/config/locales/en.yml +5 -6
  38. data/config/locales/km.yml +15 -48
  39. data/config/routes.rb +1 -0
  40. data/db/migrate/20260610000001_add_delivery_channel_to_cm_pin_codes.rb +6 -0
  41. data/db/migrate/20260610000001_drop_is_open_dated_from_cm_trips.rb +6 -0
  42. data/db/migrate/20260614000001_change_code_limit_in_cm_pin_codes.rb +5 -0
  43. data/db/migrate/20260615000002_add_index_to_code_on_cm_pin_codes.rb +5 -0
  44. data/lib/spree_cm_commissioner/transit/trip_form.rb +0 -13
  45. data/lib/spree_cm_commissioner/trip_query_result.rb +37 -6
  46. data/lib/spree_cm_commissioner/trip_result.rb +6 -7
  47. data/lib/spree_cm_commissioner/version.rb +1 -1
  48. metadata +14 -7
  49. data/app/serializers/spree/v2/tenant/campaign_serializer.rb +0 -13
  50. data/app/services/spree_cm_commissioner/advertisements/sorted_advertisements.rb +0 -61
  51. data/app/services/spree_cm_commissioner/advertisements/update_campaign_sorted_ads_ids.rb +0 -33
  52. data/app/services/spree_cm_commissioner/trips/create_open_dated_trip.rb +0 -98
  53. data/app/services/spree_cm_commissioner/trips/update_open_dated_trip.rb +0 -67
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc670dfc1c70b1d0307e09941e9e1bbcbcc8b8ebebcd3ff883b81350e42ead04
4
- data.tar.gz: 8c1fc869352e101588a38ff8ce00726a50788e7ea9bd936e53d74cf01246ef00
3
+ metadata.gz: eedbddb863eb9f58dad73a83892a225e971a89736c37af047df757d76bcfc058
4
+ data.tar.gz: 59eaad5012be611640f1c27f6566bfb91cd7b5cea176d465be69c4230c8b9241
5
5
  SHA512:
6
- metadata.gz: 696ae98c855011b50c62a2cb60e6f31564d3841531361785595b2334adb340e6f3a189bc73bb3b636801f66664d1bf8037f95bc7ea17895769be09825546e10c
7
- data.tar.gz: 641c6beaccb2eb7cc69977d204f583af78d0f49ecd642da05569579a57e96c6e882be53a163ce20cb5d8f0550970bc7998f07b173b6070d20580e9e6ac87ae37
6
+ metadata.gz: aa9db90a1c39aaa74f66135c5034cb8762e09123edaad154fb5fe6e013609b5344d23910bd8449380030c5b3e391b3a3792c92b569f5fbe2271d6e78420e55e6
7
+ data.tar.gz: 80c51833f840ba8b4db0611309153c7a7723fd986f065113a06063ac7d5aee78cce2af29dd2eb591b64a90c57a75f1f4716b9d7daf2664903ff34ad031f663e4
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.8.3.pre.pre11)
37
+ spree_cm_commissioner (2.8.3.pre.pre13)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -4,7 +4,10 @@ module Spree
4
4
  class WaitingRoomController < Spree::Admin::BaseController
5
5
  def show
6
6
  @fetcher = SpreeCmCommissioner::WaitingRoomSystemMetadataFetcher.new
7
+ @lobby_fetcher = SpreeCmCommissioner::WaitingRoomLobbyMetadataFetcher.new
8
+
7
9
  @fetcher.load_document_data
10
+ @lobby_fetcher.load_document_data
8
11
 
9
12
  @active_sesions_count = SpreeCmCommissioner::WaitingRoomSession.active.count
10
13
  end
@@ -29,6 +32,18 @@ module Spree
29
32
 
30
33
  redirect_back fallback_location: admin_system_waiting_room_path
31
34
  end
35
+
36
+ def publish_lobby_path
37
+ result = SpreeCmCommissioner::WaitingRoom::PublishLobbyPath.call
38
+
39
+ if result.success?
40
+ flash[:success] = "Published waiting guests records path: #{result.value[:records_path]}"
41
+ else
42
+ flash[:error] = result.error.to_s
43
+ end
44
+
45
+ redirect_back fallback_location: admin_system_waiting_room_path
46
+ end
32
47
  end
33
48
  end
34
49
  end
@@ -11,6 +11,7 @@ module Spree
11
11
  # - inbound_date: Date of inbound transit (optional)
12
12
  # - outbound_legs: Array of outbound leg details (required)
13
13
  # - inbound_legs: Array of inbound leg details (optional)
14
+ # - include_return: Whether to add the trip's "return included" add-on as a line item (optional)
14
15
  def create
15
16
  @outbound_legs = params[:outbound_legs].is_a?(Array) && params[:outbound_legs].any? ? build_legs(:outbound, params[:outbound_legs]) : []
16
17
  @inbound_legs = params[:inbound_legs].is_a?(Array) && params[:inbound_legs].any? ? build_legs(:inbound, params[:inbound_legs]) : []
@@ -20,7 +21,8 @@ module Spree
20
21
  inbound_date: params[:inbound_date]&.to_date,
21
22
  outbound_legs: @outbound_legs,
22
23
  inbound_legs: @inbound_legs,
23
- user: spree_current_user
24
+ user: spree_current_user,
25
+ include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return])
24
26
  )
25
27
 
26
28
  if result.success?
@@ -11,6 +11,7 @@ module Spree
11
11
  # - inbound_date: Date of inbound transit (optional)
12
12
  # - outbound_legs: Array of outbound leg details (required)
13
13
  # - inbound_legs: Array of inbound leg details (optional)
14
+ # - include_return: Whether to add the trip's "return included" add-on as a line item (optional)
14
15
  def create
15
16
  @outbound_legs = params[:outbound_legs].is_a?(Array) && params[:outbound_legs].any? ? build_legs(:outbound, params[:outbound_legs]) : []
16
17
  @inbound_legs = params[:inbound_legs].is_a?(Array) && params[:inbound_legs].any? ? build_legs(:inbound, params[:inbound_legs]) : []
@@ -20,7 +21,8 @@ module Spree
20
21
  inbound_date: params[:inbound_date]&.to_date,
21
22
  outbound_legs: @outbound_legs,
22
23
  inbound_legs: @inbound_legs,
23
- user: spree_current_user
24
+ user: spree_current_user,
25
+ include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return])
24
26
  )
25
27
 
26
28
  if result.success?
@@ -6,7 +6,7 @@ module SpreeCmCommissioner
6
6
  context.fail!(message: I18n.t('pincode_sender.pincode.blank')) if context.pin_code.nil?
7
7
 
8
8
  if context.pin_code.phone_number?
9
- send_sms
9
+ send_phone_otp
10
10
  else
11
11
  send_email
12
12
  end
@@ -16,18 +16,73 @@ module SpreeCmCommissioner
16
16
 
17
17
  private
18
18
 
19
- def send_sms
19
+ # Channel selection for phone-number OTPs lives here so the routing is
20
+ # easy to find and reason about. Telegram Gateway is primary; SMS is the fallback.
21
+ def send_phone_otp
22
+ if telegram_gateway_available?
23
+ send_via_telegram_gateway
24
+ else
25
+ send_via_sms
26
+ end
27
+ end
28
+
29
+ def telegram_gateway_available?
30
+ return false unless telegram_gateway_enabled?
31
+ return false if telegram_gateway_api_key.blank?
32
+
33
+ ability = telegram_gateway_adapter.check_send_ability(context.pin_code.contact)
34
+ context.telegram_gateway_request_id = ability.request_id if ability.ok?
35
+ ability.ok?
36
+ rescue TelegramGatewayAdapter::Error, Faraday::Error
37
+ false
38
+ end
39
+
40
+ def send_via_telegram_gateway
20
41
  from_number = sms_from_number
21
42
  return if from_number.blank?
22
43
 
23
- options = {
44
+ # Save the chosen channel + the Telegram request_id synchronously so
45
+ # (a) the API response already reflects the channel for the mobile UI,
46
+ # (b) verification can reach Telegram even if the user submits the OTP
47
+ # before the async send job has run. For Telegram-delivered codes
48
+ # the `code` column stores Telegram's request_id (Telegram generates
49
+ # and holds the actual OTP).
50
+ context.pin_code.update!(
51
+ delivery_channel: :telegram_gateway,
52
+ code: context.telegram_gateway_request_id
53
+ )
54
+
55
+ SpreeCmCommissioner::TelegramGateway::PinCodeSenderJob.perform_later(
56
+ pin_code_id: context.pin_code.id,
57
+ from: from_number,
58
+ request_id: context.telegram_gateway_request_id,
59
+ tenant_id: context.pin_code&.application&.tenant_id
60
+ )
61
+ end
62
+
63
+ def send_via_sms
64
+ from_number = sms_from_number
65
+ return if from_number.blank?
66
+
67
+ SpreeCmCommissioner::SmsPinCodeJob.perform_later(
68
+ pin_code_id: context.pin_code.id,
24
69
  from: from_number,
25
70
  to: context.pin_code.contact,
26
71
  body: I18n.t('pincode_sender.sms.body', code: context.pin_code.code, readable_type: context.pin_code.readable_type),
27
72
  tenant_id: context.pin_code&.application&.tenant_id
28
- }
73
+ )
74
+ end
75
+
76
+ def telegram_gateway_enabled?
77
+ ENV['TELEGRAM_GATEWAY_ENABLED'] == 'yes'
78
+ end
29
79
 
30
- SpreeCmCommissioner::SmsPinCodeJob.perform_later(options)
80
+ def telegram_gateway_api_key
81
+ @telegram_gateway_api_key ||= ENV.fetch('TELEGRAM_GATEWAY_API_KEY', nil)
82
+ end
83
+
84
+ def telegram_gateway_adapter
85
+ @telegram_gateway_adapter ||= TelegramGatewayAdapter::Client.new
31
86
  end
32
87
 
33
88
  def sms_from_number
@@ -38,6 +93,8 @@ module SpreeCmCommissioner
38
93
  end
39
94
 
40
95
  def send_email
96
+ context.pin_code.update!(delivery_channel: :email)
97
+
41
98
  SpreeCmCommissioner::PinCodeMailer.send_pin_code(
42
99
  context.pin_code.id,
43
100
  context.pin_code.readable_type,
@@ -19,18 +19,47 @@ module SpreeCmCommissioner
19
19
  max_sessions - active_sessions
20
20
  end
21
21
 
22
- # This query required index. create them in Firebase beforehand.
23
- # Client side must create waiting_guests document with :queued_at & :allow_to_enter_room_at to null to allow fillter & order.
22
+ # This query requires an index; create it in Firebase beforehand.
23
+ # Client must create waiting_guests documents with :queued_at and :allow_to_enter_room_at set to nil to allow filter + order queries.
24
+ #
25
+ # Yesterday's guests are always older than today's, so fill from yesterday first, then use any
26
+ # leftover slots for today. This way no one queued before the midnight rollover gets skipped.
27
+ # e.g. 5 slots, 2 waiting in yesterday -> take both, then take 3 from today.
24
28
  def fetch_long_waiting_guests(available_slots)
25
- firestore.col('waiting_guests')
26
- .doc(current_date)
27
- .col('records')
29
+ previous_guests = eligible_guests_in(previous_records_path, available_slots)
30
+
31
+ # Pre-flip window: the lobby pointer still points at yesterday, so both paths resolve to the
32
+ # same partition — return now to avoid querying (and double-counting) it twice.
33
+ return previous_guests if records_path == previous_records_path
34
+
35
+ remaining_slots = available_slots - previous_guests.size
36
+ return previous_guests if remaining_slots <= 0
37
+
38
+ previous_guests + eligible_guests_in(records_path, remaining_slots)
39
+ end
40
+
41
+ def eligible_guests_in(records_path, limit)
42
+ firestore.col(records_path)
28
43
  .where('allow_to_enter_room_at', '==', nil)
29
44
  .order('queued_at')
30
- .limit(available_slots)
45
+ .limit(limit)
31
46
  .get.to_a
32
47
  end
33
48
 
49
+ # Published path is authoritative; fall back to the server's own date if not yet published.
50
+ def records_path
51
+ lobby_data&.dig(:waiting_guests_records_path).presence || default_records_path(current_date)
52
+ end
53
+
54
+ # Drain target is derived from the server date, never the (possibly stale) lobby pointer.
55
+ def previous_records_path
56
+ default_records_path(previous_date)
57
+ end
58
+
59
+ def default_records_path(date)
60
+ "waiting_guests/#{date}/records"
61
+ end
62
+
34
63
  # For alert waiting guests to enter room, we just update :allow_to_enter_room_at.
35
64
  # App will listen to firebase & start refresh session token to enter room.
36
65
  def calling_all(waiting_guests)
@@ -45,9 +74,22 @@ module SpreeCmCommissioner
45
74
  Time.zone.now.strftime('%Y-%m-%d')
46
75
  end
47
76
 
77
+ def previous_date
78
+ 1.day.ago.strftime('%Y-%m-%d')
79
+ end
80
+
48
81
  # When open app, app request to check whether room is full or not via Firebase instead of server to minimize server requests.
82
+ # merge: true so we preserve the published `waiting_guests_records_path` on the lobby doc.
49
83
  def mark_as(full:, available_slots:)
50
- firestore.col('waiting_rooms').doc('lobby').set({ full: full, available_slots: available_slots })
84
+ lobby_document.set({ full: full, available_slots: available_slots }, merge: true)
85
+ end
86
+
87
+ def lobby_data
88
+ @lobby_data ||= lobby_document.get.data
89
+ end
90
+
91
+ def lobby_document
92
+ @lobby_document ||= firestore.col('waiting_rooms').doc('lobby')
51
93
  end
52
94
 
53
95
  def fetch_max_sessions
@@ -0,0 +1,18 @@
1
+ module SpreeCmCommissioner
2
+ module TelegramGateway
3
+ # options = { pin_code_id:, from:, request_id:, tenant_id: }
4
+ class PinCodeSenderJob < SpreeCmCommissioner::SmsJob
5
+ def perform(options = {})
6
+ pin_code = SpreeCmCommissioner::PinCode.find_by(id: options[:pin_code_id])
7
+ return if pin_code.nil?
8
+
9
+ SpreeCmCommissioner::TelegramGateway::PinCodeSender.call(
10
+ pin_code: pin_code,
11
+ from: options[:from],
12
+ request_id: options[:request_id],
13
+ tenant_id: options[:tenant_id]
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ # waiting_room_lobby_path_publisher:
2
+ # cron: "0 0 * * * Asia/Phnom_Penh" # Once per day at local midnight (date rollover)
3
+ # class: "SpreeCmCommissioner::WaitingRoom::PublishLobbyPathJob"
4
+ module SpreeCmCommissioner
5
+ module WaitingRoom
6
+ class PublishLobbyPathJob < ApplicationJob
7
+ queue_as :waiting_room
8
+
9
+ def perform
10
+ return if ENV['WAITING_ROOM_DISABLED'] == 'yes'
11
+
12
+ # call! so a publish failure raises (Sidekiq retries/alerts) instead of being silently swallowed.
13
+ SpreeCmCommissioner::WaitingRoom::PublishLobbyPath.call!
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,13 +1,28 @@
1
1
  module SpreeCmCommissioner
2
+ # Makes an open-return entitlement line item redeemable.
3
+ #
4
+ # New design: the open return is no longer a fake open-dated *trip* line item. It is a plain
5
+ # ecommerce add-on (a Spree::Product linked to the outbound trip's product via an
6
+ # `open_dated_pair` ProductRelation) sold as a regular ecommerce line item at checkout.
7
+ #
8
+ # Redemption swaps that ecommerce line item in place into a concrete transit ticket on a chosen
9
+ # return trip (see SpreeCmCommissioner::OpenDatedTrips::Redeem). Once swapped, the line item is a
10
+ # `transit` item.
2
11
  module LineItemOpenDatedTrippable
3
12
  extend ActiveSupport::Concern
4
13
 
5
14
  included do
6
15
  include SpreeCmCommissioner::StoreMetadata
7
16
 
8
- # Open dated trip metadata
9
- store_public_metadata :is_open_dated, :boolean, default: false
10
- store_public_metadata :original_trip_id, :integer # Original template trip ID BEFORE redemption
17
+ store_private_metadata :open_dated_product_id, :integer
18
+
19
+ # The endpoints (Place ids) the *return* trip must run between — already reversed from the
20
+ # outbound journey at purchase time (return origin = outbound destination, and vice versa).
21
+ # Storing the return direction means every consumer reads it directly: redemption is a plain
22
+ # id match and the search needs no flip. Endpoints rather than a trip keep this
23
+ # leg-count-independent: a multi-leg outbound still has exactly one origin and one destination.
24
+ store_private_metadata :open_dated_return_origin_place_id, :integer
25
+ store_private_metadata :open_dated_return_destination_place_id, :integer
11
26
 
12
27
  # Alias existing columns for better semantic meaning
13
28
  alias_attribute :valid_until, :to_date # Expiration date
@@ -19,19 +34,47 @@ module SpreeCmCommissioner
19
34
  accepter
20
35
  end
21
36
 
22
- # Check if ticket has been redeemed
37
+ def open_dated?
38
+ open_dated_return_origin_place_id.present? && open_dated_return_destination_place_id.present?
39
+ end
40
+
41
+ def open_dated_product
42
+ return nil if open_dated_product_id.blank?
43
+
44
+ @open_dated_product ||= Spree::Product.find_by(id: open_dated_product_id)
45
+ end
46
+
47
+ # Origin place the return trip must depart from (= outbound destination).
48
+ def open_dated_return_origin_place
49
+ return nil if open_dated_return_origin_place_id.blank?
50
+
51
+ @open_dated_return_origin_place ||= SpreeCmCommissioner::Place.find_by(id: open_dated_return_origin_place_id)
52
+ end
53
+
54
+ # Destination place the return trip must arrive at (= outbound origin).
55
+ def open_dated_return_destination_place
56
+ return nil if open_dated_return_destination_place_id.blank?
57
+
58
+ @open_dated_return_destination_place ||= SpreeCmCommissioner::Place.find_by(id: open_dated_return_destination_place_id)
59
+ end
60
+
61
+ # Human-readable return journey, e.g. "Siem Reap → Phnom Penh". Nil when endpoints are missing.
62
+ def open_dated_route_label
63
+ return nil if open_dated_return_origin_place.blank? || open_dated_return_destination_place.blank?
64
+
65
+ "#{open_dated_return_origin_place.full_path_name} → #{open_dated_return_destination_place.full_path_name}"
66
+ end
67
+
23
68
  def redeemed?
24
69
  redeemed_at.present?
25
70
  end
26
71
 
27
- # Check if ticket is expired
28
72
  def expired?
29
73
  valid_until.present? && valid_until.to_date < Date.current
30
74
  end
31
75
 
32
- # Check if ticket can be redeemed
33
76
  def can_redeem?
34
- is_open_dated? && !redeemed? && !expired?
77
+ open_dated? && !redeemed? && !expired?
35
78
  end
36
79
  end
37
80
  end
@@ -138,7 +138,6 @@ module SpreeCmCommissioner
138
138
 
139
139
  trip = SpreeCmCommissioner::Trip.find_by(id: trip_id)
140
140
  return unless trip # Skip if trip doesn't exist (will be caught by other validations)
141
- return if trip.open_dated? # Skip validation for open-dated trips
142
141
 
143
142
  # For scheduled trips, require valid trip stops
144
143
  errors.add(:boarding_trip_stop_id, 'must be greater than 0') if boarding_trip_stop_id.present? && boarding_trip_stop_id <= 0
@@ -3,8 +3,9 @@ module SpreeCmCommissioner
3
3
  has_secure_token
4
4
 
5
5
  enum contact_type: { 'phone_number' => 0, 'email' => 1, 'telegram' => 2 }
6
+ enum delivery_channel: { sms: 0, telegram_gateway: 1, email: 2 }, _prefix: :delivered_via
6
7
 
7
- validates :code, length: { maximum: 6 }
8
+ validates :code, length: { maximum: 32 }
8
9
  validates :contact, presence: true
9
10
  validates :contact, email: true, if: :email?
10
11
  validates :type, presence: true
@@ -18,6 +19,15 @@ module SpreeCmCommissioner
18
19
  PIN_CODE_MISMATCHED = 'not_match'.freeze
19
20
  PIN_CODE_OK = 'ok'.freeze
20
21
 
22
+ # Maps Telegram Gateway verification_status values to our PIN_CODE_* results.
23
+ # Anything not in this map → soft fallback to local comparison.
24
+ TELEGRAM_VERIFICATION_RESULTS = {
25
+ 'code_valid' => PIN_CODE_OK,
26
+ 'code_invalid' => PIN_CODE_MISMATCHED,
27
+ 'code_max_attempts_exceeded' => PIN_CODE_ATTEMPT_REACHED,
28
+ 'expired' => PIN_CODE_EXPIRED
29
+ }.freeze
30
+
21
31
  belongs_to :application, class_name: 'Spree::OauthApplication', optional: true
22
32
 
23
33
  def check?(code)
@@ -26,14 +36,10 @@ module SpreeCmCommissioner
26
36
 
27
37
  increment_attempt
28
38
 
29
- if self.code == code
30
- set_expire
31
- save
32
- PIN_CODE_OK
33
- else
34
- save
35
- PIN_CODE_MISMATCHED
36
- end
39
+ result = verify_code(code)
40
+ set_expire if result == PIN_CODE_OK
41
+ save
42
+ result
37
43
  end
38
44
 
39
45
  def set_expire
@@ -143,5 +149,52 @@ module SpreeCmCommissioner
143
149
  self.code = Random.new.bytes(8).bytes.join[0, 6]
144
150
  self.expires_in = expires_in_seconds
145
151
  end
152
+
153
+ # SMS codes verify internally (Plasgate has no verify API).
154
+ # Telegram-delivered codes verify with Telegram — no internal fallback,
155
+ # because `code` holds Telegram's request_id, not a comparable OTP.
156
+ # If Telegram is unreachable, we return PIN_CODE_MISMATCHED; the user
157
+ # retries from the app.
158
+ def verify_code(submitted_code)
159
+ return verify_internally(submitted_code) unless delivered_via_telegram_gateway?
160
+
161
+ verify_with_telegram(submitted_code) || PIN_CODE_MISMATCHED
162
+ end
163
+
164
+ def verify_internally(submitted_code)
165
+ code == submitted_code ? PIN_CODE_OK : PIN_CODE_MISMATCHED
166
+ end
167
+
168
+ # For Telegram-delivered PinCodes, `code` column stores Telegram's
169
+ # request_id (not the OTP itself — Telegram generated and delivered the OTP).
170
+ # Returns a PIN_CODE_* result, or nil if Telegram couldn't help us decide
171
+ # (network error, unmapped status).
172
+ def verify_with_telegram(submitted_code)
173
+ result = TelegramGatewayAdapter::Client.new.check_verification_status(code, submitted_code)
174
+ TELEGRAM_VERIFICATION_RESULTS[result.verification_status]
175
+ rescue TelegramGatewayAdapter::Error, Faraday::Error => e
176
+ log_verification_error(e)
177
+ nil
178
+ end
179
+
180
+ def log_verification_error(error)
181
+ CmAppLogger.error(
182
+ label: 'PinCode#verify_with_telegram failed',
183
+ data: {
184
+ pin_code_id: id,
185
+ request_id: code,
186
+ error_class: error.class.name,
187
+ error_message: error.message
188
+ }
189
+ )
190
+
191
+ return unless ENV['PIN_CODE_DEBUG_NOTIFIY_TELEGRAM_ENABLE'] == 'yes'
192
+
193
+ SpreeCmCommissioner::TelegramDebugPinCodeSenderJob.perform_later(
194
+ pin_code_id: id,
195
+ tenant_id: application&.tenant_id,
196
+ error_message: "Verification failed: #{error.class.name} — #{error.message}"
197
+ )
198
+ end
146
199
  end
147
200
  end
@@ -55,6 +55,12 @@ module SpreeCmCommissioner
55
55
  base.has_many :product_relations, class_name: 'SpreeCmCommissioner::ProductRelation', dependent: :destroy
56
56
  base.has_many :related_products, through: :product_relations
57
57
 
58
+ # Inverse side of product_relations: relations where this product is the related_product
59
+ # (e.g. trip add-ons point here).
60
+ base.has_many :inverse_product_relations, class_name: 'SpreeCmCommissioner::ProductRelation',
61
+ foreign_key: :related_product_id,
62
+ dependent: :destroy
63
+
58
64
  base.belongs_to :event, class_name: 'Spree::Taxon', optional: true
59
65
 
60
66
  base.has_many :preview_roles, class_name: 'SpreeCmCommissioner::PreviewRole', as: :previewable
@@ -81,10 +81,6 @@ module SpreeCmCommissioner
81
81
  :block_vpn,
82
82
  :max_votes_per_contestant_per_user
83
83
 
84
- def parent_slug
85
- parent&.slug
86
- end
87
-
88
84
  def show?
89
85
  depth == 1
90
86
  end
@@ -65,9 +65,8 @@ module SpreeCmCommissioner
65
65
  has_many :inventory_items, through: :variants
66
66
  has_many :blocks, through: :variants
67
67
 
68
- validates :departure_time, presence: true, unless: :open_dated?
69
- validates :vehicle_type, presence: true, unless: :open_dated?
70
- validates :duration, numericality: { greater_than: 0 }, unless: :open_dated?
68
+ validates :departure_time, presence: true
69
+ validates :duration, numericality: { greater_than: 0 }
71
70
 
72
71
  validate :direct_trips_cannot_have_board_to_trips, if: -> { direct? }
73
72
  validate :multi_leg_trip_stops_must_have_board_to_trip, if: -> { multi_leg? }
@@ -120,15 +119,6 @@ module SpreeCmCommissioner
120
119
  errors.add(:board_to_trips, 'direct trips cannot have board_to_trips')
121
120
  end
122
121
 
123
- def open_dated?
124
- is_open_dated == true
125
- end
126
-
127
- # Find the open dated version of this trip's product
128
- def open_dated_pair
129
- open_dated_product
130
- end
131
-
132
122
  def display_name
133
123
  product&.name
134
124
  end
@@ -8,7 +8,6 @@ module SpreeCmCommissioner
8
8
  belongs_to :advanced_to, polymorphic: true, optional: true
9
9
  belongs_to :advanced_from, polymorphic: true, optional: true
10
10
  has_many :votes, class_name: 'SpreeCmCommissioner::Vote', foreign_key: :contestant_id, dependent: :restrict_with_error
11
- has_one :leading_stat, class_name: 'SpreeCmCommissioner::VotingSessionStat', foreign_key: :leading_contestant_id, dependent: :nullify
12
11
  has_many :show_contestant_images, through: :show_contestant
13
12
  has_many :show_contestant_videos, through: :show_contestant
14
13
 
@@ -14,7 +14,7 @@
14
14
  # results = query.call # => [TripQueryResult, ...]
15
15
  #
16
16
  module SpreeCmCommissioner
17
- class SingleLegTripsQuery
17
+ class SingleLegTripsQuery # rubocop:disable Metrics/ClassLength
18
18
  attr_reader :origin_id, :destination_id, :date,
19
19
  :vendor_id, :tenant_id,
20
20
  :number_of_guests, :route_type, :options
@@ -61,8 +61,7 @@ module SpreeCmCommissioner
61
61
  .eager_load(
62
62
  vendor: :logo,
63
63
  vehicle_type: :option_values,
64
- route: {},
65
- open_dated_product: %i[trip variants]
64
+ route: {}
66
65
  )
67
66
  .select(selected_columns_sql)
68
67
  .joins(
@@ -79,6 +78,7 @@ module SpreeCmCommissioner
79
78
  LEFT JOIN spree_prices AS date_prices ON date_prices.inventory_item_id = date_iv.id AND date_prices.deleted_at IS NULL
80
79
  LEFT JOIN (#{stops_subquery_sql}) all_stops ON all_stops.trip_id = cm_trips.id
81
80
  #{return_metric_joins_sql}
81
+ #{open_dated_product_joins_sql}
82
82
  SQL
83
83
  )
84
84
  .where(<<~SQL.squish, origin: origin_id, destination: destination_id)
@@ -111,6 +111,9 @@ module SpreeCmCommissioner
111
111
  COALESCE(date_prices.compare_at_amount, prices.compare_at_amount) AS compare_at_price,
112
112
  COALESCE(date_prices.currency, prices.currency) AS currency,
113
113
  all_stops.stops AS stops,
114
+ open_dated_rel.product_id AS open_dated_product_id,
115
+ open_dated_prices.amount AS open_dated_price,
116
+ open_dated_prices.compare_at_amount AS open_dated_compare_at_price,
114
117
  #{return_columns_sql}
115
118
  SQL
116
119
  end
@@ -142,6 +145,24 @@ module SpreeCmCommissioner
142
145
  SQL
143
146
  end
144
147
 
148
+ # Joins the "open dated" pair product (if any) for the trip's product, and its master price,
149
+ # so we can expose the return add-on's price/compare_at_price without loading the full
150
+ # Spree::Product/Variant/Price records.
151
+ def open_dated_product_joins_sql
152
+ <<~SQL.squish
153
+ LEFT JOIN cm_product_relations AS open_dated_rel
154
+ ON open_dated_rel.related_product_id = cm_trips.product_id
155
+ AND open_dated_rel.relation_type = #{SpreeCmCommissioner::ProductRelation.relation_types[:open_dated_pair]}
156
+ LEFT JOIN spree_variants AS open_dated_master
157
+ ON open_dated_master.product_id = open_dated_rel.product_id
158
+ AND open_dated_master.is_master = true
159
+ LEFT JOIN spree_prices AS open_dated_prices
160
+ ON open_dated_prices.variant_id = open_dated_master.id
161
+ AND open_dated_prices.inventory_item_id IS NULL
162
+ AND open_dated_prices.deleted_at IS NULL
163
+ SQL
164
+ end
165
+
145
166
  # Subquery to aggregate all stops for each trip into a JSON array, ordered by sequence.
146
167
  # Each stop includes place coordinates (lat/lon) joined from cm_places.
147
168
  # Returns one row per trip_id with a `stops` JSON column consumed by selected_columns_sql.
@@ -14,7 +14,9 @@ module Spree
14
14
 
15
15
  base.attributes :need_confirmation, :product_type, :kyc, :kyc_fields, :allowed_upload_later, :allow_anonymous_booking,
16
16
  :use_video_as_default, :allow_transfer, :allow_gift_transfer
17
- base.attributes :reveal_description, :discontinue_on, :public_metadata, :purchasable_on, :preview
17
+
18
+ base.attributes :reveal_description, :discontinue_on, :public_metadata, :purchasable_on, :preview,
19
+ :enable_inventory_hold
18
20
 
19
21
  # Expose only the `event_id` here instead of the full event object.
20
22
  # This lets the client fetch event details separately (usually already cached),
@@ -2,7 +2,7 @@ module Spree
2
2
  module V2
3
3
  module Tenant
4
4
  class ShowEpisodeSerializer < BaseSerializer
5
- attributes :episode_number, :name, :description, :scheduled_at, :ends_at
5
+ attributes :episode_number, :name, :scheduled_at, :ends_at
6
6
 
7
7
  attribute :status do |episode|
8
8
  if episode.current?
@@ -5,11 +5,10 @@ module Spree
5
5
  set_type :show
6
6
 
7
7
  attributes :slug, :description, :permalink, :from_date, :to_date, :preview, :created_at, :updated_at,
8
- :free_vote_limit, :free_vote_limit_type, :show_type, :parent_slug
8
+ :free_vote_limit, :free_vote_limit_type, :show_type
9
9
 
10
10
  attribute :name, &:display_name
11
11
 
12
- has_many :seasons, serializer: Spree::V2::Tenant::ShowSerializer
13
12
  has_many :show_people, serializer: Spree::V2::Tenant::ShowPersonSerializer
14
13
  has_many :episodes, serializer: Spree::V2::Tenant::ShowEpisodeSerializer
15
14
  has_many :voting_sessions, serializer: Spree::V2::Tenant::VotingSessionSerializer
@@ -4,7 +4,7 @@ module SpreeCmCommissioner
4
4
  class PinCodeSerializer < BaseSerializer
5
5
  set_type :pin_code
6
6
 
7
- attribute :token, :type, :contact_type, :contact
7
+ attribute :token, :type, :contact_type, :contact, :delivery_channel
8
8
  end
9
9
  end
10
10
  end