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.
- checksums.yaml +4 -4
- data/.env.example +7 -1
- data/Gemfile.lock +1 -1
- data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +59 -3
- data/app/controllers/concerns/spree_cm_commissioner/waiting_room_authorization.rb +41 -8
- data/app/controllers/spree/admin/api_caches_controller.rb +23 -8
- data/app/controllers/spree/admin/homepage_section_controller.rb +63 -5
- 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/draft_orders_controller.rb +6 -2
- data/app/controllers/spree/api/v2/storefront/homepage_sections_controller.rb +1 -0
- data/app/controllers/spree/api/v2/storefront/invite_guests_controller.rb +2 -2
- data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +1 -0
- data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +6 -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/draft_orders_controller.rb +6 -2
- data/app/controllers/spree/api/v2/tenant/homepage_sections_controller.rb +1 -0
- data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +1 -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/active_storage/variation_decorator.rb +18 -0
- data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +4 -0
- data/app/models/concerns/spree_cm_commissioner/order_holdable.rb +3 -2
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +18 -13
- data/app/models/spree_cm_commissioner/homepage_section.rb +33 -0
- data/app/models/spree_cm_commissioner/homepage_section_app_background.rb +13 -0
- data/app/models/spree_cm_commissioner/homepage_section_web_background.rb +13 -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/product_decorator.rb +7 -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/overrides/spree/admin/taxons/_form/enforce_waiting_room_session.html.erb.deface +10 -0
- data/app/queries/spree_cm_commissioner/trip_query.rb +20 -2
- data/app/serializers/spree/v2/storefront/homepage_section_serializer.rb +4 -1
- data/app/serializers/spree/v2/tenant/homepage_section_serializer.rb +3 -1
- 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_service_calendars.rb +3 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +21 -4
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +30 -4
- data/app/services/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items.rb +15 -4
- data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +56 -5
- 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/_form.html.erb +44 -0
- data/app/views/spree/admin/homepage_section/edit.html.erb +16 -1
- data/app/views/spree/admin/shared/_api_cache_button.html.erb +5 -2
- data/config/initializers/spree_permitted_attributes.rb +1 -0
- data/config/locales/dry_validations.en.yml +4 -0
- data/config/locales/en.yml +50 -0
- data/config/locales/km.yml +50 -0
- data/config/routes.rb +8 -4
- data/db/migrate/20261021000001_add_section_type_to_cm_homepage_sections.rb +6 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +13 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
# (e.g., a countdown timer) and
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
@@ -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 %> %>
|