spree_cm_commissioner 2.14.5 → 2.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) 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/integration.rb +1 -1
  22. data/app/models/spree_cm_commissioner/integration_mapping.rb +27 -0
  23. data/app/models/spree_cm_commissioner/integrations/book_me_bus_v1.rb +1 -0
  24. data/app/models/spree_cm_commissioner/maintenance_tasks/cache_invalidation.rb +95 -11
  25. data/app/models/spree_cm_commissioner/pin_code.rb +2 -1
  26. data/app/models/spree_cm_commissioner/pin_code_event_telegram.rb +31 -0
  27. data/app/models/spree_cm_commissioner/service_calendar.rb +2 -2
  28. data/app/models/spree_cm_commissioner/taxon_decorator.rb +7 -0
  29. data/app/models/spree_cm_commissioner/vendor_decorator.rb +6 -0
  30. data/app/queries/spree_cm_commissioner/trip_query.rb +20 -2
  31. data/app/services/spree_cm_commissioner/api_caches/fetch_details.rb +128 -0
  32. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +31 -14
  33. data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +16 -19
  34. data/app/services/spree_cm_commissioner/api_caches/request_clear.rb +71 -0
  35. data/app/services/spree_cm_commissioner/guests/claim_invite_guest_service.rb +5 -3
  36. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_inventory.rb +33 -11
  37. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_product.rb +3 -2
  38. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_service_calendars.rb +3 -0
  39. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +7 -3
  40. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +73 -36
  41. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_variant.rb +1 -1
  42. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_vehicle_types.rb +1 -1
  43. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +12 -1
  44. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +2 -2
  45. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/incremental_sync_strategy.rb +1 -1
  46. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_product.rb +2 -1
  47. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_product.rb +2 -1
  48. data/app/services/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items.rb +15 -4
  49. data/app/services/spree_cm_commissioner/telegram/verify_oauth_id_token.rb +10 -0
  50. data/app/services/spree_cm_commissioner/telegram_alerts/send_telegram_order_alerts.rb +49 -0
  51. data/app/views/spree/admin/api_caches/_dialog.html.erb +47 -0
  52. data/app/views/spree/admin/api_caches/show.html.erb +88 -0
  53. data/app/views/spree/admin/homepage_section/edit.html.erb +16 -1
  54. data/app/views/spree/admin/integrations/_book_me_bus_v1_fields.html.erb +10 -0
  55. data/app/views/spree/admin/integrations/_form.html.erb +1 -1
  56. data/app/views/spree/admin/shared/_api_cache_button.html.erb +5 -2
  57. data/config/locales/en.yml +39 -0
  58. data/config/locales/km.yml +39 -0
  59. data/config/routes.rb +3 -4
  60. data/lib/spree_cm_commissioner/version.rb +1 -1
  61. metadata +8 -3
  62. data/app/jobs/spree_cm_commissioner/invalidate_cache_request_job.rb +0 -10
@@ -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
@@ -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
@@ -13,16 +13,34 @@ module SpreeCmCommissioner
13
13
  @options = options
14
14
  end
15
15
 
16
+ # Every matching trip, unless the client asks for pages with `per_page`: the apps sort and filter the full
17
+ # list locally, so a default page size would silently drop trips (Kaminari.paginate_array cuts to 25).
16
18
  def call
17
- return Kaminari.paginate_array([]) if date.to_date < Date.current
19
+ return [] if date.to_date < Date.current
18
20
 
19
21
  result = direct_trips
20
22
  result += multi_leg_trips if result.empty? || result.size < 3 || ActiveModel::Type::Boolean.new.cast(@options[:include_multi_leg])
21
- Kaminari.paginate_array(result)
23
+ preload_vehicle_type_assets(result)
24
+ paginate(result)
22
25
  end
23
26
 
24
27
  private
25
28
 
29
+ def paginate(result)
30
+ return result if options[:per_page].blank?
31
+
32
+ Kaminari.paginate_array(result).page(options[:page]).per(options[:per_page])
33
+ end
34
+
35
+ # The serializer reads each vehicle type's photos and option types; loading them once here avoids a pair of
36
+ # queries per distinct vehicle type.
37
+ def preload_vehicle_type_assets(result)
38
+ vehicle_types = result.flat_map(&:trips).filter_map(&:vehicle_type).uniq
39
+ return if vehicle_types.empty?
40
+
41
+ ActiveRecord::Associations::Preloader.new(records: vehicle_types, associations: %i[vehicle_photos option_types]).call
42
+ end
43
+
26
44
  def direct_trips
27
45
  SpreeCmCommissioner::SingleLegTripsQuery.new(
28
46
  origin_id: origin_id,
@@ -0,0 +1,128 @@
1
+ module SpreeCmCommissioner
2
+ module ApiCaches
3
+ # Everything the clear-cache dialog shows, for the Spree admin (spree/admin/api_caches/show) and
4
+ # the cm-market-server dashboard (shared_console/api_caches/show), which style it separately. Only called when the
5
+ # dialog opens, never on page render. Takes the same model/id/tenant as RequestClear.
6
+ #
7
+ # eg. FetchDetails.call(model: 'Spree::Product', id: 123)
8
+ # => success(state: :edited, edited_at:, refresh_seconds:, sends_by:, sent_at:, cooldown_until:,
9
+ # failure:, busy:, history:, patterns:)
10
+ #
11
+ # state, the dialog's headline, leans towards waiting since every clear is a paid CloudFront
12
+ # invalidation:
13
+ # :queued a clear someone asked for is waiting to be sent.
14
+ # :pending an automatic clear (queued on save, including a linked record's save) is waiting
15
+ # for OrchestrateJob, which sends it by sends_by.
16
+ # :edited edited after the last sent clear, less than a CDN TTL ago: the app may still show
17
+ # the old version.
18
+ # :sent a clear was sent after the last edit, within the last CDN TTL.
19
+ # :up_to_date otherwise: any copy cached before the last edit has expired or been cleared.
20
+ #
21
+ # cooldown_until is a CDN TTL after the last sent clear: edits made since then show in the app on
22
+ # their own by that time anyway, so RequestClear refuses another clear before it for callers
23
+ # asked to apply it (dashboard users who aren't admins).
24
+ class FetchDetails
25
+ prepend ::Spree::ServiceModule::Base
26
+
27
+ HISTORY_LIMIT = 5
28
+
29
+ # Clear-alls of every model share their storefront as maintainable (CacheInvalidation), so more
30
+ # are read than shown and the rest filtered by model.
31
+ CLEAR_ALL_SCAN_LIMIT = HISTORY_LIMIT * 4
32
+
33
+ # An automatic task is due 5 minutes after it was queued (MaintenanceTask.due_for_retry) and
34
+ # picked up by OrchestrateJob, scheduled every 5 minutes in cm-market-server's schedule.yml.
35
+ AUTOMATIC_SEND_WITHIN = 10.minutes
36
+
37
+ def call(model:, id: nil, tenant: nil)
38
+ maintainable = MaintenanceTasks::CacheInvalidation.maintainable_for(model: model, id: id, tenant: tenant)
39
+ return failure(nil, "#{[model, id].compact.join(' ')} cannot be cleared") if maintainable.nil?
40
+
41
+ model_class = Invalidate::SUPPORTED_MODELS[model]
42
+ tasks = MaintenanceTasks::CacheInvalidation.where(maintainable: maintainable).order(created_at: :desc)
43
+
44
+ if id.present?
45
+ tasks = tasks.limit(HISTORY_LIMIT).to_a
46
+ patterns = Invalidate.new.patterns_for(maintainable, model_class: model_class)
47
+ edited_at = maintainable.updated_at
48
+ else
49
+ tasks = tasks.limit(CLEAR_ALL_SCAN_LIMIT).select { |task| task.target_model == model }.first(HISTORY_LIMIT)
50
+ patterns = InvalidateAll.new.patterns_for(model, tenant: tenant)
51
+ edited_at = InvalidateAll.new.records_scope(model_class, tenant).maximum(:updated_at)
52
+ end
53
+
54
+ success(**headline(tasks, edited_at), history: history(tasks), patterns: patterns)
55
+ end
56
+
57
+ private
58
+
59
+ def headline(tasks, edited_at)
60
+ latest = tasks.first
61
+ ttl = ContentCachable.cdn_max_age(:fresh)
62
+ sent = tasks.find { |task| task.clear_status == :sent }
63
+ pending = latest if automatic_pending?(latest)
64
+ cooldown_until = sent && (sent.completed_at + ttl)
65
+
66
+ {
67
+ state: state_for(latest, pending, edited_at, sent, ttl),
68
+ edited_at: edited_at,
69
+ refresh_seconds: ttl,
70
+ sends_by: pending && (pending.updated_at + AUTOMATIC_SEND_WITHIN),
71
+ sent_at: sent&.completed_at,
72
+ cooldown_until: (cooldown_until if cooldown_until&.future?),
73
+ failure: failure_of(latest),
74
+ busy: busy?(latest)
75
+ }
76
+ end
77
+
78
+ def state_for(latest, pending, edited_at, sent, ttl)
79
+ return :pending if pending
80
+ return :queued if latest&.clear_status == :queued
81
+
82
+ freshness_state(edited_at, sent, ttl)
83
+ end
84
+
85
+ def freshness_state(edited_at, sent, ttl)
86
+ # The task touched the record before sending, so an edit it covers has updated_at <= the
87
+ # task's completed_at.
88
+ covered = sent.present? && (edited_at.nil? || edited_at <= sent.completed_at)
89
+ return :edited if edited_at.present? && !covered && edited_at > ttl.seconds.ago
90
+ return :sent if covered && sent.completed_at > ttl.seconds.ago
91
+
92
+ :up_to_date
93
+ end
94
+
95
+ # A save queued it and nobody has clicked since. With automatic invalidation switched off it
96
+ # would only be skipped, so it promises nothing.
97
+ def automatic_pending?(task)
98
+ task&.clear_status == :queued && !task.manually_triggered? && ENV['CACHE_AUTO_INVALIDATION_DISABLED'] != 'yes'
99
+ end
100
+
101
+ # A clear someone asked for, which another click would only reuse. An automatic one waiting for
102
+ # OrchestrateJob, or a queued one that already failed once, is sent now by a click.
103
+ def busy?(latest)
104
+ latest&.clear_status == :queued && latest.manually_triggered? && latest.last_error.blank?
105
+ end
106
+
107
+ # Why the latest clear didn't (or hasn't yet) gone through.
108
+ def failure_of(task)
109
+ task.last_error.presence if %i[failed queued].include?(task&.clear_status)
110
+ end
111
+
112
+ def history(tasks)
113
+ users = Spree.user_class.where(id: tasks.filter_map(&:triggered_by_id).uniq).index_by(&:id)
114
+
115
+ tasks.map do |task|
116
+ { at: task.created_at, by: triggered_by_name(task, users), status: task.clear_status, error: failure_of(task) }
117
+ end
118
+ end
119
+
120
+ def triggered_by_name(task, users)
121
+ scope = 'spree_cm_commissioner.api_caches.details'
122
+ return I18n.t('automatic', scope: scope) unless task.manually_triggered?
123
+
124
+ users[task.triggered_by_id]&.display_name.presence || I18n.t('unknown_user', scope: scope)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -1,9 +1,17 @@
1
1
  module SpreeCmCommissioner
2
2
  module ApiCaches
3
- # Clears one record's cached API responses. Every record of a model goes through InvalidateAll.
3
+ # Refreshes one record's Rails cache keys and returns the CloudFront paths that serve it, which
4
+ # MaintenanceTasks::CacheInvalidation sends. Every record of a model goes through InvalidateAll.
5
+ #
6
+ # Only the record's own storefront is cleared, as InvalidateAll does: its tenant's API
7
+ # (/api/v2/tenant/ paths) when it belongs to a tenant, the BookMe+ storefront otherwise. Each path
8
+ # is billed, and the other storefront's copy, if anything ever fetched one, expires within a CDN
9
+ # TTL anyway.
4
10
  class Invalidate
5
11
  prepend ::Spree::ServiceModule::Base
6
12
 
13
+ TENANT_PREFIX = '/api/v2/tenant/'.freeze
14
+
7
15
  SUPPORTED_MODELS = {
8
16
  'SpreeCmCommissioner::HomepageSection' => SpreeCmCommissioner::HomepageSection,
9
17
  'SpreeCmCommissioner::HomepageBackground' => SpreeCmCommissioner::HomepageBackground,
@@ -27,11 +35,11 @@ module SpreeCmCommissioner
27
35
 
28
36
  HOMEPAGE_BACKGROUND_PATTERNS = ['/api/v2/storefront/homepage*'].freeze
29
37
 
30
- # Deliberately broad, and identical for single-record and all-record
31
- # invalidation: a payment method is either vendor-owned or store-level, and a
32
- # store-level one is served for every vendor through the fallback in
33
- # SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot
34
- # tell which vendors are affected.
38
+ # Deliberately broad, identical for single-record and all-record invalidation, and the one
39
+ # model not scoped to the record's storefront: a payment method is either vendor-owned or
40
+ # store-level, and a store-level one is served for every vendor, tenants included, through the
41
+ # fallback in SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot tell
42
+ # which vendors are affected.
35
43
  #
36
44
  # vendor_id is a query param, so the trailing `*` is what clears every
37
45
  # vendor's cached variant of this path in one go.
@@ -61,19 +69,33 @@ module SpreeCmCommissioner
61
69
 
62
70
  refresh_record_cache_key(record)
63
71
 
72
+ success(record: record, api_patterns: patterns_for(record, model_class: model_class))
73
+ end
74
+
75
+ # Side-effect free, so the clear-cache dialog can show what a clear would send. Empty when the
76
+ # record's storefront has no API for it, e.g. a show of a vendor without a tenant.
77
+ # eg. patterns_for(product) => ['/api/v2/storefront/products/123*', '/api/v2/storefront/products/slug-of-product*']
78
+ def patterns_for(record, model_class: record.class)
64
79
  # The record's own class first (a Show found as a Spree::Taxon keeps its show paths), then the
65
80
  # model asked for, since STI subclasses such as Spree::Gateway::Payway have no entry of their own.
66
81
  patterns = single_api_pattern[record.class.name] || single_api_pattern[model_class.name]
67
- api_patterns = patterns.map do |pattern|
82
+ return patterns if patterns.equal?(PAYMENT_METHOD_PATTERNS)
83
+
84
+ tenant = tenant_of(record)
85
+ patterns.select { |pattern| pattern.start_with?(TENANT_PREFIX) == tenant.present? }.map do |pattern|
68
86
  pattern.gsub(/:(\w+)/) do
69
87
  key = Regexp.last_match(1)
70
88
  record.public_send(key).to_s
71
89
  end
72
90
  end
91
+ end
73
92
 
74
- enqueue_invalidate(api_patterns)
93
+ # A product, vendor or homepage section has its own tenant; a taxon, show or voting session has
94
+ # its vendor's. Menus, seat layouts and homepage backgrounds are BookMe+ only.
95
+ def tenant_of(record)
96
+ return record.tenant if record.respond_to?(:tenant)
75
97
 
76
- success(record: record, api_patterns: api_patterns)
98
+ record.vendor&.tenant if record.respond_to?(:vendor)
77
99
  end
78
100
 
79
101
  def single_api_pattern
@@ -117,11 +139,6 @@ module SpreeCmCommissioner
117
139
  record.touch
118
140
  # rubocop:enable Rails/SkipsModelValidations
119
141
  end
120
-
121
- # Enqueue cache invalidation jobs
122
- def enqueue_invalidate(patterns)
123
- SpreeCmCommissioner::InvalidateCacheRequestJob.perform_later(patterns: patterns)
124
- end
125
142
  end
126
143
  end
127
144
  end
@@ -1,8 +1,10 @@
1
1
  module SpreeCmCommissioner
2
2
  module ApiCaches
3
- # Clears every record of a model for one storefront. `tenant` is required so a clear-all never
4
- # pays for both namespaces by accident: nil is the BookMe+ storefront (/api/v2/storefront/
5
- # paths), a SpreeCmCommissioner::Tenant is that tenant's API (/api/v2/tenant/ paths).
3
+ # Refreshes every record of a model for one storefront and returns the CloudFront paths that
4
+ # serve them, which MaintenanceTasks::CacheInvalidation sends, as for Invalidate. `tenant` is
5
+ # required so a clear-all never pays for both namespaces by accident: nil is the BookMe+
6
+ # storefront (/api/v2/storefront/ paths), a SpreeCmCommissioner::Tenant is that tenant's API
7
+ # (/api/v2/tenant/ paths).
6
8
  #
7
9
  # Tenant APIs take the tenant from the OAuth token, not the URL, so every tenant shares one path
8
10
  # today; taking the record keeps callers the same if tenant paths ever carry it.
@@ -11,8 +13,6 @@ module SpreeCmCommissioner
11
13
  class InvalidateAll
12
14
  prepend ::Spree::ServiceModule::Base
13
15
 
14
- TENANT_PREFIX = '/api/v2/tenant/'.freeze
15
-
16
16
  API_PATTERNS = {
17
17
  'SpreeCmCommissioner::HomepageSection' => Invalidate::HOMEPAGE_SECTION_PATTERNS,
18
18
  'SpreeCmCommissioner::HomepageBackground' => Invalidate::HOMEPAGE_BACKGROUND_PATTERNS,
@@ -36,25 +36,22 @@ module SpreeCmCommissioner
36
36
  return failure(nil, 'Model is not supported') if model_class.nil?
37
37
  return failure(nil, 'tenant must be a SpreeCmCommissioner::Tenant or nil') unless tenant.nil? || tenant.is_a?(SpreeCmCommissioner::Tenant)
38
38
 
39
- refresh_records_cache_key(model_class, tenant)
40
-
41
- api_patterns = API_PATTERNS[model].select { |pattern| pattern.start_with?(TENANT_PREFIX) == tenant.present? }
42
-
43
- # Empty when the model has no API in this namespace, e.g. homepage backgrounds for a tenant,
44
- # and CloudFront rejects an empty batch.
45
- SpreeCmCommissioner::InvalidateCacheRequestJob.perform_later(patterns: api_patterns) if api_patterns.any?
39
+ records_scope(model_class, tenant).find_each(&:touch)
46
40
 
47
- success(api_patterns: api_patterns)
41
+ # Empty when the model has no API in this namespace, e.g. homepage backgrounds for a tenant.
42
+ success(api_patterns: patterns_for(model, tenant: tenant))
48
43
  end
49
44
 
50
- private
45
+ # Side-effect free, so the clear-cache dialog can show what a clear would send.
46
+ def patterns_for(model, tenant:)
47
+ API_PATTERNS.fetch(model, []).select { |pattern| pattern.start_with?(Invalidate::TENANT_PREFIX) == tenant.present? }
48
+ end
51
49
 
52
50
  # updated_at keys Spree's serializer and collection caches, so only this storefront's records
53
- # are touched when the model is tenant-scoped.
54
- def refresh_records_cache_key(model_class, tenant)
55
- scope = model_class.column_names.include?('tenant_id') ? model_class.where(tenant_id: tenant&.id) : model_class.all
56
-
57
- scope.find_each(&:touch)
51
+ # are touched when the model is tenant-scoped. Also where the clear-cache dialog reads the last
52
+ # edit from.
53
+ def records_scope(model_class, tenant)
54
+ model_class.column_names.include?('tenant_id') ? model_class.where(tenant_id: tenant&.id) : model_class.all
58
55
  end
59
56
  end
60
57
  end
@@ -0,0 +1,71 @@
1
+ module SpreeCmCommissioner
2
+ module ApiCaches
3
+ # What a clear-cache button calls. It records the click as a CacheInvalidation task and enqueues
4
+ # it, so the click returns at once and shows in the record's history (FetchDetails).
5
+ #
6
+ # Without id it clears every record of model on one storefront, as InvalidateAll: tenant for that
7
+ # tenant's API, nil for the BookMe+ storefront.
8
+ #
9
+ # cooldown: refuses a clear within a CDN TTL of the last one sent (FetchDetails#cooldown_until),
10
+ # for callers that limit how often their users pay for one.
11
+ #
12
+ # eg. RequestClear.call(model: 'Spree::Product', id: 123, user: current_user)
13
+ # RequestClear.call(model: 'SpreeCmCommissioner::HomepageSection', tenant: tenant, user: current_user, cooldown: true)
14
+ class RequestClear
15
+ prepend ::Spree::ServiceModule::Base
16
+
17
+ def call(model:, user:, id: nil, tenant: nil, cooldown: false)
18
+ maintainable = MaintenanceTasks::CacheInvalidation.maintainable_for(model: model, id: id, tenant: tenant)
19
+ return failure(nil, "#{[model, id].compact.join(' ')} cannot be cleared") if maintainable.nil?
20
+
21
+ cooldown_until = cooldown && FetchDetails.call(model: model, id: id, tenant: tenant).value[:cooldown_until]
22
+ return failure(nil, I18n.t('spree_cm_commissioner.api_caches.cooldown', time: I18n.l(cooldown_until, format: '%H:%M'))) if cooldown_until
23
+
24
+ task = find_or_create_task(maintainable, model: model, user: user)
25
+ return failure(nil, I18n.t('spree_cm_commissioner.api_caches.another_clear_in_progress')) if task.target_model != model
26
+
27
+ task.async_execute
28
+ success(task: task)
29
+ end
30
+
31
+ private
32
+
33
+ # index_unique_pending_maintenance_tasks allows one pending task per maintainable, which is also
34
+ # what stops a double click from paying twice: the second click reuses the first one's task.
35
+ def find_or_create_task(maintainable, model:, user:)
36
+ task = pending_task(maintainable)
37
+ return reuse(task, model, user) if task.present?
38
+
39
+ MaintenanceTasks::CacheInvalidation.create!(
40
+ maintainable: maintainable,
41
+ manually_triggered: true,
42
+ triggered_by_id: user&.id,
43
+ invalidation_model: model
44
+ )
45
+ rescue ActiveRecord::RecordNotUnique
46
+ reuse(pending_task(maintainable), model, user)
47
+ end
48
+
49
+ def pending_task(maintainable)
50
+ MaintenanceTasks::CacheInvalidation.pending.find_by(maintainable: maintainable)
51
+ end
52
+
53
+ # A pending task is either an automatic one still waiting for OrchestrateJob, which the click
54
+ # sends now and takes credit for, or a clear-all of the same storefront. One for another model
55
+ # is left alone, and the caller reports it is busy.
56
+ def reuse(task, model, user)
57
+ return task if task.invalidate_all? && task.target_model != model
58
+
59
+ task.update!(
60
+ manually_triggered: true,
61
+ triggered_by_id: user&.id || task.triggered_by_id,
62
+ invalidation_model: model,
63
+ attempt_count: 0,
64
+ last_error: nil
65
+ )
66
+
67
+ task
68
+ end
69
+ end
70
+ end
71
+ end
@@ -44,8 +44,10 @@ module SpreeCmCommissioner
44
44
 
45
45
  # Telegram Alert
46
46
  def send_claim_alert(guest, order)
47
- vendor = guest.event&.vendor
48
- return unless vendor&.preferred_telegram_chat_id
47
+ chat_id = guest.event&.telegram_alert_chat_id || guest.event&.vendor&.preferred_telegram_chat_id
48
+ return if chat_id.blank?
49
+
50
+ vendor = guest.event.vendor
49
51
 
50
52
  factory = SpreeCmCommissioner::InviteGuestClaimedTelegramMessageFactory.new(
51
53
  title: '📣 --- [NEW GUEST CLAIMED INVITATION] ---',
@@ -55,7 +57,7 @@ module SpreeCmCommissioner
55
57
  )
56
58
 
57
59
  SpreeCmCommissioner::TelegramNotificationSenderJob.perform_later(
58
- chat_id: vendor.preferred_telegram_chat_id,
60
+ chat_id: chat_id,
59
61
  message: factory.message,
60
62
  parse_mode: factory.parse_mode
61
63
  )