spree_cm_commissioner 2.14.7 → 2.14.9
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/Gemfile.lock +1 -1
- data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +9 -1
- data/app/controllers/concerns/spree_cm_commissioner/guest_owner_authorization.rb +30 -0
- data/app/controllers/spree/admin/stock_managements_controller.rb +12 -1
- data/app/controllers/spree/api/v2/organizer/invites_controller.rb +5 -1
- data/app/controllers/spree/api/v2/storefront/guests_controller.rb +13 -7
- data/app/controllers/spree/api/v2/storefront/id_cards_controller.rb +6 -4
- data/app/controllers/spree/api/v2/storefront/products_controller_decorator.rb +1 -1
- data/app/controllers/spree/api/v2/storefront/s3_signed_urls_controller.rb +4 -3
- data/app/controllers/spree/api/v2/tenant/base_controller.rb +23 -3
- data/app/controllers/spree/api/v2/tenant/cart_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/id_cards_controller.rb +6 -4
- data/app/controllers/spree/api/v2/tenant/payment_methods_controller.rb +1 -2
- data/app/controllers/spree/api/v2/tenant/products_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/s3_signed_urls_controller.rb +4 -3
- data/app/interactors/spree_cm_commissioner/guest_id_card_manager.rb +18 -9
- data/app/interactors/spree_cm_commissioner/id_card_image_updater.rb +6 -3
- data/app/interactors/spree_cm_commissioner/image_saver.rb +4 -3
- data/app/interactors/spree_cm_commissioner/profile_image_updater.rb +6 -3
- data/app/jobs/concerns/spree_cm_commissioner/idempotent_job.rb +7 -6
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +4 -13
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb +2 -21
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +3 -29
- data/app/models/concerns/spree_cm_commissioner/firestore_connection.rb +7 -9
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +1 -9
- data/app/models/concerns/spree_cm_commissioner/sales_channel_bitwise.rb +44 -0
- data/app/models/concerns/spree_cm_commissioner/user_uploaded_image.rb +17 -0
- data/app/models/spree_cm_commissioner/back_image.rb +2 -0
- data/app/models/spree_cm_commissioner/front_image.rb +2 -0
- data/app/models/spree_cm_commissioner/inventory_hold.rb +5 -0
- data/app/models/spree_cm_commissioner/order_decorator.rb +11 -0
- data/app/models/spree_cm_commissioner/product_decorator.rb +4 -0
- data/app/models/spree_cm_commissioner/s3_presigned_url.rb +8 -7
- data/app/models/spree_cm_commissioner/s3_presigned_url_builder.rb +9 -3
- data/app/models/spree_cm_commissioner/tenant.rb +3 -0
- data/app/models/spree_cm_commissioner/user_profile.rb +2 -0
- data/app/overrides/spree/admin/products/_form/enable_confirmation_email.html.erb.deface +13 -0
- data/app/overrides/spree/admin/products/_form/sellable_on_platform.html.erb.deface +12 -0
- data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +4 -1
- data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +28 -17
- data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +5 -7
- data/app/services/spree_cm_commissioner/draft_orders/create.rb +6 -3
- data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +30 -4
- data/app/services/spree_cm_commissioner/inventory_holds/acquire.rb +28 -17
- data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +1 -0
- data/app/services/spree_cm_commissioner/inventory_holds/release.rb +1 -0
- data/app/services/spree_cm_commissioner/queue_draft_orders/publish_status.rb +8 -8
- data/app/services/spree_cm_commissioner/redis_stock/base.rb +7 -1
- data/app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb +1 -8
- data/app/services/spree_cm_commissioner/waiting_room_system_metadata_fetcher.rb +2 -8
- data/config/initializers/firestore.rb +19 -0
- data/config/initializers/spree_permitted_attributes.rb +4 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/km.yml +1 -0
- data/config/routes.rb +14 -6
- data/db/migrate/20261103000001_add_sales_channel_to_spree_products.rb +14 -0
- data/lib/spree_cm_commissioner/firestore_clients.rb +49 -0
- data/lib/spree_cm_commissioner/s3_url_generator.rb +13 -1
- data/lib/spree_cm_commissioner/tenant_slug_constraint.rb +23 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- data/lib/spree_cm_commissioner.rb +2 -0
- metadata +11 -9
- data/app/controllers/spree/api/v2/organizer/images_controller.rb +0 -56
- data/app/controllers/spree/api/v2/organizer/s3_signed_urls_controller.rb +0 -29
- data/app/controllers/spree/api/v2/organizer/ticket_images_controller.rb +0 -27
- data/app/controllers/spree/api/v2/organizer/tickets_controller.rb +0 -97
- data/app/serializers/spree/v2/organizer/image_serializer.rb +0 -11
- data/app/serializers/spree/v2/organizer/s3_signed_url_serializer.rb +0 -10
- data/app/serializers/spree/v2/organizer/ticket_serializer.rb +0 -9
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
require 'google/cloud/firestore'
|
|
2
2
|
|
|
3
3
|
module SpreeCmCommissioner
|
|
4
|
-
#
|
|
5
|
-
# WaitingRoom::
|
|
6
|
-
#
|
|
7
|
-
#
|
|
4
|
+
# Cloud Firestore access for services (see WaitingRoom::Admission::CallGuests,
|
|
5
|
+
# WaitingRoom::Positions::Stamp, WaitingRoom::Sessions::Issue) plus a #firestore_available?
|
|
6
|
+
# guard so environments without the credential (dev, test, CI) no-op instead of raising on
|
|
7
|
+
# connect. The client comes from FirestoreClients, shared per process and per project; a service
|
|
8
|
+
# for another Firebase project overrides #service_account.
|
|
8
9
|
module FirestoreConnection
|
|
9
10
|
extend ActiveSupport::Concern
|
|
10
11
|
|
|
@@ -13,14 +14,11 @@ module SpreeCmCommissioner
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def firestore
|
|
16
|
-
@firestore ||=
|
|
17
|
-
project_id: service_account[:project_id],
|
|
18
|
-
credentials: service_account
|
|
19
|
-
)
|
|
17
|
+
@firestore ||= SpreeCmCommissioner::FirestoreClients.fetch(service_account)
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def service_account
|
|
23
|
-
@service_account ||=
|
|
21
|
+
@service_account ||= SpreeCmCommissioner::FirestoreClients.default_service_account
|
|
24
22
|
end
|
|
25
23
|
end
|
|
26
24
|
end
|
|
@@ -87,7 +87,7 @@ module SpreeCmCommissioner
|
|
|
87
87
|
after_transition from: :requested, to: :accepted, do: :decrement_user_request_orders_count
|
|
88
88
|
after_transition to: :accepted, do: :send_order_accepted_app_notification_to_user
|
|
89
89
|
after_transition to: :accepted, do: :send_order_accepted_telegram_alert_to_store
|
|
90
|
-
after_transition to: :accepted,
|
|
90
|
+
after_transition to: :accepted, unless: :confirmation_delivered?, do: :deliver_order_confirmation_email
|
|
91
91
|
|
|
92
92
|
event :reject do
|
|
93
93
|
transition from: :requested, to: :rejected
|
|
@@ -144,14 +144,6 @@ module SpreeCmCommissioner
|
|
|
144
144
|
subscription? || prepaid_top_up_order?
|
|
145
145
|
end
|
|
146
146
|
|
|
147
|
-
# override
|
|
148
|
-
def deliver_order_confirmation_email
|
|
149
|
-
return if requested?
|
|
150
|
-
return if any_app_only_products?
|
|
151
|
-
|
|
152
|
-
super
|
|
153
|
-
end
|
|
154
|
-
|
|
155
147
|
def increment_user_request_orders_count
|
|
156
148
|
return if user.nil?
|
|
157
149
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module SalesChannelBitwise
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
# Append only: stored values are bit masks. Names follow the `channel_direct` /
|
|
6
|
+
# `channel_platform` channel types in docs/concepts/multi-channel-pricing.md.
|
|
7
|
+
BIT_SALES_CHANNEL = {
|
|
8
|
+
direct: 0b01, # the tenant's own app/web
|
|
9
|
+
platform: 0b10 # the BookMe+ marketplace
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
validates :sales_channel, numericality: { greater_than: 0 }
|
|
14
|
+
|
|
15
|
+
# Runs after ProductDecorator#set_tenant: a product without a tenant has no direct channel to sell on.
|
|
16
|
+
before_save -> { self.sales_channel = BIT_SALES_CHANNEL[:platform] if tenant_id.nil? }
|
|
17
|
+
|
|
18
|
+
scope :sellable_on, lambda { |channel|
|
|
19
|
+
where("#{table_name}.sales_channel & ? != 0", BIT_SALES_CHANNEL.fetch(channel))
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class_methods do
|
|
24
|
+
# The channel a customer order is placed on: tenant storefront APIs set a tenant, the platform ones don't.
|
|
25
|
+
def sales_channel_for(tenant)
|
|
26
|
+
tenant.present? ? :direct : :platform
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
BIT_SALES_CHANNEL.each do |channel, bit_value|
|
|
31
|
+
define_method "sellable_on_#{channel}?" do
|
|
32
|
+
sales_channel.to_i & bit_value != 0
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Checkbox reader/writer for the dashboard: a tenant product opts in/out of being sold on BookMe+.
|
|
37
|
+
alias sellable_on_platform sellable_on_platform?
|
|
38
|
+
|
|
39
|
+
def sellable_on_platform=(value)
|
|
40
|
+
bit = BIT_SALES_CHANNEL[:platform]
|
|
41
|
+
self.sales_channel = ActiveModel::Type::Boolean.new.cast(value) ? sales_channel.to_i | bit : sales_channel.to_i & ~bit
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# For images a customer uploads themselves (profile photo, ID card). Spree's own validation accepts
|
|
3
|
+
# any `image/*`, which lets formats like SVG reach libvips when variants are generated
|
|
4
|
+
# (CVE-2026-66066), so these are limited to the formats phone cameras and galleries produce.
|
|
5
|
+
# MAX_SIZE is also the `content_length_range` on the storefront/tenant presigned POST, so an
|
|
6
|
+
# oversized file is rejected by S3 before it is ever fetched back.
|
|
7
|
+
module UserUploadedImage
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
CONTENT_TYPES = %w[image/jpeg image/png image/webp image/heic image/heif].freeze
|
|
11
|
+
MAX_SIZE = 10.megabytes
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
validates :attachment, content_type: CONTENT_TYPES, size: { less_than_or_equal_to: MAX_SIZE }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -23,6 +23,11 @@ module SpreeCmCommissioner
|
|
|
23
23
|
|
|
24
24
|
store_public_metadata :total_quantity_on_hold, :integer
|
|
25
25
|
|
|
26
|
+
# The DB sync for this acquisition (see InventoryHolds::Acquire), kept so a re-entry can re-send
|
|
27
|
+
# it under the same ledger key when the original enqueue never happened.
|
|
28
|
+
store_private_metadata :on_hold_sync_idempotency_key, :string
|
|
29
|
+
store_private_metadata :on_hold_sync_payload, :array
|
|
30
|
+
|
|
26
31
|
def finalized?
|
|
27
32
|
converted? || released?
|
|
28
33
|
end
|
|
@@ -190,6 +190,17 @@ module SpreeCmCommissioner
|
|
|
190
190
|
super
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
+
# Must live here, not in OrderStateMachine: that concern is included (below Spree::Order),
|
|
194
|
+
# while Spree::Emails::OrderDecorator is prepended above it and never calls super, so an
|
|
195
|
+
# override there is silently dead.
|
|
196
|
+
def deliver_order_confirmation_email
|
|
197
|
+
return if requested?
|
|
198
|
+
return if any_app_only_products?
|
|
199
|
+
return if products.none?(&:enable_confirmation_email?)
|
|
200
|
+
|
|
201
|
+
super
|
|
202
|
+
end
|
|
203
|
+
|
|
193
204
|
def any_app_only_products?
|
|
194
205
|
if products.loaded?
|
|
195
206
|
products.any?(&:purchasable_on_app?)
|
|
@@ -143,6 +143,7 @@ module SpreeCmCommissioner
|
|
|
143
143
|
base.store_public_metadata :enable_inventory_hold, :boolean, default: false
|
|
144
144
|
base.store_public_metadata :enable_telegram_alert, :boolean, default: true
|
|
145
145
|
base.store_public_metadata :enable_send_receipt, :boolean, default: true
|
|
146
|
+
base.store_public_metadata :enable_confirmation_email, :boolean, default: true
|
|
146
147
|
base.store_public_metadata :enable_social_sharing_email, :boolean, default: false
|
|
147
148
|
|
|
148
149
|
base.store_private_metadata :advertise_weight, :integer, default: 1
|
|
@@ -169,6 +170,9 @@ module SpreeCmCommissioner
|
|
|
169
170
|
base.belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant'
|
|
170
171
|
base.before_save :set_tenant
|
|
171
172
|
|
|
173
|
+
# Included after set_tenant so its before_save sees the resolved tenant_id.
|
|
174
|
+
base.include SpreeCmCommissioner::SalesChannelBitwise
|
|
175
|
+
|
|
172
176
|
# Create maintaining task to purge product related caches
|
|
173
177
|
base.after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
|
|
174
178
|
|
|
@@ -11,25 +11,26 @@ module SpreeCmCommissioner
|
|
|
11
11
|
URI.parse(url).host
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# content_length_range: [Range, nil] byte range S3 enforces on the upload itself; nil means no limit.
|
|
15
|
+
def initialize(bucket_name, object_key, uuid = nil, content_length_range: nil)
|
|
15
16
|
@id = uuid.presence || generate_secure_random
|
|
16
17
|
|
|
17
18
|
if object_key.blank?
|
|
18
19
|
@error_message = I18n.t('s3_signed_url.missing_file_name')
|
|
19
20
|
else
|
|
20
|
-
@s3_direct_post = presigned_post(bucket_name, object_key)
|
|
21
|
+
@s3_direct_post = presigned_post(bucket_name, object_key, content_length_range)
|
|
21
22
|
end
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
private
|
|
25
26
|
|
|
26
|
-
def presigned_post(bucket_name, object_key)
|
|
27
|
+
def presigned_post(bucket_name, object_key, content_length_range)
|
|
28
|
+
options = { key: object_key, success_action_status: '201' }
|
|
29
|
+
options[:content_length_range] = content_length_range if content_length_range
|
|
30
|
+
|
|
27
31
|
Aws::S3::Resource.new
|
|
28
32
|
.bucket(bucket_name)
|
|
29
|
-
.presigned_post(
|
|
30
|
-
key: object_key,
|
|
31
|
-
success_action_status: '201'
|
|
32
|
-
)
|
|
33
|
+
.presigned_post(**options)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def generate_secure_random
|
|
@@ -6,13 +6,15 @@ module SpreeCmCommissioner
|
|
|
6
6
|
new(options).call
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
# {:bucket_name?, (:object_key | :file_name)? }
|
|
9
|
+
# {:bucket_name?, (:object_key | :file_name)?, :content_length_range? }
|
|
10
|
+
# bucket_name/object_key are for trusted server-side callers only (admin media uploads); API
|
|
11
|
+
# controllers must pass just file_name, so callers can only write under uploads/<uuid>/.
|
|
10
12
|
def initialize(options)
|
|
11
13
|
@options = options
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def call
|
|
15
|
-
SpreeCmCommissioner::S3PresignedUrl.new(bucket_name, object_key, uuid)
|
|
17
|
+
SpreeCmCommissioner::S3PresignedUrl.new(bucket_name, object_key, uuid, content_length_range: options[:content_length_range])
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
private
|
|
@@ -20,7 +22,11 @@ module SpreeCmCommissioner
|
|
|
20
22
|
def object_key
|
|
21
23
|
return options[:object_key] if options[:object_key].present?
|
|
22
24
|
|
|
23
|
-
"
|
|
25
|
+
# basename so a file_name like "../x" or "a/b" can't move the key out of its uuid folder.
|
|
26
|
+
file_name = File.basename(options[:file_name].to_s)
|
|
27
|
+
return nil if file_name.blank? || %w[. .. /].include?(file_name)
|
|
28
|
+
|
|
29
|
+
"uploads/#{uuid}/#{file_name}"
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def bucket_name
|
|
@@ -21,6 +21,9 @@ module SpreeCmCommissioner
|
|
|
21
21
|
allow_blank: true,
|
|
22
22
|
format: { without: %r{\Ahttps?://}, message: :format }
|
|
23
23
|
|
|
24
|
+
# The slug is the first segment of /api/v2/:tenant_slug/..., so it must not shadow a v2 namespace.
|
|
25
|
+
validates :slug, exclusion: { in: SpreeCmCommissioner::TenantSlugConstraint::RESERVED_SLUGS }
|
|
26
|
+
|
|
24
27
|
validates :sms_sender_id, length: { maximum: 11 }, allow_blank: true
|
|
25
28
|
|
|
26
29
|
validates :default_currency, inclusion: { in: -> (tenant) { tenant.supported_currencies_list } }, allow_blank: true
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
|
|
2
|
+
|
|
3
|
+
<div>
|
|
4
|
+
<%= f.field_container :enable_confirmation_email do %>
|
|
5
|
+
<%= f.label :enable_confirmation_email do %>
|
|
6
|
+
<%= f.check_box :enable_confirmation_email %>
|
|
7
|
+
<%= Spree.t(:enable_confirmation_email) %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<small class="form-text text-muted">
|
|
10
|
+
When enabled, customers receive the booking confirmation email after their order is completed.
|
|
11
|
+
</small>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
|
|
2
|
+
|
|
3
|
+
<%# BookMe+'s own products are always sold on the platform, so only tenant products get the choice. %>
|
|
4
|
+
<% if f.object.vendor&.tenant_id.present? %>
|
|
5
|
+
<div data-hook="admin_product_form_sellable_on_platform">
|
|
6
|
+
<%= f.field_container :sellable_on_platform do %>
|
|
7
|
+
<%= f.check_box :sellable_on_platform %>
|
|
8
|
+
<%= f.label :sellable_on_platform, 'Also sell on BookMe+' %>
|
|
9
|
+
<%= f.error_message_on :sales_channel %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
@@ -4,7 +4,10 @@ module Spree
|
|
|
4
4
|
module TaxonSerializerDecorator
|
|
5
5
|
def self.prepended(base)
|
|
6
6
|
base.has_many :vendors, serializer: ::Spree::V2::Storefront::VendorSerializer
|
|
7
|
-
|
|
7
|
+
# The app lists an event section's tickets from this relation, so products a tenant keeps off BookMe+ are dropped here.
|
|
8
|
+
base.has_many :visible_products, serializer: ::Spree::V2::Storefront::ProductSerializer do |taxon|
|
|
9
|
+
taxon.visible_products.sellable_on(:platform)
|
|
10
|
+
end
|
|
8
11
|
base.has_many :places, serializer: ::Spree::V2::Storefront::PlaceSerializer
|
|
9
12
|
|
|
10
13
|
base.has_one :vendor, serializer: ::Spree::V2::Storefront::VendorSerializer
|
|
@@ -4,13 +4,13 @@ module SpreeCmCommissioner
|
|
|
4
4
|
# MaintenanceTasks::CacheInvalidation sends. Every record of a model goes through InvalidateAll.
|
|
5
5
|
#
|
|
6
6
|
# Only the record's own storefront is cleared, as InvalidateAll does: its tenant's API
|
|
7
|
-
# (/api/v2
|
|
8
|
-
# is billed, and the other storefront's copy, if anything ever fetched one, expires
|
|
9
|
-
# TTL anyway.
|
|
7
|
+
# (/api/v2/<tenant slug>/ paths) when it belongs to a tenant, the BookMe+ storefront otherwise.
|
|
8
|
+
# Each path is billed, and the other storefront's copy, if anything ever fetched one, expires
|
|
9
|
+
# within a CDN TTL anyway. Legacy /api/v2/tenant/ paths are never CDN-cached, so never cleared.
|
|
10
10
|
class Invalidate
|
|
11
11
|
prepend ::Spree::ServiceModule::Base
|
|
12
12
|
|
|
13
|
-
TENANT_PREFIX = '/api/v2/
|
|
13
|
+
TENANT_PREFIX = '/api/v2/:tenant_slug/'.freeze
|
|
14
14
|
|
|
15
15
|
SUPPORTED_MODELS = {
|
|
16
16
|
'SpreeCmCommissioner::HomepageSection' => SpreeCmCommissioner::HomepageSection,
|
|
@@ -30,7 +30,7 @@ module SpreeCmCommissioner
|
|
|
30
30
|
# path per BIT_SEGMENT key.
|
|
31
31
|
HOMEPAGE_SECTION_PATTERNS = [
|
|
32
32
|
'/api/v2/storefront/homepage*',
|
|
33
|
-
'/api/v2/
|
|
33
|
+
'/api/v2/:tenant_slug/homepage*'
|
|
34
34
|
].freeze
|
|
35
35
|
|
|
36
36
|
HOMEPAGE_BACKGROUND_PATTERNS = ['/api/v2/storefront/homepage*'].freeze
|
|
@@ -42,12 +42,13 @@ module SpreeCmCommissioner
|
|
|
42
42
|
# which vendors are affected.
|
|
43
43
|
#
|
|
44
44
|
# vendor_id is a query param, so the trailing `*` is what clears every
|
|
45
|
-
# vendor's cached variant of this path in one go.
|
|
45
|
+
# vendor's cached variant of this path in one go. CloudFront allows `*` only at the end, so the
|
|
46
|
+
# tenant path expands to one path per tenant.
|
|
46
47
|
#
|
|
47
48
|
# Payment methods change a handful of times a year, so a broad invalidation
|
|
48
49
|
# is cheap.
|
|
49
50
|
PAYMENT_METHOD_PATTERNS = [
|
|
50
|
-
'/api/v2/
|
|
51
|
+
'/api/v2/:tenant_slug/payment_methods*',
|
|
51
52
|
'/api/v2/storefront/payment_methods*'
|
|
52
53
|
].freeze
|
|
53
54
|
|
|
@@ -60,7 +61,7 @@ module SpreeCmCommissioner
|
|
|
60
61
|
invalidate_single_record(model_class: model_class, id: id)
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
# eg. 'Spree::Product', 123 => ['/api/v2/
|
|
64
|
+
# eg. 'Spree::Product', 123 => ['/api/v2/hangmeas/products/123*', '/api/v2/hangmeas/products/slug-of-product*']
|
|
64
65
|
def invalidate_single_record(model_class:, id:)
|
|
65
66
|
return failure(nil, "#{model_class.name} ID is required") if id.blank?
|
|
66
67
|
|
|
@@ -79,17 +80,27 @@ module SpreeCmCommissioner
|
|
|
79
80
|
# The record's own class first (a Show found as a Spree::Taxon keeps its show paths), then the
|
|
80
81
|
# model asked for, since STI subclasses such as Spree::Gateway::Payway have no entry of their own.
|
|
81
82
|
patterns = single_api_pattern[record.class.name] || single_api_pattern[model_class.name]
|
|
82
|
-
return patterns if patterns.equal?(PAYMENT_METHOD_PATTERNS)
|
|
83
|
+
return expand_tenant_slug(patterns, SpreeCmCommissioner::Tenant.pluck(:slug)) if patterns.equal?(PAYMENT_METHOD_PATTERNS)
|
|
83
84
|
|
|
84
85
|
tenant = tenant_of(record)
|
|
85
86
|
patterns.select { |pattern| pattern.start_with?(TENANT_PREFIX) == tenant.present? }.map do |pattern|
|
|
86
87
|
pattern.gsub(/:(\w+)/) do
|
|
87
88
|
key = Regexp.last_match(1)
|
|
88
|
-
record.public_send(key).to_s
|
|
89
|
+
key == 'tenant_slug' ? tenant.slug : record.public_send(key).to_s
|
|
89
90
|
end
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
|
|
94
|
+
# One copy of each `:tenant_slug` pattern per tenant; storefront patterns pass through as is.
|
|
95
|
+
def expand_tenant_slug(patterns, slugs)
|
|
96
|
+
slugs = slugs.compact_blank
|
|
97
|
+
patterns.flat_map do |pattern|
|
|
98
|
+
next [pattern] unless pattern.start_with?(TENANT_PREFIX)
|
|
99
|
+
|
|
100
|
+
slugs.map { |slug| pattern.sub(':tenant_slug', slug) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
93
104
|
# A product, vendor or homepage section has its own tenant; a taxon, show or voting session has
|
|
94
105
|
# its vendor's. Menus, seat layouts and homepage backgrounds are BookMe+ only.
|
|
95
106
|
def tenant_of(record)
|
|
@@ -106,23 +117,23 @@ module SpreeCmCommissioner
|
|
|
106
117
|
# The app reads menus from homepage_data; nothing calls /menus any more.
|
|
107
118
|
'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
|
|
108
119
|
'Spree::Taxon' => [
|
|
109
|
-
'/api/v2/
|
|
110
|
-
'/api/v2/
|
|
120
|
+
'/api/v2/:tenant_slug/taxons/:id*',
|
|
121
|
+
'/api/v2/:tenant_slug/taxons/:permalink*',
|
|
111
122
|
'/api/v2/storefront/taxons/:id*',
|
|
112
123
|
'/api/v2/storefront/taxons/:permalink*'
|
|
113
124
|
],
|
|
114
125
|
'Spree::Product' => [
|
|
115
|
-
'/api/v2/
|
|
116
|
-
'/api/v2/
|
|
126
|
+
'/api/v2/:tenant_slug/products/:id*',
|
|
127
|
+
'/api/v2/:tenant_slug/products/:slug*',
|
|
117
128
|
'/api/v2/storefront/products/:id*',
|
|
118
129
|
'/api/v2/storefront/products/:slug*'
|
|
119
130
|
],
|
|
120
|
-
'SpreeCmCommissioner::Show' => ['/api/v2/
|
|
131
|
+
'SpreeCmCommissioner::Show' => ['/api/v2/:tenant_slug/shows/:slug*'],
|
|
121
132
|
'SpreeCmCommissioner::VotingSession' => [
|
|
122
|
-
'/api/v2/
|
|
133
|
+
'/api/v2/:tenant_slug/voting_sessions/:id/voting_contestants*'
|
|
123
134
|
],
|
|
124
135
|
'Spree::Vendor' => [
|
|
125
|
-
'/api/v2/
|
|
136
|
+
'/api/v2/:tenant_slug/vendors/:id*',
|
|
126
137
|
'/api/v2/storefront/vendors/:id*',
|
|
127
138
|
'/api/v2/storefront/accommodations/:id*',
|
|
128
139
|
'/api/v2/storefront/accommodations/:slug*'
|
|
@@ -4,10 +4,7 @@ module SpreeCmCommissioner
|
|
|
4
4
|
# serve them, which MaintenanceTasks::CacheInvalidation sends, as for Invalidate. `tenant` is
|
|
5
5
|
# required so a clear-all never pays for both namespaces by accident: nil is the BookMe+
|
|
6
6
|
# storefront (/api/v2/storefront/ paths), a SpreeCmCommissioner::Tenant is that tenant's API
|
|
7
|
-
# (/api/v2
|
|
8
|
-
#
|
|
9
|
-
# Tenant APIs take the tenant from the OAuth token, not the URL, so every tenant shares one path
|
|
10
|
-
# today; taking the record keeps callers the same if tenant paths ever carry it.
|
|
7
|
+
# (/api/v2/<tenant slug>/ paths).
|
|
11
8
|
#
|
|
12
9
|
# eg. InvalidateAll.call(model: 'SpreeCmCommissioner::HomepageSection', tenant: nil)
|
|
13
10
|
class InvalidateAll
|
|
@@ -20,11 +17,11 @@ module SpreeCmCommissioner
|
|
|
20
17
|
'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
|
|
21
18
|
'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
|
|
22
19
|
'Spree::Product' => [
|
|
23
|
-
'/api/v2/
|
|
20
|
+
'/api/v2/:tenant_slug/products/*',
|
|
24
21
|
'/api/v2/storefront/products/*'
|
|
25
22
|
],
|
|
26
23
|
'Spree::Vendor' => [
|
|
27
|
-
'/api/v2/
|
|
24
|
+
'/api/v2/:tenant_slug/vendors/*',
|
|
28
25
|
'/api/v2/storefront/vendors/*',
|
|
29
26
|
'/api/v2/storefront/accommodations/*'
|
|
30
27
|
],
|
|
@@ -44,7 +41,8 @@ module SpreeCmCommissioner
|
|
|
44
41
|
|
|
45
42
|
# Side-effect free, so the clear-cache dialog can show what a clear would send.
|
|
46
43
|
def patterns_for(model, tenant:)
|
|
47
|
-
API_PATTERNS.fetch(model, []).select { |pattern| pattern.start_with?(Invalidate::TENANT_PREFIX) == tenant.present? }
|
|
44
|
+
patterns = API_PATTERNS.fetch(model, []).select { |pattern| pattern.start_with?(Invalidate::TENANT_PREFIX) == tenant.present? }
|
|
45
|
+
tenant ? Invalidate.new.expand_tenant_slug(patterns, [tenant.slug]) : patterns
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
# updated_at keys Spree's serializer and collection caches, so only this storefront's records
|
|
@@ -72,7 +72,8 @@ module SpreeCmCommissioner
|
|
|
72
72
|
device_id: options[:device_id],
|
|
73
73
|
last_ip_address: options[:last_ip_address]
|
|
74
74
|
)
|
|
75
|
-
|
|
75
|
+
sellable_variants = store.variants.joins(:product).merge(Spree::Product.sellable_on(Spree::Product.sales_channel_for(tenant)))
|
|
76
|
+
line_items_params.each { |line_item_params| build_line_item!(order, sellable_variants, line_item_params) }
|
|
76
77
|
|
|
77
78
|
ActiveRecord::Base.transaction do
|
|
78
79
|
raise StandardError, order_error_message(order) unless order.save
|
|
@@ -105,8 +106,10 @@ module SpreeCmCommissioner
|
|
|
105
106
|
|
|
106
107
|
# Builds (unsaved) an order.line_items member — persisted together with the order via
|
|
107
108
|
# order.save's cascading autosave of new has_many records, same as TransitOrder::Create.
|
|
108
|
-
def build_line_item!(order,
|
|
109
|
-
variant =
|
|
109
|
+
def build_line_item!(order, sellable_variants, line_item_params)
|
|
110
|
+
variant = sellable_variants.find_by(id: line_item_params[:variant_id])
|
|
111
|
+
raise StandardError, I18n.t('spree_cm_commissioner.draft_order.variant_not_available') if variant.nil?
|
|
112
|
+
|
|
110
113
|
options = (line_item_params[:options] || {}).to_h.with_indifferent_access
|
|
111
114
|
permitted_opts = ::Spree::PermittedAttributes.line_item_attributes.flatten.index_with { |attribute| options[attribute] }
|
|
112
115
|
opts = permitted_opts.merge(currency: order.currency).compact
|
|
@@ -35,10 +35,10 @@ module SpreeCmCommissioner
|
|
|
35
35
|
return success(:skipped) unless firestore_available?
|
|
36
36
|
|
|
37
37
|
products = active_products(taxon).to_a
|
|
38
|
-
return success(:skipped) if products.empty?
|
|
39
|
-
|
|
40
|
-
sums = aggregate_quantities(products)
|
|
41
38
|
existing_products = existing_products_data(taxon)
|
|
39
|
+
return success(:skipped) if products.empty? && existing_products.empty?
|
|
40
|
+
|
|
41
|
+
sums = products.empty? ? {} : aggregate_quantities(products)
|
|
42
42
|
now = Time.zone.now
|
|
43
43
|
|
|
44
44
|
changed = {}
|
|
@@ -51,6 +51,8 @@ module SpreeCmCommissioner
|
|
|
51
51
|
changed[product.id.to_s] = build_entry(product, status, quantities, existing_products, now)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
prune_stale_entries(changed, products, existing_products)
|
|
55
|
+
|
|
54
56
|
return success(:unchanged) if changed.empty?
|
|
55
57
|
|
|
56
58
|
event_document(taxon).set({ products: changed, server_time: now }, merge: true)
|
|
@@ -66,6 +68,20 @@ module SpreeCmCommissioner
|
|
|
66
68
|
|
|
67
69
|
private
|
|
68
70
|
|
|
71
|
+
# The write below is always `merge: true`, which only ever adds or updates map entries — it
|
|
72
|
+
# never drops one. So a product that stops being active (unlisted/unlinked from its section,
|
|
73
|
+
# discontinued, status flipped off — see `active_products`) would otherwise keep its last
|
|
74
|
+
# stock status in Firestore forever, and the app would go on rendering the removed product.
|
|
75
|
+
# Deleting the `products.<id>` field (nested FieldValue.delete, carried in the same merge set)
|
|
76
|
+
# is what actually removes it. existing_products is read even when no product is active, so an
|
|
77
|
+
# event whose every ticket got unlisted still gets its stale entries cleared.
|
|
78
|
+
def prune_stale_entries(changed, products, existing_products)
|
|
79
|
+
active_ids = products.map { |product| product.id.to_s }
|
|
80
|
+
(existing_products.keys - active_ids).each do |stale_id|
|
|
81
|
+
changed[stale_id] = firestore.field_delete
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
69
85
|
# `quantities` is the [available, on_hold, locked, locked_on_hold] tuple from
|
|
70
86
|
# `aggregate_quantities`. `previous_status` is read back from what's already stored, not
|
|
71
87
|
# recomputed — it's the bucket this product was in on the last publish, if any.
|
|
@@ -117,6 +133,16 @@ module SpreeCmCommissioner
|
|
|
117
133
|
# true. Caught via a real event whose products all had available_on: nil: the poll job was
|
|
118
134
|
# returning :skipped for it on every cycle, so Firestore just sat frozen with stale data.
|
|
119
135
|
#
|
|
136
|
+
# `preview: false` for the same reason unlisted products are excluded: events/{event_id} is
|
|
137
|
+
# publicly readable (no auth on the Firestore rule), so a preview product — active but held
|
|
138
|
+
# back from the public pending a PreviewRole, product_decorator.rb `publicly_available` — must
|
|
139
|
+
# not leak a live stock-status entry there before launch.
|
|
140
|
+
#
|
|
141
|
+
# `offline: false` because an offline product is deliberately not inventory-monitored (the
|
|
142
|
+
# admin form labels it "not monitored in Inventory Monitor", and InventoryItems finders like
|
|
143
|
+
# RecentlyChangedFinder already filter it out) — its Redis pools aren't maintained, so any
|
|
144
|
+
# stock status published for it would be meaningless.
|
|
145
|
+
#
|
|
120
146
|
# `where(status: 'active')` instead of core's `Product.active` scope: that scope also joins
|
|
121
147
|
# variants/prices in to filter by currency (`with_currency`), and `spree_variants` carries its
|
|
122
148
|
# own `available_on`/`discontinue_on` columns — once that join is present, the raw,
|
|
@@ -133,7 +159,7 @@ module SpreeCmCommissioner
|
|
|
133
159
|
# is excluded here too rather than still getting a live stock-status entry in Firestore.
|
|
134
160
|
def active_products(taxon)
|
|
135
161
|
now = Time.zone.now
|
|
136
|
-
taxon.event_products.ecommerce.where(status: 'active')
|
|
162
|
+
taxon.event_products.ecommerce.where(status: 'active', preview: false, offline: false)
|
|
137
163
|
.where('available_on IS NULL OR available_on <= ?', now)
|
|
138
164
|
.where('discontinue_on IS NULL OR discontinue_on > ?', now)
|
|
139
165
|
.where(id: taxon.children_classifications.where(visible: true).select(:product_id))
|
|
@@ -33,7 +33,10 @@ module SpreeCmCommissioner
|
|
|
33
33
|
hold = acquire_hold_for_order!(order, line_items, expires_at)
|
|
34
34
|
|
|
35
35
|
enqueue_release_job(hold)
|
|
36
|
-
|
|
36
|
+
# Re-sent on every call, including re-entry with an existing hold: if this enqueue failed
|
|
37
|
+
# after the hold committed, the retry recovers it. The key stored with the acquisition makes
|
|
38
|
+
# a repeat a no-op in the ledger once the sync has applied.
|
|
39
|
+
enqueue_sync_inventory_on_hold(hold)
|
|
37
40
|
|
|
38
41
|
CmAppLogger.log(
|
|
39
42
|
label: "#{self.class.name}#call success",
|
|
@@ -88,7 +91,11 @@ module SpreeCmCommissioner
|
|
|
88
91
|
payment_locked_at: nil,
|
|
89
92
|
release_reason: nil,
|
|
90
93
|
total_quantity_on_hold: line_items.sum(&:quantity),
|
|
91
|
-
ip_address: order.last_ip_address
|
|
94
|
+
ip_address: order.last_ip_address,
|
|
95
|
+
# Saved in the same transaction as the Redis reservation, so the DB sync for exactly
|
|
96
|
+
# this acquisition can be re-sent later without inventing a new key or payload.
|
|
97
|
+
on_hold_sync_idempotency_key: SecureRandom.uuid,
|
|
98
|
+
on_hold_sync_payload: on_hold_sync_payload(line_items)
|
|
92
99
|
)
|
|
93
100
|
|
|
94
101
|
hold.save!
|
|
@@ -107,6 +114,15 @@ module SpreeCmCommissioner
|
|
|
107
114
|
end
|
|
108
115
|
end
|
|
109
116
|
|
|
117
|
+
# Same line items and active inventory items that acquire_redis_stock! reserves.
|
|
118
|
+
def on_hold_sync_payload(line_items)
|
|
119
|
+
line_items.flat_map do |line_item|
|
|
120
|
+
line_item.inventory_items.active.map do |inventory_item|
|
|
121
|
+
{ inventory_id: inventory_item.id, quantity: line_item.quantity, locked: line_item.locked_stock? }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
110
126
|
def acquire_redis_stock!(line_items)
|
|
111
127
|
stock_keys = []
|
|
112
128
|
hold_keys = []
|
|
@@ -146,25 +162,20 @@ module SpreeCmCommissioner
|
|
|
146
162
|
SpreeCmCommissioner::InventoryHolds::ReleaseJob.set(wait_until: hold.expires_at).perform_later(hold_id: hold.id)
|
|
147
163
|
end
|
|
148
164
|
|
|
149
|
-
def enqueue_sync_inventory_on_hold(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
line_item.inventory_items.active.map do |inventory_item|
|
|
154
|
-
{
|
|
155
|
-
inventory_id: inventory_item.id,
|
|
156
|
-
quantity: line_item.quantity,
|
|
157
|
-
locked: line_item.locked_stock?
|
|
158
|
-
}
|
|
159
|
-
end
|
|
160
|
-
end
|
|
165
|
+
def enqueue_sync_inventory_on_hold(hold)
|
|
166
|
+
key = hold.on_hold_sync_idempotency_key
|
|
167
|
+
payload = hold.on_hold_sync_payload
|
|
161
168
|
|
|
162
|
-
|
|
169
|
+
# Holds acquired before the sync event was stored carry neither; their sync was sent when they
|
|
170
|
+
# were acquired.
|
|
171
|
+
return if key.blank? || payload.blank?
|
|
163
172
|
|
|
164
173
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHoldJob.perform_later(
|
|
165
|
-
order_id:
|
|
174
|
+
order_id: hold.order_id,
|
|
175
|
+
idempotency_key: key,
|
|
166
176
|
caller_source: "#{self.class.name}#enqueue_sync_inventory_on_hold",
|
|
167
|
-
|
|
177
|
+
# Read back from jsonb with string keys; BulkAdjustQuantitiesOnHold reads symbol keys.
|
|
178
|
+
inventory_id_and_quantities: payload.map(&:symbolize_keys)
|
|
168
179
|
)
|
|
169
180
|
end
|
|
170
181
|
|
|
@@ -123,6 +123,7 @@ module SpreeCmCommissioner
|
|
|
123
123
|
|
|
124
124
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvertJob.perform_later(
|
|
125
125
|
order_id: order.id,
|
|
126
|
+
idempotency_key: SecureRandom.uuid,
|
|
126
127
|
caller_source: "#{self.class.name}#enqueue_sync_inventory",
|
|
127
128
|
inventory_id_and_quantities: adjustments
|
|
128
129
|
)
|