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.
- checksums.yaml +4 -4
- data/.env.example +1 -1
- data/Gemfile.lock +1 -1
- data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +30 -7
- data/app/controllers/spree/admin/api_caches_controller.rb +23 -8
- data/app/controllers/spree/admin/homepage_section_relatable_controller.rb +24 -0
- data/app/controllers/spree/api/v2/organizer/invite_guests_controller.rb +2 -2
- data/app/controllers/spree/api/v2/resource_controller_decorator.rb +8 -0
- data/app/controllers/spree/api/v2/storefront/invite_guests_controller.rb +2 -2
- data/app/controllers/spree/api/v2/storefront/trip_search_controller.rb +18 -13
- data/app/controllers/spree/api/v2/storefront/user_contacts_controller.rb +2 -0
- data/app/controllers/spree/api/v2/tenant/trip_search_controller.rb +18 -13
- data/app/factory/spree_cm_commissioner/order_telegram_message_factory.rb +5 -1
- data/app/helpers/spree_cm_commissioner/admin/homepage_segment_helper.rb +28 -0
- data/app/interactors/spree_cm_commissioner/account_recover.rb +23 -2
- data/app/interactors/spree_cm_commissioner/firebase_id_token_provider.rb +6 -2
- data/app/interactors/spree_cm_commissioner/telegram_chats_auto_finder.rb +26 -2
- data/app/interactors/spree_cm_commissioner/telegram_chats_finder.rb +1 -1
- data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +2 -11
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +1 -13
- data/app/models/spree_cm_commissioner/integration.rb +1 -1
- data/app/models/spree_cm_commissioner/integration_mapping.rb +27 -0
- data/app/models/spree_cm_commissioner/integrations/book_me_bus_v1.rb +1 -0
- data/app/models/spree_cm_commissioner/maintenance_tasks/cache_invalidation.rb +95 -11
- data/app/models/spree_cm_commissioner/pin_code.rb +2 -1
- data/app/models/spree_cm_commissioner/pin_code_event_telegram.rb +31 -0
- data/app/models/spree_cm_commissioner/service_calendar.rb +2 -2
- data/app/models/spree_cm_commissioner/taxon_decorator.rb +7 -0
- data/app/models/spree_cm_commissioner/vendor_decorator.rb +6 -0
- data/app/queries/spree_cm_commissioner/trip_query.rb +20 -2
- data/app/services/spree_cm_commissioner/api_caches/fetch_details.rb +128 -0
- data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +31 -14
- data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +16 -19
- data/app/services/spree_cm_commissioner/api_caches/request_clear.rb +71 -0
- data/app/services/spree_cm_commissioner/guests/claim_invite_guest_service.rb +5 -3
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_inventory.rb +33 -11
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_product.rb +3 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_service_calendars.rb +3 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +7 -3
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +73 -36
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_variant.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_vehicle_types.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +12 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +2 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/incremental_sync_strategy.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_product.rb +2 -1
- data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_product.rb +2 -1
- data/app/services/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items.rb +15 -4
- data/app/services/spree_cm_commissioner/telegram/verify_oauth_id_token.rb +10 -0
- data/app/services/spree_cm_commissioner/telegram_alerts/send_telegram_order_alerts.rb +49 -0
- data/app/views/spree/admin/api_caches/_dialog.html.erb +47 -0
- data/app/views/spree/admin/api_caches/show.html.erb +88 -0
- data/app/views/spree/admin/homepage_section/edit.html.erb +16 -1
- data/app/views/spree/admin/integrations/_book_me_bus_v1_fields.html.erb +10 -0
- data/app/views/spree/admin/integrations/_form.html.erb +1 -1
- data/app/views/spree/admin/shared/_api_cache_button.html.erb +5 -2
- data/config/locales/en.yml +39 -0
- data/config/locales/km.yml +39 -0
- data/config/routes.rb +3 -4
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +8 -3
- data/app/jobs/spree_cm_commissioner/invalidate_cache_request_job.rb +0 -10
|
@@ -1,30 +1,114 @@
|
|
|
1
|
-
#
|
|
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
|
-
|
|
7
|
-
|
|
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 =
|
|
29
|
+
result = refresh_records
|
|
30
|
+
return record_failure(result.error.to_s) if result.failure?
|
|
12
31
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
24
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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,
|
|
31
|
-
#
|
|
32
|
-
# store-level one is served for every vendor through the
|
|
33
|
-
# SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot
|
|
34
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
48
|
-
return
|
|
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:
|
|
60
|
+
chat_id: chat_id,
|
|
59
61
|
message: factory.message,
|
|
60
62
|
parse_mode: factory.parse_mode
|
|
61
63
|
)
|