spree_cm_commissioner 2.14.4 → 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +7 -1
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +59 -3
  5. data/app/controllers/concerns/spree_cm_commissioner/waiting_room_authorization.rb +41 -8
  6. data/app/controllers/spree/admin/api_caches_controller.rb +23 -8
  7. data/app/controllers/spree/admin/homepage_section_controller.rb +63 -5
  8. data/app/controllers/spree/admin/homepage_section_relatable_controller.rb +24 -0
  9. data/app/controllers/spree/api/v2/organizer/invite_guests_controller.rb +2 -2
  10. data/app/controllers/spree/api/v2/resource_controller_decorator.rb +8 -0
  11. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +6 -2
  12. data/app/controllers/spree/api/v2/storefront/homepage_sections_controller.rb +1 -0
  13. data/app/controllers/spree/api/v2/storefront/invite_guests_controller.rb +2 -2
  14. data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +1 -0
  15. data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +6 -2
  16. data/app/controllers/spree/api/v2/storefront/trip_search_controller.rb +18 -13
  17. data/app/controllers/spree/api/v2/storefront/user_contacts_controller.rb +2 -0
  18. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +6 -2
  19. data/app/controllers/spree/api/v2/tenant/homepage_sections_controller.rb +1 -0
  20. data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +1 -0
  21. data/app/controllers/spree/api/v2/tenant/trip_search_controller.rb +18 -13
  22. data/app/factory/spree_cm_commissioner/order_telegram_message_factory.rb +5 -1
  23. data/app/helpers/spree_cm_commissioner/admin/homepage_segment_helper.rb +28 -0
  24. data/app/interactors/spree_cm_commissioner/account_recover.rb +23 -2
  25. data/app/interactors/spree_cm_commissioner/firebase_id_token_provider.rb +6 -2
  26. data/app/interactors/spree_cm_commissioner/telegram_chats_auto_finder.rb +26 -2
  27. data/app/interactors/spree_cm_commissioner/telegram_chats_finder.rb +1 -1
  28. data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +2 -11
  29. data/app/models/active_storage/variation_decorator.rb +18 -0
  30. data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +4 -0
  31. data/app/models/concerns/spree_cm_commissioner/order_holdable.rb +3 -2
  32. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +18 -13
  33. data/app/models/spree_cm_commissioner/homepage_section.rb +33 -0
  34. data/app/models/spree_cm_commissioner/homepage_section_app_background.rb +13 -0
  35. data/app/models/spree_cm_commissioner/homepage_section_web_background.rb +13 -0
  36. data/app/models/spree_cm_commissioner/maintenance_tasks/cache_invalidation.rb +95 -11
  37. data/app/models/spree_cm_commissioner/pin_code.rb +2 -1
  38. data/app/models/spree_cm_commissioner/pin_code_event_telegram.rb +31 -0
  39. data/app/models/spree_cm_commissioner/product_decorator.rb +7 -0
  40. data/app/models/spree_cm_commissioner/service_calendar.rb +2 -2
  41. data/app/models/spree_cm_commissioner/taxon_decorator.rb +7 -0
  42. data/app/models/spree_cm_commissioner/vendor_decorator.rb +6 -0
  43. data/app/overrides/spree/admin/taxons/_form/enforce_waiting_room_session.html.erb.deface +10 -0
  44. data/app/queries/spree_cm_commissioner/trip_query.rb +20 -2
  45. data/app/serializers/spree/v2/storefront/homepage_section_serializer.rb +4 -1
  46. data/app/serializers/spree/v2/tenant/homepage_section_serializer.rb +3 -1
  47. data/app/services/spree_cm_commissioner/api_caches/fetch_details.rb +128 -0
  48. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +31 -14
  49. data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +16 -19
  50. data/app/services/spree_cm_commissioner/api_caches/request_clear.rb +71 -0
  51. data/app/services/spree_cm_commissioner/guests/claim_invite_guest_service.rb +5 -3
  52. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_service_calendars.rb +3 -0
  53. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +21 -4
  54. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +30 -4
  55. data/app/services/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items.rb +15 -4
  56. data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +56 -5
  57. data/app/services/spree_cm_commissioner/telegram/verify_oauth_id_token.rb +10 -0
  58. data/app/services/spree_cm_commissioner/telegram_alerts/send_telegram_order_alerts.rb +49 -0
  59. data/app/views/spree/admin/api_caches/_dialog.html.erb +47 -0
  60. data/app/views/spree/admin/api_caches/show.html.erb +88 -0
  61. data/app/views/spree/admin/homepage_section/_form.html.erb +44 -0
  62. data/app/views/spree/admin/homepage_section/edit.html.erb +16 -1
  63. data/app/views/spree/admin/shared/_api_cache_button.html.erb +5 -2
  64. data/config/initializers/spree_permitted_attributes.rb +1 -0
  65. data/config/locales/dry_validations.en.yml +4 -0
  66. data/config/locales/en.yml +50 -0
  67. data/config/locales/km.yml +50 -0
  68. data/config/routes.rb +8 -4
  69. data/db/migrate/20261021000001_add_section_type_to_cm_homepage_sections.rb +6 -0
  70. data/lib/spree_cm_commissioner/version.rb +1 -1
  71. metadata +13 -3
  72. data/app/jobs/spree_cm_commissioner/invalidate_cache_request_job.rb +0 -10
@@ -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],
@@ -0,0 +1,18 @@
1
+ module ActiveStorage
2
+ module VariationDecorator
3
+ # ActiveStorage reuses a JPEG upload's own extension as the variant format, but libvips
4
+ # picks its encoder from the output extension and rejects aliases like `.jfif`
5
+ # ("is not a known file format"). Mapping here rather than renaming blobs also fixes
6
+ # variant URLs already handed out, since their signed key embeds `format: "jfif"`.
7
+ JPEG_FORMAT_ALIASES = %w[jfif jpe].freeze
8
+
9
+ def format
10
+ format = super
11
+ JPEG_FORMAT_ALIASES.include?(format.to_s.downcase) ? 'jpg' : format
12
+ end
13
+ end
14
+ end
15
+
16
+ unless ActiveStorage::Variation.ancestors.include?(ActiveStorage::VariationDecorator)
17
+ ActiveStorage::Variation.prepend(ActiveStorage::VariationDecorator)
18
+ end
@@ -18,6 +18,10 @@ module SpreeCmCommissioner
18
18
  # For certain large events, admin can disable this manually.
19
19
  store_public_metadata :allow_manual_search_for_operator, :boolean, default: true
20
20
 
21
+ # Requires an active waiting-room session to create a draft order for this event's products
22
+ # (Product#enforce_waiting_room_session?). Server-only switch, so private.
23
+ store_private_metadata :enforce_waiting_room_session, :boolean, default: false
24
+
21
25
  # ⚠️ CRITICAL: Cached seat layout IDs kept in sync by SeatLayout callback.
22
26
  # If callback is removed/modified without updating this, availability checks will fail.
23
27
  #
@@ -4,8 +4,9 @@ module SpreeCmCommissioner
4
4
 
5
5
  included do
6
6
  # Store the earliest expiration time across all holds (seats + inventory)
7
- # in `public_metadata[:hold_expires_at]`. This is used only for UI display
8
- # (e.g., a countdown timer) and does not control the release logic.
7
+ # in `public_metadata[:hold_expires_at]`. This is used for UI display
8
+ # (e.g., a countdown timer) and by LineItems::ValidatePurchaseLimits to count an
9
+ # unpaid cart against the buyer's caps; it does not control the release logic.
9
10
  # Each hold is released independently when its own expiration time is reached.
10
11
  store_public_metadata :hold_expires_at, :datetime
11
12
 
@@ -7,6 +7,7 @@ module SpreeCmCommissioner
7
7
  included do # rubocop:disable Metrics/BlockLength
8
8
  after_commit :clear_has_incomplete_guest_info_cache, on: %i[create update]
9
9
 
10
+ state_machine.before_transition to: :address, do: :ensure_within_purchase_limits
10
11
  state_machine.before_transition to: :address, do: :hold_order_holds
11
12
 
12
13
  # Same transitions core calls create_tax_charge! from (gems/core/app/models/spree/order/checkout.rb,
@@ -107,6 +108,22 @@ module SpreeCmCommissioner
107
108
  end
108
109
  end
109
110
 
111
+ # Line items are only validated when they change, so a cart sent back to `cart` (hold
112
+ # released) and advanced again would skip the purchase-limit check and could be held next to
113
+ # another cart the buyer opened meanwhile. Runs before hold_order_holds so a rejection never
114
+ # leaves a hold behind.
115
+ def ensure_within_purchase_limits
116
+ line_items.each do |line_item|
117
+ result = SpreeCmCommissioner::LineItems::ValidatePurchaseLimits.call(line_item: line_item)
118
+ next if result.success?
119
+
120
+ errors.add(:base, result.error.to_s)
121
+ return false
122
+ end
123
+
124
+ true
125
+ end
126
+
110
127
  # Best-effort, like apply_prepaid_payment!'s other callers: a user with no (or no
111
128
  # currency-matching) store credit just doesn't get a payment here, and the app can still
112
129
  # remove/reapply it afterwards through the existing checkout store-credit endpoints.
@@ -328,19 +345,7 @@ module SpreeCmCommissioner
328
345
  end
329
346
 
330
347
  def send_order_complete_telegram_alert_to_vendors
331
- return unless telegram_alert_enabled?
332
-
333
- vendor_list.each do |vendor|
334
- # `next`, not `return`: the mask is per-vendor, so one opted-out vendor on a
335
- # multi-vendor order must not suppress the others.
336
- next unless vendor.alerts_telegram_for_channel?(channel_key)
337
-
338
- chat_id = vendor.preferred_telegram_chat_id
339
- next if chat_id.blank?
340
-
341
- factory = OrderTelegramMessageFactory.new(title: vendor_alert_title(vendor), order: self, vendor: vendor)
342
- TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
343
- end
348
+ SpreeCmCommissioner::TelegramAlerts::SendTelegramOrderAlerts.call(order: self)
344
349
  end
345
350
 
346
351
  # White-label vendors announce their own brand; platform vendors stay BookMe+.
@@ -10,10 +10,26 @@ module SpreeCmCommissioner
10
10
  attribute :segment, :integer, default: 0
11
11
  attribute :mini_app, :integer, default: 0
12
12
 
13
+ enum section_type: { default: 0, trending: 1, big_banner: 2 }
14
+
13
15
  self.whitelisted_ransackable_attributes = %w[title active]
14
16
 
15
17
  has_many :homepage_section_relatables, -> { active }, inverse_of: :homepage_section, dependent: :destroy
16
18
 
19
+ has_one :app_background, as: :viewable, dependent: :destroy, class_name: 'SpreeCmCommissioner::HomepageSectionAppBackground'
20
+ has_one :web_background, as: :viewable, dependent: :destroy, class_name: 'SpreeCmCommissioner::HomepageSectionWebBackground'
21
+
22
+ # `font`/backgrounds only apply to `trending` sections. The admin form only hides those
23
+ # fields via JS when another type is picked — it doesn't clear them — and a direct API call
24
+ # could set them on any type, so normalize here rather than relying on the form. A hard
25
+ # validation would instead reject the common "switch an existing trending section back to
26
+ # default" edit, since the hidden `font` field still submits its stale value.
27
+ #
28
+ # Guarded beyond `unless: :trending?` so a routine save of an already-default/big_banner
29
+ # section (the steady-state case) skips the `app_background`/`web_background` association
30
+ # loads entirely instead of querying them on every save forever.
31
+ before_save :clear_non_trending_fields, if: :clear_non_trending_fields?
32
+
17
33
  scope :active, lambda {
18
34
  where(active: true)
19
35
  .joins(:homepage_section_relatables)
@@ -47,5 +63,22 @@ module SpreeCmCommissioner
47
63
 
48
64
  # Create maintaining task to purge homepage section related caches
49
65
  after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
66
+
67
+ private
68
+
69
+ # `.target` (not `.loaded?`) so this only fires for a background actually built/loaded in
70
+ # memory on *this* save. `.loaded?` stays true forever once an object has been saved once
71
+ # with the association touched, even after the target is nil'd out by a prior destroy —
72
+ # which made every later save of that same in-memory record re-run the clear unnecessarily.
73
+ def clear_non_trending_fields?
74
+ !trending? && (font.present? || section_type_changed? ||
75
+ association(:app_background).target.present? || association(:web_background).target.present?)
76
+ end
77
+
78
+ def clear_non_trending_fields
79
+ self.font = nil
80
+ app_background&.destroy
81
+ web_background&.destroy
82
+ end
50
83
  end
51
84
  end
@@ -0,0 +1,13 @@
1
+ module SpreeCmCommissioner
2
+ class HomepageSectionAppBackground < Asset
3
+ # 3:2 — taller than 16:9 so the image fills a `trending` card's full height instead of
4
+ # letterboxing. `medium` (960x640) is the upload target; `mini`/`small` are downscaled copies.
5
+ def asset_styles
6
+ {
7
+ mini: '160x107>',
8
+ small: '480x320>',
9
+ medium: '960x640>'
10
+ }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module SpreeCmCommissioner
2
+ class HomepageSectionWebBackground < Asset
3
+ # 5:1 — wide and short so the image spans a `trending` banner's full width without pushing
4
+ # page content down. `medium` (1440x288) is the upload target; `mini`/`small` are downscaled copies.
5
+ def asset_styles
6
+ {
7
+ mini: '160x32>',
8
+ small: '720x144>',
9
+ medium: '1440x288>'
10
+ }
11
+ end
12
+ end
13
+ end
@@ -1,30 +1,114 @@
1
- # Class for invalidating cache across all models.
1
+ # One CloudFront invalidation, queued automatically on save (debounced by OrchestrateJob) or by a
2
+ # clear-cache click (ApiCaches::RequestClear), and kept as the record's clear-cache history.
3
+ #
4
+ # A clear-all (every record of a model on one storefront, ApiCaches::InvalidateAll) is a task too,
5
+ # so it is queued, deduplicated and shown in the clear-cache dialog's history like any other clear,
6
+ # and counts towards its cooldown. It has no record of its own, so its maintainable is the
7
+ # storefront it clears: the SpreeCmCommissioner::Tenant, or the default Spree::Store for BookMe+'s.
8
+ # Clear-alls of every model share that maintainable, so the model is stored (invalidation_model).
2
9
  module SpreeCmCommissioner
3
10
  module MaintenanceTasks
4
11
  class CacheInvalidation < MaintenanceTask
12
+ OUTCOMES = %w[sent skipped failed].freeze
13
+ STOREFRONT_TYPES = %w[SpreeCmCommissioner::Tenant Spree::Store].freeze
14
+
15
+ store_private_metadata :triggered_by_id, :integer
16
+ store_private_metadata :outcome, :string
17
+ store_private_metadata :invalidation_model, :string
18
+
19
+ # For finding the invalidation in the AWS console.
20
+ store_private_metadata :cloudfront_invalidation_id, :string
21
+
5
22
  def maintain
6
- if ENV['CACHE_AUTO_INVALIDATION_DISABLED'] == 'yes'
7
- log_invalidation('skipped: CACHE_AUTO_INVALIDATION_DISABLED')
23
+ # The switch is for automatic invalidations only; a click is someone asking for one now.
24
+ if ENV['CACHE_AUTO_INVALIDATION_DISABLED'] == 'yes' && !manually_triggered?
25
+ record_outcome('skipped', message: 'CACHE_AUTO_INVALIDATION_DISABLED')
8
26
  return
9
27
  end
10
28
 
11
- result = SpreeCmCommissioner::ApiCaches::Invalidate.call(model: maintainable.class.name, id: maintainable.id)
29
+ result = refresh_records
30
+ return record_failure(result.error.to_s) if result.failure?
12
31
 
13
- if result.success?
14
- log_invalidation('sent', patterns: result.value[:api_patterns])
15
- else
16
- log_invalidation("failed: #{result.error}")
32
+ patterns = result.value[:api_patterns]
33
+
34
+ # CloudFront rejects an empty batch, e.g. homepage backgrounds on a tenant's API.
35
+ return record_outcome('skipped', message: 'no API serves it on this storefront') if patterns.empty?
36
+
37
+ send_invalidation(patterns)
38
+ end
39
+
40
+ # The task's maintainable for a clear of model: the record with id, or for a clear-all (no id)
41
+ # the storefront, see above. nil for anything that can't be cleared.
42
+ def self.maintainable_for(model:, id: nil, tenant: nil)
43
+ if id.present?
44
+ SpreeCmCommissioner::ApiCaches::Invalidate::SUPPORTED_MODELS[model]&.find_by(id: id)
45
+ elsif SpreeCmCommissioner::ApiCaches::InvalidateAll::API_PATTERNS.key?(model)
46
+ tenant || Spree::Store.default
47
+ end
48
+ end
49
+
50
+ # Only a clear-all hangs off a storefront rather than the record it clears.
51
+ def invalidate_all?
52
+ STOREFRONT_TYPES.include?(maintainable_type)
53
+ end
54
+
55
+ def target_model
56
+ invalidation_model.presence || maintainable.class.name
57
+ end
58
+
59
+ # :queued until the job runs, and :failed once it has used up its retries.
60
+ def clear_status
61
+ return attempt_count >= MAX_ATTEMPTS ? :failed : :queued if completed_at.nil?
62
+
63
+ case outcome.presence || legacy_outcome
64
+ when 'sent' then :sent
65
+ when 'skipped' then :skipped
66
+ else :failed
17
67
  end
18
68
  end
19
69
 
20
70
  private
21
71
 
72
+ def refresh_records
73
+ if invalidate_all?
74
+ tenant = maintainable if maintainable.is_a?(SpreeCmCommissioner::Tenant)
75
+ SpreeCmCommissioner::ApiCaches::InvalidateAll.call(model: target_model, tenant: tenant)
76
+ else
77
+ SpreeCmCommissioner::ApiCaches::Invalidate.call(model: target_model, id: maintainable_id)
78
+ end
79
+ end
80
+
81
+ # Synchronous, since this already runs in ProcessJob, so the task only says sent once CloudFront
82
+ # has accepted it. Errors other than CloudFront's own refusal raise, so the task is retried.
83
+ def send_invalidation(patterns)
84
+ request = SpreeCmCommissioner::InvalidateCacheRequest.call(patterns: patterns)
85
+ return record_failure(request.message) if request.failure?
86
+
87
+ self.cloudfront_invalidation_id = request.response.invalidation.id
88
+ record_outcome('sent', message: patterns.join(', '))
89
+ end
90
+
91
+ # Tasks completed before `outcome` was stored only have it in their log line.
92
+ def legacy_outcome
93
+ track_messages.reverse_each do |message|
94
+ match = message.match(/\AInvalidation (\w+)/)
95
+ return match[1] if match && OUTCOMES.include?(match[1])
96
+ end
97
+ nil
98
+ end
99
+
100
+ def record_failure(error)
101
+ self.last_error = error
102
+ record_outcome('failed', message: error)
103
+ end
104
+
22
105
  # Each path costs money on CloudFront, so every outcome is logged and kept on the task for troubleshooting.
23
- def log_invalidation(outcome, patterns: [])
24
- log_run("Invalidation #{outcome} #{patterns.join(', ')}".strip)
106
+ def record_outcome(outcome, message:)
107
+ self.outcome = outcome
108
+ log_run("Invalidation #{outcome}: #{message}")
25
109
  CmAppLogger.log(
26
110
  label: "#{self.class.name} #{outcome}",
27
- data: { maintenance_task_id: id, maintainable_type: maintainable_type, maintainable_id: maintainable_id, patterns: patterns }
111
+ data: { maintenance_task_id: id, maintainable_type: maintainable_type, maintainable_id: maintainable_id, message: message }
28
112
  )
29
113
  end
30
114
  end
@@ -139,7 +139,8 @@ module SpreeCmCommissioner
139
139
  'SpreeCmCommissioner::PinCodeUpdateUserLogin' => I18n.t('pincode.readable_type.update_user_login'),
140
140
  'SpreeCmCommissioner::PinCodeEmailConfirm' => I18n.t('pincode.readable_type.confirmation_code'),
141
141
  'SpreeCmCommissioner::PinCodeOtp' => I18n.t('pincode.readable_type.otp_code'),
142
- 'SpreeCmCommissioner::PinCodeTelegram' => I18n.t('pincode.readable_type.telegram_verification')
142
+ 'SpreeCmCommissioner::PinCodeTelegram' => I18n.t('pincode.readable_type.telegram_verification'),
143
+ 'SpreeCmCommissioner::PinCodeEventTelegram' => I18n.t('pincode.readable_type.telegram_verification')
143
144
  }
144
145
  end
145
146
 
@@ -0,0 +1,31 @@
1
+ module SpreeCmCommissioner
2
+ # PIN for connecting an event's order-alert Telegram group; `contact` is the event (Spree::Taxon) id.
3
+ # Not a PinCodeTelegram subclass on purpose: STI queries on PinCodeTelegram would then also return
4
+ # these rows, and an event id equal to a vendor id would surface as that vendor's PIN.
5
+ class PinCodeEventTelegram < PinCode
6
+ before_validation :set_telegram_attrs, on: :create
7
+ validates :contact, presence: true, numericality: { only_integer: true }
8
+
9
+ belongs_to :event, class_name: 'Spree::Taxon', foreign_key: 'contact', primary_key: 'id'
10
+
11
+ def telegram?
12
+ true
13
+ end
14
+
15
+ def expires_in_seconds
16
+ 5 * 60
17
+ end
18
+
19
+ def expired?
20
+ expired_at < Time.zone.now
21
+ end
22
+
23
+ private
24
+
25
+ def set_telegram_attrs
26
+ self.contact_type = :telegram
27
+ self.type = 'SpreeCmCommissioner::PinCodeEventTelegram'
28
+ self.expired_at = Time.zone.now + expires_in_seconds unless expired_at
29
+ end
30
+ end
31
+ end
@@ -222,6 +222,13 @@ module SpreeCmCommissioner
222
222
  end
223
223
  end
224
224
 
225
+ # Configured once on the event rather than per ticket product. Other product types (trips,
226
+ # rooms, ...) that need it later resolve their own source here; the draft-order guard
227
+ # (WaitingRoomAuthorization#ensure_waiting_room_session!) only ever asks the product.
228
+ def enforce_waiting_room_session?
229
+ event&.enforce_waiting_room_session? || false
230
+ end
231
+
225
232
  # Not yet on sale. Kept separate from available? so a scheduled product's real stock still
226
233
  # shows via in_stock? — Stock::AvailabilityValidatorDecorator checks this to block add-to-cart.
227
234
  def scheduled?
@@ -33,13 +33,13 @@ module SpreeCmCommissioner
33
33
  end
34
34
  end
35
35
 
36
- private
37
-
38
36
  def set_dates
39
37
  self.start_date = Date.new(2000, 1, 1) if start_date.blank?
40
38
  self.end_date = Date.new(2100, 1, 1) if end_date.blank?
41
39
  end
42
40
 
41
+ private
42
+
43
43
  def in_weekly_service?(date)
44
44
  day_name = date.strftime('%A').downcase
45
45
  send(day_name.to_sym)
@@ -51,6 +51,13 @@ module SpreeCmCommissioner
51
51
 
52
52
  base.store_public_metadata :live_stock_path, :string
53
53
 
54
+ # The event's own order-alert Telegram group, connected from the organizer event page. When set,
55
+ # this event's line items alert here instead of the vendor's group. Private so the chat id never
56
+ # reaches storefront serializers.
57
+ base.store_private_metadata :telegram_alert_chat_id, :string
58
+ base.store_private_metadata :telegram_alert_chat_name, :string
59
+ base.store_private_metadata :telegram_alert_chat_type, :string
60
+
54
61
  base.before_save :set_kind
55
62
  base.before_save :set_slug
56
63
  base.before_save :normalize_custom_redirect_url
@@ -253,6 +253,12 @@ module SpreeCmCommissioner
253
253
  "#{Spree::Store.default.formatted_url}/organizers/#{slug}"
254
254
  end
255
255
 
256
+ def find_event_by_id(event_id: nil)
257
+ return nil if event_id.blank?
258
+
259
+ taxons.events.find_by(slug: "events-#{event_id}")
260
+ end
261
+
256
262
  # Deep links to the public hotel detail page with a default 1-room/2-adult, 2-night search
257
263
  # pre-filled — same query params the storefront's hotel page reads (see cm-market-client's
258
264
  # JsonLd::HotelRequest), so "View Profile" previews the real booking page instead of the
@@ -0,0 +1,10 @@
1
+ <!-- insert_before "erb[loud]:contains('field_container :hide_from_nav')" -->
2
+
3
+ <%= f.field_container :enforce_waiting_room_session, class: ['custom-control', 'custom-checkbox', 'my-4'] do %>
4
+ <%= f.check_box :enforce_waiting_room_session, class: 'custom-control-input' %>
5
+ <%= f.label :enforce_waiting_room_session, Spree.t(:enforce_waiting_room_session), class: 'custom-control-label' %>
6
+ <%= f.error_message_on :enforce_waiting_room_session %>
7
+ <small class="form-text text-muted">
8
+ <%= Spree.t(:enforce_waiting_room_session_hint) %>
9
+ </small>
10
+ <% end if @taxon.event? && @taxon.depth == 1 %> %>